diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f811f6a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Disable autocrlf on generated files, they always generate with LF +# Add any extra files or paths here to make git stop saying they +# are changed when only line endings change. +src/generated/**/.cache/cache text eol=lf +src/generated/**/*.json text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3b98f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Eclipse +bin/ +*.launch +.settings/ +.metadata/ +.classpath +.project + +# IntelliJ IDEA +out/ +*.ipr +*.iws +*.iml +.idea/ + +# Gradle +build/ +.gradle/ + +# Other +eclipse/ +run/ +run-client*/ +run-data/ +node_modules/ + +# Forge MDK +forge*changelog.txt + +# Dev tools (local config) +.claude/ +.superpowers/ +.mcp.json +.codebase-index-cache.json + +# Node (not used) +package.json +package-lock.json +.prettierrc.yaml + +# Build logs +build_output.log + +# OS files +.DS_Store +Thumbs.db +desktop.ini diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6b4155e --- /dev/null +++ b/LICENSE @@ -0,0 +1,139 @@ +# TiedUp! Remake - License + +**Effective Date:** January 2025 +**Applies to:** All versions of TiedUp! Remake (past, present, and future) + +--- + +## Summary + +This software is licensed under **GPL-3.0 with Commons Clause** and additional restrictions. + +**You CAN:** +- Use the mod for free +- Modify the source code +- Distribute the mod (with source code) +- Create derivative works (must be open source under the same license) + +**You CANNOT:** +- Sell this software +- Put this software behind a paywall, subscription, or any form of monetization +- Distribute without providing source code +- Use a more restrictive license for derivative works + +--- + +## Full License Terms + +### Part 1: Commons Clause Restriction + +"Commons Clause" License Condition v1.0 + +The Software is provided to you by the Licensor under the License, as defined +below, subject to the following condition. + +Without limiting other conditions in the License, the grant of rights under the +License will not include, and the License does not grant to you, the right to +Sell the Software. + +For purposes of the foregoing, "Sell" means practicing any or all of the rights +granted to you under the License to provide to third parties, for a fee or other +consideration (including without limitation fees for hosting or consulting/ +support services related to the Software), a product or service whose value +derives, entirely or substantially, from the functionality of the Software. + +**Additional Monetization Restrictions:** + +The following are explicitly prohibited: +1. Selling the Software or any derivative work +2. Requiring payment, subscription, or donation to access or download the Software +3. Placing the Software behind a paywall of any kind (Patreon, Ko-fi, etc.) +4. Bundling the Software with paid products or services +5. Using the Software as an incentive for paid memberships or subscriptions +6. Early access monetization (charging for early access to updates) + +**Permitted:** +- Accepting voluntary donations (as long as the Software remains freely accessible) +- Using the Software on monetized content platforms (YouTube, Twitch, etc.) + +--- + +### Part 2: GNU General Public License v3.0 + +Copyright (C) 2024-2025 TiedUp! Remake Contributors + +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 . + +The full text of GPL-3.0 is available at: +https://www.gnu.org/licenses/gpl-3.0.txt + +--- + +### Part 3: Asset Exclusions + +The following assets are NOT covered by this license and remain property of +their respective owners: + +1. **Original kdnp mod Assets** (textures, models, sounds from the 1.12.2 version) + - Original creators: Yuti & Marl Velius + - These assets are used under fair use for preservation/educational purposes + - Contact original authors for commercial use + +2. **Minecraft Assets** + - All Minecraft textures, sounds, and code belong to Mojang Studios/Microsoft + - Subject to Minecraft EULA: https://www.minecraft.net/en-us/eula + +3. **Third-Party Libraries** + - PlayerAnimator: Subject to its own license (dev.kosmx.player-anim) + - Forge: Subject to Forge license (MinecraftForge) + - Other dependencies: Subject to their respective licenses + +**Code written for this remake** (files in `src/main/java/com/tiedup/remake/`) +is fully covered by this GPL-3.0 + Commons Clause license. + +--- + +### Part 4: Derivative Works + +Any derivative work based on this Software MUST: + +1. Be distributed under this same license (GPL-3.0 + Commons Clause) +2. Provide complete source code +3. Maintain all copyright notices +4. Not be sold or monetized in any way +5. Credit the original TiedUp! Remake project + +--- + +### Part 5: Disclaimer + +THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +## SPDX Identifier + +``` +SPDX-License-Identifier: GPL-3.0-only WITH Commons-Clause-1.0 +``` + +## Contact + +For licensing questions or permission requests, open an issue on the project repository. diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..249debe --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,523 @@ +Unless noted below, Minecraft Forge, Forge Mod Loader, and all +parts herein are licensed under the terms of the LGPL 2.1 found +here http://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt and +copied below. + +Homepage: http://minecraftforge.net/ + https://github.com/MinecraftForge/MinecraftForge + + +A note on authorship: +All source artifacts are property of their original author, with +the exclusion of the contents of the patches directory and others +copied from it from time to time. Authorship of the contents of +the patches directory is retained by the Minecraft Forge project. +This is because the patches are partially machine generated +artifacts, and are changed heavily due to the way forge works. +Individual attribution within them is impossible. + +Consent: +All contributions to Forge must consent to the release of any +patch content to the Forge project. + +A note on infectivity: +The LGPL is chosen specifically so that projects may depend on Forge +features without being infected with its license. That is the +purpose of the LGPL. Mods and others using this code via ordinary +Java mechanics for referencing libraries are specifically not bound +by Forge's license for the Mod code. + + +=== MCP Data === +This software includes data from the Minecraft Coder Pack (MCP), with kind permission +from them. The license to MCP data is not transitive - distribution of this data by +third parties requires independent licensing from the MCP team. This data is not +redistributable without permission from the MCP team. + +=== Sharing === +I grant permission for some parts of FML to be redistributed outside the terms of the LGPL, for the benefit of +the minecraft modding community. All contributions to these parts should be licensed under the same additional grant. + +-- Runtime patcher -- +License is granted to redistribute the runtime patcher code (src/main/java/net/minecraftforge/fml/common/patcher +and subdirectories) under any alternative open source license as classified by the OSI (http://opensource.org/licenses) + +-- ASM transformers -- +License is granted to redistribute the ASM transformer code (src/main/java/net/minecraftforge/common/asm/ and subdirectories) +under any alternative open source license as classified by the OSI (http://opensource.org/licenses) + +========================================================================= +This software includes portions from the Apache Maven project at +http://maven.apache.org/ specifically the ComparableVersion.java code. It is +included based on guidelines at +http://www.softwarefreedom.org/resources/2007/gpl-non-gpl-collaboration.html +with notices intact. The only change is a non-functional change of package name. + +This software contains a partial repackaging of javaxdelta, a BSD licensed program for generating +binary differences and applying them, sourced from the subversion at http://sourceforge.net/projects/javaxdelta/ +authored by genman, heikok, pivot. +The only changes are to replace some Trove collection types with standard Java collections, and repackaged. + +This software includes the Monocraft font from https://github.com/IdreesInc/Monocraft/ for use in the early loading +display. +========================================================================= + + + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +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 this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +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 +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser 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 Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "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 +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY 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 +LIBRARY (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 LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9f42f4d --- /dev/null +++ b/Makefile @@ -0,0 +1,171 @@ +# TiedUp! 1.20.1 - Makefile +# Simplifies build commands using Java 17 + +# Java 17 is required for Forge 1.20.1 +JAVA_HOME := /usr/lib/jvm/java-17-openjdk +export JAVA_HOME + +# Gradle wrapper +GRADLE := ./gradlew + +# Mod info (keep in sync with gradle.properties) +MOD_NAME := TiedUp +MOD_VERSION := 0.5.6-ALPHA +JAR_FILE := build/libs/tiedup-$(MOD_VERSION).jar + +# Colors for output +COLOR_RESET := \033[0m +COLOR_GREEN := \033[32m +COLOR_YELLOW := \033[33m +COLOR_BLUE := \033[34m + +# Default target +.DEFAULT_GOAL := help + +##@ General + +.PHONY: help +help: ## Display this help message + @echo "" + @echo "$(COLOR_BLUE)TiedUp! 1.20.1 - Makefile Commands$(COLOR_RESET)" + @echo "" + @awk 'BEGIN {FS = ":.*##"; printf "Usage:\n make $(COLOR_YELLOW)$(COLOR_RESET)\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " $(COLOR_YELLOW)%-15s$(COLOR_RESET) %s\n", $$1, $$2 } /^##@/ { printf "\n$(COLOR_GREEN)%s$(COLOR_RESET)\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + @echo "" + +##@ Building + +.PHONY: build +build: ## Build the mod + @echo "$(COLOR_GREEN)Building $(MOD_NAME) with Java 17...$(COLOR_RESET)" + @$(GRADLE) build + +.PHONY: clean +clean: ## Clean build artifacts + @echo "$(COLOR_YELLOW)Cleaning build files...$(COLOR_RESET)" + @$(GRADLE) clean + +.PHONY: rebuild +rebuild: clean build ## Clean and rebuild + +.PHONY: release +release: clean ## Build for release (clean + build) + @echo "$(COLOR_GREEN)Building release version of $(MOD_NAME)...$(COLOR_RESET)" + @$(GRADLE) build + @mkdir -p build/release + @cp build/reobfJar/output.jar build/release/tiedup-$(MOD_VERSION).jar + @echo "$(COLOR_GREEN)Release build complete!$(COLOR_RESET)" + @ls -lh build/release/ + +##@ Running + +.PHONY: run +run: ## Run Minecraft client + @echo "$(COLOR_GREEN)Launching Minecraft client...$(COLOR_RESET)" + @$(GRADLE) runClient + +.PHONY: server +server: ## Run dedicated server + @echo "$(COLOR_GREEN)Launching dedicated server...$(COLOR_RESET)" + @$(GRADLE) runServer + +.PHONY: client2 +client2: ## Run second client instance + @echo "$(COLOR_GREEN)Launching client 2...$(COLOR_RESET)" + @$(GRADLE) runClient2 + +.PHONY: client3 +client3: ## Run third client instance + @echo "$(COLOR_GREEN)Launching client 3...$(COLOR_RESET)" + @$(GRADLE) runClient3 + +.PHONY: data +data: ## Run data generators + @echo "$(COLOR_GREEN)Running data generators...$(COLOR_RESET)" + @$(GRADLE) runData + +##@ Multiplayer Testing + +.PHONY: mptest +mptest: mp-kill build _mp-setup ## Build and launch server + 2 clients + @echo "$(COLOR_GREEN)Starting multiplayer test environment...$(COLOR_RESET)" + @echo "$(COLOR_BLUE)Launching 3 terminals: Server, Dev1, Dev2$(COLOR_RESET)" + @konsole --new-tab -e bash -c "cd '$(CURDIR)' && $(GRADLE) runServer; read -p 'Press enter to close'" & + @sleep 2 + @konsole --new-tab -e bash -c "sleep 12 && cd '$(CURDIR)' && $(GRADLE) runClient; read -p 'Press enter to close'" & + @konsole --new-tab -e bash -c "sleep 17 && cd '$(CURDIR)' && $(GRADLE) runClient2; read -p 'Press enter to close'" & + @echo "$(COLOR_GREEN)Terminals launched. Clients will auto-connect after server starts.$(COLOR_RESET)" + +.PHONY: _mp-setup +_mp-setup: + @mkdir -p run run-client2 run-client3 + @echo "eula=true" > run/eula.txt + @if [ ! -f run/server.properties ]; then \ + echo "# TiedUp Dev Server" > run/server.properties; \ + echo "online-mode=false" >> run/server.properties; \ + echo "enable-command-block=true" >> run/server.properties; \ + echo "spawn-protection=0" >> run/server.properties; \ + echo "gamemode=creative" >> run/server.properties; \ + echo "allow-flight=true" >> run/server.properties; \ + else \ + grep -q "online-mode=false" run/server.properties || \ + sed -i 's/online-mode=true/online-mode=false/' run/server.properties; \ + fi + +.PHONY: mp-kill +mp-kill: ## Kill all Minecraft processes and clean locks + @echo "$(COLOR_YELLOW)Killing all Minecraft/Java processes...$(COLOR_RESET)" + @-fuser -k 25565/tcp 2>/dev/null; true + @-pkill -9 -f "net.minecraft.server.Main" 2>/dev/null; true + @-pkill -9 -f "net.minecraft.client.main.Main" 2>/dev/null; true + @-pkill -9 -f "MinecraftServer" 2>/dev/null; true + @-pkill -9 -f "cpw.mods.bootstraplauncher" 2>/dev/null; true + @sleep 1 + @-rm -f run/world/session.lock run/*/session.lock 2>/dev/null; true + @-rm -f run-client2/*/session.lock run-client3/*/session.lock 2>/dev/null; true + @echo "$(COLOR_GREEN)Done$(COLOR_RESET)" + +##@ Development + +.PHONY: test +test: ## Run tests + @$(GRADLE) test + +.PHONY: idea +idea: ## Generate IntelliJ IDEA run configurations + @$(GRADLE) genIntellijRuns + +.PHONY: eclipse +eclipse: ## Generate Eclipse project files + @$(GRADLE) eclipse + +##@ Information + +.PHONY: info +info: ## Show project information + @echo "" + @echo "$(COLOR_BLUE) TiedUp! - Project Information$(COLOR_RESET)" + @echo "$(COLOR_GREEN)Mod:$(COLOR_RESET) $(MOD_NAME) $(MOD_VERSION)" + @echo "$(COLOR_GREEN)Minecraft:$(COLOR_RESET) 1.20.1" + @echo "$(COLOR_GREEN)Forge:$(COLOR_RESET) 47.4.10" + @echo "$(COLOR_GREEN)Java:$(COLOR_RESET) 17 ($(JAVA_HOME))" + @echo "" + +.PHONY: version +version: ## Show Java and Gradle versions + @$(JAVA_HOME)/bin/java -version + @$(GRADLE) --version | head -5 + +##@ Maintenance + +.PHONY: refresh +refresh: ## Refresh Gradle dependencies + @$(GRADLE) --refresh-dependencies + +.PHONY: setup +setup: ## Initial setup (first time) + @echo "$(COLOR_GREEN)Running initial setup...$(COLOR_RESET)" + @$(GRADLE) wrapper + @echo "$(COLOR_GREEN)Setup complete! Run 'make build' to build the mod.$(COLOR_RESET)" + +.PHONY: all +all: clean build ## Clean and build diff --git a/README.md b/README.md index 9ca2649..1f829f4 100644 --- a/README.md +++ b/README.md @@ -1 +1,121 @@ -# Open-TiedUp! +# TiedUp! - Minecraft 1.20.1 Forge Mod + +Community remake of the TiedUp! mod for Minecraft 1.20.1 (Forge). +Adds restraint and roleplay mechanics to the game. + +> Original mod by Yuti & Marl Velius (1.12.2). +> This is an independent remake, not affiliated with the original developers. + +## Features + +- Restraint items (binds, gags, blindfolds, collars, straps, and more) +- NPC entities with AI and personality-driven dialogue (Kidnapper, Damsel, Guard, Trader, Maid, Master) +- Kidnapper camp world generation +- Captivity and prison mechanics +- Player animation system with 3D item rendering +- Multiplayer synchronization +- Mod compatibility (Minecraft Comes Alive, Wildfire's Female Gender Mod) +- In-game guide book (Patchouli) + +## Requirements + +- Java 17 +- Minecraft 1.20.1 +- Forge 47.4.10+ +- [PlayerAnimator](https://maven.kosmx.dev/) 1.0.2-rc1+ + +## Building + +```bash +# First time setup +make setup + +# Build the mod +make build + +# Clean and rebuild +make rebuild + +# See all available commands +make help +``` + +Or directly with Gradle: + +```bash +export JAVA_HOME=/usr/lib/jvm/java-17-openjdk +./gradlew build +``` + +The built JAR will be in `build/libs/`. + +## Development + +```bash +# Run Minecraft client +make run + +# Run dedicated server +make server + +# Multiplayer testing (server + 2 clients) +make mptest + +# Generate IDE configurations +make idea # IntelliJ IDEA +make eclipse # Eclipse +``` + +## Project Structure + +``` +src/main/java/com/tiedup/remake/ +├── blocks/ # Custom blocks and block entities +├── cells/ # Captive cell management +├── client/ # Rendering, GUI, animations +├── commands/ # /tiedup command +├── compat/ # Mod compatibility (MCA, Wildfire) +├── core/ # Main mod class, config, sounds +├── dialogue/ # NPC conversation system +├── entities/ # Custom NPCs and AI +├── events/ # Event handlers +├── items/ # All mod items +├── mixin/ # Minecraft bytecode modifications +├── network/ # Multiplayer packet system +├── personality/ # NPC personality system +├── state/ # Player state tracking +├── v2/ # Next-gen items and blocks +└── worldgen/ # Structure generation +``` + +## Dependencies + +Some dependencies are included as local JARs in `libs/` because they are not available on Maven Central: + +| Library | Version | Usage | +|---------|---------|-------| +| PlayerAnimator | 1.0.2-rc1+1.20 | Player pose animations | +| bendy-lib | 4.0.0 | Model part bending | +| Architectury | 9.2.14 | Required by MCA | +| Minecraft Comes Alive | 7.6.13 | Optional compatibility | +| Wildfire Gender Mod | 3.1 | Optional compatibility | + +## License + +GPL-3.0 with Commons Clause - see [LICENSE](LICENSE) for details. + +**TL;DR:** Free to use, modify, and distribute. Cannot be sold or put behind a paywall. + +## Status + +This mod is under heavy rework. Things will break, APIs will change, features will come and go. If you want to build and use it as-is, that's on you. + +## Contributing + +Contributions are welcome. Rules: + +- **Pull requests only** - no direct pushes +- **Clear commit messages** - describe what and why, not how +- **Test your changes** before submitting - at minimum, make sure it compiles and runs +- Bug fixes, new features, improvements - all welcome +- Areas where help is especially needed: textures, 3D models, multiplayer testing diff --git a/build-with-java17.sh b/build-with-java17.sh new file mode 100755 index 0000000..fc26ea8 --- /dev/null +++ b/build-with-java17.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# TiedUp! Build Helper Script +# Forces the use of Java 17 for Gradle builds (required for Forge 1.20.1) +# +# Usage: +# ./build-with-java17.sh # Run default build +# ./build-with-java17.sh clean # Clean build +# ./build-with-java17.sh runClient # Run client + +# Set Java 17 as JAVA_HOME for this script +export JAVA_HOME=/usr/lib/jvm/java-17-openjdk + +# Print Java version for confirmation +echo "Using Java version:" +$JAVA_HOME/bin/java -version + +echo "" +echo "Running Gradle with Java 17..." +echo "Command: ./gradlew $@" +echo "" + +# Run Gradle with all passed arguments +./gradlew "$@" diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..68119cb --- /dev/null +++ b/build.gradle @@ -0,0 +1,295 @@ +// MixinGradle - must be in buildscript BEFORE plugins +buildscript { + repositories { + maven { url = 'https://repo.spongepowered.org/repository/maven-public/' } + } + dependencies { + classpath 'org.spongepowered:mixingradle:0.7-SNAPSHOT' + } +} + +plugins { + id 'eclipse' + id 'idea' + id 'maven-publish' + id 'net.minecraftforge.gradle' version '[6.0,6.2)' +} + +// Apply mixin plugin AFTER ForgeGradle +apply plugin: 'org.spongepowered.mixin' + +version = mod_version +group = mod_group_id + +base { + archivesName = mod_id +} + +// Mojang ships Java 17 to end users in 1.18+, so your mod should target Java 17. +java.toolchain.languageVersion = JavaLanguageVersion.of(17) + +println "Java: ${System.getProperty 'java.version'}, JVM: ${System.getProperty 'java.vm.version'} (${System.getProperty 'java.vendor'}), Arch: ${System.getProperty 'os.arch'}" +minecraft { + // The mappings can be changed at any time and must be in the following format. + // Channel: Version: + // official MCVersion Official field/method names from Mojang mapping files + // parchment YYYY.MM.DD-MCVersion Open community-sourced parameter names and javadocs layered on top of official + // + // You must be aware of the Mojang license when using the 'official' or 'parchment' mappings. + // See more information here: https://github.com/MinecraftForge/MCPConfig/blob/master/Mojang.md + // + // Parchment is an unofficial project maintained by ParchmentMC, separate from MinecraftForge + // Additional setup is needed to use their mappings: https://parchmentmc.org/docs/getting-started + // + // Use non-default mappings at your own risk. They may not always work. + // Simply re-run your setup task after changing the mappings to update your workspace. + mappings channel: mapping_channel, version: mapping_version + + // When true, this property will have all Eclipse/IntelliJ IDEA run configurations run the "prepareX" task for the given run configuration before launching the game. + // In most cases, it is not necessary to enable. + // enableEclipsePrepareRuns = true + // enableIdeaPrepareRuns = true + + // This property allows configuring Gradle's ProcessResources task(s) to run on IDE output locations before launching the game. + // It is REQUIRED to be set to true for this template to function. + // See https://docs.gradle.org/current/dsl/org.gradle.language.jvm.tasks.ProcessResources.html + copyIdeResources = true + + // When true, this property will add the folder name of all declared run configurations to generated IDE run configurations. + // The folder name can be set on a run configuration using the "folderName" property. + // By default, the folder name of a run configuration is the name of the Gradle project containing it. + // generateRunFolders = true + + // This property enables access transformers for use in development. + // They will be applied to the Minecraft artifact. + // The access transformer file can be anywhere in the project. + // However, it must be at "META-INF/accesstransformer.cfg" in the final mod jar to be loaded by Forge. + // This default location is a best practice to automatically put the file in the right place in the final jar. + // See https://docs.minecraftforge.net/en/latest/advanced/accesstransformers/ for more information. + // accessTransformer = file('src/main/resources/META-INF/accesstransformer.cfg') + + // Default run configurations. + // These can be tweaked, removed, or duplicated as needed. + runs { + // applies to all the run configs below + configureEach { + workingDirectory project.file('run') + + // Recommended logging data for a userdev environment + // The markers can be added/remove as needed separated by commas. + // "SCAN": For mods scan. + // "REGISTRIES": For firing of registry events. + // "REGISTRYDUMP": For getting the contents of all registries. + property 'forge.logging.markers', 'REGISTRIES' + + // Recommended logging level for the console + // You can set various levels here. + // Please read: https://stackoverflow.com/questions/2031163/when-to-use-the-different-log-levels + property 'forge.logging.console.level', 'debug' + + mods { + "${mod_id}" { + source sourceSets.main + } + } + } + + client { + // Comma-separated list of namespaces to load gametests from. Empty = all namespaces. + property 'forge.enabledGameTestNamespaces', mod_id + + // Mixin properties (required for dev environment) + property 'mixin.env.remapRefMap', 'true' + property 'mixin.env.refMapRemappingFile', "${projectDir}/build/createSrgToMcp/output.srg" + + // Mixin config arg + args '-mixin.config=tiedup.mixins.json' + } + + server { + property 'forge.enabledGameTestNamespaces', mod_id + args '--nogui' + + // Mixin properties (required for dev environment) + property 'mixin.env.remapRefMap', 'true' + property 'mixin.env.refMapRemappingFile', "${projectDir}/build/createSrgToMcp/output.srg" + + // Mixin config arg + args '-mixin.config=tiedup.mixins.json' + } + + // Additional client instances for multiplayer testing + client2 { + parent runs.client + workingDirectory project.file('run-client2') + args '--username', 'Dev2' + } + + client3 { + parent runs.client + workingDirectory project.file('run-client3') + args '--username', 'Dev3' + } + + // This run config launches GameTestServer and runs all registered gametests, then exits. + // By default, the server will crash when no gametests are provided. + // The gametest system is also enabled by default for other run configs under the /test command. + gameTestServer { + property 'forge.enabledGameTestNamespaces', mod_id + } + + data { + // example of overriding the workingDirectory set in configureEach above + workingDirectory project.file('run-data') + + // Specify the modid for data generation, where to output the resulting resource, and where to look for existing resources. + args '--mod', mod_id, '--all', '--output', file('src/generated/resources/'), '--existing', file('src/main/resources/') + } + } +} + +// Include resources generated by data generators. +sourceSets.main.resources { srcDir 'src/generated/resources' } + +// MixinGradle configuration - generates refmap for production +mixin { + add sourceSets.main, 'tiedup.refmap.json' + config 'tiedup.mixins.json' +} + +repositories { + // Put repositories for dependencies here + // ForgeGradle automatically adds the Forge maven and Maven Central for you + + // PlayerAnimator library + maven { + name = "KosmX's maven" + url = 'https://maven.kosmx.dev/' + } + + // Patchouli + maven { + name = "Jared's maven" + url = 'https://maven.blamejared.com/' + } + + // Local libs directory for non-maven mods + flatDir { + dir 'libs' + } +} + +dependencies { + // Specify the version of Minecraft to use. + // Any artifact can be supplied so long as it has a "userdev" classifier artifact and is a compatible patcher artifact. + // The "userdev" classifier will be requested and setup by ForgeGradle. + // If the group id is "net.minecraft" and the artifact id is one of ["client", "server", "joined"], + // then special handling is done to allow a setup of a vanilla dependency without the use of an external repository. + minecraft "net.minecraftforge:forge:${minecraft_version}-${forge_version}" + + // Mixin annotation processor + annotationProcessor 'org.spongepowered:mixin:0.8.5:processor' + + // PlayerAnimator library for player pose animations + // Using flatDir repository for local jar + implementation fg.deobf("blank:player-animation-lib-forge:1.0.2-rc1+1.20") + + // bendy-lib for bending model parts (knees, etc.) + // Using flatDir repository for local jar + implementation fg.deobf("blank:bendy-lib-forge:4.0.0") + + // Patchouli + compileOnly fg.deobf("vazkii.patchouli:Patchouli:1.20.1-84-FORGE") + runtimeOnly fg.deobf("vazkii.patchouli:Patchouli:1.20.1-84-FORGE") + + // Wildfire's Female Gender Mod (Optional Dependency) + // Using flatDir repository for proper deobfuscation + compileOnly fg.deobf("blank:wildfire-gender-mod:3.1") + runtimeOnly fg.deobf("blank:wildfire-gender-mod:3.1") + + // Minecraft Comes Alive (Optional Dependency) + // Using flatDir repository for proper deobfuscation + compileOnly fg.deobf("blank:minecraft-comes-alive:7.6.13") + runtimeOnly fg.deobf("blank:minecraft-comes-alive:7.6.13") + + // Architectury API (Required by MCA) + // Using flatDir repository for proper deobfuscation + compileOnly fg.deobf("blank:architectury:9.2.14-forge") + runtimeOnly fg.deobf("blank:architectury:9.2.14-forge") + + // Example mod dependency with JEI - using fg.deobf() ensures the dependency is remapped to your development mappings + // The JEI API is declared for compile time use, while the full JEI artifact is used at runtime + // compileOnly fg.deobf("mezz.jei:jei-${mc_version}-common-api:${jei_version}") + // compileOnly fg.deobf("mezz.jei:jei-${mc_version}-forge-api:${jei_version}") + // runtimeOnly fg.deobf("mezz.jei:jei-${mc_version}-forge:${jei_version}") + + // Example mod dependency using a mod jar from ./libs with a flat dir repository + // This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar + // The group id is ignored when searching -- in this case, it is "blank" + // implementation fg.deobf("blank:coolmod-${mc_version}:${coolmod_version}") + + // For more info: + // http://www.gradle.org/docs/current/userguide/artifact_dependencies_tutorial.html + // http://www.gradle.org/docs/current/userguide/dependency_management.html +} + +// This block of code expands all declared replace properties in the specified resource targets. +// A missing property will result in an error. Properties are expanded using ${} Groovy notation. +// When "copyIdeResources" is enabled, this will also run before the game launches in IDE environments. +// See https://docs.gradle.org/current/dsl/org.gradle.language.jvm.tasks.ProcessResources.html +tasks.named('processResources', ProcessResources).configure { + var replaceProperties = [ + minecraft_version: minecraft_version, minecraft_version_range: minecraft_version_range, + forge_version: forge_version, forge_version_range: forge_version_range, + loader_version_range: loader_version_range, + mod_id: mod_id, mod_name: mod_name, mod_license: mod_license, mod_version: mod_version, + mod_authors: mod_authors, mod_description: mod_description, + ] + inputs.properties replaceProperties + + filesMatching(['META-INF/mods.toml', 'pack.mcmeta']) { + expand replaceProperties + [project: project] + } +} + +// Example for how to get properties into the manifest for reading at runtime. +tasks.named('jar', Jar).configure { + manifest { + attributes([ + 'Specification-Title' : mod_id, + 'Specification-Vendor' : mod_authors, + 'Specification-Version' : '1', // We are version 1 of ourselves + 'Implementation-Title' : project.name, + 'Implementation-Version' : project.jar.archiveVersion, + 'Implementation-Vendor' : mod_authors, + 'Implementation-Timestamp': new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"), + 'MixinConfigs' : 'tiedup.mixins.json' + ]) + } + + // This is the preferred method to reobfuscate your jar file + finalizedBy 'reobfJar' +} + +// However if you are in a multi-project build, dev time needs unobfed jar files, so you can delay the obfuscation until publishing by doing: +// tasks.named('publish').configure { +// dependsOn 'reobfJar' +// } + +// Example configuration to allow publishing using the maven-publish plugin +publishing { + publications { + register('mavenJava', MavenPublication) { + artifact jar + } + } + repositories { + maven { + url "file://${project.projectDir}/mcmodsrepo" + } + } +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' // Use the UTF-8 charset for Java compilation +} diff --git a/docs/ARTIST_GUIDE.md b/docs/ARTIST_GUIDE.md new file mode 100644 index 0000000..81acc2e --- /dev/null +++ b/docs/ARTIST_GUIDE.md @@ -0,0 +1,1502 @@ +# TiedUp! — 3D Item Creation Guide + +> Everything you need to create custom bondage items for TiedUp! using Blender. +> No Java required — just a GLB file and a JSON definition. + +--- + +## Table of Contents + +1. [How It Works](#how-it-works) +2. [The Skeleton](#the-skeleton) +3. [Body Regions](#body-regions) +4. [Modeling Your Item](#modeling-your-item) +5. [Weight Painting](#weight-painting) +6. [Tint Channels](#tint-channels-dynamic-colors) — dynamic per-zone coloring +7. [Animations](#animations) — item poses, fallback chain, variants, context animations +8. [Animation Templates](#animation-templates) +9. [Exporting from Blender](#exporting-from-blender) +10. [The JSON Definition](#the-json-definition) +11. [Packaging as a Resource Pack](#packaging-as-a-resource-pack) +12. [Common Mistakes](#common-mistakes) +13. [Examples](#examples) +14. [Furniture Creation](#furniture-creation) — interactive furniture (cross, pillory, cage, etc.) +15. [Quick Reference Cards](#quick-reference-cards) — body items + furniture cheat sheets + +--- + +## How It Works + +TiedUp! uses a **2-layer animation system** to render 3D items on players: + +``` +Layer 1 — CONTEXT (provided by the mod, or by your GLB) + Controls the player's overall posture: standing, sitting, kneeling, sneaking, walking. + Affects body + legs. Never touches head (vanilla head tracking is preserved). + Can be replaced by artist-made GLB files (see Context Animations). + +Layer 2 — ITEM (provided by YOUR GLB file) + Controls the bones your item owns (based on its body regions). + Can ALSO animate free bones (not owned by any other equipped item). + Overrides Layer 1 for those bones. Everything else stays vanilla. +``` + +**What this means for you:** +- You animate the bones your item affects (e.g., arms for handcuffs). +- You can also animate **free bones** — bones no other item claims (e.g., legs if no ankle cuffs are worn). See [Free Bones](#what-to-animate-in-idle). +- The mod handles the rest: legs walk, body leans when sneaking, head follows the mouse. +- Your 3D mesh is skinned to the player skeleton and follows it in real-time. +- You can create **context animation GLBs** — standalone animation packs that replace or extend the mod's default postures (walk cycles, sit poses, etc.). See [Context Animations](#context-animations). + +--- + +## The Skeleton + +Your GLB **must** use this exact skeleton. Names are **case-sensitive**. + +``` +PlayerArmature ← armature root object (never keyframe this) + └─ body ← context layer by default; animate if you own TORSO/WAIST or if free + ├─ torso ← maps to same body part as 'body'; prefer using 'body' instead + │ ├─ head + │ ├─ leftUpperArm + │ │ └─ leftLowerArm + │ └─ rightUpperArm + │ └─ rightLowerArm + ├─ leftUpperLeg + │ └─ leftLowerLeg + └─ rightUpperLeg + └─ rightLowerLeg +``` + +**11 skinned joints** (body through rightLowerLeg). `PlayerArmature` is the armature root object, not a skinned joint — it must never be keyframed. + +| Bone | What it controls | Type | +|------|-----------------|------| +| `PlayerArmature` | Root transform | Never animate | +| `body` | Torso position/rotation | Full rotation — context layer by default, animate if owned or free | +| `torso` | Same as `body` (alias) | Full rotation — prefer `body` instead | +| `head` | Head | Full rotation (pitch/yaw/roll) | +| `leftUpperArm` | Left shoulder to elbow | Full rotation | +| `leftLowerArm` | Left elbow to wrist | Bend (angle + direction) | +| `rightUpperArm` | Right shoulder to elbow | Full rotation | +| `rightLowerArm` | Right elbow to wrist | Bend (angle + direction) | +| `leftUpperLeg` | Left hip to knee | Full rotation | +| `leftLowerLeg` | Left knee to ankle | Bend (angle + direction) | +| `rightUpperLeg` | Right hip to knee | Full rotation | +| `rightLowerLeg` | Right knee to ankle | Bend (angle + direction) | + +### Upper vs Lower Bones + +- **Upper bones** (head, body, upperArm, upperLeg) support full 3-axis rotation (pitch, yaw, roll). +- **Lower bones** (lowerArm, lowerLeg) support **bend angle + bend direction** — the mod extracts both values from your Blender pose. You can pose them freely in any direction; the mod faithfully reproduces both the magnitude and direction of the bend. + +### What You Can and Cannot Animate + +**Never animate:** `PlayerArmature` — it's the armature root object, not a bone. + +**Everything else follows this rule:** +- Your item **always** controls bones in its declared regions. +- Your item **can also** animate free bones (not owned by any other equipped item). +- Your item **cannot** override bones owned by another equipped item. + +| Bone | Who Controls It | +|------|----------------| +| `body` / `torso` | Context layer by default. Your item if it owns TORSO or WAIST, or if `body` is free. | +| `head` | Vanilla head tracking by default. Your item if it owns HEAD, EYES, EARS, or MOUTH. | +| Arms (`*UpperArm`, `*LowerArm`) | Vanilla by default. Your item if it owns ARMS or HANDS. | +| Legs (`*UpperLeg`, `*LowerLeg`) | Context layer by default. Your item if it owns LEGS or FEET. | + +**Note:** `torso` and `body` both map to the same internal part. Prefer animating `body` — using `torso` produces the same result but is less intuitive. + +--- + +## Body Regions + +Every item declares which **body regions** it occupies. This determines two things: +1. **Gameplay** — which equipment slots are taken (conflict resolution) +2. **Animation** — which bones your item layer controls + +### Region Table + +| Region | GLB Bones Controlled | Typical Items | +|--------|---------------------|---------------| +| `HEAD` | `head` | Hood, helmet, head harness | +| `EYES` | `head` | Blindfold | +| `EARS` | `head` | Earplugs | +| `MOUTH` | `head` | Gag, muzzle | +| `NECK` | *(none)* | Collar, choker | +| `TORSO` | `body` (+ `torso`) | Straitjacket, harness | +| `ARMS` | `rightUpperArm`, `rightLowerArm`, `leftUpperArm`, `leftLowerArm` | Handcuffs, armbinder | +| `HANDS` | `rightUpperArm`, `rightLowerArm`, `leftUpperArm`, `leftLowerArm` | Mittens, fist mitts | +| `FINGERS` | *(none)* | Finger cuffs | +| `WAIST` | `body` (+ `torso`) | Belt, chastity belt | +| `LEGS` | `rightUpperLeg`, `rightLowerLeg`, `leftUpperLeg`, `leftLowerLeg` | Ankle cuffs, leg binder | +| `FEET` | `rightUpperLeg`, `rightLowerLeg`, `leftUpperLeg`, `leftLowerLeg` | Forced shoes, foot cuffs | +| `TAIL` | *(none)* | Tail plug (pet play) | +| `WINGS` | *(none)* | Decorative wings | + +### Global vs Sub Regions + +Three regions are **global** — they encompass sub-regions: + +| Global | Sub-regions | +|--------|------------| +| `HEAD` | EYES, EARS, MOUTH | +| `ARMS` | HANDS, FINGERS | +| `LEGS` | FEET | + +**Important:** Blocking is **not automatic**. A hood that occupies `HEAD` does not automatically block `EYES`. You must explicitly declare blocked regions in your JSON definition. This gives you full control — a tiara occupies `HEAD` but blocks nothing. + +### Regions Without Bones + +`NECK`, `FINGERS`, `TAIL`, and `WINGS` don't control any animation bones. Items in these regions are purely cosmetic from an animation standpoint — they render as a mesh on the skeleton but don't change the player's pose. They still participate in gameplay (slot blocking, escape difficulty, etc.). + +--- + +## Modeling Your Item + +### Starting Point + +Use the **TiedUp! Blender Template** (provided with the mod). It contains: +- The correct `PlayerArmature` skeleton with all 11 bones +- A reference player mesh (Steve + Alex) for scale — toggle visibility as needed +- Pre-named Action slots + +### Guidelines + +1. **Model in rest pose.** The template skeleton is in the Minecraft rest pose (arms down, legs straight). Model your item to fit the player in this pose. + +2. **Keep it tight.** Your mesh will deform with the skeleton. Loose geometry that isn't weight-painted to any bone will stay frozen in space while the player moves. + +3. **Mind the polygon count.** Minecraft renders every player in range every frame. A 500-triangle mesh is ideal. 2000 is acceptable. 10,000 is going to cause lag in multiplayer. + +4. **Textures are baked into the GLB.** Apply your materials and textures in Blender. The mod reads them directly from the GLB file. No separate texture file needed (unless you want server-side color variants). + +5. **Slim model support.** Minecraft has two player models: Steve (4px arms) and Alex (3px arms). If your item wraps tightly around the arms, consider providing a slim variant. Otherwise, the Steve-width mesh works for both (minor clipping on Alex is usually acceptable). + +--- + +## Weight Painting + +Weight paint your mesh to the skeleton bones it should follow. + +### Rules + +- **Only paint to the 11 standard bones.** Any other bones in your Blender file will be ignored by the mod. +- **Paint to the bones of your regions.** Handcuffs (ARMS region) should be weighted to `rightUpperArm`, `rightLowerArm`, `leftUpperArm`, `leftLowerArm`. +- **You can paint to bones outside your regions** for smooth deformation. For example, handcuffs might have small weights on `body` near the shoulder area for smoother bending. This is fine — the weight painting is about mesh deformation, not animation control. +- **Normalize your weights.** Each vertex's total weights across all bones must sum to 1.0. Blender does this by default. + +### Tips + +- For rigid items (metal cuffs), use hard weights — each vertex fully assigned to one bone. +- For flexible items (rope, leather), blend weights between adjacent bones for smooth bending. +- The chain between handcuffs? Weight it 50/50 to both arms, or use a separate mesh element weighted to `body`. + +--- + +## Tint Channels (Dynamic Colors) + +Tint channels let players change the color of specific parts of your item without replacing the texture. A ball gag with a red ball can become blue, green, purple — while the black strap stays black. + +### How It Works + +Your item's texture detail (highlights, shadows, stitching, scratches) is fully preserved. The mod multiplies the texture color by a **tint color** per-zone. Grayscale textures + color tint = any hue with full detail. + +### Blender Workflow + +Use **separate materials** for fixed and colorable zones: + +1. **Fixed zones** — Name the material anything (e.g., `strap`, `buckle`, `metal`). These render as-is. +2. **Colorable zones** — Name the material `tintable_1`, `tintable_2`, etc. These get tinted by the mod. + +**Example: Ball Gag** +- Select the strap faces → assign material `strap` → texture with leather detail (full color) +- Select the ball faces → assign material `tintable_1` → texture with **grayscale** surface detail (highlights, reflections, micro-scratches) +- Export GLB + +The grayscale texture on `tintable_1` preserves all the surface detail. The mod multiplies it by the tint color: gray detail × red = detailed red ball. + +### Multiple Tint Channels + +You can have as many independently-colorable zones as you want: + +``` +Material "strap" → fixed (leather, never changes) +Material "tintable_1" → ball (player picks color) +Material "tintable_2" → ring (player picks a different color) +Material "buckle" → fixed (chrome metal) +``` + +Each `tintable_N` channel gets its own color. Players can customize each zone independently. + +### Texturing Tips for Tintable Zones + +- **Use grayscale textures** for tintable zones. The tint color replaces the hue entirely. +- Keep all the detail (bumps, scratches, reflections) in the luminance channel. +- A mid-gray base (not pure white) produces more natural-looking results. +- Dark areas in your grayscale texture stay dark regardless of tint — good for crevices and shadows. + +**What happens:** `finalPixelColor = textureGray × tintColor` +- Gray (128,128,128) × Red (255,0,0) = Dark Red (128,0,0) +- White (255,255,255) × Red (255,0,0) = Bright Red (255,0,0) +- Black (0,0,0) × Red (255,0,0) = Black (0,0,0) — shadows stay dark + +### JSON Definition + +Declare default colors per channel in your item JSON: + +```json +{ + "type": "tiedup:bondage_item", + "display_name": "Ball Gag", + "model": "mycreator:models/gltf/ball_gag.glb", + "regions": ["MOUTH"], + "supports_color": true, + "tint_channels": { + "tintable_1": "#FF0000", + "tintable_2": "#C0C0C0" + }, + "animation_bones": { + "idle": [] + }, + "pose_priority": 10, + "escape_difficulty": 3 +} +``` + +Colors are hex RGB (`#RRGGBB`). These are the default colors — players can override them in-game via dyeing. + +### Material Naming Rules + +| Name Pattern | Behavior | Example | +|-------------|----------|---------| +| `tintable_1`, `tintable_2`, ... | Colorable zone, numbered | Ball, ring, strap | +| Anything else | Fixed, rendered as-is | `metal`, `buckle`, `leather` | + +- Names are **case-sensitive** and must start with `tintable_` (lowercase). +- Numbers don't need to be sequential — `tintable_1` and `tintable_5` is fine. +- The number is part of the channel name: `tintable_1` in JSON matches `tintable_1` in Blender. + +### Items Without Tint Channels + +If your item has no `tintable_` materials, everything renders as-is. No `tint_channels` field needed in the JSON. Existing items are completely unaffected. + +--- + +## Animations + +Animations define how the player's body is posed when wearing your item. + +### The Only Required Animation: `Idle` + +Every item needs at least one animation named `Idle`. This is the default pose — what the player looks like while standing still with your item equipped. + +**In Blender:** Create an Action named `PlayerArmature|Idle`. + +The `PlayerArmature|` prefix is Blender's convention for armature-scoped actions. The mod strips it automatically — the resolved name is just `Idle`. + +### What to Animate in Idle + +**Only keyframe the bones your item affects.** Leave everything else untouched. + +| Item Type | Bones to Keyframe | Example Pose | +|-----------|------------------|--------------| +| Handcuffs (ARMS) | Upper + lower arms | Arms behind back, wrists together | +| Ankle cuffs (LEGS) | Upper + lower legs | Legs closer together, slightly bent | +| Blindfold (EYES) | Head | Slight head tilt down | +| Gag (MOUTH) | *(none)* | No pose change — mesh only | +| Straitjacket (ARMS+TORSO) | Arms + body (+ legs if free) | Arms crossed, slight forward lean, optional waddle | + +**Why only your bones?** The mod's 2-layer system activates your keyframes for bones in your declared regions. But there's a nuance: **free bones** (bones not owned by any equipped item) can also be animated by your item. + +For example: if a player wears only a straitjacket (ARMS+TORSO), the legs are "free" — no item claims them. If your straitjacket's GLB has leg keyframes (e.g., a waddle walk), the mod will use them. But if the player also wears ankle cuffs (LEGS), those leg keyframes are ignored — the ankle cuffs take over. + +**The rule:** Your item always controls its own bones. It can also animate free bones if your GLB has keyframes for them. It can never override another item's bones. + +### Idle is a Single-Frame Pose + +`Idle` should be a **static pose** — one keyframe at frame 0. The mod loops it as a held position. + +``` +Frame 0: Pose all owned bones → done. +``` + +### Optional Animations + +Beyond `Idle`, you can provide animations for specific contexts. All are optional — if missing, the mod falls back through a chain (see below). + +| Animation Name | Context | Notes | +|----------------|---------|-------| +| `Idle` | Standing still | **Required.** Single-frame pose. | +| `Struggle` | Player is struggling | Multi-frame loop. 20-40 frames recommended. | +| `Walk` | Player is walking | Multi-frame loop synced to walk speed. | +| `Sneak` | Player is sneaking | Single-frame or short loop. | +| `SitIdle` | Sitting (chair, minecart) | Single-frame pose. | +| `SitStruggle` | Sitting + struggling | Multi-frame loop. | +| `KneelIdle` | Kneeling | Single-frame pose. | +| `KneelStruggle` | Kneeling + struggling | Multi-frame loop. | +| `Crawl` | Crawling (dog pose) | Multi-frame loop. | + +**Naming in Blender:** Always prefix with `PlayerArmature|`. Examples: +- `PlayerArmature|Idle` +- `PlayerArmature|Struggle` +- `PlayerArmature|SitIdle` + +### Fallback Chain + +If an animation doesn't exist in your GLB, the mod looks for alternatives. At each step, `Full` variants are tried first: + +``` +SIT + STRUGGLE: + FullSitStruggle → SitStruggle → FullStruggle → Struggle + → FullSit → Sit → FullStruggle → Struggle → FullIdle → Idle + +KNEEL + STRUGGLE: + FullKneelStruggle → KneelStruggle → FullStruggle → Struggle + → FullKneel → Kneel → FullStruggle → Struggle → FullIdle → Idle + +SIT + IDLE: FullSitIdle → SitIdle → FullSit → Sit → FullIdle → Idle +KNEEL + IDLE: FullKneelIdle → KneelIdle → FullKneel → Kneel → FullIdle → Idle +SNEAK: FullSneak → Sneak → FullIdle → Idle +WALK: FullWalk → Walk → FullIdle → Idle +STAND STRUGGLE: FullStruggle → Struggle → FullIdle → Idle +STAND IDLE: FullIdle → Idle +``` + +In practice, most items only need `Idle`. Add `FullWalk` or `FullStruggle` when your item changes how the whole body moves. + +**Practical impact:** If you only provide `Idle`, your item works in every context. The player will hold the Idle pose while sitting, kneeling, sneaking, etc. It won't look perfect, but it will work. Add more animations over time to polish the experience. + +### Animation Variants (Random Selection) + +For variety, you can provide multiple versions of the same animation. The mod picks one **at random** each time the animation triggers. + +**Convention:** Append `.1`, `.2`, `.3`, etc. + +``` +PlayerArmature|Struggle.1 ← variant 1 +PlayerArmature|Struggle.2 ← variant 2 +PlayerArmature|Struggle.3 ← variant 3 +``` + +Works for any animation name: `Idle.1`/`Idle.2`, `SitIdle.1`/`SitIdle.2`, etc. + +**Rules:** +- Number sequentially starting from `.1`. The mod stops scanning at the first gap **after `.1`** (missing `.1` does not stop the scan — it continues checking `.2`, `.3`, etc.). +- If only one version exists, don't number it — just use `Struggle`, not `Struggle.1`. +- The base name (e.g., `Struggle`) **is included** in the random pool alongside numbered variants if it exists. So `Struggle` + `Struggle.1` + `Struggle.2` = three candidates in the pool. + +### Full-Body Animations (Naming Convention) + +Some items affect the entire body — not just their declared regions. A straitjacket makes the player waddle, a full-body bind forces hopping. For these, you want to animate **all bones**, including free ones like legs and body. + +**Convention:** Prefix the animation name with `Full`. + +| Standard Name | Full-Body Name | What Changes | +|--------------|---------------|-------------| +| `Idle` | `FullIdle` | Owned bones + body lean, leg stance | +| `Walk` | `FullWalk` | Owned bones + leg waddle, body sway | +| `Struggle` | `FullStruggle` | Owned bones + full-body thrashing | +| `Sneak` | `FullSneak` | Owned bones + custom sneak posture | + +**In Blender:** +``` +PlayerArmature|Idle ← region-only: just arms for handcuffs +PlayerArmature|FullWalk ← full-body: arms + legs waddle + body bob +PlayerArmature|FullStruggle ← full-body: everything moves +``` + +**How the mod resolves this:** +1. Checks for `Full` variant first (e.g., `FullWalk`) +2. Falls back to standard name (e.g., `Walk`) +3. Follows the normal fallback chain (`Walk` → `Idle`) + +**When to use `Full` vs standard:** + +| Animation | Use Standard | Use `Full` | +|-----------|:------------:|:----------:| +| `Idle` | Your item only poses its own bones | Your item changes the whole resting posture | +| `Walk` | Legs should use vanilla/context walk | Your item needs a custom walk cycle (waddle, hop, shuffle) | +| `Struggle` | Only owned bones move | Whole body thrashes and writhes | +| `Sneak` | Default sneak lean is fine | Your item changes how sneaking looks | + +**Key points:** +- `Full` animations include keyframes for ALL bones you want to control (owned + free). +- Free bones in `Full` animations are only used when no other item owns them. +- You can provide BOTH: `Idle` (region-only) and `FullWalk` (full-body). The mod picks the right one per context. +- `FullIdle` is rarely needed — most items only need a full-body version for movement animations. + +### Context Animations + +Context animations are **separate from item animations**. They control the player's **overall body posture** — how they walk, sit, sneak, kneel, etc. They are the foundation that item animations build on top of. + +#### The Two-Layer System + +``` +Layer 1 — CONTEXT (body posture) ← walk cycle, sit pose, sneak lean... + Controls: body, legs (and any unowned bones) + Bones owned by items are disabled on this layer. + +Layer 2 — ITEM (your item's pose) ← arms behind back, legs bound... + Controls: only the bones in the item's declared regions. + Overrides Layer 1 for those bones. +``` + +The mod ships with default context animations (currently as internal JSON files). These provide basic postures: a static standing pose, a simple walk, a sneak lean, sitting/kneeling positions. + +#### Artists Can Replace or Extend Context Animations + +This is where it gets interesting. **You can create GLB files that replace or add to the mod's context animations.** These are standalone GLBs — they are NOT tied to any specific item. + +Use cases: +- **Replace the default walk cycle** with a smoother, more natural one +- **Add a new "hogtied" context** the mod doesn't ship with +- **Create an animation overhaul pack** that improves every default posture +- **Provide themed context packs** (e.g., "petplay contexts" with crawl animations) + +#### Context GLB Format + +A context GLB uses the same `PlayerArmature` skeleton as item GLBs. The difference is what it contains: + +- **Item GLB:** mesh + animations for a specific item's bones +- **Context GLB:** no mesh — animations only, for **all bones** (body, legs, etc.) + +The **filename** determines which context the GLB replaces. It must match one of the context suffixes: + +| Filename | Context It Replaces | +|----------|-------------------| +| `stand_idle.glb` | Standing still | +| `stand_walk.glb` | Walking | +| `stand_sneak.glb` | Sneaking | +| `stand_struggle.glb` | Standing + struggling | +| `sit_idle.glb` | Sitting | +| `sit_struggle.glb` | Sitting + struggling | +| `kneel_idle.glb` | Kneeling | +| `kneel_struggle.glb` | Kneeling + struggling | +| `shuffle_idle.glb` | Shuffle style — standing still (legs close together) | +| `shuffle_walk.glb` | Shuffle style — tiny dragging steps | +| `hop_idle.glb` | Hop style — standing with feet bound together | +| `hop_walk.glb` | Hop style — small bunny hops | +| `waddle_idle.glb` | Waddle style — standing with slight sway | +| `waddle_walk.glb` | Waddle style — side-to-side waddling gait | +| `crawl_idle.glb` | Crawl style — on all fours, resting (petplay/dogwalk pose) | +| `crawl_move.glb` | Crawl style — on all fours, crawling forward | + +Names are **exact and case-sensitive**. The mod strips the `.glb` extension and looks up the suffix. + +You don't need to provide all of them. Missing contexts fall back to the mod's builtin defaults. The GLB itself should contain at least one animation clip — the mod uses the first clip found. + +#### What to Animate in Context GLBs + +Context animations should animate **body and legs** — the "posture" bones. Do NOT animate bones that items will control (arms, head), as your context keyframes would be overridden by any equipped item anyway. + +| Bone | Recommended? | Why | +|------|:-----------:|-----| +| `body` | Yes | Lean, sway, bob | +| `leftUpperLeg` / `rightUpperLeg` | Yes | Walk stride, sit angle | +| `leftLowerLeg` / `rightLowerLeg` | Yes | Knee bend | +| `head` | Usually no | Overridden by any item owning HEAD/EYES/EARS/MOUTH, and by vanilla head tracking | +| `leftUpperArm` / `rightUpperArm` | Usually no | Overridden by any item owning ARMS/HANDS. Only useful as a fallback when arms are free | +| `leftLowerArm` / `rightLowerArm` | Usually no | Same as upper arms | +| `torso` | Rarely | Same effect as `body`; prefer `body` | + +#### Example: Smooth Walk Cycle + +The mod's default walk is basic. You want a nicer one: + +**In Blender:** +1. Use the TiedUp! template (same skeleton) +2. Delete the reference player mesh — context GLBs have no mesh +3. Create `PlayerArmature|Walk`: a 20-frame walk cycle animating `body` (bob), `leftUpperLeg`/`rightUpperLeg` (stride), `leftLowerLeg`/`rightLowerLeg` (knee bend) +4. Export as GLB (armature only, no mesh) + +Place it in a resource pack: +``` +assets/mycreator/tiedup_contexts/stand_walk.glb +``` + +The filename `stand_walk.glb` matches the `stand_walk` context suffix. The mod discovers it at startup (or on F3+T reload) and uses your walk cycle instead of the builtin one. Players wearing handcuffs will have your smooth walk cycle on their legs/body, with arms still locked behind their back by the item. + +#### Example: Custom Kneeling Context + +The mod ships a basic kneel. You want a more expressive one with the body leaned slightly forward and legs tucked tighter: + +**In Blender:** +1. Create `PlayerArmature|KneelIdle`: single-frame pose with body pitched forward 15°, legs folded tight +2. Create `PlayerArmature|KneelStruggle`: multi-frame animation with body rocking side to side while kneeling +3. Export as GLB + +#### Replacing vs Extending + +- **Replace**: Provide a context animation with the same name as a default. Your animation is used instead. +- **Extend**: Provide a context animation for a situation the mod doesn't cover yet (e.g., a "Hogtied" context). Items can then reference this new context. + +#### Context Packs + +Since context GLBs are loaded from resource packs, the community can create **context animation packs** — collections of improved or themed postures. Players install them like any resource pack. No code changes, no item modifications. + +``` +BetterAnimations Pack/ + assets/betteranims/tiedup_contexts/ + stand_walk.glb ← replaces default walk + stand_sneak.glb ← replaces default sneak + sit_idle.glb ← replaces default sit + kneel_idle.glb ← replaces default kneel + kneel_struggle.glb ← replaces default kneel struggle + crawl_idle.glb ← replaces default crawl idle + crawl_move.glb ← replaces default crawl movement +``` + +#### Movement Style Contexts + +The mod includes 8 movement style contexts used when a player wears leg-restraining items with a `movement_style` field. These control how the player animates while moving in a restricted way. + +**The crawl style should animate the player on all fours in a petplay/dogwalk pose** — NOT a belly-down swimming pose. Think of a pet being walked on a leash, with knees and hands on the ground, body tilted forward. The vanilla SWIMMING hitbox is used for the 0.6-block height, but the animation completely overrides the vanilla swimming visuals. + +| Style | Idle | Walk/Move | Visual | +|-------|------|-----------|--------| +| **Shuffle** | Legs close together, slight sway | Tiny dragging steps, short stride | Feet hobbled with a short chain | +| **Hop** | Feet together, standing | Small bunny hops, feet never separate | Feet bound together, forced to hop | +| **Waddle** | Slight lateral sway | Body rocks left-right, wide stance | Legs hobbled mid-thigh | +| **Crawl** | On all fours, resting | On all fours, crawling forward | Petplay/dogwalk, hands and knees | + +These are excellent candidates for artist GLB replacements — the default animations are basic placeholders. A well-made walk cycle for `shuffle_walk.glb` or `crawl_move.glb` will dramatically improve the feel of the restraint system. + +--- + +## Animation Templates + +Not every item needs custom animations. Many items in the same category share identical poses — all handcuffs put arms behind the back, all ankle cuffs restrict leg movement. + +TiedUp! provides **animation template GLBs** with pre-made animations: + +``` +assets/tiedup/models/gltf/templates/ + handcuff_anims.glb ← Idle, Struggle, SitIdle for ARMS items + leg_restraint_anims.glb ← Idle, Walk, SitIdle for LEGS items + full_body_anims.glb ← Idle, Walk, Struggle, SitIdle for full-body items +``` + +### How to Use a Template + +In your JSON definition, separate the mesh from the animations: + +```json +{ + "model": "mycreator:models/gltf/my_fancy_cuffs.glb", + "animation_source": "tiedup:models/gltf/templates/handcuff_anims.glb" +} +``` + +- `model` — your GLB with the **3D mesh** (no animations needed) +- `animation_source` — the template GLB with **animations** +- If `animation_source` is omitted, animations come from `model` + +**Artist workflow:** Model your item, weight-paint it, skip all animation work, reference a template. Done. + +### When NOT to Use a Template + +- Your item has a unique silhouette that would clip with template poses +- You want a distinctive struggle animation +- Your item affects bones differently (e.g., arms in front vs behind back) +- You want to ship a premium, polished item + +--- + +## Exporting from Blender + +### Export Settings + +**File > Export > glTF 2.0 (.glb)** + +| Setting | Value | Why | +|---------|-------|-----| +| Format | `glb` (binary) | Single file, faster loading | +| Include > Limit to | Selected Objects | Export only armature + item mesh | +| Transform > +Y Up | Checked | glTF standard | +| Mesh > Apply Modifiers | Checked | Bake subdivision, mirror, etc. | +| Mesh > Normals | Checked | Needed for lighting | +| Mesh > Vertex Colors | Checked (if used) | For tintable items | +| Animation > Export Actions | Checked | Include all named actions | +| Animation > Group by NLA Track | Unchecked | Avoids duplicate animations | +| Animation > Sampling Rate | 1 | One sample per frame | + +### Pre-Export Checklist + +- [ ] Armature is named `PlayerArmature` +- [ ] All 11 bones have correct names (case-sensitive) +- [ ] Actions are named `PlayerArmature|Idle`, `PlayerArmature|Struggle`, etc. +- [ ] Mesh is weight-painted to skeleton bones only +- [ ] Weights are normalized +- [ ] No orphan bones (extra bones not in the standard 11 are ignored but add file size) +- [ ] Materials/textures are applied (the GLB bakes them in) +- [ ] Scale is correct (1 Blender unit = 1 Minecraft block = 16 pixels) + +--- + +## The JSON Definition + +Every item needs a JSON file that declares its gameplay properties. The mod scans `assets//tiedup_items/*.json` at startup and on resource reload (F3+T). + +### Minimal Example — Rope Gag + +```json +{ + "type": "tiedup:bondage_item", + "display_name": "Rope Gag", + "model": "mycreator:models/gltf/rope_gag.glb", + "regions": ["MOUTH"], + "animation_bones": { + "idle": [] + }, + "pose_priority": 10, + "escape_difficulty": 2, + "lockable": false +} +``` + +### Standard Example — Iron Handcuffs + +```json +{ + "type": "tiedup:bondage_item", + "display_name": "Iron Handcuffs", + "model": "mycreator:models/gltf/iron_cuffs.glb", + "slim_model": "mycreator:models/gltf/iron_cuffs_slim.glb", + "animation_source": "tiedup:models/gltf/templates/handcuff_anims.glb", + "regions": ["ARMS"], + "animation_bones": { + "idle": ["rightArm", "leftArm"], + "struggle": ["rightArm", "leftArm"] + }, + "pose_priority": 30, + "escape_difficulty": 5, + "lockable": true +} +``` + +### Complex Example — Straitjacket + +```json +{ + "type": "tiedup:bondage_item", + "display_name": "Leather Straitjacket", + "model": "mycreator:models/gltf/straitjacket.glb", + "slim_model": "mycreator:models/gltf/straitjacket_slim.glb", + "regions": ["ARMS", "HANDS", "TORSO"], + "blocked_regions": ["ARMS", "HANDS", "TORSO", "FINGERS"], + "animation_bones": { + "idle": ["rightArm", "leftArm", "body"], + "struggle": ["rightArm", "leftArm", "body"] + }, + "pose_priority": 50, + "escape_difficulty": 7, + "lockable": true +} +``` + +### Complex Example — Ankle Chains (with movement style) + +```json +{ + "type": "tiedup:bondage_item", + "display_name": "Ankle Chains", + "model": "mycreator:models/gltf/ankle_chains.glb", + "regions": ["FEET"], + "animation_bones": { + "idle": ["rightLeg", "leftLeg"], + "walk": ["rightLeg", "leftLeg"] + }, + "pose_priority": 30, + "escape_difficulty": 5, + "lockable": true, + "movement_style": "shuffle" +} +``` + +The `movement_style` changes how the player physically moves — slower speed, different walking animation, and potentially disabled jumping. See [Movement Styles](#movement-styles) below. + +### Complex Example — Hogtie (crawl style) + +```json +{ + "type": "tiedup:bondage_item", + "display_name": "Hogtie Harness", + "model": "mycreator:models/gltf/hogtie.glb", + "regions": ["ARMS", "HANDS", "LEGS", "FEET"], + "blocked_regions": ["ARMS", "HANDS", "LEGS", "FEET", "FINGERS", "WAIST"], + "animation_bones": { + "idle": ["rightArm", "leftArm", "rightLeg", "leftLeg", "body"], + "struggle": ["rightArm", "leftArm", "rightLeg", "leftLeg", "body"] + }, + "pose_priority": 90, + "escape_difficulty": 9, + "lockable": true, + "movement_style": "crawl", + "movement_modifier": { + "speed_multiplier": 0.15 + } +} +``` + +### Field Reference + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | string | Yes | Always `"tiedup:bondage_item"` | +| `display_name` | string | Yes | Name shown in-game | +| `model` | string | Yes | ResourceLocation of the GLB mesh | +| `slim_model` | string | No | GLB for Alex-model players (3px arms) | +| `texture` | string | No | Override texture (if not baked in GLB) | +| `animation_source` | string | No | GLB to read animations from (defaults to `model`) | +| `regions` | string[] | Yes | Body regions this item occupies | +| `blocked_regions` | string[] | No | Regions blocked for other items (defaults to `regions`) | +| `pose_priority` | int | Yes | Higher = overrides lower-priority items (see below) | +| `escape_difficulty` | int | Yes | 1-10 scale. Higher = harder to struggle free | +| `lockable` | bool | No | Can a padlock be applied? Default: `true` | +| `supports_color` | bool | No | Whether this item has tintable zones. Default: `false` | +| `tint_channels` | object | No | Default colors per tintable zone: `{"tintable_1": "#FF0000"}` | +| `icon` | string | No | Inventory sprite model (see [Inventory Icons](#inventory-icons) below) | +| `animations` | string/object | No | `"auto"` (default) or explicit name mapping | +| `movement_style` | string | No | Movement restriction: `"waddle"`, `"shuffle"`, `"hop"`, or `"crawl"` | +| `movement_modifier` | object | No | Override speed/jump for the movement style (requires `movement_style`) | +| `animation_bones` | object | Yes | Per-animation bone whitelist (see below) | + +### animation_bones (required) + +Declares which bones each named animation is allowed to control for this item. This enables fine-grained per-animation bone filtering: an item might own `body` via its regions but only want the "idle" animation to affect the arms. + +**Format:** A JSON object where each key is an animation name (matching the GLB animation names) and each value is an array of bone names. + +**Valid bone names:** `head`, `body`, `rightArm`, `leftArm`, `rightLeg`, `leftLeg` + +**Example:** +```json +"animation_bones": { + "idle": ["rightArm", "leftArm"], + "struggle": ["rightArm", "leftArm", "body"] +} +``` + +At runtime, the effective bones for a given animation clip are computed as the **intersection** of `animation_bones[clipName]` and the item's owned parts (from region conflict resolution). If the clip name is not listed in `animation_bones`, the item falls back to using all its owned parts. + +This field is **required**. Items without `animation_bones` will be rejected by the parser. + +### Pose Priority + +When multiple items affect the same bones, the highest `pose_priority` wins. + +| Priority Range | Item Type | Example | +|---------------|-----------|---------| +| 1–10 | Light cosmetic | Collar, blindfold, gag | +| 20–40 | Standard restraint | Handcuffs, ankle cuffs | +| 40–60 | Heavy restraint | Armbinder, straitjacket | +| 60–80 | Full-body restraint | Leg binder + armbinder combo | +| 80–100 | Total restraint | Sleep sack, full bind | + +### Blocked Regions + +`blocked_regions` prevents other items from being equipped on those regions **while your item is worn**. + +**Examples:** +- Hood → `regions: ["HEAD"]`, `blocked_regions: ["HEAD", "EYES", "EARS"]` — covers head, eyes, ears. Mouth is still accessible (for a gag underneath). +- Handcuffs → `regions: ["ARMS"]`, no `blocked_regions` needed — only blocks ARMS by default. +- Straitjacket → `regions: ["ARMS", "HANDS", "TORSO"]`, `blocked_regions: ["ARMS", "HANDS", "TORSO", "FINGERS"]` — also blocks finger accessories. + +If `blocked_regions` is omitted, it defaults to the same as `regions`. + +### Movement Styles + +Items that restrict the player's legs can declare a `movement_style` to change how the player physically moves. This affects both server-side movement (speed, jumping) and client-side animation. + +**Available styles:** + +| Style | Speed | Jump | Animation | Typical Use | +|-------|-------|------|-----------|-------------| +| `waddle` | 0.6× | allowed | Side-to-side sway | Hobble skirt, thigh cuffs | +| `shuffle` | 0.4× | disabled | Tiny dragging steps | Short ankle chain, leg binder | +| `hop` | 0.35× (between hops) | auto-hop | Small bunny hops | Bound feet (rope, tape) | +| `crawl` | 0.2× | disabled | On all fours (petplay) | Hogtie, pet harness | + +**Resolution:** If multiple equipped items have different styles, the most constraining one wins (crawl > hop > shuffle > waddle). + +**Override defaults:** Use `movement_modifier` to fine-tune speed for a specific item: + +```json +{ + "movement_style": "shuffle", + "movement_modifier": { + "speed_multiplier": 0.3, + "jump_disabled": true + } +} +``` + +`movement_modifier` only works when `movement_style` is set. Without a style, the modifier is ignored. + +**For artists:** Each style has idle and walk/move context animations that can be replaced with GLB files (see [Movement Style Contexts](#movement-style-contexts) above). The default animations are basic placeholders — custom GLBs will dramatically improve the feel. + +### Inventory Icons + +By default, data-driven items show a generic placeholder icon in the inventory. To give your item a custom inventory sprite, use the `icon` field. + +**How it works:** The `icon` field points to a **Minecraft item model** (not a raw texture). The mod resolves this model at render time and displays it as the inventory sprite. + +**Step 1: Create a 16x16 PNG texture** + +Place it in your resource pack: +``` +assets//textures/item/my_armbinder_icon.png +``` + +**Step 2: Create a model JSON** + +Create a simple `item/generated` model that references your texture: +``` +assets//models/item/my_armbinder_icon.json +``` + +Contents: +```json +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": ":item/my_armbinder_icon" + } +} +``` + +**Step 3: Reference it in your item/furniture JSON** + +```json +{ + "display_name": "Leather Armbinder", + "icon": ":item/my_armbinder_icon", + ... +} +``` + +The `icon` value is the model's ResourceLocation: `:item/` (without the `.json` extension, matching how Minecraft references models). + +**Shortcut:** You can also reference any existing vanilla or mod item model. For example, `"icon": "minecraft:item/chain"` displays the vanilla chain icon. Useful for testing. + +**If `icon` is omitted:** The item displays the default generic sprite. No crash, no error. + +--- + +## Packaging as a Resource Pack + +### File Structure + +``` +MyItemPack/ + pack.mcmeta + assets/ + mycreator/ + tiedup_items/ ← JSON definitions (scanned at startup + F3+T) + rope_gag.json + iron_cuffs.json + straitjacket.json + models/gltf/ ← GLB mesh files + rope_gag.glb + iron_cuffs.glb + straitjacket.glb + models/item/ ← Icon model JSONs (for inventory sprites) + rope_gag_icon.json + iron_cuffs_icon.json + textures/item/ ← Icon textures (16x16 PNG) + rope_gag_icon.png + iron_cuffs_icon.png + tiedup_contexts/ ← Context GLBs (optional, replaces default postures) + stand_walk.glb + stand_sneak.glb +``` + +### pack.mcmeta + +```json +{ + "pack": { + "pack_format": 15, + "description": "My Custom TiedUp! Items" + } +} +``` + +### Namespace + +Use your own namespace (e.g., `mycreator`) to avoid conflicts with the base mod or other packs. The mod scans all namespaces for `tiedup_items/` and `tiedup_contexts/` directories. + +--- + +## Common Mistakes + +### Skeleton Issues + +| Mistake | Symptom | Fix | +|---------|---------|-----| +| Bone name typo (`RightUpperArm` instead of `rightUpperArm`) | Mesh doesn't follow that bone | Names are **camelCase**, not PascalCase. Check exact spelling. | +| Extra bones in the armature | No visible issue (ignored), larger file | Delete non-standard bones before export | +| Missing `PlayerArmature` root | Mesh renders at wrong position | Rename your armature root to `PlayerArmature` | +| Animating `body` bone without TORSO region | Body keyframes used only if `body` is free (no other item owns it) | Declare TORSO/WAIST region if you always want to control body, or use `Full` animations for free-bone effects | + +### Animation Issues + +| Mistake | Symptom | Fix | +|---------|---------|-----| +| Action not prefixed with `PlayerArmature\|` | Animation not found, falls back to first clip | Rename: `Idle` → `PlayerArmature\|Idle` | +| Wrong case (`idle` instead of `Idle`) | Animation not found | Use exact PascalCase: `Idle`, `SitIdle`, `KneelStruggle` | +| Variant gap (`.1`, `.2`, `.4` — missing `.3`) | Only .1 and .2 are used | Number sequentially with no gaps | +| Animating bones outside your regions | Keyframes silently ignored | Only animate bones in your declared regions | +| Multi-frame Idle | Works but wastes resources | Idle should be a single keyframe at frame 0 | + +### Weight Painting Issues + +| Mistake | Symptom | Fix | +|---------|---------|-----| +| Vertices not weighted to any bone | Part of mesh stays frozen in space | Weight paint everything to at least one bone | +| Weights not normalized | Mesh stretches or compresses oddly | Blender > Weights > Normalize All | +| Weighted to a non-standard bone | That part of mesh stays frozen | Only weight to the 11 standard bones | + +### JSON Issues + +| Mistake | Symptom | Fix | +|---------|---------|-----| +| Wrong model path | Item invisible | Check ResourceLocation format: `namespace:path/to/file.glb` | +| Missing `regions` | Item can't be equipped | Every item needs at least one region | +| `pose_priority: 0` | Other items always override yours | Use at least 1. See priority guide above. | +| `blocked_regions` too broad | Players can't equip combinations you intended | Only block what your item physically covers | + +--- + +## Examples + +### Example 1: Simple Collar (No Animation) + +A collar sits on the neck. It doesn't change the player's pose. + +**Blender:** +- Model a ring mesh around the neck area +- Weight paint to `body` bone (so it follows torso movement) +- Create `PlayerArmature|Idle` with a single keyframe — don't move any bones (identity pose) +- Export GLB + +**JSON:** +```json +{ + "type": "tiedup:bondage_item", + "display_name": "Leather Collar", + "model": "mycreator:models/gltf/leather_collar.glb", + "regions": ["NECK"], + "animation_bones": { + "idle": [] + }, + "pose_priority": 5, + "escape_difficulty": 3, + "lockable": true +} +``` + +The collar renders on the player's neck, follows body movement, and takes the NECK slot. No bones are animated — the player moves normally. + +### Example 2: Handcuffs (Arms Behind Back) + +**Blender:** +- Model cuff meshes on both wrists + a chain between them +- Weight paint cuffs to `rightLowerArm` and `leftLowerArm`, chain to `body` +- Create `PlayerArmature|Idle`: pose both arms behind the back +- Optionally create `PlayerArmature|Struggle`: multi-frame animation of pulling against cuffs +- Export GLB + +**JSON:** +```json +{ + "type": "tiedup:bondage_item", + "display_name": "Iron Handcuffs", + "model": "mycreator:models/gltf/iron_cuffs.glb", + "animation_source": "tiedup:models/gltf/templates/handcuff_anims.glb", + "regions": ["ARMS"], + "animation_bones": { + "idle": ["rightArm", "leftArm"], + "struggle": ["rightArm", "leftArm"] + }, + "pose_priority": 30, + "escape_difficulty": 5, + "lockable": true +} +``` + +Using `animation_source` means the cuffs mesh comes from your GLB but animations come from the official handcuff template. The arms go behind the back, the cuffs mesh follows. The player can still walk, look around, sit, and sneak — only the arms are locked. + +### Example 3: Blindfold (Head Region, Cosmetic Pose) + +**Blender:** +- Model a strip of cloth across the eyes +- Weight paint to `head` bone +- Create `PlayerArmature|Idle`: tilt head down ~10 degrees (subtle "I can't see" pose) +- Export GLB + +**JSON:** +```json +{ + "type": "tiedup:bondage_item", + "display_name": "Silk Blindfold", + "model": "mycreator:models/gltf/silk_blindfold.glb", + "regions": ["EYES"], + "animation_bones": { + "idle": ["head"] + }, + "pose_priority": 10, + "escape_difficulty": 1, + "lockable": false +} +``` + +The blindfold occupies EYES (a sub-region of HEAD). A hood (HEAD) would block it, but a gag (MOUTH) wouldn't. The head tilt is subtle and combines with vanilla head tracking. + +--- + +## Furniture Creation + +> Create interactive furniture (St. Andrew's Cross, pillory, cage, etc.) using a **single GLB file** containing both the furniture model and player seat skeletons. + +### How Furniture Works + +Unlike body items (which are skinned onto the player's skeleton), furniture is a **standalone entity** in the world. It has its own mesh, its own skeleton, and its own animations. Players "sit" on it via the riding system, and the mod forces a pose on the player from the furniture's GLB. + +``` +One GLB file contains everything: + + Furniture_Armature ← The furniture mesh (cross, pillory, cage...) + │ Mesh: wood_frame, chains, locks... + │ Animations: Idle, Occupied, LockClose, Shake... + │ + Player_main ← Player skeleton #1 (positioned on the furniture) + │ Standard 11 bones (same as body items) + │ Animations: main:Idle, main:Struggle, main:Enter... + │ + Player_left (optional) ← Player skeleton #2 (for multi-seat furniture) + │ Same 11 bones + │ Animations: left:Idle, left:Struggle... + │ + Player_right (optional) ← Player skeleton #3 + ... +``` + +The artist sees the **exact result** in Blender — the player posed on the furniture. No guessing offsets in JSON. + +> **Note:** There is currently no ghost preview when placing furniture in-game. The entity spawns immediately on right-click. Use Blender to check positioning. + +### Furniture Skeleton + +The furniture armature can have **any bones you want**. Unlike body items (which must use the standard 11-bone player skeleton), furniture bones are custom. Name them whatever makes sense: `door_hinge`, `chain_left`, `lock_bolt`, etc. The name `Furniture_Armature` used in this guide is a convention — any name works as long as it does NOT start with `Player_`. + +``` +Furniture_Armature ← armature root (required, any name NOT starting with "Player_") + └─ frame ← main body of the furniture + ├─ door_hinge ← animated: opens/closes + ├─ chain_left ← animated: tightens when occupied + ├─ chain_right + └─ lock_bolt ← animated: rotates when locked +``` + +**Rules:** +- The furniture armature name must NOT start with `Player_` (that prefix is reserved for seat skeletons) +- All bones are kept — no filtering (unlike body items which filter through `isKnownBone`) +- Weight paint your furniture mesh to these bones normally + +### Seat Skeletons (Player_*) + +Each seat is a **standard player skeleton** (the same 11 bones from body items) positioned on the furniture. The armature name determines the seat ID. + +``` +Player_main → seat ID: "main" +Player_left → seat ID: "left" +Player_right → seat ID: "right" +Player_grab → seat ID: "grab" (for monsters) +``` + +**How to set up a seat in Blender:** + +1. **Duplicate the standard player armature** (from a body item template or the skeleton reference above) +2. **Rename it** to `Player_{seatId}` (e.g., `Player_main`) +3. **Position it** on the furniture exactly where you want the player to appear +4. **Pose it** in the `Idle` position (arms spread on a cross, bent over a pillory, etc.) +5. The root position + rotation of this armature = where the player sits in-game + +**Important:** +- The seat skeleton uses the EXACT same 11 bone names as body items (`body`, `head`, `leftUpperArm`, etc.) +- You do NOT need a mesh for the seat skeleton — it's data-only (positioning + animation) +- The seat skeleton's world-space position relative to the furniture origin = the player's offset in-game +- Multiple seats = multiple `Player_*` armatures (each positioned differently on the furniture) + +### Furniture Animations + +Furniture uses a **naming convention** to separate furniture animations from player animations. + +#### Furniture Mesh Animations + +Target the `Furniture_Armature`. Blender exports them as `Furniture_Armature|AnimName`. + +| Animation Name | When Played | Required? | +|---------------|------------|-----------| +| `Idle` | Default state, no passengers | Recommended | +| `Occupied` | At least one player is seated | Optional (falls back to Idle) | +| `LockClose` | A seat gets locked (one-shot) | Optional | +| `LockOpen` | A seat gets unlocked (one-shot) | Optional | +| `Shake` | Player is struggling (loops during struggle) | Optional | + +Example in Blender's Action Editor: +``` +Furniture_Armature|Idle ← chains hanging loose +Furniture_Armature|Occupied ← chains pulled taut +Furniture_Armature|Shake ← whole frame vibrates +``` + +#### Player Seat Animations + +Target the `Player_*` armatures. Blender exports them as `Player_main|AnimName`. +The mod resolves them as `{seatId}:{AnimName}`. + +| Animation Name | When Played | Required? | +|---------------|------------|-----------| +| `Idle` | Default seated pose | **Yes** (no fallback) | +| `Struggle` | Player struggling to escape | Optional (stays in Idle) | +| `Enter` | Mount transition (one-shot, 1 second) | Optional (snaps to Idle if absent) | +| `Exit` | Dismount transition (one-shot, 1 second) | Optional (snaps to vanilla if absent) | + +Example in Blender's Action Editor: +``` +Player_main|Idle → resolved as "main:Idle" ← arms spread, legs apart +Player_main|Struggle → resolved as "main:Struggle" ← pulling against restraints +Player_left|Idle → resolved as "left:Idle" ← head and arms through pillory +Player_right|Idle → resolved as "right:Idle" ← same pose, other side +``` + +**Key difference from body items:** Furniture player animations control **ALL 11 bones**, not just region-owned bones. The furniture overrides the player's entire pose for the blocked regions, and the remaining regions still show body item effects (gag, blindfold, etc.). + +### The JSON Definition + +Furniture JSON goes in `data//tiedup_furniture/`. Unlike body items, furniture definitions are **server-authoritative** and synced to clients automatically. + +#### Full Format + +```json +{ + "id": "mynamespace:my_cross", + "display_name": "Custom Cross", + "translation_key": "furniture.mynamespace.my_cross", + + "model": "mynamespace:models/gltf/furniture/my_cross.glb", + "icon": "mynamespace:item/my_cross_icon", + + "tint_channels": { + "tintable_0": "#8B4513", + "tintable_1": "#1A1A1A" + }, + "supports_color": true, + + "hitbox": { "width": 1.2, "height": 2.4 }, + + "placement": { + "snap_to_wall": true, + "floor_only": true + }, + + "lockable": true, + "break_resistance": 100, + "drop_on_break": true, + + "seats": [ + { + "id": "main", + "armature": "Player_main", + "blocked_regions": ["ARMS", "HANDS", "LEGS", "FEET"], + "lockable": true, + "locked_difficulty": 150, + "item_difficulty_bonus": true + } + ], + + "category": "restraint" +} +``` + +#### JSON Field Guide + +| Field | Required? | Description | +|-------|-----------|-------------| +| `id` | Yes | Unique ID, e.g., `mynamespace:wooden_cross` | +| `display_name` | Yes | Fallback name if no translation key | +| `translation_key` | No | i18n key for localized name | +| `model` | Yes | Path to GLB file in assets | +| `icon` | No | Inventory sprite model (same system as body items — see [Inventory Icons](#inventory-icons)) | +| `tint_channels` | No | Default tint colors (hex `#RRGGBB`) per channel | +| `supports_color` | No | **Planned** — player recoloring via dye (default: false) | +| `hitbox.width` | No | Entity collision width (0.1–5.0, default: 1.0) | +| `hitbox.height` | No | Entity collision height (0.1–5.0, default: 1.0) | +| `placement.snap_to_wall` | No | Align to nearest wall on placement (default: false) | +| `placement.floor_only` | No | Can only be placed on solid ground (default: true) | +| `lockable` | No | Can seats be locked with a key? (default: false) | +| `break_resistance` | No | Cumulative damage to break (1–10000, default: 100) | +| `drop_on_break` | No | Drop placer item when broken? (default: true) | +| `seats` | Yes | 1–8 seat definitions (see below) | +| `feedback.mount_sound` | No | Sound when a player is force-mounted | +| `feedback.lock_sound` | No | Sound when a seat is locked | +| `feedback.unlock_sound` | No | Sound when a seat is unlocked | +| `feedback.struggle_loop_sound` | No | Sound played when struggle starts | +| `feedback.escape_sound` | No | Sound on successful escape (default: chain break) | +| `feedback.denied_sound` | No | Sound when action is denied (locked dismount, no seat) | +| `category` | No | Creative tab grouping (default: "furniture") | + +#### Seat Definition Fields + +| Field | Required? | Description | +|-------|-----------|-------------| +| `id` | Yes | Seat identifier (must match `Player_{id}` armature name, no `:` allowed) | +| `armature` | Yes | GLB armature name (e.g., `Player_main`) | +| `blocked_regions` | No | Body regions the furniture controls (default: empty — no blocking) | +| `lockable` | No | Can this seat be locked? (inherits from top-level `lockable`) | +| `locked_difficulty` | Yes if lockable | Struggle difficulty when locked (1–10000) | +| `item_difficulty_bonus` | No | Body items on free regions add to escape difficulty? (default: false) | + +#### Blocked Regions Explained + +When a player sits in a seat with `blocked_regions: ["ARMS", "HANDS"]`: +- **Animation:** The furniture's player pose controls arm + hand bones. Body items on ARMS/HANDS are ignored. +- **Rendering:** Body items on ARMS/HANDS are hidden (the furniture pose replaces them visually). +- **Gameplay:** The master cannot equip/unequip items on ARMS/HANDS while the player is seated. +- **Other regions** (HEAD, MOUTH, NECK, etc.) work normally — items render, can be changed, and contribute to escape difficulty if `item_difficulty_bonus: true`. + +> **Note:** The struggle minigame for escaping locked furniture seats uses the same continuous struggle system as body items. The player holds directional keys to drain resistance. The total difficulty = `locked_difficulty` + bonus from equipped body items on non-blocked regions (if `item_difficulty_bonus: true`), capped at 600. + +### Tint Channels (Same as Body Items) + +Furniture supports the exact same tint channel system as body items. See [Tint Channels](#tint-channels-dynamic-colors) above — everything applies identically. + +Name your Blender materials `tintable_0`, `tintable_1`, etc., use grayscale textures, and define defaults in the JSON `tint_channels` field. + +### Exporting Furniture from Blender + +Same export settings as body items, with one extra consideration: + +1. **Select ALL armatures** — Furniture_Armature AND all Player_* armatures +2. **File → Export → glTF 2.0 (.glb)** +3. Settings: + - Format: glTF Binary (.glb) + - Include: Selected Objects + - Mesh: Apply Modifiers + - Animation: Export all actions (NLA strips or all actions) + - **Important:** Do NOT merge armatures — each must remain separate + +### Packaging as a Resource/Data Pack + +Furniture needs files in **two** locations: + +``` +my_resource_pack/ +├── assets/mynamespace/ +│ ├── models/gltf/furniture/ +│ │ └── my_cross.glb ← The GLB model (client resource) +│ ├── models/item/ +│ │ └── my_cross_icon.json ← Icon model (inventory sprite) +│ └── textures/item/ +│ └── my_cross_icon.png ← Icon texture (16x16) +│ +└── data/mynamespace/ + └── tiedup_furniture/ + └── my_cross.json ← The JSON definition (server data) +``` + +The GLB goes in `assets/` (it's a client resource for rendering). The JSON goes in `data/` (it's server-authoritative gameplay data, synced to clients via packet). + +### Common Furniture Mistakes + +| Mistake | Symptom | Fix | +|---------|---------|-----| +| Seat armature named `Main` instead of `Player_main` | No seat detected, furniture is decoration only | Prefix MUST be `Player_` | +| Seat ID contains `:` (e.g., `Player_seat:left`) | JSON rejected by parser | Use only alphanumeric + underscore in seat IDs | +| Player skeleton uses wrong bone names | Player not posed correctly on furniture | Use the exact 11 standard bone names (camelCase) | +| Armatures merged into one on export | Parser can't separate furniture from seats | Export with separate armatures, don't merge | +| No `Idle` animation for a seat | Player has no pose on furniture | Every seat MUST have at least `{armature}|Idle` | +| Furniture animation named `Player_main|Idle` | Parsed as a seat animation, not furniture | Furniture anims go on `Furniture_Armature`, not `Player_*` | +| More than 8 seats | JSON rejected by parser | Maximum 8 seats per furniture piece | +| `blocked_regions` references unknown region | JSON rejected by parser | Use exact names from the Region Table above | + +### Furniture Examples + +#### Example 1: Simple Chair (No Restraint) + +A decorative chair anyone can sit on. No locking, no blocked regions. + +**Blender:** +- Model a chair mesh, weight paint to a single `frame` bone +- Add `Player_main` skeleton sitting on the chair +- Create `Player_main|Idle` with the player seated (legs bent, arms on armrests) +- Create `Furniture_Armature|Idle` (static, single keyframe) + +**JSON:** +```json +{ + "id": "mycreator:wooden_chair", + "display_name": "Wooden Chair", + "model": "mycreator:models/gltf/furniture/wooden_chair.glb", + "hitbox": { "width": 0.8, "height": 1.0 }, + "seats": [ + { + "id": "main", + "armature": "Player_main", + "blocked_regions": [] + } + ] +} +``` + +No locking, no blocked regions. The player sits freely and can stand up anytime. + +#### Example 2: St. Andrew's Cross (Single Seat) + +**Blender:** +- Model the X-frame with chains at wrist and ankle positions +- Add bones for animated parts: `chain_left`, `chain_right`, `lock_mechanism` +- Add `Player_main` skeleton with arms and legs spread in an X pose +- Create animations: + - `Furniture_Armature|Idle` — chains hanging loose + - `Furniture_Armature|Occupied` — chains pulled taut + - `Furniture_Armature|Shake` — frame vibrating (for struggle) + - `Player_main|Idle` — arms spread, legs apart, flush against the cross + - `Player_main|Struggle` — pulling against restraints, body twisting + +**JSON:** +```json +{ + "id": "mycreator:saint_andrews_cross", + "display_name": "St. Andrew's Cross", + "model": "mycreator:models/gltf/furniture/cross.glb", + "tint_channels": { "tintable_0": "#8B4513" }, + "supports_color": true, + "hitbox": { "width": 1.2, "height": 2.4 }, + "placement": { "snap_to_wall": true }, + "lockable": true, + "break_resistance": 150, + "seats": [ + { + "id": "main", + "armature": "Player_main", + "blocked_regions": ["ARMS", "HANDS", "LEGS", "FEET"], + "lockable": true, + "locked_difficulty": 150, + "item_difficulty_bonus": true + } + ], + "category": "restraint" +} +``` + +The player is locked with arms and legs controlled by the cross. A master can still equip a gag (MOUTH), blindfold (EYES), or collar (NECK) on the mounted player. The gag adds to escape difficulty because `item_difficulty_bonus: true`. + +#### Example 3: Double Pillory (Two Seats) + +**Blender:** +- Model a long wooden pillory with two holes +- Add `Player_left` and `Player_right` skeletons, each bent forward with head and arms through the pillory holes +- Position them side by side on the furniture + +**JSON:** +```json +{ + "id": "mycreator:double_pillory", + "display_name": "Double Pillory", + "model": "mycreator:models/gltf/furniture/pillory.glb", + "hitbox": { "width": 2.0, "height": 1.5 }, + "lockable": true, + "seats": [ + { + "id": "left", + "armature": "Player_left", + "blocked_regions": ["ARMS", "HANDS", "HEAD"], + "lockable": true, + "locked_difficulty": 120 + }, + { + "id": "right", + "armature": "Player_right", + "blocked_regions": ["ARMS", "HANDS", "HEAD"], + "lockable": true, + "locked_difficulty": 120 + } + ], + "category": "restraint" +} +``` + +Two players can be locked side by side. The mod picks the seat nearest to where you're looking when you sit down. + +### Monster Seat System (Planned) + +The furniture system is built on a universal `ISeatProvider` interface that is **not limited to static furniture**. Any living entity (monster, NPC) can implement the same interface to hold players in constrained poses using the same mechanics: blocked regions, forced animations, lock/escape. + +**Example use case:** A tentacle monster that grabs a player on attack — the player "rides" the monster, gets a forced pose (arms restrained), and must struggle to escape. The monster's GLB would contain a `Player_grab` armature with `Player_grab|Idle` and `Player_grab|Struggle` animations, following the exact same convention as furniture seats. + +**What this means for artists:** If you create a monster model, you can include `Player_*` armatures in the GLB using the same workflow as furniture. The seat animations, blocked regions, and escape mechanics will work identically. + +**Current status:** The `ISeatProvider` interface and all downstream systems (animation, rendering, packets, escape) are implemented and ready. No monster entity or AI has been created yet — this requires its own design phase (AI goals, behaviors, spawn conditions, etc.). + +--- + +## Quick Reference Cards + +### Body Items + +``` +REQUIRED: + ✓ Skeleton: PlayerArmature with 11 named bones (camelCase, exact) + ✓ At least one animation: PlayerArmature|Idle + ✓ Weight painting to standard bones + ✓ JSON in assets//tiedup_items/ with type, display_name, model, regions + +NEVER DO: + ✗ Animate PlayerArmature (armature root, not a bone) + ✗ Use wrong case for bone names or animation names + ✗ Leave vertices unweighted + ✗ Use pose_priority 0 + +GOOD TO KNOW: + → Only Idle is required. Everything else has fallbacks. + → Templates let you skip animation entirely. + → Free bones (not owned by any item) CAN be animated by your GLB. + → Bones owned by another equipped item are always ignored. + → The mod handles sitting, sneaking, walking — you don't have to. + → Context GLBs in tiedup_contexts/ replace default postures. + → Slim model is optional. Steve mesh works on Alex (minor clipping). + → Textures bake into the GLB. No separate file needed. +``` + +### Furniture + +``` +REQUIRED: + ✓ Furniture_Armature (any name NOT starting with "Player_") + ✓ At least one Player_{seatId} armature with 11 standard player bones + ✓ At least one animation per seat: Player_{seatId}|Idle + ✓ GLB in assets//models/gltf/furniture/ + ✓ JSON in data//tiedup_furniture/ with id, display_name, model, seats + +NEVER DO: + ✗ Name furniture armature starting with "Player_" + ✗ Use ":" in seat IDs + ✗ Merge armatures on export (each must stay separate) + ✗ Use wrong bone names in seat skeletons + ✗ More than 8 seats per furniture + +GOOD TO KNOW: + → Furniture bones can be anything (not limited to player skeleton) + → Seat position = Player_* armature position in Blender (no JSON offset) + → Player animations on blocked regions override body items + → Body items on non-blocked regions still render normally + → Furniture mesh animations (Idle, Occupied, Shake) are optional + → Tint channels work the same as body items + → JSON is server-authoritative, synced to clients automatically +``` diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..dcf4191 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,62 @@ +# Sets default memory used for gradle commands. Can be overridden by user or command line properties. +# This is required to provide enough memory for the Minecraft decompilation process. +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false + +# Force Gradle to use Java 17 (required for Minecraft 1.20.1) +org.gradle.java.home=/usr/lib/jvm/java-17-openjdk + + +## Environment Properties + +# The Minecraft version must agree with the Forge version to get a valid artifact +minecraft_version=1.20.1 +# The Minecraft version range can use any release version of Minecraft as bounds. +# Snapshots, pre-releases, and release candidates are not guaranteed to sort properly +# as they do not follow standard versioning conventions. +minecraft_version_range=[1.20.1,1.21) +# The Forge version must agree with the Minecraft version to get a valid artifact +forge_version=47.4.10 +# The Forge version range can use any version of Forge as bounds or match the loader version range +forge_version_range=[47,) +# The loader version range can only use the major version of Forge/FML as bounds +loader_version_range=[47,) +# The mapping channel to use for mappings. +# The default set of supported mapping channels are ["official", "snapshot", "snapshot_nodoc", "stable", "stable_nodoc"]. +# Additional mapping channels can be registered through the "channelProviders" extension in a Gradle plugin. +# +# | Channel | Version | | +# |-----------|----------------------|--------------------------------------------------------------------------------| +# | official | MCVersion | Official field/method names from Mojang mapping files | +# | parchment | YYYY.MM.DD-MCVersion | Open community-sourced parameter names and javadocs layered on top of official | +# +# You must be aware of the Mojang license when using the 'official' or 'parchment' mappings. +# See more information here: https://github.com/MinecraftForge/MCPConfig/blob/master/Mojang.md +# +# Parchment is an unofficial project maintained by ParchmentMC, separate from Minecraft Forge. +# Additional setup is needed to use their mappings, see https://parchmentmc.org/docs/getting-started +mapping_channel=official +# The mapping version to query from the mapping channel. +# This must match the format required by the mapping channel. +mapping_version=1.20.1 + + +## Mod Properties + +# The unique mod identifier for the mod. Must be lowercase in English locale. Must fit the regex [a-z][a-z0-9_]{1,63} +# Must match the String constant located in the main mod class annotated with @Mod. +mod_id=tiedup +# The human-readable display name for the mod. +mod_name=TiedUp +# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. +mod_license=GPL-3.0 WITH Commons-Clause (No Sale/Paywall) +# The mod version. See https://semver.org/ +mod_version=0.5.6-ALPHA +# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. +# This should match the base package used for the mod sources. +# See https://maven.apache.org/guides/mini/guide-naming-conventions.html +mod_group_id=com.tiedup.remake +# The authors of the mod. This is a simple text string that is used for display purposes in the mod list. +mod_authors=Unknown +# The description of the mod. This is a simple multiline text string that is used for display purposes in the mod list. +mod_description=Community remake of the TiedUp! mod for Minecraft 1.20.1.\nAdds restraint and roleplay mechanics.\n\nOriginal mod by Yuti & Marl Velius (1.12.2)\nThis is an independent remake, not affiliated with original developers. \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..c1962a7 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2617362 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..aeb74cb --- /dev/null +++ b/gradlew @@ -0,0 +1,245 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..6689b85 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/libs/architectury-9.2.14-forge.jar b/libs/architectury-9.2.14-forge.jar new file mode 100644 index 0000000..d02a8ba Binary files /dev/null and b/libs/architectury-9.2.14-forge.jar differ diff --git a/libs/bendy-lib-forge-4.0.0.jar b/libs/bendy-lib-forge-4.0.0.jar new file mode 100644 index 0000000..16bdcf2 Binary files /dev/null and b/libs/bendy-lib-forge-4.0.0.jar differ diff --git a/libs/minecraft-comes-alive-7.6.13.jar b/libs/minecraft-comes-alive-7.6.13.jar new file mode 100644 index 0000000..26012af Binary files /dev/null and b/libs/minecraft-comes-alive-7.6.13.jar differ diff --git a/libs/player-animation-lib-forge-1.0.2-rc1+1.20.jar b/libs/player-animation-lib-forge-1.0.2-rc1+1.20.jar new file mode 100644 index 0000000..d9a8935 Binary files /dev/null and b/libs/player-animation-lib-forge-1.0.2-rc1+1.20.jar differ diff --git a/libs/wildfire-gender-mod-3.1.jar b/libs/wildfire-gender-mod-3.1.jar new file mode 100644 index 0000000..80a3343 Binary files /dev/null and b/libs/wildfire-gender-mod-3.1.jar differ diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..758df8b --- /dev/null +++ b/settings.gradle @@ -0,0 +1,13 @@ +pluginManagement { + repositories { + gradlePluginPortal() + maven { + name = 'MinecraftForge' + url = 'https://maven.minecraftforge.net/' + } + } +} + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0' +} \ No newline at end of file diff --git a/src/main/java/com/tiedup/remake/blocks/BlockCellCore.java b/src/main/java/com/tiedup/remake/blocks/BlockCellCore.java new file mode 100644 index 0000000..cd5a6d9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/blocks/BlockCellCore.java @@ -0,0 +1,288 @@ +package com.tiedup.remake.blocks; + +import com.tiedup.remake.blocks.entity.CellCoreBlockEntity; +import com.tiedup.remake.cells.*; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.cell.PacketOpenCoreMenu; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.SoundType; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.material.MapColor; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.Shapes; +import net.minecraft.world.phys.shapes.VoxelShape; +import org.jetbrains.annotations.Nullable; + +/** + * Cell Core block - the anchor for a Cell System V2 cell. + * + * Placed into a wall of an enclosed room. On placement, runs flood-fill to + * detect the room boundaries and registers a new cell. On removal, destroys + * the cell. + * + * Obsidian-tier hardness to prevent easy breaking by prisoners. + */ +public class BlockCellCore extends BaseEntityBlock { + + public BlockCellCore() { + super( + BlockBehaviour.Properties.of() + .mapColor(MapColor.STONE) + .strength(50.0f, 1200.0f) + .requiresCorrectToolForDrops() + .sound(SoundType.STONE) + .noOcclusion() + .dynamicShape() + ); + } + + @Nullable + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new CellCoreBlockEntity(pos, state); + } + + @Override + public RenderShape getRenderShape(BlockState state) { + return RenderShape.MODEL; + } + + // ==================== DYNAMIC SHAPE (disguise-aware) ==================== + + @SuppressWarnings("deprecation") + @Override + public VoxelShape getShape( + BlockState state, + BlockGetter level, + BlockPos pos, + CollisionContext context + ) { + if (level.getBlockEntity(pos) instanceof CellCoreBlockEntity core) { + BlockState disguise = core.getDisguiseState(); + if (disguise == null) disguise = core.resolveDisguise(); + if (disguise != null) { + return disguise.getShape(level, pos, context); + } + } + return Shapes.block(); + } + + @SuppressWarnings("deprecation") + @Override + public VoxelShape getCollisionShape( + BlockState state, + BlockGetter level, + BlockPos pos, + CollisionContext context + ) { + if (level.getBlockEntity(pos) instanceof CellCoreBlockEntity core) { + BlockState disguise = core.getDisguiseState(); + if (disguise == null) disguise = core.resolveDisguise(); + if (disguise != null) { + return disguise.getCollisionShape(level, pos, context); + } + } + return Shapes.block(); + } + + /** + * Right-click handler: open the Cell Core menu for the cell owner. + */ + @SuppressWarnings("deprecation") + @Override + public InteractionResult use( + BlockState state, + Level level, + BlockPos pos, + Player player, + InteractionHand hand, + BlockHitResult hit + ) { + if (level.isClientSide) { + return InteractionResult.SUCCESS; + } + + if (!(player instanceof ServerPlayer serverPlayer)) { + return InteractionResult.PASS; + } + + BlockEntity be = level.getBlockEntity(pos); + if ( + !(be instanceof CellCoreBlockEntity core) || + core.getCellId() == null + ) { + return InteractionResult.PASS; + } + + CellRegistryV2 registry = CellRegistryV2.get((ServerLevel) level); + CellDataV2 cell = registry.getCell(core.getCellId()); + if (cell == null) { + return InteractionResult.PASS; + } + + // Check ownership (owner, camp cell, or OP level 2) + if (!cell.canPlayerManage(player.getUUID(), player.hasPermissions(2))) { + player.displayClientMessage( + Component.translatable("msg.tiedup.cell_core.not_owner"), + true + ); + return InteractionResult.CONSUME; + } + + // Send packet with all cell stats + ModNetwork.sendToPlayer( + new PacketOpenCoreMenu( + pos, + cell.getId(), + cell.getName() != null ? cell.getName() : "", + cell.getState().getSerializedName(), + cell.getInteriorBlocks().size(), + cell.getWallBlocks().size(), + cell.getBreachedPositions().size(), + cell.getPrisonerCount(), + cell.getBeds().size(), + cell.getDoors().size(), + cell.getAnchors().size(), + core.getSpawnPoint() != null, + core.getDeliveryPoint() != null, + core.getDisguiseState() != null + ), + serverPlayer + ); + + return InteractionResult.CONSUME; + } + + /** + * On placement: run flood-fill to detect the room, create a cell if successful. + * If the room cannot be detected, break the block back and show an error. + */ + @Override + public void setPlacedBy( + Level level, + BlockPos pos, + BlockState state, + @Nullable LivingEntity placer, + ItemStack stack + ) { + super.setPlacedBy(level, pos, state, placer, stack); + + if ( + level instanceof ServerLevel serverLevel && + placer instanceof Player player + ) { + BlockEntity be = level.getBlockEntity(pos); + if (!(be instanceof CellCoreBlockEntity core)) return; + + FloodFillResult result = FloodFillAlgorithm.tryFill(level, pos); + if (result.isSuccess()) { + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + CellDataV2 cell = registry.createCell( + pos, + result, + player.getUUID() + ); + + core.setCellId(cell.getId()); + core.setInteriorFace(result.getInteriorFace()); + + player.displayClientMessage( + Component.translatable( + "msg.tiedup.cell_core.created", + result.getInterior().size(), + result.getWalls().size() + ), + true + ); + } else { + // Failed — break the block back (can't form a cell here) + level.destroyBlock(pos, true); + player.displayClientMessage( + Component.translatable(result.getErrorKey()), + true + ); + } + } + } + + /** + * On removal: destroy the cell in the registry. + */ + @SuppressWarnings("deprecation") + @Override + public void onRemove( + BlockState state, + Level level, + BlockPos pos, + BlockState newState, + boolean movedByPiston + ) { + if (!state.is(newState.getBlock())) { + if (level instanceof ServerLevel serverLevel) { + BlockEntity be = level.getBlockEntity(pos); + if ( + be instanceof CellCoreBlockEntity core && + core.getCellId() != null + ) { + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + CellDataV2 cell = registry.getCell(core.getCellId()); + if (cell != null) { + cell.setState(CellState.COMPROMISED); + + // Free all prisoners + for (UUID prisonerId : cell.getPrisonerIds()) { + registry.releasePrisoner( + cell.getId(), + prisonerId, + serverLevel.getServer() + ); + } + + // Notify owner + if (cell.getOwnerId() != null) { + ServerPlayer owner = serverLevel + .getServer() + .getPlayerList() + .getPlayer(cell.getOwnerId()); + if (owner != null) { + String cellName = + cell.getName() != null + ? cell.getName() + : "Cell " + + cell + .getId() + .toString() + .substring(0, 8); + SystemMessageManager.sendToPlayer( + owner, + SystemMessageManager.MessageCategory.WARNING, + cellName + " has been destroyed!" + ); + } + } + + registry.removeCell(cell.getId()); + } + } + } + } + super.onRemove(state, level, pos, newState, movedByPiston); + } +} diff --git a/src/main/java/com/tiedup/remake/blocks/BlockCellDoor.java b/src/main/java/com/tiedup/remake/blocks/BlockCellDoor.java new file mode 100644 index 0000000..66ea23a --- /dev/null +++ b/src/main/java/com/tiedup/remake/blocks/BlockCellDoor.java @@ -0,0 +1,34 @@ +package com.tiedup.remake.blocks; + +import net.minecraft.world.level.block.DoorBlock; +import net.minecraft.world.level.block.SoundType; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.level.block.state.properties.BlockSetType; +import net.minecraft.world.level.material.MapColor; + +/** + * Cell Door Block - Iron-like door that cannot be opened by hand. + * + * Phase 16: Blocks + * + * Features: + * - Cannot be opened by clicking (requires redstone) + * - Uses iron door behavior + * - Sturdy construction + * + * Based on original BlockKidnapDoorBase from 1.12.2 + */ +public class BlockCellDoor extends DoorBlock { + + public BlockCellDoor() { + super( + BlockBehaviour.Properties.of() + .mapColor(MapColor.METAL) + .strength(5.0f, 45.0f) + .sound(SoundType.METAL) + .requiresCorrectToolForDrops() + .noOcclusion(), + BlockSetType.IRON // J'ai remis moi même + ); + } +} diff --git a/src/main/java/com/tiedup/remake/blocks/BlockIronBarDoor.java b/src/main/java/com/tiedup/remake/blocks/BlockIronBarDoor.java new file mode 100644 index 0000000..4a409a1 --- /dev/null +++ b/src/main/java/com/tiedup/remake/blocks/BlockIronBarDoor.java @@ -0,0 +1,346 @@ +package com.tiedup.remake.blocks; + +import com.tiedup.remake.blocks.entity.IronBarDoorBlockEntity; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.items.ItemCellKey; +import com.tiedup.remake.items.ItemKey; +import com.tiedup.remake.items.ItemMasterKey; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LevelAccessor; +import net.minecraft.world.level.block.*; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.*; +import net.minecraft.world.level.material.MapColor; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.Shapes; +import net.minecraft.world.phys.shapes.VoxelShape; +import org.jetbrains.annotations.Nullable; + +/** + * Iron Bar Door - A door made of iron bars that can be locked. + * + * Phase: Kidnapper Revamp - Cell System + * + * Features: + * - Lockable with ItemKey (stores key UUID) + * - Can be unlocked with matching key, ItemCellKey, or ItemMasterKey + * - When locked, cannot be opened by redstone or by hand + * - Visually connects to iron bars (like vanilla iron bars) + * - Uses DoorBlock mechanics for open/close state + */ +public class BlockIronBarDoor extends DoorBlock implements EntityBlock { + + // VoxelShapes for different orientations - centered like iron bars (7-9 = 2 pixels thick) + protected static final VoxelShape SOUTH_AABB = Block.box( + 0.0, + 0.0, + 7.0, + 16.0, + 16.0, + 9.0 + ); + protected static final VoxelShape NORTH_AABB = Block.box( + 0.0, + 0.0, + 7.0, + 16.0, + 16.0, + 9.0 + ); + protected static final VoxelShape WEST_AABB = Block.box( + 7.0, + 0.0, + 0.0, + 9.0, + 16.0, + 16.0 + ); + protected static final VoxelShape EAST_AABB = Block.box( + 7.0, + 0.0, + 0.0, + 9.0, + 16.0, + 16.0 + ); + + public BlockIronBarDoor() { + super( + BlockBehaviour.Properties.of() + .mapColor(MapColor.METAL) + .strength(5.0f, 45.0f) + .sound(SoundType.METAL) + .requiresCorrectToolForDrops() + .noOcclusion(), + BlockSetType.IRON + ); + } + + // ==================== SHAPE ==================== + + @Override + public VoxelShape getShape( + BlockState state, + BlockGetter level, + BlockPos pos, + CollisionContext context + ) { + Direction facing = state.getValue(FACING); + boolean open = state.getValue(OPEN); + boolean hingeRight = state.getValue(HINGE) == DoorHingeSide.RIGHT; + + Direction effectiveDirection; + if (open) { + effectiveDirection = hingeRight + ? facing.getCounterClockWise() + : facing.getClockWise(); + } else { + effectiveDirection = facing; + } + + return switch (effectiveDirection) { + case NORTH -> NORTH_AABB; + case SOUTH -> SOUTH_AABB; + case WEST -> WEST_AABB; + case EAST -> EAST_AABB; + default -> SOUTH_AABB; + }; + } + + // ==================== INTERACTION ==================== + + @Override + public InteractionResult use( + BlockState state, + Level level, + BlockPos pos, + Player player, + InteractionHand hand, + BlockHitResult hit + ) { + // Get the BlockEntity + BlockEntity be = level.getBlockEntity( + getBlockEntityPos(state, pos, level) + ); + if (!(be instanceof IronBarDoorBlockEntity doorEntity)) { + return InteractionResult.PASS; + } + + ItemStack heldItem = player.getItemInHand(hand); + + // Handle key interactions + if (heldItem.getItem() instanceof ItemMasterKey) { + // Master key can always unlock + if (doorEntity.isLocked()) { + if (!level.isClientSide) { + doorEntity.setLocked(false); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "Door unlocked with master key" + ); + } + return InteractionResult.sidedSuccess(level.isClientSide); + } + } else if (heldItem.getItem() instanceof ItemCellKey) { + // Cell key can unlock any iron bar door + if (doorEntity.isLocked()) { + if (!level.isClientSide) { + doorEntity.setLocked(false); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "Door unlocked with cell key" + ); + } + return InteractionResult.sidedSuccess(level.isClientSide); + } + } else if (heldItem.getItem() instanceof ItemKey key) { + UUID keyUUID = key.getKeyUUID(heldItem); + + if (doorEntity.isLocked()) { + // Try to unlock + if (doorEntity.matchesKey(keyUUID)) { + if (!level.isClientSide) { + doorEntity.setLocked(false); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "Door unlocked" + ); + } + return InteractionResult.sidedSuccess(level.isClientSide); + } else { + if (!level.isClientSide) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "This key doesn't fit this lock" + ); + } + return InteractionResult.FAIL; + } + } else { + // Lock the door with this key + if (!level.isClientSide) { + doorEntity.setLockedByKeyUUID(keyUUID); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "Door locked" + ); + } + return InteractionResult.sidedSuccess(level.isClientSide); + } + } + + // If locked and no valid key, deny access + if (doorEntity.isLocked()) { + if (!level.isClientSide) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "This door is locked" + ); + } + return InteractionResult.FAIL; + } + + // Iron doors don't open by hand normally, but we allow it if unlocked + // Toggle the door state + if (!level.isClientSide) { + boolean newOpen = !state.getValue(OPEN); + setOpen(player, level, state, pos, newOpen); + } + return InteractionResult.sidedSuccess(level.isClientSide); + } + + /** + * Get the BlockEntity position (handles double-height doors). + */ + private BlockPos getBlockEntityPos( + BlockState state, + BlockPos pos, + Level level + ) { + // LOW FIX: Check if HALF property exists before accessing (WorldEdit/SetBlock safety) + if (!state.hasProperty(HALF)) { + // Invalid state - assume lower half + return pos; + } + + // BlockEntity is always on the lower half + if (state.getValue(HALF) == DoubleBlockHalf.UPPER) { + return pos.below(); + } + return pos; + } + + // ==================== REDSTONE ==================== + + @Override + public void neighborChanged( + BlockState state, + Level level, + BlockPos pos, + Block neighborBlock, + BlockPos neighborPos, + boolean movedByPiston + ) { + // Check if locked before allowing redstone control + BlockEntity be = level.getBlockEntity( + getBlockEntityPos(state, pos, level) + ); + if ( + be instanceof IronBarDoorBlockEntity doorEntity && + doorEntity.isLocked() + ) { + // Door is locked, ignore redstone + return; + } + + // Allow normal redstone behavior if unlocked + super.neighborChanged( + state, + level, + pos, + neighborBlock, + neighborPos, + movedByPiston + ); + } + + // ==================== BLOCK ENTITY ==================== + + @Nullable + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + // Only create block entity for the lower half + if (state.getValue(HALF) == DoubleBlockHalf.LOWER) { + return new IronBarDoorBlockEntity(pos, state); + } + return null; + } + + // ==================== PLACEMENT ==================== + + @Override + public void setPlacedBy( + Level level, + BlockPos pos, + BlockState state, + @Nullable net.minecraft.world.entity.LivingEntity placer, + ItemStack stack + ) { + super.setPlacedBy(level, pos, state, placer, stack); + // BlockEntity is automatically created for lower half + } + + @Override + public void onRemove( + BlockState state, + Level level, + BlockPos pos, + BlockState newState, + boolean movedByPiston + ) { + if (!state.is(newState.getBlock())) { + // Block entity is removed automatically + } + super.onRemove(state, level, pos, newState, movedByPiston); + } + + // ==================== VISUAL ==================== + + @Override + public float getShadeBrightness( + BlockState state, + BlockGetter level, + BlockPos pos + ) { + // Allow some light through like iron bars + return 1.0f; + } + + @Override + public boolean propagatesSkylightDown( + BlockState state, + BlockGetter level, + BlockPos pos + ) { + return true; + } +} diff --git a/src/main/java/com/tiedup/remake/blocks/BlockKidnapBomb.java b/src/main/java/com/tiedup/remake/blocks/BlockKidnapBomb.java new file mode 100644 index 0000000..1940841 --- /dev/null +++ b/src/main/java/com/tiedup/remake/blocks/BlockKidnapBomb.java @@ -0,0 +1,305 @@ +package com.tiedup.remake.blocks; + +import com.tiedup.remake.blocks.entity.KidnapBombBlockEntity; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityKidnapBomb; +import com.tiedup.remake.util.BondageItemLoaderUtility; +import java.util.List; +import javax.annotation.Nullable; +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.SoundType; +import net.minecraft.world.level.block.TntBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.gameevent.GameEvent; +import net.minecraft.world.level.storage.loot.LootParams; +import net.minecraft.world.level.storage.loot.parameters.LootContextParams; +import net.minecraft.world.phys.BlockHitResult; + +/** + * Kidnap Bomb Block - TNT that applies bondage on explosion. + * + * Phase 16: Blocks + * + * Features: + * - TNT-like block that can be ignited + * - Stores bondage items via BlockEntity + * - On explosion, applies items to entities in radius (no block damage) + * - Can be loaded by right-clicking with bondage items + * + * Based on original BlockKidnapBomb from 1.12.2 + */ +public class BlockKidnapBomb + extends TntBlock + implements EntityBlock, ICanBeLoaded +{ + + public BlockKidnapBomb() { + super( + BlockBehaviour.Properties.of() + .strength(0.0f) + .sound(SoundType.GRASS) + .ignitedByLava() + ); + } + + // ======================================== + // BLOCK ENTITY + // ======================================== + + @Nullable + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new KidnapBombBlockEntity(pos, state); + } + + @Nullable + public KidnapBombBlockEntity getBombEntity( + BlockGetter level, + BlockPos pos + ) { + BlockEntity be = level.getBlockEntity(pos); + return be instanceof KidnapBombBlockEntity + ? (KidnapBombBlockEntity) be + : null; + } + + // ======================================== + // EXPLOSION HANDLING + // ======================================== + + @Override + public void onCaughtFire( + BlockState state, + Level level, + BlockPos pos, + @Nullable net.minecraft.core.Direction face, + @Nullable LivingEntity igniter + ) { + if (!level.isClientSide) { + KidnapBombBlockEntity bombTile = getBombEntity(level, pos); + explode(level, pos, bombTile, igniter); + } + } + + /** + * Spawn the primed kidnap bomb entity. + */ + public void explode( + Level level, + BlockPos pos, + @Nullable KidnapBombBlockEntity bombTile, + @Nullable LivingEntity igniter + ) { + if (!level.isClientSide) { + EntityKidnapBomb entity = new EntityKidnapBomb( + level, + pos.getX() + 0.5, + pos.getY(), + pos.getZ() + 0.5, + igniter, + bombTile + ); + level.addFreshEntity(entity); + level.playSound( + null, + entity.getX(), + entity.getY(), + entity.getZ(), + SoundEvents.TNT_PRIMED, + SoundSource.BLOCKS, + 1.0f, + 1.0f + ); + level.gameEvent(igniter, GameEvent.PRIME_FUSE, pos); + + TiedUpMod.LOGGER.info( + "[BlockKidnapBomb] Bomb primed at {} by {}", + pos, + igniter != null ? igniter.getName().getString() : "unknown" + ); + } + } + + // ======================================== + // LOADING ITEMS + // ======================================== + + @Override + public InteractionResult use( + BlockState state, + Level level, + BlockPos pos, + Player player, + InteractionHand hand, + BlockHitResult hit + ) { + ItemStack heldItem = player.getItemInHand(hand); + + // First check if it's flint and steel (default TNT behavior) + if ( + heldItem.is(Items.FLINT_AND_STEEL) || heldItem.is(Items.FIRE_CHARGE) + ) { + return super.use(state, level, pos, player, hand, hit); + } + + if (hand != InteractionHand.MAIN_HAND) { + return InteractionResult.PASS; + } + + if (heldItem.isEmpty()) { + return InteractionResult.PASS; + } + + // Check if it's a bondage item + if (!BondageItemLoaderUtility.isLoadableBondageItem(heldItem)) { + return InteractionResult.PASS; + } + + // Server-side only + if (level.isClientSide) { + return InteractionResult.SUCCESS; + } + + KidnapBombBlockEntity bomb = getBombEntity(level, pos); + if (bomb == null) { + return InteractionResult.PASS; + } + + // Try to load the held item into the appropriate slot + if ( + BondageItemLoaderUtility.loadItemIntoHolder(bomb, heldItem, player) + ) { + SystemMessageManager.sendToPlayer( + player, + "Item loaded into bomb", + ChatFormatting.YELLOW + ); + return InteractionResult.SUCCESS; + } + + return InteractionResult.PASS; + } + + // ======================================== + // DROPS WITH NBT + // ======================================== + + @Override + public List getDrops( + BlockState state, + LootParams.Builder params + ) { + BlockEntity be = params.getOptionalParameter( + LootContextParams.BLOCK_ENTITY + ); + ItemStack stack = new ItemStack(this); + + if (be instanceof KidnapBombBlockEntity bomb) { + CompoundTag beTag = new CompoundTag(); + bomb.writeBondageData(beTag); + + if (!beTag.isEmpty()) { + stack.addTagElement("BlockEntityTag", beTag); + } + } + + return List.of(stack); + } + + // ======================================== + // TOOLTIP + // ======================================== + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable BlockGetter level, + List tooltip, + TooltipFlag flag + ) { + tooltip.add( + Component.translatable("block.tiedup.kidnap_bomb.desc").withStyle( + ChatFormatting.GRAY + ) + ); + + CompoundTag nbt = stack.getTag(); + if (nbt != null && nbt.contains("BlockEntityTag")) { + CompoundTag beTag = nbt.getCompound("BlockEntityTag"); + + // Check if loaded with any items + if ( + beTag.contains("bind") || + beTag.contains("gag") || + beTag.contains("blindfold") || + beTag.contains("earplugs") || + beTag.contains("collar") + ) { + tooltip.add( + Component.literal("Loaded:").withStyle( + ChatFormatting.YELLOW + ) + ); + + // List loaded items + BondageItemLoaderUtility.addItemToTooltip( + tooltip, + beTag, + "bind" + ); + BondageItemLoaderUtility.addItemToTooltip( + tooltip, + beTag, + "gag" + ); + BondageItemLoaderUtility.addItemToTooltip( + tooltip, + beTag, + "blindfold" + ); + BondageItemLoaderUtility.addItemToTooltip( + tooltip, + beTag, + "earplugs" + ); + BondageItemLoaderUtility.addItemToTooltip( + tooltip, + beTag, + "collar" + ); + BondageItemLoaderUtility.addItemToTooltip( + tooltip, + beTag, + "clothes" + ); + } else { + tooltip.add( + Component.literal("Empty").withStyle(ChatFormatting.GREEN) + ); + } + } else { + tooltip.add( + Component.literal("Empty").withStyle(ChatFormatting.GREEN) + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/blocks/BlockMarker.java b/src/main/java/com/tiedup/remake/blocks/BlockMarker.java new file mode 100644 index 0000000..7ffae53 --- /dev/null +++ b/src/main/java/com/tiedup/remake/blocks/BlockMarker.java @@ -0,0 +1,247 @@ +package com.tiedup.remake.blocks; + +import com.tiedup.remake.blocks.entity.MarkerBlockEntity; +import com.tiedup.remake.blocks.entity.ModBlockEntities; +import com.tiedup.remake.cells.MarkerType; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.items.ItemAdminWand; +import java.util.List; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.SoundType; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.material.MapColor; +import net.minecraft.world.level.material.PushReaction; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.Shapes; +import net.minecraft.world.phys.shapes.VoxelShape; +import org.jetbrains.annotations.Nullable; + +/** + * Marker Block - Invisible block used to define cell spawn points. + * + * Phase: Kidnapper Revamp - Cell System + * + * Features: + * - Invisible (no render) + * - No collision + * - Stores cell UUID via BlockEntity + * - Destroying removes the cell from registry + * + * Placed by structure generation or Admin Wand. + */ +public class BlockMarker extends BaseEntityBlock { + + // Small shape for selection/picking + private static final VoxelShape SHAPE = Block.box(6, 6, 6, 10, 10, 10); + + public BlockMarker() { + super( + BlockBehaviour.Properties.of() + .mapColor(MapColor.NONE) + .strength(0.5f) // Easy to break + .sound(SoundType.WOOD) + .noCollission() + .noOcclusion() + .pushReaction(PushReaction.DESTROY) + ); + } + + // ==================== SHAPE ==================== + + @Override + public VoxelShape getShape( + BlockState state, + BlockGetter level, + BlockPos pos, + CollisionContext context + ) { + // Small hitbox for selection + return SHAPE; + } + + @Override + public VoxelShape getCollisionShape( + BlockState state, + BlockGetter level, + BlockPos pos, + CollisionContext context + ) { + // No collision + return Shapes.empty(); + } + + @Override + public VoxelShape getVisualShape( + BlockState state, + BlockGetter level, + BlockPos pos, + CollisionContext context + ) { + // No visual shape + return Shapes.empty(); + } + + // ==================== RENDER ==================== + + @Override + public RenderShape getRenderShape(BlockState state) { + // Invisible - no model rendering + return RenderShape.INVISIBLE; + } + + @Override + public float getShadeBrightness( + BlockState state, + BlockGetter level, + BlockPos pos + ) { + // Full brightness (no shadow) + return 1.0f; + } + + @Override + public boolean propagatesSkylightDown( + BlockState state, + BlockGetter level, + BlockPos pos + ) { + return true; + } + + // ==================== PARTICLE FEEDBACK ==================== + + /** + * Spawn particles to make the marker visible when a player is holding a wand. + * This provides visual feedback for the otherwise invisible block. + */ + @Override + public void animateTick( + BlockState state, + Level level, + BlockPos pos, + RandomSource random + ) { + // Only show particles if a nearby player is holding an AdminWand + Player nearestPlayer = level.getNearestPlayer( + pos.getX() + 0.5, + pos.getY() + 0.5, + pos.getZ() + 0.5, + 16.0, // Detection range + false // Include spectators + ); + + if (nearestPlayer == null) return; + + // Check if player is holding an Admin Wand + boolean holdingWand = + nearestPlayer.getMainHandItem().getItem() instanceof + ItemAdminWand || + nearestPlayer.getOffhandItem().getItem() instanceof ItemAdminWand; + + if (!holdingWand) return; + + // Spawn subtle particles at the marker position + // Use END_ROD for a glowing effect + if (random.nextInt(3) == 0) { + // 1 in 3 chance per tick for subtle effect + level.addParticle( + ParticleTypes.END_ROD, + pos.getX() + 0.5 + (random.nextDouble() - 0.5) * 0.3, + pos.getY() + 0.5 + (random.nextDouble() - 0.5) * 0.3, + pos.getZ() + 0.5 + (random.nextDouble() - 0.5) * 0.3, + 0, + 0.02, + 0 // Slight upward drift + ); + } + } + + // ==================== BLOCK ENTITY ==================== + + @Nullable + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new MarkerBlockEntity(pos, state); + } + + // ==================== DESTRUCTION ==================== + + @Override + public void onRemove( + BlockState state, + Level level, + BlockPos pos, + BlockState newState, + boolean movedByPiston + ) { + if (!state.is(newState.getBlock())) { + // Block is being destroyed, remove the cell from registry + if (level instanceof ServerLevel serverLevel) { + BlockEntity be = level.getBlockEntity(pos); + if (be instanceof MarkerBlockEntity marker) { + // Alert kidnappers if this was a WALL marker (potential breach) + if (marker.getMarkerType() == MarkerType.WALL) { + alertNearbyKidnappersOfBreach( + serverLevel, + pos, + marker.getCellId() + ); + } + marker.deleteCell(); + } + } + } + super.onRemove(state, level, pos, newState, movedByPiston); + } + + /** + * Alert nearby kidnappers when a WALL marker is destroyed. + * This indicates a potential breach in the cell that prisoners could escape through. + * + * @param level The server level + * @param breachPos The position of the destroyed wall + * @param cellId The cell UUID (may be null) + */ + private void alertNearbyKidnappersOfBreach( + ServerLevel level, + BlockPos breachPos, + UUID cellId + ) { + if (cellId == null) { + return; + } + + // Find kidnappers within 50 blocks + AABB searchBox = new AABB(breachPos).inflate(50, 20, 50); + List kidnappers = level.getEntitiesOfClass( + EntityKidnapper.class, + searchBox + ); + + if (!kidnappers.isEmpty()) { + TiedUpMod.LOGGER.info( + "[BlockMarker] WALL destroyed at {}, alerting {} kidnappers", + breachPos.toShortString(), + kidnappers.size() + ); + + for (EntityKidnapper kidnapper : kidnappers) { + kidnapper.onCellBreach(breachPos, cellId); + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/blocks/BlockRopeTrap.java b/src/main/java/com/tiedup/remake/blocks/BlockRopeTrap.java new file mode 100644 index 0000000..c24a196 --- /dev/null +++ b/src/main/java/com/tiedup/remake/blocks/BlockRopeTrap.java @@ -0,0 +1,395 @@ +package com.tiedup.remake.blocks; + +import com.tiedup.remake.blocks.entity.TrapBlockEntity; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.BondageItemLoaderUtility; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.List; +import javax.annotation.Nullable; +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LevelAccessor; +import net.minecraft.world.level.LevelReader; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.SoundType; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.loot.LootParams; +import net.minecraft.world.level.storage.loot.parameters.LootContextParams; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.Shapes; +import net.minecraft.world.phys.shapes.VoxelShape; + +/** + * Rope Trap Block - Trap that ties up entities when they walk on it. + * + * Phase 16: Blocks + * + * Features: + * - Flat block (1 pixel tall) placed on solid surfaces + * - Can be loaded with bondage items by right-clicking + * - When armed and entity walks on it, applies all stored items + * - Destroys itself after triggering + * - Preserves NBT data when broken + * + * Based on original BlockRopesTrap from 1.12.2 + */ +public class BlockRopeTrap extends Block implements EntityBlock, ICanBeLoaded { + + // Shape: 1 pixel tall carpet-like block + protected static final VoxelShape TRAP_SHAPE = Block.box( + 0.0, + 0.0, + 0.0, + 16.0, + 1.0, + 16.0 + ); + + public BlockRopeTrap() { + super( + BlockBehaviour.Properties.of() + .strength(1.0f, 0.5f) + .sound(SoundType.WOOL) + .noOcclusion() + .noCollission() // Entities can walk through/on it + ); + } + + // ======================================== + // SHAPE AND RENDERING + // ======================================== + + @Override + public VoxelShape getShape( + BlockState state, + BlockGetter level, + BlockPos pos, + CollisionContext context + ) { + return TRAP_SHAPE; + } + + @Override + public VoxelShape getCollisionShape( + BlockState state, + BlockGetter level, + BlockPos pos, + CollisionContext context + ) { + return Shapes.empty(); // No collision - entities walk through + } + + @Override + public RenderShape getRenderShape(BlockState state) { + return RenderShape.MODEL; + } + + // ======================================== + // PLACEMENT RULES + // ======================================== + + @Override + public boolean canSurvive( + BlockState state, + LevelReader level, + BlockPos pos + ) { + BlockPos below = pos.below(); + BlockState belowState = level.getBlockState(below); + // Can only be placed on full solid blocks + return belowState.isFaceSturdy(level, below, Direction.UP); + } + + @Nullable + @Override + public BlockState getStateForPlacement(BlockPlaceContext context) { + if ( + !canSurvive( + defaultBlockState(), + context.getLevel(), + context.getClickedPos() + ) + ) { + return null; + } + return defaultBlockState(); + } + + @Override + public BlockState updateShape( + BlockState state, + Direction facing, + BlockState facingState, + LevelAccessor level, + BlockPos pos, + BlockPos facingPos + ) { + // Break if support block is removed + if (facing == Direction.DOWN && !canSurvive(state, level, pos)) { + return Blocks.AIR.defaultBlockState(); + } + return super.updateShape( + state, + facing, + facingState, + level, + pos, + facingPos + ); + } + + // ======================================== + // BLOCK ENTITY + // ======================================== + + @Nullable + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new TrapBlockEntity(pos, state); + } + + @Nullable + public TrapBlockEntity getTrapEntity(BlockGetter level, BlockPos pos) { + BlockEntity be = level.getBlockEntity(pos); + return be instanceof TrapBlockEntity ? (TrapBlockEntity) be : null; + } + + // ======================================== + // TRAP TRIGGER + // ======================================== + + @Override + public void entityInside( + BlockState state, + Level level, + BlockPos pos, + Entity entity + ) { + if (level.isClientSide) return; + + // Only affect living entities + if (!(entity instanceof LivingEntity living)) return; + + // Get target's kidnapped state + IBondageState targetState = KidnappedHelper.getKidnappedState(living); + if (targetState == null) return; + + // Don't trigger if already tied + if (targetState.isTiedUp()) return; + + // Get trap data + TrapBlockEntity trap = getTrapEntity(level, pos); + if (trap == null || !trap.isArmed()) return; + + // Apply all bondage items + ItemStack bind = trap.getBind(); + ItemStack gag = trap.getGag(); + ItemStack blindfold = trap.getBlindfold(); + ItemStack earplugs = trap.getEarplugs(); + ItemStack collar = trap.getCollar(); + ItemStack clothes = trap.getClothes(); + + targetState.applyBondage( + bind, + gag, + blindfold, + earplugs, + collar, + clothes + ); + + // Destroy the trap + level.destroyBlock(pos, false); + + // Notify target + if (entity instanceof Player player) { + player.displayClientMessage( + Component.translatable("tiedup.trap.triggered").withStyle( + ChatFormatting.RED + ), + true + ); + } + + TiedUpMod.LOGGER.info( + "[BlockRopeTrap] Trap triggered at {} on {}", + pos, + entity.getName().getString() + ); + } + + // ======================================== + // LOADING ITEMS + // ======================================== + + @Override + public InteractionResult use( + BlockState state, + Level level, + BlockPos pos, + Player player, + InteractionHand hand, + BlockHitResult hit + ) { + if (hand != InteractionHand.MAIN_HAND) { + return InteractionResult.PASS; + } + + ItemStack heldItem = player.getItemInHand(hand); + + // Empty hand = do nothing + if (heldItem.isEmpty()) { + return InteractionResult.PASS; + } + + // Check if it's a bondage item + if (!BondageItemLoaderUtility.isLoadableBondageItem(heldItem)) { + return InteractionResult.PASS; + } + + // Server-side only + if (level.isClientSide) { + return InteractionResult.SUCCESS; + } + + TrapBlockEntity trap = getTrapEntity(level, pos); + if (trap == null) { + return InteractionResult.PASS; + } + + // Try to load the held item into the appropriate slot + if ( + BondageItemLoaderUtility.loadItemIntoHolder(trap, heldItem, player) + ) { + SystemMessageManager.sendToPlayer( + player, + "Item loaded into trap", + ChatFormatting.YELLOW + ); + return InteractionResult.SUCCESS; + } + + return InteractionResult.PASS; + } + + // ======================================== + // DROPS WITH NBT + // ======================================== + + @Override + public List getDrops( + BlockState state, + LootParams.Builder params + ) { + BlockEntity be = params.getOptionalParameter( + LootContextParams.BLOCK_ENTITY + ); + ItemStack stack = new ItemStack(this); + + if (be instanceof TrapBlockEntity trap) { + CompoundTag beTag = new CompoundTag(); + trap.writeBondageData(beTag); + + if (!beTag.isEmpty()) { + stack.addTagElement("BlockEntityTag", beTag); + } + } + + return List.of(stack); + } + + // ======================================== + // TOOLTIP + // ======================================== + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable BlockGetter level, + List tooltip, + TooltipFlag flag + ) { + tooltip.add( + Component.translatable("block.tiedup.rope_trap.desc").withStyle( + ChatFormatting.GRAY + ) + ); + + CompoundTag nbt = stack.getTag(); + if (nbt != null && nbt.contains("BlockEntityTag")) { + CompoundTag beTag = nbt.getCompound("BlockEntityTag"); + + // Check if armed + if (beTag.contains("bind")) { + tooltip.add( + Component.literal("Armed").withStyle( + ChatFormatting.DARK_RED + ) + ); + + // List loaded items + BondageItemLoaderUtility.addItemToTooltip( + tooltip, + beTag, + "bind" + ); + BondageItemLoaderUtility.addItemToTooltip( + tooltip, + beTag, + "gag" + ); + BondageItemLoaderUtility.addItemToTooltip( + tooltip, + beTag, + "blindfold" + ); + BondageItemLoaderUtility.addItemToTooltip( + tooltip, + beTag, + "earplugs" + ); + BondageItemLoaderUtility.addItemToTooltip( + tooltip, + beTag, + "collar" + ); + BondageItemLoaderUtility.addItemToTooltip( + tooltip, + beTag, + "clothes" + ); + } else { + tooltip.add( + Component.literal("Disarmed").withStyle( + ChatFormatting.GREEN + ) + ); + } + } else { + tooltip.add( + Component.literal("Disarmed").withStyle(ChatFormatting.GREEN) + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/blocks/BlockTrappedChest.java b/src/main/java/com/tiedup/remake/blocks/BlockTrappedChest.java new file mode 100644 index 0000000..2d1239c --- /dev/null +++ b/src/main/java/com/tiedup/remake/blocks/BlockTrappedChest.java @@ -0,0 +1,254 @@ +package com.tiedup.remake.blocks; + +import com.tiedup.remake.blocks.entity.TrappedChestBlockEntity; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.items.base.*; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.BondageItemLoaderUtility; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.List; +import java.util.function.Supplier; +import javax.annotation.Nullable; +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.ChestBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.loot.LootParams; +import net.minecraft.world.level.storage.loot.parameters.LootContextParams; +import net.minecraft.world.phys.BlockHitResult; + +/** + * Trapped Chest Block - Chest that traps players when opened. + * + * Phase 16: Blocks + * + * Extends vanilla ChestBlock for proper chest behavior. + * Sneak + right-click to load bondage items. + * Normal open triggers the trap if armed. + */ +public class BlockTrappedChest extends ChestBlock implements ICanBeLoaded { + + public BlockTrappedChest() { + super(BlockBehaviour.Properties.of().strength(2.5f).noOcclusion(), () -> + com.tiedup.remake.blocks.entity.ModBlockEntities.TRAPPED_CHEST.get() + ); + } + + // ======================================== + // BLOCK ENTITY + // ======================================== + + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new TrappedChestBlockEntity(pos, state); + } + + @Nullable + public TrappedChestBlockEntity getTrapEntity( + BlockGetter level, + BlockPos pos + ) { + BlockEntity be = level.getBlockEntity(pos); + return be instanceof TrappedChestBlockEntity + ? (TrappedChestBlockEntity) be + : null; + } + + // ======================================== + // INTERACTION - TRAP TRIGGER + // ======================================== + + @Override + public InteractionResult use( + BlockState state, + Level level, + BlockPos pos, + Player player, + InteractionHand hand, + BlockHitResult hit + ) { + if (hand != InteractionHand.MAIN_HAND) { + return InteractionResult.PASS; + } + + ItemStack heldItem = player.getItemInHand(hand); + + // Check if holding a bondage item = load it (don't open chest) + if (BondageItemLoaderUtility.isLoadableBondageItem(heldItem)) { + // Server-side only + if (!level.isClientSide) { + TrappedChestBlockEntity chest = getTrapEntity(level, pos); + if ( + chest != null && + BondageItemLoaderUtility.loadItemIntoHolder( + chest, + heldItem, + player + ) + ) { + SystemMessageManager.sendToPlayer( + player, + "Item loaded into trap", + ChatFormatting.YELLOW + ); + } + } + return InteractionResult.SUCCESS; + } + + // Normal open - check for trap trigger first (server-side) + if (!level.isClientSide) { + TrappedChestBlockEntity chest = getTrapEntity(level, pos); + if (chest != null && chest.isArmed()) { + IBondageState playerState = KidnappedHelper.getKidnappedState( + player + ); + if (playerState != null && !playerState.isTiedUp()) { + // Apply bondage + playerState.applyBondage( + chest.getBind(), + chest.getGag(), + chest.getBlindfold(), + chest.getEarplugs(), + chest.getCollar(), + chest.getClothes() + ); + + // Clear the chest trap contents + chest.setBind(ItemStack.EMPTY); + chest.setGag(ItemStack.EMPTY); + chest.setBlindfold(ItemStack.EMPTY); + chest.setEarplugs(ItemStack.EMPTY); + chest.setCollar(ItemStack.EMPTY); + chest.setClothes(ItemStack.EMPTY); + + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.WARNING, + "You fell into a trap!" + ); + + // FIX: Don't open chest GUI after trap triggers + return InteractionResult.SUCCESS; + } + } + } + + // Normal chest behavior (open GUI) - only if trap didn't trigger + return super.use(state, level, pos, player, hand, hit); + } + + // ======================================== + // DROPS WITH NBT + // ======================================== + + @Override + public List getDrops( + BlockState state, + LootParams.Builder params + ) { + List drops = super.getDrops(state, params); + + BlockEntity be = params.getOptionalParameter( + LootContextParams.BLOCK_ENTITY + ); + if (be instanceof TrappedChestBlockEntity chest && chest.isArmed()) { + // Add trap data to the first drop (the chest itself) + if (!drops.isEmpty()) { + ItemStack stack = drops.get(0); + CompoundTag beTag = new CompoundTag(); + chest.writeBondageData(beTag); + if (!beTag.isEmpty()) { + stack.addTagElement("BlockEntityTag", beTag); + } + } + } + + return drops; + } + + // ======================================== + // TOOLTIP + // ======================================== + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable BlockGetter level, + List tooltip, + TooltipFlag flag + ) { + tooltip.add( + Component.translatable("block.tiedup.trapped_chest.desc").withStyle( + ChatFormatting.GRAY + ) + ); + + CompoundTag nbt = stack.getTag(); + if (nbt != null && nbt.contains("BlockEntityTag")) { + CompoundTag beTag = nbt.getCompound("BlockEntityTag"); + + if ( + beTag.contains("bind") || + beTag.contains("gag") || + beTag.contains("blindfold") || + beTag.contains("earplugs") || + beTag.contains("collar") + ) { + tooltip.add( + Component.literal("Armed").withStyle( + ChatFormatting.DARK_RED + ) + ); + BondageItemLoaderUtility.addItemToTooltip( + tooltip, + beTag, + "bind" + ); + BondageItemLoaderUtility.addItemToTooltip( + tooltip, + beTag, + "gag" + ); + BondageItemLoaderUtility.addItemToTooltip( + tooltip, + beTag, + "blindfold" + ); + BondageItemLoaderUtility.addItemToTooltip( + tooltip, + beTag, + "earplugs" + ); + BondageItemLoaderUtility.addItemToTooltip( + tooltip, + beTag, + "collar" + ); + } else { + tooltip.add( + Component.literal("Disarmed").withStyle( + ChatFormatting.GREEN + ) + ); + } + } else { + tooltip.add( + Component.literal("Disarmed").withStyle(ChatFormatting.GREEN) + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/blocks/ICanBeLoaded.java b/src/main/java/com/tiedup/remake/blocks/ICanBeLoaded.java new file mode 100644 index 0000000..71e0f52 --- /dev/null +++ b/src/main/java/com/tiedup/remake/blocks/ICanBeLoaded.java @@ -0,0 +1,17 @@ +package com.tiedup.remake.blocks; + +/** + * Marker interface for blocks that can have bondage items loaded into them. + * + * Phase 16: Blocks + * + * Implemented by: + * - BlockRopesTrap - applies items when entity walks on it + * - BlockTrappedBed - applies items when player sleeps + * - BlockKidnapBomb - passes items to explosion entity + * + * These blocks have associated BlockEntities that store the bondage items. + */ +public interface ICanBeLoaded { + // Marker interface - no methods required +} diff --git a/src/main/java/com/tiedup/remake/blocks/ModBlocks.java b/src/main/java/com/tiedup/remake/blocks/ModBlocks.java new file mode 100644 index 0000000..5db466e --- /dev/null +++ b/src/main/java/com/tiedup/remake/blocks/ModBlocks.java @@ -0,0 +1,225 @@ +package com.tiedup.remake.blocks; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.function.Supplier; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.DoubleHighBlockItem; +import net.minecraft.world.item.Item; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.DoorBlock; +import net.minecraft.world.level.block.SlabBlock; +import net.minecraft.world.level.block.SoundType; +import net.minecraft.world.level.block.StairBlock; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.level.material.MapColor; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegistryObject; + +/** + * Mod Blocks Registration + * + * Phase 16: Blocks + * + * Handles registration of all TiedUp blocks using DeferredRegister. + * + * Blocks: + * - Padded block + variants (slab, stairs, pane) + * - Rope trap + * - Trapped bed + * - Cell door + * - Kidnap bomb + */ +public class ModBlocks { + + // DeferredRegister for blocks + public static final DeferredRegister BLOCKS = + DeferredRegister.create(ForgeRegistries.BLOCKS, TiedUpMod.MOD_ID); + + // DeferredRegister for block items (linked to ModItems) + public static final DeferredRegister BLOCK_ITEMS = + DeferredRegister.create(ForgeRegistries.ITEMS, TiedUpMod.MOD_ID); + + // ======================================== + // PADDED BLOCKS + // ======================================== + + /** + * Base padded block properties. + * Cloth material, soft, quiet. + */ + private static BlockBehaviour.Properties paddedProperties() { + return BlockBehaviour.Properties.of() + .mapColor(MapColor.WOOL) + .strength(0.9f, 45.0f) + .sound(SoundType.WOOL); + } + + /** + * Padded Block - Basic soft block. + */ + public static final RegistryObject PADDED_BLOCK = registerBlock( + "padded_block", + () -> new Block(paddedProperties()) + ); + + /** + * Padded Slab - Half-height padded block. + */ + public static final RegistryObject PADDED_SLAB = registerBlock( + "padded_slab", + () -> new SlabBlock(paddedProperties()) + ); + + /** + * Padded Stairs - Stair variant of padded block. + */ + public static final RegistryObject PADDED_STAIRS = registerBlock( + "padded_stairs", + () -> + new StairBlock( + () -> PADDED_BLOCK.get().defaultBlockState(), + paddedProperties() + ) + ); + + // ======================================== + // TRAP BLOCKS + // ======================================== + + /** + * Rope Trap - Flat trap that ties up entities that walk on it. + * Uses BlockEntity to store bondage items. + */ + public static final RegistryObject ROPE_TRAP = registerBlock( + "rope_trap", + BlockRopeTrap::new + ); + + /** + * Kidnap Bomb - TNT that applies bondage on explosion. + * Uses BlockEntity to store bondage items. + */ + public static final RegistryObject KIDNAP_BOMB = registerBlock( + "kidnap_bomb", + BlockKidnapBomb::new + ); + + /** + * Trapped Chest - Chest that traps players when opened. + * Uses BlockEntity to store bondage items. + */ + public static final RegistryObject TRAPPED_CHEST = registerBlock( + "trapped_chest", + BlockTrappedChest::new + ); + + // ======================================== + // DOOR BLOCKS + // ======================================== + + /** + * Cell Door - Iron-like door that requires redstone to open. + * Cannot be opened by hand. + */ + public static final RegistryObject CELL_DOOR = + registerDoorBlock("cell_door", BlockCellDoor::new); + + // ======================================== + // CELL SYSTEM BLOCKS + // ======================================== + + /** + * Marker Block - Invisible block for cell spawn points. + * Stores cell UUID and links to CellRegistry. + */ + public static final RegistryObject MARKER = registerBlockNoItem( + "marker", + BlockMarker::new + ); + + /** + * Iron Bar Door - Lockable door made of iron bars. + * Can be locked with keys and unlocked with matching key, cell key, or master key. + */ + public static final RegistryObject IRON_BAR_DOOR = + registerDoorBlock("iron_bar_door", BlockIronBarDoor::new); + + /** + * Cell Core - Anchor block for Cell System V2. + * Placed into a wall; runs flood-fill to detect the room and register a cell. + */ + public static final RegistryObject CELL_CORE = registerBlock( + "cell_core", + BlockCellCore::new + ); + + // ======================================== + // REGISTRATION HELPERS + // ======================================== + + /** + * Register a block and its corresponding BlockItem. + * + * @param name Block registry name + * @param blockSupplier Block supplier + * @return RegistryObject for the block + */ + private static RegistryObject registerBlock( + String name, + Supplier blockSupplier + ) { + RegistryObject block = BLOCKS.register(name, blockSupplier); + registerBlockItem(name, block); + return block; + } + + /** + * Register a BlockItem for a block. + * + * @param name Item registry name (same as block) + * @param block The block to create an item for + */ + private static void registerBlockItem( + String name, + RegistryObject block + ) { + BLOCK_ITEMS.register(name, () -> + new BlockItem(block.get(), new Item.Properties()) + ); + } + + /** + * Register a block without an item. + * Used for blocks that need special item handling (e.g., trapped bed, doors). + * + * @param name Block registry name + * @param blockSupplier Block supplier + * @return RegistryObject for the block + */ + private static RegistryObject registerBlockNoItem( + String name, + Supplier blockSupplier + ) { + return BLOCKS.register(name, blockSupplier); + } + + /** + * Register a door block with DoubleHighBlockItem. + * Doors are double-height blocks and need special item handling. + * + * @param name Block registry name + * @param blockSupplier Block supplier (must return DoorBlock or subclass) + * @return RegistryObject for the block + */ + private static RegistryObject registerDoorBlock( + String name, + Supplier blockSupplier + ) { + RegistryObject block = BLOCKS.register(name, blockSupplier); + BLOCK_ITEMS.register(name, () -> + new DoubleHighBlockItem(block.get(), new Item.Properties()) + ); + return block; + } +} diff --git a/src/main/java/com/tiedup/remake/blocks/entity/BondageItemBlockEntity.java b/src/main/java/com/tiedup/remake/blocks/entity/BondageItemBlockEntity.java new file mode 100644 index 0000000..237c3ee --- /dev/null +++ b/src/main/java/com/tiedup/remake/blocks/entity/BondageItemBlockEntity.java @@ -0,0 +1,342 @@ +package com.tiedup.remake.blocks.entity; + +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.items.base.ItemBlindfold; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.items.base.ItemEarplugs; +import com.tiedup.remake.items.base.ItemGag; +import com.tiedup.remake.items.clothes.GenericClothes; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientGamePacketListener; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; + +/** + * Base BlockEntity for blocks that store bondage items. + * + * Phase 16: Blocks + * + * Stores up to 6 bondage items: + * - Bind (ropes, chains, straitjacket, etc.) + * - Gag + * - Blindfold + * - Earplugs + * - Collar + * - Clothes + * + * Features: + * - Full NBT serialization + * - Network synchronization for client rendering + * - Item type validation on load + * + * Based on original TileEntityBondageItemHandler from 1.12.2 + */ +public abstract class BondageItemBlockEntity + extends BlockEntity + implements IBondageItemHolder +{ + + // ======================================== + // STORED ITEMS + // ======================================== + + private ItemStack bind = ItemStack.EMPTY; + private ItemStack gag = ItemStack.EMPTY; + private ItemStack blindfold = ItemStack.EMPTY; + private ItemStack earplugs = ItemStack.EMPTY; + private ItemStack collar = ItemStack.EMPTY; + private ItemStack clothes = ItemStack.EMPTY; + + /** + * Off-mode prevents network updates. + * Used when reading NBT for tooltips without affecting the world. + */ + private final boolean offMode; + + // ======================================== + // CONSTRUCTORS + // ======================================== + + public BondageItemBlockEntity( + BlockEntityType type, + BlockPos pos, + BlockState state + ) { + this(type, pos, state, false); + } + + public BondageItemBlockEntity( + BlockEntityType type, + BlockPos pos, + BlockState state, + boolean offMode + ) { + super(type, pos, state); + this.offMode = offMode; + } + + // ======================================== + // BIND + // ======================================== + + @Override + public ItemStack getBind() { + return this.bind; + } + + @Override + public void setBind(ItemStack bind) { + this.bind = bind != null ? bind : ItemStack.EMPTY; + this.setChangedAndSync(); + } + + // ======================================== + // GAG + // ======================================== + + @Override + public ItemStack getGag() { + return this.gag; + } + + @Override + public void setGag(ItemStack gag) { + this.gag = gag != null ? gag : ItemStack.EMPTY; + this.setChangedAndSync(); + } + + // ======================================== + // BLINDFOLD + // ======================================== + + @Override + public ItemStack getBlindfold() { + return this.blindfold; + } + + @Override + public void setBlindfold(ItemStack blindfold) { + this.blindfold = blindfold != null ? blindfold : ItemStack.EMPTY; + this.setChangedAndSync(); + } + + // ======================================== + // EARPLUGS + // ======================================== + + @Override + public ItemStack getEarplugs() { + return this.earplugs; + } + + @Override + public void setEarplugs(ItemStack earplugs) { + this.earplugs = earplugs != null ? earplugs : ItemStack.EMPTY; + this.setChangedAndSync(); + } + + // ======================================== + // COLLAR + // ======================================== + + @Override + public ItemStack getCollar() { + return this.collar; + } + + @Override + public void setCollar(ItemStack collar) { + this.collar = collar != null ? collar : ItemStack.EMPTY; + this.setChangedAndSync(); + } + + // ======================================== + // CLOTHES + // ======================================== + + @Override + public ItemStack getClothes() { + return this.clothes; + } + + @Override + public void setClothes(ItemStack clothes) { + this.clothes = clothes != null ? clothes : ItemStack.EMPTY; + this.setChangedAndSync(); + } + + // ======================================== + // STATE + // ======================================== + + @Override + public boolean isArmed() { + return !this.bind.isEmpty(); + } + + /** + * Clear all stored bondage items. + * Called after applying items to a target. + */ + public void clearAllItems() { + this.bind = ItemStack.EMPTY; + this.gag = ItemStack.EMPTY; + this.blindfold = ItemStack.EMPTY; + this.earplugs = ItemStack.EMPTY; + this.collar = ItemStack.EMPTY; + this.clothes = ItemStack.EMPTY; + this.setChangedAndSync(); + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + @Override + public void load(CompoundTag tag) { + super.load(tag); + this.readBondageData(tag); + } + + @Override + protected void saveAdditional(CompoundTag tag) { + super.saveAdditional(tag); + this.writeBondageData(tag); + } + + @Override + public void readBondageData(CompoundTag tag) { + // Read bind with type validation + if (tag.contains("bind")) { + ItemStack bindStack = ItemStack.of(tag.getCompound("bind")); + if ( + !bindStack.isEmpty() && bindStack.getItem() instanceof ItemBind + ) { + this.bind = bindStack; + } + } + + // Read gag with type validation + if (tag.contains("gag")) { + ItemStack gagStack = ItemStack.of(tag.getCompound("gag")); + if (!gagStack.isEmpty() && gagStack.getItem() instanceof ItemGag) { + this.gag = gagStack; + } + } + + // Read blindfold with type validation + if (tag.contains("blindfold")) { + ItemStack blindfoldStack = ItemStack.of( + tag.getCompound("blindfold") + ); + if ( + !blindfoldStack.isEmpty() && + blindfoldStack.getItem() instanceof ItemBlindfold + ) { + this.blindfold = blindfoldStack; + } + } + + // Read earplugs with type validation + if (tag.contains("earplugs")) { + ItemStack earplugsStack = ItemStack.of(tag.getCompound("earplugs")); + if ( + !earplugsStack.isEmpty() && + earplugsStack.getItem() instanceof ItemEarplugs + ) { + this.earplugs = earplugsStack; + } + } + + // Read collar with type validation + if (tag.contains("collar")) { + ItemStack collarStack = ItemStack.of(tag.getCompound("collar")); + if ( + !collarStack.isEmpty() && + collarStack.getItem() instanceof ItemCollar + ) { + this.collar = collarStack; + } + } + + // Read clothes with type validation + if (tag.contains("clothes")) { + ItemStack clothesStack = ItemStack.of(tag.getCompound("clothes")); + if ( + !clothesStack.isEmpty() && + clothesStack.getItem() instanceof GenericClothes + ) { + this.clothes = clothesStack; + } + } + } + + @Override + public CompoundTag writeBondageData(CompoundTag tag) { + if (!this.bind.isEmpty()) { + tag.put("bind", this.bind.save(new CompoundTag())); + } + if (!this.gag.isEmpty()) { + tag.put("gag", this.gag.save(new CompoundTag())); + } + if (!this.blindfold.isEmpty()) { + tag.put("blindfold", this.blindfold.save(new CompoundTag())); + } + if (!this.earplugs.isEmpty()) { + tag.put("earplugs", this.earplugs.save(new CompoundTag())); + } + if (!this.collar.isEmpty()) { + tag.put("collar", this.collar.save(new CompoundTag())); + } + if (!this.clothes.isEmpty()) { + tag.put("clothes", this.clothes.save(new CompoundTag())); + } + return tag; + } + + // ======================================== + // NETWORK SYNC + // ======================================== + + /** + * Mark dirty and sync to clients. + */ + protected void setChangedAndSync() { + if (!this.offMode && this.level != null) { + this.setChanged(); + // Notify clients of block update + this.level.sendBlockUpdated( + this.worldPosition, + this.getBlockState(), + this.getBlockState(), + 3 + ); + } + } + + @Override + public CompoundTag getUpdateTag() { + CompoundTag tag = super.getUpdateTag(); + this.writeBondageData(tag); + return tag; + } + + @Nullable + @Override + public Packet getUpdatePacket() { + return ClientboundBlockEntityDataPacket.create(this); + } + + @Override + public void handleUpdateTag(CompoundTag tag) { + if (!this.offMode) { + this.readBondageData(tag); + } + } +} diff --git a/src/main/java/com/tiedup/remake/blocks/entity/CellCoreBlockEntity.java b/src/main/java/com/tiedup/remake/blocks/entity/CellCoreBlockEntity.java new file mode 100644 index 0000000..75e3305 --- /dev/null +++ b/src/main/java/com/tiedup/remake/blocks/entity/CellCoreBlockEntity.java @@ -0,0 +1,532 @@ +package com.tiedup.remake.blocks.entity; + +import com.tiedup.remake.cells.*; +import com.tiedup.remake.core.TiedUpMod; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtUtils; +import net.minecraft.nbt.Tag; +import net.minecraft.network.Connection; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientGamePacketListener; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.client.model.data.ModelData; +import net.minecraftforge.client.model.data.ModelProperty; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Block entity for the Cell Core block. + * + * Stores the cell ID link, spawn/delivery points, disguise block, + * and which face of the Core points into the cell interior. + */ +public class CellCoreBlockEntity extends BlockEntity { + + /** Shared ModelProperty for disguise — defined here (not in client-only CellCoreBakedModel) to avoid server classloading issues. */ + public static final ModelProperty DISGUISE_PROPERTY = + new ModelProperty<>(); + + @Nullable + private UUID cellId; + + @Nullable + private BlockPos spawnPoint; + + @Nullable + private BlockPos deliveryPoint; + + @Nullable + private BlockState disguiseState; + + @Nullable + private Direction interiorFace; + + @Nullable + private List pathWaypoints; + + /** Transient: pathWaypoints set by MarkerBlockEntity during V1→V2 conversion */ + @Nullable + private transient List pendingPathWaypoints; + + public CellCoreBlockEntity(BlockPos pos, BlockState state) { + super(ModBlockEntities.CELL_CORE.get(), pos, state); + } + + // ==================== LIFECYCLE ==================== + + @Override + public void onLoad() { + super.onLoad(); + + if (!(level instanceof ServerLevel serverLevel)) { + return; + } + + // If we have a cellId but the cell isn't in CellRegistryV2, + // this is a structure-placed Core that needs flood-fill registration. + if (cellId != null) { + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + + if ( + registry.getCell(cellId) == null && + registry.getCellAtCore(worldPosition) == null + ) { + // Structure-placed Core: run flood-fill and create cell + FloodFillResult result = FloodFillAlgorithm.tryFill( + serverLevel, + worldPosition + ); + + CellDataV2 newCell; + if (result.isSuccess()) { + newCell = registry.createCell(worldPosition, result, null); + if (result.getInteriorFace() != null) { + this.interiorFace = result.getInteriorFace(); + } + } else { + // Flood-fill failed (e.g. chunk not fully loaded) — create minimal cell + newCell = new CellDataV2(UUID.randomUUID(), worldPosition); + registry.registerExistingCell(newCell); + TiedUpMod.LOGGER.warn( + "[CellCoreBlockEntity] Flood-fill failed at {}: {}. Created minimal cell.", + worldPosition.toShortString(), + result.getErrorKey() + ); + } + + // Update our cellId to match the new cell + this.cellId = newCell.getId(); + + // Transfer spawn/delivery to cell data + newCell.setSpawnPoint(this.spawnPoint); + newCell.setDeliveryPoint(this.deliveryPoint); + + // Transfer pathWaypoints: persistent field (from NBT) or V1 migration pending + List waypointsToTransfer = null; + if ( + this.pathWaypoints != null && !this.pathWaypoints.isEmpty() + ) { + waypointsToTransfer = this.pathWaypoints; + } else if ( + this.pendingPathWaypoints != null && + !this.pendingPathWaypoints.isEmpty() + ) { + waypointsToTransfer = this.pendingPathWaypoints; + this.pendingPathWaypoints = null; + } + if (waypointsToTransfer != null) { + newCell.setPathWaypoints(waypointsToTransfer); + TiedUpMod.LOGGER.info( + "[CellCoreBlockEntity] Transferred {} pathWaypoints to cell {}", + waypointsToTransfer.size(), + cellId.toString().substring(0, 8) + ); + } + + // Mark as camp-owned and link to nearest camp + newCell.setOwnerType(CellOwnerType.CAMP); + + CampOwnership ownership = CampOwnership.get(serverLevel); + CampOwnership.CampData nearestCamp = + ownership.findNearestAliveCamp(worldPosition, 40); + + if (nearestCamp != null) { + newCell.setOwnerId(nearestCamp.getCampId()); + TiedUpMod.LOGGER.info( + "[CellCoreBlockEntity] Created cell {} linked to camp {} at {}", + cellId.toString().substring(0, 8), + nearestCamp.getCampId().toString().substring(0, 8), + worldPosition.toShortString() + ); + } else { + // No camp yet: generate deterministic camp ID (same algo as MarkerBlockEntity) + UUID structureCampId = generateStructureCampId( + worldPosition + ); + newCell.setOwnerId(structureCampId); + TiedUpMod.LOGGER.info( + "[CellCoreBlockEntity] Created cell {} with structure camp ID {} at {}", + cellId.toString().substring(0, 8), + structureCampId.toString().substring(0, 8), + worldPosition.toShortString() + ); + } + + registry.updateCampIndex(newCell, null); + registry.setDirty(); + setChangedAndSync(); + } else { + // Existing cell: sync pathWaypoints from CellDataV2 → Core BE + // (so re-saving structures picks them up in the new offset format) + CellDataV2 existingCell = registry.getCell(cellId); + if (existingCell == null) { + existingCell = registry.getCellAtCore(worldPosition); + } + if (existingCell != null) { + if ( + this.pathWaypoints == null || + this.pathWaypoints.isEmpty() + ) { + List cellWaypoints = + existingCell.getPathWaypoints(); + if (!cellWaypoints.isEmpty()) { + this.pathWaypoints = new ArrayList<>(cellWaypoints); + setChanged(); // persist without network sync + } + } + } + } + } + } + + /** + * Generate a deterministic camp ID based on structure position. + * Uses the same 128-block grid algorithm as MarkerBlockEntity. + */ + private static UUID generateStructureCampId(BlockPos pos) { + int gridX = (pos.getX() / 128) * 128; + int gridZ = (pos.getZ() / 128) * 128; + long mostSigBits = ((long) gridX << 32) | (gridZ & 0xFFFFFFFFL); + long leastSigBits = 0x8000000000000000L | (gridX ^ gridZ); + return new UUID(mostSigBits, leastSigBits); + } + + // ==================== GETTERS/SETTERS ==================== + + @Nullable + public UUID getCellId() { + return cellId; + } + + public void setCellId(@Nullable UUID cellId) { + this.cellId = cellId; + setChangedAndSync(); + } + + @Nullable + public BlockPos getSpawnPoint() { + return spawnPoint; + } + + public void setSpawnPoint(@Nullable BlockPos spawnPoint) { + this.spawnPoint = spawnPoint; + setChangedAndSync(); + } + + @Nullable + public BlockPos getDeliveryPoint() { + return deliveryPoint; + } + + public void setDeliveryPoint(@Nullable BlockPos deliveryPoint) { + this.deliveryPoint = deliveryPoint; + setChangedAndSync(); + } + + @Nullable + public BlockState getDisguiseState() { + return disguiseState; + } + + public void setDisguiseState(@Nullable BlockState disguiseState) { + this.disguiseState = disguiseState; + setChangedAndSync(); + requestModelDataUpdate(); + } + + @Nullable + public Direction getInteriorFace() { + return interiorFace; + } + + public void setInteriorFace(@Nullable Direction interiorFace) { + this.interiorFace = interiorFace; + setChangedAndSync(); + } + + @Nullable + public List getPathWaypoints() { + return pathWaypoints; + } + + public void setPathWaypoints(@Nullable List pathWaypoints) { + this.pathWaypoints = pathWaypoints; + setChangedAndSync(); + } + + public void setPendingPathWaypoints(List waypoints) { + this.pendingPathWaypoints = waypoints; + this.pathWaypoints = waypoints; // also persist + } + + // ==================== MODEL DATA (Camouflage) ==================== + + @Override + public @NotNull ModelData getModelData() { + BlockState disguise = resolveDisguise(); + if (disguise != null) { + return ModelData.builder() + .with(DISGUISE_PROPERTY, disguise) + .build(); + } + return ModelData.EMPTY; + } + + @Nullable + public BlockState resolveDisguise() { + // Explicit disguise takes priority (preserves full BlockState including slab type, etc.) + if (disguiseState != null) { + return disguiseState; + } + + // Auto-detect most common solid neighbor + if (level != null) { + return detectMostCommonNeighbor(); + } + return null; + } + + @Nullable + private BlockState detectMostCommonNeighbor() { + if (level == null) return null; + + // Track full BlockState (preserves slab type, stair facing, etc.) + Map counts = new HashMap<>(); + for (Direction dir : Direction.values()) { + BlockPos neighborPos = worldPosition.relative(dir); + BlockState neighbor = level.getBlockState(neighborPos); + if ( + neighbor.getBlock() instanceof + com.tiedup.remake.blocks.BlockCellCore + ) continue; + if ( + neighbor.isSolidRender(level, neighborPos) || + neighbor.getBlock() instanceof + net.minecraft.world.level.block.SlabBlock + ) { + counts.merge(neighbor, 1, Integer::sum); + } + } + + if (counts.isEmpty()) return null; + + BlockState mostCommon = null; + int maxCount = 0; + for (Map.Entry entry : counts.entrySet()) { + if (entry.getValue() > maxCount) { + maxCount = entry.getValue(); + mostCommon = entry.getKey(); + } + } + return mostCommon; + } + + // ==================== NBT ==================== + + @Override + protected void saveAdditional(CompoundTag tag) { + super.saveAdditional(tag); + if (cellId != null) tag.putUUID("cellId", cellId); + // Save positions as relative offsets from core position (survives structure placement + rotation) + if (spawnPoint != null) { + tag.put( + "spawnOffset", + NbtUtils.writeBlockPos(toOffset(spawnPoint, worldPosition)) + ); + } + if (deliveryPoint != null) { + tag.put( + "deliveryOffset", + NbtUtils.writeBlockPos(toOffset(deliveryPoint, worldPosition)) + ); + } + if (pathWaypoints != null && !pathWaypoints.isEmpty()) { + ListTag list = new ListTag(); + for (BlockPos wp : pathWaypoints) { + list.add(NbtUtils.writeBlockPos(toOffset(wp, worldPosition))); + } + tag.put("pathWaypointOffsets", list); + } + if (disguiseState != null) tag.put( + "disguiseState", + NbtUtils.writeBlockState(disguiseState) + ); + if (interiorFace != null) tag.putString( + "interiorFace", + interiorFace.getSerializedName() + ); + } + + @Override + public void load(CompoundTag tag) { + super.load(tag); + cellId = tag.contains("cellId") ? tag.getUUID("cellId") : null; + + // Spawn point: new relative offset format, then old absolute fallback + if (tag.contains("spawnOffset")) { + spawnPoint = fromOffset( + NbtUtils.readBlockPos(tag.getCompound("spawnOffset")), + worldPosition + ); + } else if (tag.contains("spawnPoint")) { + spawnPoint = NbtUtils.readBlockPos(tag.getCompound("spawnPoint")); + } else { + spawnPoint = null; + } + + // Delivery point: new relative offset format, then old absolute fallback + if (tag.contains("deliveryOffset")) { + deliveryPoint = fromOffset( + NbtUtils.readBlockPos(tag.getCompound("deliveryOffset")), + worldPosition + ); + } else if (tag.contains("deliveryPoint")) { + deliveryPoint = NbtUtils.readBlockPos( + tag.getCompound("deliveryPoint") + ); + } else { + deliveryPoint = null; + } + + // Path waypoints (relative offsets) + if (tag.contains("pathWaypointOffsets")) { + ListTag list = tag.getList("pathWaypointOffsets", Tag.TAG_COMPOUND); + pathWaypoints = new ArrayList<>(); + for (int i = 0; i < list.size(); i++) { + pathWaypoints.add( + fromOffset( + NbtUtils.readBlockPos(list.getCompound(i)), + worldPosition + ) + ); + } + } else { + pathWaypoints = null; + } + + // Retrocompat: old saves stored "disguiseBlock" as ResourceLocation string + if (tag.contains("disguiseState")) { + disguiseState = NbtUtils.readBlockState( + BuiltInRegistries.BLOCK.asLookup(), + tag.getCompound("disguiseState") + ); + } else if (tag.contains("disguiseBlock")) { + // V1 compat: convert old ResourceLocation to default BlockState + ResourceLocation oldId = ResourceLocation.tryParse( + tag.getString("disguiseBlock") + ); + if (oldId != null) { + Block block = BuiltInRegistries.BLOCK.get(oldId); + disguiseState = (block != + net.minecraft.world.level.block.Blocks.AIR) + ? block.defaultBlockState() + : null; + } + } else { + disguiseState = null; + } + interiorFace = tag.contains("interiorFace") + ? Direction.byName(tag.getString("interiorFace")) + : null; + } + + // ==================== OFFSET HELPERS ==================== + + /** Convert absolute position to relative offset from origin. */ + private static BlockPos toOffset(BlockPos absolute, BlockPos origin) { + return new BlockPos( + absolute.getX() - origin.getX(), + absolute.getY() - origin.getY(), + absolute.getZ() - origin.getZ() + ); + } + + /** Convert relative offset back to absolute position. */ + private static BlockPos fromOffset(BlockPos offset, BlockPos origin) { + return origin.offset(offset.getX(), offset.getY(), offset.getZ()); + } + + // ==================== NETWORK SYNC ==================== + + private void setChangedAndSync() { + if (level != null) { + setChanged(); + level.sendBlockUpdated( + worldPosition, + getBlockState(), + getBlockState(), + 3 + ); + } + } + + @Override + public CompoundTag getUpdateTag() { + CompoundTag tag = super.getUpdateTag(); + saveAdditional(tag); + return tag; + } + + @Nullable + @Override + public Packet getUpdatePacket() { + return ClientboundBlockEntityDataPacket.create(this); + } + + @Override + public void onDataPacket( + Connection net, + ClientboundBlockEntityDataPacket pkt + ) { + CompoundTag tag = pkt.getTag(); + if (tag != null) { + handleUpdateTag(tag); + } + } + + @Override + public void handleUpdateTag(CompoundTag tag) { + load(tag); + requestModelDataUpdate(); + // Force chunk section re-render to pick up new model data immediately. + // Enqueue to main thread since handleUpdateTag may run on network thread. + if (level != null && level.isClientSide()) { + net.minecraft.client.Minecraft.getInstance().tell( + this::markRenderDirty + ); + } + } + + /** + * Marks the chunk section containing this block entity for re-rendering. + * Must be called on the client main thread only. + */ + private void markRenderDirty() { + if (level == null || !level.isClientSide()) return; + net.minecraft.client.Minecraft mc = + net.minecraft.client.Minecraft.getInstance(); + if (mc.levelRenderer != null) { + mc.levelRenderer.blockChanged( + level, + worldPosition, + getBlockState(), + getBlockState(), + 8 + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/blocks/entity/IBondageItemHolder.java b/src/main/java/com/tiedup/remake/blocks/entity/IBondageItemHolder.java new file mode 100644 index 0000000..a5866dc --- /dev/null +++ b/src/main/java/com/tiedup/remake/blocks/entity/IBondageItemHolder.java @@ -0,0 +1,91 @@ +package com.tiedup.remake.blocks.entity; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.item.ItemStack; + +/** + * Interface for BlockEntities that store bondage items. + * + * Phase 16: Blocks + * + * Defines the contract for storing and retrieving bondage items: + * - Bind (ropes, chains, etc.) + * - Gag + * - Blindfold + * - Earplugs + * - Collar + * - Clothes + * + * Based on original ITileEntityBondageItemHolder from 1.12.2 + */ +public interface IBondageItemHolder { + // ======================================== + // BIND + // ======================================== + + ItemStack getBind(); + void setBind(ItemStack bind); + + // ======================================== + // GAG + // ======================================== + + ItemStack getGag(); + void setGag(ItemStack gag); + + // ======================================== + // BLINDFOLD + // ======================================== + + ItemStack getBlindfold(); + void setBlindfold(ItemStack blindfold); + + // ======================================== + // EARPLUGS + // ======================================== + + ItemStack getEarplugs(); + void setEarplugs(ItemStack earplugs); + + // ======================================== + // COLLAR + // ======================================== + + ItemStack getCollar(); + void setCollar(ItemStack collar); + + // ======================================== + // CLOTHES + // ======================================== + + ItemStack getClothes(); + void setClothes(ItemStack clothes); + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + /** + * Read bondage items from NBT. + * @param tag The compound tag to read from + */ + void readBondageData(CompoundTag tag); + + /** + * Write bondage items to NBT. + * @param tag The compound tag to write to + * @return The modified compound tag + */ + CompoundTag writeBondageData(CompoundTag tag); + + // ======================================== + // STATE + // ======================================== + + /** + * Check if this holder has any bondage items loaded. + * Typically checks if bind is present. + * @return true if armed/loaded + */ + boolean isArmed(); +} diff --git a/src/main/java/com/tiedup/remake/blocks/entity/IronBarDoorBlockEntity.java b/src/main/java/com/tiedup/remake/blocks/entity/IronBarDoorBlockEntity.java new file mode 100644 index 0000000..cc6f14e --- /dev/null +++ b/src/main/java/com/tiedup/remake/blocks/entity/IronBarDoorBlockEntity.java @@ -0,0 +1,168 @@ +package com.tiedup.remake.blocks.entity; + +import com.tiedup.remake.items.base.ILockable; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientGamePacketListener; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +/** + * BlockEntity for iron bar door blocks. + * + * Phase: Kidnapper Revamp - Cell System + * + * Stores the lock state and key UUID for the door. + * Implements a block-based version of the ILockable pattern. + */ +public class IronBarDoorBlockEntity extends BlockEntity { + + @Nullable + private UUID lockedByKeyUUID; + + private boolean locked = false; + + public IronBarDoorBlockEntity(BlockPos pos, BlockState state) { + super(ModBlockEntities.IRON_BAR_DOOR.get(), pos, state); + } + + // ==================== LOCK STATE ==================== + + /** + * Check if this door is locked. + */ + public boolean isLocked() { + return locked; + } + + /** + * Set the locked state. + * + * @param locked true to lock, false to unlock + */ + public void setLocked(boolean locked) { + this.locked = locked; + if (!locked) { + this.lockedByKeyUUID = null; + } + setChangedAndSync(); + } + + // ==================== KEY UUID ==================== + + /** + * Get the UUID of the key that locked this door. + */ + @Nullable + public UUID getLockedByKeyUUID() { + return lockedByKeyUUID; + } + + /** + * Lock this door with a specific key. + * + * @param keyUUID The key UUID, or null to unlock + */ + public void setLockedByKeyUUID(@Nullable UUID keyUUID) { + this.lockedByKeyUUID = keyUUID; + this.locked = keyUUID != null; + setChangedAndSync(); + } + + /** + * Check if a key matches this door's lock. + * + * @param keyUUID The key UUID to test + * @return true if the key matches + */ + public boolean matchesKey(UUID keyUUID) { + if (keyUUID == null) return false; + return lockedByKeyUUID != null && lockedByKeyUUID.equals(keyUUID); + } + + /** + * Check if this door can be unlocked by the given key. + * Matches either the specific key or any master key. + * + * @param keyUUID The key UUID to test + * @param isMasterKey Whether the key is a master key + * @return true if the door can be unlocked + */ + public boolean canUnlockWith(UUID keyUUID, boolean isMasterKey) { + if (!locked) return true; + if (isMasterKey) return true; + return matchesKey(keyUUID); + } + + // ==================== NBT SERIALIZATION ==================== + + @Override + public void load(CompoundTag tag) { + super.load(tag); + + this.locked = tag.getBoolean(ILockable.NBT_LOCKED); + + if (tag.contains(ILockable.NBT_LOCKED_BY_KEY_UUID)) { + this.lockedByKeyUUID = tag.getUUID( + ILockable.NBT_LOCKED_BY_KEY_UUID + ); + } else { + this.lockedByKeyUUID = null; + } + } + + @Override + protected void saveAdditional(CompoundTag tag) { + super.saveAdditional(tag); + + tag.putBoolean(ILockable.NBT_LOCKED, locked); + + if (lockedByKeyUUID != null) { + tag.putUUID(ILockable.NBT_LOCKED_BY_KEY_UUID, lockedByKeyUUID); + } + } + + // ==================== NETWORK SYNC ==================== + + protected void setChangedAndSync() { + if (level != null) { + setChanged(); + level.sendBlockUpdated( + worldPosition, + getBlockState(), + getBlockState(), + 3 + ); + } + } + + @Override + public CompoundTag getUpdateTag() { + CompoundTag tag = super.getUpdateTag(); + tag.putBoolean(ILockable.NBT_LOCKED, locked); + if (lockedByKeyUUID != null) { + tag.putUUID(ILockable.NBT_LOCKED_BY_KEY_UUID, lockedByKeyUUID); + } + return tag; + } + + @Nullable + @Override + public Packet getUpdatePacket() { + return ClientboundBlockEntityDataPacket.create(this); + } + + @Override + public void handleUpdateTag(CompoundTag tag) { + this.locked = tag.getBoolean(ILockable.NBT_LOCKED); + if (tag.contains(ILockable.NBT_LOCKED_BY_KEY_UUID)) { + this.lockedByKeyUUID = tag.getUUID( + ILockable.NBT_LOCKED_BY_KEY_UUID + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/blocks/entity/KidnapBombBlockEntity.java b/src/main/java/com/tiedup/remake/blocks/entity/KidnapBombBlockEntity.java new file mode 100644 index 0000000..8fe3e21 --- /dev/null +++ b/src/main/java/com/tiedup/remake/blocks/entity/KidnapBombBlockEntity.java @@ -0,0 +1,32 @@ +package com.tiedup.remake.blocks.entity; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.state.BlockState; + +/** + * BlockEntity for kidnap bomb blocks. + * + * Phase 16: Blocks + * + * Stores bondage items that will be applied when the bomb explodes. + * Simple extension of BondageItemBlockEntity. + * + * Based on original TileEntityKidnapBomb from 1.12.2 + */ +public class KidnapBombBlockEntity extends BondageItemBlockEntity { + + public KidnapBombBlockEntity(BlockPos pos, BlockState state) { + super(ModBlockEntities.KIDNAP_BOMB.get(), pos, state); + } + + /** + * Constructor with off-mode for tooltip reading. + */ + public KidnapBombBlockEntity( + BlockPos pos, + BlockState state, + boolean offMode + ) { + super(ModBlockEntities.KIDNAP_BOMB.get(), pos, state, offMode); + } +} diff --git a/src/main/java/com/tiedup/remake/blocks/entity/MarkerBlockEntity.java b/src/main/java/com/tiedup/remake/blocks/entity/MarkerBlockEntity.java new file mode 100644 index 0000000..1847116 --- /dev/null +++ b/src/main/java/com/tiedup/remake/blocks/entity/MarkerBlockEntity.java @@ -0,0 +1,1146 @@ +package com.tiedup.remake.blocks.entity; + +import com.tiedup.remake.blocks.ModBlocks; +import com.tiedup.remake.cells.CampOwnership; +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.cells.MarkerType; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.EntityKidnapperMerchant; +import com.tiedup.remake.entities.EntityMaid; +import com.tiedup.remake.entities.EntitySlaveTrader; +import com.tiedup.remake.entities.ModEntities; +import java.util.List; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientGamePacketListener; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.MobSpawnType; +import net.minecraft.world.level.block.SlabBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.AABB; + +/** + * BlockEntity for marker blocks in the cell system. + * + * Phase: Kidnapper Revamp - Cell System + * + * Stores: + * - Cell UUID this marker belongs to + * - Marker type (WALL, ANCHOR, etc.) + * + * The marker serves as the spawn point for a cell and links to the CellRegistry. + */ +public class MarkerBlockEntity extends BlockEntity { + + @Nullable + private UUID cellId; + + private MarkerType markerType = MarkerType.WALL; + + /** Flag to track if spawner has already spawned its kidnapper */ + private boolean hasSpawned = false; + + public MarkerBlockEntity(BlockPos pos, BlockState state) { + super(ModBlockEntities.MARKER.get(), pos, state); + } + + // ==================== CELL ID ==================== + + /** + * Get the cell UUID this marker belongs to. + */ + @Nullable + public UUID getCellId() { + return cellId; + } + + /** + * Set the cell UUID this marker belongs to. + */ + public void setCellId(@Nullable UUID cellId) { + this.cellId = cellId; + setChangedAndSync(); + } + + // ==================== MARKER TYPE ==================== + + /** + * Get the marker type. + */ + public MarkerType getMarkerType() { + return markerType; + } + + /** + * Set the marker type. + */ + public void setMarkerType(MarkerType type) { + this.markerType = type; + setChangedAndSync(); + } + + /** + * Handle marker destruction. + * - If this is the spawn point marker: delete the entire cell + * - If this is a linked position (WALL, ANCHOR, etc.): only remove this position + * + * @return true if something was deleted + */ + public boolean deleteCell() { + if (cellId == null || !(level instanceof ServerLevel serverLevel)) { + return false; + } + + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + CellDataV2 cell = registry.getCell(cellId); + + if (cell == null) { + // Cell doesn't exist - just clear our reference + this.cellId = null; + setChangedAndSync(); + return false; + } + + boolean result; + + // Check if this marker is the spawn point (primary marker) or a secondary position + if ( + cell.getCorePos().equals(worldPosition) || + (cell.getSpawnPoint() != null && + cell.getSpawnPoint().equals(worldPosition)) + ) { + // This is the core/spawn point - remove the entire cell + registry.removeCell(cellId); + com.tiedup.remake.core.TiedUpMod.LOGGER.info( + "[MarkerBlockEntity] Spawn point destroyed - removed entire cell {}", + cellId.toString().substring(0, 8) + ); + result = true; + } else { + // Secondary position — V2 geometry is managed by flood-fill, just clear reference + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[MarkerBlockEntity] Secondary marker {} cleared from cell {}", + worldPosition.toShortString(), + cellId.toString().substring(0, 8) + ); + result = true; + } + + this.cellId = null; + setChangedAndSync(); + + return result; + } + + // ==================== LIFECYCLE ==================== + + @Override + public void onLoad() { + super.onLoad(); + + if (!(level instanceof ServerLevel serverLevel)) { + return; + } + + // V1→V2 RETROCOMPAT: Cell markers loaded from old .nbt templates. + // Only the primary marker (spawn point, with cachedCellPositions) creates a Cell Core. + // Secondary markers (WALL/BED/DOOR/etc. without cachedCellPositions) are simply removed — + // the Core's flood-fill will reconstruct the cell geometry. + if (markerType.isCellMarker()) { + if (cachedCellPositions != null) { + // Primary marker (spawn point) → convert to Cell Core with data transfer + convertToCellCore(serverLevel); + } else { + // Secondary marker (WALL/BED/DOOR/etc.) → just remove, flood-fill will handle + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[MarkerBlockEntity] Removing secondary cell marker {} at {}", + markerType.getSerializedName(), + worldPosition.toShortString() + ); + serverLevel.removeBlock(worldPosition, false); + } + return; + } + + // Register LOOT markers in CampData for fast chest lookup + if (markerType == MarkerType.LOOT) { + CampOwnership ownership = CampOwnership.get(serverLevel); + CampOwnership.CampData nearestCamp = ownership.findNearestAliveCamp( + worldPosition, + 50 + ); + if (nearestCamp != null) { + // LOOT markers are above chests - register the position below + BlockPos chestPos = worldPosition.below(); + if ( + serverLevel.getBlockEntity(chestPos) instanceof + ChestBlockEntity + ) { + nearestCamp.addLootChestPosition(chestPos); + ownership.setDirty(); + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[MarkerBlockEntity] Registered LOOT chest at {} for camp {}", + chestPos.toShortString(), + nearestCamp.getCampId().toString().substring(0, 8) + ); + } + } + } + + // Debug: Log all spawn-related marker checks + if ( + markerType == MarkerType.SPAWNER || + markerType == MarkerType.TRADER_SPAWN || + markerType == MarkerType.MAID_SPAWN || + markerType == MarkerType.MERCHANT_SPAWN + ) { + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[MarkerBlockEntity] onLoad() SPAWN marker at {}: type={}, hasSpawned={}", + worldPosition.toShortString(), + markerType.getSerializedName(), + hasSpawned + ); + } + + // Handle SPAWNER markers - spawn a kidnapper on first load + if (markerType == MarkerType.SPAWNER && !hasSpawned) { + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[MarkerBlockEntity] onLoad() Triggering spawnKidnapper at {}", + worldPosition.toShortString() + ); + spawnKidnapper(serverLevel); + } + + // Handle TRADER_SPAWN markers - spawn a SlaveTrader on first load + if (markerType == MarkerType.TRADER_SPAWN && !hasSpawned) { + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[MarkerBlockEntity] onLoad() Triggering spawnTrader at {}", + worldPosition.toShortString() + ); + spawnTrader(serverLevel); + } + + // Handle MAID_SPAWN markers - spawn a Maid on first load + if (markerType == MarkerType.MAID_SPAWN && !hasSpawned) { + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[MarkerBlockEntity] onLoad() Triggering spawnMaid at {}", + worldPosition.toShortString() + ); + spawnMaid(serverLevel); + } + + // Handle MERCHANT_SPAWN markers - spawn a Merchant on first load + if (markerType == MarkerType.MERCHANT_SPAWN && !hasSpawned) { + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[MarkerBlockEntity] onLoad() Triggering spawnMerchant at {}", + worldPosition.toShortString() + ); + spawnMerchant(serverLevel); + } + } + + /** + * Convert this cell marker to a V2 Cell Core block. + * Used for retrocompat: old .nbt templates that still have BlockMarker cell markers. + * + * Steps: + * 1. Find solid block below this position (max 3 down) for Core placement + * 2. Place BlockCellCore at that position + * 3. Configure Core: cellId, spawnPoint, deliveryPoint from cached positions (if available) + * 4. Core's onLoad() triggers flood-fill and V2 cell creation + * 5. Replace this marker with air + * + * Works for both primary markers (with cachedCellPositions) and secondary cell markers. + * When cachedCellPositions is null, delivery point won't be set — maid AI handles null fallback. + */ + private void convertToCellCore(ServerLevel serverLevel) { + // Find solid block below for Core placement + // Accepts full cubes (isSolidRender) and slabs (upper or lower half) + BlockPos corePos = null; + for (int yOff = 0; yOff <= 3; yOff++) { + BlockPos candidate = worldPosition.below(yOff); + BlockState candidateState = serverLevel.getBlockState(candidate); + if ( + candidateState.isSolidRender(serverLevel, candidate) || + candidateState.getBlock() instanceof SlabBlock + ) { + corePos = candidate; + break; + } + } + + if (corePos == null) { + // Fallback: place Core at marker position itself + corePos = worldPosition; + } + + // Skip if a Cell Core already exists at this position (prevents duplicates) + if ( + serverLevel.getBlockState(corePos).getBlock() instanceof + com.tiedup.remake.blocks.BlockCellCore + ) { + com.tiedup.remake.core.TiedUpMod.LOGGER.info( + "[MarkerBlockEntity] Skipping Core placement at {} — already exists (marker at {})", + corePos.toShortString(), + worldPosition.toShortString() + ); + if (!corePos.equals(worldPosition)) { + serverLevel.removeBlock(worldPosition, false); + } + return; + } + + // Place Cell Core block + serverLevel.setBlock( + corePos, + ModBlocks.CELL_CORE.get().defaultBlockState(), + 3 + ); + + // Configure the Core block entity + if ( + serverLevel.getBlockEntity(corePos) instanceof + CellCoreBlockEntity coreBE + ) { + // Ensure a cellId exists — new camp markers won't have one + UUID coreId = this.cellId != null ? this.cellId : UUID.randomUUID(); + coreBE.setCellId(coreId); + coreBE.setSpawnPoint(worldPosition); // Marker position becomes spawn point + + // Extract delivery point from cached positions + if ( + cachedCellPositions != null && + cachedCellPositions.contains( + MarkerType.DELIVERY.getSerializedName() + ) + ) { + net.minecraft.nbt.ListTag deliveryList = + cachedCellPositions.getList( + MarkerType.DELIVERY.getSerializedName(), + net.minecraft.nbt.Tag.TAG_COMPOUND + ); + if (deliveryList.size() > 0) { + BlockPos deliveryOffset = + net.minecraft.nbt.NbtUtils.readBlockPos( + deliveryList.getCompound(0) + ); + BlockPos deliveryWorld = worldPosition.offset( + deliveryOffset + ); + coreBE.setDeliveryPoint(deliveryWorld); + } + } + + // Extract pathWaypoints from cached positions + if ( + cachedCellPositions != null && + cachedCellPositions.contains("pathWaypoints") + ) { + net.minecraft.nbt.ListTag waypointsList = + cachedCellPositions.getList( + "pathWaypoints", + net.minecraft.nbt.Tag.TAG_COMPOUND + ); + java.util.ArrayList waypoints = + new java.util.ArrayList<>(); + for (int i = 0; i < waypointsList.size(); i++) { + BlockPos offset = net.minecraft.nbt.NbtUtils.readBlockPos( + waypointsList.getCompound(i) + ); + waypoints.add(worldPosition.offset(offset)); + } + coreBE.setPendingPathWaypoints(waypoints); + } + + com.tiedup.remake.core.TiedUpMod.LOGGER.info( + "[MarkerBlockEntity] Converted spawn marker to Cell Core at {} (marker was at {}, cellId={})", + corePos.toShortString(), + worldPosition.toShortString(), + cellId != null ? cellId.toString().substring(0, 8) : "null" + ); + } + + // Replace the marker block with air (unless Core was placed at our position) + if (!corePos.equals(worldPosition)) { + serverLevel.removeBlock(worldPosition, false); + } + } + + /** + * Spawn a kidnapper at this spawner marker position. + * Called once when the marker is first loaded (structure placement). + */ + private void spawnKidnapper(ServerLevel serverLevel) { + EntityKidnapper kidnapper = ModEntities.KIDNAPPER.get().create( + serverLevel + ); + if (kidnapper != null) { + // Spawn above the marker block + double x = worldPosition.getX() + 0.5; + double y = worldPosition.getY() + 1.0; + double z = worldPosition.getZ() + 0.5; + + kidnapper.moveTo( + x, + y, + z, + serverLevel.random.nextFloat() * 360F, + 0.0F + ); + kidnapper.finalizeSpawn( + serverLevel, + serverLevel.getCurrentDifficultyAt(worldPosition), + MobSpawnType.STRUCTURE, + null, + null + ); + + // Pre-link to structure camp ID so dispersal goal works immediately + // Uses same ID generation as trader spawn for consistency + UUID structureCampId = generateStructureCampId(worldPosition); + kidnapper.setAssociatedStructure(structureCampId); + + serverLevel.addFreshEntity(kidnapper); + + // Mark as spawned so we don't spawn again on chunk reload + hasSpawned = true; + setChangedAndSync(); + + com.tiedup.remake.core.TiedUpMod.LOGGER.info( + "[MarkerBlockEntity] Spawned kidnapper at {} for camp {}", + worldPosition.toShortString(), + structureCampId.toString().substring(0, 8) + ); + } + } + + /** + * Spawn a SlaveTrader at this marker position. + * Creates a camp entry in CampOwnership. + * Links nearby kidnappers and spawns new ones. + */ + private void spawnTrader(ServerLevel serverLevel) { + EntitySlaveTrader trader = ModEntities.SLAVE_TRADER.get().create( + serverLevel + ); + if (trader != null) { + double x = worldPosition.getX() + 0.5; + double y = worldPosition.getY() + 1.0; + double z = worldPosition.getZ() + 0.5; + + trader.moveTo(x, y, z, serverLevel.random.nextFloat() * 360F, 0.0F); + trader.finalizeSpawn( + serverLevel, + serverLevel.getCurrentDifficultyAt(worldPosition), + MobSpawnType.STRUCTURE, + null, + null + ); + + // Create camp entry using structure-based camp ID for consistency + // This ensures cells created by the same structure will link to this camp + UUID campId = generateStructureCampId(worldPosition); + trader.setCampUUID(campId); + trader.setSpawnPos(worldPosition); + + serverLevel.addFreshEntity(trader); + + // Register camp ownership + CampOwnership ownership = CampOwnership.get(serverLevel); + ownership.registerCamp( + campId, + trader.getUUID(), + null, + worldPosition + ); + + // Link nearby existing kidnappers to this camp (100 block radius) + linkNearbyKidnappers(serverLevel, campId, worldPosition); + + // Link nearby cells to this camp (they were created with their own cellId as ownerId) + linkNearbyCellsToCamp(serverLevel, campId, worldPosition); + + // Spawn ~10 new kidnappers in an annulus around the camp (40-100 block radius) + spawnCampKidnappers(serverLevel, campId, worldPosition); + + hasSpawned = true; + setChangedAndSync(); + + com.tiedup.remake.core.TiedUpMod.LOGGER.info( + "[MarkerBlockEntity] Spawned SlaveTrader at {} for camp {}", + worldPosition.toShortString(), + campId.toString().substring(0, 8) + ); + } + } + + /** + * Link existing kidnappers within 100 blocks to the camp. + */ + private void linkNearbyKidnappers( + ServerLevel serverLevel, + UUID campId, + BlockPos center + ) { + CampOwnership ownership = CampOwnership.get(serverLevel); + + // Search for kidnappers within 100 blocks + AABB searchBox = new AABB(center).inflate(100, 50, 100); + List nearbyKidnappers = serverLevel.getEntitiesOfClass( + EntityKidnapper.class, + searchBox, + kidnapper -> !(kidnapper instanceof EntityMaid) // Don't link maids + ); + + int linked = 0; + for (EntityKidnapper kidnapper : nearbyKidnappers) { + UUID existingCamp = kidnapper.getAssociatedStructure(); + if (existingCamp == null) { + // No camp yet - link to this trader's camp + kidnapper.setAssociatedStructure(campId); + ownership.linkKidnapperToCamp(campId, kidnapper.getUUID()); + linked++; + } else if ( + !existingCamp.equals(campId) && + !ownership.isCampAlive(existingCamp) + ) { + // Kidnapper has an orphaned camp ID (structure grid boundary split — + // generateStructureCampId can give different IDs for markers in the + // same structure if they straddle a 128-block grid boundary). + // Re-link to the real camp since the orphan was never registered. + kidnapper.setAssociatedStructure(campId); + ownership.linkKidnapperToCamp(campId, kidnapper.getUUID()); + linked++; + com.tiedup.remake.core.TiedUpMod.LOGGER.info( + "[MarkerBlockEntity] Re-linked kidnapper {} from orphan camp {} to {}", + kidnapper.getNpcName(), + existingCamp.toString().substring(0, 8), + campId.toString().substring(0, 8) + ); + } + } + + com.tiedup.remake.core.TiedUpMod.LOGGER.info( + "[MarkerBlockEntity] Linked {} existing kidnappers to camp {}", + linked, + campId.toString().substring(0, 8) + ); + } + + /** + * Link nearby cells to this camp. + * Cells created by structure placement may have: + * - Their own cellId as ownerId (old behavior) + * - A structure-based camp ID (new behavior) + * This method updates them to use the canonical campId from the trader. + */ + private void linkNearbyCellsToCamp( + ServerLevel serverLevel, + UUID campId, + BlockPos center + ) { + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + + // Find all cells within 50 blocks (typical structure size) + // Reduced from 100 to avoid linking cells from other structures + List nearbyCells = registry.findCellsNear(center, 50); + + // Also calculate what the structure-based camp ID would be for this position + UUID expectedStructureCampId = generateStructureCampId(center); + + com.tiedup.remake.core.TiedUpMod.LOGGER.info( + "[MarkerBlockEntity] linkNearbyCellsToCamp: found {} cells near {}, expectedStructureCampId={}, targetCampId={}", + nearbyCells.size(), + center.toShortString(), + expectedStructureCampId.toString().substring(0, 8), + campId.toString().substring(0, 8) + ); + + int linked = 0; + for (CellDataV2 cell : nearbyCells) { + // Only link cells that are camp-owned + if (cell.isCampOwned()) { + UUID currentOwnerId = cell.getOwnerId(); + + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[MarkerBlockEntity] Checking cell {} at {} (currentOwnerId={}, expectedStructureCampId={}, targetCampId={})", + cell.getId().toString().substring(0, 8), + cell.getCorePos().toShortString(), + currentOwnerId != null + ? currentOwnerId.toString().substring(0, 8) + : "null", + expectedStructureCampId.toString().substring(0, 8), + campId.toString().substring(0, 8) + ); + + // Link if: + // 1. ownerId equals cellId (old structure default) - need to update + // 2. ownerId equals structure-based camp ID but different from target - need to update + // 3. ownerId is already correct (campId) - update index only + boolean needsLink = false; + boolean updateIndexOnly = false; + + if (currentOwnerId != null) { + if (currentOwnerId.equals(campId)) { + // ownerId is already correct - just ensure index is updated + updateIndexOnly = true; + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[MarkerBlockEntity] Cell {} already has correct ownerId, updating index only", + cell.getId().toString().substring(0, 8) + ); + } else if (currentOwnerId.equals(cell.getId())) { + // Old behavior: cell has its own ID as owner + needsLink = true; + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[MarkerBlockEntity] Cell {} needs link (reason: ownerId equals cellId)", + cell.getId().toString().substring(0, 8) + ); + } else if (currentOwnerId.equals(expectedStructureCampId)) { + // New behavior: cell has structure-based ID, update to canonical camp ID + needsLink = true; + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[MarkerBlockEntity] Cell {} needs link (reason: ownerId equals expectedStructureCampId)", + cell.getId().toString().substring(0, 8) + ); + } else { + // Check if cell's camp is orphaned (grid boundary split — + // different markers in the same structure got different camp IDs) + CampOwnership ownership = CampOwnership.get( + serverLevel + ); + if (!ownership.isCampAlive(currentOwnerId)) { + needsLink = true; + com.tiedup.remake.core.TiedUpMod.LOGGER.info( + "[MarkerBlockEntity] Cell {} needs link (reason: orphan camp {} not registered)", + cell.getId().toString().substring(0, 8), + currentOwnerId.toString().substring(0, 8) + ); + } + } + } + + if (needsLink) { + // Update ownerId and index + UUID oldOwnerId = currentOwnerId; + cell.setOwnerId(campId); + registry.updateCampIndex(cell, oldOwnerId); + linked++; + + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[MarkerBlockEntity] Linked cell {} to camp {} (changed ownerId)", + cell.getId().toString().substring(0, 8), + campId.toString().substring(0, 8) + ); + } else if (updateIndexOnly) { + // ownerId is correct, just update index + registry.updateCampIndex(cell, null); + linked++; + + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[MarkerBlockEntity] Linked cell {} to camp {} (index update only)", + cell.getId().toString().substring(0, 8), + campId.toString().substring(0, 8) + ); + } + } + } + + com.tiedup.remake.core.TiedUpMod.LOGGER.info( + "[MarkerBlockEntity] Linked {} cells to camp {}", + linked, + campId.toString().substring(0, 8) + ); + } + + /** + * Spawn ~10 kidnappers in an annulus around the camp center (40-100 blocks). + */ + private void spawnCampKidnappers( + ServerLevel serverLevel, + UUID campId, + BlockPos center + ) { + CampOwnership ownership = CampOwnership.get(serverLevel); + int spawned = 0; + int targetCount = 10; + + // Try to spawn 10 kidnappers + for ( + int attempt = 0; + attempt < 50 && spawned < targetCount; + attempt++ + ) { + // Random angle + double angle = serverLevel.random.nextDouble() * Math.PI * 2; + + // Random distance in the 40-100 block annulus + double distance = 40 + serverLevel.random.nextDouble() * 60; + + int targetX = center.getX() + (int) (Math.cos(angle) * distance); + int targetZ = center.getZ() + (int) (Math.sin(angle) * distance); + + // Find valid spawn position + BlockPos spawnPos = findValidSpawnPosition( + serverLevel, + targetX, + targetZ, + center.getY() + ); + if (spawnPos == null) { + continue; + } + + // Spawn kidnapper + EntityKidnapper kidnapper = ModEntities.KIDNAPPER.get().create( + serverLevel + ); + if (kidnapper != null) { + kidnapper.moveTo( + spawnPos.getX() + 0.5, + spawnPos.getY(), + spawnPos.getZ() + 0.5, + serverLevel.random.nextFloat() * 360F, + 0.0F + ); + kidnapper.finalizeSpawn( + serverLevel, + serverLevel.getCurrentDifficultyAt(spawnPos), + MobSpawnType.STRUCTURE, + null, + null + ); + + // Link to camp + kidnapper.setAssociatedStructure(campId); + serverLevel.addFreshEntity(kidnapper); + ownership.linkKidnapperToCamp(campId, kidnapper.getUUID()); + spawned++; + } + } + + com.tiedup.remake.core.TiedUpMod.LOGGER.info( + "[MarkerBlockEntity] Spawned {} new kidnappers for camp {}", + spawned, + campId.toString().substring(0, 8) + ); + } + + /** + * Find a valid spawn position at the given XZ coordinates. + * Checks for solid ground with air above. + */ + @Nullable + private BlockPos findValidSpawnPosition( + ServerLevel level, + int x, + int z, + int baseY + ) { + // Search within ±20 blocks of base Y + for (int yOffset = 0; yOffset <= 20; yOffset++) { + // Check above first + BlockPos above = new BlockPos(x, baseY + yOffset, z); + if (isValidSpawnPosition(level, above)) { + return above; + } + + // Then check below + if (yOffset > 0) { + BlockPos below = new BlockPos(x, baseY - yOffset, z); + if (isValidSpawnPosition(level, below)) { + return below; + } + } + } + return null; + } + + /** + * Check if a position is valid for spawning. + * Requires solid ground below and 2 blocks of air. + */ + private boolean isValidSpawnPosition(ServerLevel level, BlockPos pos) { + // Ground must be solid + if (!level.getBlockState(pos.below()).isSolid()) { + return false; + } + // Feet and head must be non-solid (air or passable) + if ( + level.getBlockState(pos).isSolid() || + level.getBlockState(pos.above()).isSolid() + ) { + return false; + } + // Not in water + if ( + !level.getFluidState(pos).isEmpty() || + !level.getFluidState(pos.above()).isEmpty() + ) { + return false; + } + return true; + } + + /** + * Spawn a Maid at this marker position. + * Links to the nearest SlaveTrader. + */ + private void spawnMaid(ServerLevel serverLevel) { + EntityMaid maid = ModEntities.MAID.get().create(serverLevel); + if (maid != null) { + double x = worldPosition.getX() + 0.5; + double y = worldPosition.getY() + 1.0; + double z = worldPosition.getZ() + 0.5; + + maid.moveTo(x, y, z, serverLevel.random.nextFloat() * 360F, 0.0F); + maid.finalizeSpawn( + serverLevel, + serverLevel.getCurrentDifficultyAt(worldPosition), + MobSpawnType.STRUCTURE, + null, + null + ); + + serverLevel.addFreshEntity(maid); + + // Find nearest trader and link + CampOwnership ownership = CampOwnership.get(serverLevel); + CampOwnership.CampData nearestCamp = ownership.findNearestAliveCamp( + worldPosition, + 50 + ); + if (nearestCamp != null) { + maid.setMasterTraderUUID(nearestCamp.getTraderUUID()); + + // Update camp with maid UUID + nearestCamp.setMaidUUID(maid.getUUID()); + + // Update trader with maid UUID + var traderEntity = serverLevel.getEntity( + nearestCamp.getTraderUUID() + ); + if (traderEntity instanceof EntitySlaveTrader trader) { + trader.setMaidUUID(maid.getUUID()); + } + + com.tiedup.remake.core.TiedUpMod.LOGGER.info( + "[MarkerBlockEntity] Spawned Maid at {} linked to trader {}", + worldPosition.toShortString(), + nearestCamp.getTraderUUID().toString().substring(0, 8) + ); + } else { + com.tiedup.remake.core.TiedUpMod.LOGGER.warn( + "[MarkerBlockEntity] Spawned Maid at {} but no trader found nearby", + worldPosition.toShortString() + ); + } + + hasSpawned = true; + setChangedAndSync(); + } + } + + /** + * Spawn a Merchant at this marker position. + * Links to the nearest camp/trader. + */ + private void spawnMerchant(ServerLevel serverLevel) { + EntityKidnapperMerchant merchant = + ModEntities.KIDNAPPER_MERCHANT.get().create(serverLevel); + if (merchant != null) { + double x = worldPosition.getX() + 0.5; + double y = worldPosition.getY() + 1.0; + double z = worldPosition.getZ() + 0.5; + + merchant.moveTo( + x, + y, + z, + serverLevel.random.nextFloat() * 360F, + 0.0F + ); + merchant.finalizeSpawn( + serverLevel, + serverLevel.getCurrentDifficultyAt(worldPosition), + MobSpawnType.STRUCTURE, + null, + null + ); + + serverLevel.addFreshEntity(merchant); + + // Find nearest camp and link + CampOwnership ownership = CampOwnership.get(serverLevel); + CampOwnership.CampData nearestCamp = ownership.findNearestAliveCamp( + worldPosition, + 100 + ); + if (nearestCamp != null) { + merchant.setAssociatedStructure(nearestCamp.getCampId()); + + com.tiedup.remake.core.TiedUpMod.LOGGER.info( + "[MarkerBlockEntity] Spawned Merchant at {} linked to camp {}", + worldPosition.toShortString(), + nearestCamp.getCampId().toString().substring(0, 8) + ); + } else { + com.tiedup.remake.core.TiedUpMod.LOGGER.warn( + "[MarkerBlockEntity] Spawned Merchant at {} but no camp found nearby", + worldPosition.toShortString() + ); + } + + hasSpawned = true; + setChangedAndSync(); + } + } + + // ==================== SPAWN RESET ==================== + + /** + * Reset the hasSpawned flag to false. + * Used by admin commands to prepare markers for structure saving. + */ + public void resetHasSpawned() { + this.hasSpawned = false; + setChangedAndSync(); + } + + /** + * Check if this marker has already spawned its NPC. + */ + public boolean hasSpawned() { + return hasSpawned; + } + + /** + * Check if this marker is a spawn type (SPAWNER, TRADER_SPAWN, MAID_SPAWN, MERCHANT_SPAWN). + */ + public boolean isSpawnMarker() { + return ( + markerType == MarkerType.SPAWNER || + markerType == MarkerType.TRADER_SPAWN || + markerType == MarkerType.MAID_SPAWN || + markerType == MarkerType.MERCHANT_SPAWN + ); + } + + // ==================== STRUCTURE CAMP ID ==================== + + /** + * Generate a deterministic camp ID based on structure position. + * Uses a grid-based approach: rounds position to nearest 32 blocks, + * ensuring all markers in the same structure get the same camp ID. + * + * @param pos The position of the marker + * @return A deterministic UUID for this structure + */ + private UUID generateStructureCampId(BlockPos pos) { + // Round to nearest 128 blocks (structure grid) + // Increased from 32 to 128 to ensure entire structure (40-80 blocks) falls in same grid cell + int gridX = (pos.getX() / 128) * 128; + int gridZ = (pos.getZ() / 128) * 128; + + // Create a deterministic UUID from the grid position + // Using a simple hash-based approach + long mostSigBits = ((long) gridX << 32) | (gridZ & 0xFFFFFFFFL); + long leastSigBits = 0x8000000000000000L | (gridX ^ gridZ); // Version 4 UUID-like + + return new UUID(mostSigBits, leastSigBits); + } + + // ==================== NBT SERIALIZATION ==================== + + /** Cached cell positions from NBT (for structure placement) */ + private CompoundTag cachedCellPositions = null; + + @Override + public void load(CompoundTag tag) { + super.load(tag); + + if (tag.contains("cellId")) { + this.cellId = tag.getUUID("cellId"); + } else { + this.cellId = null; + } + + if (tag.contains("markerType")) { + this.markerType = MarkerType.fromString( + tag.getString("markerType") + ); + } + + if (tag.contains("hasSpawned")) { + this.hasSpawned = tag.getBoolean("hasSpawned"); + } + + // Debug logging to understand structure loading + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[MarkerBlockEntity] load() at {}: markerType={}, hasSpawned={}, cellId={}", + worldPosition != null ? worldPosition.toShortString() : "unknown", + markerType.getSerializedName(), + hasSpawned, + cellId != null ? cellId.toString().substring(0, 8) : "null" + ); + + // Cache cell positions from structure NBT (will be applied in onLoad) + if (tag.contains("cellPositions")) { + this.cachedCellPositions = tag.getCompound("cellPositions"); + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[MarkerBlockEntity] load(): Found cellPositions in NBT for cell {}: {}", + cellId != null ? cellId.toString().substring(0, 8) : "null", + cachedCellPositions.getAllKeys() + ); + } + } + + @Override + protected void saveAdditional(CompoundTag tag) { + super.saveAdditional(tag); + + if (cellId != null) { + tag.putUUID("cellId", cellId); + + // If this is the spawn point marker, save all cell positions + // This allows structures to preserve cell data + if (level instanceof ServerLevel serverLevel) { + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + CellDataV2 cell = registry.getCell(cellId); + if ( + cell != null && + (cell.getCorePos().equals(worldPosition) || + (cell.getSpawnPoint() != null && + cell.getSpawnPoint().equals(worldPosition))) + ) { + // Save positions relative to spawn point for structure portability + CompoundTag positionsTag = saveCellPositionsRelative(cell); + if (!positionsTag.isEmpty()) { + tag.put("cellPositions", positionsTag); + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[MarkerBlockEntity] Saved cellPositions for cell {} at {}: {}", + cellId.toString().substring(0, 8), + worldPosition.toShortString(), + positionsTag.getAllKeys() + ); + } + } else if (cell == null) { + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[MarkerBlockEntity] saveAdditional: cell {} not found in registry", + cellId.toString().substring(0, 8) + ); + } + } + } + tag.putString("markerType", markerType.getSerializedName()); + + if (hasSpawned) { + tag.putBoolean("hasSpawned", true); + } + } + + /** + * Save cell positions as relative offsets from spawn point. + * This makes the structure portable to any location. + */ + private CompoundTag saveCellPositionsRelative(CellDataV2 cell) { + CompoundTag positionsTag = new CompoundTag(); + + // Save V2 typed positions as relative offsets + savePositionList( + positionsTag, + MarkerType.DELIVERY.getSerializedName(), + cell.getDeliveryPoint() != null + ? List.of(cell.getDeliveryPoint()) + : List.of() + ); + savePositionList( + positionsTag, + MarkerType.BED.getSerializedName(), + cell.getBeds() + ); + savePositionList( + positionsTag, + MarkerType.ANCHOR.getSerializedName(), + cell.getAnchors() + ); + savePositionList( + positionsTag, + MarkerType.DOOR.getSerializedName(), + cell.getDoors() + ); + savePositionList( + positionsTag, + "pathWaypoints", + cell.getPathWaypoints() + ); + + return positionsTag; + } + + /** Save a list of positions as relative offsets from this marker's worldPosition. */ + private void savePositionList( + CompoundTag tag, + String key, + java.util.Collection positions + ) { + if (positions.isEmpty()) return; + net.minecraft.nbt.ListTag list = new net.minecraft.nbt.ListTag(); + for (BlockPos pos : positions) { + list.add( + net.minecraft.nbt.NbtUtils.writeBlockPos( + pos.subtract(worldPosition) + ) + ); + } + tag.put(key, list); + } + + // ==================== NETWORK SYNC ==================== + + protected void setChangedAndSync() { + if (level != null) { + setChanged(); + level.sendBlockUpdated( + worldPosition, + getBlockState(), + getBlockState(), + 3 + ); + } + } + + @Override + public CompoundTag getUpdateTag() { + CompoundTag tag = super.getUpdateTag(); + if (cellId != null) { + tag.putUUID("cellId", cellId); + } + tag.putString("markerType", markerType.getSerializedName()); + return tag; + } + + @Nullable + @Override + public Packet getUpdatePacket() { + return ClientboundBlockEntityDataPacket.create(this); + } + + @Override + public void handleUpdateTag(CompoundTag tag) { + if (tag.contains("cellId")) { + this.cellId = tag.getUUID("cellId"); + } + if (tag.contains("markerType")) { + this.markerType = MarkerType.fromString( + tag.getString("markerType") + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/blocks/entity/ModBlockEntities.java b/src/main/java/com/tiedup/remake/blocks/entity/ModBlockEntities.java new file mode 100644 index 0000000..62ba712 --- /dev/null +++ b/src/main/java/com/tiedup/remake/blocks/entity/ModBlockEntities.java @@ -0,0 +1,114 @@ +package com.tiedup.remake.blocks.entity; + +import com.tiedup.remake.blocks.ModBlocks; +import com.tiedup.remake.core.TiedUpMod; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegistryObject; + +/** + * Mod Block Entities Registration + * + * Phase 16: Blocks + * + * Handles registration of all TiedUp block entities using DeferredRegister. + */ +public class ModBlockEntities { + + // DeferredRegister for block entity types + public static final DeferredRegister> BLOCK_ENTITIES = + DeferredRegister.create( + ForgeRegistries.BLOCK_ENTITY_TYPES, + TiedUpMod.MOD_ID + ); + + // ======================================== + // TRAP BLOCK ENTITIES + // ======================================== + + /** + * Trap block entity - stores bondage items for rope trap. + */ + public static final RegistryObject> TRAP = + BLOCK_ENTITIES.register("trap", () -> + BlockEntityType.Builder.of( + TrapBlockEntity::new, + ModBlocks.ROPE_TRAP.get() + ).build(null) + ); + + // LOW FIX: Removed BED BLOCK ENTITIES section - feature not implemented + + // ======================================== + // BOMB BLOCK ENTITIES + // ======================================== + + /** + * Kidnap bomb block entity - stores bondage items for explosion effect. + */ + public static final RegistryObject< + BlockEntityType + > KIDNAP_BOMB = BLOCK_ENTITIES.register("kidnap_bomb", () -> + BlockEntityType.Builder.of( + KidnapBombBlockEntity::new, + ModBlocks.KIDNAP_BOMB.get() + ).build(null) + ); + + // ======================================== + // CHEST BLOCK ENTITIES + // ======================================== + + /** + * Trapped chest block entity - stores bondage items for when player opens it. + */ + public static final RegistryObject< + BlockEntityType + > TRAPPED_CHEST = BLOCK_ENTITIES.register("trapped_chest", () -> + BlockEntityType.Builder.of( + TrappedChestBlockEntity::new, + ModBlocks.TRAPPED_CHEST.get() + ).build(null) + ); + + // ======================================== + // CELL SYSTEM BLOCK ENTITIES + // ======================================== + + /** + * Marker block entity - stores cell UUID for cell system. + */ + public static final RegistryObject< + BlockEntityType + > MARKER = BLOCK_ENTITIES.register("marker", () -> + BlockEntityType.Builder.of( + MarkerBlockEntity::new, + ModBlocks.MARKER.get() + ).build(null) + ); + + /** + * Iron bar door block entity - stores lock state and key UUID. + */ + public static final RegistryObject< + BlockEntityType + > IRON_BAR_DOOR = BLOCK_ENTITIES.register("iron_bar_door", () -> + BlockEntityType.Builder.of( + IronBarDoorBlockEntity::new, + ModBlocks.IRON_BAR_DOOR.get() + ).build(null) + ); + + /** + * Cell Core block entity - stores cell ID, spawn/delivery points, and disguise. + */ + public static final RegistryObject< + BlockEntityType + > CELL_CORE = BLOCK_ENTITIES.register("cell_core", () -> + BlockEntityType.Builder.of( + CellCoreBlockEntity::new, + ModBlocks.CELL_CORE.get() + ).build(null) + ); +} diff --git a/src/main/java/com/tiedup/remake/blocks/entity/TrapBlockEntity.java b/src/main/java/com/tiedup/remake/blocks/entity/TrapBlockEntity.java new file mode 100644 index 0000000..d584f02 --- /dev/null +++ b/src/main/java/com/tiedup/remake/blocks/entity/TrapBlockEntity.java @@ -0,0 +1,28 @@ +package com.tiedup.remake.blocks.entity; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.state.BlockState; + +/** + * BlockEntity for rope trap blocks. + * + * Phase 16: Blocks + * + * Stores bondage items that will be applied when an entity walks on the trap. + * Simple extension of BondageItemBlockEntity. + * + * Based on original TileEntityTrap from 1.12.2 + */ +public class TrapBlockEntity extends BondageItemBlockEntity { + + public TrapBlockEntity(BlockPos pos, BlockState state) { + super(ModBlockEntities.TRAP.get(), pos, state); + } + + /** + * Constructor with off-mode for tooltip reading. + */ + public TrapBlockEntity(BlockPos pos, BlockState state, boolean offMode) { + super(ModBlockEntities.TRAP.get(), pos, state, offMode); + } +} diff --git a/src/main/java/com/tiedup/remake/blocks/entity/TrappedChestBlockEntity.java b/src/main/java/com/tiedup/remake/blocks/entity/TrappedChestBlockEntity.java new file mode 100644 index 0000000..fe6ab1e --- /dev/null +++ b/src/main/java/com/tiedup/remake/blocks/entity/TrappedChestBlockEntity.java @@ -0,0 +1,230 @@ +package com.tiedup.remake.blocks.entity; + +import com.tiedup.remake.items.base.*; +import com.tiedup.remake.items.clothes.GenericClothes; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientGamePacketListener; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +/** + * BlockEntity for trapped chest blocks. + * + * Phase 16: Blocks + * + * Extends ChestBlockEntity for proper chest behavior, + * but also stores bondage items for the trap. + */ +public class TrappedChestBlockEntity + extends ChestBlockEntity + implements IBondageItemHolder +{ + + // Bondage item storage (separate from chest inventory) + private ItemStack bind = ItemStack.EMPTY; + private ItemStack gag = ItemStack.EMPTY; + private ItemStack blindfold = ItemStack.EMPTY; + private ItemStack earplugs = ItemStack.EMPTY; + private ItemStack collar = ItemStack.EMPTY; + private ItemStack clothes = ItemStack.EMPTY; + + public TrappedChestBlockEntity(BlockPos pos, BlockState state) { + super(ModBlockEntities.TRAPPED_CHEST.get(), pos, state); + } + + // ======================================== + // BONDAGE ITEM HOLDER IMPLEMENTATION + // ======================================== + + @Override + public ItemStack getBind() { + return bind; + } + + @Override + public void setBind(ItemStack stack) { + if (stack.isEmpty() || stack.getItem() instanceof ItemBind) { + this.bind = stack; + setChangedAndSync(); + } + } + + @Override + public ItemStack getGag() { + return gag; + } + + @Override + public void setGag(ItemStack stack) { + if (stack.isEmpty() || stack.getItem() instanceof ItemGag) { + this.gag = stack; + setChangedAndSync(); + } + } + + @Override + public ItemStack getBlindfold() { + return blindfold; + } + + @Override + public void setBlindfold(ItemStack stack) { + if (stack.isEmpty() || stack.getItem() instanceof ItemBlindfold) { + this.blindfold = stack; + setChangedAndSync(); + } + } + + @Override + public ItemStack getEarplugs() { + return earplugs; + } + + @Override + public void setEarplugs(ItemStack stack) { + if (stack.isEmpty() || stack.getItem() instanceof ItemEarplugs) { + this.earplugs = stack; + setChangedAndSync(); + } + } + + @Override + public ItemStack getCollar() { + return collar; + } + + @Override + public void setCollar(ItemStack stack) { + if (stack.isEmpty() || stack.getItem() instanceof ItemCollar) { + this.collar = stack; + setChangedAndSync(); + } + } + + @Override + public ItemStack getClothes() { + return clothes; + } + + @Override + public void setClothes(ItemStack stack) { + if (stack.isEmpty() || stack.getItem() instanceof GenericClothes) { + this.clothes = stack; + setChangedAndSync(); + } + } + + @Override + public boolean isArmed() { + return ( + !bind.isEmpty() || + !gag.isEmpty() || + !blindfold.isEmpty() || + !earplugs.isEmpty() || + !collar.isEmpty() || + !clothes.isEmpty() + ); + } + + @Override + public void readBondageData(CompoundTag tag) { + if (tag.contains("bind")) bind = ItemStack.of(tag.getCompound("bind")); + if (tag.contains("gag")) gag = ItemStack.of(tag.getCompound("gag")); + if (tag.contains("blindfold")) blindfold = ItemStack.of( + tag.getCompound("blindfold") + ); + if (tag.contains("earplugs")) earplugs = ItemStack.of( + tag.getCompound("earplugs") + ); + if (tag.contains("collar")) collar = ItemStack.of( + tag.getCompound("collar") + ); + if (tag.contains("clothes")) clothes = ItemStack.of( + tag.getCompound("clothes") + ); + } + + @Override + public CompoundTag writeBondageData(CompoundTag tag) { + if (!bind.isEmpty()) tag.put("bind", bind.save(new CompoundTag())); + if (!gag.isEmpty()) tag.put("gag", gag.save(new CompoundTag())); + if (!blindfold.isEmpty()) tag.put( + "blindfold", + blindfold.save(new CompoundTag()) + ); + if (!earplugs.isEmpty()) tag.put( + "earplugs", + earplugs.save(new CompoundTag()) + ); + if (!collar.isEmpty()) tag.put( + "collar", + collar.save(new CompoundTag()) + ); + if (!clothes.isEmpty()) tag.put( + "clothes", + clothes.save(new CompoundTag()) + ); + return tag; + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + @Override + public void load(CompoundTag tag) { + super.load(tag); + readBondageData(tag); + } + + @Override + protected void saveAdditional(CompoundTag tag) { + super.saveAdditional(tag); + writeBondageData(tag); + } + + // ======================================== + // NETWORK SYNC + // ======================================== + + /** + * Mark dirty and sync to clients. + * Ensures bondage trap state is visible to all players. + */ + protected void setChangedAndSync() { + if (this.level != null) { + this.setChanged(); + // Notify clients of block update + this.level.sendBlockUpdated( + this.worldPosition, + this.getBlockState(), + this.getBlockState(), + 3 + ); + } + } + + @Override + public CompoundTag getUpdateTag() { + CompoundTag tag = super.getUpdateTag(); + writeBondageData(tag); + return tag; + } + + @Nullable + @Override + public Packet getUpdatePacket() { + return ClientboundBlockEntityDataPacket.create(this); + } + + @Override + public void handleUpdateTag(CompoundTag tag) { + super.handleUpdateTag(tag); + readBondageData(tag); + } +} diff --git a/src/main/java/com/tiedup/remake/bounty/Bounty.java b/src/main/java/com/tiedup/remake/bounty/Bounty.java new file mode 100644 index 0000000..c9c99fc --- /dev/null +++ b/src/main/java/com/tiedup/remake/bounty/Bounty.java @@ -0,0 +1,210 @@ +package com.tiedup.remake.bounty; + +import java.util.UUID; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.item.ItemStack; + +/** + * Represents a single bounty placed on a player. + * + * Phase 17: Bounty System + * + * A bounty is created when a player (client) offers a reward for capturing + * another player (target). Anyone who delivers the target to the client + * receives the reward. + */ +public class Bounty { + + private final String id; + private final UUID clientId; + private final UUID targetId; + private String clientName; + private String targetName; + private final ItemStack reward; + private final long creationTime; // System.currentTimeMillis() + private final int durationSeconds; // How long the bounty lasts + + /** + * Create a new bounty. + */ + public Bounty( + UUID clientId, + String clientName, + UUID targetId, + String targetName, + ItemStack reward, + int durationSeconds + ) { + this.id = UUID.randomUUID().toString(); + this.clientId = clientId; + this.clientName = clientName; + this.targetId = targetId; + this.targetName = targetName; + this.reward = reward.copy(); + this.creationTime = System.currentTimeMillis(); + this.durationSeconds = durationSeconds; + } + + /** + * Create a bounty from NBT data. + */ + private Bounty( + String id, + UUID clientId, + String clientName, + UUID targetId, + String targetName, + ItemStack reward, + long creationTime, + int durationSeconds + ) { + this.id = id; + this.clientId = clientId; + this.clientName = clientName; + this.targetId = targetId; + this.targetName = targetName; + this.reward = reward; + this.creationTime = creationTime; + this.durationSeconds = durationSeconds; + } + + // ==================== GETTERS ==================== + + public String getId() { + return id; + } + + public UUID getClientId() { + return clientId; + } + + public UUID getTargetId() { + return targetId; + } + + public String getClientName() { + return clientName; + } + + public String getTargetName() { + return targetName; + } + + public ItemStack getReward() { + return reward.copy(); + } + + public int getDurationSeconds() { + return durationSeconds; + } + + public long getCreationTime() { + return creationTime; + } + + // ==================== TIME CALCULATIONS ==================== + + /** + * Get remaining time in seconds. + */ + public int getSecondsRemaining() { + long elapsed = (System.currentTimeMillis() - creationTime) / 1000; + return Math.max(0, durationSeconds - (int) elapsed); + } + + /** + * Get remaining time as [hours, minutes]. + */ + public int[] getRemainingTime() { + int seconds = getSecondsRemaining(); + int hours = seconds / 3600; + int minutes = (seconds % 3600) / 60; + return new int[] { hours, minutes }; + } + + /** + * Check if bounty has expired. + */ + public boolean isExpired() { + return getSecondsRemaining() <= 0; + } + + // ==================== PLAYER CHECKS ==================== + + /** + * Check if player is the client (creator) of this bounty. + */ + public boolean isClient(UUID playerId) { + return clientId.equals(playerId); + } + + /** + * Check if player is the target of this bounty. + */ + public boolean isTarget(UUID playerId) { + return targetId.equals(playerId); + } + + /** + * Check if bounty matches (client receives target). + */ + public boolean matches(UUID clientId, UUID targetId) { + return this.clientId.equals(clientId) && this.targetId.equals(targetId); + } + + // ==================== REWARD DESCRIPTION ==================== + + /** + * Get a description of the reward (e.g., "Diamond x 5"). + */ + public String getRewardDescription() { + if (reward.isEmpty()) { + return "Nothing"; + } + return reward.getHoverName().getString() + " x " + reward.getCount(); + } + + // ==================== NAME UPDATES ==================== + + /** + * Update client name (for when player was offline during creation). + */ + public void setClientName(String name) { + this.clientName = name; + } + + /** + * Update target name (for when player was offline during creation). + */ + public void setTargetName(String name) { + this.targetName = name; + } + + // ==================== NBT SERIALIZATION ==================== + + public CompoundTag save() { + CompoundTag tag = new CompoundTag(); + tag.putString("id", id); + tag.putUUID("clientId", clientId); + tag.putUUID("targetId", targetId); + tag.putString("clientName", clientName); + tag.putString("targetName", targetName); + tag.put("reward", reward.save(new CompoundTag())); + tag.putLong("creationTime", creationTime); + tag.putInt("durationSeconds", durationSeconds); + return tag; + } + + public static Bounty load(CompoundTag tag) { + return new Bounty( + tag.getString("id"), + tag.getUUID("clientId"), + tag.getString("clientName"), + tag.getUUID("targetId"), + tag.getString("targetName"), + ItemStack.of(tag.getCompound("reward")), + tag.getLong("creationTime"), + tag.getInt("durationSeconds") + ); + } +} diff --git a/src/main/java/com/tiedup/remake/bounty/BountyManager.java b/src/main/java/com/tiedup/remake/bounty/BountyManager.java new file mode 100644 index 0000000..fe1c71c --- /dev/null +++ b/src/main/java/com/tiedup/remake/bounty/BountyManager.java @@ -0,0 +1,407 @@ +package com.tiedup.remake.bounty; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.core.SettingsAccessor; +import java.util.*; +import javax.annotation.Nullable; +import net.minecraft.ChatFormatting; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.saveddata.SavedData; + +/** + * World-saved data manager for bounties. + * + * Phase 17: Bounty System + * + * Manages all active bounties, handles expiration, delivery rewards, + * and stores bounties for offline players. + */ +public class BountyManager extends SavedData { + + private static final String DATA_NAME = "tiedup_bounties"; + + /** Pending rewards expire after 30 real days (in milliseconds). */ + private static final long PENDING_REWARD_EXPIRATION_MS = + 30L * 24 * 60 * 60 * 1000; + + // Active bounties + private final List bounties = new ArrayList<>(); + + // Bounties for offline players (to return reward when they log in) + // Stored with timestamp via Bounty.creationTime + durationSeconds + private final List pendingRewards = new ArrayList<>(); + + // ==================== CONSTRUCTION ==================== + + public BountyManager() {} + + public static BountyManager create() { + return new BountyManager(); + } + + public static BountyManager load(CompoundTag tag) { + BountyManager manager = new BountyManager(); + + // Load active bounties + ListTag bountiesTag = tag.getList("bounties", Tag.TAG_COMPOUND); + for (int i = 0; i < bountiesTag.size(); i++) { + manager.bounties.add(Bounty.load(bountiesTag.getCompound(i))); + } + + // Load pending rewards (with expiration cleanup) + ListTag pendingTag = tag.getList("pendingRewards", Tag.TAG_COMPOUND); + int expiredCount = 0; + for (int i = 0; i < pendingTag.size(); i++) { + Bounty bounty = Bounty.load(pendingTag.getCompound(i)); + if (!isPendingRewardExpired(bounty)) { + manager.pendingRewards.add(bounty); + } else { + expiredCount++; + } + } + + if (expiredCount > 0) { + TiedUpMod.LOGGER.info( + "[BOUNTY] Cleaned up {} expired pending rewards (>30 days)", + expiredCount + ); + } + + TiedUpMod.LOGGER.info( + "[BOUNTY] Loaded {} active bounties, {} pending rewards", + manager.bounties.size(), + manager.pendingRewards.size() + ); + + return manager; + } + + @Override + public CompoundTag save(CompoundTag tag) { + // Save active bounties + ListTag bountiesTag = new ListTag(); + for (Bounty bounty : bounties) { + bountiesTag.add(bounty.save()); + } + tag.put("bounties", bountiesTag); + + // Save pending rewards + ListTag pendingTag = new ListTag(); + for (Bounty bounty : pendingRewards) { + pendingTag.add(bounty.save()); + } + tag.put("pendingRewards", pendingTag); + + return tag; + } + + // ==================== ACCESS ==================== + + /** + * Get the BountyManager for a world. + */ + public static BountyManager get(ServerLevel level) { + return level + .getDataStorage() + .computeIfAbsent( + BountyManager::load, + BountyManager::create, + DATA_NAME + ); + } + + /** + * Get the BountyManager from a server. + */ + public static BountyManager get(MinecraftServer server) { + ServerLevel overworld = server.overworld(); + return get(overworld); + } + + // ==================== BOUNTY MANAGEMENT ==================== + + /** + * Get all active bounties (removes expired ones). + */ + public List getBounties(ServerLevel level) { + // Clean up expired bounties + Iterator it = bounties.iterator(); + while (it.hasNext()) { + Bounty bounty = it.next(); + if (bounty.isExpired()) { + it.remove(); + onBountyExpired(level, bounty); + } + } + setDirty(); + return new ArrayList<>(bounties); + } + + /** + * Add a new bounty. + */ + public void addBounty(Bounty bounty) { + bounties.add(bounty); + setDirty(); + TiedUpMod.LOGGER.info( + "[BOUNTY] New bounty: {} on {} by {}", + bounty.getId(), + bounty.getTargetName(), + bounty.getClientName() + ); + } + + /** + * Get a bounty by ID. + */ + @Nullable + public Bounty getBountyById(String id) { + for (Bounty bounty : bounties) { + if (bounty.getId().equals(id)) { + return bounty; + } + } + return null; + } + + /** + * Cancel a bounty. + * Only the client or an admin can cancel. + * If client cancels, they get their reward back. + */ + public boolean cancelBounty(ServerPlayer player, String bountyId) { + Bounty bounty = getBountyById(bountyId); + if (bounty == null) { + return false; + } + + boolean isAdmin = player.hasPermissions(2); + boolean isClient = bounty.isClient(player.getUUID()); + + if (!isClient && !isAdmin) { + return false; + } + + bounties.remove(bounty); + setDirty(); + + // Return reward to client (or drop if admin cancelled) + if (isClient) { + giveReward(player, bounty); + broadcastMessage( + player.server, + player.getName().getString() + + " cancelled their bounty on " + + bounty.getTargetName() + ); + } else { + onBountyExpired(player.serverLevel(), bounty); + broadcastMessage( + player.server, + player.getName().getString() + + " (admin) cancelled bounty on " + + bounty.getTargetName() + ); + } + + return true; + } + + // ==================== DELIVERY ==================== + + /** + * Try to deliver a captive to a client. + * Called when a hunter brings a captive near the bounty client. + * + * @param hunter The player delivering the captive + * @param client The bounty client receiving the captive + * @param target The captive being delivered + * @return true if bounty was fulfilled + */ + public boolean tryDeliverCaptive( + ServerPlayer hunter, + ServerPlayer client, + ServerPlayer target + ) { + boolean delivered = false; + + Iterator it = bounties.iterator(); + while (it.hasNext()) { + Bounty bounty = it.next(); + + // Skip expired + if (bounty.isExpired()) { + continue; + } + + // Check if this bounty matches + if (bounty.matches(client.getUUID(), target.getUUID())) { + it.remove(); + setDirty(); + + // Give reward to hunter + giveReward(hunter, bounty); + delivered = true; + + broadcastMessage( + hunter.server, + hunter.getName().getString() + + " delivered " + + target.getName().getString() + + " to " + + client.getName().getString() + + " for " + + bounty.getRewardDescription() + + "!" + ); + + TiedUpMod.LOGGER.info( + "[BOUNTY] Delivered: {} brought {} to {}", + hunter.getName().getString(), + target.getName().getString(), + client.getName().getString() + ); + } + } + + return delivered; + } + + // ==================== EXPIRATION ==================== + + /** + * Handle bounty expiration. + * Returns reward to client if online, otherwise stores for later. + */ + private void onBountyExpired(ServerLevel level, Bounty bounty) { + ServerPlayer client = level + .getServer() + .getPlayerList() + .getPlayer(bounty.getClientId()); + + if (client != null) { + // Client is online - return reward + giveReward(client, bounty); + SystemMessageManager.sendChatToPlayer( + client, + "Your bounty on " + + bounty.getTargetName() + + " has expired. Reward returned.", + ChatFormatting.YELLOW + ); + } else { + // Client is offline - store for later + pendingRewards.add(bounty); + setDirty(); + } + + TiedUpMod.LOGGER.info( + "[BOUNTY] Expired: {} on {}", + bounty.getClientName(), + bounty.getTargetName() + ); + } + + /** + * Check for pending rewards when a player joins. + */ + public void onPlayerJoin(ServerPlayer player) { + Iterator it = pendingRewards.iterator(); + while (it.hasNext()) { + Bounty bounty = it.next(); + if (bounty.isClient(player.getUUID())) { + giveReward(player, bounty); + it.remove(); + setDirty(); + + SystemMessageManager.sendChatToPlayer( + player, + "Your expired bounty reward has been returned: " + + bounty.getRewardDescription(), + ChatFormatting.YELLOW + ); + } + } + } + + // ==================== VALIDATION ==================== + + /** + * Check if a player can create a new bounty. + */ + public boolean canCreateBounty(ServerPlayer player, ServerLevel level) { + if (player.hasPermissions(2)) { + return true; // Admins bypass limit + } + + int count = 0; + for (Bounty bounty : bounties) { + if (bounty.isClient(player.getUUID())) { + count++; + } + } + + int max = SettingsAccessor.getMaxBounties(level.getGameRules()); + return count < max; + } + + /** + * Get the number of active bounties for a player. + */ + public int getBountyCount(UUID playerId) { + int count = 0; + for (Bounty bounty : bounties) { + if (bounty.isClient(playerId)) { + count++; + } + } + return count; + } + + // ==================== HELPERS ==================== + + private void giveReward(ServerPlayer player, Bounty bounty) { + ItemStack reward = bounty.getReward(); + if (!reward.isEmpty()) { + if (!player.getInventory().add(reward)) { + // Inventory full - drop at feet + player.drop(reward, false); + } + } + } + + private void broadcastMessage(MinecraftServer server, String message) { + server + .getPlayerList() + .broadcastSystemMessage( + Component.literal("[Bounty] " + message).withStyle( + ChatFormatting.GOLD + ), + false + ); + } + + /** + * Check if a pending reward has been waiting too long (>30 days). + * Uses the bounty's original expiration time as baseline. + */ + private static boolean isPendingRewardExpired(Bounty bounty) { + // Calculate when the bounty originally expired + // creationTime is in milliseconds, durationSeconds needs conversion + long expirationTime = + bounty.getCreationTime() + (bounty.getDurationSeconds() * 1000L); + long now = System.currentTimeMillis(); + + // Check if it's been more than 30 days since expiration + return (now - expirationTime) > PENDING_REWARD_EXPIRATION_MS; + } +} diff --git a/src/main/java/com/tiedup/remake/cells/CampLifecycleManager.java b/src/main/java/com/tiedup/remake/cells/CampLifecycleManager.java new file mode 100644 index 0000000..c1bc7e8 --- /dev/null +++ b/src/main/java/com/tiedup/remake/cells/CampLifecycleManager.java @@ -0,0 +1,332 @@ +package com.tiedup.remake.cells; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.v2.BodyRegionV2; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * Handles camp lifecycle events: camp death, prisoner freeing, and camp defense alerts. + * + *

This is a stateless utility class. All state lives in {@link CampOwnership} + * (the SavedData singleton). Methods here orchestrate multi-system side effects + * that don't belong in the data layer. + */ +public final class CampLifecycleManager { + + private CampLifecycleManager() {} // utility class + + /** + * Mark a camp as dead (trader killed) and perform full cleanup. + * This: + * - Cancels all ransoms for the camp's prisoners + * - Frees all prisoners (untie, unlock collars) + * - Clears all labor states + * - Removes all cells belonging to the camp + * - Makes the camp inactive + * + * @param campId The camp UUID + * @param level The server level for entity lookups + */ + public static void markCampDead(UUID campId, ServerLevel level) { + CampOwnership ownership = CampOwnership.get(level); + CampOwnership.CampData data = ownership.getCamp(campId); + if (data == null) { + return; + } + + UUID traderUUID = data.getTraderUUID(); + + TiedUpMod.LOGGER.info( + "[CampLifecycleManager] Camp {} dying - freeing all prisoners", + campId.toString().substring(0, 8) + ); + + // PERFORMANCE FIX: Use PrisonerManager's index instead of CellRegistry + // This is O(1) lookup instead of iterating all cells + PrisonerManager manager = PrisonerManager.get(level); + Set prisonerIds = manager.getPrisonersInCamp(campId); + + TiedUpMod.LOGGER.debug( + "[CampLifecycleManager] Found {} prisoners in camp {} via index", + prisonerIds.size(), + campId.toString().substring(0, 8) + ); + + // Cancel ransoms and free each prisoner + for (UUID prisonerId : prisonerIds) { + // Cancel ransom by clearing it + if (manager.getRansomRecord(prisonerId) != null) { + manager.setRansomRecord(prisonerId, null); + TiedUpMod.LOGGER.debug( + "[CampLifecycleManager] Cancelled ransom for prisoner {}", + prisonerId.toString().substring(0, 8) + ); + } + + // Free the prisoner + ServerPlayer prisoner = level + .getServer() + .getPlayerList() + .getPlayer(prisonerId); + if (prisoner != null) { + // Online: untie, unlock collar, release with 5-min grace period + removeLaborTools(prisoner); + freePrisonerOnCampDeath(prisoner, traderUUID, level); + // Cell cleanup only -- freePrisonerOnCampDeath already called release() + // which transitions to PROTECTED with grace period. + // Calling escape() here would override PROTECTED->FREE, losing the grace. + CellRegistryV2.get(level).releasePrisonerFromAllCells( + prisonerId + ); + } else { + // Offline: full escape via PrisonerService (no grace period needed) + com.tiedup.remake.prison.service.PrisonerService.get().escape( + level, + prisonerId, + "camp death" + ); + } + ownership.unmarkPrisonerProcessed(prisonerId); + } + + // HIGH FIX: Remove all cells belonging to this camp from CellRegistryV2 + // Prevents memory leak and stale data in indices + CellRegistryV2 cellRegistry = CellRegistryV2.get(level); + List campCells = cellRegistry.getCellsByCamp(campId); + for (CellDataV2 cell : campCells) { + cellRegistry.removeCell(cell.getId()); + TiedUpMod.LOGGER.debug( + "[CampLifecycleManager] Removed cell {} from registry", + cell.getId().toString().substring(0, 8) + ); + } + TiedUpMod.LOGGER.info( + "[CampLifecycleManager] Removed {} cells for dead camp {}", + campCells.size(), + campId.toString().substring(0, 8) + ); + + // Mark camp as dead + data.setAlive(false); + ownership.setDirty(); + + TiedUpMod.LOGGER.info( + "[CampLifecycleManager] Camp {} is now dead, {} prisoners freed", + campId.toString().substring(0, 8), + prisonerIds.size() + ); + } + + /** + * Alert all NPCs in a camp to defend against an attacker. + * Called when someone tries to restrain the trader or maid. + * + * @param campId The camp UUID + * @param attacker The player attacking + * @param level The server level + */ + public static void alertCampToDefend( + UUID campId, + Player attacker, + ServerLevel level + ) { + CampOwnership ownership = CampOwnership.get(level); + CampOwnership.CampData camp = ownership.getCamp(campId); + if (camp == null) return; + + int alertedCount = 0; + + // 1. Alert the trader + UUID traderUUID = camp.getTraderUUID(); + if (traderUUID != null) { + net.minecraft.world.entity.Entity traderEntity = level.getEntity( + traderUUID + ); + if ( + traderEntity instanceof + com.tiedup.remake.entities.EntitySlaveTrader trader + ) { + if (trader.isAlive() && !trader.isTiedUp()) { + trader.setTarget(attacker); + trader.setLastAttacker(attacker); + alertedCount++; + } + } + } + + // 2. Alert the maid + UUID maidUUID = camp.getMaidUUID(); + if (maidUUID != null) { + net.minecraft.world.entity.Entity maidEntity = level.getEntity( + maidUUID + ); + if ( + maidEntity instanceof com.tiedup.remake.entities.EntityMaid maid + ) { + if (maid.isAlive() && !maid.isTiedUp()) { + maid.setTarget(attacker); + maid.setMaidState( + com.tiedup.remake.entities.ai.maid.MaidState.DEFENDING + ); + alertedCount++; + } + } + } + + // 3. Alert all kidnappers + Set kidnapperUUIDs = camp.getLinkedKidnappers(); + for (UUID kidnapperUUID : kidnapperUUIDs) { + net.minecraft.world.entity.Entity entity = level.getEntity( + kidnapperUUID + ); + if ( + entity instanceof + com.tiedup.remake.entities.EntityKidnapper kidnapper + ) { + if (kidnapper.isAlive() && !kidnapper.isTiedUp()) { + kidnapper.setTarget(attacker); + kidnapper.setLastAttacker(attacker); + alertedCount++; + } + } + } + + TiedUpMod.LOGGER.info( + "[CampLifecycleManager] Camp {} alerted {} NPCs to defend against {}", + campId.toString().substring(0, 8), + alertedCount, + attacker.getName().getString() + ); + } + + // ==================== PRIVATE HELPERS ==================== + + /** + * Free a prisoner when their camp dies. + * Untie, unlock collar, cancel sale, notify. + * Uses legitimate removal flag to prevent alerting kidnappers. + */ + private static void freePrisonerOnCampDeath( + ServerPlayer prisoner, + UUID traderUUID, + ServerLevel level + ) { + IRestrainable state = KidnappedHelper.getKidnappedState(prisoner); + if (state == null) { + return; + } + + // Suppress collar removal alerts - this is a legitimate release (camp death) + ItemCollar.runWithSuppressedAlert(() -> { + // Unlock collar if owned by the dead camp/trader + unlockCollarIfOwnedBy(prisoner, state, traderUUID); + + // Remove all restraints (including collar if any) + state.untie(true); + + // Cancel sale + state.cancelSale(); + }); + + // Clear client HUD + com.tiedup.remake.network.ModNetwork.sendToPlayer( + new com.tiedup.remake.network.labor.PacketSyncLaborProgress(), + prisoner + ); + + // Notify prisoner + prisoner.sendSystemMessage( + Component.literal("Your captor has died. You are FREE!").withStyle( + ChatFormatting.GREEN, + ChatFormatting.BOLD + ) + ); + + // Grant grace period (5 minutes = 6000 ticks) + PrisonerManager manager = PrisonerManager.get(level); + manager.release(prisoner.getUUID(), level.getGameTime(), 6000); + + prisoner.sendSystemMessage( + Component.literal( + "You have 5 minutes of protection from kidnappers." + ).withStyle(ChatFormatting.AQUA) + ); + + TiedUpMod.LOGGER.info( + "[CampLifecycleManager] Freed prisoner {} on camp death (no alert)", + prisoner.getName().getString() + ); + } + + /** + * Unlock a prisoner's collar if it's owned by the specified owner (trader/kidnapper). + */ + private static void unlockCollarIfOwnedBy( + ServerPlayer prisoner, + IRestrainable state, + UUID ownerUUID + ) { + ItemStack collar = state.getEquipment(BodyRegionV2.NECK); + if (collar.isEmpty()) { + return; + } + + if (collar.getItem() instanceof ItemCollar collarItem) { + List owners = collarItem.getOwners(collar); + + // If the dead trader/camp is an owner, unlock the collar + if (owners.contains(ownerUUID)) { + if (collar.getItem() instanceof ILockable lockable) { + lockable.setLockedByKeyUUID(collar, null); // Unlock and clear + } + TiedUpMod.LOGGER.debug( + "[CampLifecycleManager] Unlocked collar for {} (owner {} died)", + prisoner.getName().getString(), + ownerUUID.toString().substring(0, 8) + ); + } + } + } + + /** + * SECURITY: Remove all labor tools from player inventory. + * Prevents prisoners from keeping unbreakable tools when freed/released. + */ + private static void removeLaborTools(ServerPlayer player) { + var inventory = player.getInventory(); + int removedCount = 0; + + for (int i = 0; i < inventory.getContainerSize(); i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty() && stack.hasTag()) { + CompoundTag tag = stack.getTag(); + if (tag != null && tag.getBoolean("LaborTool")) { + inventory.setItem(i, ItemStack.EMPTY); + removedCount++; + } + } + } + + if (removedCount > 0) { + TiedUpMod.LOGGER.debug( + "[CampLifecycleManager] Removed {} labor tools from {} on camp death", + removedCount, + player.getName().getString() + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/cells/CampMaidManager.java b/src/main/java/com/tiedup/remake/cells/CampMaidManager.java new file mode 100644 index 0000000..d11d644 --- /dev/null +++ b/src/main/java/com/tiedup/remake/cells/CampMaidManager.java @@ -0,0 +1,158 @@ +package com.tiedup.remake.cells; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.prison.PrisonerManager; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.minecraft.server.level.ServerLevel; +import org.jetbrains.annotations.Nullable; + +/** + * Manages maid lifecycle within camps: death, respawn timers, prisoner reassignment. + * + *

This is a stateless utility class. All state lives in {@link CampOwnership} + * (the SavedData singleton). Methods here orchestrate maid-specific side effects. + */ +public final class CampMaidManager { + + private CampMaidManager() {} // utility class + + /** + * Mark the maid as dead for a camp. + * The camp remains alive but prisoners are paused until new maid spawns. + * + * @param campId The camp UUID + * @param currentTime The current game time + * @param level The server level + */ + public static void markMaidDead(UUID campId, long currentTime, ServerLevel level) { + CampOwnership ownership = CampOwnership.get(level); + CampOwnership.CampData data = ownership.getCamp(campId); + if (data == null || !data.isAlive()) { + return; + } + + // Save maid UUID before clearing (fix NPE) + UUID deadMaidId = data.getMaidUUID(); + + data.setMaidDeathTime(currentTime); + data.setMaidUUID(null); + + // Reset prisoners who were being escorted by the dead maid + if (deadMaidId != null) { + reassignPrisonersFromMaid(deadMaidId, null, level); + } + + ownership.setDirty(); + + TiedUpMod.LOGGER.info( + "[CampMaidManager] Maid died for camp {} - respawn available in 5 minutes", + campId.toString().substring(0, 8) + ); + } + + /** + * Assign a new maid to a camp (after respawn or initial setup). + * + * @param campId The camp UUID + * @param newMaidUUID The new maid's UUID + * @param level The server level + */ + public static void assignNewMaid( + UUID campId, + UUID newMaidUUID, + ServerLevel level + ) { + CampOwnership ownership = CampOwnership.get(level); + CampOwnership.CampData data = ownership.getCamp(campId); + if (data == null) { + return; + } + + UUID oldMaidId = data.getMaidUUID(); + data.setMaidUUID(newMaidUUID); + data.setMaidDeathTime(-1); // Reset death time + + // Transfer prisoners to new maid + reassignPrisonersFromMaid(oldMaidId, newMaidUUID, level); + + ownership.setDirty(); + + TiedUpMod.LOGGER.info( + "[CampMaidManager] New maid {} assigned to camp {}", + newMaidUUID.toString().substring(0, 8), + campId.toString().substring(0, 8) + ); + } + + /** + * Get camps that need a new maid spawned. + * + * @param currentTime The current game time + * @param level The server level + * @return List of camp IDs ready for maid respawn + */ + public static List getCampsNeedingMaidRespawn(long currentTime, ServerLevel level) { + CampOwnership ownership = CampOwnership.get(level); + List result = new ArrayList<>(); + for (CampOwnership.CampData data : ownership.getAllCamps()) { + if (data.isAlive() && data.canRespawnMaid(currentTime)) { + result.add(data.getCampId()); + } + } + return result; + } + + /** + * Reassign all prisoners from one maid to another (for maid death/replacement). + * The new PrisonerManager tracks labor state separately via LaborRecord. + * + * @param oldMaidId The old maid's UUID (or null to assign to all unassigned prisoners) + * @param newMaidId The new maid's UUID (or null if maid died with no replacement) + * @param level The server level + */ + public static void reassignPrisonersFromMaid( + @Nullable UUID oldMaidId, + @Nullable UUID newMaidId, + ServerLevel level + ) { + PrisonerManager manager = PrisonerManager.get(level); + + for (UUID playerId : manager.getAllPrisonerIds()) { + com.tiedup.remake.prison.LaborRecord labor = manager.getLaborRecord( + playerId + ); + if (labor == null) continue; + + // Check if this prisoner was managed by the old maid + UUID assignedMaid = labor.getMaidId(); + boolean shouldReassign = + (oldMaidId == null && assignedMaid == null) || + (oldMaidId != null && oldMaidId.equals(assignedMaid)); + + if (shouldReassign) { + // Update maid ID in labor record + labor.setMaidId(newMaidId); + + // If maid died (no replacement) during active work, prisoner can rest + if ( + newMaidId == null && + labor.getPhase() != + com.tiedup.remake.prison.LaborRecord.WorkPhase.IDLE + ) { + labor.setPhase( + com.tiedup.remake.prison.LaborRecord.WorkPhase.IDLE, + level.getGameTime() + ); + TiedUpMod.LOGGER.info( + "[CampMaidManager] Prisoner {} labor reset to IDLE - maid died during escort", + playerId.toString().substring(0, 8) + ); + } + // If there's a replacement maid, keep current state so they get picked up + } + } + CampOwnership.get(level).setDirty(); + } +} diff --git a/src/main/java/com/tiedup/remake/cells/CampOwnership.java b/src/main/java/com/tiedup/remake/cells/CampOwnership.java new file mode 100644 index 0000000..487d706 --- /dev/null +++ b/src/main/java/com/tiedup/remake/cells/CampOwnership.java @@ -0,0 +1,576 @@ +package com.tiedup.remake.cells; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.saveddata.SavedData; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Global registry for camp ownership linking camps to their SlaveTrader. + * + * This registry tracks: + * - Camp UUID -> CampData (trader, maid, alive status) + * - When a trader dies, the camp becomes inactive + * + * Persists across server restarts using Minecraft's SavedData system. + */ +public class CampOwnership extends SavedData { + + private static final String DATA_NAME = "tiedup_camp_ownership"; + + // Camp UUID -> CampData + private final Map camps = new ConcurrentHashMap<>(); + + // Prisoners that have been processed (to avoid re-processing) + private final Set processedPrisoners = ConcurrentHashMap.newKeySet(); + + // ==================== CAMP DATA CLASS ==================== + + /** + * Data structure representing a camp and its owner. + * Uses thread-safe collections for concurrent access. + */ + /** Maid respawn delay in ticks (5 minutes = 6000 ticks) */ + public static final long MAID_RESPAWN_DELAY = 6000; + + public static class CampData { + + private final UUID campId; + private volatile UUID traderUUID; + private volatile UUID maidUUID; + private volatile boolean isAlive = true; + private volatile BlockPos center; + private final Set linkedKidnapperUUIDs = + ConcurrentHashMap.newKeySet(); + + /** Time when maid died (for respawn timer), -1 if alive */ + private volatile long maidDeathTime = -1; + + /** Cached positions of LOOT chests (below LOOT markers) */ + private final List lootChestPositions = new ArrayList<>(); + + public CampData(UUID campId) { + this.campId = campId; + } + + public CampData( + UUID campId, + UUID traderUUID, + @Nullable UUID maidUUID, + BlockPos center + ) { + this.campId = campId; + this.traderUUID = traderUUID; + this.maidUUID = maidUUID; + this.center = center; + this.isAlive = true; + } + + // Getters + public UUID getCampId() { + return campId; + } + + public UUID getTraderUUID() { + return traderUUID; + } + + public UUID getMaidUUID() { + return maidUUID; + } + + public boolean isAlive() { + return isAlive; + } + + public BlockPos getCenter() { + return center; + } + + // Setters + public void setTraderUUID(UUID traderUUID) { + this.traderUUID = traderUUID; + } + + public void setMaidUUID(UUID maidUUID) { + this.maidUUID = maidUUID; + } + + public void setAlive(boolean alive) { + this.isAlive = alive; + } + + public void setCenter(BlockPos center) { + this.center = center; + } + + // Maid death/respawn + public long getMaidDeathTime() { + return maidDeathTime; + } + + public void setMaidDeathTime(long time) { + this.maidDeathTime = time; + } + + public boolean isMaidDead() { + return maidDeathTime >= 0; + } + + public boolean canRespawnMaid(long currentTime) { + return ( + isMaidDead() && + (currentTime - maidDeathTime) >= MAID_RESPAWN_DELAY + ); + } + + // Loot chest management + public List getLootChestPositions() { + return lootChestPositions; + } + + public void addLootChestPosition(BlockPos pos) { + if (!lootChestPositions.contains(pos)) { + lootChestPositions.add(pos); + } + } + + // Kidnapper management + public void addKidnapper(UUID kidnapperUUID) { + linkedKidnapperUUIDs.add(kidnapperUUID); + } + + public void removeKidnapper(UUID kidnapperUUID) { + linkedKidnapperUUIDs.remove(kidnapperUUID); + } + + public Set getLinkedKidnappers() { + return Collections.unmodifiableSet(linkedKidnapperUUIDs); + } + + public boolean hasKidnapper(UUID kidnapperUUID) { + return linkedKidnapperUUIDs.contains(kidnapperUUID); + } + + public int getKidnapperCount() { + return linkedKidnapperUUIDs.size(); + } + + // NBT Serialization + public CompoundTag save() { + CompoundTag tag = new CompoundTag(); + tag.putUUID("campId", campId); + if (traderUUID != null) tag.putUUID("traderUUID", traderUUID); + if (maidUUID != null) tag.putUUID("maidUUID", maidUUID); + tag.putBoolean("isAlive", isAlive); + if (center != null) { + tag.putInt("centerX", center.getX()); + tag.putInt("centerY", center.getY()); + tag.putInt("centerZ", center.getZ()); + } + // Save linked kidnappers + if (!linkedKidnapperUUIDs.isEmpty()) { + ListTag kidnapperList = new ListTag(); + for (UUID uuid : linkedKidnapperUUIDs) { + CompoundTag uuidTag = new CompoundTag(); + uuidTag.putUUID("uuid", uuid); + kidnapperList.add(uuidTag); + } + tag.put("linkedKidnappers", kidnapperList); + } + // Save maid death time + tag.putLong("maidDeathTime", maidDeathTime); + // Save loot chest positions + if (!lootChestPositions.isEmpty()) { + ListTag lootList = new ListTag(); + for (BlockPos pos : lootChestPositions) { + CompoundTag posTag = new CompoundTag(); + posTag.putInt("x", pos.getX()); + posTag.putInt("y", pos.getY()); + posTag.putInt("z", pos.getZ()); + lootList.add(posTag); + } + tag.put("lootChestPositions", lootList); + } + return tag; + } + + public static CampData load(CompoundTag tag) { + UUID campId = tag.getUUID("campId"); + CampData data = new CampData(campId); + if (tag.contains("traderUUID")) data.traderUUID = tag.getUUID( + "traderUUID" + ); + if (tag.contains("maidUUID")) data.maidUUID = tag.getUUID( + "maidUUID" + ); + data.isAlive = tag.getBoolean("isAlive"); + if (tag.contains("centerX")) { + data.center = new BlockPos( + tag.getInt("centerX"), + tag.getInt("centerY"), + tag.getInt("centerZ") + ); + } + // Load linked kidnappers + if (tag.contains("linkedKidnappers")) { + ListTag kidnapperList = tag.getList( + "linkedKidnappers", + Tag.TAG_COMPOUND + ); + for (int i = 0; i < kidnapperList.size(); i++) { + CompoundTag uuidTag = kidnapperList.getCompound(i); + if (uuidTag.contains("uuid")) { + data.linkedKidnapperUUIDs.add(uuidTag.getUUID("uuid")); + } + } + } + // Load maid death time + if (tag.contains("maidDeathTime")) { + data.maidDeathTime = tag.getLong("maidDeathTime"); + } + // Load loot chest positions + if (tag.contains("lootChestPositions")) { + ListTag lootList = tag.getList( + "lootChestPositions", + Tag.TAG_COMPOUND + ); + for (int i = 0; i < lootList.size(); i++) { + CompoundTag posTag = lootList.getCompound(i); + data.lootChestPositions.add( + new BlockPos( + posTag.getInt("x"), + posTag.getInt("y"), + posTag.getInt("z") + ) + ); + } + } + return data; + } + } + + // ==================== STATIC ACCESS ==================== + + /** + * Get the CampOwnership registry for a server level. + */ + public static CampOwnership get(ServerLevel level) { + return level + .getDataStorage() + .computeIfAbsent( + CampOwnership::load, + CampOwnership::new, + DATA_NAME + ); + } + + /** + * Get the CampOwnership from a MinecraftServer. + */ + public static CampOwnership get(MinecraftServer server) { + ServerLevel overworld = server.overworld(); + return get(overworld); + } + + // ==================== CAMP MANAGEMENT ==================== + + /** + * Register a new camp with its trader and maid. + * + * @param campId The camp/structure UUID + * @param traderUUID The SlaveTrader entity UUID + * @param maidUUID The Maid entity UUID (can be null) + * @param center The center position of the camp + */ + public void registerCamp( + UUID campId, + UUID traderUUID, + @Nullable UUID maidUUID, + BlockPos center + ) { + CampData data = new CampData(campId, traderUUID, maidUUID, center); + camps.put(campId, data); + setDirty(); + } + + /** + * Check if a camp is alive (has living trader). + * + * @param campId The camp UUID + * @return true if camp exists and is alive + */ + public boolean isCampAlive(UUID campId) { + CampData data = camps.get(campId); + return data != null && data.isAlive(); + } + + /** + * Get camp data by camp UUID. + */ + @Nullable + public CampData getCamp(UUID campId) { + return camps.get(campId); + } + + /** + * Get camp data by trader UUID. + */ + @Nullable + public CampData getCampByTrader(UUID traderUUID) { + for (CampData camp : camps.values()) { + if (traderUUID.equals(camp.getTraderUUID())) { + return camp; + } + } + return null; + } + + /** + * Get camp data by maid UUID. + */ + @Nullable + public CampData getCampByMaid(UUID maidUUID) { + for (CampData camp : camps.values()) { + if (maidUUID.equals(camp.getMaidUUID())) { + return camp; + } + } + return null; + } + + /** + * Find camps near a position. + * + * @param center The center position + * @param radius The search radius + * @return List of camps within radius + */ + public List findCampsNear(BlockPos center, double radius) { + List nearby = new ArrayList<>(); + double radiusSq = radius * radius; + + for (CampData camp : camps.values()) { + if ( + camp.getCenter() != null && + camp.getCenter().distSqr(center) <= radiusSq + ) { + nearby.add(camp); + } + } + return nearby; + } + + /** + * Find the nearest alive camp to a position. + * + * @param pos The position to search from + * @param radius Maximum search radius + * @return The nearest alive camp, or null + */ + @Nullable + public CampData findNearestAliveCamp(BlockPos pos, double radius) { + CampData nearest = null; + double nearestDistSq = radius * radius; + + for (CampData camp : camps.values()) { + if (!camp.isAlive() || camp.getCenter() == null) continue; + + double distSq = camp.getCenter().distSqr(pos); + if (distSq < nearestDistSq) { + nearestDistSq = distSq; + nearest = camp; + } + } + return nearest; + } + + /** + * Remove a camp from the registry. + */ + @Nullable + public CampData removeCamp(UUID campId) { + CampData removed = camps.remove(campId); + if (removed != null) { + setDirty(); + } + return removed; + } + + /** + * Get all registered camps. + */ + public Collection getAllCamps() { + return Collections.unmodifiableCollection(camps.values()); + } + + /** + * Get all alive camps. + */ + public List getAliveCamps() { + List alive = new ArrayList<>(); + for (CampData camp : camps.values()) { + if (camp.isAlive()) { + alive.add(camp); + } + } + return alive; + } + + /** + * Link a kidnapper to a camp. + * + * @param campId The camp UUID + * @param kidnapperUUID The kidnapper UUID + */ + public void linkKidnapperToCamp(UUID campId, UUID kidnapperUUID) { + CampData data = camps.get(campId); + if (data != null) { + data.addKidnapper(kidnapperUUID); + setDirty(); + } + } + + /** + * Unlink a kidnapper from a camp. + * + * @param campId The camp UUID + * @param kidnapperUUID The kidnapper UUID + */ + public void unlinkKidnapperFromCamp(UUID campId, UUID kidnapperUUID) { + CampData data = camps.get(campId); + if (data != null) { + data.removeKidnapper(kidnapperUUID); + setDirty(); + } + } + + /** + * Check if a kidnapper is linked to a camp. + * + * @param campId The camp UUID + * @param kidnapperUUID The kidnapper UUID + * @return true if the kidnapper is linked to this camp + */ + public boolean isKidnapperLinked(UUID campId, UUID kidnapperUUID) { + CampData data = camps.get(campId); + return data != null && data.hasKidnapper(kidnapperUUID); + } + + /** + * Find the camp a kidnapper is linked to. + * + * @param kidnapperUUID The kidnapper UUID + * @return The camp data, or null if not linked + */ + @Nullable + public CampData findCampByKidnapper(UUID kidnapperUUID) { + for (CampData camp : camps.values()) { + if (camp.hasKidnapper(kidnapperUUID)) { + return camp; + } + } + return null; + } + + /** + * Mark a prisoner as processed (to avoid re-processing). + * + * @param prisonerId The prisoner's UUID + */ + public void markPrisonerProcessed(UUID prisonerId) { + processedPrisoners.add(prisonerId); + setDirty(); + } + + /** + * Check if a prisoner has been processed. + * + * @param prisonerId The prisoner's UUID + * @return true if already processed + */ + public boolean isPrisonerProcessed(UUID prisonerId) { + return processedPrisoners.contains(prisonerId); + } + + /** + * Remove a prisoner from processed set. + * + * @param prisonerId The prisoner's UUID + */ + public void unmarkPrisonerProcessed(UUID prisonerId) { + if (processedPrisoners.remove(prisonerId)) { + setDirty(); + } + } + + /** + * Get the set of processed prisoners for a camp (unmodifiable). + * + * @return Unmodifiable set of processed prisoner UUIDs + */ + public Set getProcessedPrisoners() { + return Collections.unmodifiableSet(processedPrisoners); + } + + // ==================== PERSISTENCE ==================== + + @Override + public @NotNull CompoundTag save(@NotNull CompoundTag tag) { + // Save camps + ListTag campList = new ListTag(); + for (CampData camp : camps.values()) { + campList.add(camp.save()); + } + tag.put("camps", campList); + + // Save processed prisoners + ListTag processedList = new ListTag(); + for (UUID uuid : processedPrisoners) { + CompoundTag uuidTag = new CompoundTag(); + uuidTag.putUUID("uuid", uuid); + processedList.add(uuidTag); + } + tag.put("processedPrisoners", processedList); + + return tag; + } + + public static CampOwnership load(CompoundTag tag) { + CampOwnership registry = new CampOwnership(); + + // Load camps + if (tag.contains("camps")) { + ListTag campList = tag.getList("camps", Tag.TAG_COMPOUND); + for (int i = 0; i < campList.size(); i++) { + CampData camp = CampData.load(campList.getCompound(i)); + registry.camps.put(camp.getCampId(), camp); + } + } + + // Load processed prisoners + if (tag.contains("processedPrisoners")) { + ListTag processedList = tag.getList( + "processedPrisoners", + Tag.TAG_COMPOUND + ); + for (int i = 0; i < processedList.size(); i++) { + CompoundTag uuidTag = processedList.getCompound(i); + if (uuidTag.contains("uuid")) { + registry.processedPrisoners.add(uuidTag.getUUID("uuid")); + } + } + } + + return registry; + } + +} diff --git a/src/main/java/com/tiedup/remake/cells/CellDataV2.java b/src/main/java/com/tiedup/remake/cells/CellDataV2.java new file mode 100644 index 0000000..c0acf4a --- /dev/null +++ b/src/main/java/com/tiedup/remake/cells/CellDataV2.java @@ -0,0 +1,607 @@ +package com.tiedup.remake.cells; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtUtils; +import net.minecraft.nbt.Tag; +import org.jetbrains.annotations.Nullable; + +/** + * Cell data for Cell System V2. + * + * Named CellDataV2 to coexist with v1 CellData during the migration period. + * Contains geometry (interior + walls from flood-fill), breach tracking, + * prisoner management, and auto-detected features. + */ +public class CellDataV2 { + + private static final int MAX_PRISONERS = 4; + + // Identity + private final UUID id; + private CellState state; + private final BlockPos corePos; + + // Cached from Core BE (for use when chunk is unloaded) + @Nullable + private BlockPos spawnPoint; + + @Nullable + private BlockPos deliveryPoint; + + // Ownership + @Nullable + private UUID ownerId; + + private CellOwnerType ownerType = CellOwnerType.PLAYER; + + @Nullable + private String name; + + // Geometry (from flood-fill) + private final Set interiorBlocks; + private final Set wallBlocks; + private final Set breachedPositions; + private int totalWallCount; + + // Interior face direction (which face of Core points inside) + @Nullable + private Direction interiorFace; + + // Auto-detected features + private final List beds; + private final List petBeds; + private final List anchors; + private final List doors; + private final List linkedRedstone; + + // Prisoners + private final List prisonerIds = new CopyOnWriteArrayList<>(); + private final Map prisonerTimestamps = + new ConcurrentHashMap<>(); + + // Camp navigation + private final List pathWaypoints = new CopyOnWriteArrayList<>(); + + // ==================== CONSTRUCTORS ==================== + + /** + * Create from a successful flood-fill result. + */ + public CellDataV2(BlockPos corePos, FloodFillResult result) { + this.id = UUID.randomUUID(); + this.state = CellState.INTACT; + this.corePos = corePos.immutable(); + this.interiorFace = result.getInteriorFace(); + + this.interiorBlocks = ConcurrentHashMap.newKeySet(); + this.interiorBlocks.addAll(result.getInterior()); + + this.wallBlocks = ConcurrentHashMap.newKeySet(); + this.wallBlocks.addAll(result.getWalls()); + + this.breachedPositions = ConcurrentHashMap.newKeySet(); + this.totalWallCount = result.getWalls().size(); + + this.beds = new CopyOnWriteArrayList<>(result.getBeds()); + this.petBeds = new CopyOnWriteArrayList<>(result.getPetBeds()); + this.anchors = new CopyOnWriteArrayList<>(result.getAnchors()); + this.doors = new CopyOnWriteArrayList<>(result.getDoors()); + this.linkedRedstone = new CopyOnWriteArrayList<>( + result.getLinkedRedstone() + ); + } + + /** + * Create for NBT loading (minimal constructor). + */ + public CellDataV2(UUID id, BlockPos corePos) { + this.id = id; + this.state = CellState.INTACT; + this.corePos = corePos.immutable(); + + this.interiorBlocks = ConcurrentHashMap.newKeySet(); + this.wallBlocks = ConcurrentHashMap.newKeySet(); + this.breachedPositions = ConcurrentHashMap.newKeySet(); + this.totalWallCount = 0; + + this.beds = new CopyOnWriteArrayList<>(); + this.petBeds = new CopyOnWriteArrayList<>(); + this.anchors = new CopyOnWriteArrayList<>(); + this.doors = new CopyOnWriteArrayList<>(); + this.linkedRedstone = new CopyOnWriteArrayList<>(); + } + + // ==================== IDENTITY ==================== + + public UUID getId() { + return id; + } + + public CellState getState() { + return state; + } + + public void setState(CellState state) { + this.state = state; + } + + public BlockPos getCorePos() { + return corePos; + } + + @Nullable + public BlockPos getSpawnPoint() { + return spawnPoint; + } + + public void setSpawnPoint(@Nullable BlockPos spawnPoint) { + this.spawnPoint = spawnPoint != null ? spawnPoint.immutable() : null; + } + + @Nullable + public BlockPos getDeliveryPoint() { + return deliveryPoint; + } + + public void setDeliveryPoint(@Nullable BlockPos deliveryPoint) { + this.deliveryPoint = + deliveryPoint != null ? deliveryPoint.immutable() : null; + } + + @Nullable + public Direction getInteriorFace() { + return interiorFace; + } + + // ==================== OWNERSHIP ==================== + + @Nullable + public UUID getOwnerId() { + return ownerId; + } + + public void setOwnerId(@Nullable UUID ownerId) { + this.ownerId = ownerId; + } + + public CellOwnerType getOwnerType() { + return ownerType; + } + + public void setOwnerType(CellOwnerType ownerType) { + this.ownerType = ownerType; + } + + @Nullable + public String getName() { + return name; + } + + public void setName(@Nullable String name) { + this.name = name; + } + + public boolean isOwnedBy(UUID playerId) { + return playerId != null && playerId.equals(ownerId); + } + + /** + * Check if a player can manage this cell (open menu, rename, modify settings). + * - OPs (level 2+) can always manage any cell (including camp-owned). + * - Player-owned cells: only the owning player. + * - Camp-owned cells: OPs only. + * + * @param playerId UUID of the player + * @param hasOpPerms true if the player has OP level 2+ + * @return true if the player is allowed to manage this cell + */ + public boolean canPlayerManage(UUID playerId, boolean hasOpPerms) { + if (hasOpPerms) return true; + if (isCampOwned()) return false; + return isOwnedBy(playerId); + } + + public boolean hasOwner() { + return ownerId != null; + } + + public boolean isCampOwned() { + return ownerType == CellOwnerType.CAMP; + } + + public boolean isPlayerOwned() { + return ownerType == CellOwnerType.PLAYER; + } + + @Nullable + public UUID getCampId() { + return isCampOwned() ? ownerId : null; + } + + // ==================== GEOMETRY ==================== + + public Set getInteriorBlocks() { + return Collections.unmodifiableSet(interiorBlocks); + } + + public Set getWallBlocks() { + return Collections.unmodifiableSet(wallBlocks); + } + + public Set getBreachedPositions() { + return Collections.unmodifiableSet(breachedPositions); + } + + public int getTotalWallCount() { + return totalWallCount; + } + + public boolean isContainedInCell(BlockPos pos) { + return interiorBlocks.contains(pos); + } + + public boolean isWallBlock(BlockPos pos) { + return wallBlocks.contains(pos); + } + + // ==================== BREACH MANAGEMENT ==================== + + public void addBreach(BlockPos wallPos) { + if (wallBlocks.remove(wallPos)) { + breachedPositions.add(wallPos.immutable()); + } + } + + public void repairBreach(BlockPos wallPos) { + if (breachedPositions.remove(wallPos)) { + wallBlocks.add(wallPos.immutable()); + } + } + + public float getBreachPercentage() { + if (totalWallCount == 0) return 0.0f; + return (float) breachedPositions.size() / totalWallCount; + } + + // ==================== FEATURES ==================== + + public List getBeds() { + return Collections.unmodifiableList(beds); + } + + public List getPetBeds() { + return Collections.unmodifiableList(petBeds); + } + + public List getAnchors() { + return Collections.unmodifiableList(anchors); + } + + public List getDoors() { + return Collections.unmodifiableList(doors); + } + + public List getLinkedRedstone() { + return Collections.unmodifiableList(linkedRedstone); + } + + // ==================== PRISONER MANAGEMENT ==================== + + public List getPrisonerIds() { + return Collections.unmodifiableList(prisonerIds); + } + + public boolean hasPrisoner(UUID prisonerId) { + return prisonerIds.contains(prisonerId); + } + + public boolean isFull() { + return prisonerIds.size() >= MAX_PRISONERS; + } + + public boolean isOccupied() { + return !prisonerIds.isEmpty(); + } + + public int getPrisonerCount() { + return prisonerIds.size(); + } + + public boolean addPrisoner(UUID prisonerId) { + if (isFull() || prisonerIds.contains(prisonerId)) { + return false; + } + prisonerIds.add(prisonerId); + prisonerTimestamps.put(prisonerId, System.currentTimeMillis()); + return true; + } + + public boolean removePrisoner(UUID prisonerId) { + boolean removed = prisonerIds.remove(prisonerId); + if (removed) { + prisonerTimestamps.remove(prisonerId); + } + return removed; + } + + @Nullable + public Long getPrisonerTimestamp(UUID prisonerId) { + return prisonerTimestamps.get(prisonerId); + } + + // ==================== CAMP NAVIGATION ==================== + + public List getPathWaypoints() { + return Collections.unmodifiableList(pathWaypoints); + } + + public void setPathWaypoints(List waypoints) { + pathWaypoints.clear(); + pathWaypoints.addAll(waypoints); + } + + // ==================== WIRE RECONSTRUCTION (client-side) ==================== + + /** Add a wall block position (used for client-side packet reconstruction). */ + public void addWallBlock(BlockPos pos) { + wallBlocks.add(pos.immutable()); + } + + /** Add a bed position (used for client-side packet reconstruction). */ + public void addBed(BlockPos pos) { + beds.add(pos.immutable()); + } + + /** Add an anchor position (used for client-side packet reconstruction). */ + public void addAnchor(BlockPos pos) { + anchors.add(pos.immutable()); + } + + /** Add a door position (used for client-side packet reconstruction). */ + public void addDoor(BlockPos pos) { + doors.add(pos.immutable()); + } + + // ==================== GEOMETRY UPDATE (rescan) ==================== + + /** + * Replace geometry with a new flood-fill result (used during rescan). + */ + public void updateGeometry(FloodFillResult result) { + interiorBlocks.clear(); + interiorBlocks.addAll(result.getInterior()); + + wallBlocks.clear(); + wallBlocks.addAll(result.getWalls()); + + breachedPositions.clear(); + totalWallCount = result.getWalls().size(); + + if (result.getInteriorFace() != null) { + this.interiorFace = result.getInteriorFace(); + } + + beds.clear(); + beds.addAll(result.getBeds()); + petBeds.clear(); + petBeds.addAll(result.getPetBeds()); + anchors.clear(); + anchors.addAll(result.getAnchors()); + doors.clear(); + doors.addAll(result.getDoors()); + linkedRedstone.clear(); + linkedRedstone.addAll(result.getLinkedRedstone()); + + state = CellState.INTACT; + } + + // ==================== NBT PERSISTENCE ==================== + + public CompoundTag save() { + CompoundTag tag = new CompoundTag(); + + tag.putUUID("id", id); + tag.putString("state", state.getSerializedName()); + tag.put("corePos", NbtUtils.writeBlockPos(corePos)); + + if (spawnPoint != null) { + tag.put("spawnPoint", NbtUtils.writeBlockPos(spawnPoint)); + } + if (deliveryPoint != null) { + tag.put("deliveryPoint", NbtUtils.writeBlockPos(deliveryPoint)); + } + if (interiorFace != null) { + tag.putString("interiorFace", interiorFace.getSerializedName()); + } + + // Ownership + if (ownerId != null) { + tag.putUUID("ownerId", ownerId); + } + tag.putString("ownerType", ownerType.getSerializedName()); + if (name != null) { + tag.putString("name", name); + } + + // Geometry + tag.put("interior", saveBlockPosSet(interiorBlocks)); + tag.put("walls", saveBlockPosSet(wallBlocks)); + tag.put("breached", saveBlockPosSet(breachedPositions)); + tag.putInt("totalWallCount", totalWallCount); + + // Features + tag.put("beds", saveBlockPosList(beds)); + tag.put("petBeds", saveBlockPosList(petBeds)); + tag.put("anchors", saveBlockPosList(anchors)); + tag.put("doors", saveBlockPosList(doors)); + tag.put("linkedRedstone", saveBlockPosList(linkedRedstone)); + + // Prisoners + if (!prisonerIds.isEmpty()) { + ListTag prisonerList = new ListTag(); + for (UUID uuid : prisonerIds) { + CompoundTag prisonerTag = new CompoundTag(); + prisonerTag.putUUID("id", uuid); + prisonerTag.putLong( + "timestamp", + prisonerTimestamps.getOrDefault( + uuid, + System.currentTimeMillis() + ) + ); + prisonerList.add(prisonerTag); + } + tag.put("prisoners", prisonerList); + } + + // Path waypoints + if (!pathWaypoints.isEmpty()) { + tag.put("pathWaypoints", saveBlockPosList(pathWaypoints)); + } + + return tag; + } + + @Nullable + public static CellDataV2 load(CompoundTag tag) { + if (!tag.contains("id") || !tag.contains("corePos")) { + return null; + } + + UUID id = tag.getUUID("id"); + BlockPos corePos = NbtUtils.readBlockPos(tag.getCompound("corePos")); + CellDataV2 cell = new CellDataV2(id, corePos); + + cell.state = CellState.fromString(tag.getString("state")); + + if (tag.contains("spawnPoint")) { + cell.spawnPoint = NbtUtils.readBlockPos( + tag.getCompound("spawnPoint") + ); + } + if (tag.contains("deliveryPoint")) { + cell.deliveryPoint = NbtUtils.readBlockPos( + tag.getCompound("deliveryPoint") + ); + } + if (tag.contains("interiorFace")) { + cell.interiorFace = Direction.byName(tag.getString("interiorFace")); + } + + // Ownership + if (tag.contains("ownerId")) { + cell.ownerId = tag.getUUID("ownerId"); + } + if (tag.contains("ownerType")) { + cell.ownerType = CellOwnerType.fromString( + tag.getString("ownerType") + ); + } + if (tag.contains("name")) { + cell.name = tag.getString("name"); + } + + // Geometry + loadBlockPosSet(tag, "interior", cell.interiorBlocks); + loadBlockPosSet(tag, "walls", cell.wallBlocks); + loadBlockPosSet(tag, "breached", cell.breachedPositions); + cell.totalWallCount = tag.getInt("totalWallCount"); + + // Features + loadBlockPosList(tag, "beds", cell.beds); + loadBlockPosList(tag, "petBeds", cell.petBeds); + loadBlockPosList(tag, "anchors", cell.anchors); + loadBlockPosList(tag, "doors", cell.doors); + loadBlockPosList(tag, "linkedRedstone", cell.linkedRedstone); + + // Prisoners + if (tag.contains("prisoners")) { + ListTag prisonerList = tag.getList("prisoners", Tag.TAG_COMPOUND); + for (int i = 0; i < prisonerList.size(); i++) { + CompoundTag prisonerTag = prisonerList.getCompound(i); + UUID prisonerId = prisonerTag.getUUID("id"); + cell.prisonerIds.add(prisonerId); + long timestamp = prisonerTag.contains("timestamp") + ? prisonerTag.getLong("timestamp") + : System.currentTimeMillis(); + cell.prisonerTimestamps.put(prisonerId, timestamp); + } + } + + // Path waypoints + loadBlockPosList(tag, "pathWaypoints", cell.pathWaypoints); + + return cell; + } + + // ==================== NBT HELPERS ==================== + + private static ListTag saveBlockPosSet(Set positions) { + ListTag list = new ListTag(); + for (BlockPos pos : positions) { + list.add(NbtUtils.writeBlockPos(pos)); + } + return list; + } + + private static ListTag saveBlockPosList(List positions) { + ListTag list = new ListTag(); + for (BlockPos pos : positions) { + list.add(NbtUtils.writeBlockPos(pos)); + } + return list; + } + + private static void loadBlockPosSet( + CompoundTag parent, + String key, + Set target + ) { + if (parent.contains(key)) { + ListTag list = parent.getList(key, Tag.TAG_COMPOUND); + for (int i = 0; i < list.size(); i++) { + target.add(NbtUtils.readBlockPos(list.getCompound(i))); + } + } + } + + private static void loadBlockPosList( + CompoundTag parent, + String key, + List target + ) { + if (parent.contains(key)) { + ListTag list = parent.getList(key, Tag.TAG_COMPOUND); + for (int i = 0; i < list.size(); i++) { + target.add(NbtUtils.readBlockPos(list.getCompound(i))); + } + } + } + + // ==================== DEBUG ==================== + + @Override + public String toString() { + return ( + "CellDataV2{id=" + + id.toString().substring(0, 8) + + "..., state=" + + state + + ", core=" + + corePos.toShortString() + + ", interior=" + + interiorBlocks.size() + + ", walls=" + + wallBlocks.size() + + ", prisoners=" + + prisonerIds.size() + + "}" + ); + } +} diff --git a/src/main/java/com/tiedup/remake/cells/CellOwnerType.java b/src/main/java/com/tiedup/remake/cells/CellOwnerType.java new file mode 100644 index 0000000..6e94053 --- /dev/null +++ b/src/main/java/com/tiedup/remake/cells/CellOwnerType.java @@ -0,0 +1,33 @@ +package com.tiedup.remake.cells; + +/** + * Enum indicating who owns a cell. + * Used to distinguish player-created cells from camp-generated cells. + * + * Extracted from CellData.OwnerType to decouple V2 code from V1 CellData. + */ +public enum CellOwnerType { + /** Cell created by a player using Cell Wand */ + PLAYER("player"), + /** Cell generated with a kidnapper camp structure */ + CAMP("camp"); + + private final String serializedName; + + CellOwnerType(String serializedName) { + this.serializedName = serializedName; + } + + public String getSerializedName() { + return serializedName; + } + + public static CellOwnerType fromString(String name) { + for (CellOwnerType type : values()) { + if (type.serializedName.equalsIgnoreCase(name)) { + return type; + } + } + return PLAYER; // Default to PLAYER for backwards compatibility + } +} diff --git a/src/main/java/com/tiedup/remake/cells/CellRegistryV2.java b/src/main/java/com/tiedup/remake/cells/CellRegistryV2.java new file mode 100644 index 0000000..91e4345 --- /dev/null +++ b/src/main/java/com/tiedup/remake/cells/CellRegistryV2.java @@ -0,0 +1,903 @@ +package com.tiedup.remake.cells; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.saveddata.SavedData; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Global registry for Cell System V2 data. + * + * Named CellRegistryV2 to coexist with v1 CellRegistry during migration. + * Uses "tiedup_cell_registry_v2" as the SavedData name. + * + * Provides spatial indices for fast lookups by wall position, interior position, + * core position, chunk, and camp. + */ +public class CellRegistryV2 extends SavedData { + + private static final String DATA_NAME = "tiedup_cell_registry_v2"; + + /** Reservation timeout in ticks (30 seconds = 600 ticks) */ + private static final long RESERVATION_TIMEOUT_TICKS = 600L; + + // ==================== RESERVATION ==================== + + private static class CellReservation { + + private final UUID kidnapperUUID; + private final long expiryTime; + + public CellReservation(UUID kidnapperUUID, long expiryTime) { + this.kidnapperUUID = kidnapperUUID; + this.expiryTime = expiryTime; + } + + public UUID getKidnapperUUID() { + return kidnapperUUID; + } + + public boolean isExpired(long currentTime) { + return currentTime >= expiryTime; + } + } + + // ==================== STORAGE ==================== + + // Primary storage + private final Map cells = new ConcurrentHashMap<>(); + + // Indices (rebuilt on load) + private final Map wallToCell = new ConcurrentHashMap<>(); + private final Map interiorToCell = + new ConcurrentHashMap<>(); + private final Map coreToCell = new ConcurrentHashMap<>(); + + // Spatial + camp indices + private final Map> cellsByChunk = + new ConcurrentHashMap<>(); + private final Map> cellsByCamp = new ConcurrentHashMap<>(); + + // Breach tracking index (breached wall position → cell ID) + private final Map breachedToCell = + new ConcurrentHashMap<>(); + + // Reservations (not persisted) + private final Map reservations = + new ConcurrentHashMap<>(); + + // ==================== STATIC ACCESS ==================== + + public static CellRegistryV2 get(ServerLevel level) { + return level + .getDataStorage() + .computeIfAbsent( + CellRegistryV2::load, + CellRegistryV2::new, + DATA_NAME + ); + } + + public static CellRegistryV2 get(MinecraftServer server) { + return get(server.overworld()); + } + + // ==================== CELL LIFECYCLE ==================== + + /** + * Create a new cell from a flood-fill result. + */ + public CellDataV2 createCell( + BlockPos corePos, + FloodFillResult result, + @Nullable UUID ownerId + ) { + CellDataV2 cell = new CellDataV2(corePos, result); + if (ownerId != null) { + cell.setOwnerId(ownerId); + } + + cells.put(cell.getId(), cell); + + // Register in indices + coreToCell.put(corePos.immutable(), cell.getId()); + for (BlockPos pos : cell.getWallBlocks()) { + wallToCell.put(pos.immutable(), cell.getId()); + } + for (BlockPos pos : cell.getInteriorBlocks()) { + interiorToCell.put(pos.immutable(), cell.getId()); + } + + addToSpatialIndex(cell); + setDirty(); + return cell; + } + + /** + * Register a pre-constructed CellDataV2 (used by migration and structure loading). + * The cell must already have its ID and corePos set. + */ + public void registerExistingCell(CellDataV2 cell) { + cells.put(cell.getId(), cell); + coreToCell.put(cell.getCorePos().immutable(), cell.getId()); + + for (BlockPos pos : cell.getWallBlocks()) { + wallToCell.put(pos.immutable(), cell.getId()); + } + for (BlockPos pos : cell.getInteriorBlocks()) { + interiorToCell.put(pos.immutable(), cell.getId()); + } + + addToSpatialIndex(cell); + setDirty(); + } + + /** + * Remove a cell from the registry and all indices. + */ + public void removeCell(UUID cellId) { + CellDataV2 cell = cells.remove(cellId); + if (cell == null) return; + + coreToCell.remove(cell.getCorePos()); + for (BlockPos pos : cell.getWallBlocks()) { + wallToCell.remove(pos); + } + for (BlockPos pos : cell.getBreachedPositions()) { + breachedToCell.remove(pos); + } + for (BlockPos pos : cell.getInteriorBlocks()) { + interiorToCell.remove(pos); + } + + removeFromSpatialIndex(cell); + reservations.remove(cellId); + setDirty(); + } + + /** + * Rescan a cell with a new flood-fill result. + * Clears old indices and repopulates with new geometry. + */ + public void rescanCell(UUID cellId, FloodFillResult newResult) { + CellDataV2 cell = cells.get(cellId); + if (cell == null) return; + + // Clear old indices for this cell + for (BlockPos pos : cell.getWallBlocks()) { + wallToCell.remove(pos); + } + for (BlockPos pos : cell.getBreachedPositions()) { + wallToCell.remove(pos); + breachedToCell.remove(pos); + } + for (BlockPos pos : cell.getInteriorBlocks()) { + interiorToCell.remove(pos); + } + removeFromSpatialIndex(cell); + + // Update geometry + cell.updateGeometry(newResult); + + // Rebuild indices + for (BlockPos pos : cell.getWallBlocks()) { + wallToCell.put(pos.immutable(), cellId); + } + for (BlockPos pos : cell.getInteriorBlocks()) { + interiorToCell.put(pos.immutable(), cellId); + } + addToSpatialIndex(cell); + + setDirty(); + } + + // ==================== QUERIES ==================== + + @Nullable + public CellDataV2 getCell(UUID cellId) { + return cells.get(cellId); + } + + @Nullable + public CellDataV2 getCellAtCore(BlockPos corePos) { + UUID cellId = coreToCell.get(corePos); + return cellId != null ? cells.get(cellId) : null; + } + + @Nullable + public CellDataV2 getCellContaining(BlockPos pos) { + UUID cellId = interiorToCell.get(pos); + return cellId != null ? cells.get(cellId) : null; + } + + @Nullable + public CellDataV2 getCellByWall(BlockPos pos) { + UUID cellId = wallToCell.get(pos); + return cellId != null ? cells.get(cellId) : null; + } + + @Nullable + public UUID getCellIdAtWall(BlockPos pos) { + return wallToCell.get(pos); + } + + public boolean isInsideAnyCell(BlockPos pos) { + return interiorToCell.containsKey(pos); + } + + public Collection getAllCells() { + return Collections.unmodifiableCollection(cells.values()); + } + + public int getCellCount() { + return cells.size(); + } + + public List getCellsByCamp(UUID campId) { + Set cellIds = cellsByCamp.get(campId); + if (cellIds == null) return Collections.emptyList(); + + List result = new ArrayList<>(); + for (UUID cellId : cellIds) { + CellDataV2 cell = cells.get(cellId); + if (cell != null) { + result.add(cell); + } + } + return result; + } + + public List findCellsNear(BlockPos center, double radius) { + List nearby = new ArrayList<>(); + double radiusSq = radius * radius; + + int chunkRadius = (int) Math.ceil(radius / 16.0) + 1; + ChunkPos centerChunk = new ChunkPos(center); + + for (int dx = -chunkRadius; dx <= chunkRadius; dx++) { + for (int dz = -chunkRadius; dz <= chunkRadius; dz++) { + ChunkPos checkChunk = new ChunkPos( + centerChunk.x + dx, + centerChunk.z + dz + ); + Set cellsInChunk = cellsByChunk.get(checkChunk); + + if (cellsInChunk != null) { + for (UUID cellId : cellsInChunk) { + CellDataV2 cell = cells.get(cellId); + if ( + cell != null && + cell.getCorePos().distSqr(center) <= radiusSq + ) { + nearby.add(cell); + } + } + } + } + } + return nearby; + } + + @Nullable + public CellDataV2 findCellByPrisoner(UUID prisonerId) { + for (CellDataV2 cell : cells.values()) { + if (cell.hasPrisoner(prisonerId)) { + return cell; + } + } + return null; + } + + @Nullable + public CellDataV2 getCellByName(String name) { + if (name == null || name.isEmpty()) return null; + for (CellDataV2 cell : cells.values()) { + if (name.equals(cell.getName())) { + return cell; + } + } + return null; + } + + public List getCellsByOwner(UUID ownerId) { + if (ownerId == null) return Collections.emptyList(); + return cells + .values() + .stream() + .filter(c -> ownerId.equals(c.getOwnerId())) + .collect(Collectors.toList()); + } + + public int getCellCountOwnedBy(UUID ownerId) { + if (ownerId == null) return 0; + return (int) cells + .values() + .stream() + .filter(c -> ownerId.equals(c.getOwnerId())) + .count(); + } + + @Nullable + public UUID getNextCellId(@Nullable UUID currentId) { + if (cells.isEmpty()) return null; + List ids = new ArrayList<>(cells.keySet()); + if (currentId == null) return ids.get(0); + int index = ids.indexOf(currentId); + if (index < 0 || index >= ids.size() - 1) return ids.get(0); + return ids.get(index + 1); + } + + // ==================== CAMP QUERIES ==================== + + public List getPrisonersInCamp(UUID campId) { + if (campId == null) return Collections.emptyList(); + return getCellsByCamp(campId) + .stream() + .flatMap(cell -> cell.getPrisonerIds().stream()) + .collect(Collectors.toList()); + } + + public int getPrisonerCountInCamp(UUID campId) { + if (campId == null) return 0; + return getCellsByCamp(campId) + .stream() + .mapToInt(CellDataV2::getPrisonerCount) + .sum(); + } + + @Nullable + public CellDataV2 findAvailableCellInCamp(UUID campId) { + if (campId == null) return null; + for (CellDataV2 cell : getCellsByCamp(campId)) { + if (!cell.isFull()) { + return cell; + } + } + return null; + } + + public boolean hasCampCells(UUID campId) { + if (campId == null) return false; + Set cellIds = cellsByCamp.get(campId); + return cellIds != null && !cellIds.isEmpty(); + } + + /** + * Update the camp index for a cell after ownership change. + * Removes from old camp index, adds to new if camp-owned. + */ + public void updateCampIndex(CellDataV2 cell, @Nullable UUID oldOwnerId) { + if (oldOwnerId != null) { + Set oldCampCells = cellsByCamp.get(oldOwnerId); + if (oldCampCells != null) { + oldCampCells.remove(cell.getId()); + if (oldCampCells.isEmpty()) { + cellsByCamp.remove(oldOwnerId); + } + } + } + + if (cell.isCampOwned() && cell.getOwnerId() != null) { + cellsByCamp + .computeIfAbsent(cell.getOwnerId(), k -> + ConcurrentHashMap.newKeySet() + ) + .add(cell.getId()); + } + + setDirty(); + } + + // ==================== PRISONER MANAGEMENT ==================== + + public synchronized boolean assignPrisoner(UUID cellId, UUID prisonerId) { + CellDataV2 cell = cells.get(cellId); + if (cell == null || cell.isFull()) return false; + + // Ensure prisoner uniqueness across cells + CellDataV2 existingCell = findCellByPrisoner(prisonerId); + if (existingCell != null) { + if (existingCell.getId().equals(cellId)) { + return true; // Already in this cell + } + return false; // Already in another cell + } + + if (cell.addPrisoner(prisonerId)) { + setDirty(); + return true; + } + return false; + } + + public boolean releasePrisoner( + UUID cellId, + UUID prisonerId, + MinecraftServer server + ) { + CellDataV2 cell = cells.get(cellId); + if (cell == null) return false; + + if (cell.removePrisoner(prisonerId)) { + // Synchronize with PrisonerManager + if (cell.isCampOwned() && cell.getCampId() != null) { + com.tiedup.remake.prison.PrisonerManager manager = + com.tiedup.remake.prison.PrisonerManager.get( + server.overworld() + ); + com.tiedup.remake.prison.PrisonerState currentState = + manager.getState(prisonerId); + + boolean isBeingExtracted = + currentState == + com.tiedup.remake.prison.PrisonerState.WORKING; + + if ( + !isBeingExtracted && + currentState == + com.tiedup.remake.prison.PrisonerState.IMPRISONED + ) { + manager.release( + prisonerId, + server.overworld().getGameTime() + ); + } + } + + setDirty(); + return true; + } + return false; + } + + public boolean assignPrisonerWithNotification( + UUID cellId, + UUID prisonerId, + MinecraftServer server, + String prisonerName + ) { + CellDataV2 cell = cells.get(cellId); + if (cell == null || cell.isFull()) return false; + + if (cell.addPrisoner(prisonerId)) { + setDirty(); + if (cell.hasOwner()) { + notifyOwner( + server, + cell.getOwnerId(), + SystemMessageManager.MessageCategory.PRISONER_ARRIVED, + prisonerName + ); + } + return true; + } + return false; + } + + public boolean releasePrisonerWithNotification( + UUID cellId, + UUID prisonerId, + MinecraftServer server, + String prisonerName, + boolean escaped + ) { + CellDataV2 cell = cells.get(cellId); + if (cell == null) return false; + + if (cell.removePrisoner(prisonerId)) { + // Synchronize with PrisonerManager + if (cell.isCampOwned() && cell.getCampId() != null) { + com.tiedup.remake.prison.PrisonerManager manager = + com.tiedup.remake.prison.PrisonerManager.get( + server.overworld() + ); + com.tiedup.remake.prison.PrisonerState currentState = + manager.getState(prisonerId); + + if ( + currentState == + com.tiedup.remake.prison.PrisonerState.IMPRISONED + ) { + manager.release( + prisonerId, + server.overworld().getGameTime() + ); + } + } + + setDirty(); + + if (cell.hasOwner()) { + SystemMessageManager.MessageCategory category = escaped + ? SystemMessageManager.MessageCategory.PRISONER_ESCAPED + : SystemMessageManager.MessageCategory.PRISONER_RELEASED; + notifyOwner(server, cell.getOwnerId(), category, prisonerName); + } + return true; + } + return false; + } + + public int releasePrisonerFromAllCells(UUID prisonerId) { + int count = 0; + for (CellDataV2 cell : cells.values()) { + if (cell.removePrisoner(prisonerId)) { + count++; + } + } + if (count > 0) { + setDirty(); + } + return count; + } + + /** Offline timeout for cleanup: 30 minutes */ + private static final long OFFLINE_TIMEOUT_MS = 30 * 60 * 1000L; + + public int cleanupEscapedPrisoners( + ServerLevel level, + com.tiedup.remake.state.CollarRegistry collarRegistry, + double maxDistance + ) { + int removed = 0; + + for (CellDataV2 cell : cells.values()) { + List toRemove = new ArrayList<>(); + + for (UUID prisonerId : cell.getPrisonerIds()) { + boolean shouldRemove = false; + String reason = null; + + ServerPlayer prisoner = level + .getServer() + .getPlayerList() + .getPlayer(prisonerId); + + if (prisoner == null) { + Long timestamp = cell.getPrisonerTimestamp(prisonerId); + long ts = + timestamp != null + ? timestamp + : System.currentTimeMillis(); + long offlineDuration = System.currentTimeMillis() - ts; + + if (offlineDuration > OFFLINE_TIMEOUT_MS) { + shouldRemove = true; + reason = + "offline for too long (" + + (offlineDuration / 60000) + + " minutes)"; + } else { + continue; + } + } else { + // Use corePos for distance check (V2 uses core position, not spawnPoint) + double distSq = prisoner + .blockPosition() + .distSqr(cell.getCorePos()); + if (distSq > maxDistance * maxDistance) { + shouldRemove = true; + reason = + "too far from cell (" + + (int) Math.sqrt(distSq) + + " blocks)"; + } + + if ( + !shouldRemove && !collarRegistry.hasOwners(prisonerId) + ) { + shouldRemove = true; + reason = "no collar registered"; + } + + if (!shouldRemove) { + com.tiedup.remake.state.IBondageState state = + com.tiedup.remake.util.KidnappedHelper.getKidnappedState( + prisoner + ); + if (state == null || !state.isCaptive()) { + shouldRemove = true; + reason = "no longer captive"; + } + } + } + + if (shouldRemove) { + toRemove.add(prisonerId); + TiedUpMod.LOGGER.info( + "[CellRegistryV2] Removing escaped prisoner {} from cell {} - reason: {}", + prisonerId.toString().substring(0, 8), + cell.getId().toString().substring(0, 8), + reason + ); + } + } + + for (UUID id : toRemove) { + cell.removePrisoner(id); + + if (cell.isCampOwned() && cell.getCampId() != null) { + com.tiedup.remake.prison.PrisonerManager manager = + com.tiedup.remake.prison.PrisonerManager.get( + level.getServer().overworld() + ); + com.tiedup.remake.prison.PrisonerState currentState = + manager.getState(id); + + if ( + currentState == + com.tiedup.remake.prison.PrisonerState.IMPRISONED + ) { + com.tiedup.remake.prison.service.PrisonerService.get().escape( + level, + id, + "offline_cleanup" + ); + } + } + + removed++; + } + } + + if (removed > 0) { + setDirty(); + } + + return removed; + } + + // ==================== NOTIFICATIONS ==================== + + private void notifyOwner( + MinecraftServer server, + UUID ownerId, + SystemMessageManager.MessageCategory category, + String prisonerName + ) { + if (server == null || ownerId == null) return; + ServerPlayer owner = server.getPlayerList().getPlayer(ownerId); + if (owner != null) { + String template = SystemMessageManager.getTemplate(category); + String formattedMessage = String.format(template, prisonerName); + SystemMessageManager.sendToPlayer( + owner, + category, + formattedMessage + ); + } + } + + // ==================== BREACH MANAGEMENT ==================== + + /** + * Record a wall breach: updates CellDataV2 and indices atomically. + */ + public void addBreach(UUID cellId, BlockPos pos) { + CellDataV2 cell = cells.get(cellId); + if (cell == null) return; + + cell.addBreach(pos); + wallToCell.remove(pos); + breachedToCell.put(pos.immutable(), cellId); + setDirty(); + } + + /** + * Repair a wall breach: updates CellDataV2 and indices atomically. + */ + public void repairBreach(UUID cellId, BlockPos pos) { + CellDataV2 cell = cells.get(cellId); + if (cell == null) return; + + cell.repairBreach(pos); + breachedToCell.remove(pos); + wallToCell.put(pos.immutable(), cellId); + setDirty(); + } + + /** + * Get the cell ID for a breached wall position. + */ + @Nullable + public UUID getCellIdAtBreach(BlockPos pos) { + return breachedToCell.get(pos); + } + + // ==================== RESERVATIONS ==================== + + public boolean reserveCell(UUID cellId, UUID kidnapperUUID, long gameTime) { + cleanupExpiredReservations(gameTime); + + long expiryTime = gameTime + RESERVATION_TIMEOUT_TICKS; + CellReservation existing = reservations.get(cellId); + + if (existing != null) { + if (existing.getKidnapperUUID().equals(kidnapperUUID)) { + reservations.put( + cellId, + new CellReservation(kidnapperUUID, expiryTime) + ); + return true; + } + if (!existing.isExpired(gameTime)) { + return false; + } + } + + reservations.put( + cellId, + new CellReservation(kidnapperUUID, expiryTime) + ); + return true; + } + + public boolean consumeReservation(UUID cellId, UUID kidnapperUUID) { + CellReservation reservation = reservations.remove(cellId); + if (reservation == null) return false; + return reservation.getKidnapperUUID().equals(kidnapperUUID); + } + + public boolean isReservedByOther( + UUID cellId, + @Nullable UUID kidnapperUUID, + long gameTime + ) { + CellReservation reservation = reservations.get(cellId); + if (reservation == null) return false; + if (reservation.isExpired(gameTime)) { + reservations.remove(cellId); + return false; + } + return ( + kidnapperUUID == null || + !reservation.getKidnapperUUID().equals(kidnapperUUID) + ); + } + + public void cancelReservation(UUID cellId, UUID kidnapperUUID) { + CellReservation reservation = reservations.get(cellId); + if ( + reservation != null && + reservation.getKidnapperUUID().equals(kidnapperUUID) + ) { + reservations.remove(cellId); + } + } + + private void cleanupExpiredReservations(long gameTime) { + reservations + .entrySet() + .removeIf(entry -> entry.getValue().isExpired(gameTime)); + } + + // ==================== SPATIAL INDEX ==================== + + private void addToSpatialIndex(CellDataV2 cell) { + ChunkPos chunkPos = new ChunkPos(cell.getCorePos()); + cellsByChunk + .computeIfAbsent(chunkPos, k -> ConcurrentHashMap.newKeySet()) + .add(cell.getId()); + + // Add to camp index if camp-owned + if ( + cell.getOwnerType() == CellOwnerType.CAMP && + cell.getOwnerId() != null + ) { + cellsByCamp + .computeIfAbsent(cell.getOwnerId(), k -> + ConcurrentHashMap.newKeySet() + ) + .add(cell.getId()); + } + } + + private void removeFromSpatialIndex(CellDataV2 cell) { + ChunkPos chunkPos = new ChunkPos(cell.getCorePos()); + Set cellsInChunk = cellsByChunk.get(chunkPos); + if (cellsInChunk != null) { + cellsInChunk.remove(cell.getId()); + if (cellsInChunk.isEmpty()) { + cellsByChunk.remove(chunkPos); + } + } + + if ( + cell.getOwnerType() == CellOwnerType.CAMP && + cell.getOwnerId() != null + ) { + Set cellsInCamp = cellsByCamp.get(cell.getOwnerId()); + if (cellsInCamp != null) { + cellsInCamp.remove(cell.getId()); + if (cellsInCamp.isEmpty()) { + cellsByCamp.remove(cell.getOwnerId()); + } + } + } + } + + // ==================== INDEX REBUILD ==================== + + private void rebuildIndices() { + wallToCell.clear(); + interiorToCell.clear(); + coreToCell.clear(); + breachedToCell.clear(); + cellsByChunk.clear(); + cellsByCamp.clear(); + + for (CellDataV2 cell : cells.values()) { + coreToCell.put(cell.getCorePos(), cell.getId()); + for (BlockPos pos : cell.getWallBlocks()) { + wallToCell.put(pos, cell.getId()); + } + for (BlockPos pos : cell.getBreachedPositions()) { + breachedToCell.put(pos, cell.getId()); + } + for (BlockPos pos : cell.getInteriorBlocks()) { + interiorToCell.put(pos, cell.getId()); + } + addToSpatialIndex(cell); + } + } + + // ==================== PERSISTENCE ==================== + + @Override + public @NotNull CompoundTag save(@NotNull CompoundTag tag) { + ListTag cellList = new ListTag(); + for (CellDataV2 cell : cells.values()) { + cellList.add(cell.save()); + } + tag.put("cells", cellList); + return tag; + } + + public static CellRegistryV2 load(CompoundTag tag) { + CellRegistryV2 registry = new CellRegistryV2(); + + if (tag.contains("cells")) { + ListTag cellList = tag.getList("cells", Tag.TAG_COMPOUND); + for (int i = 0; i < cellList.size(); i++) { + CellDataV2 cell = CellDataV2.load(cellList.getCompound(i)); + if (cell != null) { + registry.cells.put(cell.getId(), cell); + } + } + } + + registry.rebuildIndices(); + return registry; + } + + // ==================== DEBUG ==================== + + public String toDebugString() { + StringBuilder sb = new StringBuilder(); + sb.append("CellRegistryV2:\n"); + sb.append(" Total cells: ").append(cells.size()).append("\n"); + sb.append(" Wall index: ").append(wallToCell.size()).append("\n"); + sb + .append(" Interior index: ") + .append(interiorToCell.size()) + .append("\n"); + sb.append(" Core index: ").append(coreToCell.size()).append("\n"); + + for (CellDataV2 cell : cells.values()) { + sb.append(" ").append(cell.toString()).append("\n"); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/tiedup/remake/cells/CellSelectionManager.java b/src/main/java/com/tiedup/remake/cells/CellSelectionManager.java new file mode 100644 index 0000000..f373754 --- /dev/null +++ b/src/main/java/com/tiedup/remake/cells/CellSelectionManager.java @@ -0,0 +1,96 @@ +package com.tiedup.remake.cells; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.core.BlockPos; +import org.jetbrains.annotations.Nullable; + +/** + * Server-side manager tracking which players are in selection mode + * (Set Spawn, Set Delivery, Set Disguise) after clicking a Cell Core menu button. + * + * Static map pattern matching ForcedSeatingHandler. + */ +public class CellSelectionManager { + + private static final long TIMEOUT_MS = 30 * 1000L; + private static final double MAX_DISTANCE_SQ = 10.0 * 10.0; + + private static final ConcurrentHashMap selections = + new ConcurrentHashMap<>(); + + public static class SelectionContext { + + public final SelectionMode mode; + public final BlockPos corePos; + public final UUID cellId; + public final long startTimeMs; + public final BlockPos playerStartPos; + + public SelectionContext( + SelectionMode mode, + BlockPos corePos, + UUID cellId, + BlockPos playerStartPos + ) { + this.mode = mode; + this.corePos = corePos; + this.cellId = cellId; + this.startTimeMs = System.currentTimeMillis(); + this.playerStartPos = playerStartPos; + } + } + + public static void startSelection( + UUID playerId, + SelectionMode mode, + BlockPos corePos, + UUID cellId, + BlockPos playerPos + ) { + selections.put( + playerId, + new SelectionContext(mode, corePos, cellId, playerPos) + ); + } + + @Nullable + public static SelectionContext getSelection(UUID playerId) { + return selections.get(playerId); + } + + public static void clearSelection(UUID playerId) { + selections.remove(playerId); + } + + public static boolean isInSelectionMode(UUID playerId) { + return selections.containsKey(playerId); + } + + /** + * Check if selection should be cancelled due to timeout or distance. + */ + public static boolean shouldCancel(UUID playerId, BlockPos currentPos) { + SelectionContext ctx = selections.get(playerId); + if (ctx == null) return false; + + // Timeout check + if (System.currentTimeMillis() - ctx.startTimeMs > TIMEOUT_MS) { + return true; + } + + // Distance check (from core, not player start) + if (ctx.corePos.distSqr(currentPos) > MAX_DISTANCE_SQ) { + return true; + } + + return false; + } + + /** + * Called on player disconnect to prevent memory leaks. + */ + public static void cleanup(UUID playerId) { + selections.remove(playerId); + } +} diff --git a/src/main/java/com/tiedup/remake/cells/CellState.java b/src/main/java/com/tiedup/remake/cells/CellState.java new file mode 100644 index 0000000..494ab03 --- /dev/null +++ b/src/main/java/com/tiedup/remake/cells/CellState.java @@ -0,0 +1,33 @@ +package com.tiedup.remake.cells; + +/** + * State of a Cell System V2 cell. + * + * INTACT — all walls present, fully operational. + * BREACHED — some walls broken, prisoners may escape. + * COMPROMISED — Core destroyed or too many walls broken; cell is non-functional. + */ +public enum CellState { + INTACT("intact"), + BREACHED("breached"), + COMPROMISED("compromised"); + + private final String serializedName; + + CellState(String serializedName) { + this.serializedName = serializedName; + } + + public String getSerializedName() { + return serializedName; + } + + public static CellState fromString(String name) { + for (CellState state : values()) { + if (state.serializedName.equalsIgnoreCase(name)) { + return state; + } + } + return INTACT; + } +} diff --git a/src/main/java/com/tiedup/remake/cells/ConfiscatedInventoryRegistry.java b/src/main/java/com/tiedup/remake/cells/ConfiscatedInventoryRegistry.java new file mode 100644 index 0000000..d38739e --- /dev/null +++ b/src/main/java/com/tiedup/remake/cells/ConfiscatedInventoryRegistry.java @@ -0,0 +1,641 @@ +package com.tiedup.remake.cells; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtUtils; +import net.minecraft.nbt.Tag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.Container; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.minecraft.world.level.saveddata.SavedData; +import org.jetbrains.annotations.Nullable; + +/** + * Phase 2: SavedData registry for confiscated player inventories. + * + * When a player is imprisoned: + * 1. Their inventory is saved to NBT + * 2. Items are transferred to a LOOT chest in the cell + * 3. Player's inventory is cleared + * 4. Data is persisted for recovery after server restart + * + * Recovery options: + * - Player finds and opens the chest manually + * - Player escapes and returns to chest + * - Admin command /tiedup returnstuff + */ +public class ConfiscatedInventoryRegistry extends SavedData { + + private static final String DATA_NAME = + TiedUpMod.MOD_ID + "_confiscated_inventories"; + + /** + * Map of prisoner UUID to their confiscated inventory data + */ + private final Map confiscatedInventories = + new HashMap<>(); + + /** + * Data class for a single confiscated inventory + */ + public static class ConfiscatedData { + + public final UUID prisonerId; + public final CompoundTag inventoryNbt; + public final BlockPos chestPos; + public final UUID cellId; + public final long confiscatedTime; + + public ConfiscatedData( + UUID prisonerId, + CompoundTag inventoryNbt, + BlockPos chestPos, + @Nullable UUID cellId, + long confiscatedTime + ) { + this.prisonerId = prisonerId; + this.inventoryNbt = inventoryNbt; + this.chestPos = chestPos; + this.cellId = cellId; + this.confiscatedTime = confiscatedTime; + } + + public CompoundTag save() { + CompoundTag tag = new CompoundTag(); + tag.putUUID("prisonerId", prisonerId); + tag.put("inventory", inventoryNbt); + tag.put("chestPos", NbtUtils.writeBlockPos(chestPos)); + if (cellId != null) { + tag.putUUID("cellId", cellId); + } + tag.putLong("time", confiscatedTime); + return tag; + } + + public static ConfiscatedData load(CompoundTag tag) { + UUID prisonerId = tag.getUUID("prisonerId"); + CompoundTag inventoryNbt = tag.getCompound("inventory"); + BlockPos chestPos = NbtUtils.readBlockPos( + tag.getCompound("chestPos") + ); + UUID cellId = tag.contains("cellId") ? tag.getUUID("cellId") : null; + long time = tag.getLong("time"); + return new ConfiscatedData( + prisonerId, + inventoryNbt, + chestPos, + cellId, + time + ); + } + } + + public ConfiscatedInventoryRegistry() {} + + // ==================== STATIC ACCESSORS ==================== + + /** + * Get or create the registry for a server level. + */ + public static ConfiscatedInventoryRegistry get(ServerLevel level) { + return level + .getDataStorage() + .computeIfAbsent( + ConfiscatedInventoryRegistry::load, + ConfiscatedInventoryRegistry::new, + DATA_NAME + ); + } + + // ==================== CONFISCATION METHODS ==================== + + /** + * Confiscate a player's inventory. + * + * @param player The player whose inventory to confiscate + * @param chestPos The position of the LOOT chest + * @param cellId The cell ID (optional) + * @return true if confiscation was successful + */ + public boolean confiscate( + ServerPlayer player, + BlockPos chestPos, + @Nullable UUID cellId + ) { + Inventory inventory = player.getInventory(); + + // CRITICAL FIX: Transaction safety - save record BEFORE any state changes + // This ensures that if the server crashes, we have a backup to restore from + + // Save inventory to NBT (backup in case of issues) + CompoundTag inventoryNbt = savePlayerInventory(player); + + // Create and persist record FIRST (before modifying game state) + ConfiscatedData data = new ConfiscatedData( + player.getUUID(), + inventoryNbt, + chestPos, + cellId, + System.currentTimeMillis() + ); + confiscatedInventories.put(player.getUUID(), data); + setDirty(); // Persist immediately before state changes + + // Now attempt transfer to chest + boolean transferred = transferToChest( + player.serverLevel(), + chestPos, + inventory + ); + + if (!transferred) { + // Transfer failed - rollback: remove record and DO NOT clear inventory + confiscatedInventories.remove(player.getUUID()); + setDirty(); // Persist the rollback + + TiedUpMod.LOGGER.error( + "[ConfiscatedInventoryRegistry] Failed to transfer items for {} - items NOT confiscated, record rolled back", + player.getName().getString() + ); + return false; + } + + // Transfer succeeded - now safe to clear inventory + inventory.clearContent(); + + TiedUpMod.LOGGER.info( + "[ConfiscatedInventoryRegistry] Confiscated inventory from {} (chest: {}, cell: {})", + player.getName().getString(), + chestPos.toShortString(), + cellId != null ? cellId.toString().substring(0, 8) : "none" + ); + + // Final persist to save cleared inventory state + setDirty(); + return true; + } + + /** + * Dump a player's inventory to a chest WITHOUT creating a backup record. + * Used for daily labor returns - items gathered during work are transferred to camp storage. + * Does NOT clear the player's inventory - caller should clear labor tools separately. + * + * @param player The player whose inventory to dump + * @param chestPos The position of the chest + * @return true if transfer was successful + */ + public boolean dumpInventoryToChest( + ServerPlayer player, + BlockPos chestPos + ) { + Inventory inventory = player.getInventory(); + + // Transfer items to chest + boolean transferred = transferToChest( + player.serverLevel(), + chestPos, + inventory + ); + + if (!transferred) { + TiedUpMod.LOGGER.warn( + "[ConfiscatedInventoryRegistry] Failed to dump labor inventory for {} - chest issue", + player.getName().getString() + ); + return false; + } + + // Clear player inventory (items are now in chest) + inventory.clearContent(); + + TiedUpMod.LOGGER.debug( + "[ConfiscatedInventoryRegistry] Dumped labor inventory from {} to chest at {}", + player.getName().getString(), + chestPos.toShortString() + ); + + return true; + } + + /** + * Deposits items across multiple LOOT chests with smart rotation. + * Distributes items evenly to prevent single chest from filling too quickly. + * + * @param items List of items to deposit + * @param chestPositions List of chest positions to use (in priority order) + * @param level Server level + * @return Number of items successfully deposited (remainder dropped) + */ + public int depositItemsInChests( + List items, + List chestPositions, + ServerLevel level + ) { + if (items.isEmpty() || chestPositions.isEmpty()) { + return 0; + } + + int deposited = 0; + List overflow = new ArrayList<>(); + + // Try to deposit each item + for (ItemStack stack : items) { + if (stack.isEmpty()) continue; + + boolean placed = false; + + // Try all chests in order + for (BlockPos chestPos : chestPositions) { + BlockEntity be = level.getBlockEntity(chestPos); + if (!(be instanceof ChestBlockEntity chest)) continue; + + // Try to stack with existing items first + for (int i = 0; i < chest.getContainerSize(); i++) { + ItemStack slot = chest.getItem(i); + + // Stack with existing + if ( + !slot.isEmpty() && + ItemStack.isSameItemSameTags(slot, stack) && + slot.getCount() < slot.getMaxStackSize() + ) { + int spaceInSlot = + slot.getMaxStackSize() - slot.getCount(); + int toAdd = Math.min(spaceInSlot, stack.getCount()); + + slot.grow(toAdd); + chest.setChanged(); + stack.shrink(toAdd); + deposited += toAdd; + + if (stack.isEmpty()) { + placed = true; + break; + } + } + } + + if (placed) break; + + // Try to place in empty slot + if (!stack.isEmpty()) { + for (int i = 0; i < chest.getContainerSize(); i++) { + if (chest.getItem(i).isEmpty()) { + chest.setItem(i, stack.copy()); + chest.setChanged(); + deposited += stack.getCount(); + placed = true; + break; + } + } + } + + if (placed) break; + } + + // If not placed, add to overflow + if (!placed && !stack.isEmpty()) { + overflow.add(stack); + } + } + + // Drop overflow items at first chest location + if (!overflow.isEmpty() && !chestPositions.isEmpty()) { + BlockPos dropPos = chestPositions.get(0); + for (ItemStack stack : overflow) { + net.minecraft.world.entity.item.ItemEntity itemEntity = + new net.minecraft.world.entity.item.ItemEntity( + level, + dropPos.getX() + 0.5, + dropPos.getY() + 1.0, + dropPos.getZ() + 0.5, + stack + ); + level.addFreshEntity(itemEntity); + } + + TiedUpMod.LOGGER.warn( + "[ConfiscatedInventoryRegistry] {} items overflowed - dropped at {}", + overflow.stream().mapToInt(ItemStack::getCount).sum(), + dropPos.toShortString() + ); + } + + return deposited; + } + + /** + * Save player inventory to NBT. + */ + private CompoundTag savePlayerInventory(ServerPlayer player) { + CompoundTag tag = new CompoundTag(); + tag.put("Items", player.getInventory().save(new ListTag())); + return tag; + } + + /** + * Transfer inventory contents to a chest. + * Handles ALL inventory slots: + * - Slots 0-35: Main inventory (hotbar 0-8, backpack 9-35) + * - Slots 36-39: Armor (boots, leggings, chestplate, helmet) + * - Slot 40: Offhand + */ + private boolean transferToChest( + ServerLevel level, + BlockPos chestPos, + Inventory inventory + ) { + // Find an existing chest near the LOOT marker position + BlockPos actualChestPos = findExistingChestNear(level, chestPos); + if (actualChestPos == null) { + TiedUpMod.LOGGER.warn( + "[ConfiscatedInventoryRegistry] No existing chest found near {} - structure may be damaged", + chestPos.toShortString() + ); + return false; + } + + BlockEntity be = level.getBlockEntity(actualChestPos); + if (!(be instanceof Container chest)) { + TiedUpMod.LOGGER.warn( + "[ConfiscatedInventoryRegistry] Block at {} is not a container", + actualChestPos.toShortString() + ); + return false; + } + + // Transfer ALL items including armor and offhand + int mainItems = 0; + int armorItems = 0; + int offhandItems = 0; + int droppedItems = 0; + + // getContainerSize() returns 41: 36 main + 4 armor + 1 offhand + for (int i = 0; i < inventory.getContainerSize(); i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty()) { + ItemStack remaining = addToContainer(chest, stack.copy()); + if (remaining.isEmpty()) { + // Track which slot type was transferred + if (i < 36) { + mainItems++; + } else if (i < 40) { + armorItems++; + } else { + offhandItems++; + } + } else { + // Couldn't fit all items - drop them on the ground near the chest + // This prevents permanent item loss + net.minecraft.world.entity.item.ItemEntity itemEntity = + new net.minecraft.world.entity.item.ItemEntity( + level, + actualChestPos.getX() + 0.5, + actualChestPos.getY() + 1.0, + actualChestPos.getZ() + 0.5, + remaining + ); + // Add slight random velocity to spread items + itemEntity.setDeltaMovement( + (level.random.nextDouble() - 0.5) * 0.2, + 0.2, + (level.random.nextDouble() - 0.5) * 0.2 + ); + level.addFreshEntity(itemEntity); + droppedItems += remaining.getCount(); + } + } + } + + if (droppedItems > 0) { + TiedUpMod.LOGGER.info( + "[ConfiscatedInventoryRegistry] Dropped {} overflow items at {}", + droppedItems, + actualChestPos.toShortString() + ); + } + + int totalTransferred = mainItems + armorItems + offhandItems; + TiedUpMod.LOGGER.info( + "[ConfiscatedInventoryRegistry] Confiscated {} items (inventory: {}, armor: {}, offhand: {})", + totalTransferred, + mainItems, + armorItems, + offhandItems + ); + + return true; + } + + /** + * Add an item stack to a container, returning any remainder. + */ + private ItemStack addToContainer(Container container, ItemStack stack) { + ItemStack remaining = stack.copy(); + + for ( + int i = 0; + i < container.getContainerSize() && !remaining.isEmpty(); + i++ + ) { + ItemStack slotStack = container.getItem(i); + + if (slotStack.isEmpty()) { + container.setItem(i, remaining.copy()); + remaining = ItemStack.EMPTY; + } else if (ItemStack.isSameItemSameTags(slotStack, remaining)) { + int space = slotStack.getMaxStackSize() - slotStack.getCount(); + int toTransfer = Math.min(space, remaining.getCount()); + if (toTransfer > 0) { + slotStack.grow(toTransfer); + remaining.shrink(toTransfer); + } + } + } + + container.setChanged(); + return remaining; + } + + /** + * Find an existing chest near the given position (typically a LOOT marker). + * The LOOT marker is placed ABOVE the physical chest in the structure, + * so we check below first, then at the position, then in a small radius. + * + * Does NOT spawn new chests - structures must have chests placed by their templates. + * + * @return The position of an existing chest, or null if none found + */ + @Nullable + private static BlockPos findExistingChestNear( + ServerLevel level, + BlockPos pos + ) { + // Check below the marker (chest is usually under the LOOT marker) + BlockPos below = pos.below(); + if (level.getBlockEntity(below) instanceof ChestBlockEntity) { + return below; + } + + // Check at the marker position itself + if (level.getBlockEntity(pos) instanceof ChestBlockEntity) { + return pos; + } + + // Search in a small radius (3 blocks) + for (int radius = 1; radius <= 3; radius++) { + for (int dx = -radius; dx <= radius; dx++) { + for (int dy = -2; dy <= 1; dy++) { + for (int dz = -radius; dz <= radius; dz++) { + if (dx == 0 && dy == 0 && dz == 0) continue; + + BlockPos testPos = pos.offset(dx, dy, dz); + if ( + level.getBlockEntity(testPos) instanceof + ChestBlockEntity + ) { + TiedUpMod.LOGGER.debug( + "[ConfiscatedInventoryRegistry] Found existing chest at {} (offset from marker {})", + testPos.toShortString(), + pos.toShortString() + ); + return testPos; + } + } + } + } + } + + TiedUpMod.LOGGER.warn( + "[ConfiscatedInventoryRegistry] No existing chest found near marker at {}", + pos.toShortString() + ); + return null; + } + + // ==================== RECOVERY METHODS ==================== + + /** + * Check if a player has confiscated inventory. + */ + public boolean hasConfiscatedInventory(UUID playerId) { + return confiscatedInventories.containsKey(playerId); + } + + /** + * Get confiscated data for a player. + */ + @Nullable + public ConfiscatedData getConfiscatedData(UUID playerId) { + return confiscatedInventories.get(playerId); + } + + /** + * Get the chest position for a player's confiscated inventory. + */ + @Nullable + public BlockPos getChestPosition(UUID playerId) { + ConfiscatedData data = confiscatedInventories.get(playerId); + return data != null ? data.chestPos : null; + } + + /** + * Restore a player's confiscated inventory from the NBT backup. + * This gives items directly to the player, bypassing the chest. + * + * @param player The player to restore inventory to + * @return true if restoration was successful, false if no confiscated data found + */ + public boolean restoreInventory(ServerPlayer player) { + ConfiscatedData data = confiscatedInventories.get(player.getUUID()); + if (data == null) { + TiedUpMod.LOGGER.debug( + "[ConfiscatedInventoryRegistry] No confiscated inventory for {}", + player.getName().getString() + ); + return false; + } + + // Load inventory from NBT backup + if (data.inventoryNbt.contains("Items")) { + ListTag items = data.inventoryNbt.getList( + "Items", + Tag.TAG_COMPOUND + ); + player.getInventory().load(items); + + TiedUpMod.LOGGER.info( + "[ConfiscatedInventoryRegistry] Restored {} inventory slots to {}", + items.size(), + player.getName().getString() + ); + } + + // Remove the confiscation record + confiscatedInventories.remove(player.getUUID()); + setDirty(); + + return true; + } + + /** + * Remove the confiscation record for a player without restoring items. + * Used when the player has already retrieved items from the chest manually. + * + * @param playerId The player's UUID + */ + public void clearConfiscationRecord(UUID playerId) { + if (confiscatedInventories.remove(playerId) != null) { + TiedUpMod.LOGGER.debug( + "[ConfiscatedInventoryRegistry] Cleared confiscation record for {}", + playerId.toString().substring(0, 8) + ); + setDirty(); + } + } + + // ==================== SERIALIZATION ==================== + + @Override + public CompoundTag save(CompoundTag tag) { + ListTag list = new ListTag(); + for (ConfiscatedData data : confiscatedInventories.values()) { + list.add(data.save()); + } + tag.put("confiscated", list); + return tag; + } + + public static ConfiscatedInventoryRegistry load(CompoundTag tag) { + ConfiscatedInventoryRegistry registry = + new ConfiscatedInventoryRegistry(); + + if (tag.contains("confiscated")) { + ListTag list = tag.getList("confiscated", Tag.TAG_COMPOUND); + for (int i = 0; i < list.size(); i++) { + ConfiscatedData data = ConfiscatedData.load( + list.getCompound(i) + ); + registry.confiscatedInventories.put(data.prisonerId, data); + } + } + + TiedUpMod.LOGGER.info( + "[ConfiscatedInventoryRegistry] Loaded {} confiscated inventory records", + registry.confiscatedInventories.size() + ); + + return registry; + } +} diff --git a/src/main/java/com/tiedup/remake/cells/FloodFillAlgorithm.java b/src/main/java/com/tiedup/remake/cells/FloodFillAlgorithm.java new file mode 100644 index 0000000..1bdd077 --- /dev/null +++ b/src/main/java/com/tiedup/remake/cells/FloodFillAlgorithm.java @@ -0,0 +1,407 @@ +package com.tiedup.remake.cells; + +import java.util.*; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.*; +import net.minecraft.world.level.block.state.BlockState; + +/** + * BFS flood-fill algorithm for detecting enclosed rooms around a Cell Core. + * + * Scans outward from air neighbors of the Core block, treating solid blocks + * (including the Core itself) as walls. Picks the smallest successful fill + * as the cell interior (most likely the room, not the hallway). + */ +public final class FloodFillAlgorithm { + + static final int MAX_VOLUME = 1200; + static final int MIN_VOLUME = 2; + static final int MAX_X = 12; + static final int MAX_Y = 8; + static final int MAX_Z = 12; + + private FloodFillAlgorithm() {} + + /** + * Try flood-fill from each air neighbor of the Core position. + * Pick the smallest successful fill (= most likely the cell, not the hallway). + * If none succeed, return a failure result. + */ + public static FloodFillResult tryFill(Level level, BlockPos corePos) { + Set bestInterior = null; + Direction bestDirection = null; + + for (Direction dir : Direction.values()) { + BlockPos neighbor = corePos.relative(dir); + BlockState neighborState = level.getBlockState(neighbor); + + if (!isPassable(neighborState)) { + continue; + } + + Set interior = bfs(level, neighbor, corePos); + if (interior == null) { + // Overflow or out of bounds — this direction opens to the outside + continue; + } + + if (interior.size() < MIN_VOLUME) { + continue; + } + + if (bestInterior == null || interior.size() < bestInterior.size()) { + bestInterior = interior; + bestDirection = dir; + } + } + + if (bestInterior == null) { + // No direction produced a valid fill — check why + // Try again to determine the most helpful error message + boolean anyAir = false; + boolean tooLarge = false; + boolean tooSmall = false; + boolean outOfBounds = false; + + for (Direction dir : Direction.values()) { + BlockPos neighbor = corePos.relative(dir); + BlockState neighborState = level.getBlockState(neighbor); + if (!isPassable(neighborState)) continue; + anyAir = true; + + Set result = bfsDiagnostic(level, neighbor, corePos); + if (result == null) { + // Overflowed — could be not enclosed or too large + tooLarge = true; + } else if (result.size() < MIN_VOLUME) { + tooSmall = true; + } else { + outOfBounds = true; + } + } + + if (!anyAir) { + return FloodFillResult.failure( + "msg.tiedup.cell_core.not_enclosed" + ); + } else if (tooLarge) { + // Could be open to outside or genuinely too large + return FloodFillResult.failure( + "msg.tiedup.cell_core.not_enclosed" + ); + } else if (outOfBounds) { + return FloodFillResult.failure( + "msg.tiedup.cell_core.out_of_bounds" + ); + } else if (tooSmall) { + return FloodFillResult.failure( + "msg.tiedup.cell_core.too_small" + ); + } else { + return FloodFillResult.failure( + "msg.tiedup.cell_core.not_enclosed" + ); + } + } + + // Build walls set + Set walls = findWalls(level, bestInterior, corePos); + + // Detect features + List beds = new ArrayList<>(); + List petBeds = new ArrayList<>(); + List anchors = new ArrayList<>(); + List doors = new ArrayList<>(); + List linkedRedstone = new ArrayList<>(); + detectFeatures( + level, + bestInterior, + walls, + beds, + petBeds, + anchors, + doors, + linkedRedstone + ); + + return FloodFillResult.success( + bestInterior, + walls, + bestDirection, + beds, + petBeds, + anchors, + doors, + linkedRedstone + ); + } + + /** + * BFS from start position, treating corePos and solid blocks as walls. + * + * @return The set of interior (passable) positions, or null if the fill + * overflowed MAX_VOLUME or exceeded MAX bounds. + */ + private static Set bfs( + Level level, + BlockPos start, + BlockPos corePos + ) { + Set visited = new HashSet<>(); + Queue queue = new ArrayDeque<>(); + + visited.add(start); + queue.add(start); + + int minX = start.getX(), + maxX = start.getX(); + int minY = start.getY(), + maxY = start.getY(); + int minZ = start.getZ(), + maxZ = start.getZ(); + + while (!queue.isEmpty()) { + BlockPos current = queue.poll(); + + for (Direction dir : Direction.values()) { + BlockPos next = current.relative(dir); + + if (next.equals(corePos)) { + // Core is always treated as wall + continue; + } + + if (visited.contains(next)) { + continue; + } + + // Treat unloaded chunks as walls to avoid synchronous chunk loading + if (!level.isLoaded(next)) { + continue; + } + + BlockState state = level.getBlockState(next); + if (!isPassable(state)) { + // Solid block = wall, don't expand + continue; + } + + visited.add(next); + + // Check volume + if (visited.size() > MAX_VOLUME) { + return null; // Too large or not enclosed + } + + // Update bounds + minX = Math.min(minX, next.getX()); + maxX = Math.max(maxX, next.getX()); + minY = Math.min(minY, next.getY()); + maxY = Math.max(maxY, next.getY()); + minZ = Math.min(minZ, next.getZ()); + maxZ = Math.max(maxZ, next.getZ()); + + // Check dimensional bounds + if ( + (maxX - minX + 1) > MAX_X || + (maxY - minY + 1) > MAX_Y || + (maxZ - minZ + 1) > MAX_Z + ) { + return null; // Exceeds max dimensions + } + + queue.add(next); + } + } + + return visited; + } + + /** + * Diagnostic BFS: same as bfs() but returns the set even on bounds overflow + * (returns null only on volume overflow). Used to determine error messages. + */ + private static Set bfsDiagnostic( + Level level, + BlockPos start, + BlockPos corePos + ) { + Set visited = new HashSet<>(); + Queue queue = new ArrayDeque<>(); + + visited.add(start); + queue.add(start); + + while (!queue.isEmpty()) { + BlockPos current = queue.poll(); + + for (Direction dir : Direction.values()) { + BlockPos next = current.relative(dir); + + if (next.equals(corePos) || visited.contains(next)) { + continue; + } + + // Treat unloaded chunks as walls to avoid synchronous chunk loading + if (!level.isLoaded(next)) { + continue; + } + + BlockState state = level.getBlockState(next); + if (!isPassable(state)) { + continue; + } + + visited.add(next); + + if (visited.size() > MAX_VOLUME) { + return null; + } + + queue.add(next); + } + } + + return visited; + } + + /** + * Find all solid blocks adjacent to the interior set (the walls of the cell). + * The Core block itself is always included as a wall. + */ + private static Set findWalls( + Level level, + Set interior, + BlockPos corePos + ) { + Set walls = new HashSet<>(); + walls.add(corePos); + + for (BlockPos pos : interior) { + for (Direction dir : Direction.values()) { + BlockPos neighbor = pos.relative(dir); + if (!interior.contains(neighbor) && !neighbor.equals(corePos)) { + // This is a solid boundary block + walls.add(neighbor); + } + } + } + + return walls; + } + + /** + * Scan interior and wall blocks to detect notable features. + */ + private static void detectFeatures( + Level level, + Set interior, + Set walls, + List beds, + List petBeds, + List anchors, + List doors, + List linkedRedstone + ) { + // Scan interior for beds and pet beds + for (BlockPos pos : interior) { + BlockState state = level.getBlockState(pos); + Block block = state.getBlock(); + + if (block instanceof BedBlock) { + // Only count the HEAD part to avoid double-counting (beds are 2 blocks) + if ( + state.getValue(BedBlock.PART) == + net.minecraft.world.level.block.state.properties.BedPart.HEAD + ) { + beds.add(pos.immutable()); + } + } + + // Check for mod's pet bed block + if (block instanceof com.tiedup.remake.v2.blocks.PetBedBlock) { + petBeds.add(pos.immutable()); + } + } + + // Scan walls for doors, redstone components, and anchors + for (BlockPos pos : walls) { + BlockState state = level.getBlockState(pos); + Block block = state.getBlock(); + + // Doors, trapdoors, fence gates + if (block instanceof DoorBlock) { + // Only count the lower half to avoid double-counting + if ( + state.getValue(DoorBlock.HALF) == + net.minecraft.world.level.block.state.properties.DoubleBlockHalf.LOWER + ) { + doors.add(pos.immutable()); + } + } else if ( + block instanceof TrapDoorBlock || + block instanceof FenceGateBlock + ) { + doors.add(pos.immutable()); + } + + // Chain blocks as anchors + if (block instanceof ChainBlock) { + anchors.add(pos.immutable()); + } + + // Buttons and levers as linked redstone + if (block instanceof ButtonBlock || block instanceof LeverBlock) { + linkedRedstone.add(pos.immutable()); + } + } + + // Also check for buttons/levers on the interior side adjacent to walls + for (BlockPos pos : interior) { + BlockState state = level.getBlockState(pos); + Block block = state.getBlock(); + + if (block instanceof ButtonBlock || block instanceof LeverBlock) { + linkedRedstone.add(pos.immutable()); + } + } + } + + /** + * Determine if a block state is passable for flood-fill purposes. + * + * Air and non-solid blocks (torches, carpets, flowers, signs, etc.) are passable. + * Closed doors block the fill (treated as walls). Open doors let fill through. + * Glass, bars, fences are solid → treated as wall. + */ + private static boolean isPassable(BlockState state) { + if (state.isAir()) { + return true; + } + + Block block = state.getBlock(); + + // Doors are always treated as walls for flood-fill (detected as features separately). + // This prevents the fill from leaking through open doors. + if ( + block instanceof DoorBlock || + block instanceof TrapDoorBlock || + block instanceof FenceGateBlock + ) { + return false; + } + + // Beds are interior furniture, not walls. + // BedBlock.isSolid() returns true in 1.20.1 which would misclassify them as walls, + // preventing detectFeatures() from finding them (it only scans interior for beds). + if (block instanceof BedBlock) { + return true; + } + + // Non-solid decorative blocks are passable + // This covers torches, carpets, flowers, signs, pressure plates, etc. + return !state.isSolid(); + } +} diff --git a/src/main/java/com/tiedup/remake/cells/FloodFillResult.java b/src/main/java/com/tiedup/remake/cells/FloodFillResult.java new file mode 100644 index 0000000..4b2fdef --- /dev/null +++ b/src/main/java/com/tiedup/remake/cells/FloodFillResult.java @@ -0,0 +1,140 @@ +package com.tiedup.remake.cells; + +import java.util.*; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import org.jetbrains.annotations.Nullable; + +/** + * Immutable result container returned by the flood-fill algorithm. + * + * Either a success (with geometry and detected features) or a failure (with an error translation key). + */ +public class FloodFillResult { + + private final boolean success; + + @Nullable + private final String errorKey; + + // Geometry + private final Set interior; + private final Set walls; + + @Nullable + private final Direction interiorFace; + + // Auto-detected features + private final List beds; + private final List petBeds; + private final List anchors; + private final List doors; + private final List linkedRedstone; + + private FloodFillResult( + boolean success, + @Nullable String errorKey, + Set interior, + Set walls, + @Nullable Direction interiorFace, + List beds, + List petBeds, + List anchors, + List doors, + List linkedRedstone + ) { + this.success = success; + this.errorKey = errorKey; + this.interior = Collections.unmodifiableSet(interior); + this.walls = Collections.unmodifiableSet(walls); + this.interiorFace = interiorFace; + this.beds = Collections.unmodifiableList(beds); + this.petBeds = Collections.unmodifiableList(petBeds); + this.anchors = Collections.unmodifiableList(anchors); + this.doors = Collections.unmodifiableList(doors); + this.linkedRedstone = Collections.unmodifiableList(linkedRedstone); + } + + public static FloodFillResult success( + Set interior, + Set walls, + Direction interiorFace, + List beds, + List petBeds, + List anchors, + List doors, + List linkedRedstone + ) { + return new FloodFillResult( + true, + null, + interior, + walls, + interiorFace, + beds, + petBeds, + anchors, + doors, + linkedRedstone + ); + } + + public static FloodFillResult failure(String errorKey) { + return new FloodFillResult( + false, + errorKey, + Collections.emptySet(), + Collections.emptySet(), + null, + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList() + ); + } + + // --- Getters --- + + public boolean isSuccess() { + return success; + } + + @Nullable + public String getErrorKey() { + return errorKey; + } + + public Set getInterior() { + return interior; + } + + public Set getWalls() { + return walls; + } + + @Nullable + public Direction getInteriorFace() { + return interiorFace; + } + + public List getBeds() { + return beds; + } + + public List getPetBeds() { + return petBeds; + } + + public List getAnchors() { + return anchors; + } + + public List getDoors() { + return doors; + } + + public List getLinkedRedstone() { + return linkedRedstone; + } +} diff --git a/src/main/java/com/tiedup/remake/cells/MarkerType.java b/src/main/java/com/tiedup/remake/cells/MarkerType.java new file mode 100644 index 0000000..af786a2 --- /dev/null +++ b/src/main/java/com/tiedup/remake/cells/MarkerType.java @@ -0,0 +1,161 @@ +package com.tiedup.remake.cells; + +/** + * Enum defining the types of markers used in the cell system. + * + * Phase: Kidnapper Revamp - Cell System + * + * Markers are invisible points placed by structure builders to define + * functional areas within kidnapper hideouts. + */ +public enum MarkerType { + // ==================== CELL MARKERS (V1 legacy — kept for retrocompat) ==================== + + /** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */ + @Deprecated + WALL("wall", true), + + /** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */ + @Deprecated + ANCHOR("anchor", true), + + /** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */ + @Deprecated + BED("bed", true), + + /** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */ + @Deprecated + DOOR("door", true), + + /** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */ + @Deprecated + DELIVERY("delivery", true), + + // ==================== STRUCTURE MARKERS (Admin Wand) ==================== + + /** + * Entrance marker - Main entry point to the structure. + * Used for AI pathfinding and player release point. + */ + ENTRANCE("entrance", false), + + /** + * Patrol marker - Waypoint for kidnapper AI patrol routes. + * Guards will walk between these points. + */ + PATROL("patrol", false), + + /** + * Loot marker - Position for loot chests in structures. + * Used for confiscated inventory storage. + */ + LOOT("loot", false), + + /** + * Spawner marker - Position for kidnapper spawns. + * Kidnappers respawn at these points. + */ + SPAWNER("spawner", false), + + /** + * Trader spawn marker - Position for SlaveTrader spawn. + * Only spawns once when structure generates. + */ + TRADER_SPAWN("trader_spawn", false), + + /** + * Maid spawn marker - Position for Maid spawn. + * Spawns linked to the nearest trader. + */ + MAID_SPAWN("maid_spawn", false), + + /** + * Merchant spawn marker - Position for Merchant spawn. + * Spawns a merchant NPC that can trade/buy items. + */ + MERCHANT_SPAWN("merchant_spawn", false); + + private final String serializedName; + private final boolean cellMarker; + + MarkerType(String serializedName, boolean cellMarker) { + this.serializedName = serializedName; + this.cellMarker = cellMarker; + } + + /** + * Get the serialized name for NBT/network storage. + */ + public String getSerializedName() { + return serializedName; + } + + /** + * Check if this is a cell-level marker (vs structure-level). + */ + public boolean isCellMarker() { + return cellMarker; + } + + /** + * Check if this is a structure-level marker. + */ + public boolean isStructureMarker() { + return !cellMarker; + } + + /** + * Check if this marker type should be linked to a cell's positions. + * This includes WALL, ANCHOR, BED, DOOR - positions that define cell structure. + */ + public boolean isLinkedPosition() { + return cellMarker; + } + + /** + * Parse a MarkerType from its serialized name. + * + * @param name The serialized name + * @return The MarkerType, or WALL as default + */ + public static MarkerType fromString(String name) { + for (MarkerType type : values()) { + if (type.serializedName.equalsIgnoreCase(name)) { + return type; + } + } + return ENTRANCE; + } + + /** + * Get the next STRUCTURE marker type (for Admin Wand). + * Cycles: ENTRANCE -> PATROL -> LOOT -> SPAWNER -> TRADER_SPAWN -> MAID_SPAWN -> MERCHANT_SPAWN -> ENTRANCE + */ + public MarkerType nextStructureType() { + return switch (this) { + case ENTRANCE -> PATROL; + case PATROL -> LOOT; + case LOOT -> SPAWNER; + case SPAWNER -> TRADER_SPAWN; + case TRADER_SPAWN -> MAID_SPAWN; + case MAID_SPAWN -> MERCHANT_SPAWN; + case MERCHANT_SPAWN -> ENTRANCE; + default -> ENTRANCE; + }; + } + + /** + * Get all structure marker types. + */ + public static MarkerType[] structureTypes() { + return new MarkerType[] { + ENTRANCE, + PATROL, + LOOT, + SPAWNER, + TRADER_SPAWN, + MAID_SPAWN, + MERCHANT_SPAWN, + }; + } +} diff --git a/src/main/java/com/tiedup/remake/cells/SelectionMode.java b/src/main/java/com/tiedup/remake/cells/SelectionMode.java new file mode 100644 index 0000000..6d5acaf --- /dev/null +++ b/src/main/java/com/tiedup/remake/cells/SelectionMode.java @@ -0,0 +1,12 @@ +package com.tiedup.remake.cells; + +/** + * Selection modes for the Cell Core right-click menu. + * When a player clicks "Set Spawn", "Set Delivery", or "Set Disguise", + * they enter a selection mode where their next block click is captured. + */ +public enum SelectionMode { + SET_SPAWN, + SET_DELIVERY, + SET_DISGUISE, +} diff --git a/src/main/java/com/tiedup/remake/client/FirstPersonMittensRenderer.java b/src/main/java/com/tiedup/remake/client/FirstPersonMittensRenderer.java new file mode 100644 index 0000000..75aa239 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/FirstPersonMittensRenderer.java @@ -0,0 +1,155 @@ +package com.tiedup.remake.client; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.GenericBind; +import com.tiedup.remake.items.base.BindVariant; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import net.minecraft.client.Minecraft; +import net.minecraft.client.model.PlayerModel; +import net.minecraft.client.model.geom.ModelPart; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.entity.player.PlayerRenderer; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.HumanoidArm; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.RenderArmEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Renders mittens on the player's arms in first-person view. + * + * Uses RenderArmEvent which fires specifically when a player's arm + * is being rendered in first person. This is more targeted than RenderHandEvent. + * + * @see RenderArmEvent Documentation + */ +@OnlyIn(Dist.CLIENT) +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE, + value = Dist.CLIENT +) +public class FirstPersonMittensRenderer { + + private static final ResourceLocation MITTENS_TEXTURE = + ResourceLocation.fromNamespaceAndPath( + TiedUpMod.MOD_ID, + "textures/models/bondage/mittens/mittens.png" + ); + + /** + * Render mittens overlay on the player's arm in first-person view. + * + * This event fires after the arm is set up for rendering but we can add + * our own rendering on top of it. + */ + @SubscribeEvent + public static void onRenderArm(RenderArmEvent event) { + AbstractClientPlayer player = event.getPlayer(); + + // Get player's bind state + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) return; + + // If tied up, arms are hidden by FirstPersonHandHideHandler - don't render mittens + if (state.isTiedUp()) return; + + // Check if player has mittens + if (!state.hasMittens()) return; + + // Hide mittens when player is in a wrap or latex sack (hands are covered) + if (isBindHidingMittens(player)) return; + + // Render mittens on this arm + renderMittensOnArm(event); + } + + /** + * Render the mittens overlay on the arm. + */ + private static void renderMittensOnArm(RenderArmEvent event) { + PoseStack poseStack = event.getPoseStack(); + MultiBufferSource buffer = event.getMultiBufferSource(); + int packedLight = event.getPackedLight(); + AbstractClientPlayer player = event.getPlayer(); + HumanoidArm arm = event.getArm(); + + // Get the player's model to access the arm ModelPart + Minecraft mc = Minecraft.getInstance(); + var renderer = mc.getEntityRenderDispatcher().getRenderer(player); + if (!(renderer instanceof PlayerRenderer playerRenderer)) return; + + PlayerModel playerModel = + playerRenderer.getModel(); + + poseStack.pushPose(); + + // Get the appropriate arm from the player model + ModelPart armPart = (arm == HumanoidArm.RIGHT) + ? playerModel.rightArm + : playerModel.leftArm; + ModelPart sleevePart = (arm == HumanoidArm.RIGHT) + ? playerModel.rightSleeve + : playerModel.leftSleeve; + + // The arm is already positioned by the game's first-person renderer + // We just need to render our mittens texture on top + + // Use a slightly scaled version to appear on top (avoid z-fighting) + poseStack.scale(1.001F, 1.001F, 1.001F); + + // Render the arm with mittens texture + VertexConsumer vertexConsumer = buffer.getBuffer( + RenderType.entitySolid(MITTENS_TEXTURE) + ); + + // Render the arm part with mittens texture + armPart.render( + poseStack, + vertexConsumer, + packedLight, + OverlayTexture.NO_OVERLAY + ); + + // Also render the sleeve part if visible + if (sleevePart.visible) { + sleevePart.render( + poseStack, + vertexConsumer, + packedLight, + OverlayTexture.NO_OVERLAY + ); + } + + poseStack.popPose(); + } + + /** + * Check if the player's current bind variant hides mittens. + * WRAP and LATEX_SACK cover the entire body including hands. + */ + private static boolean isBindHidingMittens(AbstractClientPlayer player) { + net.minecraft.world.item.ItemStack bindStack = + V2EquipmentHelper.getInRegion( + player, + BodyRegionV2.ARMS + ); + if (bindStack.isEmpty()) return false; + if (bindStack.getItem() instanceof GenericBind bind) { + BindVariant variant = bind.getVariant(); + return ( + variant == BindVariant.WRAP || variant == BindVariant.LATEX_SACK + ); + } + return false; + } +} diff --git a/src/main/java/com/tiedup/remake/client/ModKeybindings.java b/src/main/java/com/tiedup/remake/client/ModKeybindings.java new file mode 100644 index 0000000..7c23660 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/ModKeybindings.java @@ -0,0 +1,446 @@ +package com.tiedup.remake.client; + +import com.mojang.blaze3d.platform.InputConstants; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import com.tiedup.remake.client.gui.screens.AdjustmentScreen; +import com.tiedup.remake.client.gui.screens.UnifiedBondageScreen; +import com.tiedup.remake.items.base.ItemCollar; +import org.jetbrains.annotations.Nullable; +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.action.PacketForceSeatModifier; +import com.tiedup.remake.network.action.PacketStruggle; +import com.tiedup.remake.network.action.PacketTighten; +import com.tiedup.remake.network.bounty.PacketRequestBounties; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.network.PacketV2StruggleStart; +import com.tiedup.remake.state.PlayerBindState; +import net.minecraft.ChatFormatting; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.client.event.RegisterKeyMappingsEvent; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Phase 7: Client-side keybindings for TiedUp mod. + * + * Manages key mappings and sends packets to server when keys are pressed. + * + * Based on original KeyBindings from 1.12.2 + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE, + value = Dist.CLIENT +) +public class ModKeybindings { + + /** + * Key category for TiedUp keybindings + */ + private static final String CATEGORY = "key.categories.tiedup"; + + /** + * Struggle keybinding - Press to struggle against binds + * Default: R key + */ + public static final KeyMapping STRUGGLE_KEY = new KeyMapping( + "key.tiedup.struggle", // Translation key + InputConstants.Type.KEYSYM, + InputConstants.KEY_R, // Default key: R + CATEGORY + ); + + /** + * Adjustment screen keybinding - Open item adjustment screen + * Default: K key + */ + public static final KeyMapping ADJUSTMENT_KEY = new KeyMapping( + "key.tiedup.adjustment_screen", + InputConstants.Type.KEYSYM, + InputConstants.KEY_K, // Default key: K + CATEGORY + ); + + /** + * Bondage inventory keybinding - Open bondage inventory screen + * Default: J key + */ + public static final KeyMapping INVENTORY_KEY = new KeyMapping( + "key.tiedup.bondage_inventory", + InputConstants.Type.KEYSYM, + InputConstants.KEY_J, // Default key: J + CATEGORY + ); + + /** + * Slave management keybinding - Open slave management dashboard + * Default: L key + */ + public static final KeyMapping SLAVE_MANAGEMENT_KEY = new KeyMapping( + "key.tiedup.slave_management", + InputConstants.Type.KEYSYM, + InputConstants.KEY_L, // Default key: L + CATEGORY + ); + + /** + * Bounty list keybinding - Open bounty list screen + * Default: B key + */ + public static final KeyMapping BOUNTY_KEY = new KeyMapping( + "key.tiedup.bounties", + InputConstants.Type.KEYSYM, + InputConstants.KEY_B, // Default key: B + CATEGORY + ); + + /** + * Force seat keybinding - Hold to force captive on/off vehicles + * Default: Left ALT key + */ + public static final KeyMapping FORCE_SEAT_KEY = new KeyMapping( + "key.tiedup.force_seat", + InputConstants.Type.KEYSYM, + InputConstants.KEY_LALT, // Default key: Left ALT + CATEGORY + ); + + /** + * Tighten bind keybinding - Tighten binds on looked-at target + * Default: T key + */ + public static final KeyMapping TIGHTEN_KEY = new KeyMapping( + "key.tiedup.tighten", + InputConstants.Type.KEYSYM, + InputConstants.KEY_T, // Default key: T + CATEGORY + ); + + /** Track last sent state to avoid spamming packets */ + private static boolean lastForceSeatState = false; + + /** + * Check if Force Seat key is currently pressed. + */ + public static boolean isForceSeatPressed() { + return FORCE_SEAT_KEY.isDown(); + } + + /** + * Register keybindings. + * Called during mod initialization (MOD bus). + * + * @param event The registration event + */ + public static void register(RegisterKeyMappingsEvent event) { + event.register(STRUGGLE_KEY); + event.register(ADJUSTMENT_KEY); + event.register(INVENTORY_KEY); + event.register(SLAVE_MANAGEMENT_KEY); + event.register(BOUNTY_KEY); + event.register(FORCE_SEAT_KEY); + event.register(TIGHTEN_KEY); + TiedUpMod.LOGGER.info("Registered {} keybindings", 7); + } + + // ==================== STRUGGLE MINI-GAME (uses vanilla movement keys) ==================== + + /** + * Get the vanilla movement keybind for a given direction index. + * Uses Minecraft's movement keys so AZERTY/QWERTY is already configured. + * @param index 0=FORWARD, 1=LEFT, 2=BACK, 3=RIGHT + * @return The keybind or null if invalid index + */ + public static KeyMapping getStruggleDirectionKey(int index) { + Minecraft mc = Minecraft.getInstance(); + if (mc.options == null) return null; + return switch (index) { + case 0 -> mc.options.keyUp; // Forward (W/Z) + case 1 -> mc.options.keyLeft; // Strafe Left (A/Q) + case 2 -> mc.options.keyDown; // Back (S) + case 3 -> mc.options.keyRight; // Strafe Right (D) + default -> null; + }; + } + + /** + * Check if a keycode matches any vanilla movement keybind. + * @param keyCode The GLFW key code + * @return The direction index (0-3) or -1 if not a movement key + */ + public static int getStruggleDirectionFromKeyCode(int keyCode) { + Minecraft mc = Minecraft.getInstance(); + if (mc.options == null) return -1; + if (mc.options.keyUp.matches(keyCode, 0)) return 0; + if (mc.options.keyLeft.matches(keyCode, 0)) return 1; + if (mc.options.keyDown.matches(keyCode, 0)) return 2; + if (mc.options.keyRight.matches(keyCode, 0)) return 3; + return -1; + } + + /** + * Get the display name of a vanilla movement key. + * Shows the actual bound key (W for QWERTY, Z for AZERTY, etc.) + * @param index 0=FORWARD, 1=LEFT, 2=BACK, 3=RIGHT + * @return The key's display name + */ + public static String getStruggleDirectionKeyName(int index) { + KeyMapping key = getStruggleDirectionKey(index); + if (key == null) return "?"; + return key.getTranslatedKeyMessage().getString().toUpperCase(); + } + + /** + * Handle key presses on client tick. + * Called every client tick (FORGE bus). + * + * @param event The tick event + */ + @SubscribeEvent + public static void onClientTick(TickEvent.ClientTickEvent event) { + // Only run at end of tick + if (event.phase != TickEvent.Phase.END) { + return; + } + + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null || mc.level == null) { + return; + } + + // Sync Force Seat keybind state to server (only send on change) + boolean currentForceSeatState = isForceSeatPressed(); + if (currentForceSeatState != lastForceSeatState) { + lastForceSeatState = currentForceSeatState; + ModNetwork.sendToServer( + new PacketForceSeatModifier(currentForceSeatState) + ); + } + + // Check struggle key - Phase 21: Flow based on bind/accessories + while (STRUGGLE_KEY.consumeClick()) { + handleStruggleKey(); + } + + // Check adjustment screen key + while (ADJUSTMENT_KEY.consumeClick()) { + // Only open if not already in a screen and player has adjustable items + if (mc.screen == null && AdjustmentScreen.canOpen()) { + mc.setScreen(new AdjustmentScreen()); + TiedUpMod.LOGGER.debug( + "[CLIENT] Adjustment key pressed - opening screen" + ); + } + } + + // Check bondage inventory key - opens UnifiedBondageScreen in SELF or MASTER mode + while (INVENTORY_KEY.consumeClick()) { + if (mc.screen == null) { + LivingEntity masterTarget = findOwnedCollarTarget(mc.player); + if (masterTarget != null) { + mc.setScreen(new UnifiedBondageScreen(masterTarget)); + } else { + mc.setScreen(new UnifiedBondageScreen()); + } + } + } + + // SLAVE_MANAGEMENT_KEY: now handled by [J] with master mode detection (see above) + while (SLAVE_MANAGEMENT_KEY.consumeClick()) { + // consumed but no-op — kept registered to avoid key conflict during transition + } + + // Check bounty list key + while (BOUNTY_KEY.consumeClick()) { + // Request bounty list from server (server will open the screen) + if (mc.screen == null) { + ModNetwork.sendToServer(new PacketRequestBounties()); + TiedUpMod.LOGGER.debug( + "[CLIENT] Bounty key pressed - requesting bounty list" + ); + } + } + + // Check tighten key + while (TIGHTEN_KEY.consumeClick()) { + // Send tighten packet to server (server finds target) + if (mc.screen == null) { + ModNetwork.sendToServer(new PacketTighten()); + TiedUpMod.LOGGER.debug( + "[CLIENT] Tighten key pressed - sending tighten request" + ); + } + } + } + + /** + * Phase 21: Handle struggle key press with new flow. + * + * Flow: + * 1. If bind equipped: Send PacketStruggle to server (struggle against bind) + * 2. If no bind: Check for locked accessories + * - If locked accessories exist: Open StruggleChoiceScreen + * - If no locked accessories: Show "Nothing to struggle" message + */ + private static void handleStruggleKey() { + Minecraft mc = Minecraft.getInstance(); + Player player = mc.player; + if (player == null || mc.screen != null) { + return; + } + + // V2 path: check if player has V2 equipment to struggle against + if (com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.hasAnyEquipment(player)) { + handleV2Struggle(player); + return; + } + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + return; + } + + // Check if player has bind equipped + if (state.isTiedUp()) { + // Has bind - struggle against it + // Phase 2.5: Check if mini-game is enabled + if (ModConfig.SERVER.struggleMiniGameEnabled.get()) { + // New: Start struggle mini-game + ModNetwork.sendToServer(new PacketV2StruggleStart(BodyRegionV2.ARMS)); + TiedUpMod.LOGGER.debug( + "[CLIENT] Struggle key pressed - starting V2 struggle mini-game" + ); + } else { + // Legacy: Probability-based struggle + ModNetwork.sendToServer(new PacketStruggle()); + TiedUpMod.LOGGER.debug( + "[CLIENT] Struggle key pressed - legacy struggle against bind" + ); + } + return; + } + + // No bind - check for locked accessories + boolean hasLockedAccessories = hasAnyLockedAccessory(player); + + if (hasLockedAccessories) { + // Open UnifiedBondageScreen in self mode + mc.setScreen(new UnifiedBondageScreen()); + TiedUpMod.LOGGER.debug( + "[CLIENT] Struggle key pressed - opening unified bondage screen" + ); + } else { + // No locked accessories - show message + player.displayClientMessage( + Component.translatable("tiedup.struggle.nothing").withStyle( + ChatFormatting.GRAY + ), + true + ); + TiedUpMod.LOGGER.debug( + "[CLIENT] Struggle key pressed - nothing to struggle" + ); + } + } + + /** + * Handle struggle key for V2 equipment. + * Auto-targets the highest posePriority item. + */ + private static void handleV2Struggle(Player player) { + java.util.Map equipped = + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getAllEquipped(player); + + if (equipped.isEmpty()) return; + + // Auto-target: find highest posePriority item + com.tiedup.remake.v2.BodyRegionV2 bestRegion = null; + int bestPriority = Integer.MIN_VALUE; + + for (java.util.Map.Entry entry : equipped.entrySet()) { + ItemStack stack = entry.getValue(); + if (stack.getItem() instanceof com.tiedup.remake.v2.bondage.IV2BondageItem item) { + if (item.getPosePriority(stack) > bestPriority) { + bestPriority = item.getPosePriority(stack); + bestRegion = entry.getKey(); + } + } + } + + if (bestRegion != null) { + ModNetwork.sendToServer( + new com.tiedup.remake.v2.bondage.network.PacketV2StruggleStart(bestRegion) + ); + TiedUpMod.LOGGER.debug( + "[CLIENT] V2 Struggle key pressed - targeting region {}", + bestRegion.name() + ); + } + } + + /** + * Check the crosshair entity: if it is a LivingEntity wearing a collar owned by the player, + * return it as the MASTER mode target. Returns null if no valid target. + */ + @Nullable + private static LivingEntity findOwnedCollarTarget(Player player) { + if (player == null) return null; + Minecraft mc = Minecraft.getInstance(); + net.minecraft.world.entity.Entity crosshair = mc.crosshairPickEntity; + if (crosshair instanceof LivingEntity living) { + return checkCollarOwnership(living, player) ? living : null; + } + return null; + } + + /** + * Returns true if the given entity has a collar in the NECK region that lists the player as an owner. + */ + private static boolean checkCollarOwnership(LivingEntity target, Player player) { + ItemStack collarStack = com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getInRegion( + target, BodyRegionV2.NECK + ); + if (!collarStack.isEmpty() && collarStack.getItem() instanceof ItemCollar collar) { + return collar.isOwner(collarStack, player); + } + return false; + } + + /** + * Check if player has any locked accessories. + */ + private static boolean hasAnyLockedAccessory(Player player) { + BodyRegionV2[] accessoryRegions = { + BodyRegionV2.MOUTH, + BodyRegionV2.EYES, + BodyRegionV2.EARS, + BodyRegionV2.NECK, + BodyRegionV2.TORSO, + BodyRegionV2.HANDS, + }; + + for (BodyRegionV2 region : accessoryRegions) { + ItemStack stack = V2EquipmentHelper.getInRegion(player, region); + if ( + !stack.isEmpty() && + stack.getItem() instanceof ILockable lockable + ) { + if (lockable.isLocked(stack)) { + return true; + } + } + } + + return false; + } +} diff --git a/src/main/java/com/tiedup/remake/client/MuffledSoundInstance.java b/src/main/java/com/tiedup/remake/client/MuffledSoundInstance.java new file mode 100644 index 0000000..a664171 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/MuffledSoundInstance.java @@ -0,0 +1,118 @@ +package com.tiedup.remake.client; + +import net.minecraft.client.resources.sounds.Sound; +import net.minecraft.client.resources.sounds.SoundInstance; +import net.minecraft.client.resources.sounds.TickableSoundInstance; +import net.minecraft.client.sounds.SoundManager; +import net.minecraft.client.sounds.WeighedSoundEvents; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundSource; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Wrapper around a SoundInstance that applies volume and pitch modifiers. + * Used for the earplugs muffling effect. + * + * This delegates all methods to the wrapped sound, but overrides + * getVolume() and getPitch() to apply modifiers. + */ +@OnlyIn(Dist.CLIENT) +public class MuffledSoundInstance implements SoundInstance { + + private final SoundInstance wrapped; + private final float volumeMultiplier; + private final float pitchMultiplier; + + public MuffledSoundInstance( + SoundInstance wrapped, + float volumeMultiplier, + float pitchMultiplier + ) { + this.wrapped = wrapped; + this.volumeMultiplier = volumeMultiplier; + this.pitchMultiplier = pitchMultiplier; + } + + @Override + public ResourceLocation getLocation() { + return wrapped.getLocation(); + } + + @Override + public WeighedSoundEvents resolve(SoundManager soundManager) { + return wrapped.resolve(soundManager); + } + + @Override + public Sound getSound() { + return wrapped.getSound(); + } + + @Override + public SoundSource getSource() { + return wrapped.getSource(); + } + + @Override + public boolean isLooping() { + return wrapped.isLooping(); + } + + @Override + public boolean isRelative() { + return wrapped.isRelative(); + } + + @Override + public int getDelay() { + return wrapped.getDelay(); + } + + @Override + public float getVolume() { + // Apply muffling to volume + return wrapped.getVolume() * volumeMultiplier; + } + + @Override + public float getPitch() { + // Apply muffling to pitch + return wrapped.getPitch() * pitchMultiplier; + } + + @Override + public double getX() { + return wrapped.getX(); + } + + @Override + public double getY() { + return wrapped.getY(); + } + + @Override + public double getZ() { + return wrapped.getZ(); + } + + @Override + public Attenuation getAttenuation() { + return wrapped.getAttenuation(); + } + + /** + * Check if this is wrapping a tickable sound. + * Used to handle special cases. + */ + public boolean isTickable() { + return wrapped instanceof TickableSoundInstance; + } + + /** + * Get the wrapped sound instance. + */ + public SoundInstance getWrapped() { + return wrapped; + } +} diff --git a/src/main/java/com/tiedup/remake/client/animation/AnimationStateRegistry.java b/src/main/java/com/tiedup/remake/client/animation/AnimationStateRegistry.java new file mode 100644 index 0000000..bdfb1cd --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/AnimationStateRegistry.java @@ -0,0 +1,60 @@ +package com.tiedup.remake.client.animation; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Central registry for player animation state tracking. + * + *

Holds per-player state maps that were previously scattered across + * AnimationTickHandler. Provides a single clearAll() entry point for + * world unload cleanup. + */ +@OnlyIn(Dist.CLIENT) +public final class AnimationStateRegistry { + + /** Track last tied state per player */ + static final Map lastTiedState = new ConcurrentHashMap<>(); + + /** Track last animation ID per player to avoid redundant updates */ + static final Map lastAnimId = new ConcurrentHashMap<>(); + + private AnimationStateRegistry() {} + + public static Map getLastTiedState() { + return lastTiedState; + } + + public static Map getLastAnimId() { + return lastAnimId; + } + + /** + * Clear all animation-related state in one call. + * Called on world unload to prevent memory leaks and stale data. + */ + public static void clearAll() { + // Animation state tracking + lastTiedState.clear(); + lastAnimId.clear(); + + // Animation managers + BondageAnimationManager.clearAll(); + PendingAnimationManager.clearAll(); + + // V2 animation context system (clearAll chains to ContextAnimationFactory.clearCache) + com.tiedup.remake.client.gltf.GltfAnimationApplier.clearAll(); + + // Render state + com.tiedup.remake.client.animation.render.DogPoseRenderHandler.clearState(); + + // NPC animation state + com.tiedup.remake.client.animation.tick.NpcAnimationTickHandler.clearAll(); + + // MCA animation cache + com.tiedup.remake.client.animation.tick.MCAAnimationTickCache.clear(); + } +} diff --git a/src/main/java/com/tiedup/remake/client/animation/BondageAnimationManager.java b/src/main/java/com/tiedup/remake/client/animation/BondageAnimationManager.java new file mode 100644 index 0000000..8fe0e1b --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/BondageAnimationManager.java @@ -0,0 +1,737 @@ +package com.tiedup.remake.client.animation; + +import com.mojang.logging.LogUtils; +import com.tiedup.remake.v2.furniture.ISeatProvider; +import dev.kosmx.playerAnim.api.layered.IAnimation; +import dev.kosmx.playerAnim.api.layered.KeyframeAnimationPlayer; +import dev.kosmx.playerAnim.api.layered.ModifierLayer; +import dev.kosmx.playerAnim.core.data.KeyframeAnimation; +import dev.kosmx.playerAnim.impl.IAnimatedPlayer; +import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationAccess; +import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationFactory; +import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.slf4j.Logger; + +/** + * Unified animation manager for bondage animations. + * + *

Handles both players and NPCs (any entity implementing IAnimatedPlayer). + * Uses PlayerAnimator library for smooth keyframe animations with bendy-lib support. + * + *

This replaces the previous split system: + *

    + *
  • PlayerAnimatorBridge (for players)
  • + *
  • DamselAnimationManager (for NPCs)
  • + *
+ */ +@OnlyIn(Dist.CLIENT) +public class BondageAnimationManager { + + private static final Logger LOGGER = LogUtils.getLogger(); + + /** Cache of ModifierLayers for NPC entities (players use PlayerAnimationAccess) */ + private static final Map> npcLayers = + new ConcurrentHashMap<>(); + + /** Cache of context ModifierLayers for NPC entities */ + private static final Map> npcContextLayers = + new ConcurrentHashMap<>(); + + /** Cache of furniture ModifierLayers for NPC entities */ + private static final Map> npcFurnitureLayers = + new ConcurrentHashMap<>(); + + /** Factory ID for PlayerAnimator item layer (players only) */ + private static final ResourceLocation FACTORY_ID = + ResourceLocation.fromNamespaceAndPath("tiedup", "bondage"); + + /** Factory ID for PlayerAnimator context layer (players only) */ + private static final ResourceLocation CONTEXT_FACTORY_ID = + ResourceLocation.fromNamespaceAndPath("tiedup", "bondage_context"); + + /** Factory ID for PlayerAnimator furniture layer (players only) */ + private static final ResourceLocation FURNITURE_FACTORY_ID = + ResourceLocation.fromNamespaceAndPath("tiedup", "bondage_furniture"); + + /** Priority for context animation layer (lower = overridable by item layer) */ + private static final int CONTEXT_LAYER_PRIORITY = 40; + /** Priority for item animation layer (higher = overrides context layer) */ + private static final int ITEM_LAYER_PRIORITY = 42; + /** + * Priority for furniture animation layer (highest = overrides item layer on blocked bones). + * Non-blocked bones are disabled so items can still animate them via the item layer. + */ + private static final int FURNITURE_LAYER_PRIORITY = 43; + + /** Number of ticks to wait before removing a stale furniture animation. */ + private static final int FURNITURE_GRACE_TICKS = 3; + + /** + * Tracks ticks since a player with an active furniture animation stopped riding + * an ISeatProvider. After {@link #FURNITURE_GRACE_TICKS}, the animation is removed + * to prevent stuck poses from entity death or network issues. + * + *

Uses ConcurrentHashMap for safe access from both client tick and render thread.

+ */ + private static final Map furnitureGraceTicks = new ConcurrentHashMap<>(); + + /** + * Initialize the animation system. + * Must be called during client setup to register the player animation factory. + */ + public static void init() { + LOGGER.info("BondageAnimationManager initializing..."); + + // Context layer: lower priority = evaluated first, overridable by item layer. + // In AnimationStack, layers are sorted ascending by priority and evaluated in order. + // Higher priority layers override lower ones. + PlayerAnimationFactory.ANIMATION_DATA_FACTORY.registerFactory( + CONTEXT_FACTORY_ID, + CONTEXT_LAYER_PRIORITY, + player -> new ModifierLayer<>() + ); + + // Item layer: higher priority = evaluated last, overrides context layer + PlayerAnimationFactory.ANIMATION_DATA_FACTORY.registerFactory( + FACTORY_ID, + ITEM_LAYER_PRIORITY, + player -> new ModifierLayer<>() + ); + + // Furniture layer: highest priority = overrides item layer on blocked bones. + // Non-blocked bones are disabled via FurnitureAnimationContext so items + // can still animate free regions (gag, blindfold, etc.). + PlayerAnimationFactory.ANIMATION_DATA_FACTORY.registerFactory( + FURNITURE_FACTORY_ID, + FURNITURE_LAYER_PRIORITY, + player -> new ModifierLayer<>() + ); + + LOGGER.info( + "BondageAnimationManager: Factories registered — context (pri {}), item (pri {}), furniture (pri {})", + CONTEXT_LAYER_PRIORITY, ITEM_LAYER_PRIORITY, FURNITURE_LAYER_PRIORITY + ); + } + + // ======================================== + // PLAY ANIMATION + // ======================================== + + /** + * Play an animation on any entity (player or NPC). + * + * @param entity The entity to animate + * @param animId Animation ID string (will be prefixed with "tiedup:" namespace) + * @return true if animation started successfully, false if layer not available + */ + public static boolean playAnimation(LivingEntity entity, String animId) { + ResourceLocation location = ResourceLocation.fromNamespaceAndPath( + "tiedup", + animId + ); + return playAnimation(entity, location); + } + + /** + * Play an animation on any entity (player or NPC). + * + *

If the animation layer is not available (e.g., remote player not fully + * initialized), the animation will be queued for retry via PendingAnimationManager. + * + * @param entity The entity to animate + * @param animId Full ResourceLocation of the animation + * @return true if animation started successfully, false if layer not available + */ + public static boolean playAnimation( + LivingEntity entity, + ResourceLocation animId + ) { + if (entity == null || !entity.level().isClientSide()) { + return false; + } + + KeyframeAnimation anim = PlayerAnimationRegistry.getAnimation(animId); + if (anim == null) { + // Try fallback: remove _sneak_ suffix if present + ResourceLocation fallbackId = tryFallbackAnimation(animId); + if (fallbackId != null) { + anim = PlayerAnimationRegistry.getAnimation(fallbackId); + if (anim != null) { + LOGGER.debug( + "Using fallback animation '{}' for missing '{}'", + fallbackId, + animId + ); + } + } + if (anim == null) { + LOGGER.warn("Animation not found in registry: {}", animId); + return false; + } + } + + ModifierLayer layer = getOrCreateLayer(entity); + if (layer != null) { + // Check if same animation is already playing + // Use reference comparison (==) instead of equals() because: + // 1. PlayerAnimationRegistry caches animations by ID + // 2. Same ID = same cached object reference + // 3. This avoids issues with KeyframeAnimation.equals() implementation + IAnimation current = layer.getAnimation(); + if (current instanceof KeyframeAnimationPlayer player) { + if (player.getData() == anim) { + // Same animation already playing, don't reset + return true; // Still counts as success + } + } + layer.setAnimation(new KeyframeAnimationPlayer(anim)); + + // Remove from pending queue if it was waiting + PendingAnimationManager.remove(entity.getUUID()); + + LOGGER.debug( + "Playing animation '{}' on entity: {}", + animId, + entity.getUUID() + ); + return true; + } else { + // Layer not available - queue for retry if it's a player + if (entity instanceof AbstractClientPlayer) { + PendingAnimationManager.queueForRetry( + entity.getUUID(), + animId.getPath() + ); + LOGGER.debug( + "Animation layer not ready for {}, queued for retry", + entity.getName().getString() + ); + } else { + LOGGER.warn( + "Animation layer is NULL for NPC: {} (type: {})", + entity.getName().getString(), + entity.getClass().getSimpleName() + ); + } + return false; + } + } + + /** + * Play a pre-converted KeyframeAnimation directly on an entity, bypassing the registry. + * Used by GltfAnimationApplier for GLB-converted poses. + * + * @param entity The entity to animate + * @param anim The KeyframeAnimation to play + * @return true if animation started successfully + */ + public static boolean playDirect(LivingEntity entity, KeyframeAnimation anim) { + if (entity == null || anim == null || !entity.level().isClientSide()) { + return false; + } + + ModifierLayer layer = getOrCreateLayer(entity); + if (layer != null) { + IAnimation current = layer.getAnimation(); + if (current instanceof KeyframeAnimationPlayer player) { + if (player.getData() == anim) { + return true; // Same animation already playing + } + } + layer.setAnimation(new KeyframeAnimationPlayer(anim)); + PendingAnimationManager.remove(entity.getUUID()); + return true; + } + return false; + } + + // ======================================== + // STOP ANIMATION + // ======================================== + + /** + * Stop any currently playing animation on an entity. + * + * @param entity The entity + */ + public static void stopAnimation(LivingEntity entity) { + if (entity == null || !entity.level().isClientSide()) { + return; + } + + ModifierLayer layer = getLayer(entity); + if (layer != null) { + layer.setAnimation(null); + LOGGER.debug("Stopped animation on entity: {}", entity.getUUID()); + } + } + + // ======================================== + // LAYER MANAGEMENT + // ======================================== + + /** + * Get the ModifierLayer for an entity (without creating). + */ + private static ModifierLayer getLayer(LivingEntity entity) { + // Players: try PlayerAnimationAccess first, then cache + if (entity instanceof AbstractClientPlayer player) { + ModifierLayer factoryLayer = getPlayerLayer(player); + if (factoryLayer != null) { + return factoryLayer; + } + // Check cache (for remote players using fallback) + return npcLayers.get(entity.getUUID()); + } + + // NPCs: use cache + return npcLayers.get(entity.getUUID()); + } + + /** + * Get or create the ModifierLayer for an entity. + */ + @SuppressWarnings("unchecked") + private static ModifierLayer getOrCreateLayer( + LivingEntity entity + ) { + UUID uuid = entity.getUUID(); + + // Players: try factory-based access first, fallback to direct stack access + if (entity instanceof AbstractClientPlayer player) { + // Try the registered factory first (works for local player) + ModifierLayer factoryLayer = getPlayerLayer(player); + if (factoryLayer != null) { + return factoryLayer; + } + + // Fallback for remote players: use direct stack access like NPCs + // This handles cases where the factory data isn't available + if (player instanceof IAnimatedPlayer animated) { + return npcLayers.computeIfAbsent(uuid, k -> { + ModifierLayer newLayer = new ModifierLayer<>(); + animated + .getAnimationStack() + .addAnimLayer(ITEM_LAYER_PRIORITY, newLayer); + LOGGER.info( + "Created animation layer for remote player via stack: {}", + player.getName().getString() + ); + return newLayer; + }); + } + } + + // NPCs implementing IAnimatedPlayer: create/cache layer + if (entity instanceof IAnimatedPlayer animated) { + return npcLayers.computeIfAbsent(uuid, k -> { + ModifierLayer newLayer = new ModifierLayer<>(); + animated + .getAnimationStack() + .addAnimLayer(ITEM_LAYER_PRIORITY, newLayer); + LOGGER.debug("Created animation layer for NPC: {}", uuid); + return newLayer; + }); + } + + LOGGER.warn( + "Entity {} does not support animations (not a player or IAnimatedPlayer)", + uuid + ); + return null; + } + + /** + * Get the animation layer for a player from PlayerAnimationAccess. + */ + @SuppressWarnings("unchecked") + private static ModifierLayer getPlayerLayer( + AbstractClientPlayer player + ) { + try { + return (ModifierLayer< + IAnimation + >) PlayerAnimationAccess.getPlayerAssociatedData(player).get( + FACTORY_ID + ); + } catch (Exception e) { + LOGGER.error( + "Failed to get animation layer for player: {}", + player.getName().getString(), + e + ); + return null; + } + } + + /** + * Safely get the animation layer for a player. + * Returns null if the layer is not yet initialized. + * + *

Public method for PendingAnimationManager to access. + * Checks both the factory-based layer and the NPC cache fallback. + * + * @param player The player + * @return The animation layer, or null if not available + */ + @javax.annotation.Nullable + public static ModifierLayer getPlayerLayerSafe( + AbstractClientPlayer player + ) { + // Try factory first + ModifierLayer factoryLayer = getPlayerLayer(player); + if (factoryLayer != null) { + return factoryLayer; + } + + // Check NPC cache (for remote players using fallback path) + return npcLayers.get(player.getUUID()); + } + + // ======================================== + // CONTEXT LAYER (lower priority, for sit/kneel/sneak) + // ======================================== + + /** + * Get the context animation layer for a player from PlayerAnimationAccess. + * Returns null if the layer is not yet initialized. + */ + @SuppressWarnings("unchecked") + @javax.annotation.Nullable + private static ModifierLayer getPlayerContextLayer( + AbstractClientPlayer player + ) { + try { + return (ModifierLayer< + IAnimation + >) PlayerAnimationAccess.getPlayerAssociatedData(player).get( + CONTEXT_FACTORY_ID + ); + } catch (Exception e) { + return null; + } + } + + /** + * Get or create the context animation layer for an NPC entity. + * Uses CONTEXT_LAYER_PRIORITY, below the item layer at ITEM_LAYER_PRIORITY. + */ + @javax.annotation.Nullable + private static ModifierLayer getOrCreateNpcContextLayer( + LivingEntity entity + ) { + if (entity instanceof IAnimatedPlayer animated) { + return npcContextLayers.computeIfAbsent( + entity.getUUID(), + k -> { + ModifierLayer layer = new ModifierLayer<>(); + animated.getAnimationStack().addAnimLayer(CONTEXT_LAYER_PRIORITY, layer); + return layer; + } + ); + } + return null; + } + + /** + * Play a context animation on the context layer (lower priority). + * Context animations (sit, kneel, sneak) can be overridden by item animations + * on the main layer which has higher priority. + * + * @param entity The entity to animate + * @param anim The KeyframeAnimation to play on the context layer + * @return true if animation started successfully + */ + public static boolean playContext( + LivingEntity entity, + KeyframeAnimation anim + ) { + if (entity == null || anim == null || !entity.level().isClientSide()) { + return false; + } + + ModifierLayer layer; + if (entity instanceof AbstractClientPlayer player) { + layer = getPlayerContextLayer(player); + } else { + layer = getOrCreateNpcContextLayer(entity); + } + + if (layer != null) { + layer.setAnimation(new KeyframeAnimationPlayer(anim)); + return true; + } + return false; + } + + /** + * Stop the context layer animation. + * + * @param entity The entity whose context animation should stop + */ + public static void stopContext(LivingEntity entity) { + if (entity == null || !entity.level().isClientSide()) { + return; + } + + ModifierLayer layer; + if (entity instanceof AbstractClientPlayer player) { + layer = getPlayerContextLayer(player); + } else { + layer = npcContextLayers.get(entity.getUUID()); + } + + if (layer != null) { + layer.setAnimation(null); + } + } + + // ======================================== + // FURNITURE LAYER (highest priority, for seat poses) + // ======================================== + + /** + * Play a furniture animation on the furniture layer (highest priority). + * + *

The furniture layer sits above the item layer so it controls blocked-region + * bones. Non-blocked bones should already be disabled in the provided animation + * (via {@link com.tiedup.remake.v2.furniture.client.FurnitureAnimationContext#create}). + * This allows bondage items on free regions to still animate via the item layer.

+ * + * @param player the player to animate + * @param animation the KeyframeAnimation from FurnitureAnimationContext + * @return true if animation started successfully + */ + public static boolean playFurniture(Player player, KeyframeAnimation animation) { + if (player == null || animation == null || !player.level().isClientSide()) { + return false; + } + + ModifierLayer layer = getFurnitureLayer(player); + if (layer != null) { + layer.setAnimation(new KeyframeAnimationPlayer(animation)); + // Reset grace ticks since we just started/refreshed the animation + furnitureGraceTicks.remove(player.getUUID()); + LOGGER.debug("Playing furniture animation on player: {}", player.getName().getString()); + return true; + } + + LOGGER.warn("Furniture layer not available for player: {}", player.getName().getString()); + return false; + } + + /** + * Stop the furniture layer animation for a player. + * + * @param player the player whose furniture animation should stop + */ + public static void stopFurniture(Player player) { + if (player == null || !player.level().isClientSide()) { + return; + } + + ModifierLayer layer = getFurnitureLayer(player); + if (layer != null) { + layer.setAnimation(null); + } + furnitureGraceTicks.remove(player.getUUID()); + LOGGER.debug("Stopped furniture animation on player: {}", player.getName().getString()); + } + + /** + * Check whether a player currently has an active furniture animation. + * + * @param player the player to check + * @return true if the furniture layer has an active animation + */ + public static boolean hasFurnitureAnimation(Player player) { + if (player == null || !player.level().isClientSide()) { + return false; + } + + ModifierLayer layer = getFurnitureLayer(player); + return layer != null && layer.getAnimation() != null; + } + + /** + * Get the furniture ModifierLayer for a player. + * Uses PlayerAnimationAccess for local/factory-registered players, + * falls back to NPC cache for remote players. + */ + @SuppressWarnings("unchecked") + @javax.annotation.Nullable + private static ModifierLayer getFurnitureLayer(Player player) { + if (player instanceof AbstractClientPlayer clientPlayer) { + try { + ModifierLayer layer = (ModifierLayer) + PlayerAnimationAccess.getPlayerAssociatedData(clientPlayer) + .get(FURNITURE_FACTORY_ID); + if (layer != null) { + return layer; + } + } catch (Exception e) { + // Fall through to NPC cache + } + + // Fallback for remote players: check NPC furniture cache + return npcFurnitureLayers.get(player.getUUID()); + } + + // Non-player entities: use NPC cache + return npcFurnitureLayers.get(player.getUUID()); + } + + /** + * Safety tick for furniture animations. Call once per client tick per player. + * + *

If a player has an active furniture animation but is NOT riding an + * {@link ISeatProvider}, increment a grace counter. After + * {@link #FURNITURE_GRACE_TICKS} consecutive ticks without a seat, the + * animation is removed to prevent stuck poses from entity death, network + * desync, or teleportation.

+ * + *

If the player IS riding an ISeatProvider, the counter is reset.

+ * + * @param player the player to check + */ + public static void tickFurnitureSafety(Player player) { + if (player == null || !player.level().isClientSide()) { + return; + } + + if (!hasFurnitureAnimation(player)) { + // No furniture animation active, nothing to guard + furnitureGraceTicks.remove(player.getUUID()); + return; + } + + UUID uuid = player.getUUID(); + + // Check if the player is riding an ISeatProvider + Entity vehicle = player.getVehicle(); + boolean ridingSeat = vehicle instanceof ISeatProvider; + + if (ridingSeat) { + // Player is properly seated, reset grace counter + furnitureGraceTicks.remove(uuid); + } else { + // Player has furniture anim but no seat -- increment grace + int ticks = furnitureGraceTicks.merge(uuid, 1, Integer::sum); + if (ticks >= FURNITURE_GRACE_TICKS) { + LOGGER.info("Removing stale furniture animation for player {} " + + "(not riding ISeatProvider for {} ticks)", + player.getName().getString(), ticks); + stopFurniture(player); + } + } + } + + // ======================================== + // FALLBACK ANIMATION HANDLING + // ======================================== + + /** + * Try to find a fallback animation ID when the requested one doesn't exist. + * + *

Fallback chain: + *

    + *
  1. Remove _sneak_ suffix (sneak variants often missing)
  2. + *
  3. For sit_dog/kneel_dog variants, fall back to basic standing DOG
  4. + *
  5. For _arms_ variants, try FULL variant
  6. + *
+ * + * @param originalId The original animation ID that wasn't found + * @return A fallback ResourceLocation to try, or null if no fallback + */ + @javax.annotation.Nullable + private static ResourceLocation tryFallbackAnimation( + ResourceLocation originalId + ) { + String path = originalId.getPath(); + String namespace = originalId.getNamespace(); + + // 1. Remove _sneak_ suffix + if (path.contains("_sneak_")) { + String fallback = path.replace("_sneak_", "_"); + return ResourceLocation.fromNamespaceAndPath(namespace, fallback); + } + + // 2. sit_dog_* / kneel_dog_* -> tied_up_dog_* + if (path.startsWith("sit_dog_") || path.startsWith("kneel_dog_")) { + String suffix = path.substring(path.lastIndexOf("_")); // _idle or _struggle + return ResourceLocation.fromNamespaceAndPath( + namespace, + "tied_up_dog" + suffix + ); + } + + // 3. _arms_ variants -> try FULL variant (remove _arms) + if (path.contains("_arms_")) { + String fallback = path.replace("_arms_", "_"); + return ResourceLocation.fromNamespaceAndPath(namespace, fallback); + } + + // 4. Struggle variants for free/legs -> idle variant + if ( + (path.startsWith("sit_free_") || + path.startsWith("kneel_free_") || + path.startsWith("sit_legs_") || + path.startsWith("kneel_legs_")) && + path.endsWith("_struggle") + ) { + String fallback = path.replace("_struggle", "_idle"); + return ResourceLocation.fromNamespaceAndPath(namespace, fallback); + } + + return null; + } + + // ======================================== + // CLEANUP + // ======================================== + + /** + * Clean up animation layer for an NPC when it's removed. + * + * @param entityId UUID of the removed entity + */ + /** All NPC layer caches, for bulk cleanup operations. */ + private static final Map>[] ALL_NPC_CACHES = new Map[] { + npcLayers, npcContextLayers, npcFurnitureLayers + }; + + public static void cleanup(UUID entityId) { + for (Map> cache : ALL_NPC_CACHES) { + ModifierLayer layer = cache.remove(entityId); + if (layer != null) { + layer.setAnimation(null); + } + } + furnitureGraceTicks.remove(entityId); + LOGGER.debug("Cleaned up animation layers for entity: {}", entityId); + } + + /** + * Clear all NPC animation layers. + * Should be called on world unload. + */ + public static void clearAll() { + for (Map> cache : ALL_NPC_CACHES) { + cache.values().forEach(layer -> layer.setAnimation(null)); + cache.clear(); + } + furnitureGraceTicks.clear(); + LOGGER.info("Cleared all NPC animation layers"); + } + +} diff --git a/src/main/java/com/tiedup/remake/client/animation/PendingAnimationManager.java b/src/main/java/com/tiedup/remake/client/animation/PendingAnimationManager.java new file mode 100644 index 0000000..58ccff8 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/PendingAnimationManager.java @@ -0,0 +1,156 @@ +package com.tiedup.remake.client.animation; + +import com.mojang.logging.LogUtils; +import dev.kosmx.playerAnim.api.layered.IAnimation; +import dev.kosmx.playerAnim.api.layered.KeyframeAnimationPlayer; +import dev.kosmx.playerAnim.api.layered.ModifierLayer; +import dev.kosmx.playerAnim.core.data.KeyframeAnimation; +import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry; +import java.util.Iterator; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.slf4j.Logger; + +/** + * Manages pending animations for remote players whose animation layers + * may not be immediately available due to timing issues. + * + *

When a player is tied, the sync packet may arrive before the remote player's + * animation layer is initialized by PlayerAnimator. This class queues failed + * animation attempts and retries them each tick until success or timeout. + * + *

This follows the same pattern as SyncManager's pending queue for inventory sync. + */ +@OnlyIn(Dist.CLIENT) +public class PendingAnimationManager { + + private static final Logger LOGGER = LogUtils.getLogger(); + + /** Pending animations waiting for layer initialization */ + private static final Map pending = + new ConcurrentHashMap<>(); + + /** Maximum retry attempts before giving up (~2 seconds at 20 ticks/sec) */ + private static final int MAX_RETRIES = 40; + + /** + * Queue a player's animation for retry. + * Called when playAnimation fails due to null layer. + * + * @param uuid The player's UUID + * @param animId The animation ID (without namespace) + */ + public static void queueForRetry(UUID uuid, String animId) { + pending.compute(uuid, (k, existing) -> { + if (existing == null) { + LOGGER.debug( + "Queued animation '{}' for retry on player {}", + animId, + uuid + ); + return new PendingEntry(animId, 0); + } + // Update animation ID but preserve retry count + return new PendingEntry(animId, existing.retries); + }); + } + + /** + * Remove a player from the pending queue. + * Called when animation succeeds or player disconnects. + * + * @param uuid The player's UUID + */ + public static void remove(UUID uuid) { + pending.remove(uuid); + } + + /** + * Check if a player has a pending animation. + * + * @param uuid The player's UUID + * @return true if pending + */ + public static boolean hasPending(UUID uuid) { + return pending.containsKey(uuid); + } + + /** + * Process pending animations. Called every tick from AnimationTickHandler. + * Attempts to play queued animations and removes successful or expired entries. + * + * @param level The client level + */ + public static void processPending(ClientLevel level) { + if (pending.isEmpty()) return; + + Iterator> it = pending + .entrySet() + .iterator(); + + while (it.hasNext()) { + Map.Entry entry = it.next(); + UUID uuid = entry.getKey(); + PendingEntry pe = entry.getValue(); + + // Check expiration + if (pe.retries >= MAX_RETRIES) { + LOGGER.warn("Animation retry exhausted for player {}", uuid); + it.remove(); + continue; + } + + // Try to find player and play animation + Player player = level.getPlayerByUUID(uuid); + if (player instanceof AbstractClientPlayer clientPlayer) { + ModifierLayer layer = + BondageAnimationManager.getPlayerLayerSafe(clientPlayer); + + if (layer != null) { + ResourceLocation loc = + ResourceLocation.fromNamespaceAndPath( + "tiedup", + pe.animId + ); + KeyframeAnimation anim = + PlayerAnimationRegistry.getAnimation(loc); + + if (anim != null) { + layer.setAnimation(new KeyframeAnimationPlayer(anim)); + LOGGER.info( + "Animation retry succeeded for {} after {} attempts", + clientPlayer.getName().getString(), + pe.retries + ); + it.remove(); + continue; + } + } + } + + // Increment retry count + pending.put(uuid, new PendingEntry(pe.animId, pe.retries + 1)); + } + } + + /** + * Clear all pending animations. + * Called on world unload. + */ + public static void clearAll() { + pending.clear(); + LOGGER.debug("Cleared all pending animations"); + } + + /** + * Record to store pending animation data. + */ + private record PendingEntry(String animId, int retries) {} +} diff --git a/src/main/java/com/tiedup/remake/client/animation/StaticPoseApplier.java b/src/main/java/com/tiedup/remake/client/animation/StaticPoseApplier.java new file mode 100644 index 0000000..4c496b5 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/StaticPoseApplier.java @@ -0,0 +1,137 @@ +package com.tiedup.remake.client.animation; + +import com.tiedup.remake.items.base.PoseType; +import net.minecraft.client.model.HumanoidModel; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Applies static bondage poses directly to HumanoidModel. + * + *

Used for entities that don't support PlayerAnimator (e.g., MCA villagers). + * Directly modifies arm/leg rotations on the model. + * + *

Extracted from BondageAnimationManager to separate concerns: + * BondageAnimationManager handles PlayerAnimator layers, + * StaticPoseApplier handles raw model manipulation. + */ +@OnlyIn(Dist.CLIENT) +public class StaticPoseApplier { + + /** + * Apply a static bondage pose directly to a HumanoidModel. + * + * @param model The humanoid model to modify + * @param poseType The pose type (STANDARD, STRAITJACKET, WRAP, LATEX_SACK) + * @param armsBound whether ARMS region is occupied + * @param legsBound whether LEGS region is occupied + */ + public static void applyStaticPose( + HumanoidModel model, + PoseType poseType, + boolean armsBound, + boolean legsBound + ) { + if (model == null) { + return; + } + + applyBodyPose(model, poseType); + + if (armsBound) { + applyArmPose(model, poseType); + } + + if (legsBound) { + applyLegPose(model, poseType); + } + } + + /** + * Apply arm pose based on pose type. + * Values converted from animation JSON (degrees to radians). + */ + private static void applyArmPose( + HumanoidModel model, + PoseType poseType + ) { + switch (poseType) { + case STANDARD -> { + model.rightArm.xRot = 0.899f; + model.rightArm.yRot = 1.0f; + model.rightArm.zRot = 0f; + model.leftArm.xRot = 0.899f; + model.leftArm.yRot = -1.0f; + model.leftArm.zRot = 0f; + } + case STRAITJACKET -> { + model.rightArm.xRot = 0.764f; + model.rightArm.yRot = -0.84f; + model.rightArm.zRot = 0f; + model.leftArm.xRot = 0.764f; + model.leftArm.yRot = 0.84f; + model.leftArm.zRot = 0f; + } + case WRAP, LATEX_SACK -> { + model.rightArm.xRot = 0f; + model.rightArm.yRot = 0f; + model.rightArm.zRot = -0.087f; + model.leftArm.xRot = 0f; + model.leftArm.yRot = 0f; + model.leftArm.zRot = 0.087f; + } + case DOG -> { + model.rightArm.xRot = -2.094f; + model.rightArm.yRot = 0.175f; + model.rightArm.zRot = 0f; + model.leftArm.xRot = -2.094f; + model.leftArm.yRot = -0.175f; + model.leftArm.zRot = 0f; + } + case HUMAN_CHAIR -> { + model.rightArm.xRot = -2.094f; + model.rightArm.yRot = 0.175f; + model.rightArm.zRot = 0f; + model.leftArm.xRot = -2.094f; + model.leftArm.yRot = -0.175f; + model.leftArm.zRot = 0f; + } + } + } + + /** + * Apply leg pose based on pose type. + */ + private static void applyLegPose( + HumanoidModel model, + PoseType poseType + ) { + if (poseType == PoseType.DOG || poseType == PoseType.HUMAN_CHAIR) { + model.rightLeg.xRot = -1.047f; + model.rightLeg.yRot = 0.349f; + model.rightLeg.zRot = 0f; + model.leftLeg.xRot = -1.047f; + model.leftLeg.yRot = -0.349f; + model.leftLeg.zRot = 0f; + } else { + model.rightLeg.xRot = 0f; + model.rightLeg.yRot = 0f; + model.rightLeg.zRot = -0.1f; + model.leftLeg.xRot = 0f; + model.leftLeg.yRot = 0f; + model.leftLeg.zRot = 0.1f; + } + } + + /** + * Apply body pose for DOG/HUMAN_CHAIR pose. + */ + public static void applyBodyPose( + HumanoidModel model, + PoseType poseType + ) { + if (poseType == PoseType.DOG || poseType == PoseType.HUMAN_CHAIR) { + model.body.xRot = -1.571f; + } + } +} diff --git a/src/main/java/com/tiedup/remake/client/animation/context/AnimationContext.java b/src/main/java/com/tiedup/remake/client/animation/context/AnimationContext.java new file mode 100644 index 0000000..132b381 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/context/AnimationContext.java @@ -0,0 +1,93 @@ +package com.tiedup.remake.client.animation.context; + +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Represents the current player/NPC posture and action state for animation selection. + * Determines which base body posture animation to play. + * + *

Each context maps to a GLB animation name via a prefix + variant scheme: + *

    + *
  • Prefix: "Sit", "Kneel", "Sneak", "Walk", or "" (standing)
  • + *
  • Variant: "Idle" or "Struggle"
  • + *
+ * The {@link GlbAnimationResolver} uses these to build a fallback chain + * (e.g., SitStruggle -> Struggle -> SitIdle -> Idle).

+ */ +@OnlyIn(Dist.CLIENT) +public enum AnimationContext { + + STAND_IDLE("stand_idle", false), + STAND_WALK("stand_walk", false), + STAND_SNEAK("stand_sneak", false), + STAND_STRUGGLE("stand_struggle", true), + SIT_IDLE("sit_idle", false), + SIT_STRUGGLE("sit_struggle", true), + KNEEL_IDLE("kneel_idle", false), + KNEEL_STRUGGLE("kneel_struggle", true), + + // Movement style contexts + SHUFFLE_IDLE("shuffle_idle", false), + SHUFFLE_WALK("shuffle_walk", false), + HOP_IDLE("hop_idle", false), + HOP_WALK("hop_walk", false), + WADDLE_IDLE("waddle_idle", false), + WADDLE_WALK("waddle_walk", false), + CRAWL_IDLE("crawl_idle", false), + CRAWL_MOVE("crawl_move", false); + + private final String animationSuffix; + private final boolean struggling; + + AnimationContext(String animationSuffix, boolean struggling) { + this.animationSuffix = animationSuffix; + this.struggling = struggling; + } + + /** + * Suffix used as key for context animation JSON files (e.g., "stand_idle"). + */ + public String getAnimationSuffix() { + return animationSuffix; + } + + /** + * Whether this context represents an active struggle state. + */ + public boolean isStruggling() { + return struggling; + } + + /** + * Get the GLB animation name prefix for this context's posture. + * Used by the fallback chain in {@link GlbAnimationResolver}. + * + * @return "Sit", "Kneel", "Sneak", "Walk", or "" for standing + */ + public String getGlbContextPrefix() { + return switch (this) { + case SIT_IDLE, SIT_STRUGGLE -> "Sit"; + case KNEEL_IDLE, KNEEL_STRUGGLE -> "Kneel"; + case STAND_SNEAK -> "Sneak"; + case STAND_WALK -> "Walk"; + case STAND_IDLE, STAND_STRUGGLE -> ""; + case SHUFFLE_IDLE, SHUFFLE_WALK -> "Shuffle"; + case HOP_IDLE, HOP_WALK -> "Hop"; + case WADDLE_IDLE, WADDLE_WALK -> "Waddle"; + case CRAWL_IDLE, CRAWL_MOVE -> "Crawl"; + }; + } + + /** + * Get the GLB animation variant name: "Struggle" or "Idle". + */ + public String getGlbVariant() { + return switch (this) { + case STAND_STRUGGLE, SIT_STRUGGLE, KNEEL_STRUGGLE -> "Struggle"; + case STAND_WALK, SHUFFLE_WALK, HOP_WALK, WADDLE_WALK -> "Walk"; + case CRAWL_MOVE -> "Move"; + default -> "Idle"; + }; + } +} diff --git a/src/main/java/com/tiedup/remake/client/animation/context/AnimationContextResolver.java b/src/main/java/com/tiedup/remake/client/animation/context/AnimationContextResolver.java new file mode 100644 index 0000000..ea60996 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/context/AnimationContextResolver.java @@ -0,0 +1,118 @@ +package com.tiedup.remake.client.animation.context; + +import com.tiedup.remake.client.state.PetBedClientState; +import com.tiedup.remake.entities.AbstractTiedUpNpc; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.v2.bondage.movement.MovementStyle; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.jetbrains.annotations.Nullable; + +/** + * Resolves the current {@link AnimationContext} for players and NPCs based on their state. + * + *

This is a pure function with no side effects -- it reads entity state and returns + * the appropriate animation context. The resolution priority is: + *

    + *
  1. Sitting (pet bed for players, pose for NPCs) -- highest priority posture
  2. + *
  3. Kneeling (NPCs only)
  4. + *
  5. Struggling (standing struggle if not sitting/kneeling)
  6. + *
  7. Sneaking (players only)
  8. + *
  9. Walking (horizontal movement detected)
  10. + *
  11. Standing idle (fallback)
  12. + *
+ * + *

For players, the "sitting" state is determined by the client-side pet bed cache + * ({@link PetBedClientState}) rather than entity data, since pet bed state is not + * synced via entity data accessors.

+ */ +@OnlyIn(Dist.CLIENT) +public final class AnimationContextResolver { + + private AnimationContextResolver() {} + + /** + * Resolve the animation context for a player based on their bind state and movement. + * + *

Priority chain: + *

    + *
  1. Sitting (pet bed/furniture) -- highest priority posture
  2. + *
  3. Struggling -- standing struggle if not sitting
  4. + *
  5. Movement style -- style-specific idle/walk based on movement
  6. + *
  7. Sneaking
  8. + *
  9. Walking
  10. + *
  11. Standing idle -- fallback
  12. + *
+ * + * @param player the player entity (must not be null) + * @param state the player's bind state, or null if not bound + * @param activeStyle the active movement style from client state, or null + * @return the resolved animation context, never null + */ + public static AnimationContext resolve(Player player, @Nullable PlayerBindState state, + @Nullable MovementStyle activeStyle) { + boolean sitting = PetBedClientState.get(player.getUUID()) != 0; + boolean struggling = state != null && state.isStruggling(); + boolean sneaking = player.isCrouching(); + boolean moving = player.getDeltaMovement().horizontalDistanceSqr() > 1.0E-6; + + if (sitting) { + return struggling ? AnimationContext.SIT_STRUGGLE : AnimationContext.SIT_IDLE; + } + if (struggling) { + return AnimationContext.STAND_STRUGGLE; + } + if (activeStyle != null) { + return resolveStyleContext(activeStyle, moving); + } + if (sneaking) { + return AnimationContext.STAND_SNEAK; + } + if (moving) { + return AnimationContext.STAND_WALK; + } + return AnimationContext.STAND_IDLE; + } + + /** + * Map a movement style + moving flag to the appropriate AnimationContext. + */ + private static AnimationContext resolveStyleContext(MovementStyle style, boolean moving) { + return switch (style) { + case SHUFFLE -> moving ? AnimationContext.SHUFFLE_WALK : AnimationContext.SHUFFLE_IDLE; + case HOP -> moving ? AnimationContext.HOP_WALK : AnimationContext.HOP_IDLE; + case WADDLE -> moving ? AnimationContext.WADDLE_WALK : AnimationContext.WADDLE_IDLE; + case CRAWL -> moving ? AnimationContext.CRAWL_MOVE : AnimationContext.CRAWL_IDLE; + }; + } + + /** + * Resolve the animation context for a Damsel NPC based on pose and movement. + * + *

Unlike players, NPCs support kneeling as a distinct posture and do not sneak.

+ * + * @param entity the damsel entity (must not be null) + * @return the resolved animation context, never null + */ + public static AnimationContext resolveNpc(AbstractTiedUpNpc entity) { + boolean sitting = entity.isSitting(); + boolean kneeling = entity.isKneeling(); + boolean struggling = entity.isStruggling(); + boolean moving = entity.getDeltaMovement().horizontalDistanceSqr() > 1.0E-6; + + if (sitting) { + return struggling ? AnimationContext.SIT_STRUGGLE : AnimationContext.SIT_IDLE; + } + if (kneeling) { + return struggling ? AnimationContext.KNEEL_STRUGGLE : AnimationContext.KNEEL_IDLE; + } + if (struggling) { + return AnimationContext.STAND_STRUGGLE; + } + if (moving) { + return AnimationContext.STAND_WALK; + } + return AnimationContext.STAND_IDLE; + } +} diff --git a/src/main/java/com/tiedup/remake/client/animation/context/ContextAnimationFactory.java b/src/main/java/com/tiedup/remake/client/animation/context/ContextAnimationFactory.java new file mode 100644 index 0000000..f326437 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/context/ContextAnimationFactory.java @@ -0,0 +1,161 @@ +package com.tiedup.remake.client.animation.context; + +import com.mojang.logging.LogUtils; +import dev.kosmx.playerAnim.core.data.KeyframeAnimation; +import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.Nullable; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.slf4j.Logger; + +/** + * Builds context {@link KeyframeAnimation}s with item-owned body parts disabled. + * + *

Context animations (loaded from {@code context_*.json} files in the PlayerAnimator + * registry) control the base body posture -- standing, sitting, walking, etc. + * When a V2 bondage item "owns" certain body parts (e.g., handcuffs own rightArm + leftArm), + * those parts must NOT be driven by the context animation because the item's own + * GLB animation controls them instead.

+ * + *

This factory loads the base context animation, creates a mutable copy, disables + * the owned parts, and builds an immutable result. Results are cached by + * {@code contextSuffix|ownedPartsHash} to avoid repeated copies.

+ * + *

Thread safety: the cache uses {@link ConcurrentHashMap}. All methods are + * called from the render thread, but the concurrent map avoids issues if + * resource reload triggers on a different thread.

+ * + * @see AnimationContext + * @see RegionBoneMapper#computeOwnedParts + */ +@OnlyIn(Dist.CLIENT) +public final class ContextAnimationFactory { + + private static final Logger LOGGER = LogUtils.getLogger(); + private static final String NAMESPACE = "tiedup"; + + /** + * Cache keyed by "contextSuffix|ownedPartsHashCode". + * Null values are stored as sentinels for missing animations to avoid repeated lookups. + */ + private static final Map CACHE = new ConcurrentHashMap<>(); + + /** + * Sentinel set used to track cache keys where the base animation was not found, + * so we don't log the same warning repeatedly. + */ + private static final Set MISSING_WARNED = ConcurrentHashMap.newKeySet(); + + private ContextAnimationFactory() {} + + /** + * Create (or retrieve from cache) a context animation with the given parts disabled. + * + *

If no parts need disabling, the base animation is returned as-is (no copy needed). + * If the base animation is not found in the PlayerAnimator registry, returns null.

+ * + * @param context the current animation context (determines which context_*.json to load) + * @param disabledParts set of PlayerAnimator part names to disable on the context layer + * (e.g., {"rightArm", "leftArm"}), typically from + * {@link RegionBoneMapper.BoneOwnership#disabledOnContext()} + * @return the context animation with disabled parts suppressed, or null if not found + */ + @Nullable + public static KeyframeAnimation create(AnimationContext context, Set disabledParts) { + String cacheKey = context.getAnimationSuffix() + "|" + String.join(",", new java.util.TreeSet<>(disabledParts)); + // computeIfAbsent cannot store null values, so we handle the missing case + // by checking the MISSING_WARNED set to avoid redundant work. + KeyframeAnimation cached = CACHE.get(cacheKey); + if (cached != null) { + return cached; + } + if (MISSING_WARNED.contains(cacheKey)) { + return null; + } + + KeyframeAnimation result = buildContextAnimation(context, disabledParts); + if (result != null) { + CACHE.put(cacheKey, result); + } else { + MISSING_WARNED.add(cacheKey); + } + return result; + } + + /** + * Build a context animation with the specified parts disabled. + * + *

Flow: + *

    + *
  1. Check {@link ContextGlbRegistry} for a GLB-based context animation (takes priority)
  2. + *
  3. Fall back to {@code tiedup:context_} in PlayerAnimationRegistry (JSON-based)
  4. + *
  5. If no parts need disabling, return the base animation directly (immutable, shared)
  6. + *
  7. Otherwise, create a mutable copy via {@link KeyframeAnimation#mutableCopy()}
  8. + *
  9. Disable each part via {@link KeyframeAnimation.StateCollection#setEnabled(boolean)}
  10. + *
  11. Build and return the new immutable animation
  12. + *
+ */ + @Nullable + private static KeyframeAnimation buildContextAnimation(AnimationContext context, + Set disabledParts) { + String suffix = context.getAnimationSuffix(); + + // Priority 1: GLB-based context animation from ContextGlbRegistry + KeyframeAnimation baseAnim = ContextGlbRegistry.get(suffix); + + // Priority 2: JSON-based context animation from PlayerAnimationRegistry + if (baseAnim == null) { + ResourceLocation animId = ResourceLocation.fromNamespaceAndPath( + NAMESPACE, "context_" + suffix + ); + baseAnim = PlayerAnimationRegistry.getAnimation(animId); + } + + if (baseAnim == null) { + LOGGER.warn("[V2Animation] Context animation not found for suffix: {}", suffix); + return null; + } + + if (disabledParts.isEmpty()) { + return baseAnim; + } + + // Create mutable copy so we can disable parts without affecting the registry/cache original + KeyframeAnimation.AnimationBuilder builder = baseAnim.mutableCopy(); + disableParts(builder, disabledParts); + return builder.build(); + } + + /** + * Disable all animation axes on the specified parts. + * + *

Uses {@link KeyframeAnimation.AnimationBuilder#getPart(String)} to look up parts + * by name, then {@link KeyframeAnimation.StateCollection#setEnabled(boolean)} to disable + * all axes (x, y, z, pitch, yaw, roll, and bend/bendDirection if applicable).

+ * + *

Unknown part names are silently ignored -- this can happen if the disabled parts set + * includes future bone names not present in the current context animation.

+ */ + private static void disableParts(KeyframeAnimation.AnimationBuilder builder, + Set disabledParts) { + for (String partName : disabledParts) { + KeyframeAnimation.StateCollection part = builder.getPart(partName); + if (part != null) { + part.setEnabled(false); + } + } + } + + /** + * Clear all cached animations. Call this on resource reload or when equipped items change + * in a way that might invalidate cached part ownership. + */ + public static void clearCache() { + CACHE.clear(); + MISSING_WARNED.clear(); + } +} diff --git a/src/main/java/com/tiedup/remake/client/animation/context/ContextGlbRegistry.java b/src/main/java/com/tiedup/remake/client/animation/context/ContextGlbRegistry.java new file mode 100644 index 0000000..7035dbd --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/context/ContextGlbRegistry.java @@ -0,0 +1,121 @@ +package com.tiedup.remake.client.animation.context; + +import com.tiedup.remake.client.gltf.GlbParser; +import com.tiedup.remake.client.gltf.GltfData; +import com.tiedup.remake.client.gltf.GltfPoseConverter; +import dev.kosmx.playerAnim.core.data.KeyframeAnimation; +import java.io.InputStream; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.jetbrains.annotations.Nullable; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Registry for context animations loaded from GLB files. + * + *

Scans the {@code tiedup_contexts/} resource directory for {@code .glb} files, + * parses each one via {@link GlbParser}, converts to a {@link KeyframeAnimation} + * via {@link GltfPoseConverter#convert(GltfData)}, and stores the result keyed by + * the file name suffix (e.g., {@code "stand_walk"} from {@code tiedup_contexts/stand_walk.glb}).

+ * + *

GLB context animations take priority over JSON-based PlayerAnimator context + * animations. This allows artists to author posture animations directly in Blender + * instead of hand-editing JSON keyframes.

+ * + *

Reloaded on resource pack reload (F3+T) via the listener registered in + * {@link com.tiedup.remake.client.gltf.GltfClientSetup}.

+ * + *

Thread safety: the registry field is a volatile reference to an unmodifiable map. + * {@link #reload} builds a new map on the reload thread then atomically swaps the + * reference, so the render thread never sees a partially populated registry.

+ */ +@OnlyIn(Dist.CLIENT) +public final class ContextGlbRegistry { + + private static final Logger LOGGER = LogManager.getLogger("GltfPipeline"); + + /** Resource directory containing context GLB files. */ + private static final String DIRECTORY = "tiedup_contexts"; + + /** + * Registry keyed by context suffix (e.g., "stand_walk", "sit_idle"). + * Values are fully converted KeyframeAnimations with all parts enabled. + * + *

Volatile reference to an unmodifiable map. Reload builds a new map + * and swaps atomically; the render thread always sees a consistent snapshot.

+ */ + private static volatile Map REGISTRY = Map.of(); + + private ContextGlbRegistry() {} + + /** + * Reload all context GLB files from the resource manager. + * + *

Scans {@code assets//tiedup_contexts/} for {@code .glb} files. + * Each file is parsed and converted to a full-body KeyframeAnimation. + * The context suffix is extracted from the file path: + * {@code tiedup_contexts/stand_walk.glb} becomes key {@code "stand_walk"}.

+ * + *

GLB files without animation data or with parse errors are logged and skipped.

+ * + * @param resourceManager the current resource manager (from reload listener) + */ + public static void reload(ResourceManager resourceManager) { + Map newRegistry = new HashMap<>(); + + Map resources = resourceManager.listResources( + DIRECTORY, loc -> loc.getPath().endsWith(".glb")); + + for (Map.Entry entry : resources.entrySet()) { + ResourceLocation loc = entry.getKey(); + Resource resource = entry.getValue(); + + // Extract suffix from path: "tiedup_contexts/stand_walk.glb" -> "stand_walk" + String path = loc.getPath(); + String fileName = path.substring(path.lastIndexOf('/') + 1); + String suffix = fileName.substring(0, fileName.length() - 4); // strip ".glb" + + try (InputStream is = resource.open()) { + GltfData data = GlbParser.parse(is, loc.toString()); + + // Convert to a full-body KeyframeAnimation (all parts enabled) + KeyframeAnimation anim = GltfPoseConverter.convert(data); + newRegistry.put(suffix, anim); + + LOGGER.info("[GltfPipeline] Loaded context GLB: '{}' -> suffix '{}'", loc, suffix); + } catch (Exception e) { + LOGGER.error("[GltfPipeline] Failed to load context GLB: {}", loc, e); + } + } + + // Atomic swap: render thread never sees a partially populated registry + REGISTRY = Collections.unmodifiableMap(newRegistry); + LOGGER.info("[ContextGlb] Loaded {} context GLB animations", newRegistry.size()); + } + + /** + * Get a context animation by suffix. + * + * @param contextSuffix the context suffix (e.g., "stand_walk", "sit_idle") + * @return the KeyframeAnimation, or null if no GLB was found for this suffix + */ + @Nullable + public static KeyframeAnimation get(String contextSuffix) { + return REGISTRY.get(contextSuffix); + } + + /** + * Clear all cached context animations. + * Called on resource reload and world unload. + */ + public static void clear() { + REGISTRY = Map.of(); + } +} diff --git a/src/main/java/com/tiedup/remake/client/animation/context/GlbAnimationResolver.java b/src/main/java/com/tiedup/remake/client/animation/context/GlbAnimationResolver.java new file mode 100644 index 0000000..73ff8d2 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/context/GlbAnimationResolver.java @@ -0,0 +1,151 @@ +package com.tiedup.remake.client.animation.context; + +import com.tiedup.remake.client.gltf.GltfCache; +import com.tiedup.remake.client.gltf.GltfData; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; +import org.jetbrains.annotations.Nullable; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Resolves which named animation to play from a GLB file based on the current + * {@link AnimationContext}. Implements three features: + * + *
    + *
  1. Context-based resolution with fallback chain — tries progressively + * less specific animation names until one is found: + *
    SitStruggle -> Struggle -> SitIdle -> Sit -> Idle -> null
  2. + *
  3. Animation variants — if {@code Struggle.1}, {@code Struggle.2}, + * {@code Struggle.3} exist in the GLB, one is picked at random each time
  4. + *
  5. Shared animation templates — animations can come from a separate GLB + * file (passed as {@code animationSource} to {@link #resolveAnimationData})
  6. + *
+ * + *

This class is stateless and thread-safe. All methods are static.

+ */ +@OnlyIn(Dist.CLIENT) +public final class GlbAnimationResolver { + + private GlbAnimationResolver() {} + + /** + * Resolve the animation data source. + * If {@code animationSource} is non-null, load that GLB for animations + * (shared template). Otherwise use the item's own model GLB. + * + * @param itemModelLoc the item's GLB model resource location + * @param animationSource optional separate GLB containing shared animations + * @return parsed GLB data, or null if loading failed + */ + @Nullable + public static GltfData resolveAnimationData(ResourceLocation itemModelLoc, + @Nullable ResourceLocation animationSource) { + ResourceLocation source = animationSource != null ? animationSource : itemModelLoc; + return GltfCache.get(source); + } + + /** + * Resolve the best animation name from a GLB for the given context. + * Supports variant selection ({@code Struggle.1}, {@code Struggle.2} -> random pick) + * and full-body animations ({@code FullWalk}, {@code FullStruggle}). + * + *

Fallback chain (Full variants checked first at each step):

+ *
+     * FullSitStruggle -> SitStruggle -> FullStruggle -> Struggle
+     *   -> FullSitIdle -> SitIdle -> FullSit -> Sit
+     *   -> FullIdle -> Idle -> null
+     * 
+ * + * @param data the parsed GLB data containing named animations + * @param context the current animation context (posture + action) + * @return the animation name to use, or null to use the default (first) clip + */ + @Nullable + public static String resolve(GltfData data, AnimationContext context) { + String prefix = context.getGlbContextPrefix(); // "Sit", "Kneel", "Sneak", "Walk", "" + String variant = context.getGlbVariant(); // "Idle" or "Struggle" + + // 1. Exact match: "FullSitIdle" then "SitIdle" (with variants) + String exact = prefix + variant; + if (!exact.isEmpty()) { + String picked = pickWithVariants(data, "Full" + exact); + if (picked != null) return picked; + picked = pickWithVariants(data, exact); + if (picked != null) return picked; + } + + // 2. For struggles: try "FullStruggle" then "Struggle" (with variants) + if (context.isStruggling()) { + String picked = pickWithVariants(data, "FullStruggle"); + if (picked != null) return picked; + picked = pickWithVariants(data, "Struggle"); + if (picked != null) return picked; + } + + // 3. Context-only: "FullSit" then "Sit" (with variants) + if (!prefix.isEmpty()) { + String picked = pickWithVariants(data, "Full" + prefix); + if (picked != null) return picked; + picked = pickWithVariants(data, prefix); + if (picked != null) return picked; + } + + // 4. Variant-only: "FullIdle" then "Idle" (with variants) + { + String picked = pickWithVariants(data, "Full" + variant); + if (picked != null) return picked; + picked = pickWithVariants(data, variant); + if (picked != null) return picked; + } + + // 5. Default: return null = use first animation clip in GLB + return null; + } + + /** + * Look for an animation by base name, including numbered variants. + *
    + *
  • If "Struggle" exists alone, return "Struggle"
  • + *
  • If "Struggle.1" and "Struggle.2" exist, pick one randomly
  • + *
  • If both "Struggle" and "Struggle.1" exist, include all in the random pool
  • + *
+ * + *

Variant numbering starts at 1 and tolerates a missing {@code .1} + * (continues to check {@code .2}). Gaps after index 1 stop the scan. + * For example, {@code Struggle.1, Struggle.3} would only find + * {@code Struggle.1} because the gap at index 2 stops iteration. + * However, if only {@code Struggle.2} exists (no {@code .1}), it will + * still be found because the scan skips the first gap.

+ * + * @param data the parsed GLB data + * @param baseName the base animation name (e.g., "Struggle", "SitIdle") + * @return the selected animation name, or null if no match found + */ + @Nullable + private static String pickWithVariants(GltfData data, String baseName) { + Map anims = data.namedAnimations(); + List candidates = new ArrayList<>(); + + if (anims.containsKey(baseName)) { + candidates.add(baseName); + } + + // Check numbered variants: baseName.1, baseName.2, ... + for (int i = 1; i <= 99; i++) { + String variantName = baseName + "." + i; + if (anims.containsKey(variantName)) { + candidates.add(variantName); + } else if (i > 1) { + break; // Stop at first gap after .1 + } + } + + if (candidates.isEmpty()) return null; + if (candidates.size() == 1) return candidates.get(0); + return candidates.get(ThreadLocalRandom.current().nextInt(candidates.size())); + } +} diff --git a/src/main/java/com/tiedup/remake/client/animation/context/RegionBoneMapper.java b/src/main/java/com/tiedup/remake/client/animation/context/RegionBoneMapper.java new file mode 100644 index 0000000..4ea84bf --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/context/RegionBoneMapper.java @@ -0,0 +1,344 @@ +package com.tiedup.remake.client.animation.context; + +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem; +import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition; +import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry; +import java.util.*; +import org.jetbrains.annotations.Nullable; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Maps V2 body regions to PlayerAnimator part names. + * Bridge between gameplay regions and animation bones. + * + *

PlayerAnimator uses 6 named parts: head, body, rightArm, leftArm, rightLeg, leftLeg. + * This mapper translates the 14 {@link BodyRegionV2} gameplay regions into those bone names, + * enabling the animation system to know which bones are "owned" by equipped bondage items.

+ * + *

Regions without a direct bone mapping (NECK, FINGERS, TAIL, WINGS) return empty sets. + * These regions still affect gameplay (blocking, escape difficulty) but don't directly + * constrain animation bones.

+ */ +@OnlyIn(Dist.CLIENT) +public final class RegionBoneMapper { + + /** All PlayerAnimator part names for the player model. */ + public static final Set ALL_PARTS = Set.of( + "head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg" + ); + + /** + * Describes bone ownership for a specific item in the context of all equipped items. + * + *
    + *
  • {@code thisParts} — parts owned exclusively by the winning item
  • + *
  • {@code otherParts} — parts owned by other equipped items
  • + *
  • {@link #freeParts()} — parts not owned by any item (available for animation)
  • + *
  • {@link #enabledParts()} — parts the winning item may animate (owned + free)
  • + *
+ * + *

When both the winning item and another item claim the same bone, + * the other item takes precedence (the bone goes to {@code otherParts}).

+ */ + public record BoneOwnership(Set thisParts, Set otherParts) { + + /** + * Parts not owned by any item. These are "free" and can be animated + * by the winning item IF the GLB contains keyframes for them. + */ + public Set freeParts() { + Set free = new HashSet<>(ALL_PARTS); + free.removeAll(thisParts); + free.removeAll(otherParts); + return Collections.unmodifiableSet(free); + } + + /** + * Parts the winning item is allowed to animate: its own parts + free parts. + * Free parts are only actually enabled if the GLB has keyframes for them. + */ + public Set enabledParts() { + Set enabled = new HashSet<>(thisParts); + enabled.addAll(freeParts()); + return Collections.unmodifiableSet(enabled); + } + + /** + * Parts that must be disabled on the context layer: parts owned by this item + * (handled by item layer) + parts owned by other items (handled by their layer). + * This equals ALL_PARTS minus freeParts. + */ + public Set disabledOnContext() { + Set disabled = new HashSet<>(thisParts); + disabled.addAll(otherParts); + return Collections.unmodifiableSet(disabled); + } + } + + private static final Map> REGION_TO_PARTS; + + static { + Map> map = new EnumMap<>(BodyRegionV2.class); + map.put(BodyRegionV2.HEAD, Set.of("head")); + map.put(BodyRegionV2.EYES, Set.of("head")); + map.put(BodyRegionV2.EARS, Set.of("head")); + map.put(BodyRegionV2.MOUTH, Set.of("head")); + map.put(BodyRegionV2.NECK, Set.of()); + map.put(BodyRegionV2.TORSO, Set.of("body")); + map.put(BodyRegionV2.ARMS, Set.of("rightArm", "leftArm")); + map.put(BodyRegionV2.HANDS, Set.of("rightArm", "leftArm")); + map.put(BodyRegionV2.FINGERS, Set.of()); + map.put(BodyRegionV2.WAIST, Set.of("body")); + map.put(BodyRegionV2.LEGS, Set.of("rightLeg", "leftLeg")); + map.put(BodyRegionV2.FEET, Set.of("rightLeg", "leftLeg")); + map.put(BodyRegionV2.TAIL, Set.of()); + map.put(BodyRegionV2.WINGS, Set.of()); + REGION_TO_PARTS = Collections.unmodifiableMap(map); + } + + private RegionBoneMapper() {} + + /** + * Get the PlayerAnimator part names affected by a single body region. + * + * @param region the V2 body region + * @return unmodifiable set of part name strings, never null (may be empty) + */ + public static Set getPartsForRegion(BodyRegionV2 region) { + return REGION_TO_PARTS.getOrDefault(region, Set.of()); + } + + /** + * Compute the union of all PlayerAnimator parts "owned" by equipped bondage items. + * + *

Iterates over the equipped map (as returned by + * {@link com.tiedup.remake.v2.bondage.IV2BondageEquipment#getAllEquipped()}) + * and collects every bone affected by each item's occupied regions.

+ * + * @param equipped map from representative region to equipped ItemStack + * @return unmodifiable set of owned part name strings + */ + public static Set computeOwnedParts(Map equipped) { + Set owned = new HashSet<>(); + for (Map.Entry entry : equipped.entrySet()) { + ItemStack stack = entry.getValue(); + if (stack.getItem() instanceof IV2BondageItem v2Item) { + for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) { + owned.addAll(getPartsForRegion(region)); + } + } + } + return Collections.unmodifiableSet(owned); + } + + /** + * Compute per-item bone ownership for a specific "winning" item. + * + *

Iterates over all equipped items. Parts owned by the winning item + * go to {@code thisParts}; parts owned by other items go to {@code otherParts}. + * If both the winning item and another item claim the same bone, the other + * item takes precedence (conflict resolution: other wins).

+ * + *

Uses ItemStack reference equality ({@code ==}) to identify the winning item + * because the same ItemStack instance is used in the equipped map.

+ * + * @param equipped map from representative region to equipped ItemStack + * @param winningItemStack the ItemStack of the highest-priority V2 item with a GLB model + * @return BoneOwnership describing this item's parts vs other items' parts + */ + public static BoneOwnership computePerItemParts(Map equipped, + ItemStack winningItemStack) { + Set thisParts = new HashSet<>(); + Set otherParts = new HashSet<>(); + + // Track which ItemStacks we've already processed to avoid duplicate work + // (multiple regions can map to the same ItemStack) + Set processed = Collections.newSetFromMap(new IdentityHashMap<>()); + + for (Map.Entry entry : equipped.entrySet()) { + ItemStack stack = entry.getValue(); + if (processed.contains(stack)) continue; + processed.add(stack); + + if (stack.getItem() instanceof IV2BondageItem v2Item) { + Set itemParts = new HashSet<>(); + for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) { + itemParts.addAll(getPartsForRegion(region)); + } + + if (stack == winningItemStack) { + thisParts.addAll(itemParts); + } else { + otherParts.addAll(itemParts); + } + } + } + + // Conflict resolution: if both this item and another claim the same bone, + // the other item takes precedence + thisParts.removeAll(otherParts); + + return new BoneOwnership( + Collections.unmodifiableSet(thisParts), + Collections.unmodifiableSet(otherParts) + ); + } + + /** + * Result of resolving the highest-priority V2 item with a GLB model. + * Combines the model location, optional animation source, and the winning ItemStack + * into a single object so callers don't need two separate iteration passes. + * + * @param modelLoc the GLB model ResourceLocation of the winning item + * @param animSource separate GLB for animations (shared template), or null to use modelLoc + * @param winningItem the actual ItemStack reference (for identity comparison in + * {@link #computePerItemParts}) + */ + public record GlbModelResult(ResourceLocation modelLoc, @Nullable ResourceLocation animSource, + ItemStack winningItem) {} + + /** + * Animation info for a single equipped V2 item. + * Used by the multi-item animation pipeline to process each item independently. + * + * @param modelLoc GLB model location (for rendering + default animation source) + * @param animSource separate animation GLB, or null to use modelLoc + * @param ownedParts parts this item exclusively owns (after conflict resolution) + * @param posePriority the item's pose priority (for free-bone assignment) + * @param animationBones per-animation bone whitelist from the data-driven definition. + * Empty map for hardcoded items (no filtering applied). + */ + public record V2ItemAnimInfo(ResourceLocation modelLoc, @Nullable ResourceLocation animSource, + Set ownedParts, int posePriority, + Map> animationBones) {} + + /** + * Find the highest-priority V2 item with a GLB model in the equipped map. + * + *

Single pass over all equipped items, comparing their + * {@link IV2BondageItem#getPosePriority()} to select the dominant model. + * Returns both the model location and the winning ItemStack reference so + * callers can pass the ItemStack to {@link #computePerItemParts} without + * a second iteration.

+ * + * @param equipped map of equipped V2 items by body region (may be empty, never null) + * @return the winning item's model info, or null if no V2 item has a GLB model (V1 fallback) + */ + @Nullable + public static GlbModelResult resolveWinningItem(Map equipped) { + ItemStack bestStack = null; + ResourceLocation bestModel = null; + int bestPriority = Integer.MIN_VALUE; + for (Map.Entry entry : equipped.entrySet()) { + ItemStack stack = entry.getValue(); + if (stack.getItem() instanceof IV2BondageItem v2Item) { + ResourceLocation model = v2Item.getModelLocation(stack); + if (model != null && v2Item.getPosePriority(stack) > bestPriority) { + bestPriority = v2Item.getPosePriority(stack); + bestModel = model; + bestStack = stack; + } + } + } + if (bestStack == null || bestModel == null) return null; + + // Extract animation source from data-driven item definitions. + // For hardcoded IV2BondageItem implementations, animSource stays null + // (the model's own animations are used). + ResourceLocation animSource = null; + if (bestStack.getItem() instanceof DataDrivenBondageItem) { + DataDrivenItemDefinition def = DataDrivenItemRegistry.get(bestStack); + if (def != null) { + animSource = def.animationSource(); + } + } + + return new GlbModelResult(bestModel, animSource, bestStack); + } + + /** + * Resolve ALL equipped V2 items with GLB models, with per-item bone ownership. + * + *

Each item gets ownership of its declared regions' bones. When two items claim + * the same bone, the higher-priority item wins. The highest-priority item is also + * designated as the "free bone donor" — it can animate free bones if its GLB has + * keyframes for them.

+ * + * @param equipped map from representative region to equipped ItemStack + * @return list of V2ItemAnimInfo, sorted by priority descending. Empty if no V2 items. + * The first element (if any) is the free-bone donor. + */ + public static List resolveAllV2Items(Map equipped) { + record ItemEntry(ItemStack stack, IV2BondageItem v2Item, ResourceLocation model, + @Nullable ResourceLocation animSource, Set rawParts, int priority, + Map> animationBones) {} + + List entries = new ArrayList<>(); + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + + for (Map.Entry entry : equipped.entrySet()) { + ItemStack stack = entry.getValue(); + if (seen.contains(stack)) continue; + seen.add(stack); + + if (stack.getItem() instanceof IV2BondageItem v2Item) { + ResourceLocation model = v2Item.getModelLocation(stack); + if (model == null) continue; + + Set rawParts = new HashSet<>(); + for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) { + rawParts.addAll(getPartsForRegion(region)); + } + if (rawParts.isEmpty()) continue; + + ResourceLocation animSource = null; + Map> animBones = Map.of(); + if (stack.getItem() instanceof DataDrivenBondageItem) { + DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack); + if (def != null) { + animSource = def.animationSource(); + animBones = def.animationBones(); + } + } + + entries.add(new ItemEntry(stack, v2Item, model, animSource, rawParts, + v2Item.getPosePriority(stack), animBones)); + } + } + + if (entries.isEmpty()) return List.of(); + + entries.sort((a, b) -> Integer.compare(b.priority(), a.priority())); + + Set claimed = new HashSet<>(); + List result = new ArrayList<>(); + + for (ItemEntry e : entries) { + Set ownedParts = new HashSet<>(e.rawParts()); + ownedParts.removeAll(claimed); + if (ownedParts.isEmpty()) continue; + claimed.addAll(ownedParts); + result.add(new V2ItemAnimInfo(e.model(), e.animSource(), + Collections.unmodifiableSet(ownedParts), e.priority(), e.animationBones())); + } + + return Collections.unmodifiableList(result); + } + + /** + * Compute the set of all bone parts owned by any item in the resolved list. + * Used to disable owned parts on the context layer. + */ + public static Set computeAllOwnedParts(List items) { + Set allOwned = new HashSet<>(); + for (V2ItemAnimInfo item : items) { + allOwned.addAll(item.ownedParts()); + } + return Collections.unmodifiableSet(allOwned); + } +} diff --git a/src/main/java/com/tiedup/remake/client/animation/render/DogPoseRenderHandler.java b/src/main/java/com/tiedup/remake/client/animation/render/DogPoseRenderHandler.java new file mode 100644 index 0000000..a8222bb --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/render/DogPoseRenderHandler.java @@ -0,0 +1,198 @@ +package com.tiedup.remake.client.animation.render; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.items.base.PoseType; +import com.tiedup.remake.state.HumanChairHelper; +import com.tiedup.remake.state.PlayerBindState; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.RenderPlayerEvent; +import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Handles DOG and HUMAN_CHAIR pose rendering adjustments. + * + *

Applies vertical offset and smooth body rotation for DOG/HUMAN_CHAIR poses. + * Runs at HIGH priority to ensure transforms are applied before other Pre handlers. + * + *

Extracted from PlayerArmHideEventHandler for single-responsibility. + */ +@OnlyIn(Dist.CLIENT) +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE, + value = Dist.CLIENT +) +public class DogPoseRenderHandler { + + /** + * DOG pose state tracking per player. + * Stores: [0: smoothedTarget, 1: currentRot, 2: appliedDelta, 3: isMoving (0/1)] + */ + private static final Int2ObjectMap dogPoseState = + new Int2ObjectOpenHashMap<>(); + + // Array indices for dogPoseState + private static final int IDX_TARGET = 0; + private static final int IDX_CURRENT = 1; + private static final int IDX_DELTA = 2; + private static final int IDX_MOVING = 3; + + /** + * Get the rotation delta applied to a player's render for DOG pose. + * Used by MixinPlayerModel to compensate head rotation. + */ + public static float getAppliedRotationDelta(int playerId) { + float[] state = dogPoseState.get(playerId); + return state != null ? state[IDX_DELTA] : 0f; + } + + /** + * Check if a player is currently moving in DOG pose. + */ + public static boolean isDogPoseMoving(int playerId) { + float[] state = dogPoseState.get(playerId); + return state != null && state[IDX_MOVING] > 0.5f; + } + + /** + * Clear all DOG pose state data. + * Called on world unload to prevent memory leaks. + */ + public static void clearState() { + dogPoseState.clear(); + } + + /** + * Before player render: Apply vertical offset and rotation for DOG/HUMAN_CHAIR poses. + * HIGH priority ensures this runs before arm/item hiding handlers. + */ + @SubscribeEvent(priority = EventPriority.HIGH) + public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) { + Player player = event.getEntity(); + if (!(player instanceof AbstractClientPlayer)) { + return; + } + + if (player.isRemoved() || !player.isAlive()) { + return; + } + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + return; + } + + ItemStack bindForPose = state.getEquipment(BodyRegionV2.ARMS); + if ( + bindForPose.isEmpty() || + !(bindForPose.getItem() instanceof ItemBind itemBind) + ) { + return; + } + + PoseType bindPoseType = itemBind.getPoseType(); + // Check for humanChairMode NBT override + bindPoseType = HumanChairHelper.resolveEffectivePose( + bindPoseType, + bindForPose + ); + + if ( + bindPoseType != PoseType.DOG && bindPoseType != PoseType.HUMAN_CHAIR + ) { + return; + } + + // Lower player by 6 model units (6/16 = 0.375 blocks) + event + .getPoseStack() + .translate(0, RenderConstants.DOG_AND_PETBED_Y_OFFSET, 0); + + int playerId = player.getId(); + net.minecraft.world.phys.Vec3 movement = player.getDeltaMovement(); + boolean isMoving = movement.horizontalDistanceSqr() > 0.0001; + + // Get or create state - initialize to current body rotation + float[] s = dogPoseState.get(playerId); + if (s == null) { + s = new float[] { player.yBodyRot, player.yBodyRot, 0f, 0f }; + dogPoseState.put(playerId, s); + } + + // Human chair: lock rotation state — body must not turn + if (bindPoseType == PoseType.HUMAN_CHAIR) { + s[IDX_CURRENT] = player.yBodyRot; + s[IDX_TARGET] = player.yBodyRot; + s[IDX_DELTA] = 0f; + s[IDX_MOVING] = 0f; + } else { + // Determine target rotation + float rawTarget; + if (isMoving) { + // Moving: face movement direction + rawTarget = (float) Math.toDegrees( + Math.atan2(-movement.x, movement.z) + ); + } else { + // Stationary: face where head is looking + rawTarget = player.yHeadRot; + } + + // Check if head would be clamped (body lagging behind head) + float predictedHeadYaw = net.minecraft.util.Mth.wrapDegrees( + player.yHeadRot - s[IDX_CURRENT] + ); + float maxYaw = isMoving + ? RenderConstants.HEAD_MAX_YAW_MOVING + : RenderConstants.HEAD_MAX_YAW_STATIONARY; + boolean headAtLimit = + Math.abs(predictedHeadYaw) > + maxYaw * RenderConstants.HEAD_AT_LIMIT_RATIO; + + if (headAtLimit && !isMoving) { + // Head at limit while stationary: snap body to release head + float sign = predictedHeadYaw > 0 ? 1f : -1f; + s[IDX_CURRENT] = + player.yHeadRot - + sign * maxYaw * RenderConstants.HEAD_SNAP_RELEASE_RATIO; + s[IDX_TARGET] = s[IDX_CURRENT]; + } else { + // Normal smoothing + float targetDelta = net.minecraft.util.Mth.wrapDegrees( + rawTarget - s[IDX_TARGET] + ); + float targetSpeed = isMoving + ? RenderConstants.DOG_TARGET_SPEED_MOVING + : RenderConstants.DOG_TARGET_SPEED_STATIONARY; + s[IDX_TARGET] += targetDelta * targetSpeed; + + float rotDelta = net.minecraft.util.Mth.wrapDegrees( + s[IDX_TARGET] - s[IDX_CURRENT] + ); + float speed = isMoving + ? RenderConstants.DOG_ROT_SPEED_MOVING + : RenderConstants.DOG_ROT_SPEED_STATIONARY; + s[IDX_CURRENT] += rotDelta * speed; + } + } + + // Calculate and store the delta we apply to poseStack + s[IDX_DELTA] = player.yBodyRot - s[IDX_CURRENT]; + s[IDX_MOVING] = isMoving ? 1f : 0f; + + // Apply rotation to make body face our custom direction + event + .getPoseStack() + .mulPose(com.mojang.math.Axis.YP.rotationDegrees(s[IDX_DELTA])); + } +} diff --git a/src/main/java/com/tiedup/remake/client/animation/render/FirstPersonHandHideHandler.java b/src/main/java/com/tiedup/remake/client/animation/render/FirstPersonHandHideHandler.java new file mode 100644 index 0000000..f00dd18 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/render/FirstPersonHandHideHandler.java @@ -0,0 +1,51 @@ +package com.tiedup.remake.client.animation.render; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.PlayerBindState; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.RenderHandEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Hide first-person hand/item rendering based on bondage state. + * + * Behavior: + * - Tied up: Hide hands completely (hands are behind back) + * - Mittens: Hide hands + items (Forge limitation - can't separate them) + */ +@OnlyIn(Dist.CLIENT) +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE, + value = Dist.CLIENT +) +public class FirstPersonHandHideHandler { + + @SubscribeEvent + public static void onRenderHand(RenderHandEvent event) { + Minecraft mc = Minecraft.getInstance(); + if (mc == null) { + return; + } + LocalPlayer player = mc.player; + + if (player == null) { + return; + } + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + return; + } + + // Tied or Mittens: hide hands completely + // (Forge limitation: RenderHandEvent controls hand + item together) + if (state.isTiedUp() || state.hasMittens()) { + event.setCanceled(true); + } + } +} diff --git a/src/main/java/com/tiedup/remake/client/animation/render/HeldItemHideHandler.java b/src/main/java/com/tiedup/remake/client/animation/render/HeldItemHideHandler.java new file mode 100644 index 0000000..01b60e0 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/render/HeldItemHideHandler.java @@ -0,0 +1,93 @@ +package com.tiedup.remake.client.animation.render; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.PlayerBindState; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.RenderPlayerEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Hides held items when player has arms bound or is wearing mittens. + * + *

Uses Pre/Post pattern to temporarily replace held items with empty + * stacks for rendering, then restore them after. + * + *

Extracted from PlayerArmHideEventHandler for single-responsibility. + */ +@OnlyIn(Dist.CLIENT) +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE, + value = Dist.CLIENT +) +public class HeldItemHideHandler { + + /** + * Stored items to restore after rendering. + * Key: Player entity ID (int), Value: [mainHand, offHand] + */ + private static final Int2ObjectMap storedItems = + new Int2ObjectOpenHashMap<>(); + + @SubscribeEvent + public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) { + Player player = event.getEntity(); + if (!(player instanceof AbstractClientPlayer)) { + return; + } + + if (player.isRemoved() || !player.isAlive()) { + return; + } + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + return; + } + + boolean hasArmsBound = state.hasArmsBound(); + boolean hasMittens = state.hasMittens(); + + if (hasArmsBound || hasMittens) { + ItemStack mainHand = player.getItemInHand( + InteractionHand.MAIN_HAND + ); + ItemStack offHand = player.getItemInHand(InteractionHand.OFF_HAND); + + if (!mainHand.isEmpty() || !offHand.isEmpty()) { + storedItems.put( + player.getId(), + new ItemStack[] { mainHand.copy(), offHand.copy() } + ); + + player.setItemInHand( + InteractionHand.MAIN_HAND, + ItemStack.EMPTY + ); + player.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY); + } + } + } + + @SubscribeEvent + public static void onRenderPlayerPost(RenderPlayerEvent.Post event) { + Player player = event.getEntity(); + if (!(player instanceof AbstractClientPlayer)) { + return; + } + + ItemStack[] items = storedItems.remove(player.getId()); + if (items != null) { + player.setItemInHand(InteractionHand.MAIN_HAND, items[0]); + player.setItemInHand(InteractionHand.OFF_HAND, items[1]); + } + } +} diff --git a/src/main/java/com/tiedup/remake/client/animation/render/PetBedRenderHandler.java b/src/main/java/com/tiedup/remake/client/animation/render/PetBedRenderHandler.java new file mode 100644 index 0000000..99d4c93 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/render/PetBedRenderHandler.java @@ -0,0 +1,111 @@ +package com.tiedup.remake.client.animation.render; + +import com.tiedup.remake.client.state.PetBedClientState; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.items.base.PoseType; +import com.tiedup.remake.state.HumanChairHelper; +import com.tiedup.remake.state.PlayerBindState; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.RenderPlayerEvent; +import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Handles pet bed render adjustments (SIT and SLEEP modes). + * + *

Applies vertical offset and forced standing pose for pet bed states. + * Runs at HIGH priority alongside DogPoseRenderHandler. + * + *

Extracted from PlayerArmHideEventHandler for single-responsibility. + */ +@OnlyIn(Dist.CLIENT) +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE, + value = Dist.CLIENT +) +public class PetBedRenderHandler { + + /** + * Before player render: Apply vertical offset and forced pose for pet bed. + */ + @SubscribeEvent(priority = EventPriority.HIGH) + public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) { + Player player = event.getEntity(); + if (!(player instanceof AbstractClientPlayer)) { + return; + } + + if (player.isRemoved() || !player.isAlive()) { + return; + } + + java.util.UUID petBedUuid = player.getUUID(); + byte petBedMode = PetBedClientState.get(petBedUuid); + + if (petBedMode == 1 || petBedMode == 2) { + // Skip Y-offset if DogPoseRenderHandler already applies it + // (DOG/HUMAN_CHAIR pose uses the same offset amount) + if (!isDogOrChairPose(player)) { + event + .getPoseStack() + .translate(0, RenderConstants.DOG_AND_PETBED_Y_OFFSET, 0); + } + } + if (petBedMode == 2) { + // SLEEP: force STANDING pose to prevent vanilla sleeping rotation + player.setForcedPose(net.minecraft.world.entity.Pose.STANDING); + + // Compensate for vanilla sleeping Y offset + player + .getSleepingPos() + .ifPresent(pos -> { + double yOffset = player.getY() - pos.getY(); + if (yOffset > 0.01) { + event.getPoseStack().translate(0, -yOffset, 0); + } + }); + } + } + + /** + * Check if the player is in DOG or HUMAN_CHAIR pose. + * Used to avoid double Y-offset with DogPoseRenderHandler. + */ + private static boolean isDogOrChairPose(Player player) { + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) return false; + ItemStack bind = state.getEquipment(BodyRegionV2.ARMS); + if ( + bind.isEmpty() || !(bind.getItem() instanceof ItemBind itemBind) + ) return false; + PoseType pose = HumanChairHelper.resolveEffectivePose( + itemBind.getPoseType(), + bind + ); + return pose == PoseType.DOG || pose == PoseType.HUMAN_CHAIR; + } + + /** + * After player render: Restore forced pose for pet bed SLEEP mode. + */ + @SubscribeEvent + public static void onRenderPlayerPost(RenderPlayerEvent.Post event) { + Player player = event.getEntity(); + if (!(player instanceof AbstractClientPlayer)) { + return; + } + + byte petBedMode = PetBedClientState.get(player.getUUID()); + if (petBedMode == 2) { + player.setForcedPose(null); + } + } +} diff --git a/src/main/java/com/tiedup/remake/client/animation/render/PlayerArmHideEventHandler.java b/src/main/java/com/tiedup/remake/client/animation/render/PlayerArmHideEventHandler.java new file mode 100644 index 0000000..2d31e6b --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/render/PlayerArmHideEventHandler.java @@ -0,0 +1,135 @@ +package com.tiedup.remake.client.animation.render; + +import com.tiedup.remake.client.renderer.layers.ClothesRenderHelper; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.items.base.PoseType; +import com.tiedup.remake.items.clothes.ClothesProperties; +import com.tiedup.remake.state.PlayerBindState; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; +import net.minecraft.client.model.PlayerModel; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.RenderPlayerEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Hide player arms and outer layers based on bondage/clothes state. + * + *

Responsibilities (after extraction of dog pose, pet bed, and held items): + *

    + *
  • Hide arms for wrap/latex_sack poses
  • + *
  • Hide outer layers (hat, jacket, sleeves, pants) based on clothes settings
  • + *
+ * + *

Uses Pre/Post pattern to temporarily modify and restore state. + */ +@OnlyIn(Dist.CLIENT) +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE, + value = Dist.CLIENT +) +public class PlayerArmHideEventHandler { + + /** + * Stored layer visibility to restore after rendering. + * Key: Player entity ID (int), Value: [hat, jacket, leftSleeve, rightSleeve, leftPants, rightPants] + */ + private static final Int2ObjectMap storedLayers = + new Int2ObjectOpenHashMap<>(); + + /** + * Before player render: + * - Hide arms for wrap/latex_sack poses + * - Hide outer layers based on clothes settings (Phase 19) + */ + @SubscribeEvent + public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) { + Player player = event.getEntity(); + if (!(player instanceof AbstractClientPlayer clientPlayer)) { + return; + } + + if (player.isRemoved() || !player.isAlive()) { + return; + } + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + return; + } + + PlayerModel model = event.getRenderer().getModel(); + + // === HIDE ARMS (wrap/latex_sack poses) === + if (state.hasArmsBound()) { + ItemStack bind = state.getEquipment(BodyRegionV2.ARMS); + if ( + !bind.isEmpty() && bind.getItem() instanceof ItemBind itemBind + ) { + PoseType poseType = itemBind.getPoseType(); + + // Only hide arms for wrap/sack poses (arms are covered by the item) + if ( + poseType == PoseType.WRAP || poseType == PoseType.LATEX_SACK + ) { + model.leftArm.visible = false; + model.rightArm.visible = false; + model.leftSleeve.visible = false; + model.rightSleeve.visible = false; + } + } + } + + // === HIDE WEARER LAYERS (clothes settings) - Phase 19 === + ItemStack clothes = state.getEquipment(BodyRegionV2.TORSO); + if (!clothes.isEmpty()) { + ClothesProperties props = + ClothesRenderHelper.getPropsForLayerHiding( + clothes, + clientPlayer + ); + if (props != null) { + boolean[] savedLayers = ClothesRenderHelper.hideWearerLayers( + model, + props + ); + if (savedLayers != null) { + storedLayers.put(player.getId(), savedLayers); + } + } + } + } + + /** + * After player render: Restore arm visibility and layer visibility. + */ + @SubscribeEvent + public static void onRenderPlayerPost(RenderPlayerEvent.Post event) { + Player player = event.getEntity(); + if (!(player instanceof AbstractClientPlayer)) { + return; + } + + PlayerModel model = event.getRenderer().getModel(); + + // === RESTORE ARM VISIBILITY === + model.leftArm.visible = true; + model.rightArm.visible = true; + model.leftSleeve.visible = true; + model.rightSleeve.visible = true; + + // === RESTORE WEARER LAYERS - Phase 19 === + boolean[] savedLayers = storedLayers.remove(player.getId()); + if (savedLayers != null) { + ClothesRenderHelper.restoreWearerLayers(model, savedLayers); + } + } +} diff --git a/src/main/java/com/tiedup/remake/client/animation/render/RenderConstants.java b/src/main/java/com/tiedup/remake/client/animation/render/RenderConstants.java new file mode 100644 index 0000000..6c6da6b --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/render/RenderConstants.java @@ -0,0 +1,58 @@ +package com.tiedup.remake.client.animation.render; + +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Centralizes magic numbers used across render handlers. + * + *

DOG pose rotation smoothing, head clamp limits, and vertical offsets + * that were previously scattered as unnamed literals. + */ +@OnlyIn(Dist.CLIENT) +public final class RenderConstants { + + private RenderConstants() {} + + // === DOG pose rotation smoothing speeds === + + /** Speed for smoothing body rotation toward target while moving */ + public static final float DOG_ROT_SPEED_MOVING = 0.15f; + + /** Speed for smoothing body rotation toward target while stationary */ + public static final float DOG_ROT_SPEED_STATIONARY = 0.12f; + + /** Speed for smoothing target rotation while moving */ + public static final float DOG_TARGET_SPEED_MOVING = 0.2f; + + /** Speed for smoothing target rotation while stationary */ + public static final float DOG_TARGET_SPEED_STATIONARY = 0.3f; + + // === Head clamp limits === + + /** Maximum head yaw relative to body while moving (degrees) */ + public static final float HEAD_MAX_YAW_MOVING = 60f; + + /** Maximum head yaw relative to body while stationary (degrees) */ + public static final float HEAD_MAX_YAW_STATIONARY = 90f; + + /** Threshold ratio for detecting head-at-limit (triggers body snap) */ + public static final float HEAD_AT_LIMIT_RATIO = 0.85f; + + /** Ratio of max yaw to snap body to when releasing head */ + public static final float HEAD_SNAP_RELEASE_RATIO = 0.7f; + + // === Vertical offsets (model units, 16 = 1 block) === + + /** Y offset for DOG and PET BED poses (6/16 = 0.375 blocks) */ + public static final double DOG_AND_PETBED_Y_OFFSET = -6.0 / 16.0; + + /** Y offset for Damsel sitting pose (model units) */ + public static final float DAMSEL_SIT_OFFSET = -10.0f; + + /** Y offset for Damsel kneeling pose (model units) */ + public static final float DAMSEL_KNEEL_OFFSET = -5.0f; + + /** Y offset for Damsel dog pose (model units) */ + public static final float DAMSEL_DOG_OFFSET = -7.0f; +} diff --git a/src/main/java/com/tiedup/remake/client/animation/tick/AnimationTickHandler.java b/src/main/java/com/tiedup/remake/client/animation/tick/AnimationTickHandler.java new file mode 100644 index 0000000..3246551 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/tick/AnimationTickHandler.java @@ -0,0 +1,318 @@ +package com.tiedup.remake.client.animation.tick; + +import com.mojang.logging.LogUtils; +import com.tiedup.remake.client.animation.AnimationStateRegistry; +import com.tiedup.remake.client.animation.BondageAnimationManager; +import com.tiedup.remake.client.animation.PendingAnimationManager; +import com.tiedup.remake.client.animation.util.AnimationIdBuilder; +import com.tiedup.remake.client.events.CellHighlightHandler; +import com.tiedup.remake.client.events.LeashProxyClientHandler; +import com.tiedup.remake.client.gltf.GltfAnimationApplier; +import com.tiedup.remake.client.state.ClothesClientCache; +import com.tiedup.remake.client.state.MovementStyleClientState; +import com.tiedup.remake.client.state.PetBedClientState; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.items.base.PoseType; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageEquipment; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import com.tiedup.remake.v2.bondage.movement.MovementStyle; +import com.tiedup.remake.client.animation.context.AnimationContext; +import com.tiedup.remake.client.animation.context.AnimationContextResolver; +import com.tiedup.remake.client.animation.context.RegionBoneMapper; +import com.tiedup.remake.state.HumanChairHelper; +import com.tiedup.remake.state.PlayerBindState; +import java.util.Map; +import java.util.UUID; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.event.entity.player.PlayerEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; +import org.slf4j.Logger; + +/** + * Event handler for player animation tick updates. + * + *

Simplified handler that: + *

    + *
  • Tracks tied/struggling/sneaking state for players
  • + *
  • Plays animations via BondageAnimationManager when state changes
  • + *
  • Handles cleanup on logout/world unload
  • + *
+ * + *

Registered on the FORGE event bus (not MOD bus). + */ +@Mod.EventBusSubscriber( + modid = "tiedup", + value = Dist.CLIENT, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +@OnlyIn(Dist.CLIENT) +public class AnimationTickHandler { + + private static final Logger LOGGER = LogUtils.getLogger(); + + /** Tick counter for periodic cleanup tasks */ + private static int cleanupTickCounter = 0; + + /** + * Client tick event - called every tick on the client. + * Updates animations for all players when their bondage state changes. + */ + @SubscribeEvent + public static void onClientTick(TickEvent.ClientTickEvent event) { + if (event.phase != TickEvent.Phase.END) { + return; + } + + Minecraft mc = Minecraft.getInstance(); + if (mc.level == null || mc.isPaused()) { + return; + } + + // Process pending animations first (retry failed animations for remote players) + PendingAnimationManager.processPending(mc.level); + + // Periodic cleanup of stale cache entries (every 60 seconds = 1200 ticks) + if (++cleanupTickCounter >= 1200) { + cleanupTickCounter = 0; + ClothesClientCache.cleanupStale(); + } + + // Then update all player animations + for (Player player : mc.level.players()) { + if (player instanceof AbstractClientPlayer clientPlayer) { + updatePlayerAnimation(clientPlayer); + } + // Safety: remove stale furniture animations for players no longer on seats + BondageAnimationManager.tickFurnitureSafety(player); + } + } + + /** + * Update animation for a single player. + */ + private static void updatePlayerAnimation(AbstractClientPlayer player) { + // Safety check: skip for removed/dead players + if (player.isRemoved() || !player.isAlive()) { + return; + } + + PlayerBindState state = PlayerBindState.getInstance(player); + UUID uuid = player.getUUID(); + + // Check if player has ANY V2 bondage item equipped (not just ARMS). + // isTiedUp() only checks ARMS, but items on LEGS, HEAD, etc. also need animation. + boolean isTied = state != null && (state.isTiedUp() + || V2EquipmentHelper.hasAnyEquipment(player)); + boolean wasTied = + AnimationStateRegistry.getLastTiedState().getOrDefault(uuid, false); + + // Pet bed animations take priority over bondage animations + if (PetBedClientState.get(uuid) != 0) { + // Lock body rotation to bed facing (prevents camera from rotating the model) + float lockedRot = PetBedClientState.getFacing(uuid); + player.yBodyRot = lockedRot; + player.yBodyRotO = lockedRot; + + // Clamp head rotation to ±50° from body (like vehicle) + float headRot = player.getYHeadRot(); + float clamped = + lockedRot + + net.minecraft.util.Mth.clamp( + net.minecraft.util.Mth.wrapDegrees(headRot - lockedRot), + -50f, + 50f + ); + player.setYHeadRot(clamped); + player.yHeadRotO = clamped; + + AnimationStateRegistry.getLastTiedState().put(uuid, isTied); + return; + } + + // Human chair: clamp 1st-person camera only (body lock handled by MixinLivingEntityBodyRot) + // NO return — animation HUMAN_CHAIR must continue playing below + if (isTied && state != null) { + ItemStack chairBind = state.getEquipment(BodyRegionV2.ARMS); + if (HumanChairHelper.isActive(chairBind)) { + // 1st person only: clamp yRot so player can't look behind + // 3rd person: yRot untouched → camera orbits freely 360° + if ( + player == Minecraft.getInstance().player && + Minecraft.getInstance().options.getCameraType() == + net.minecraft.client.CameraType.FIRST_PERSON + ) { + float lockedRot = HumanChairHelper.getFacing(chairBind); + float camClamped = + lockedRot + + net.minecraft.util.Mth.clamp( + net.minecraft.util.Mth.wrapDegrees( + player.getYRot() - lockedRot + ), + -90f, + 90f + ); + player.setYRot(camClamped); + player.yRotO = + lockedRot + + net.minecraft.util.Mth.clamp( + net.minecraft.util.Mth.wrapDegrees( + player.yRotO - lockedRot + ), + -90f, + 90f + ); + } + } + } + + if (isTied) { + // Resolve V2 equipped items + IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(player); + Map equipped = equipment != null + ? equipment.getAllEquipped() : Map.of(); + + // Resolve ALL V2 items with GLB models and per-item bone ownership + java.util.List v2Items = + RegionBoneMapper.resolveAllV2Items(equipped); + + if (!v2Items.isEmpty()) { + // V2 path: multi-item composite animation + java.util.Set allOwnedParts = RegionBoneMapper.computeAllOwnedParts(v2Items); + MovementStyle activeStyle = MovementStyleClientState.get(player.getUUID()); + AnimationContext context = AnimationContextResolver.resolve(player, state, activeStyle); + GltfAnimationApplier.applyMultiItemV2Animation(player, v2Items, context, allOwnedParts); + // Clear V1 tracking so transition back works + AnimationStateRegistry.getLastAnimId().remove(uuid); + } else { + // V1 fallback + if (GltfAnimationApplier.hasActiveState(player)) { + GltfAnimationApplier.clearV2Animation(player); + } + String animId = buildAnimationId(player, state); + String lastId = AnimationStateRegistry.getLastAnimId().get(uuid); + if (!animId.equals(lastId)) { + boolean success = BondageAnimationManager.playAnimation(player, animId); + if (success) { + AnimationStateRegistry.getLastAnimId().put(uuid, animId); + } + } + } + } else if (wasTied) { + // Was tied, now free - stop all animations + if (GltfAnimationApplier.hasActiveState(player)) { + GltfAnimationApplier.clearV2Animation(player); + } else { + BondageAnimationManager.stopAnimation(player); + } + AnimationStateRegistry.getLastAnimId().remove(uuid); + } + + AnimationStateRegistry.getLastTiedState().put(uuid, isTied); + } + + /** + * Build animation ID from player's current state (V1 path). + */ + private static String buildAnimationId( + Player player, + PlayerBindState state + ) { + ItemStack bind = state.getEquipment(BodyRegionV2.ARMS); + PoseType poseType = PoseType.STANDARD; + + if (bind.getItem() instanceof ItemBind itemBind) { + poseType = itemBind.getPoseType(); + + // Human chair mode: override DOG pose to HUMAN_CHAIR (straight limbs) + poseType = HumanChairHelper.resolveEffectivePose(poseType, bind); + } + + // Derive bound state from V2 regions (works client-side, synced via capability) + boolean armsBound = V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS); + boolean legsBound = V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.LEGS); + + // V1 fallback: if no V2 regions are set but player is tied, derive from ItemBind NBT + if (!armsBound && !legsBound && bind.getItem() instanceof ItemBind) { + armsBound = ItemBind.hasArmsBound(bind); + legsBound = ItemBind.hasLegsBound(bind); + } + + boolean isStruggling = state.isStruggling(); + boolean isSneaking = player.isCrouching(); + boolean isMoving = + player.getDeltaMovement().horizontalDistanceSqr() > 1e-6; + + // Build animation ID with sneak and movement support + return AnimationIdBuilder.build( + poseType, + armsBound, + legsBound, + null, + isStruggling, + true, + isSneaking, + isMoving + ); + } + + /** + * Player logout event - cleanup animation data. + */ + @SubscribeEvent + public static void onPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) { + if (event.getEntity().level().isClientSide()) { + UUID uuid = event.getEntity().getUUID(); + AnimationStateRegistry.getLastTiedState().remove(uuid); + AnimationStateRegistry.getLastAnimId().remove(uuid); + BondageAnimationManager.cleanup(uuid); + GltfAnimationApplier.removeTracking(uuid); + } + } + + /** + * World unload event - clear all animation and cache data. + * FIX: Now also clears client-side caches to prevent memory leaks and stale data. + */ + @SubscribeEvent + public static void onWorldUnload( + net.minecraftforge.event.level.LevelEvent.Unload event + ) { + if (event.getLevel().isClientSide()) { + // Animation state (includes BondageAnimationManager, PendingAnimationManager, + // DogPoseRenderHandler, MCAAnimationTickCache) + // AnimationStateRegistry.clearAll() handles GltfAnimationApplier.clearAll() transitively + AnimationStateRegistry.clearAll(); + + // Non-animation client-side caches + PetBedClientState.clearAll(); + MovementStyleClientState.clearAll(); + com.tiedup.remake.client.state.CollarRegistryClient.clear(); + CellHighlightHandler.clearCache(); + LeashProxyClientHandler.clearAll(); + com.tiedup.remake.client.state.ClientLaborState.clearTask(); + com.tiedup.remake.client.state.ClothesClientCache.clearAll(); + com.tiedup.remake.client.texture.DynamicTextureManager.getInstance().clearAll(); + + // C1: Player bind state client instances (prevents stale Player references) + PlayerBindState.clearClientInstances(); + + // C2: Armor stand bondage data (entity IDs are not stable across worlds) + com.tiedup.remake.entities.armorstand.ArmorStandBondageClientCache.clear(); + + // C3: Furniture GLB model cache (resource-backed, also cleared on F3+T reload) + com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.clear(); + + LOGGER.debug( + "Cleared all animation and cache data due to world unload" + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/client/animation/tick/MCAAnimationTickCache.java b/src/main/java/com/tiedup/remake/client/animation/tick/MCAAnimationTickCache.java new file mode 100644 index 0000000..1ed9265 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/tick/MCAAnimationTickCache.java @@ -0,0 +1,51 @@ +package com.tiedup.remake.client.animation.tick; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Cache for MCA villager animation tick tracking. + * Used by MixinVillagerEntityBaseModelMCA to prevent animations from ticking + * multiple times per game tick. + * + *

This is extracted from the mixin so it can be cleared on world unload + * to prevent memory leaks. + */ +@OnlyIn(Dist.CLIENT) +public final class MCAAnimationTickCache { + + private static final Map lastTickMap = new HashMap<>(); + + private MCAAnimationTickCache() { + // Utility class + } + + /** + * Get the last tick value for an entity. + * @param uuid Entity UUID + * @return Last tick value, or -1 if not cached + */ + public static int getLastTick(UUID uuid) { + return lastTickMap.getOrDefault(uuid, -1); + } + + /** + * Set the last tick value for an entity. + * @param uuid Entity UUID + * @param tick Current tick value + */ + public static void setLastTick(UUID uuid, int tick) { + lastTickMap.put(uuid, tick); + } + + /** + * Clear all cached data. + * Called on world unload to prevent memory leaks. + */ + public static void clear() { + lastTickMap.clear(); + } +} diff --git a/src/main/java/com/tiedup/remake/client/animation/tick/NpcAnimationTickHandler.java b/src/main/java/com/tiedup/remake/client/animation/tick/NpcAnimationTickHandler.java new file mode 100644 index 0000000..132dc1d --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/tick/NpcAnimationTickHandler.java @@ -0,0 +1,198 @@ +package com.tiedup.remake.client.animation.tick; + +import com.tiedup.remake.client.animation.BondageAnimationManager; +import com.tiedup.remake.client.animation.context.AnimationContext; +import com.tiedup.remake.client.animation.context.AnimationContextResolver; +import com.tiedup.remake.client.animation.context.RegionBoneMapper; +import com.tiedup.remake.client.animation.util.AnimationIdBuilder; +import com.tiedup.remake.client.gltf.GltfAnimationApplier; +import com.tiedup.remake.entities.AbstractTiedUpNpc; +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.entities.ai.master.MasterState; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.items.base.PoseType; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageEquipment; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.UUID; +import net.minecraft.client.Minecraft; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Tick handler for NPC (AbstractTiedUpNpc) bondage animations. + * + *

Same pattern as AnimationTickHandler for players, but for loaded + * AbstractTiedUpNpc instances. Tracks last animation ID per NPC UUID and + * triggers BondageAnimationManager.playAnimation() on state changes. + * + *

Extracted from DamselModel.setupAnim() to decouple animation + * triggering from model rendering. + */ +@OnlyIn(Dist.CLIENT) +@Mod.EventBusSubscriber( + modid = "tiedup", + value = Dist.CLIENT, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class NpcAnimationTickHandler { + + /** Track last animation ID per NPC to avoid redundant updates */ + private static final Map lastNpcAnimId = new ConcurrentHashMap<>(); + + /** + * Client tick: update animations for all loaded AbstractTiedUpNpc instances. + */ + @SubscribeEvent + public static void onClientTick(TickEvent.ClientTickEvent event) { + if (event.phase != TickEvent.Phase.END) { + return; + } + + Minecraft mc = Minecraft.getInstance(); + if (mc.level == null || mc.isPaused()) { + return; + } + + for (Entity entity : mc.level.entitiesForRendering()) { + if ( + entity instanceof AbstractTiedUpNpc damsel && + entity.isAlive() && + !entity.isRemoved() + ) { + updateNpcAnimation(damsel); + } + } + } + + /** + * Update animation for a single NPC. + * + *

Dual-layer V2 path: if the highest-priority equipped V2 item has a GLB model, + * uses {@link GltfAnimationApplier#applyV2Animation} which plays a context layer + * (base posture) and an item layer (GLB-driven bones). Sitting and kneeling are + * handled by the context resolver, so the V2 path now covers all postures. + * + *

V1 fallback: if no V2 GLB model is found, falls back to JSON-based + * PlayerAnimator animations via {@link BondageAnimationManager}. + */ + private static void updateNpcAnimation(AbstractTiedUpNpc entity) { + boolean inPose = + entity.isTiedUp() || entity.isSitting() || entity.isKneeling(); + + UUID uuid = entity.getUUID(); + + if (inPose) { + // Resolve V2 equipment map + IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(entity); + Map equipped = equipment != null + ? equipment.getAllEquipped() : Map.of(); + RegionBoneMapper.GlbModelResult glbResult = RegionBoneMapper.resolveWinningItem(equipped); + + if (glbResult != null) { + // V2 path: dual-layer animation with per-item bone ownership + RegionBoneMapper.BoneOwnership ownership = + RegionBoneMapper.computePerItemParts(equipped, glbResult.winningItem()); + AnimationContext context = AnimationContextResolver.resolveNpc(entity); + GltfAnimationApplier.applyV2Animation(entity, glbResult.modelLoc(), + glbResult.animSource(), context, ownership); + lastNpcAnimId.remove(uuid); + } else { + // V1 fallback: JSON-based PlayerAnimator animations + if (GltfAnimationApplier.hasActiveState(entity)) { + GltfAnimationApplier.clearV2Animation(entity); + } + + String animId = buildNpcAnimationId(entity); + String lastId = lastNpcAnimId.get(uuid); + if (!animId.equals(lastId)) { + BondageAnimationManager.playAnimation(entity, animId); + lastNpcAnimId.put(uuid, animId); + } + } + } else { + if (lastNpcAnimId.containsKey(uuid) || GltfAnimationApplier.hasActiveState(entity)) { + if (GltfAnimationApplier.hasActiveState(entity)) { + GltfAnimationApplier.clearV2Animation(entity); + } else { + BondageAnimationManager.stopAnimation(entity); + } + lastNpcAnimId.remove(uuid); + } + } + } + + /** + * Build animation ID for an NPC from its current state (V1 path). + */ + private static String buildNpcAnimationId(AbstractTiedUpNpc entity) { + // Determine position prefix for SIT/KNEEL poses + String positionPrefix = null; + if (entity.isSitting()) { + positionPrefix = "sit"; + } else if (entity.isKneeling()) { + positionPrefix = "kneel"; + } + + net.minecraft.world.item.ItemStack bind = entity.getEquipment(BodyRegionV2.ARMS); + PoseType poseType = PoseType.STANDARD; + boolean hasBind = false; + + if (bind.getItem() instanceof ItemBind itemBind) { + poseType = itemBind.getPoseType(); + hasBind = true; + } + + // Derive bound state from V2 regions (AbstractTiedUpNpc implements IV2EquipmentHolder) + boolean armsBound = V2EquipmentHelper.isRegionOccupied(entity, BodyRegionV2.ARMS); + boolean legsBound = V2EquipmentHelper.isRegionOccupied(entity, BodyRegionV2.LEGS); + + // V1 fallback: if no V2 regions set but NPC has a bind, derive from ItemBind NBT + if (!armsBound && !legsBound && bind.getItem() instanceof ItemBind) { + armsBound = ItemBind.hasArmsBound(bind); + legsBound = ItemBind.hasLegsBound(bind); + } + + boolean isStruggling = entity.isStruggling(); + boolean isSneaking = entity.isCrouching(); + boolean isMoving = + entity.getDeltaMovement().horizontalDistanceSqr() > 1e-6; + + String animId = AnimationIdBuilder.build( + poseType, + armsBound, + legsBound, + positionPrefix, + isStruggling, + hasBind, + isSneaking, + isMoving + ); + + // Master NPC sitting on human chair: use dedicated sitting animation + if ( + entity instanceof EntityMaster masterEntity && + masterEntity.getMasterState() == MasterState.HUMAN_CHAIR && + masterEntity.isSitting() + ) { + animId = "master_chair_sit_idle"; + } + + return animId; + } + + /** + * Clear all NPC animation state. + * Called on world unload to prevent memory leaks. + */ + public static void clearAll() { + lastNpcAnimId.clear(); + } +} diff --git a/src/main/java/com/tiedup/remake/client/animation/util/AnimationIdBuilder.java b/src/main/java/com/tiedup/remake/client/animation/util/AnimationIdBuilder.java new file mode 100644 index 0000000..942d381 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/util/AnimationIdBuilder.java @@ -0,0 +1,273 @@ +package com.tiedup.remake.client.animation.util; + +import com.tiedup.remake.items.base.PoseType; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Utility class for building animation ResourceLocation IDs. + * + *

Centralizes the logic for constructing animation file names. + * Used by BondageAnimationManager, NpcAnimationTickHandler, and AnimationTickHandler. + * + *

Animation naming convention: + *

+ * {poseType}_{bindMode}_{variant}.json
+ *
+ * poseType: tied_up_basic | straitjacket | wrap | latex_sack
+ * bindMode: (empty for FULL) | _arms | _legs
+ * variant: _idle | _struggle | (empty for static)
+ * 
+ * + *

Examples: + *

    + *
  • tiedup:tied_up_basic_idle - STANDARD + FULL + idle
  • + *
  • tiedup:straitjacket_arms_struggle - STRAITJACKET + ARMS + struggle
  • + *
  • tiedup:wrap_idle - WRAP + FULL + idle
  • + *
+ */ +@OnlyIn(Dist.CLIENT) +public final class AnimationIdBuilder { + + private static final String NAMESPACE = "tiedup"; + + // Bind mode suffixes + private static final String SUFFIX_ARMS = "_arms"; + private static final String SUFFIX_LEGS = "_legs"; + + // Variant suffixes + private static final String SUFFIX_IDLE = "_idle"; + private static final String SUFFIX_WALK = "_walk"; + private static final String SUFFIX_STRUGGLE = "_struggle"; + private static final String SUFFIX_SNEAK = "_sneak"; + + private AnimationIdBuilder() { + // Utility class - no instantiation + } + + /** + * Get base animation name from pose type. + * Delegates to {@link PoseType#getAnimationId()}. + * + * @param poseType Pose type + * @return Base name string + */ + public static String getBaseName(PoseType poseType) { + return poseType.getAnimationId(); + } + + /** + * Get suffix for bind mode derived from region flags. + * + * @param armsBound whether ARMS region is occupied + * @param legsBound whether LEGS region is occupied + * @return Suffix string: "" for FULL (both), "_arms" for arms-only, "_legs" for legs-only + */ + public static String getModeSuffix(boolean armsBound, boolean legsBound) { + if (armsBound && legsBound) return ""; // FULL has no suffix + if (armsBound) return SUFFIX_ARMS; + if (legsBound) return SUFFIX_LEGS; + return ""; // neither bound = no suffix (shouldn't happen in practice) + } + + /** + * Get bind type name for SIT/KNEEL animations. + * Delegates to {@link PoseType#getBindTypeName()}. + * + * @param poseType Pose type + * @return Bind type name ("basic", "straitjacket", "wrap", "latex_sack") + */ + public static String getBindTypeName(PoseType poseType) { + return poseType.getBindTypeName(); + } + + // ======================================== + // Unified Build Method + // ======================================== + + /** + * Build animation ID string for entities. + * + *

This method handles all cases: + *

    + *
  • Standing poses: tied_up_basic_idle, straitjacket_struggle, etc.
  • + *
  • Sitting poses: sit_basic_idle, sit_free_idle, etc.
  • + *
  • Kneeling poses: kneel_basic_idle, kneel_wrap_struggle, etc.
  • + *
+ * + * @param poseType The bind pose type (STANDARD, STRAITJACKET, etc.) + * @param armsBound whether ARMS region is occupied + * @param legsBound whether LEGS region is occupied + * @param positionPrefix Position prefix ("sit", "kneel") or null for standing + * @param isStruggling Whether entity is struggling + * @param hasBind Whether entity has a bind equipped + * @return Animation ID string (without namespace) + */ + public static String build( + PoseType poseType, + boolean armsBound, + boolean legsBound, + String positionPrefix, + boolean isStruggling, + boolean hasBind + ) { + return build( + poseType, + armsBound, + legsBound, + positionPrefix, + isStruggling, + hasBind, + false + ); + } + + /** + * Build animation ID string for entities with sneak support. + * + * @param poseType The bind pose type (STANDARD, STRAITJACKET, etc.) + * @param armsBound whether ARMS region is occupied + * @param legsBound whether LEGS region is occupied + * @param positionPrefix Position prefix ("sit", "kneel") or null for standing + * @param isStruggling Whether entity is struggling + * @param hasBind Whether entity has a bind equipped + * @param isSneaking Whether entity is sneaking + * @return Animation ID string (without namespace) + */ + public static String build( + PoseType poseType, + boolean armsBound, + boolean legsBound, + String positionPrefix, + boolean isStruggling, + boolean hasBind, + boolean isSneaking + ) { + return build( + poseType, + armsBound, + legsBound, + positionPrefix, + isStruggling, + hasBind, + isSneaking, + false + ); + } + + /** + * Build animation ID string for entities with sneak and movement support. + * + * @param poseType The bind pose type (STANDARD, STRAITJACKET, etc.) + * @param armsBound whether ARMS region is occupied + * @param legsBound whether LEGS region is occupied + * @param positionPrefix Position prefix ("sit", "kneel") or null for standing + * @param isStruggling Whether entity is struggling + * @param hasBind Whether entity has a bind equipped + * @param isSneaking Whether entity is sneaking + * @param isMoving Whether entity is moving + * @return Animation ID string (without namespace) + */ + public static String build( + PoseType poseType, + boolean armsBound, + boolean legsBound, + String positionPrefix, + boolean isStruggling, + boolean hasBind, + boolean isSneaking, + boolean isMoving + ) { + String sneakSuffix = isSneaking ? SUFFIX_SNEAK : ""; + + // Determine variant suffix based on state priority: struggle > walk > idle + String variantSuffix; + if (isStruggling) { + variantSuffix = SUFFIX_STRUGGLE; + } else if (isMoving && poseType == PoseType.DOG) { + // DOG pose has a walking animation (tied_up_dog_walk.json) + variantSuffix = SUFFIX_WALK; + } else { + variantSuffix = SUFFIX_IDLE; + } + + // SIT or KNEEL pose + if (positionPrefix != null) { + if (!hasBind) { + // No bind: free pose (arms natural) + return positionPrefix + "_free" + sneakSuffix + variantSuffix; + } + + // Has bind + String bindTypeName; + if (legsBound && !armsBound) { + // LEGS-only mode = arms free + bindTypeName = "legs"; + } else { + // FULL or ARMS mode + bindTypeName = getBindTypeName(poseType); + } + return ( + positionPrefix + + "_" + + bindTypeName + + sneakSuffix + + variantSuffix + ); + } + + // Standing pose (no position prefix) + String baseName = getBaseName(poseType); + String modeSuffix = getModeSuffix(armsBound, legsBound); + + // LEGS-only mode: only lock legs, arms are free - no idle/struggle variants needed + if (legsBound && !armsBound) { + return baseName + modeSuffix; + } + + return baseName + modeSuffix + sneakSuffix + variantSuffix; + } + + /** + * Build animation ResourceLocation for SIT or KNEEL pose. + * + * @param posePrefix "sit" or "kneel" + * @param poseType Bind pose type + * @param armsBound whether ARMS region is occupied + * @param legsBound whether LEGS region is occupied + * @param isStruggling Whether entity is struggling + * @return Animation ResourceLocation + */ + public static ResourceLocation buildPositionAnimation( + String posePrefix, + PoseType poseType, + boolean armsBound, + boolean legsBound, + boolean isStruggling + ) { + String bindTypeName; + if (legsBound && !armsBound) { + bindTypeName = "legs"; + } else { + bindTypeName = getBindTypeName(poseType); + } + + String variantSuffix = isStruggling ? SUFFIX_STRUGGLE : SUFFIX_IDLE; + String animationName = posePrefix + "_" + bindTypeName + variantSuffix; + return ResourceLocation.fromNamespaceAndPath(NAMESPACE, animationName); + } + + /** + * Build animation ResourceLocation for SIT or KNEEL pose when NOT bound. + * + * @param posePrefix "sit" or "kneel" + * @return Animation ResourceLocation for free pose + */ + public static ResourceLocation buildFreePositionAnimation( + String posePrefix + ) { + String animationName = posePrefix + "_free" + SUFFIX_IDLE; + return ResourceLocation.fromNamespaceAndPath(NAMESPACE, animationName); + } +} diff --git a/src/main/java/com/tiedup/remake/client/animation/util/DogPoseHelper.java b/src/main/java/com/tiedup/remake/client/animation/util/DogPoseHelper.java new file mode 100644 index 0000000..9b9e451 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/animation/util/DogPoseHelper.java @@ -0,0 +1,149 @@ +package com.tiedup.remake.client.animation.util; + +import net.minecraft.client.model.geom.ModelPart; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Utility class for DOG pose head compensation. + * + *

Problem

+ *

When in DOG pose, the body is rotated -90° pitch (horizontal, face down). + * This makes the head point at the ground. We need to compensate: + *

    + *
  • Head pitch: add -90° offset so head looks forward
  • + *
  • Head yaw: convert to zRot (roll) since yRot axis is sideways
  • + *
+ * + *

Architecture: Players vs NPCs

+ *
+ * ┌─────────────────────────────────────────────────────────────────┐
+ * │                        PLAYERS                                  │
+ * ├─────────────────────────────────────────────────────────────────┤
+ * │ 1. PlayerArmHideEventHandler.onRenderPlayerPre()                │
+ * │    - Offset vertical (-6 model units)                           │
+ * │    - Rotation Y lissée (dogPoseState tracking)                  │
+ * │                                                                 │
+ * │ 2. Animation (PlayerAnimator)                                   │
+ * │    - body.pitch = -90° → appliqué au PoseStack automatiquement  │
+ * │                                                                 │
+ * │ 3. MixinPlayerModel.setupAnim() @TAIL                           │
+ * │    - Uses DogPoseHelper.applyHeadCompensationClamped()          │
+ * └─────────────────────────────────────────────────────────────────┘
+ *
+ * ┌─────────────────────────────────────────────────────────────────┐
+ * │                         NPCs                                    │
+ * ├─────────────────────────────────────────────────────────────────┤
+ * │ 1. EntityDamsel.tick()                                          │
+ * │    - Uses RotationSmoother for Y rotation (10% per tick)        │
+ * │                                                                 │
+ * │ 2. DamselRenderer.setupRotations()                              │
+ * │    - super.setupRotations() (applique rotation Y)               │
+ * │    - Rotation X -90° au PoseStack (APRÈS Y = espace local)      │
+ * │    - Offset vertical (-7 model units)                           │
+ * │                                                                 │
+ * │ 3. DamselModel.setupAnim()                                      │
+ * │    - body.xRot = 0 (évite double rotation)                      │
+ * │    - Uses DogPoseHelper.applyHeadCompensation()                 │
+ * └─────────────────────────────────────────────────────────────────┘
+ * 
+ * + *

Key Differences

+ * + * + * + * + * + * + * + *
AspectPlayersNPCs
Rotation X applicationAuto by PlayerAnimatorManual in setupRotations()
Rotation Y smoothingPlayerArmHideEventHandlerEntityDamsel.tick() via RotationSmoother
Head compensationMixinPlayerModelDamselModel.setupAnim()
Reset body.xRotNot neededYes (prevents double rotation)
Vertical offset-6 model units-7 model units
+ * + *

Usage

+ *

Used by: + *

    + *
  • MixinPlayerModel - for player head compensation
  • + *
  • DamselModel - for NPC head compensation
  • + *
+ * + * @see RotationSmoother for Y rotation smoothing + * @see com.tiedup.remake.mixin.client.MixinPlayerModel + * @see com.tiedup.remake.client.model.DamselModel + */ +@OnlyIn(Dist.CLIENT) +public final class DogPoseHelper { + + private static final float DEG_TO_RAD = (float) Math.PI / 180F; + private static final float HEAD_PITCH_OFFSET = (float) Math.toRadians(-90); + + private DogPoseHelper() { + // Utility class - no instantiation + } + + /** + * Apply head compensation for DOG pose (horizontal body). + * + *

When body is horizontal (-90° pitch), the head needs compensation: + *

    + *
  • xRot: -90° offset + player's up/down look (headPitch)
  • + *
  • yRot: 0 (this axis points sideways when body is horizontal)
  • + *
  • zRot: -headYaw (left/right look, replaces yaw)
  • + *
+ * + * @param head The head ModelPart to modify + * @param hat The hat ModelPart to sync (can be null) + * @param headPitch Player's up/down look angle in degrees + * @param headYaw Head yaw relative to body in degrees (netHeadYaw for NPCs, + * netHeadYaw + rotationDelta for players) + */ + public static void applyHeadCompensation( + ModelPart head, + ModelPart hat, + float headPitch, + float headYaw + ) { + float pitchRad = headPitch * DEG_TO_RAD; + float yawRad = headYaw * DEG_TO_RAD; + + // xRot: base offset (-90° to look forward) + player's up/down look + head.xRot = HEAD_PITCH_OFFSET + pitchRad; + + // yRot: stays at 0 (this axis points sideways when body is horizontal) + head.yRot = 0; + + // zRot: used for left/right look (replaces yaw since body is horizontal) + head.zRot = -yawRad; + + // Sync hat layer if provided + if (hat != null) { + hat.copyFrom(head); + } + } + + /** + * Apply head compensation with yaw clamping. + * + *

Same as {@link #applyHeadCompensation} but clamps yaw to a maximum angle. + * Used for players where yaw range depends on movement state. + * + * @param head The head ModelPart to modify + * @param hat The hat ModelPart to sync (can be null) + * @param headPitch Player's up/down look angle in degrees + * @param headYaw Head yaw relative to body in degrees + * @param maxYaw Maximum allowed yaw angle in degrees + */ + public static void applyHeadCompensationClamped( + ModelPart head, + ModelPart hat, + float headPitch, + float headYaw, + float maxYaw + ) { + // Wrap first so 350° becomes -10° before clamping (fixes full-rotation accumulation) + float clampedYaw = net.minecraft.util.Mth.clamp( + net.minecraft.util.Mth.wrapDegrees(headYaw), + -maxYaw, + maxYaw + ); + applyHeadCompensation(head, hat, headPitch, clampedYaw); + } +} diff --git a/src/main/java/com/tiedup/remake/client/events/BlindfoldRenderEventHandler.java b/src/main/java/com/tiedup/remake/client/events/BlindfoldRenderEventHandler.java new file mode 100644 index 0000000..8c5fab1 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/events/BlindfoldRenderEventHandler.java @@ -0,0 +1,234 @@ +package com.tiedup.remake.client.events; + +import com.mojang.blaze3d.systems.RenderSystem; +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.PlayerBindState; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.HumanoidArm; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.client.event.RenderGuiOverlayEvent; +import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Phase 5: Blindfold Rendering + * + * Based on the original TiedUp! mod (1.12.2) by Yuti & Marl Velius. + * + * The original approach: + * 1. Render blindfold texture over the entire screen (covers everything) + * 2. Manually redraw the hotbar on top of the blindfold + * + * This ensures the hotbar remains visible while the game world is obscured. + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE, + value = Dist.CLIENT +) +public class BlindfoldRenderEventHandler { + + private static final ResourceLocation BLINDFOLD_TEXTURE = + ResourceLocation.fromNamespaceAndPath( + TiedUpMod.MOD_ID, + "textures/misc/blindfolded.png" + ); + + // Vanilla widgets texture (contains hotbar graphics) + private static final ResourceLocation WIDGETS_TEXTURE = + ResourceLocation.fromNamespaceAndPath( + "minecraft", + "textures/gui/widgets.png" + ); + + private static boolean wasBlindfolded = false; + + /** + * Render the blindfold overlay AFTER the hotbar is rendered. + * Then redraw the hotbar on top of the blindfold (original mod approach). + */ + @SubscribeEvent + public static void onRenderGuiPost(RenderGuiOverlayEvent.Post event) { + // Render after HOTBAR (same as original mod) + if (event.getOverlay() != VanillaGuiOverlay.HOTBAR.type()) { + return; + } + + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + + // Safety checks + if (player == null || mc.options.hideGui) { + return; + } + + // Non-hardcore mode: hide blindfold when a GUI screen is open + boolean hardcore = ModConfig.CLIENT.hardcoreBlindfold.get(); + if (!hardcore && mc.screen != null) { + return; + } + + // Get player state + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + return; + } + + boolean isBlindfolded = state.isBlindfolded(); + + // Log state changes only + if (isBlindfolded != wasBlindfolded) { + if (isBlindfolded) { + TiedUpMod.LOGGER.info( + "[BLINDFOLD] Player is now blindfolded - rendering overlay" + ); + } else { + TiedUpMod.LOGGER.info( + "[BLINDFOLD] Player is no longer blindfolded - stopping overlay" + ); + } + wasBlindfolded = isBlindfolded; + } + + // Only render if blindfolded + if (!isBlindfolded) { + return; + } + + try { + int screenWidth = mc.getWindow().getGuiScaledWidth(); + int screenHeight = mc.getWindow().getGuiScaledHeight(); + + // Set opacity: hardcore forces full opacity, otherwise use config + float opacity = hardcore + ? 1.0F + : ModConfig.CLIENT.blindfoldOverlayOpacity + .get() + .floatValue(); + RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, opacity); + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); + + // Step 1: Render the blindfold texture over the entire screen + event + .getGuiGraphics() + .blit( + BLINDFOLD_TEXTURE, + 0, + 0, + 0.0F, + 0.0F, + screenWidth, + screenHeight, + screenWidth, + screenHeight + ); + + // Reset shader color for hotbar + RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F); + + // Step 2: Redraw the hotbar on top of the blindfold (original mod approach) + redrawHotbar( + mc, + event.getGuiGraphics(), + screenWidth, + screenHeight, + player + ); + } catch (RuntimeException e) { + TiedUpMod.LOGGER.error("[BLINDFOLD] Error rendering overlay", e); + } + } + + /** + * Manually redraw the hotbar on top of the blindfold texture. + * Based on the original mod's redrawHotBar() function. + * + * This draws: + * - Hotbar background (182x22 pixels) + * - Selected slot highlight + * - Offhand slot (if item present) + */ + private static void redrawHotbar( + Minecraft mc, + GuiGraphics guiGraphics, + int screenWidth, + int screenHeight, + LocalPlayer player + ) { + // Reset render state + RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F); + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); + + // Center of screen (hotbar is centered) + int centerX = screenWidth / 2; + int hotbarY = screenHeight - 22; // Hotbar is 22 pixels from bottom + + // Draw hotbar background (182 pixels wide, 22 pixels tall) + // Original: this.drawTexturedModalRect(i - 91, sr.getScaledHeight() - 22, 0, 0, 182, 22); + guiGraphics.blit( + WIDGETS_TEXTURE, + centerX - 91, + hotbarY, // Position + 0, + 0, // Texture UV start + 182, + 22 // Size + ); + + // Draw selected slot highlight (24x22 pixels) + // Original: this.drawTexturedModalRect(i - 91 - 1 + entityplayer.inventory.currentItem * 20, ...); + int selectedSlot = player.getInventory().selected; + guiGraphics.blit( + WIDGETS_TEXTURE, + centerX - 91 - 1 + selectedSlot * 20, + hotbarY - 1, // Position (offset by selected slot) + 0, + 22, // Texture UV (highlight texture) + 24, + 22 // Size + ); + + // Draw offhand slot if player has an item in offhand + ItemStack offhandItem = player.getItemInHand(InteractionHand.OFF_HAND); + if (!offhandItem.isEmpty()) { + HumanoidArm offhandSide = player.getMainArm().getOpposite(); + + if (offhandSide == HumanoidArm.LEFT) { + // Offhand on left side + // Original: this.drawTexturedModalRect(i - 91 - 29, sr.getScaledHeight() - 23, 24, 22, 29, 24); + guiGraphics.blit( + WIDGETS_TEXTURE, + centerX - 91 - 29, + hotbarY - 1, // Position + 24, + 22, // Texture UV + 29, + 24 // Size + ); + } else { + // Offhand on right side + // Original: this.drawTexturedModalRect(i + 91, sr.getScaledHeight() - 23, 53, 22, 29, 24); + guiGraphics.blit( + WIDGETS_TEXTURE, + centerX + 91, + hotbarY - 1, // Position + 53, + 22, // Texture UV + 29, + 24 // Size + ); + } + } + + RenderSystem.disableBlend(); + } +} diff --git a/src/main/java/com/tiedup/remake/client/events/CellHighlightHandler.java b/src/main/java/com/tiedup/remake/client/events/CellHighlightHandler.java new file mode 100644 index 0000000..5325a7b --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/events/CellHighlightHandler.java @@ -0,0 +1,323 @@ +package com.tiedup.remake.client.events; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.tiedup.remake.blocks.BlockMarker; +import com.tiedup.remake.blocks.entity.MarkerBlockEntity; +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.cells.MarkerType; +import com.tiedup.remake.client.renderer.CellOutlineRenderer; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.ItemAdminWand; +import java.util.UUID; +import net.minecraft.client.Camera; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.client.event.RenderLevelStageEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Event handler for rendering cell outlines when holding an admin wand. + * + * Phase: Kidnapper Revamp - Cell System + * + * Renders colored outlines around cell positions when: + * - Player is holding an Admin Wand + * - A cell is currently selected in the wand + * + * The outlines help builders visualize which blocks are part of the cell. + * + * Network sync: On dedicated servers, cell data is synced via PacketSyncCellData. + * On integrated servers, direct access to server data is used as a fallback. + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE, + value = Dist.CLIENT +) +public class CellHighlightHandler { + + // Client-side cache of cell data (synced from server via PacketSyncCellData) + private static final java.util.Map syncedCells = + new java.util.concurrent.ConcurrentHashMap<>(); + + // Legacy single-cell cache for backward compatibility + private static CellDataV2 cachedCellData = null; + private static UUID cachedCellId = null; + + /** + * Render cell outlines after translucent blocks. + */ + @SubscribeEvent + public static void onRenderLevelStage(RenderLevelStageEvent event) { + // Only render after translucent stage (so outlines appear on top) + if ( + event.getStage() != + RenderLevelStageEvent.Stage.AFTER_TRANSLUCENT_BLOCKS + ) { + return; + } + + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + + if (player == null) return; + + // Check if player is holding an Admin Wand + ItemStack mainHand = player.getMainHandItem(); + ItemStack offHand = player.getOffhandItem(); + + boolean holdingAdminWand = + mainHand.getItem() instanceof ItemAdminWand || + offHand.getItem() instanceof ItemAdminWand; + + if (!holdingAdminWand) { + cachedCellData = null; + cachedCellId = null; + return; + } + + PoseStack poseStack = event.getPoseStack(); + Camera camera = event.getCamera(); + + // If holding Admin Wand, render nearby structure markers and preview + renderNearbyStructureMarkers(poseStack, camera, player); + renderAdminWandPreview(poseStack, camera, player, mainHand, offHand); + } + + /** + * Render a preview outline showing where the Admin Wand will place a marker. + */ + private static void renderAdminWandPreview( + PoseStack poseStack, + Camera camera, + LocalPlayer player, + ItemStack mainHand, + ItemStack offHand + ) { + // Get the block the player is looking at + net.minecraft.world.phys.HitResult hitResult = + Minecraft.getInstance().hitResult; + if ( + hitResult == null || + hitResult.getType() != net.minecraft.world.phys.HitResult.Type.BLOCK + ) { + return; + } + + net.minecraft.world.phys.BlockHitResult blockHit = + (net.minecraft.world.phys.BlockHitResult) hitResult; + BlockPos targetPos = blockHit.getBlockPos().above(); // Marker goes above the clicked block + + // Get the current marker type from the wand + MarkerType type; + if (mainHand.getItem() instanceof ItemAdminWand) { + type = ItemAdminWand.getCurrentType(mainHand); + } else { + type = ItemAdminWand.getCurrentType(offHand); + } + + Vec3 cameraPos = camera.getPosition(); + float[] color = CellOutlineRenderer.getColorForType(type); + + // Make preview semi-transparent and pulsing + float alpha = + 0.5f + 0.3f * (float) Math.sin(System.currentTimeMillis() / 200.0); + float[] previewColor = { color[0], color[1], color[2], alpha }; + + // Setup rendering (depth test off so preview shows through blocks) + com.mojang.blaze3d.systems.RenderSystem.enableBlend(); + com.mojang.blaze3d.systems.RenderSystem.defaultBlendFunc(); + com.mojang.blaze3d.systems.RenderSystem.disableDepthTest(); + com.mojang.blaze3d.systems.RenderSystem.depthMask(false); + com.mojang.blaze3d.systems.RenderSystem.setShader( + net.minecraft.client.renderer.GameRenderer::getPositionColorShader + ); + + CellOutlineRenderer.renderFilledBlock( + poseStack, + targetPos, + cameraPos, + previewColor + ); + + com.mojang.blaze3d.systems.RenderSystem.depthMask(true); + com.mojang.blaze3d.systems.RenderSystem.enableDepthTest(); + com.mojang.blaze3d.systems.RenderSystem.disableBlend(); + } + + /** + * Render outlines for nearby structure markers (markers without cell IDs). + */ + private static void renderNearbyStructureMarkers( + PoseStack poseStack, + Camera camera, + LocalPlayer player + ) { + Level level = player.level(); + BlockPos playerPos = player.blockPosition(); + Vec3 cameraPos = camera.getPosition(); + + // Collect markers first to check if we need to render anything + java.util.List< + java.util.Map.Entry + > markersToRender = new java.util.ArrayList<>(); + + // Scan in a 32-block radius for structure markers + int radius = 32; + + for (int x = -radius; x <= radius; x++) { + for (int y = -radius / 2; y <= radius / 2; y++) { + for (int z = -radius; z <= radius; z++) { + BlockPos pos = playerPos.offset(x, y, z); + + if ( + level.getBlockState(pos).getBlock() instanceof + BlockMarker + ) { + BlockEntity be = level.getBlockEntity(pos); + if (be instanceof MarkerBlockEntity marker) { + // Only render structure markers (no cell ID) + if (marker.getCellId() == null) { + markersToRender.add( + java.util.Map.entry( + pos, + marker.getMarkerType() + ) + ); + } + } + } + } + } + } + + // Only setup rendering if we have markers to render + if (!markersToRender.isEmpty()) { + // Setup rendering state (depth test off so markers show through blocks) + com.mojang.blaze3d.systems.RenderSystem.enableBlend(); + com.mojang.blaze3d.systems.RenderSystem.defaultBlendFunc(); + com.mojang.blaze3d.systems.RenderSystem.disableDepthTest(); + com.mojang.blaze3d.systems.RenderSystem.depthMask(false); + com.mojang.blaze3d.systems.RenderSystem.setShader( + net.minecraft.client.renderer + .GameRenderer::getPositionColorShader + ); + + for (var entry : markersToRender) { + BlockPos pos = entry.getKey(); + MarkerType type = entry.getValue(); + float[] baseColor = CellOutlineRenderer.getColorForType(type); + // Semi-transparent fill + float[] fillColor = { + baseColor[0], + baseColor[1], + baseColor[2], + 0.4f, + }; + CellOutlineRenderer.renderFilledBlock( + poseStack, + pos, + cameraPos, + fillColor + ); + } + + // Restore rendering state + com.mojang.blaze3d.systems.RenderSystem.depthMask(true); + com.mojang.blaze3d.systems.RenderSystem.enableDepthTest(); + com.mojang.blaze3d.systems.RenderSystem.disableBlend(); + } + } + + /** + * Get cell data on client side. + * First checks the network-synced cache, then falls back to integrated server access. + * + * @param cellId The cell UUID to look up + * @return CellDataV2 if found, null otherwise + */ + private static CellDataV2 getCellDataClient(UUID cellId) { + if (cellId == null) return null; + + // Priority 1: Check network-synced cache (works on dedicated servers) + CellDataV2 synced = syncedCells.get(cellId); + if (synced != null) { + return synced; + } + + // Priority 2: Check legacy single-cell cache + if (cellId.equals(cachedCellId) && cachedCellData != null) { + return cachedCellData; + } + + // Priority 3: On integrated server, access server level directly (fallback) + Minecraft mc = Minecraft.getInstance(); + if (mc.getSingleplayerServer() != null) { + ServerLevel serverLevel = mc.getSingleplayerServer().overworld(); + if (serverLevel != null) { + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + CellDataV2 cell = registry.getCell(cellId); + if (cell != null) { + // Cache for future use + cachedCellId = cellId; + cachedCellData = cell; + return cell; + } + } + } + + // Not found - on dedicated server, packet hasn't arrived yet + return null; + } + + /** + * Update cached cell data (called from network sync - PacketSyncCellData). + * Stores in both the synced map and legacy cache for compatibility. + * + * @param cell The cell data received from server + */ + public static void updateCachedCell(CellDataV2 cell) { + if (cell != null) { + // Store in synced map + syncedCells.put(cell.getId(), cell); + + // Also update legacy cache + cachedCellId = cell.getId(); + cachedCellData = cell; + } + } + + /** + * Remove a cell from the cache (e.g., when cell is deleted). + * + * @param cellId The cell UUID to remove + */ + public static void removeCachedCell(UUID cellId) { + if (cellId != null) { + syncedCells.remove(cellId); + if (cellId.equals(cachedCellId)) { + cachedCellId = null; + cachedCellData = null; + } + } + } + + /** + * Clear all cached cell data. + * Called when disconnecting from server or on dimension change. + */ + public static void clearCache() { + syncedCells.clear(); + cachedCellId = null; + cachedCellData = null; + } +} diff --git a/src/main/java/com/tiedup/remake/client/events/EarplugSoundHandler.java b/src/main/java/com/tiedup/remake/client/events/EarplugSoundHandler.java new file mode 100644 index 0000000..9b31543 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/events/EarplugSoundHandler.java @@ -0,0 +1,91 @@ +package com.tiedup.remake.client.events; + +import com.tiedup.remake.client.MuffledSoundInstance; +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.PlayerBindState; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.resources.sounds.SoundInstance; +import net.minecraft.sounds.SoundSource; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.sound.PlaySoundEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Client-side sound handler for earplugs effect. + * + * When the player has earplugs equipped, all sounds are muffled + * (volume reduced to simulate hearing impairment). + * + * Based on Forge's PlaySoundEvent to intercept and modify sounds. + */ +@OnlyIn(Dist.CLIENT) +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE, + value = Dist.CLIENT +) +public class EarplugSoundHandler { + + /** Pitch modifier to make sounds more muffled (much lower for "underwater" effect) */ + private static final float MUFFLED_PITCH_MODIFIER = 0.6f; + + /** Categories to always let through at normal volume (important UI feedback) */ + private static final SoundSource[] UNAFFECTED_CATEGORIES = { + SoundSource.MASTER, // Master volume controls + SoundSource.MUSIC, // Music is internal, not muffled by earplugs + }; + + /** + * Intercept sound events and muffle them if player has earplugs. + */ + @SubscribeEvent + public static void onPlaySound(PlaySoundEvent event) { + // Get the sound being played + SoundInstance sound = event.getSound(); + if (sound == null) { + return; + } + + // Check if player has earplugs + Minecraft mc = Minecraft.getInstance(); + if (mc == null) { + return; + } + LocalPlayer player = mc.player; + if (player == null) { + return; + } + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null || !state.hasEarplugs()) { + return; + } + + // Check if this sound category should be affected + SoundSource source = sound.getSource(); + for (SoundSource unaffected : UNAFFECTED_CATEGORIES) { + if (source == unaffected) { + return; // Don't muffle this category + } + } + + // Don't wrap already-wrapped sounds (prevent infinite recursion) + if (sound instanceof MuffledSoundInstance) { + return; + } + + // Wrap the sound with our muffling wrapper + // The wrapper delegates to the original but modifies getVolume()/getPitch() + SoundInstance muffledSound = new MuffledSoundInstance( + sound, + ModConfig.CLIENT.earplugVolumeMultiplier.get().floatValue(), + MUFFLED_PITCH_MODIFIER + ); + + event.setSound(muffledSound); + } +} diff --git a/src/main/java/com/tiedup/remake/client/events/EntityCleanupHandler.java b/src/main/java/com/tiedup/remake/client/events/EntityCleanupHandler.java new file mode 100644 index 0000000..44fb94d --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/events/EntityCleanupHandler.java @@ -0,0 +1,71 @@ +package com.tiedup.remake.client.events; + +import com.mojang.logging.LogUtils; +import com.tiedup.remake.client.animation.BondageAnimationManager; +import com.tiedup.remake.client.animation.PendingAnimationManager; +import com.tiedup.remake.core.TiedUpMod; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.event.entity.EntityLeaveLevelEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; +import org.slf4j.Logger; + +/** + * Automatic cleanup handler for entity-related resources. + * + *

This handler automatically cleans up animation layers and pending animations + * when entities leave the world, preventing memory leaks from stale cache entries. + * + *

Phase: Performance & Memory Management + * + *

Previously, cleanup had to be called manually via {@link BondageAnimationManager#cleanup(java.util.UUID)}, + * which was error-prone and could lead to memory leaks if forgotten. + * This handler ensures cleanup happens automatically on entity removal. + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE, + value = Dist.CLIENT +) +public class EntityCleanupHandler { + + private static final Logger LOGGER = LogUtils.getLogger(); + + /** + * Automatically clean up animation resources when an entity leaves the world. + * + *

This event fires when: + *

    + *
  • An entity is removed from the world (killed, despawned, unloaded)
  • + *
  • A player logs out
  • + *
  • A chunk is unloaded and its entities are removed
  • + *
+ * + *

Cleanup includes: + *

    + *
  • Removing animation layers from {@link BondageAnimationManager}
  • + *
  • Removing pending animations from {@link PendingAnimationManager}
  • + *
+ * + * @param event The entity leave level event + */ + @SubscribeEvent + public static void onEntityLeaveLevel(EntityLeaveLevelEvent event) { + // Only process on client side + if (!event.getLevel().isClientSide()) { + return; + } + + // Clean up animation layers + BondageAnimationManager.cleanup(event.getEntity().getUUID()); + + // Clean up pending animation queue + PendingAnimationManager.remove(event.getEntity().getUUID()); + + LOGGER.debug( + "Auto-cleaned animation resources for entity: {} (type: {})", + event.getEntity().getUUID(), + event.getEntity().getClass().getSimpleName() + ); + } +} diff --git a/src/main/java/com/tiedup/remake/client/events/LeashProxyClientHandler.java b/src/main/java/com/tiedup/remake/client/events/LeashProxyClientHandler.java new file mode 100644 index 0000000..15c550f --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/events/LeashProxyClientHandler.java @@ -0,0 +1,156 @@ +package com.tiedup.remake.client.events; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.BindVariant; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.client.Minecraft; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Client-side handler for smooth leash proxy positioning. + * + * FIX: Changed from RenderLevelStageEvent.AFTER_ENTITIES to ClientTickEvent. + * AFTER_ENTITIES positioned the proxy AFTER rendering, causing 1-frame lag. + * ClientTickEvent positions BEFORE rendering for smooth leash display. + * + * Instead of waiting for server position updates (which causes lag), + * this handler repositions the proxy entity locally each tick based + * on the player's current position. + */ +@OnlyIn(Dist.CLIENT) +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + value = Dist.CLIENT, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class LeashProxyClientHandler { + + /** + * Map of player UUID -> proxy entity ID. + * Uses UUID for player (persistent) and entity ID for proxy (runtime). + */ + private static final Map playerToProxy = + new ConcurrentHashMap<>(); + + /** Default Y offset for normal standing pose (neck height) */ + private static final double DEFAULT_Y_OFFSET = 1.3; + + /** Y offset for dogwalk pose (back/hip level) */ + private static final double DOGWALK_Y_OFFSET = 0.35; + + /** + * Handle sync packet from server. + * Called when a player gets leashed or unleashed. + */ + public static void handleSyncPacket( + UUID targetPlayerUUID, + int proxyId, + boolean attach + ) { + if (attach) { + playerToProxy.put(targetPlayerUUID, proxyId); + TiedUpMod.LOGGER.debug( + "[LeashProxyClient] Registered proxy {} for player {}", + proxyId, + targetPlayerUUID + ); + } else { + playerToProxy.remove(targetPlayerUUID); + TiedUpMod.LOGGER.debug( + "[LeashProxyClient] Removed proxy for player {}", + targetPlayerUUID + ); + } + } + + /** + * FIX: Use ClientTickEvent instead of RenderLevelStageEvent.AFTER_ENTITIES. + * This positions proxies BEFORE rendering, eliminating the 1-frame lag + * that caused jittery leash rendering. + */ + @SubscribeEvent + public static void onClientTick(TickEvent.ClientTickEvent event) { + // Only run at end of tick (after player position is updated) + if (event.phase != TickEvent.Phase.END) { + return; + } + + if (playerToProxy.isEmpty()) { + return; + } + + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.level == null || mc.isPaused()) { + return; + } + Level level = mc.level; + + // Reposition each tracked proxy + for (Map.Entry entry : playerToProxy.entrySet()) { + UUID playerUUID = entry.getKey(); + int proxyId = entry.getValue(); + + Player playerEntity = level.getPlayerByUUID(playerUUID); + Entity proxyEntity = level.getEntity(proxyId); + + if (playerEntity != null && proxyEntity != null) { + // FIX: Calculate Y offset based on bind type (dogwalk vs normal) + double yOffset = calculateYOffset(playerEntity); + + // Use current position (interpolation will be handled by renderer) + double x = playerEntity.getX(); + double y = playerEntity.getY() + yOffset; + double z = playerEntity.getZ() - 0.15; + + // Set proxy position + proxyEntity.setPos(x, y, z); + + // Update old positions for smooth interpolation + proxyEntity.xOld = proxyEntity.xo = x; + proxyEntity.yOld = proxyEntity.yo = y; + proxyEntity.zOld = proxyEntity.zo = z; + } + } + } + + /** + * Calculate Y offset based on player's bind type. + * Dogwalk (DOGBINDER) uses lower offset for 4-legged pose. + */ + private static double calculateYOffset(Player player) { + IBondageState state = KidnappedHelper.getKidnappedState(player); + if (state != null && state.isTiedUp()) { + ItemStack bind = state.getEquipment(BodyRegionV2.ARMS); + if ( + !bind.isEmpty() && + bind.getItem() == ModItems.getBind(BindVariant.DOGBINDER) + ) { + return DOGWALK_Y_OFFSET; + } + } + return DEFAULT_Y_OFFSET; + } + + /** + * Clear all tracked proxies. + * Called when disconnecting from server. + */ + public static void clearAll() { + playerToProxy.clear(); + } +} diff --git a/src/main/java/com/tiedup/remake/client/events/SelfBondageInputHandler.java b/src/main/java/com/tiedup/remake/client/events/SelfBondageInputHandler.java new file mode 100644 index 0000000..e2f633d --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/events/SelfBondageInputHandler.java @@ -0,0 +1,179 @@ +package com.tiedup.remake.client.events; + +import com.tiedup.remake.items.base.*; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import com.tiedup.remake.network.selfbondage.PacketSelfBondage; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.event.entity.player.PlayerInteractEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Client-side event handler for self-bondage input. + * + * Intercepts left-click when holding bondage items and + * sends packets continuously to the server to perform self-bondage. + * + * Self-bondage items: + * - Binds (rope, chain, etc.) - Self-tie (requires holding left-click) + * - Gags - Self-gag (if already tied, instant) + * - Blindfolds - Self-blindfold (if already tied, instant) + * - Mittens - Self-mitten (if already tied, instant) + * - Earplugs - Self-earplug (if already tied, instant) + * - Collar - NOT ALLOWED (cannot self-collar) + */ +@Mod.EventBusSubscriber( + modid = "tiedup", + value = Dist.CLIENT, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +@OnlyIn(Dist.CLIENT) +public class SelfBondageInputHandler { + + /** Track if we're currently in self-bondage mode */ + private static boolean isSelfBondageActive = false; + + /** The hand we're using for self-bondage */ + private static InteractionHand activeHand = null; + + /** Tick counter for packet sending interval */ + private static int tickCounter = 0; + + /** Send packet every 4 ticks (5 times per second) for smooth progress */ + private static final int PACKET_INTERVAL = 4; + + /** + * Handle left-click in empty air - START self-bondage. + */ + @SubscribeEvent + public static void onLeftClickEmpty( + PlayerInteractEvent.LeftClickEmpty event + ) { + startSelfBondage(); + } + + /** + * Handle left-click on block - START self-bondage (cancel block breaking). + */ + @SubscribeEvent + public static void onLeftClickBlock( + PlayerInteractEvent.LeftClickBlock event + ) { + if (!event.getLevel().isClientSide()) return; + + ItemStack stack = event.getItemStack(); + if (isSelfBondageItem(stack.getItem())) { + event.setCanceled(true); + startSelfBondage(); + } + } + + /** + * Start self-bondage mode if holding a bondage item. + */ + private static void startSelfBondage() { + LocalPlayer player = Minecraft.getInstance().player; + if (player == null) return; + + // Check main hand first, then off hand + InteractionHand hand = InteractionHand.MAIN_HAND; + ItemStack stack = player.getMainHandItem(); + + if (!isSelfBondageItem(stack.getItem())) { + stack = player.getOffhandItem(); + hand = InteractionHand.OFF_HAND; + + if (!isSelfBondageItem(stack.getItem())) { + return; // No bondage item in either hand + } + } + + // Start self-bondage mode + isSelfBondageActive = true; + activeHand = hand; + tickCounter = 0; + + // Send initial packet immediately + ModNetwork.sendToServer(new PacketSelfBondage(hand)); + } + + /** + * Client tick - continuously send packets while attack button is held. + */ + @SubscribeEvent + public static void onClientTick(TickEvent.ClientTickEvent event) { + if (event.phase != TickEvent.Phase.END) return; + if (!isSelfBondageActive) return; + + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + + // Stop if conditions are no longer valid + if (player == null || mc.screen != null) { + stopSelfBondage(); + return; + } + + // Check if attack button is still held + if (!mc.options.keyAttack.isDown()) { + stopSelfBondage(); + return; + } + + // Check if still holding bondage item in the active hand + ItemStack stack = player.getItemInHand(activeHand); + if (!isSelfBondageItem(stack.getItem())) { + stopSelfBondage(); + return; + } + + // Send packet at interval for continuous progress + tickCounter++; + if (tickCounter >= PACKET_INTERVAL) { + tickCounter = 0; + ModNetwork.sendToServer(new PacketSelfBondage(activeHand)); + } + } + + /** + * Stop self-bondage mode. + */ + private static void stopSelfBondage() { + isSelfBondageActive = false; + activeHand = null; + tickCounter = 0; + } + + /** + * Check if an item supports self-bondage. + * Collar is explicitly excluded. + */ + private static boolean isSelfBondageItem(Item item) { + // Collar cannot be self-equipped (V1 collar guard) + if (item instanceof ItemCollar) { + return false; + } + + // V2 bondage items support self-bondage (left-click hold with tying duration) + if (item instanceof IV2BondageItem) { + return true; + } + + // V1 bondage items (legacy) + return ( + item instanceof ItemBind || + item instanceof ItemGag || + item instanceof ItemBlindfold || + item instanceof ItemMittens || + item instanceof ItemEarplugs + ); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gltf/GlbParser.java b/src/main/java/com/tiedup/remake/client/gltf/GlbParser.java new file mode 100644 index 0000000..be437dc --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gltf/GlbParser.java @@ -0,0 +1,628 @@ +package com.tiedup.remake.client.gltf; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.joml.Matrix4f; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +/** + * Parser for binary .glb (glTF 2.0) files. + * Extracts mesh geometry, skinning data, bone hierarchy, and animations. + * Filters out meshes named "Player". + */ +public final class GlbParser { + + private static final Logger LOGGER = LogManager.getLogger("GltfPipeline"); + + private static final int GLB_MAGIC = 0x46546C67; // "glTF" + private static final int GLB_VERSION = 2; + private static final int CHUNK_JSON = 0x4E4F534A; // "JSON" + private static final int CHUNK_BIN = 0x004E4942; // "BIN\0" + + private GlbParser() {} + + /** + * Parse a .glb file from an InputStream. + * + * @param input the input stream (will be fully read) + * @param debugName name for log messages + * @return parsed GltfData + * @throws IOException if the file is malformed or I/O fails + */ + public static GltfData parse(InputStream input, String debugName) throws IOException { + byte[] allBytes = input.readAllBytes(); + ByteBuffer buf = ByteBuffer.wrap(allBytes).order(ByteOrder.LITTLE_ENDIAN); + + // -- Header -- + int magic = buf.getInt(); + if (magic != GLB_MAGIC) { + throw new IOException("Not a GLB file: " + debugName); + } + int version = buf.getInt(); + if (version != GLB_VERSION) { + throw new IOException("Unsupported GLB version " + version + " in " + debugName); + } + int totalLength = buf.getInt(); + + // -- JSON chunk -- + int jsonChunkLength = buf.getInt(); + int jsonChunkType = buf.getInt(); + if (jsonChunkType != CHUNK_JSON) { + throw new IOException("Expected JSON chunk in " + debugName); + } + byte[] jsonBytes = new byte[jsonChunkLength]; + buf.get(jsonBytes); + String jsonStr = new String(jsonBytes, StandardCharsets.UTF_8); + JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject(); + + // -- BIN chunk -- + ByteBuffer binData = null; + if (buf.hasRemaining()) { + int binChunkLength = buf.getInt(); + int binChunkType = buf.getInt(); + if (binChunkType != CHUNK_BIN) { + throw new IOException("Expected BIN chunk in " + debugName); + } + byte[] binBytes = new byte[binChunkLength]; + buf.get(binBytes); + binData = ByteBuffer.wrap(binBytes).order(ByteOrder.LITTLE_ENDIAN); + } + if (binData == null) { + throw new IOException("No BIN chunk in " + debugName); + } + + JsonArray accessors = root.getAsJsonArray("accessors"); + JsonArray bufferViews = root.getAsJsonArray("bufferViews"); + JsonArray nodes = root.getAsJsonArray("nodes"); + JsonArray meshes = root.getAsJsonArray("meshes"); + + // -- Find skin -- + JsonArray skins = root.getAsJsonArray("skins"); + if (skins == null || skins.size() == 0) { + throw new IOException("No skins found in " + debugName); + } + JsonObject skin = skins.get(0).getAsJsonObject(); + JsonArray skinJoints = skin.getAsJsonArray("joints"); + + // Filter skin joints to only include known deforming bones + List filteredJointNodes = new ArrayList<>(); + int[] skinJointRemap = new int[skinJoints.size()]; // old skin index -> new filtered index + java.util.Arrays.fill(skinJointRemap, -1); + for (int j = 0; j < skinJoints.size(); j++) { + int nodeIdx = skinJoints.get(j).getAsInt(); + JsonObject node = nodes.get(nodeIdx).getAsJsonObject(); + String name = node.has("name") ? node.get("name").getAsString() : "joint_" + j; + if (GltfBoneMapper.isKnownBone(name)) { + skinJointRemap[j] = filteredJointNodes.size(); + filteredJointNodes.add(nodeIdx); + } else { + LOGGER.debug("[GltfPipeline] Skipping non-deforming bone: '{}' (node {})", name, nodeIdx); + } + } + + int jointCount = filteredJointNodes.size(); + String[] jointNames = new String[jointCount]; + int[] parentJointIndices = new int[jointCount]; + Quaternionf[] restRotations = new Quaternionf[jointCount]; + Vector3f[] restTranslations = new Vector3f[jointCount]; + + // Map node index -> joint index (filtered) + int[] nodeToJoint = new int[nodes.size()]; + java.util.Arrays.fill(nodeToJoint, -1); + for (int j = 0; j < jointCount; j++) { + int nodeIdx = filteredJointNodes.get(j); + nodeToJoint[nodeIdx] = j; + } + + // Read joint names, rest pose, and build parent mapping + java.util.Arrays.fill(parentJointIndices, -1); + for (int j = 0; j < jointCount; j++) { + int nodeIdx = filteredJointNodes.get(j); + JsonObject node = nodes.get(nodeIdx).getAsJsonObject(); + + jointNames[j] = node.has("name") ? node.get("name").getAsString() : "joint_" + j; + + // Rest rotation + if (node.has("rotation")) { + JsonArray r = node.getAsJsonArray("rotation"); + restRotations[j] = new Quaternionf( + r.get(0).getAsFloat(), r.get(1).getAsFloat(), + r.get(2).getAsFloat(), r.get(3).getAsFloat() + ); + } else { + restRotations[j] = new Quaternionf(); // identity + } + + // Rest translation + if (node.has("translation")) { + JsonArray t = node.getAsJsonArray("translation"); + restTranslations[j] = new Vector3f( + t.get(0).getAsFloat(), t.get(1).getAsFloat(), t.get(2).getAsFloat() + ); + } else { + restTranslations[j] = new Vector3f(); + } + } + + // Build parent indices by traversing node children + for (int ni = 0; ni < nodes.size(); ni++) { + JsonObject node = nodes.get(ni).getAsJsonObject(); + if (node.has("children")) { + int parentJoint = nodeToJoint[ni]; + JsonArray children = node.getAsJsonArray("children"); + for (JsonElement child : children) { + int childNodeIdx = child.getAsInt(); + int childJoint = nodeToJoint[childNodeIdx]; + if (childJoint >= 0 && parentJoint >= 0) { + parentJointIndices[childJoint] = parentJoint; + } + } + } + } + + // -- Inverse Bind Matrices -- + // IBM accessor is indexed by original skin joint order, so we pick the filtered entries + Matrix4f[] inverseBindMatrices = new Matrix4f[jointCount]; + if (skin.has("inverseBindMatrices")) { + int ibmAccessor = skin.get("inverseBindMatrices").getAsInt(); + float[] ibmData = GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, ibmAccessor); + for (int origJ = 0; origJ < skinJoints.size(); origJ++) { + int newJ = skinJointRemap[origJ]; + if (newJ >= 0) { + inverseBindMatrices[newJ] = new Matrix4f(); + inverseBindMatrices[newJ].set(ibmData, origJ * 16); + } + } + } else { + for (int j = 0; j < jointCount; j++) { + inverseBindMatrices[j] = new Matrix4f(); // identity + } + } + + // -- Find mesh (ignore "Player" mesh, take LAST non-Player) -- + // WORKAROUND: Takes the LAST non-Player mesh because modelers may leave prototype meshes + // in the .glb. Revert to first non-Player mesh once modeler workflow is standardized. + int targetMeshIdx = -1; + if (meshes != null) { + for (int mi = 0; mi < meshes.size(); mi++) { + JsonObject mesh = meshes.get(mi).getAsJsonObject(); + String meshName = mesh.has("name") ? mesh.get("name").getAsString() : ""; + if (!"Player".equals(meshName)) { + targetMeshIdx = mi; + } + } + } + + // -- Parse root material names (for tint channel detection) -- + String[] materialNames = GlbParserUtils.parseMaterialNames(root); + + // Mesh data: empty arrays if no mesh found (animation-only GLB) + float[] positions; + float[] normals; + float[] texCoords; + int[] indices; + int vertexCount; + int[] meshJoints; + float[] weights; + List parsedPrimitives = new ArrayList<>(); + + if (targetMeshIdx >= 0) { + JsonObject mesh = meshes.get(targetMeshIdx).getAsJsonObject(); + JsonArray primitives = mesh.getAsJsonArray("primitives"); + + // -- Accumulate vertex data from ALL primitives -- + List allPositions = new ArrayList<>(); + List allNormals = new ArrayList<>(); + List allTexCoords = new ArrayList<>(); + List allJoints = new ArrayList<>(); + List allWeights = new ArrayList<>(); + int cumulativeVertexCount = 0; + + for (int pi = 0; pi < primitives.size(); pi++) { + JsonObject primitive = primitives.get(pi).getAsJsonObject(); + JsonObject attributes = primitive.getAsJsonObject("attributes"); + + // -- Read this primitive's vertex data -- + float[] primPositions = GlbParserUtils.readFloatAccessor( + accessors, bufferViews, binData, + attributes.get("POSITION").getAsInt() + ); + float[] primNormals = attributes.has("NORMAL") + ? GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, attributes.get("NORMAL").getAsInt()) + : new float[primPositions.length]; + float[] primTexCoords = attributes.has("TEXCOORD_0") + ? GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, attributes.get("TEXCOORD_0").getAsInt()) + : new float[primPositions.length / 3 * 2]; + + int primVertexCount = primPositions.length / 3; + + // -- Read this primitive's indices (offset by cumulative vertex count) -- + int[] primIndices; + if (primitive.has("indices")) { + primIndices = GlbParserUtils.readIntAccessor( + accessors, bufferViews, binData, + primitive.get("indices").getAsInt() + ); + } else { + // Non-indexed: generate sequential indices + primIndices = new int[primVertexCount]; + for (int i = 0; i < primVertexCount; i++) primIndices[i] = i; + } + + // Offset indices by cumulative vertex count from prior primitives + if (cumulativeVertexCount > 0) { + for (int i = 0; i < primIndices.length; i++) { + primIndices[i] += cumulativeVertexCount; + } + } + + // -- Read skinning attributes for this primitive -- + int[] primJoints = new int[primVertexCount * 4]; + float[] primWeights = new float[primVertexCount * 4]; + + if (attributes.has("JOINTS_0")) { + primJoints = GlbParserUtils.readIntAccessor( + accessors, bufferViews, binData, + attributes.get("JOINTS_0").getAsInt() + ); + // Remap vertex joint indices from original skin order to filtered order + for (int i = 0; i < primJoints.length; i++) { + int origIdx = primJoints[i]; + if (origIdx >= 0 && origIdx < skinJointRemap.length) { + primJoints[i] = skinJointRemap[origIdx] >= 0 ? skinJointRemap[origIdx] : 0; + } else { + primJoints[i] = 0; + } + } + } + if (attributes.has("WEIGHTS_0")) { + primWeights = GlbParserUtils.readFloatAccessor( + accessors, bufferViews, binData, + attributes.get("WEIGHTS_0").getAsInt() + ); + } + + // -- Resolve material name and tint channel -- + String matName = null; + if (primitive.has("material")) { + int matIdx = primitive.get("material").getAsInt(); + if (matIdx >= 0 && matIdx < materialNames.length) { + matName = materialNames[matIdx]; + } + } + boolean isTintable = matName != null && matName.startsWith("tintable_"); + String tintChannel = isTintable ? matName : null; + + parsedPrimitives.add(new GltfData.Primitive(primIndices, matName, isTintable, tintChannel)); + + allPositions.add(primPositions); + allNormals.add(primNormals); + allTexCoords.add(primTexCoords); + allJoints.add(primJoints); + allWeights.add(primWeights); + cumulativeVertexCount += primVertexCount; + } + + // -- Flatten accumulated data into single arrays -- + vertexCount = cumulativeVertexCount; + positions = GlbParserUtils.flattenFloats(allPositions); + normals = GlbParserUtils.flattenFloats(allNormals); + texCoords = GlbParserUtils.flattenFloats(allTexCoords); + meshJoints = GlbParserUtils.flattenInts(allJoints); + weights = GlbParserUtils.flattenFloats(allWeights); + + // Build union of all primitive indices (for backward-compat indices() accessor) + int totalIndices = 0; + for (GltfData.Primitive p : parsedPrimitives) totalIndices += p.indices().length; + indices = new int[totalIndices]; + int offset = 0; + for (GltfData.Primitive p : parsedPrimitives) { + System.arraycopy(p.indices(), 0, indices, offset, p.indices().length); + offset += p.indices().length; + } + } else { + // Animation-only GLB: no mesh data + LOGGER.info("[GltfPipeline] No mesh found in '{}' (animation-only GLB)", debugName); + positions = new float[0]; + normals = new float[0]; + texCoords = new float[0]; + indices = new int[0]; + vertexCount = 0; + meshJoints = new int[0]; + weights = new float[0]; + } + + // -- Read ALL animations -- + Map allClips = new LinkedHashMap<>(); + JsonArray animations = root.getAsJsonArray("animations"); + if (animations != null) { + for (int ai = 0; ai < animations.size(); ai++) { + JsonObject anim = animations.get(ai).getAsJsonObject(); + String animName = anim.has("name") ? anim.get("name").getAsString() : "animation_" + ai; + // Strip the "ArmatureName|" prefix if present (Blender convention) + if (animName.contains("|")) { + animName = animName.substring(animName.lastIndexOf('|') + 1); + } + GltfData.AnimationClip clip = parseAnimation(anim, accessors, bufferViews, binData, nodeToJoint, jointCount); + if (clip != null) { + allClips.put(animName, clip); + } + } + } + + // Default animation = first clip (for backward compat) + GltfData.AnimationClip animClip = allClips.isEmpty() ? null : allClips.values().iterator().next(); + + LOGGER.info("[GltfPipeline] Parsed '{}': vertices={}, indices={}, joints={}, animations={}", + debugName, vertexCount, indices.length, jointCount, allClips.size()); + for (String name : allClips.keySet()) { + LOGGER.debug("[GltfPipeline] animation: '{}'", name); + } + for (int j = 0; j < jointCount; j++) { + Quaternionf rq = restRotations[j]; + Vector3f rt = restTranslations[j]; + LOGGER.debug(String.format("[GltfPipeline] joint[%d] = '%s', parent=%d, restQ=(%.3f,%.3f,%.3f,%.3f) restT=(%.3f,%.3f,%.3f)", + j, jointNames[j], parentJointIndices[j], + rq.x, rq.y, rq.z, rq.w, rt.x, rt.y, rt.z)); + } + + // Log animation translation channels for default clip (BEFORE MC conversion) + if (animClip != null && animClip.translations() != null) { + Vector3f[][] animTrans = animClip.translations(); + for (int j = 0; j < jointCount; j++) { + if (j < animTrans.length && animTrans[j] != null) { + Vector3f at = animTrans[j][0]; // first frame + Vector3f rt = restTranslations[j]; + LOGGER.debug(String.format( + "[GltfPipeline] joint[%d] '%s' has ANIM TRANSLATION: (%.4f,%.4f,%.4f) vs rest (%.4f,%.4f,%.4f) delta=(%.4f,%.4f,%.4f)", + j, jointNames[j], + at.x, at.y, at.z, + rt.x, rt.y, rt.z, + at.x - rt.x, at.y - rt.y, at.z - rt.z)); + } + } + } else { + LOGGER.debug("[GltfPipeline] Default animation has NO translation channels"); + } + + // Save raw glTF rotations BEFORE coordinate conversion (for pose converter) + // MC model space faces +Z just like glTF, so delta quaternions for ModelPart + // rotation should be computed from raw glTF data, not from the converted data. + Quaternionf[] rawRestRotations = new Quaternionf[jointCount]; + for (int j = 0; j < jointCount; j++) { + rawRestRotations[j] = new Quaternionf(restRotations[j]); + } + + // Build raw copies of ALL animation clips (before MC conversion) + Map rawAllClips = new LinkedHashMap<>(); + for (Map.Entry entry : allClips.entrySet()) { + rawAllClips.put(entry.getKey(), GlbParserUtils.deepCopyClip(entry.getValue())); + } + GltfData.AnimationClip rawAnimClip = rawAllClips.isEmpty() ? null : rawAllClips.values().iterator().next(); + + // Convert from glTF coordinate system (Y-up, faces +Z) to MC (Y-up, faces -Z) + // This is a 180° rotation around Y: negate X and Z for all spatial data + // Convert ALL animation clips to MC space + for (GltfData.AnimationClip clip : allClips.values()) { + GlbParserUtils.convertAnimationToMinecraftSpace(clip, jointCount); + } + convertToMinecraftSpace(positions, normals, restTranslations, restRotations, + inverseBindMatrices, null, jointCount); // pass null — clips already converted above + LOGGER.debug("[GltfPipeline] Converted all data to Minecraft coordinate space"); + + return new GltfData( + positions, normals, texCoords, + indices, meshJoints, weights, + jointNames, parentJointIndices, + inverseBindMatrices, + restRotations, restTranslations, + rawRestRotations, + rawAnimClip, + animClip, + allClips, rawAllClips, + parsedPrimitives, + vertexCount, jointCount + ); + } + + // ---- Animation parsing ---- + + private static GltfData.AnimationClip parseAnimation( + JsonObject animation, + JsonArray accessors, JsonArray bufferViews, + ByteBuffer binData, + int[] nodeToJoint, int jointCount + ) { + JsonArray channels = animation.getAsJsonArray("channels"); + JsonArray samplers = animation.getAsJsonArray("samplers"); + + // Collect rotation and translation channels + List rotJoints = new ArrayList<>(); + List rotTimestamps = new ArrayList<>(); + List rotValues = new ArrayList<>(); + + List transJoints = new ArrayList<>(); + List transTimestamps = new ArrayList<>(); + List transValues = new ArrayList<>(); + + for (JsonElement chElem : channels) { + JsonObject channel = chElem.getAsJsonObject(); + JsonObject target = channel.getAsJsonObject("target"); + String path = target.get("path").getAsString(); + + int nodeIdx = target.get("node").getAsInt(); + if (nodeIdx >= nodeToJoint.length || nodeToJoint[nodeIdx] < 0) continue; + int jointIdx = nodeToJoint[nodeIdx]; + + int samplerIdx = channel.get("sampler").getAsInt(); + JsonObject sampler = samplers.get(samplerIdx).getAsJsonObject(); + + float[] times = GlbParserUtils.readFloatAccessor( + accessors, bufferViews, binData, + sampler.get("input").getAsInt() + ); + + if ("rotation".equals(path)) { + float[] quats = GlbParserUtils.readFloatAccessor( + accessors, bufferViews, binData, + sampler.get("output").getAsInt() + ); + Quaternionf[] qArr = new Quaternionf[times.length]; + for (int i = 0; i < times.length; i++) { + qArr[i] = new Quaternionf( + quats[i * 4], quats[i * 4 + 1], + quats[i * 4 + 2], quats[i * 4 + 3] + ); + } + rotJoints.add(jointIdx); + rotTimestamps.add(times); + rotValues.add(qArr); + } else if ("translation".equals(path)) { + float[] vecs = GlbParserUtils.readFloatAccessor( + accessors, bufferViews, binData, + sampler.get("output").getAsInt() + ); + Vector3f[] tArr = new Vector3f[times.length]; + for (int i = 0; i < times.length; i++) { + tArr[i] = new Vector3f( + vecs[i * 3], vecs[i * 3 + 1], vecs[i * 3 + 2] + ); + } + transJoints.add(jointIdx); + transTimestamps.add(times); + transValues.add(tArr); + } + } + + if (rotJoints.isEmpty() && transJoints.isEmpty()) return null; + + // Use the first available channel's timestamps as reference + float[] timestamps = !rotTimestamps.isEmpty() + ? rotTimestamps.get(0) + : transTimestamps.get(0); + int frameCount = timestamps.length; + + // Build per-joint rotation arrays (null if no animation for that joint) + Quaternionf[][] rotations = new Quaternionf[jointCount][]; + for (int i = 0; i < rotJoints.size(); i++) { + int jIdx = rotJoints.get(i); + Quaternionf[] vals = rotValues.get(i); + rotations[jIdx] = new Quaternionf[frameCount]; + for (int f = 0; f < frameCount; f++) { + rotations[jIdx][f] = f < vals.length ? vals[f] : vals[vals.length - 1]; + } + } + + // Build per-joint translation arrays (null if no animation for that joint) + Vector3f[][] translations = new Vector3f[jointCount][]; + for (int i = 0; i < transJoints.size(); i++) { + int jIdx = transJoints.get(i); + Vector3f[] vals = transValues.get(i); + translations[jIdx] = new Vector3f[frameCount]; + for (int f = 0; f < frameCount; f++) { + translations[jIdx][f] = f < vals.length + ? new Vector3f(vals[f]) + : new Vector3f(vals[vals.length - 1]); + } + } + + // Log translation channels found + if (!transJoints.isEmpty()) { + LOGGER.debug("[GltfPipeline] Animation has {} translation channel(s)", + transJoints.size()); + } + + return new GltfData.AnimationClip(timestamps, rotations, translations, frameCount); + } + + // ---- Coordinate system conversion ---- + + /** + * Convert all spatial data from glTF space to MC model-def space. + * The Blender-exported character faces -Z in glTF, same as MC model-def. + * Only X (right→left) and Y (up→down) differ between the two spaces. + * Equivalent to a 180° rotation around Z: negate X and Y components. + * + * For positions/normals/translations: (x,y,z) → (-x, -y, z) + * For quaternions: (x,y,z,w) → (-x, -y, z, w) (conjugation by 180° Z) + * For matrices: M → C * M * C where C = diag(-1, -1, 1, 1) + */ + private static void convertToMinecraftSpace( + float[] positions, float[] normals, + Vector3f[] restTranslations, Quaternionf[] restRotations, + Matrix4f[] inverseBindMatrices, + GltfData.AnimationClip animClip, int jointCount + ) { + // Vertex positions: negate X and Y + for (int i = 0; i < positions.length; i += 3) { + positions[i] = -positions[i]; // X + positions[i + 1] = -positions[i + 1]; // Y + } + + // Vertex normals: negate X and Y + for (int i = 0; i < normals.length; i += 3) { + normals[i] = -normals[i]; + normals[i + 1] = -normals[i + 1]; + } + + // Rest translations: negate X and Y + for (Vector3f t : restTranslations) { + t.x = -t.x; + t.y = -t.y; + } + + // Rest rotations: conjugate by 180° Z = negate qx and qy + for (Quaternionf q : restRotations) { + q.x = -q.x; + q.y = -q.y; + } + + // Inverse bind matrices: C * M * C where C = diag(-1, -1, 1) + Matrix4f C = new Matrix4f().scaling(-1, -1, 1); + Matrix4f temp = new Matrix4f(); + for (Matrix4f ibm : inverseBindMatrices) { + temp.set(C).mul(ibm).mul(C); + ibm.set(temp); + } + + // Animation quaternions: same conjugation + if (animClip != null) { + Quaternionf[][] rotations = animClip.rotations(); + for (int j = 0; j < jointCount; j++) { + if (j < rotations.length && rotations[j] != null) { + for (Quaternionf q : rotations[j]) { + q.x = -q.x; + q.y = -q.y; + } + } + } + + // Animation translations: negate X and Y (same as rest translations) + Vector3f[][] translations = animClip.translations(); + if (translations != null) { + for (int j = 0; j < jointCount; j++) { + if (j < translations.length && translations[j] != null) { + for (Vector3f t : translations[j]) { + t.x = -t.x; + t.y = -t.y; + } + } + } + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/client/gltf/GlbParserUtils.java b/src/main/java/com/tiedup/remake/client/gltf/GlbParserUtils.java new file mode 100644 index 0000000..e5e791b --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gltf/GlbParserUtils.java @@ -0,0 +1,253 @@ +package com.tiedup.remake.client.gltf; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.nio.ByteBuffer; +import java.util.List; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +/** + * Shared stateless utilities for parsing binary glTF (.glb) files. + * + *

These methods are used by both {@link GlbParser} (single-armature bondage meshes) + * and {@link com.tiedup.remake.v2.furniture.client.FurnitureGlbParser FurnitureGlbParser} + * (multi-armature furniture meshes). Extracted to eliminate ~160 lines of verbatim + * duplication between the two parsers.

+ * + *

All methods are pure functions (no state, no side effects).

+ */ +public final class GlbParserUtils { + + // glTF component type constants + public static final int BYTE = 5120; + public static final int UNSIGNED_BYTE = 5121; + public static final int SHORT = 5122; + public static final int UNSIGNED_SHORT = 5123; + public static final int UNSIGNED_INT = 5125; + public static final int FLOAT = 5126; + + private GlbParserUtils() {} + + // ---- Material name parsing ---- + + /** + * Parse the root "materials" array and extract each material's "name" field. + * Returns an empty array if no materials are present. + */ + public static String[] parseMaterialNames(JsonObject root) { + if (!root.has("materials") || !root.get("materials").isJsonArray()) { + return new String[0]; + } + JsonArray materials = root.getAsJsonArray("materials"); + String[] names = new String[materials.size()]; + for (int i = 0; i < materials.size(); i++) { + JsonObject mat = materials.get(i).getAsJsonObject(); + names[i] = mat.has("name") ? mat.get("name").getAsString() : null; + } + return names; + } + + // ---- Array flattening utilities ---- + + public static float[] flattenFloats(List arrays) { + int total = 0; + for (float[] a : arrays) total += a.length; + float[] result = new float[total]; + int offset = 0; + for (float[] a : arrays) { + System.arraycopy(a, 0, result, offset, a.length); + offset += a.length; + } + return result; + } + + public static int[] flattenInts(List arrays) { + int total = 0; + for (int[] a : arrays) total += a.length; + int[] result = new int[total]; + int offset = 0; + for (int[] a : arrays) { + System.arraycopy(a, 0, result, offset, a.length); + offset += a.length; + } + return result; + } + + // ---- Accessor reading utilities ---- + + public static float[] readFloatAccessor( + JsonArray accessors, JsonArray bufferViews, + ByteBuffer binData, int accessorIdx + ) { + JsonObject accessor = accessors.get(accessorIdx).getAsJsonObject(); + int count = accessor.get("count").getAsInt(); + int componentType = accessor.get("componentType").getAsInt(); + String type = accessor.get("type").getAsString(); + int components = typeComponents(type); + + int bvIdx = accessor.get("bufferView").getAsInt(); + JsonObject bv = bufferViews.get(bvIdx).getAsJsonObject(); + int byteOffset = (bv.has("byteOffset") ? bv.get("byteOffset").getAsInt() : 0) + + (accessor.has("byteOffset") ? accessor.get("byteOffset").getAsInt() : 0); + int byteStride = bv.has("byteStride") ? bv.get("byteStride").getAsInt() : 0; + + int totalElements = count * components; + float[] result = new float[totalElements]; + + int componentSize = componentByteSize(componentType); + int stride = byteStride > 0 ? byteStride : components * componentSize; + + for (int i = 0; i < count; i++) { + int pos = byteOffset + i * stride; + for (int c = 0; c < components; c++) { + binData.position(pos + c * componentSize); + result[i * components + c] = readComponentAsFloat(binData, componentType); + } + } + + return result; + } + + public static int[] readIntAccessor( + JsonArray accessors, JsonArray bufferViews, + ByteBuffer binData, int accessorIdx + ) { + JsonObject accessor = accessors.get(accessorIdx).getAsJsonObject(); + int count = accessor.get("count").getAsInt(); + int componentType = accessor.get("componentType").getAsInt(); + String type = accessor.get("type").getAsString(); + int components = typeComponents(type); + + int bvIdx = accessor.get("bufferView").getAsInt(); + JsonObject bv = bufferViews.get(bvIdx).getAsJsonObject(); + int byteOffset = (bv.has("byteOffset") ? bv.get("byteOffset").getAsInt() : 0) + + (accessor.has("byteOffset") ? accessor.get("byteOffset").getAsInt() : 0); + int byteStride = bv.has("byteStride") ? bv.get("byteStride").getAsInt() : 0; + + int totalElements = count * components; + int[] result = new int[totalElements]; + + int componentSize = componentByteSize(componentType); + int stride = byteStride > 0 ? byteStride : components * componentSize; + + for (int i = 0; i < count; i++) { + int pos = byteOffset + i * stride; + for (int c = 0; c < components; c++) { + binData.position(pos + c * componentSize); + result[i * components + c] = readComponentAsInt(binData, componentType); + } + } + + return result; + } + + public static float readComponentAsFloat(ByteBuffer buf, int componentType) { + return switch (componentType) { + case FLOAT -> buf.getFloat(); + case BYTE -> buf.get() / 127.0f; + case UNSIGNED_BYTE -> (buf.get() & 0xFF) / 255.0f; + case SHORT -> buf.getShort() / 32767.0f; + case UNSIGNED_SHORT -> (buf.getShort() & 0xFFFF) / 65535.0f; + case UNSIGNED_INT -> (buf.getInt() & 0xFFFFFFFFL) / (float) 0xFFFFFFFFL; + default -> throw new IllegalArgumentException("Unknown component type: " + componentType); + }; + } + + public static int readComponentAsInt(ByteBuffer buf, int componentType) { + return switch (componentType) { + case BYTE -> buf.get(); + case UNSIGNED_BYTE -> buf.get() & 0xFF; + case SHORT -> buf.getShort(); + case UNSIGNED_SHORT -> buf.getShort() & 0xFFFF; + case UNSIGNED_INT -> buf.getInt(); + case FLOAT -> (int) buf.getFloat(); + default -> throw new IllegalArgumentException("Unknown component type: " + componentType); + }; + } + + public static int typeComponents(String type) { + return switch (type) { + case "SCALAR" -> 1; + case "VEC2" -> 2; + case "VEC3" -> 3; + case "VEC4" -> 4; + case "MAT4" -> 16; + default -> throw new IllegalArgumentException("Unknown accessor type: " + type); + }; + } + + public static int componentByteSize(int componentType) { + return switch (componentType) { + case BYTE, UNSIGNED_BYTE -> 1; + case SHORT, UNSIGNED_SHORT -> 2; + case UNSIGNED_INT, FLOAT -> 4; + default -> throw new IllegalArgumentException("Unknown component type: " + componentType); + }; + } + + // ---- Deep-copy utility ---- + + /** + * Deep-copy an AnimationClip (preserves original data before MC conversion). + */ + public static GltfData.AnimationClip deepCopyClip(GltfData.AnimationClip clip) { + Quaternionf[][] rawRotations = new Quaternionf[clip.rotations().length][]; + for (int j = 0; j < clip.rotations().length; j++) { + if (clip.rotations()[j] != null) { + rawRotations[j] = new Quaternionf[clip.rotations()[j].length]; + for (int f = 0; f < clip.rotations()[j].length; f++) { + rawRotations[j][f] = new Quaternionf(clip.rotations()[j][f]); + } + } + } + Vector3f[][] rawTranslations = null; + if (clip.translations() != null) { + rawTranslations = new Vector3f[clip.translations().length][]; + for (int j = 0; j < clip.translations().length; j++) { + if (clip.translations()[j] != null) { + rawTranslations[j] = new Vector3f[clip.translations()[j].length]; + for (int f = 0; f < clip.translations()[j].length; f++) { + rawTranslations[j][f] = new Vector3f(clip.translations()[j][f]); + } + } + } + } + return new GltfData.AnimationClip( + clip.timestamps().clone(), rawRotations, rawTranslations, + clip.frameCount() + ); + } + + // ---- Coordinate system conversion ---- + + /** + * Convert an animation clip's rotations and translations to MC space. + * Negate qx/qy for rotations and negate tx/ty for translations. + */ + public static void convertAnimationToMinecraftSpace(GltfData.AnimationClip clip, int jointCount) { + if (clip == null) return; + + Quaternionf[][] rotations = clip.rotations(); + for (int j = 0; j < jointCount; j++) { + if (j < rotations.length && rotations[j] != null) { + for (Quaternionf q : rotations[j]) { + q.x = -q.x; + q.y = -q.y; + } + } + } + + Vector3f[][] translations = clip.translations(); + if (translations != null) { + for (int j = 0; j < jointCount; j++) { + if (j < translations.length && translations[j] != null) { + for (Vector3f t : translations[j]) { + t.x = -t.x; + t.y = -t.y; + } + } + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfAnimationApplier.java b/src/main/java/com/tiedup/remake/client/gltf/GltfAnimationApplier.java new file mode 100644 index 0000000..f28ef0c --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfAnimationApplier.java @@ -0,0 +1,450 @@ +package com.tiedup.remake.client.gltf; + +import com.tiedup.remake.client.animation.BondageAnimationManager; +import com.tiedup.remake.client.animation.context.AnimationContext; +import com.tiedup.remake.client.animation.context.ContextAnimationFactory; +import com.tiedup.remake.client.animation.context.GlbAnimationResolver; +import com.tiedup.remake.client.animation.context.RegionBoneMapper; +import dev.kosmx.playerAnim.core.data.KeyframeAnimation; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.Nullable; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.LivingEntity; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * V2 Animation Applier -- manages dual-layer animation for V2 bondage items. + * + *

Orchestrates two PlayerAnimator layers simultaneously: + *

    + *
  • Context layer (priority 40): base body posture (stand/sit/kneel/sneak/walk) + * with item-owned parts disabled, via {@link ContextAnimationFactory}
  • + *
  • Item layer (priority 42): per-item GLB animation with only owned bones enabled, + * via {@link GltfPoseConverter#convertSelective}
  • + *
+ * + *

Each equipped V2 item controls ONLY the bones matching its occupied body regions. + * Bones not owned by any item pass through from the context layer, which provides the + * appropriate base posture animation. + * + *

State tracking avoids redundant animation replays: a composite key of + * {@code animSource|context|ownedParts} is compared per-entity to skip no-op updates. + * + *

Item animations are cached by {@code animSource#context#ownedParts} since the same + * GLB + context + owned parts always produces the same KeyframeAnimation. + * + * @see ContextAnimationFactory + * @see GlbAnimationResolver + * @see GltfPoseConverter#convertSelective + * @see BondageAnimationManager + */ +@OnlyIn(Dist.CLIENT) +public final class GltfAnimationApplier { + + private static final Logger LOGGER = LogManager.getLogger("GltfPipeline"); + + /** + * Cache of converted item-layer KeyframeAnimations. + * Keyed by "animSource#context#ownedPartsHash". + * Same GLB + same context + same owned parts = same KeyframeAnimation. + */ + private static final Map itemAnimCache = new ConcurrentHashMap<>(); + + /** + * Track which composite state is currently active per entity, to avoid redundant replays. + * Keyed by entity UUID, value is "animSource|context|sortedParts". + */ + private static final Map activeStateKeys = new ConcurrentHashMap<>(); + + /** Track cache keys where GLB loading failed, to avoid per-tick retries. */ + private static final Set failedLoadKeys = ConcurrentHashMap.newKeySet(); + + private GltfAnimationApplier() {} + + // ======================================== + // INIT (legacy) + // ======================================== + + /** + * Legacy init method -- called by GltfClientSetup. + * No-op: layer registration is handled by {@link BondageAnimationManager#init()}. + */ + public static void init() { + // No-op: animation layers are managed by BondageAnimationManager + } + + // ======================================== + // V2 DUAL-LAYER API + // ======================================== + + /** + * Apply the full V2 animation state: context layer + item layer. + * + *

Flow: + *

    + *
  1. Build a composite state key and skip if unchanged
  2. + *
  3. Create/retrieve a context animation with disabledOnContext parts disabled, + * play on context layer via {@link BondageAnimationManager#playContext}
  4. + *
  5. Load the GLB (from {@code animationSource} or {@code modelLoc}), + * resolve the named animation via {@link GlbAnimationResolver#resolve}, + * convert with selective parts via {@link GltfPoseConverter#convertSelective}, + * play on item layer via {@link BondageAnimationManager#playDirect}
  6. + *
+ * + *

The ownership model enables "free bone" animation: if a bone is not claimed + * by any item, the winning item can animate it IF its GLB has keyframes for that bone. + * This allows a straitjacket (ARMS+TORSO) to also animate free legs.

+ * + * @param entity the entity to animate + * @param modelLoc the item's GLB model (for mesh rendering, and default animation source) + * @param animationSource separate GLB for animations (shared template), or null to use modelLoc + * @param context current animation context (STAND_IDLE, SIT_IDLE, etc.) + * @param ownership bone ownership: which parts this item owns vs other items + * @return true if the item layer animation was applied successfully + */ + public static boolean applyV2Animation(LivingEntity entity, ResourceLocation modelLoc, + @Nullable ResourceLocation animationSource, + AnimationContext context, RegionBoneMapper.BoneOwnership ownership) { + if (entity == null || modelLoc == null) return false; + + ResourceLocation animSource = animationSource != null ? animationSource : modelLoc; + // Cache key includes both owned and enabled parts for full disambiguation + String ownedKey = canonicalPartsKey(ownership.thisParts()); + String enabledKey = canonicalPartsKey(ownership.enabledParts()); + String partsKey = ownedKey + ";" + enabledKey; + + // Build composite state key to avoid redundant updates + String stateKey = animSource + "|" + context.name() + "|" + partsKey; + String currentKey = activeStateKeys.get(entity.getUUID()); + if (stateKey.equals(currentKey)) { + return true; // Already active, no-op + } + + // === Layer 1: Context animation (base body posture) === + // Parts owned by ANY item (this or others) are disabled on the context layer. + // Only free parts remain enabled on context. + KeyframeAnimation contextAnim = ContextAnimationFactory.create( + context, ownership.disabledOnContext()); + if (contextAnim != null) { + BondageAnimationManager.playContext(entity, contextAnim); + } + + // === Layer 2: Item animation (GLB pose with selective bones) === + String itemCacheKey = buildItemCacheKey(animSource, context, partsKey); + + // Skip if this GLB already failed to load + if (failedLoadKeys.contains(itemCacheKey)) { + activeStateKeys.put(entity.getUUID(), stateKey); + return false; + } + + KeyframeAnimation itemAnim = itemAnimCache.get(itemCacheKey); + if (itemAnim == null) { + GltfData animData = GlbAnimationResolver.resolveAnimationData(modelLoc, animationSource); + if (animData == null) { + LOGGER.warn("[GltfPipeline] Failed to load animation GLB: {}", animSource); + failedLoadKeys.add(itemCacheKey); + activeStateKeys.put(entity.getUUID(), stateKey); + return false; + } + // Resolve which named animation to use (with fallback chain + variant selection) + String glbAnimName = GlbAnimationResolver.resolve(animData, context); + // Pass both owned parts and enabled parts (owned + free) for selective enabling + itemAnim = GltfPoseConverter.convertSelective( + animData, glbAnimName, ownership.thisParts(), ownership.enabledParts()); + itemAnimCache.put(itemCacheKey, itemAnim); + } + + BondageAnimationManager.playDirect(entity, itemAnim); + + activeStateKeys.put(entity.getUUID(), stateKey); + return true; + } + + /** + * Apply V2 animation from ALL equipped items simultaneously. + * + *

Each item contributes keyframes for only its owned bones into a shared + * {@link KeyframeAnimation.AnimationBuilder}. The first item in the list (highest priority) + * can additionally animate free bones if its GLB has keyframes for them.

+ * + * @param entity the entity to animate + * @param items resolved V2 items with per-item ownership, sorted by priority desc + * @param context current animation context + * @param allOwnedParts union of all owned parts across all items + * @return true if the composite animation was applied + */ + public static boolean applyMultiItemV2Animation(LivingEntity entity, + List items, + AnimationContext context, Set allOwnedParts) { + if (entity == null || items.isEmpty()) return false; + + // Build composite state key + StringBuilder keyBuilder = new StringBuilder(); + for (RegionBoneMapper.V2ItemAnimInfo item : items) { + ResourceLocation src = item.animSource() != null ? item.animSource() : item.modelLoc(); + keyBuilder.append(src).append(':').append(canonicalPartsKey(item.ownedParts())).append(';'); + } + keyBuilder.append(context.name()); + String stateKey = keyBuilder.toString(); + + String currentKey = activeStateKeys.get(entity.getUUID()); + if (stateKey.equals(currentKey)) { + return true; // Already active + } + + // === Layer 1: Context animation === + KeyframeAnimation contextAnim = ContextAnimationFactory.create(context, allOwnedParts); + if (contextAnim != null) { + BondageAnimationManager.playContext(entity, contextAnim); + } + + // === Layer 2: Composite item animation === + String compositeCacheKey = "multi#" + stateKey; + + if (failedLoadKeys.contains(compositeCacheKey)) { + activeStateKeys.put(entity.getUUID(), stateKey); + return false; + } + + KeyframeAnimation compositeAnim = itemAnimCache.get(compositeCacheKey); + if (compositeAnim == null) { + KeyframeAnimation.AnimationBuilder builder = + new KeyframeAnimation.AnimationBuilder( + dev.kosmx.playerAnim.core.data.AnimationFormat.JSON_EMOTECRAFT); + builder.beginTick = 0; + builder.endTick = 1; + builder.stopTick = 1; + builder.isLooped = true; + builder.returnTick = 0; + builder.name = "gltf_composite"; + + boolean anyLoaded = false; + + for (int i = 0; i < items.size(); i++) { + RegionBoneMapper.V2ItemAnimInfo item = items.get(i); + ResourceLocation animSource = item.animSource() != null ? item.animSource() : item.modelLoc(); + + GltfData animData = GlbAnimationResolver.resolveAnimationData(item.modelLoc(), item.animSource()); + if (animData == null) { + LOGGER.warn("[GltfPipeline] Failed to load GLB for multi-item: {}", animSource); + continue; + } + + String glbAnimName = GlbAnimationResolver.resolve(animData, context); + GltfData.AnimationClip rawClip; + if (glbAnimName != null) { + rawClip = animData.getRawAnimation(glbAnimName); + } else { + rawClip = null; + } + if (rawClip == null) { + rawClip = animData.rawGltfAnimation(); + } + + // Compute effective parts: intersect animation_bones whitelist with ownedParts + // if the item declares per-animation bone filtering. + Set effectiveParts = item.ownedParts(); + if (glbAnimName != null && !item.animationBones().isEmpty()) { + Set override = item.animationBones().get(glbAnimName); + if (override != null) { + Set filtered = new HashSet<>(override); + filtered.retainAll(item.ownedParts()); + if (!filtered.isEmpty()) { + effectiveParts = filtered; + } + } + } + + GltfPoseConverter.addBonesToBuilder( + builder, animData, rawClip, effectiveParts); + anyLoaded = true; + + LOGGER.debug("[GltfPipeline] Multi-item: {} -> owned={}, effective={}, anim={}", + animSource, item.ownedParts(), effectiveParts, glbAnimName); + } + + if (!anyLoaded) { + failedLoadKeys.add(compositeCacheKey); + activeStateKeys.put(entity.getUUID(), stateKey); + return false; + } + + // Enable only owned parts on the item layer. + // Free parts (head, body, etc. not owned by any item) are disabled here + // so they pass through to the context layer / vanilla animation. + String[] allPartNames = {"head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"}; + for (String partName : allPartNames) { + KeyframeAnimation.StateCollection part = getPartByName(builder, partName); + if (part != null) { + if (allOwnedParts.contains(partName)) { + part.fullyEnablePart(false); + } else { + part.setEnabled(false); + } + } + } + + compositeAnim = builder.build(); + itemAnimCache.put(compositeCacheKey, compositeAnim); + } + + BondageAnimationManager.playDirect(entity, compositeAnim); + activeStateKeys.put(entity.getUUID(), stateKey); + return true; + } + + // ======================================== + // CLEAR / QUERY + // ======================================== + + /** + * Clear all V2 animation layers from an entity and remove tracking. + * Stops both the context layer and the item layer. + * + * @param entity the entity to clear animations from + */ + public static void clearV2Animation(LivingEntity entity) { + if (entity == null) return; + activeStateKeys.remove(entity.getUUID()); + BondageAnimationManager.stopContext(entity); + BondageAnimationManager.stopAnimation(entity); + } + + /** + * Check if an entity has active V2 animation state. + * + * @param entity the entity to check + * @return true if the entity has an active V2 animation state key + */ + public static boolean hasActiveState(LivingEntity entity) { + return entity != null && activeStateKeys.containsKey(entity.getUUID()); + } + + /** + * Remove tracking for an entity (e.g., on logout/unload). + * Does NOT stop any currently playing animation -- use {@link #clearV2Animation} for that. + * + * @param entityId UUID of the entity to stop tracking + */ + public static void removeTracking(UUID entityId) { + activeStateKeys.remove(entityId); + } + + // ======================================== + // CACHE MANAGEMENT + // ======================================== + + /** + * Invalidate all cached item animations and tracking state. + * Call this on resource reload (F3+T) to pick up changed GLB/JSON files. + * + *

Does NOT clear ContextAnimationFactory or ContextGlbRegistry here. + * Those are cleared in the reload listener AFTER ContextGlbRegistry.reload() + * to prevent the render thread from caching stale JSON fallbacks during + * the window between clear and repopulate.

+ */ + public static void invalidateCache() { + itemAnimCache.clear(); + activeStateKeys.clear(); + failedLoadKeys.clear(); + } + + /** + * Clear all state (cache + tracking). Called on world unload. + * Clears everything including context caches (no concurrent reload during unload). + */ + public static void clearAll() { + itemAnimCache.clear(); + activeStateKeys.clear(); + failedLoadKeys.clear(); + com.tiedup.remake.client.animation.context.ContextGlbRegistry.clear(); + ContextAnimationFactory.clearCache(); + } + + // ======================================== + // LEGACY F9 DEBUG TOGGLE + // ======================================== + + private static boolean debugEnabled = false; + + /** + * Toggle debug mode via F9 key. + * When enabled, applies handcuffs V2 animation (rightArm + leftArm) to the local player + * using STAND_IDLE context. When disabled, clears all V2 animation. + */ + public static void toggle() { + debugEnabled = !debugEnabled; + LOGGER.info("[GltfPipeline] Debug toggle: {}", debugEnabled ? "ON" : "OFF"); + + AbstractClientPlayer player = Minecraft.getInstance().player; + if (player == null) return; + + if (debugEnabled) { + ResourceLocation modelLoc = ResourceLocation.fromNamespaceAndPath( + "tiedup", "models/gltf/v2/handcuffs/cuffs_prototype.glb" + ); + Set armParts = Set.of("rightArm", "leftArm"); + RegionBoneMapper.BoneOwnership debugOwnership = + new RegionBoneMapper.BoneOwnership(armParts, Set.of()); + applyV2Animation(player, modelLoc, null, AnimationContext.STAND_IDLE, debugOwnership); + } else { + clearV2Animation(player); + } + } + + /** + * Whether F9 debug mode is currently enabled. + */ + public static boolean isEnabled() { + return debugEnabled; + } + + // ======================================== + // INTERNAL + // ======================================== + + /** + * Build cache key for item-layer animations. + * Format: "animSource#contextName#sortedParts" + */ + private static String buildItemCacheKey(ResourceLocation animSource, + AnimationContext context, String partsKey) { + return animSource + "#" + context.name() + "#" + partsKey; + } + + /** + * Build a canonical, deterministic string from the owned parts set. + * Sorted alphabetically and joined by comma — guarantees no hash collisions. + */ + private static String canonicalPartsKey(Set ownedParts) { + return String.join(",", new TreeSet<>(ownedParts)); + } + + /** + * Look up an {@link KeyframeAnimation.StateCollection} by part name on a builder. + */ + private static KeyframeAnimation.StateCollection getPartByName( + KeyframeAnimation.AnimationBuilder builder, String name) { + return switch (name) { + case "head" -> builder.head; + case "body" -> builder.body; + case "rightArm" -> builder.rightArm; + case "leftArm" -> builder.leftArm; + case "rightLeg" -> builder.rightLeg; + case "leftLeg" -> builder.leftLeg; + default -> null; + }; + } +} diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfBoneMapper.java b/src/main/java/com/tiedup/remake/client/gltf/GltfBoneMapper.java new file mode 100644 index 0000000..dc8ed95 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfBoneMapper.java @@ -0,0 +1,104 @@ +package com.tiedup.remake.client.gltf; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import net.minecraft.client.model.HumanoidModel; +import net.minecraft.client.model.geom.ModelPart; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Maps glTF bone names to Minecraft HumanoidModel parts. + * Handles upper bones (full rotation) and lower bones (bend only). + */ +@OnlyIn(Dist.CLIENT) +public final class GltfBoneMapper { + + /** Maps glTF bone name -> MC model part field name */ + private static final Map BONE_TO_PART = new HashMap<>(); + + /** Lower bones that represent bend (elbow/knee) */ + private static final Set LOWER_BONES = Set.of( + "leftLowerArm", "rightLowerArm", + "leftLowerLeg", "rightLowerLeg" + ); + + /** Maps lower bone name -> corresponding upper bone name */ + private static final Map LOWER_TO_UPPER = Map.of( + "leftLowerArm", "leftUpperArm", + "rightLowerArm", "rightUpperArm", + "leftLowerLeg", "leftUpperLeg", + "rightLowerLeg", "rightUpperLeg" + ); + + static { + BONE_TO_PART.put("body", "body"); + BONE_TO_PART.put("torso", "body"); + BONE_TO_PART.put("head", "head"); + BONE_TO_PART.put("leftUpperArm", "leftArm"); + BONE_TO_PART.put("leftLowerArm", "leftArm"); + BONE_TO_PART.put("rightUpperArm", "rightArm"); + BONE_TO_PART.put("rightLowerArm", "rightArm"); + BONE_TO_PART.put("leftUpperLeg", "leftLeg"); + BONE_TO_PART.put("leftLowerLeg", "leftLeg"); + BONE_TO_PART.put("rightUpperLeg", "rightLeg"); + BONE_TO_PART.put("rightLowerLeg", "rightLeg"); + } + + private GltfBoneMapper() {} + + /** + * Get the ModelPart corresponding to a glTF bone name. + * + * @param model the HumanoidModel + * @param boneName glTF bone name + * @return the ModelPart, or null if not mapped + */ + public static ModelPart getModelPart(HumanoidModel model, String boneName) { + String partName = BONE_TO_PART.get(boneName); + if (partName == null) return null; + + return switch (partName) { + case "body" -> model.body; + case "head" -> model.head; + case "leftArm" -> model.leftArm; + case "rightArm" -> model.rightArm; + case "leftLeg" -> model.leftLeg; + case "rightLeg" -> model.rightLeg; + default -> null; + }; + } + + /** + * Check if this bone represents a lower segment (bend: elbow/knee). + */ + public static boolean isLowerBone(String boneName) { + return LOWER_BONES.contains(boneName); + } + + /** + * Get the upper bone name for a given lower bone. + * Returns null if not a lower bone. + */ + public static String getUpperBoneFor(String lowerBoneName) { + return LOWER_TO_UPPER.get(lowerBoneName); + } + + /** + * Get the PlayerAnimator part name for a glTF bone. + * Both glTF and PlayerAnimator use "body" for the torso part. + */ + public static String getAnimPartName(String boneName) { + String partName = BONE_TO_PART.get(boneName); + if (partName == null) return null; + return partName; + } + + /** + * Check if a bone name is known/mapped. + */ + public static boolean isKnownBone(String boneName) { + return BONE_TO_PART.containsKey(boneName); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfCache.java b/src/main/java/com/tiedup/remake/client/gltf/GltfCache.java new file mode 100644 index 0000000..2f9ee91 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfCache.java @@ -0,0 +1,67 @@ +package com.tiedup.remake.client.gltf; + +import java.io.InputStream; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.client.Minecraft; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.Resource; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Lazy-loading cache for parsed glTF data. + * Loads .glb files via Minecraft's ResourceManager on first access. + */ +@OnlyIn(Dist.CLIENT) +public final class GltfCache { + + private static final Logger LOGGER = LogManager.getLogger("GltfPipeline"); + private static final Map CACHE = new ConcurrentHashMap<>(); + + private GltfCache() {} + + /** + * Get parsed glTF data for a resource, loading it on first access. + * + * @param location resource location of the .glb file (e.g. "tiedup:models/gltf/v2/handcuffs/cuffs_prototype.glb") + * @return parsed GltfData, or null if loading failed + */ + public static GltfData get(ResourceLocation location) { + GltfData cached = CACHE.get(location); + if (cached != null) return cached; + + try { + Resource resource = Minecraft.getInstance() + .getResourceManager() + .getResource(location) + .orElse(null); + if (resource == null) { + LOGGER.error("[GltfPipeline] Resource not found: {}", location); + return null; + } + + try (InputStream is = resource.open()) { + GltfData data = GlbParser.parse(is, location.toString()); + CACHE.put(location, data); + return data; + } + } catch (Exception e) { + LOGGER.error("[GltfPipeline] Failed to load GLB: {}", location, e); + return null; + } + } + + /** Clear all cached data (call on resource reload). */ + public static void clearCache() { + CACHE.clear(); + LOGGER.info("[GltfPipeline] Cache cleared"); + } + + /** Initialize the cache (called during FMLClientSetupEvent). */ + public static void init() { + LOGGER.info("[GltfPipeline] GltfCache initialized"); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java b/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java new file mode 100644 index 0000000..dd36473 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java @@ -0,0 +1,140 @@ +package com.tiedup.remake.client.gltf; + +import com.mojang.blaze3d.platform.InputConstants; +import com.tiedup.remake.client.animation.context.ContextAnimationFactory; +import com.tiedup.remake.client.animation.context.ContextGlbRegistry; +import com.tiedup.remake.v2.bondage.client.V2BondageRenderLayer; +import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemReloadListener; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.renderer.entity.player.PlayerRenderer; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.util.profiling.ProfilerFiller; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.client.event.EntityRenderersEvent; +import net.minecraftforge.client.event.RegisterClientReloadListenersEvent; +import net.minecraftforge.client.event.RegisterKeyMappingsEvent; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent; +import net.minecraft.server.packs.resources.SimplePreparableReloadListener; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Forge event registration for the glTF pipeline. + * Registers keybind (F9), render layers, and animation factory. + */ +public final class GltfClientSetup { + + private static final Logger LOGGER = LogManager.getLogger("GltfPipeline"); + + private static final String KEY_CATEGORY = "key.categories.tiedup"; + static final KeyMapping TOGGLE_KEY = new KeyMapping( + "key.tiedup.gltf_toggle", + InputConstants.Type.KEYSYM, + InputConstants.KEY_F9, + KEY_CATEGORY + ); + + private GltfClientSetup() {} + + /** + * MOD bus event subscribers (FMLClientSetupEvent, RegisterKeyMappings, AddLayers). + */ + @Mod.EventBusSubscriber( + modid = "tiedup", + bus = Mod.EventBusSubscriber.Bus.MOD, + value = Dist.CLIENT + ) + public static class ModBusEvents { + + @SubscribeEvent + public static void onClientSetup(FMLClientSetupEvent event) { + event.enqueueWork(() -> { + GltfCache.init(); + GltfAnimationApplier.init(); + LOGGER.info("[GltfPipeline] Client setup complete"); + }); + } + + @SubscribeEvent + public static void onRegisterKeybindings(RegisterKeyMappingsEvent event) { + event.register(TOGGLE_KEY); + LOGGER.info("[GltfPipeline] Keybind registered: F9"); + } + + @SuppressWarnings("unchecked") + @SubscribeEvent + public static void onAddLayers(EntityRenderersEvent.AddLayers event) { + // Add GltfRenderLayer (prototype/debug with F9 toggle) to player renderers + var defaultRenderer = event.getSkin("default"); + if (defaultRenderer instanceof PlayerRenderer playerRenderer) { + playerRenderer.addLayer(new GltfRenderLayer(playerRenderer)); + playerRenderer.addLayer(new V2BondageRenderLayer<>(playerRenderer)); + LOGGER.info("[GltfPipeline] Render layers added to 'default' player renderer"); + } + + // Add both layers to slim player renderer (Alex) + var slimRenderer = event.getSkin("slim"); + if (slimRenderer instanceof PlayerRenderer playerRenderer) { + playerRenderer.addLayer(new GltfRenderLayer(playerRenderer)); + playerRenderer.addLayer(new V2BondageRenderLayer<>(playerRenderer)); + LOGGER.info("[GltfPipeline] Render layers added to 'slim' player renderer"); + } + } + + /** + * Register resource reload listener to clear GLB caches on resource pack reload. + * This ensures re-exported GLB models are picked up without restarting the game. + */ + @SubscribeEvent + public static void onRegisterReloadListeners(RegisterClientReloadListenersEvent event) { + event.registerReloadListener(new SimplePreparableReloadListener() { + @Override + protected Void prepare(ResourceManager resourceManager, ProfilerFiller profiler) { + return null; + } + + @Override + protected void apply(Void nothing, ResourceManager resourceManager, ProfilerFiller profiler) { + GltfCache.clearCache(); + GltfAnimationApplier.invalidateCache(); + GltfMeshRenderer.clearRenderTypeCache(); + // Reload context GLB animations from resource packs FIRST, + // then clear the factory cache so it rebuilds against the + // new GLB registry (prevents stale JSON fallback caching). + ContextGlbRegistry.reload(resourceManager); + ContextAnimationFactory.clearCache(); + com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.clear(); + LOGGER.info("[GltfPipeline] Caches cleared on resource reload"); + } + }); + LOGGER.info("[GltfPipeline] Resource reload listener registered"); + + // Data-driven bondage item definitions (tiedup_items/*.json) + event.registerReloadListener(new DataDrivenItemReloadListener()); + LOGGER.info("[GltfPipeline] Data-driven item reload listener registered"); + } + } + + /** + * FORGE bus event subscribers (ClientTickEvent for keybind toggle). + */ + @Mod.EventBusSubscriber( + modid = "tiedup", + bus = Mod.EventBusSubscriber.Bus.FORGE, + value = Dist.CLIENT + ) + public static class ForgeBusEvents { + + @SubscribeEvent + public static void onClientTick(TickEvent.ClientTickEvent event) { + if (event.phase != TickEvent.Phase.END) return; + + while (TOGGLE_KEY.consumeClick()) { + GltfAnimationApplier.toggle(); + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfData.java b/src/main/java/com/tiedup/remake/client/gltf/GltfData.java new file mode 100644 index 0000000..f1a4541 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfData.java @@ -0,0 +1,194 @@ +package com.tiedup.remake.client.gltf; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.Nullable; +import org.joml.Matrix4f; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +/** + * Immutable container for parsed glTF/GLB data. + * Holds mesh geometry, skinning data, bone hierarchy, and optional animations. + *

+ * Supports multiple named animations per GLB file. The "default" animation + * (first clip) is accessible via {@link #animation()} and {@link #rawGltfAnimation()} + * for backward compatibility. All animations are available via + * {@link #namedAnimations()}. + */ +public final class GltfData { + + // -- Mesh geometry (flattened arrays) -- + private final float[] positions; // VEC3, length = vertexCount * 3 + private final float[] normals; // VEC3, length = vertexCount * 3 + private final float[] texCoords; // VEC2, length = vertexCount * 2 + private final int[] indices; // triangle indices + + // -- Skinning data (per-vertex, 4 influences) -- + private final int[] joints; // 4 joint indices per vertex, length = vertexCount * 4 + private final float[] weights; // 4 weights per vertex, length = vertexCount * 4 + + // -- Bone hierarchy (MC-converted for skinning) -- + private final String[] jointNames; + private final int[] parentJointIndices; // -1 for root + private final Matrix4f[] inverseBindMatrices; + private final Quaternionf[] restRotations; + private final Vector3f[] restTranslations; + + // -- Raw glTF rotations (unconverted, for pose conversion) -- + private final Quaternionf[] rawGltfRestRotations; + @Nullable + private final AnimationClip rawGltfAnimation; + + // -- Optional animation clip (MC-converted for skinning) -- + @Nullable + private final AnimationClip animation; + + // -- Multiple named animations -- + private final Map namedAnimations; // MC-converted + private final Map rawNamedAnimations; // raw glTF space + + // -- Per-primitive material/tint info -- + private final List primitives; + + // -- Counts -- + private final int vertexCount; + private final int jointCount; + + /** + * Full constructor with multiple named animations and per-primitive data. + */ + public GltfData( + float[] positions, float[] normals, float[] texCoords, + int[] indices, int[] joints, float[] weights, + String[] jointNames, int[] parentJointIndices, + Matrix4f[] inverseBindMatrices, + Quaternionf[] restRotations, Vector3f[] restTranslations, + Quaternionf[] rawGltfRestRotations, + @Nullable AnimationClip rawGltfAnimation, + @Nullable AnimationClip animation, + Map namedAnimations, + Map rawNamedAnimations, + List primitives, + int vertexCount, int jointCount + ) { + this.positions = positions; + this.normals = normals; + this.texCoords = texCoords; + this.indices = indices; + this.joints = joints; + this.weights = weights; + this.jointNames = jointNames; + this.parentJointIndices = parentJointIndices; + this.inverseBindMatrices = inverseBindMatrices; + this.restRotations = restRotations; + this.restTranslations = restTranslations; + this.rawGltfRestRotations = rawGltfRestRotations; + this.rawGltfAnimation = rawGltfAnimation; + this.animation = animation; + this.namedAnimations = Collections.unmodifiableMap(new LinkedHashMap<>(namedAnimations)); + this.rawNamedAnimations = Collections.unmodifiableMap(new LinkedHashMap<>(rawNamedAnimations)); + this.primitives = List.copyOf(primitives); + this.vertexCount = vertexCount; + this.jointCount = jointCount; + } + + /** + * Legacy constructor for backward compatibility (single animation only). + */ + public GltfData( + float[] positions, float[] normals, float[] texCoords, + int[] indices, int[] joints, float[] weights, + String[] jointNames, int[] parentJointIndices, + Matrix4f[] inverseBindMatrices, + Quaternionf[] restRotations, Vector3f[] restTranslations, + Quaternionf[] rawGltfRestRotations, + @Nullable AnimationClip rawGltfAnimation, + @Nullable AnimationClip animation, + int vertexCount, int jointCount + ) { + this(positions, normals, texCoords, indices, joints, weights, + jointNames, parentJointIndices, inverseBindMatrices, + restRotations, restTranslations, rawGltfRestRotations, + rawGltfAnimation, animation, + new LinkedHashMap<>(), new LinkedHashMap<>(), + List.of(new Primitive(indices, null, false, null)), + vertexCount, jointCount); + } + + public float[] positions() { return positions; } + public float[] normals() { return normals; } + public float[] texCoords() { return texCoords; } + public int[] indices() { return indices; } + public int[] joints() { return joints; } + public float[] weights() { return weights; } + public String[] jointNames() { return jointNames; } + public int[] parentJointIndices() { return parentJointIndices; } + public Matrix4f[] inverseBindMatrices() { return inverseBindMatrices; } + public Quaternionf[] restRotations() { return restRotations; } + public Vector3f[] restTranslations() { return restTranslations; } + public Quaternionf[] rawGltfRestRotations() { return rawGltfRestRotations; } + @Nullable + public AnimationClip rawGltfAnimation() { return rawGltfAnimation; } + @Nullable + public AnimationClip animation() { return animation; } + public int vertexCount() { return vertexCount; } + public int jointCount() { return jointCount; } + + /** Per-primitive material and tint metadata. One entry per glTF primitive in the mesh. */ + public List primitives() { return primitives; } + + /** All named animations in MC-converted space. Keys are animation names (e.g. "BasicPose", "Struggle"). */ + public Map namedAnimations() { return namedAnimations; } + + /** Get a specific named animation in MC-converted space, or null if not found. */ + @Nullable + public AnimationClip getAnimation(String name) { return namedAnimations.get(name); } + + /** Get a specific named animation in raw glTF space, or null if not found. */ + @Nullable + public AnimationClip getRawAnimation(String name) { return rawNamedAnimations.get(name); } + + /** + * Animation clip: per-bone timestamps, quaternion rotations, and optional translations. + */ + public static final class AnimationClip { + private final float[] timestamps; // shared timestamps + private final Quaternionf[][] rotations; // [jointIndex][frameIndex], null if no anim + @Nullable + private final Vector3f[][] translations; // [jointIndex][frameIndex], null if no anim + private final int frameCount; + + public AnimationClip(float[] timestamps, Quaternionf[][] rotations, + @Nullable Vector3f[][] translations, int frameCount) { + this.timestamps = timestamps; + this.rotations = rotations; + this.translations = translations; + this.frameCount = frameCount; + } + + public float[] timestamps() { return timestamps; } + public Quaternionf[][] rotations() { return rotations; } + @Nullable + public Vector3f[][] translations() { return translations; } + public int frameCount() { return frameCount; } + } + + /** + * Per-primitive metadata parsed from the glTF mesh. + * Each primitive corresponds to a material assignment in Blender. + * + * @param indices triangle indices for this primitive (already offset to the unified vertex buffer) + * @param materialName the glTF material name, or null if unassigned + * @param tintable true if the material name starts with "tintable_" + * @param tintChannel the tint channel key (e.g. "tintable_0"), or null if not tintable + */ + public record Primitive( + int[] indices, + @Nullable String materialName, + boolean tintable, + @Nullable String tintChannel + ) {} +} diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfLiveBoneReader.java b/src/main/java/com/tiedup/remake/client/gltf/GltfLiveBoneReader.java new file mode 100644 index 0000000..8ebf6d1 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfLiveBoneReader.java @@ -0,0 +1,245 @@ +package com.tiedup.remake.client.gltf; + +import dev.kosmx.playerAnim.core.util.Pair; +import dev.kosmx.playerAnim.impl.IAnimatedPlayer; +import dev.kosmx.playerAnim.impl.animation.AnimationApplier; +import net.minecraft.client.model.HumanoidModel; +import net.minecraft.client.model.geom.ModelPart; +import net.minecraft.world.entity.LivingEntity; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.joml.Matrix4f; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +/** + * Reads the LIVE skeleton state from HumanoidModel (after PlayerAnimator + bendy-lib + * have applied all rotations for the current frame) and produces joint matrices + * compatible with {@link GltfSkinningEngine#skinVertex}. + *

+ * KEY INSIGHT: The ModelPart xRot/yRot/zRot values set by PlayerAnimator represent + * DELTA rotations (difference from rest pose) expressed in the MC model-def frame. + * GltfPoseConverter computed them as parent-frame deltas, decomposed to Euler ZYX. + *

+ * To reconstruct the correct LOCAL rotation for the glTF hierarchy: + *

+ *   delta    = rotationZYX(zRot, yRot, xRot)   // MC-frame delta from ModelPart
+ *   localRot = delta * restQ_mc                  // delta applied on top of local rest
+ * 
+ * No de-parenting is needed because both delta and restQ_mc are already in the + * parent's local frame. The MC-to-glTF conjugation (negate qx,qy) is a homomorphism, + * so frame relationships are preserved through the conversion. + *

+ * For bones WITHOUT a MC ModelPart (root, torso), use the MC-converted rest rotation + * directly from GltfData. + */ +@OnlyIn(Dist.CLIENT) +public final class GltfLiveBoneReader { + + private static final Logger LOGGER = LogManager.getLogger("GltfPipeline"); + + private GltfLiveBoneReader() {} + + /** + * Compute joint matrices by reading live skeleton state from the HumanoidModel. + *

+ * For upper bones: reconstructs the MC-frame delta from ModelPart euler angles, + * then composes with the MC-converted rest rotation to get the local rotation. + * For lower bones: reads bend values from the entity's AnimationApplier and + * composes the bend delta with the local rest rotation. + * For non-animated bones: uses rest rotation from GltfData directly. + *

+ * The resulting joint matrices should match {@link GltfSkinningEngine#computeJointMatrices} + * when the player is in the rest pose (no animation active). + * + * @param model the HumanoidModel after PlayerAnimator has applied rotations + * @param data parsed glTF data (MC-converted) + * @param entity the living entity being rendered + * @return array of joint matrices ready for skinning, or null on failure + */ + public static Matrix4f[] computeJointMatricesFromModel( + HumanoidModel model, GltfData data, LivingEntity entity + ) { + if (model == null || data == null || entity == null) return null; + + int jointCount = data.jointCount(); + Matrix4f[] jointMatrices = new Matrix4f[jointCount]; + Matrix4f[] worldTransforms = new Matrix4f[jointCount]; + + int[] parents = data.parentJointIndices(); + String[] jointNames = data.jointNames(); + Quaternionf[] restRotations = data.restRotations(); + Vector3f[] restTranslations = data.restTranslations(); + + // Get the AnimationApplier for bend values (may be null) + AnimationApplier emote = getAnimationApplier(entity); + + for (int j = 0; j < jointCount; j++) { + String boneName = jointNames[j]; + Quaternionf localRot; + + if (GltfBoneMapper.isLowerBone(boneName)) { + // --- Lower bone: reconstruct from bend values --- + localRot = computeLowerBoneLocalRotation( + boneName, j, restRotations, emote + ); + } else if (hasUniqueModelPart(boneName)) { + // --- Upper bone with a unique ModelPart --- + ModelPart part = GltfBoneMapper.getModelPart(model, boneName); + if (part != null) { + localRot = computeUpperBoneLocalRotation( + part, j, restRotations + ); + } else { + // Fallback: use rest rotation + localRot = new Quaternionf(restRotations[j]); + } + } else { + // --- Non-animated bone (root, torso, etc.): use rest rotation --- + localRot = new Quaternionf(restRotations[j]); + } + + // Build local transform: translate(restTranslation) * rotate(localRot) + Matrix4f local = new Matrix4f(); + local.translate(restTranslations[j]); + local.rotate(localRot); + + // Compose with parent to get world transform + if (parents[j] >= 0 && worldTransforms[parents[j]] != null) { + worldTransforms[j] = new Matrix4f(worldTransforms[parents[j]]).mul(local); + } else { + worldTransforms[j] = new Matrix4f(local); + } + + // Final joint matrix = worldTransform * inverseBindMatrix + jointMatrices[j] = new Matrix4f(worldTransforms[j]) + .mul(data.inverseBindMatrices()[j]); + } + + return jointMatrices; + } + + /** + * Compute local rotation for an upper bone that has a unique ModelPart. + *

+ * ModelPart xRot/yRot/zRot are DELTA rotations (set by PlayerAnimator) expressed + * as ZYX Euler angles in the MC model-def frame. These deltas were originally + * computed by GltfPoseConverter as parent-frame quantities. + *

+ * The local rotation for the glTF hierarchy is simply: + *

+     *   delta    = rotationZYX(zRot, yRot, xRot)
+     *   localRot = delta * restQ_mc
+     * 
+ * No de-parenting is needed: both delta and restQ_mc are already in the parent's + * frame. The MC-to-glTF negate-xy conjugation is a group homomorphism, preserving + * the frame relationship. + */ + private static Quaternionf computeUpperBoneLocalRotation( + ModelPart part, int jointIndex, + Quaternionf[] restRotations + ) { + // Reconstruct the MC-frame delta from ModelPart euler angles. + Quaternionf delta = new Quaternionf().rotationZYX(part.zRot, part.yRot, part.xRot); + // Local rotation = delta applied on top of the local rest rotation. + return new Quaternionf(delta).mul(restRotations[jointIndex]); + } + + /** + * Compute local rotation for a lower bone (elbow/knee) from bend values. + *

+ * Bend values are read from the entity's AnimationApplier. The bend delta is + * reconstructed as a quaternion rotation around the bend axis, then composed + * with the local rest rotation: + *

+     *   bendQuat = axisAngle(cos(bendAxis)*s, 0, sin(bendAxis)*s, cos(halfAngle))
+     *   localRot = bendQuat * restQ_mc
+     * 
+ * No de-parenting needed — same reasoning as upper bones. + */ + private static Quaternionf computeLowerBoneLocalRotation( + String boneName, int jointIndex, + Quaternionf[] restRotations, + AnimationApplier emote + ) { + if (emote != null) { + // Get the MC part name for the upper bone of this lower bone + String upperBone = GltfBoneMapper.getUpperBoneFor(boneName); + String animPartName = (upperBone != null) + ? GltfBoneMapper.getAnimPartName(upperBone) + : null; + + if (animPartName != null) { + Pair bend = emote.getBend(animPartName); + if (bend != null) { + float bendAxis = bend.getLeft(); + float bendValue = bend.getRight(); + + // Reconstruct bend as quaternion (this is the delta) + float ax = (float) Math.cos(bendAxis); + float az = (float) Math.sin(bendAxis); + float halfAngle = bendValue * 0.5f; + float s = (float) Math.sin(halfAngle); + Quaternionf bendQuat = new Quaternionf( + ax * s, 0, az * s, (float) Math.cos(halfAngle) + ); + + // Local rotation = bend delta applied on top of local rest rotation + return new Quaternionf(bendQuat).mul(restRotations[jointIndex]); + } + } + } + + // No bend data or no AnimationApplier — use rest rotation (identity delta) + return new Quaternionf(restRotations[jointIndex]); + } + + /** + * Check if a bone name corresponds to a bone that has its OWN unique ModelPart + * (not just a mapping — it must be the PRIMARY bone for that ModelPart). + *

+ * "torso" maps to model.body but "body" is the primary bone for it. + * Lower bones share a ModelPart with their upper bone. + * Unknown bones (e.g., "PlayerArmature") have no ModelPart at all. + */ + private static boolean hasUniqueModelPart(String boneName) { + // Bones that should read their rotation from the live HumanoidModel. + // + // NOTE: "body" is deliberately EXCLUDED. MC's HumanoidModel is FLAT — + // body, arms, legs, head are all siblings with ABSOLUTE rotations. + // But the GLB skeleton is HIERARCHICAL (body → torso → arms). + // If we read body's live rotation (e.g., attack swing yRot), it propagates + // to arms/head through the hierarchy, but MC's flat model does NOT do this. + // Result: cuffs mesh rotates with body during attack while arms stay put. + // + // Body rotation effects that matter (sneak lean, sitting) are handled by + // LivingEntityRenderer's PoseStack transform, which applies to the entire + // mesh uniformly. No need to read body rotation into joint matrices. + return switch (boneName) { + case "head" -> true; + case "leftUpperArm" -> true; + case "rightUpperArm"-> true; + case "leftUpperLeg" -> true; + case "rightUpperLeg"-> true; + default -> false; // body, torso, lower bones, unknown + }; + } + + /** + * Get the AnimationApplier from an entity, if available. + * Works for both players (via mixin) and NPCs implementing IAnimatedPlayer. + */ + private static AnimationApplier getAnimationApplier(LivingEntity entity) { + if (entity instanceof IAnimatedPlayer animated) { + try { + return animated.playerAnimator_getAnimation(); + } catch (Exception e) { + LOGGER.debug("[GltfPipeline] Could not get AnimationApplier for {}: {}", + entity.getClass().getSimpleName(), e.getMessage()); + } + } + return null; + } +} diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfMeshRenderer.java b/src/main/java/com/tiedup/remake/client/gltf/GltfMeshRenderer.java new file mode 100644 index 0000000..dfba4d7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfMeshRenderer.java @@ -0,0 +1,255 @@ +package com.tiedup.remake.client.gltf; + +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.blaze3d.vertex.VertexFormat; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderStateShard; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.joml.Matrix3f; +import org.joml.Matrix4f; +import org.joml.Vector4f; + +/** + * Submits CPU-skinned glTF mesh vertices to Minecraft's rendering pipeline. + * Uses TRIANGLES mode RenderType (same pattern as ObjModelRenderer). + */ +@OnlyIn(Dist.CLIENT) +public final class GltfMeshRenderer extends RenderStateShard { + + private static final ResourceLocation WHITE_TEXTURE = + ResourceLocation.fromNamespaceAndPath("tiedup", "models/obj/shared/white.png"); + + /** Cached default RenderType (white texture). Created once, reused every frame. */ + private static RenderType cachedDefaultRenderType; + + /** Cache for texture-specific RenderTypes, keyed by ResourceLocation. */ + private static final Map RENDER_TYPE_CACHE = new ConcurrentHashMap<>(); + + private GltfMeshRenderer() { + super("tiedup_gltf_renderer", () -> {}, () -> {}); + } + + /** + * Get the default TRIANGLES-mode RenderType (white texture), creating it once if needed. + */ + private static RenderType getDefaultRenderType() { + if (cachedDefaultRenderType == null) { + cachedDefaultRenderType = createTriangleRenderType(WHITE_TEXTURE); + } + return cachedDefaultRenderType; + } + + /** + * Public accessor for the default RenderType (white texture). + * Used by external renderers that need the same RenderType for tinted rendering. + */ + public static RenderType getRenderTypeForDefaultTexture() { + return getDefaultRenderType(); + } + + /** + * Get a RenderType for a specific texture, caching it for reuse. + * + * @param texture the texture ResourceLocation + * @return the cached or newly created RenderType + */ + private static RenderType getRenderTypeForTexture(ResourceLocation texture) { + return RENDER_TYPE_CACHE.computeIfAbsent(texture, + GltfMeshRenderer::createTriangleRenderType); + } + + /** + * Create a TRIANGLES-mode RenderType for glTF mesh rendering with the given texture. + */ + private static RenderType createTriangleRenderType(ResourceLocation texture) { + RenderType.CompositeState state = RenderType.CompositeState.builder() + .setShaderState(RENDERTYPE_ENTITY_CUTOUT_NO_CULL_SHADER) + .setTextureState( + new RenderStateShard.TextureStateShard(texture, false, false) + ) + .setTransparencyState(NO_TRANSPARENCY) + .setCullState(NO_CULL) + .setLightmapState(LIGHTMAP) + .setOverlayState(OVERLAY) + .createCompositeState(true); + + return RenderType.create( + "tiedup_gltf_triangles", + DefaultVertexFormat.NEW_ENTITY, + VertexFormat.Mode.TRIANGLES, + 256 * 1024, + true, + false, + state + ); + } + + /** + * Clear cached RenderTypes. Call on resource reload so that re-exported + * textures are picked up without restarting the game. + */ + public static void clearRenderTypeCache() { + cachedDefaultRenderType = null; + RENDER_TYPE_CACHE.clear(); + } + + /** + * Render a skinned glTF mesh using the default white texture. + * + * @param data parsed glTF data + * @param jointMatrices computed joint matrices from skinning engine + * @param poseStack current pose stack + * @param buffer multi-buffer source + * @param packedLight packed light value + * @param packedOverlay packed overlay value + */ + public static void renderSkinned( + GltfData data, Matrix4f[] jointMatrices, + PoseStack poseStack, MultiBufferSource buffer, + int packedLight, int packedOverlay + ) { + renderSkinnedInternal(data, jointMatrices, poseStack, buffer, + packedLight, packedOverlay, getDefaultRenderType()); + } + + /** + * Render a skinned glTF mesh using a custom texture. + * + * @param data parsed glTF data + * @param jointMatrices computed joint matrices from skinning engine + * @param poseStack current pose stack + * @param buffer multi-buffer source + * @param packedLight packed light value + * @param packedOverlay packed overlay value + * @param texture the texture to use for rendering + */ + public static void renderSkinned( + GltfData data, Matrix4f[] jointMatrices, + PoseStack poseStack, MultiBufferSource buffer, + int packedLight, int packedOverlay, + ResourceLocation texture + ) { + renderSkinnedInternal(data, jointMatrices, poseStack, buffer, + packedLight, packedOverlay, getRenderTypeForTexture(texture)); + } + + /** + * Internal rendering implementation shared by both overloads. + */ + private static void renderSkinnedInternal( + GltfData data, Matrix4f[] jointMatrices, + PoseStack poseStack, MultiBufferSource buffer, + int packedLight, int packedOverlay, + RenderType renderType + ) { + Matrix4f pose = poseStack.last().pose(); + Matrix3f normalMat = poseStack.last().normal(); + + VertexConsumer vc = buffer.getBuffer(renderType); + + int[] indices = data.indices(); + float[] texCoords = data.texCoords(); + + float[] outPos = new float[3]; + float[] outNormal = new float[3]; + + // Pre-allocate scratch vectors outside the loop to avoid per-vertex allocations + Vector4f tmpPos = new Vector4f(); + Vector4f tmpNorm = new Vector4f(); + + for (int idx : indices) { + // Skin this vertex + GltfSkinningEngine.skinVertex(data, idx, jointMatrices, outPos, outNormal, tmpPos, tmpNorm); + + // UV coordinates + float u = texCoords[idx * 2]; + float v = texCoords[idx * 2 + 1]; + + vc.vertex(pose, outPos[0], outPos[1], outPos[2]) + .color(255, 255, 255, 255) + .uv(u, 1.0f - v) + .overlayCoords(packedOverlay) + .uv2(packedLight) + .normal(normalMat, outNormal[0], outNormal[1], outNormal[2]) + .endVertex(); + } + } + + /** + * Render a skinned glTF mesh with per-primitive tint colors. + * + *

Each primitive in the mesh is checked against the tintColors map. + * If a primitive is tintable and its channel is present in the map, + * the corresponding RGB color is applied as vertex color (multiplied + * against the texture by the {@code rendertype_entity_cutout_no_cull} shader). + * Non-tintable primitives render with white (no tint).

+ * + *

This is a single VertexConsumer stream — all primitives share the + * same RenderType and draw call, only the vertex color differs per range.

+ * + * @param data parsed glTF data (must have primitives) + * @param jointMatrices computed joint matrices from skinning engine + * @param poseStack current pose stack + * @param buffer multi-buffer source + * @param packedLight packed light value + * @param packedOverlay packed overlay value + * @param renderType the RenderType to use + * @param tintColors channel name to RGB int (0xRRGGBB); empty map = white everywhere + */ + public static void renderSkinnedTinted( + GltfData data, Matrix4f[] jointMatrices, + PoseStack poseStack, MultiBufferSource buffer, + int packedLight, int packedOverlay, + RenderType renderType, + Map tintColors + ) { + Matrix4f pose = poseStack.last().pose(); + Matrix3f normalMat = poseStack.last().normal(); + + VertexConsumer vc = buffer.getBuffer(renderType); + float[] texCoords = data.texCoords(); + + float[] outPos = new float[3]; + float[] outNormal = new float[3]; + Vector4f tmpPos = new Vector4f(); + Vector4f tmpNorm = new Vector4f(); + + List primitives = data.primitives(); + + for (GltfData.Primitive prim : primitives) { + // Determine color for this primitive + int r = 255, g = 255, b = 255; + if (prim.tintable() && prim.tintChannel() != null) { + Integer colorInt = tintColors.get(prim.tintChannel()); + if (colorInt != null) { + r = (colorInt >> 16) & 0xFF; + g = (colorInt >> 8) & 0xFF; + b = colorInt & 0xFF; + } + } + + for (int idx : prim.indices()) { + GltfSkinningEngine.skinVertex(data, idx, jointMatrices, outPos, outNormal, tmpPos, tmpNorm); + + float u = texCoords[idx * 2]; + float v = texCoords[idx * 2 + 1]; + + vc.vertex(pose, outPos[0], outPos[1], outPos[2]) + .color(r, g, b, 255) + .uv(u, 1.0f - v) + .overlayCoords(packedOverlay) + .uv2(packedLight) + .normal(normalMat, outNormal[0], outNormal[1], outNormal[2]) + .endVertex(); + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfPoseConverter.java b/src/main/java/com/tiedup/remake/client/gltf/GltfPoseConverter.java new file mode 100644 index 0000000..3978acf --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfPoseConverter.java @@ -0,0 +1,485 @@ +package com.tiedup.remake.client.gltf; + +import dev.kosmx.playerAnim.core.data.AnimationFormat; +import dev.kosmx.playerAnim.core.data.KeyframeAnimation; +import dev.kosmx.playerAnim.core.util.Ease; +import java.util.HashSet; +import java.util.Set; +import org.jetbrains.annotations.Nullable; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +/** + * Converts glTF rest pose + animation quaternions into a PlayerAnimator KeyframeAnimation. + *

+ * Data is expected to be already in MC coordinate space (converted by GlbParser). + * For upper bones: computes delta quaternion, decomposes to Euler ZYX (pitch/yaw/roll). + * For lower bones: extracts bend angle from delta quaternion. + *

+ * The GLB model's arm pivots are expected to match MC's exactly (world Y=1.376), + * so no angle scaling is needed. If the pivots don't match, fix the Blender model. + *

+ * Produces a static looping pose (beginTick=0, endTick=1, looped). + */ +@OnlyIn(Dist.CLIENT) +public final class GltfPoseConverter { + + private static final Logger LOGGER = LogManager.getLogger("GltfPipeline"); + + private GltfPoseConverter() {} + + /** + * Convert a GltfData's rest pose (or first animation frame) to a KeyframeAnimation. + * Uses the default (first) animation clip. + * GltfData must already be in MC coordinate space. + * + * @param data parsed glTF data (in MC space) + * @return a static looping KeyframeAnimation suitable for PlayerAnimator + */ + public static KeyframeAnimation convert(GltfData data) { + return convertClip(data, data.rawGltfAnimation(), "gltf_pose"); + } + + /** + * Convert a specific named animation from GltfData to a KeyframeAnimation. + * Falls back to the default animation if the name is not found. + * + * @param data parsed glTF data (in MC space) + * @param animationName the name of the animation to convert (e.g. "Struggle", "Idle") + * @return a static looping KeyframeAnimation suitable for PlayerAnimator + */ + public static KeyframeAnimation convert(GltfData data, String animationName) { + GltfData.AnimationClip rawClip = data.getRawAnimation(animationName); + if (rawClip == null) { + LOGGER.warn("[GltfPipeline] Animation '{}' not found, falling back to default", animationName); + return convert(data); + } + return convertClip(data, rawClip, "gltf_" + animationName); + } + + /** + * Convert a GLB animation with selective part enabling and free-bone support. + * + *

Owned parts are always enabled in the output animation. Free parts (in + * {@code enabledParts} but not in {@code ownedParts}) are only enabled if the + * GLB contains actual keyframe data for them. Parts not in {@code enabledParts} + * at all are always disabled (pass through to lower layers).

+ * + * @param data parsed glTF data (in MC space) + * @param animationName animation name in GLB, or null for default + * @param ownedParts parts the item explicitly owns (always enabled) + * @param enabledParts parts the item may animate (owned + free); free parts + * are only enabled if the GLB has keyframes for them + * @return KeyframeAnimation with selective parts active + */ + public static KeyframeAnimation convertSelective(GltfData data, @Nullable String animationName, + Set ownedParts, Set enabledParts) { + GltfData.AnimationClip rawClip; + String animName; + if (animationName != null) { + rawClip = data.getRawAnimation(animationName); + animName = "gltf_" + animationName; + } else { + rawClip = null; + animName = "gltf_pose"; + } + if (rawClip == null) { + rawClip = data.rawGltfAnimation(); + } + return convertClipSelective(data, rawClip, animName, ownedParts, enabledParts); + } + + /** + * Internal: convert a specific raw animation clip with selective part enabling + * and free-bone support. + * + *

Tracks which PlayerAnimator parts received actual keyframe data from the GLB. + * A bone has keyframes if {@code rawClip.rotations()[jointIndex] != null}. + * This information is used by {@link #enableSelectiveParts} to decide whether + * free parts should be enabled.

+ * + * @param ownedParts parts the item explicitly owns (always enabled) + * @param enabledParts parts the item may animate (owned + free) + */ + private static KeyframeAnimation convertClipSelective(GltfData data, GltfData.AnimationClip rawClip, + String animName, Set ownedParts, Set enabledParts) { + KeyframeAnimation.AnimationBuilder builder = + new KeyframeAnimation.AnimationBuilder(AnimationFormat.JSON_EMOTECRAFT); + + builder.beginTick = 0; + builder.endTick = 1; + builder.stopTick = 1; + builder.isLooped = true; + builder.returnTick = 0; + builder.name = animName; + + String[] jointNames = data.jointNames(); + Quaternionf[] rawRestRotations = data.rawGltfRestRotations(); + + // Track which PlayerAnimator part names received actual animation data + Set partsWithKeyframes = new HashSet<>(); + + for (int j = 0; j < data.jointCount(); j++) { + String boneName = jointNames[j]; + if (!GltfBoneMapper.isKnownBone(boneName)) continue; + + // Check if this joint has explicit animation data (not just rest pose fallback). + // A bone counts as explicitly animated if it has rotation OR translation keyframes. + boolean hasExplicitAnim = rawClip != null && ( + (j < rawClip.rotations().length && rawClip.rotations()[j] != null) + || (rawClip.translations() != null + && j < rawClip.translations().length + && rawClip.translations()[j] != null) + ); + + Quaternionf animQ = getRawAnimQuaternion(rawClip, rawRestRotations, j); + Quaternionf restQ = rawRestRotations[j]; + + // delta_local = inverse(rest_q) * anim_q (in bone-local frame) + Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ); + + // Convert to PARENT frame: delta_parent = rest * delta_local * inv(rest) + Quaternionf deltaParent = new Quaternionf(restQ).mul(deltaLocal) + .mul(new Quaternionf(restQ).invert()); + + // Convert from glTF parent frame to MC model-def frame. + // 180deg rotation around Z (X and Y differ): negate qx and qy. + Quaternionf deltaQ = new Quaternionf(deltaParent); + deltaQ.x = -deltaQ.x; + deltaQ.y = -deltaQ.y; + + if (GltfBoneMapper.isLowerBone(boneName)) { + convertLowerBone(builder, boneName, deltaQ); + } else { + convertUpperBone(builder, boneName, deltaQ); + } + + // Record which PlayerAnimator part received data + if (hasExplicitAnim) { + String animPart = GltfBoneMapper.getAnimPartName(boneName); + if (animPart != null) { + partsWithKeyframes.add(animPart); + } + // For lower bones, the keyframe data goes to the upper bone's part + if (GltfBoneMapper.isLowerBone(boneName)) { + String upperBone = GltfBoneMapper.getUpperBoneFor(boneName); + if (upperBone != null) { + String upperPart = GltfBoneMapper.getAnimPartName(upperBone); + if (upperPart != null) { + partsWithKeyframes.add(upperPart); + } + } + } + } + } + + // Selective: enable owned parts always, free parts only if they have keyframes + enableSelectiveParts(builder, ownedParts, enabledParts, partsWithKeyframes); + + KeyframeAnimation anim = builder.build(); + LOGGER.debug("[GltfPipeline] Converted selective animation '{}' (owned: {}, enabled: {}, withKeyframes: {})", + animName, ownedParts, enabledParts, partsWithKeyframes); + return anim; + } + + /** + * Add keyframes for specific owned parts from a GLB animation clip to an existing builder. + * + *

Only writes keyframes for bones that map to a part in {@code ownedParts}. + * Other bones are skipped entirely. This allows multiple items to contribute + * to the same animation builder without overwriting each other's keyframes.

+ * + * @param builder the shared animation builder to add keyframes to + * @param data parsed glTF data + * @param rawClip the raw animation clip, or null for rest pose + * @param ownedParts parts this item exclusively owns (only these get keyframes) + * @return set of part names that received actual keyframe data from the GLB + */ + public static Set addBonesToBuilder( + KeyframeAnimation.AnimationBuilder builder, + GltfData data, @Nullable GltfData.AnimationClip rawClip, + Set ownedParts) { + + String[] jointNames = data.jointNames(); + Quaternionf[] rawRestRotations = data.rawGltfRestRotations(); + Set partsWithKeyframes = new HashSet<>(); + + for (int j = 0; j < data.jointCount(); j++) { + String boneName = jointNames[j]; + if (!GltfBoneMapper.isKnownBone(boneName)) continue; + + // Only process bones that belong to this item's owned parts + String animPart = GltfBoneMapper.getAnimPartName(boneName); + if (animPart == null || !ownedParts.contains(animPart)) continue; + + // For lower bones, check if the UPPER bone's part is owned + // (lower bone keyframes go to the upper bone's StateCollection) + if (GltfBoneMapper.isLowerBone(boneName)) { + String upperBone = GltfBoneMapper.getUpperBoneFor(boneName); + if (upperBone != null) { + String upperPart = GltfBoneMapper.getAnimPartName(upperBone); + if (upperPart == null || !ownedParts.contains(upperPart)) continue; + } + } + + boolean hasExplicitAnim = rawClip != null && ( + (j < rawClip.rotations().length && rawClip.rotations()[j] != null) + || (rawClip.translations() != null + && j < rawClip.translations().length + && rawClip.translations()[j] != null) + ); + + Quaternionf animQ = getRawAnimQuaternion(rawClip, rawRestRotations, j); + Quaternionf restQ = rawRestRotations[j]; + + Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ); + Quaternionf deltaParent = new Quaternionf(restQ).mul(deltaLocal) + .mul(new Quaternionf(restQ).invert()); + + Quaternionf deltaQ = new Quaternionf(deltaParent); + deltaQ.x = -deltaQ.x; + deltaQ.y = -deltaQ.y; + + if (GltfBoneMapper.isLowerBone(boneName)) { + convertLowerBone(builder, boneName, deltaQ); + } else { + convertUpperBone(builder, boneName, deltaQ); + } + + if (hasExplicitAnim) { + partsWithKeyframes.add(animPart); + if (GltfBoneMapper.isLowerBone(boneName)) { + String upperBone = GltfBoneMapper.getUpperBoneFor(boneName); + if (upperBone != null) { + String upperPart = GltfBoneMapper.getAnimPartName(upperBone); + if (upperPart != null) partsWithKeyframes.add(upperPart); + } + } + } + } + + return partsWithKeyframes; + } + + /** + * Convert an animation clip using skeleton data from a separate source. + * + *

This is useful when the animation clip is stored separately from the + * skeleton (e.g., furniture seat animations where the Player_* armature's + * clips are parsed into a separate map from the skeleton GltfData).

+ * + *

The resulting animation has all parts fully enabled. Callers should + * create a mutable copy and selectively disable parts as needed.

+ * + * @param skeleton the GltfData providing rest pose, joint names, and joint count + * @param clip the raw animation clip (in glTF space) to convert + * @param animName debug name for the resulting animation + * @return a static looping KeyframeAnimation with all parts enabled + */ + public static KeyframeAnimation convertWithSkeleton( + GltfData skeleton, GltfData.AnimationClip clip, String animName) { + return convertClip(skeleton, clip, animName); + } + + /** + * Internal: convert a specific raw animation clip to a KeyframeAnimation. + */ + private static KeyframeAnimation convertClip(GltfData data, GltfData.AnimationClip rawClip, String animName) { + KeyframeAnimation.AnimationBuilder builder = + new KeyframeAnimation.AnimationBuilder(AnimationFormat.JSON_EMOTECRAFT); + + builder.beginTick = 0; + builder.endTick = 1; + builder.stopTick = 1; + builder.isLooped = true; + builder.returnTick = 0; + builder.name = animName; + + String[] jointNames = data.jointNames(); + Quaternionf[] rawRestRotations = data.rawGltfRestRotations(); + + for (int j = 0; j < data.jointCount(); j++) { + String boneName = jointNames[j]; + + if (!GltfBoneMapper.isKnownBone(boneName)) continue; + + Quaternionf animQ = getRawAnimQuaternion(rawClip, rawRestRotations, j); + Quaternionf restQ = rawRestRotations[j]; + + // delta_local = inverse(rest_q) * anim_q (in bone-local frame) + Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ); + + // Convert to PARENT frame: delta_parent = rest * delta_local * inv(rest) + // Simplifies algebraically to: animQ * inv(restQ) + Quaternionf deltaParent = new Quaternionf(restQ).mul(deltaLocal) + .mul(new Quaternionf(restQ).invert()); + + // Convert from glTF parent frame to MC model-def frame. + // 180° rotation around Z (X and Y differ): negate qx and qy. + Quaternionf deltaQ = new Quaternionf(deltaParent); + deltaQ.x = -deltaQ.x; + deltaQ.y = -deltaQ.y; + + LOGGER.debug(String.format( + "[GltfPipeline] Bone '%s': restQ=(%.3f,%.3f,%.3f,%.3f) animQ=(%.3f,%.3f,%.3f,%.3f) deltaQ=(%.3f,%.3f,%.3f,%.3f)", + boneName, + restQ.x, restQ.y, restQ.z, restQ.w, + animQ.x, animQ.y, animQ.z, animQ.w, + deltaQ.x, deltaQ.y, deltaQ.z, deltaQ.w)); + + if (GltfBoneMapper.isLowerBone(boneName)) { + convertLowerBone(builder, boneName, deltaQ); + } else { + convertUpperBone(builder, boneName, deltaQ); + } + } + + builder.fullyEnableParts(); + + KeyframeAnimation anim = builder.build(); + LOGGER.debug("[GltfPipeline] Converted glTF animation '{}' to KeyframeAnimation", animName); + return anim; + } + + /** + * Get the raw animation quaternion for a joint from a specific clip. + * Falls back to rest rotation if the clip is null or has no data for this joint. + */ + private static Quaternionf getRawAnimQuaternion( + GltfData.AnimationClip rawClip, Quaternionf[] rawRestRotations, int jointIndex + ) { + if (rawClip != null && jointIndex < rawClip.rotations().length + && rawClip.rotations()[jointIndex] != null) { + return rawClip.rotations()[jointIndex][0]; // first frame + } + return rawRestRotations[jointIndex]; // fallback to rest + } + + private static void convertUpperBone( + KeyframeAnimation.AnimationBuilder builder, + String boneName, Quaternionf deltaQ + ) { + // Decompose delta quaternion to Euler ZYX + // JOML's getEulerAnglesZYX stores: euler.x = X rotation, euler.y = Y rotation, euler.z = Z rotation + // (the "ZYX" refers to rotation ORDER, not storage order) + Vector3f euler = new Vector3f(); + deltaQ.getEulerAnglesZYX(euler); + float pitch = euler.x; // X rotation (pitch) + float yaw = euler.y; // Y rotation (yaw) + float roll = euler.z; // Z rotation (roll) + + LOGGER.debug(String.format( + "[GltfPipeline] Upper bone '%s': pitch=%.1f° yaw=%.1f° roll=%.1f°", + boneName, + Math.toDegrees(pitch), + Math.toDegrees(yaw), + Math.toDegrees(roll))); + + // Get the StateCollection for this body part + String animPart = GltfBoneMapper.getAnimPartName(boneName); + if (animPart == null) return; + + KeyframeAnimation.StateCollection part = getPartByName(builder, animPart); + if (part == null) return; + + part.pitch.addKeyFrame(0, pitch, Ease.CONSTANT); + part.yaw.addKeyFrame(0, yaw, Ease.CONSTANT); + part.roll.addKeyFrame(0, roll, Ease.CONSTANT); + } + + private static void convertLowerBone( + KeyframeAnimation.AnimationBuilder builder, + String boneName, Quaternionf deltaQ + ) { + // Extract bend angle and axis from the delta quaternion + float angle = 2.0f * (float) Math.acos( + Math.min(1.0, Math.abs(deltaQ.w)) + ); + + // Determine bend direction from axis + float bendDirection = 0.0f; + if (deltaQ.x * deltaQ.x + deltaQ.z * deltaQ.z > 0.001f) { + bendDirection = (float) Math.atan2(deltaQ.z, deltaQ.x); + } + + // Sign: if w is negative, the angle wraps + if (deltaQ.w < 0) { + angle = -angle; + } + + LOGGER.debug(String.format( + "[GltfPipeline] Lower bone '%s': bendAngle=%.1f° bendDir=%.1f°", + boneName, + Math.toDegrees(angle), + Math.toDegrees(bendDirection))); + + // Apply bend to the upper bone's StateCollection + String upperBone = GltfBoneMapper.getUpperBoneFor(boneName); + if (upperBone == null) return; + + String animPart = GltfBoneMapper.getAnimPartName(upperBone); + if (animPart == null) return; + + KeyframeAnimation.StateCollection part = getPartByName(builder, animPart); + if (part == null || !part.isBendable) return; + + part.bend.addKeyFrame(0, angle, Ease.CONSTANT); + part.bendDirection.addKeyFrame(0, bendDirection, Ease.CONSTANT); + } + + private static KeyframeAnimation.StateCollection getPartByName( + KeyframeAnimation.AnimationBuilder builder, String name + ) { + return switch (name) { + case "head" -> builder.head; + case "body" -> builder.body; + case "rightArm" -> builder.rightArm; + case "leftArm" -> builder.leftArm; + case "rightLeg" -> builder.rightLeg; + case "leftLeg" -> builder.leftLeg; + default -> null; + }; + } + + /** + * Enable parts selectively based on ownership and keyframe presence. + * + *
    + *
  • Owned parts: always enabled (the item controls these bones)
  • + *
  • Free parts WITH keyframes: enabled (the GLB has animation data for them)
  • + *
  • Free parts WITHOUT keyframes: disabled (no data to animate, pass through to context)
  • + *
  • Other items' parts: disabled (pass through to their own layer)
  • + *
+ * + * @param builder the animation builder with keyframes already added + * @param ownedParts parts the item explicitly owns (always enabled) + * @param enabledParts parts the item may animate (owned + free) + * @param partsWithKeyframes parts that received actual animation data from the GLB + */ + private static void enableSelectiveParts( + KeyframeAnimation.AnimationBuilder builder, + Set ownedParts, Set enabledParts, + Set partsWithKeyframes) { + String[] allParts = {"head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"}; + for (String partName : allParts) { + KeyframeAnimation.StateCollection part = getPartByName(builder, partName); + if (part != null) { + if (ownedParts.contains(partName)) { + // Always enable owned parts — the item controls these bones + part.fullyEnablePart(false); + } else if (enabledParts.contains(partName) && partsWithKeyframes.contains(partName)) { + // Free part WITH keyframes: enable so the GLB animation drives it + part.fullyEnablePart(false); + } else { + // Other item's part, or free part without keyframes: disable. + // Disabled parts pass through to the lower-priority context layer. + part.setEnabled(false); + } + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfRenderLayer.java b/src/main/java/com/tiedup/remake/client/gltf/GltfRenderLayer.java new file mode 100644 index 0000000..93a278a --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfRenderLayer.java @@ -0,0 +1,94 @@ +package com.tiedup.remake.client.gltf; + +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.Minecraft; +import net.minecraft.client.model.PlayerModel; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.entity.RenderLayerParent; +import net.minecraft.client.renderer.entity.layers.RenderLayer; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.joml.Matrix4f; + +/** + * RenderLayer that renders the glTF mesh (handcuffs) on the player. + * Only active when enabled and only renders on the local player. + *

+ * Uses the live skinning path: reads live skeleton from HumanoidModel + * via {@link GltfLiveBoneReader}, following PlayerAnimator + bendy-lib rotations. + * Falls back to GLB-internal skinning via {@link GltfSkinningEngine} if live reading fails. + */ +@OnlyIn(Dist.CLIENT) +public class GltfRenderLayer + extends RenderLayer> { + + private static final Logger LOGGER = LogManager.getLogger("GltfPipeline"); + + private static final ResourceLocation CUFFS_MODEL = + ResourceLocation.fromNamespaceAndPath( + "tiedup", "models/gltf/v2/handcuffs/cuffs_prototype.glb" + ); + + public GltfRenderLayer( + RenderLayerParent> renderer + ) { + super(renderer); + } + + /** + * The Y translate offset to place the glTF mesh in the MC PoseStack. + *

+ * After LivingEntityRenderer's scale(-1,-1,1) + translate(0,-1.501,0), + * the PoseStack origin is at the model top (1.501 blocks above feet), Y-down. + * The glTF mesh (MC-converted) has feet at Y=0 and head at Y≈-1.5. + * Translating by 1.501 maps glTF feet to PoseStack feet and head to top. + */ + private static final float ALIGNMENT_Y = 1.501f; + + @Override + public void render( + PoseStack poseStack, + MultiBufferSource buffer, + int packedLight, + AbstractClientPlayer entity, + float limbSwing, + float limbSwingAmount, + float partialTick, + float ageInTicks, + float netHeadYaw, + float headPitch + ) { + if (!GltfAnimationApplier.isEnabled()) return; + if (entity != Minecraft.getInstance().player) return; + + GltfData data = GltfCache.get(CUFFS_MODEL); + if (data == null) return; + + // Live path: read skeleton from HumanoidModel (after PlayerAnimator) + PlayerModel parentModel = this.getParentModel(); + Matrix4f[] joints = GltfLiveBoneReader.computeJointMatricesFromModel( + parentModel, data, entity + ); + if (joints == null) { + // Fallback to GLB-internal path if live reading fails + joints = GltfSkinningEngine.computeJointMatrices(data); + } + + poseStack.pushPose(); + + // Align glTF mesh with MC model (feet-to-feet alignment) + poseStack.translate(0, ALIGNMENT_Y, 0); + + GltfMeshRenderer.renderSkinned( + data, joints, poseStack, buffer, + packedLight, + net.minecraft.client.renderer.entity.LivingEntityRenderer + .getOverlayCoords(entity, 0.0f) + ); + poseStack.popPose(); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfSkinningEngine.java b/src/main/java/com/tiedup/remake/client/gltf/GltfSkinningEngine.java new file mode 100644 index 0000000..a8078dc --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfSkinningEngine.java @@ -0,0 +1,296 @@ +package com.tiedup.remake.client.gltf; + +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.joml.Matrix4f; +import org.joml.Quaternionf; +import org.joml.Vector3f; +import org.joml.Vector4f; + +/** + * CPU-based Linear Blend Skinning (LBS) engine. + * Computes joint matrices purely from glTF data (rest translations + animation rotations). + * All data is in MC-converted space (consistent with IBMs and vertex positions). + */ +@OnlyIn(Dist.CLIENT) +public final class GltfSkinningEngine { + + private GltfSkinningEngine() {} + + /** + * Compute joint matrices from glTF animation/rest data (default animation). + * Each joint matrix = worldTransform * inverseBindMatrix. + * Uses MC-converted glTF data throughout for consistency. + * + * @param data parsed glTF data (MC-converted) + * @return array of joint matrices ready for skinning + */ + public static Matrix4f[] computeJointMatrices(GltfData data) { + return computeJointMatricesFromClip(data, data.animation()); + } + + /** + * Compute joint matrices with frame interpolation for animated entities. + * Uses SLERP for rotations and LERP for translations between adjacent keyframes. + * + *

The {@code time} parameter is in frame-space: 0.0 corresponds to the first + * keyframe and {@code frameCount - 1} to the last. Values between integer frames + * are interpolated. Out-of-range values are clamped.

+ * + * @param data the parsed glTF data (MC-converted) + * @param clip the animation clip to sample (null = rest pose for all joints) + * @param time time in frame-space (0.0 = first frame, N-1 = last frame) + * @return interpolated joint matrices ready for skinning + */ + public static Matrix4f[] computeJointMatricesAnimated( + GltfData data, GltfData.AnimationClip clip, float time + ) { + int jointCount = data.jointCount(); + Matrix4f[] jointMatrices = new Matrix4f[jointCount]; + Matrix4f[] worldTransforms = new Matrix4f[jointCount]; + + int[] parents = data.parentJointIndices(); + + for (int j = 0; j < jointCount; j++) { + // Build local transform: translate(interpT) * rotate(interpQ) + Matrix4f local = new Matrix4f(); + local.translate(getInterpolatedTranslation(data, clip, j, time)); + local.rotate(getInterpolatedRotation(data, clip, j, time)); + + // Compose with parent + if (parents[j] >= 0 && worldTransforms[parents[j]] != null) { + worldTransforms[j] = new Matrix4f(worldTransforms[parents[j]]).mul(local); + } else { + worldTransforms[j] = new Matrix4f(local); + } + + // Final joint matrix = worldTransform * inverseBindMatrix + jointMatrices[j] = new Matrix4f(worldTransforms[j]) + .mul(data.inverseBindMatrices()[j]); + } + + return jointMatrices; + } + + /** + * Internal: compute joint matrices from a specific animation clip. + */ + private static Matrix4f[] computeJointMatricesFromClip(GltfData data, GltfData.AnimationClip clip) { + int jointCount = data.jointCount(); + Matrix4f[] jointMatrices = new Matrix4f[jointCount]; + Matrix4f[] worldTransforms = new Matrix4f[jointCount]; + + int[] parents = data.parentJointIndices(); + + for (int j = 0; j < jointCount; j++) { + // Build local transform: translate(animT or restT) * rotate(animQ or restQ) + Matrix4f local = new Matrix4f(); + local.translate(getAnimTranslation(data, clip, j)); + local.rotate(getAnimRotation(data, clip, j)); + + // Compose with parent + if (parents[j] >= 0 && worldTransforms[parents[j]] != null) { + worldTransforms[j] = new Matrix4f(worldTransforms[parents[j]]).mul(local); + } else { + worldTransforms[j] = new Matrix4f(local); + } + + // Final joint matrix = worldTransform * inverseBindMatrix + jointMatrices[j] = new Matrix4f(worldTransforms[j]) + .mul(data.inverseBindMatrices()[j]); + } + + return jointMatrices; + } + + /** + * Get the animation rotation for a joint (MC-converted). + * Falls back to rest rotation if no animation. + */ + private static Quaternionf getAnimRotation(GltfData data, GltfData.AnimationClip clip, int jointIndex) { + if (clip != null && jointIndex < clip.rotations().length + && clip.rotations()[jointIndex] != null) { + return clip.rotations()[jointIndex][0]; // first frame + } + return data.restRotations()[jointIndex]; + } + + /** + * Get the animation translation for a joint (MC-converted). + * Falls back to rest translation if no animation translation exists. + */ + private static Vector3f getAnimTranslation(GltfData data, GltfData.AnimationClip clip, int jointIndex) { + if (clip != null && clip.translations() != null + && jointIndex < clip.translations().length + && clip.translations()[jointIndex] != null) { + return clip.translations()[jointIndex][0]; // first frame + } + return data.restTranslations()[jointIndex]; + } + + // ---- Interpolated accessors (for computeJointMatricesAnimated) ---- + + /** + * Get an interpolated rotation for a joint at a fractional frame time. + * Uses SLERP between the two bounding keyframes. + * + *

Falls back to rest rotation when the clip is null or has no rotation + * data for the given joint. A single-frame channel returns that frame directly.

+ * + * @param data parsed glTF data + * @param clip animation clip (may be null) + * @param jointIndex joint to query + * @param time frame-space time (clamped internally) + * @return new Quaternionf with the interpolated rotation (never mutates source data) + */ + private static Quaternionf getInterpolatedRotation( + GltfData data, GltfData.AnimationClip clip, int jointIndex, float time + ) { + if (clip == null || jointIndex >= clip.rotations().length + || clip.rotations()[jointIndex] == null) { + // No animation data for this joint -- use rest pose (copy to avoid mutation) + Quaternionf rest = data.restRotations()[jointIndex]; + return new Quaternionf(rest); + } + + Quaternionf[] frames = clip.rotations()[jointIndex]; + if (frames.length == 1) { + return new Quaternionf(frames[0]); + } + + // Clamp time to valid range [0, frameCount-1] + float clamped = Math.max(0.0f, Math.min(time, frames.length - 1)); + int f0 = (int) Math.floor(clamped); + int f1 = Math.min(f0 + 1, frames.length - 1); + float alpha = clamped - f0; + + if (alpha < 1e-6f || f0 == f1) { + return new Quaternionf(frames[f0]); + } + + // SLERP: create a copy of frame0 and slerp toward frame1 + return new Quaternionf(frames[f0]).slerp(frames[f1], alpha); + } + + /** + * Get an interpolated translation for a joint at a fractional frame time. + * Uses LERP between the two bounding keyframes. + * + *

Falls back to rest translation when the clip is null, the clip has no + * translation data at all, or has no translation data for the given joint. + * A single-frame channel returns that frame directly.

+ * + * @param data parsed glTF data + * @param clip animation clip (may be null) + * @param jointIndex joint to query + * @param time frame-space time (clamped internally) + * @return new Vector3f with the interpolated translation (never mutates source data) + */ + private static Vector3f getInterpolatedTranslation( + GltfData data, GltfData.AnimationClip clip, int jointIndex, float time + ) { + if (clip == null || clip.translations() == null + || jointIndex >= clip.translations().length + || clip.translations()[jointIndex] == null) { + // No animation data for this joint -- use rest pose (copy to avoid mutation) + Vector3f rest = data.restTranslations()[jointIndex]; + return new Vector3f(rest); + } + + Vector3f[] frames = clip.translations()[jointIndex]; + if (frames.length == 1) { + return new Vector3f(frames[0]); + } + + // Clamp time to valid range [0, frameCount-1] + float clamped = Math.max(0.0f, Math.min(time, frames.length - 1)); + int f0 = (int) Math.floor(clamped); + int f1 = Math.min(f0 + 1, frames.length - 1); + float alpha = clamped - f0; + + if (alpha < 1e-6f || f0 == f1) { + return new Vector3f(frames[f0]); + } + + // LERP: create a copy of frame0 and lerp toward frame1 + return new Vector3f(frames[f0]).lerp(frames[f1], alpha); + } + + /** + * Skin a single vertex using Linear Blend Skinning. + * + *

Callers should pre-allocate {@code tmpPos} and {@code tmpNorm} and reuse + * them across all vertices in a mesh to avoid per-vertex allocations (12k+ + * allocations per frame for a typical mesh).

+ * + * @param data parsed glTF data + * @param vertexIdx index into the vertex arrays + * @param jointMatrices joint matrices from computeJointMatrices + * @param outPos output skinned position (3 floats) + * @param outNormal output skinned normal (3 floats) + * @param tmpPos pre-allocated scratch Vector4f for position transforms + * @param tmpNorm pre-allocated scratch Vector4f for normal transforms + */ + public static void skinVertex( + GltfData data, int vertexIdx, Matrix4f[] jointMatrices, + float[] outPos, float[] outNormal, + Vector4f tmpPos, Vector4f tmpNorm + ) { + float[] positions = data.positions(); + float[] normals = data.normals(); + int[] joints = data.joints(); + float[] weights = data.weights(); + + // Rest position + float vx = positions[vertexIdx * 3]; + float vy = positions[vertexIdx * 3 + 1]; + float vz = positions[vertexIdx * 3 + 2]; + + // Rest normal + float nx = normals[vertexIdx * 3]; + float ny = normals[vertexIdx * 3 + 1]; + float nz = normals[vertexIdx * 3 + 2]; + + // LBS: v_skinned = Σ(w[i] * jointMatrix[j[i]] * v_rest) + float sx = 0, sy = 0, sz = 0; + float snx = 0, sny = 0, snz = 0; + + for (int i = 0; i < 4; i++) { + int ji = joints[vertexIdx * 4 + i]; + float w = weights[vertexIdx * 4 + i]; + if (w <= 0.0f || ji >= jointMatrices.length) continue; + + Matrix4f jm = jointMatrices[ji]; + + // Transform position + tmpPos.set(vx, vy, vz, 1.0f); + jm.transform(tmpPos); + sx += w * tmpPos.x; + sy += w * tmpPos.y; + sz += w * tmpPos.z; + + // Transform normal (ignore translation) + tmpNorm.set(nx, ny, nz, 0.0f); + jm.transform(tmpNorm); + snx += w * tmpNorm.x; + sny += w * tmpNorm.y; + snz += w * tmpNorm.z; + } + + outPos[0] = sx; + outPos[1] = sy; + outPos[2] = sz; + + // Normalize the normal + float len = (float) Math.sqrt(snx * snx + sny * sny + snz * snz); + if (len > 0.0001f) { + outNormal[0] = snx / len; + outNormal[1] = sny / len; + outNormal[2] = snz / len; + } else { + outNormal[0] = 0; + outNormal[1] = 1; + outNormal[2] = 0; + } + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/overlays/LaborProgressOverlay.java b/src/main/java/com/tiedup/remake/client/gui/overlays/LaborProgressOverlay.java new file mode 100644 index 0000000..e9e4de9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/overlays/LaborProgressOverlay.java @@ -0,0 +1,158 @@ +package com.tiedup.remake.client.gui.overlays; + +import com.tiedup.remake.client.gui.util.GuiColors; +import com.tiedup.remake.client.gui.util.GuiRenderUtil; +import com.tiedup.remake.client.state.ClientLaborState; +import com.tiedup.remake.core.TiedUpMod; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.RenderGuiOverlayEvent; +import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Overlay that shows labor task progress bar. + * Displayed in the top-right corner when a labor task is active. + */ +@OnlyIn(Dist.CLIENT) +@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT) +public class LaborProgressOverlay { + + // Bar dimensions + private static final int BAR_WIDTH = 150; + private static final int BAR_HEIGHT = 10; + private static final int PADDING = 6; + private static final int MARGIN = 10; + + // Animation + private static float smoothProgress = 0.0f; + private static final float SMOOTH_SPEED = 0.1f; + + @SubscribeEvent + public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) { + // Render after hotbar + if (event.getOverlay() != VanillaGuiOverlay.HOTBAR.type()) { + return; + } + + // Check if we have an active task + if (!ClientLaborState.hasActiveTask()) { + // Fade out smoothly + smoothProgress = Math.max(0, smoothProgress - SMOOTH_SPEED); + if (smoothProgress <= 0.01f) { + return; + } + } else { + // Smooth interpolation towards target + float target = ClientLaborState.getProgressFraction(); + smoothProgress += (target - smoothProgress) * SMOOTH_SPEED; + } + + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null) { + return; + } + + GuiGraphics graphics = event.getGuiGraphics(); + int screenWidth = mc.getWindow().getGuiScaledWidth(); + + // Position in top-right corner + int x = screenWidth - BAR_WIDTH - PADDING * 2 - MARGIN; + int y = MARGIN; + + // Calculate panel dimensions + int panelWidth = BAR_WIDTH + PADDING * 2; + int panelHeight = BAR_HEIGHT + PADDING * 2 + mc.font.lineHeight * 2 + 6; + + // Background panel + graphics.fill( + x, + y, + x + panelWidth, + y + panelHeight, + GuiColors.withAlpha(GuiColors.BG_DARK, 220) + ); + + // Border + GuiRenderUtil.drawBorder( + graphics, + x, + y, + panelWidth, + panelHeight, + GuiColors.ACCENT_TAN + ); + + // Task description + String description = ClientLaborState.getTaskDescription(); + graphics.drawString( + mc.font, + description, + x + PADDING, + y + PADDING, + GuiColors.TEXT_WHITE, + false + ); + + // Progress bar position + int barX = x + PADDING; + int barY = y + PADDING + mc.font.lineHeight + 4; + + // Progress bar background + graphics.fill( + barX, + barY, + barX + BAR_WIDTH, + barY + BAR_HEIGHT, + GuiColors.BG_LIGHT + ); + + // Progress bar fill + int fillWidth = (int) (BAR_WIDTH * smoothProgress); + int fillColor = + smoothProgress >= 1.0f ? GuiColors.SUCCESS : GuiColors.ACCENT_TAN; + graphics.fill( + barX, + barY, + barX + fillWidth, + barY + BAR_HEIGHT, + fillColor + ); + + // Progress bar border + GuiRenderUtil.drawBorder( + graphics, + barX, + barY, + BAR_WIDTH, + BAR_HEIGHT, + GuiColors.BORDER_LIGHT + ); + + // Progress text inside bar + String progressStr = ClientLaborState.getProgressString(); + int textWidth = mc.font.width(progressStr); + graphics.drawString( + mc.font, + progressStr, + barX + (BAR_WIDTH - textWidth) / 2, + barY + (BAR_HEIGHT - mc.font.lineHeight) / 2 + 1, + GuiColors.TEXT_WHITE, + false + ); + + // Value text below bar + String valueStr = ClientLaborState.getValueEmeralds() + " emeralds"; + graphics.drawString( + mc.font, + valueStr, + x + PADDING, + barY + BAR_HEIGHT + 2, + GuiColors.TEXT_GRAY, + false + ); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/overlays/ProgressOverlay.java b/src/main/java/com/tiedup/remake/client/gui/overlays/ProgressOverlay.java new file mode 100644 index 0000000..1aeda38 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/overlays/ProgressOverlay.java @@ -0,0 +1,251 @@ +package com.tiedup.remake.client.gui.overlays; + +import com.tiedup.remake.client.gui.util.GuiColors; +import com.tiedup.remake.client.gui.util.GuiRenderUtil; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.tasks.PlayerStateTask; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.RenderGuiOverlayEvent; +import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Overlay that shows a progress bar for tying/untying/struggling actions. + * Displayed above the hotbar when an action is in progress. + * + * Phase 16: GUI Revamp - Progress bar overlay + */ +@OnlyIn(Dist.CLIENT) +@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT) +public class ProgressOverlay { + + // Bar dimensions + private static final int BAR_WIDTH = 200; + private static final int BAR_HEIGHT = 12; + private static final int PADDING = 4; + + // Animation + private static float smoothProgress = 0.0f; + private static final float SMOOTH_SPEED = 0.15f; + + @SubscribeEvent + public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) { + // Render after crosshair + if (event.getOverlay() != VanillaGuiOverlay.CROSSHAIR.type()) { + return; + } + + Minecraft mc = Minecraft.getInstance(); + Player player = mc.player; + if (player == null) { + return; + } + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + return; + } + + // Check for active actions + ProgressInfo info = getActiveProgress(state); + if (info == null) { + // Fade out smoothly + smoothProgress = Math.max(0, smoothProgress - SMOOTH_SPEED); + if (smoothProgress <= 0.01f) { + return; + } + } else { + // Smooth interpolation + smoothProgress += (info.progress - smoothProgress) * SMOOTH_SPEED; + } + + if (info == null && smoothProgress <= 0.01f) { + return; + } + + GuiGraphics graphics = event.getGuiGraphics(); + int screenWidth = mc.getWindow().getGuiScaledWidth(); + int screenHeight = mc.getWindow().getGuiScaledHeight(); + + // Center horizontally, above hotbar + int x = (screenWidth - BAR_WIDTH) / 2; + int y = screenHeight - 60; + + // Background panel + int panelHeight = BAR_HEIGHT + PADDING * 2 + 12; // +12 for text + graphics.fill( + x - PADDING, + y - PADDING, + x + BAR_WIDTH + PADDING, + y + panelHeight, + GuiColors.withAlpha(GuiColors.BG_DARK, 200) + ); + + // Progress bar background + graphics.fill(x, y, x + BAR_WIDTH, y + BAR_HEIGHT, GuiColors.BG_LIGHT); + + // Progress bar fill + int fillWidth = (int) (BAR_WIDTH * smoothProgress); + int fillColor = info != null ? info.color : GuiColors.ACCENT_TAN; + graphics.fill(x, y, x + fillWidth, y + BAR_HEIGHT, fillColor); + + // Border + GuiRenderUtil.drawBorder( + graphics, + x, + y, + BAR_WIDTH, + BAR_HEIGHT, + GuiColors.BORDER_LIGHT + ); + + // Percentage text + String percent = String.format("%.0f%%", smoothProgress * 100); + int textWidth = mc.font.width(percent); + graphics.drawString( + mc.font, + percent, + x + (BAR_WIDTH - textWidth) / 2, + y + (BAR_HEIGHT - mc.font.lineHeight) / 2 + 1, + GuiColors.TEXT_WHITE, + false + ); + + // Description text + if (info != null) { + graphics.drawCenteredString( + mc.font, + info.description, + x + BAR_WIDTH / 2, + y + BAR_HEIGHT + PADDING, + GuiColors.TEXT_GRAY + ); + } + } + + /** + * Get active progress information. + */ + private static ProgressInfo getActiveProgress(PlayerBindState state) { + // Check client tying task + PlayerStateTask tyingTask = state.getClientTyingTask(); + if (tyingTask != null && !tyingTask.isOutdated()) { + float progress = tyingTask.getProgress(); + Component text = getTyingText(tyingTask); + return new ProgressInfo(progress, GuiColors.WARNING, text); + } + + // Check client untying task + PlayerStateTask untyingTask = state.getClientUntyingTask(); + if (untyingTask != null && !untyingTask.isOutdated()) { + float progress = untyingTask.getProgress(); + Component text = getUntyingText(untyingTask); + return new ProgressInfo(progress, GuiColors.SUCCESS, text); + } + + // Check client feeding task + PlayerStateTask feedingTask = state.getClientFeedingTask(); + if (feedingTask != null && !feedingTask.isOutdated()) { + float progress = feedingTask.getProgress(); + Component text = getFeedingText(feedingTask); + return new ProgressInfo(progress, GuiColors.ACCENT_TAN, text); + } + + return null; + } + + /** + * Get the appropriate text for tying progress based on role. + */ + private static Component getTyingText(PlayerStateTask task) { + String otherName = task.getOtherEntityName(); + if (otherName == null || otherName.isEmpty()) { + otherName = "???"; + } + + if (task.isKidnapper()) { + // Kidnapper sees: "Tying [target]..." + return Component.translatable( + "gui.tiedup.action.tying_target", + otherName + ); + } else { + // Victim sees: "[kidnapper] is tying you up!" + return Component.translatable( + "gui.tiedup.action.being_tied_by", + otherName + ); + } + } + + /** + * Get the appropriate text for untying progress based on role. + */ + private static Component getUntyingText(PlayerStateTask task) { + String otherName = task.getOtherEntityName(); + if (otherName == null || otherName.isEmpty()) { + otherName = "???"; + } + + if (task.isKidnapper()) { + // Helper sees: "Untying [target]..." + return Component.translatable( + "gui.tiedup.action.untying_target", + otherName + ); + } else { + // Victim sees: "[helper] is untying you!" + return Component.translatable( + "gui.tiedup.action.being_untied_by", + otherName + ); + } + } + + /** + * Get the appropriate text for feeding progress based on role. + */ + private static Component getFeedingText(PlayerStateTask task) { + String otherName = task.getOtherEntityName(); + if (otherName == null || otherName.isEmpty()) { + otherName = "???"; + } + + if (task.isKidnapper()) { + // Feeder sees: "Feeding [target]..." + return Component.translatable( + "gui.tiedup.action.feeding_target", + otherName + ); + } else { + // Target sees: "[feeder] is feeding you!" + return Component.translatable( + "gui.tiedup.action.being_fed_by", + otherName + ); + } + } + + /** + * Progress information container. + */ + private static class ProgressInfo { + + final float progress; + final int color; + final Component description; + + ProgressInfo(float progress, int color, Component description) { + this.progress = progress; + this.color = color; + this.description = description; + } + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/overlays/StatusOverlay.java b/src/main/java/com/tiedup/remake/client/gui/overlays/StatusOverlay.java new file mode 100644 index 0000000..0735640 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/overlays/StatusOverlay.java @@ -0,0 +1,202 @@ +package com.tiedup.remake.client.gui.overlays; + +import com.tiedup.remake.client.gui.util.GuiColors; +import com.tiedup.remake.client.gui.util.GuiRenderUtil; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.PlayerBindState; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.RenderGuiOverlayEvent; +import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Overlay that shows status icons when player is restrained. + * Icons appear in top-left corner showing current bondage state. + * + * Phase 16: GUI Revamp - Status indicator overlay + */ +@OnlyIn(Dist.CLIENT) +@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT) +public class StatusOverlay { + + // Icon size and spacing + private static final int ICON_SIZE = 16; + private static final int PADDING = 2; + private static final int MARGIN = 5; + + // Visibility toggle + private static boolean visible = true; + + // Icon colors (use centralized GuiColors) + private static final int COLOR_BOUND = GuiColors.TYPE_BIND; + private static final int COLOR_GAGGED = GuiColors.TYPE_GAG; + private static final int COLOR_BLIND = GuiColors.TYPE_BLINDFOLD; + private static final int COLOR_DEAF = GuiColors.TYPE_EARPLUGS; + private static final int COLOR_COLLAR = GuiColors.TYPE_COLLAR; + private static final int COLOR_MITTENS = GuiColors.TYPE_MITTENS; + + /** + * Toggle overlay visibility. + */ + public static void toggleVisibility() { + visible = !visible; + } + + /** + * Set overlay visibility. + */ + public static void setVisible(boolean vis) { + visible = vis; + } + + /** + * Check if overlay is visible. + */ + public static boolean isVisible() { + return visible; + } + + @SubscribeEvent + public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) { + // Only render after hotbar + if (event.getOverlay() != VanillaGuiOverlay.HOTBAR.type()) { + return; + } + + if (!visible) { + return; + } + + Minecraft mc = Minecraft.getInstance(); + Player player = mc.player; + if (player == null) { + return; + } + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + return; + } + + // Don't show if not restrained at all + if ( + !state.isTiedUp() && + !state.isGagged() && + !state.isBlindfolded() && + !state.hasEarplugs() && + !state.hasCollar() && + !state.hasMittens() + ) { + return; + } + + GuiGraphics graphics = event.getGuiGraphics(); + + // Position: top-left corner + int x = MARGIN; + int y = MARGIN; + + // Background panel + int iconCount = countActiveIcons(state); + if (iconCount > 0) { + int cols = Math.min(iconCount, 3); + int rows = (iconCount + 2) / 3; + int panelWidth = cols * (ICON_SIZE + PADDING) + PADDING; + int panelHeight = rows * (ICON_SIZE + PADDING) + PADDING; + + // Semi-transparent background + graphics.fill( + x - PADDING, + y - PADDING, + x + panelWidth, + y + panelHeight, + GuiColors.withAlpha(GuiColors.BG_DARK, 180) + ); + } + + // Render icons in grid (max 3 per row) + int iconIndex = 0; + + if (state.isTiedUp()) { + renderIcon(graphics, x, y, iconIndex++, COLOR_BOUND, "B"); + } + if (state.isGagged()) { + renderIcon(graphics, x, y, iconIndex++, COLOR_GAGGED, "G"); + } + if (state.isBlindfolded()) { + renderIcon(graphics, x, y, iconIndex++, COLOR_BLIND, "X"); + } + if (state.hasEarplugs()) { + renderIcon(graphics, x, y, iconIndex++, COLOR_DEAF, "D"); + } + if (state.hasCollar()) { + renderIcon(graphics, x, y, iconIndex++, COLOR_COLLAR, "C"); + } + if (state.hasMittens()) { + renderIcon(graphics, x, y, iconIndex++, COLOR_MITTENS, "M"); + } + } + + /** + * Count active status icons. + */ + private static int countActiveIcons(PlayerBindState state) { + int count = 0; + if (state.isTiedUp()) count++; + if (state.isGagged()) count++; + if (state.isBlindfolded()) count++; + if (state.hasEarplugs()) count++; + if (state.hasCollar()) count++; + if (state.hasMittens()) count++; + return count; + } + + /** + * Render a single status icon. + * Uses colored squares with letters as placeholders until proper textures are made. + */ + private static void renderIcon( + GuiGraphics graphics, + int baseX, + int baseY, + int index, + int color, + String letter + ) { + int col = index % 3; + int row = index / 3; + + int x = baseX + col * (ICON_SIZE + PADDING); + int y = baseY + row * (ICON_SIZE + PADDING); + + // Icon background + graphics.fill(x, y, x + ICON_SIZE, y + ICON_SIZE, color); + + // Border + GuiRenderUtil.drawBorder( + graphics, + x, + y, + ICON_SIZE, + ICON_SIZE, + GuiColors.darken(color, 0.3f) + ); + + // Letter (centered) + Minecraft mc = Minecraft.getInstance(); + int textWidth = mc.font.width(letter); + graphics.drawString( + mc.font, + letter, + x + (ICON_SIZE - textWidth) / 2, + y + (ICON_SIZE - mc.font.lineHeight) / 2 + 1, + GuiColors.TEXT_WHITE, + false + ); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/overlays/UntieTooltipOverlay.java b/src/main/java/com/tiedup/remake/client/gui/overlays/UntieTooltipOverlay.java new file mode 100644 index 0000000..e74416d --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/overlays/UntieTooltipOverlay.java @@ -0,0 +1,176 @@ +package com.tiedup.remake.client.gui.overlays; + +import com.tiedup.remake.client.ModKeybindings; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.EntityHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.RenderGuiOverlayEvent; +import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Overlay that shows a tooltip when looking at a tied player/NPC. + * Displayed below the crosshair when aiming at a restrainable entity. + * + * Shows contextual hints like: + * - "[Right-click] Untie" when looking at tied entity + * - "[Right-click] Remove gag" when looking at gagged entity + */ +@OnlyIn(Dist.CLIENT) +@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT) +public class UntieTooltipOverlay { + + /** Offset below the crosshair */ + private static final int Y_OFFSET = 12; + + /** Text color (white with slight transparency) */ + private static final int TEXT_COLOR = 0xFFFFFFFF; + + /** Shadow color */ + private static final int SHADOW_COLOR = 0x80000000; + + @SubscribeEvent + public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) { + // Render after crosshair + if (event.getOverlay() != VanillaGuiOverlay.CROSSHAIR.type()) { + return; + } + + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null) { + return; + } + + // Check what we're looking at + HitResult hitResult = mc.hitResult; + if (!(hitResult instanceof EntityHitResult entityHit)) { + return; + } + + Entity target = entityHit.getEntity(); + if (!(target instanceof LivingEntity livingTarget)) { + return; + } + + // Get the target's kidnapped state + IBondageState state = KidnappedHelper.getKidnappedState(livingTarget); + if (state == null) { + return; + } + + // Determine what action is available + String tooltip = getTooltipText(state); + if (tooltip == null) { + return; + } + + // Render the tooltip below the crosshair + GuiGraphics graphics = event.getGuiGraphics(); + int screenWidth = mc.getWindow().getGuiScaledWidth(); + int screenHeight = mc.getWindow().getGuiScaledHeight(); + + int centerX = screenWidth / 2; + int centerY = screenHeight / 2; + + // Draw text centered below crosshair + int textWidth = mc.font.width(tooltip); + int textX = centerX - textWidth / 2; + int textY = centerY + Y_OFFSET; + + // Draw shadow first for better readability + graphics.drawString( + mc.font, + tooltip, + textX + 1, + textY + 1, + SHADOW_COLOR, + false + ); + graphics.drawString(mc.font, tooltip, textX, textY, TEXT_COLOR, false); + } + + /** + * Get the appropriate tooltip text based on the target's state. + * Respects lock status - locked items show "Help struggle" instead. + * + * @param state The target's kidnapped state + * @return The tooltip text, or null if no action is available + */ + private static String getTooltipText(IBondageState state) { + // Priority order: untie > ungag > unblindfold > uncollar + // If item is locked, can only help struggle (not remove directly) + String struggleKey = + "[" + + ModKeybindings.STRUGGLE_KEY.getTranslatedKeyMessage().getString() + + "]"; + + if (state.isTiedUp()) { + ItemStack bind = state.getEquipment(BodyRegionV2.ARMS); + if (state.isLocked(bind, false)) { + return struggleKey + " Help struggle"; + } + return "[Right-click] Untie"; + } + + if (state.isGagged()) { + // Check if player is holding food → force feeding prompt + Minecraft mc = Minecraft.getInstance(); + if ( + mc.player != null && + mc.player.getMainHandItem().getItem().isEdible() + ) { + return "[Right-click] Feed"; + } + ItemStack gag = state.getEquipment(BodyRegionV2.MOUTH); + if (state.isLocked(gag, false)) { + return struggleKey + " Help struggle"; + } + return "[Right-click] Remove gag"; + } + + if (state.isBlindfolded()) { + ItemStack blindfold = state.getEquipment(BodyRegionV2.EYES); + if (state.isLocked(blindfold, false)) { + return struggleKey + " Help struggle"; + } + return "[Right-click] Remove blindfold"; + } + + if (state.hasCollar()) { + ItemStack collar = state.getEquipment(BodyRegionV2.NECK); + if (state.isLocked(collar, false)) { + return struggleKey + " Help struggle"; + } + return "[Right-click] Remove collar"; + } + + if (state.hasEarplugs()) { + ItemStack earplugs = state.getEquipment(BodyRegionV2.EARS); + if (state.isLocked(earplugs, false)) { + return struggleKey + " Help struggle"; + } + return "[Right-click] Remove earplugs"; + } + + if (state.hasMittens()) { + ItemStack mittens = state.getEquipment(BodyRegionV2.HANDS); + if (state.isLocked(mittens, false)) { + return struggleKey + " Help struggle"; + } + return "[Right-click] Remove mittens"; + } + + return null; + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/screens/AdjustmentScreen.java b/src/main/java/com/tiedup/remake/client/gui/screens/AdjustmentScreen.java new file mode 100644 index 0000000..9ff667f --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/screens/AdjustmentScreen.java @@ -0,0 +1,87 @@ +package com.tiedup.remake.client.gui.screens; + +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.item.PacketAdjustItem; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.state.PlayerBindState; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Screen for adjusting Y position of the player's own gags and blindfolds. + * Shows 3D preview of player with real-time adjustment. + * + * Phase 16b: GUI Refactoring - Simplified using BaseAdjustmentScreen + */ +@OnlyIn(Dist.CLIENT) +public class AdjustmentScreen extends BaseAdjustmentScreen { + + public AdjustmentScreen() { + super(Component.translatable("gui.tiedup.adjust_position")); + } + + // ==================== ABSTRACT IMPLEMENTATIONS ==================== + + @Override + protected LivingEntity getTargetEntity() { + return this.minecraft.player; + } + + @Override + protected ItemStack getGag() { + Player player = this.minecraft.player; + if (player == null) return ItemStack.EMPTY; + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) return ItemStack.EMPTY; + + return state.getEquipment(BodyRegionV2.MOUTH); + } + + @Override + protected ItemStack getBlindfold() { + Player player = this.minecraft.player; + if (player == null) return ItemStack.EMPTY; + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) return ItemStack.EMPTY; + + return state.getEquipment(BodyRegionV2.EYES); + } + + @Override + protected void sendAdjustment(Mode mode, float value, float scale) { + BodyRegionV2 region = switch (mode) { + case GAG -> BodyRegionV2.MOUTH; + case BLINDFOLD -> BodyRegionV2.EYES; + case BOTH -> null; // Handled separately in applyAdjustment + }; + + if (region != null) { + ModNetwork.sendToServer(new PacketAdjustItem(region, value, scale)); + } + } + + // ==================== STATIC HELPERS ==================== + + /** + * Check if this screen should be openable (player has adjustable items). + */ + public static boolean canOpen() { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null) return false; + + PlayerBindState state = PlayerBindState.getInstance(mc.player); + if (state == null) return false; + + return ( + !state.getEquipment(BodyRegionV2.MOUTH).isEmpty() || + !state.getEquipment(BodyRegionV2.EYES).isEmpty() + ); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/screens/BaseAdjustmentScreen.java b/src/main/java/com/tiedup/remake/client/gui/screens/BaseAdjustmentScreen.java new file mode 100644 index 0000000..b525565 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/screens/BaseAdjustmentScreen.java @@ -0,0 +1,481 @@ +package com.tiedup.remake.client.gui.screens; + +import static com.tiedup.remake.client.gui.util.GuiLayoutConstants.*; + +import com.tiedup.remake.client.gui.util.GuiColors; +import com.tiedup.remake.client.gui.widgets.AdjustmentSlider; +import com.tiedup.remake.client.gui.widgets.EntityPreviewWidget; +import com.tiedup.remake.items.base.AdjustmentHelper; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.network.chat.Component; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Base class for adjustment screens (player self and remote slave adjustment). + * Refactored to use new BaseScreen architecture. + */ +@OnlyIn(Dist.CLIENT) +public abstract class BaseAdjustmentScreen extends BaseScreen { + + /** + * Adjustment mode - which item(s) to adjust. + */ + protected enum Mode { + GAG, + BLINDFOLD, + BOTH, + } + + // Current state + protected Mode currentMode = Mode.GAG; + protected float currentValue = 0.0f; + protected float currentScaleValue = AdjustmentHelper.DEFAULT_SCALE; + + // Widgets + protected EntityPreviewWidget preview; + protected AdjustmentSlider slider; + protected Button gagButton; + protected Button blindfoldButton; + protected Button bothButton; + protected Button resetButton; + protected Button decrementButton; + protected Button incrementButton; + protected Button scaleDecrementButton; + protected Button scaleIncrementButton; + protected Button scaleResetButton; + protected Button doneButton; + + // Selected mode indicator colors + private static final int TAB_SELECTED = GuiColors.ACCENT_BROWN; + + protected BaseAdjustmentScreen(Component title) { + super(title); + } + + @Override + protected int getPreferredWidth() { + // Target: ~320px + return 320; + } + + @Override + protected int getPreferredHeight() { + // Target: ~290px (extra row for scale controls) + return 290; + } + + // ==================== ABSTRACT METHODS ==================== + + protected abstract LivingEntity getTargetEntity(); + + protected abstract ItemStack getGag(); + + protected abstract ItemStack getBlindfold(); + + protected abstract void sendAdjustment(Mode mode, float value, float scale); + + protected String getExtraInfo() { + return null; + } + + // ==================== INITIALIZATION ==================== + + @Override + protected void init() { + super.init(); + + // Determine initial mode based on what's equipped + if (getGag().isEmpty() && !getBlindfold().isEmpty()) { + currentMode = Mode.BLINDFOLD; + } + + // Load current adjustment value + loadCurrentValue(); + + setupPreview(); + setupSlider(); + setupModeTabs(); + setupActionButtons(); + setupScaleButtons(); + setupDoneButton(); + + updateButtonStates(); + } + + private void setupPreview() { + // Preview on left side of panel + int previewX = this.leftPos + MARGIN_L; + int previewY = this.topPos + TITLE_HEIGHT + MARGIN_M; + int previewWidth = 120; // Fixed width for preview area + int previewHeight = 140; + + LivingEntity entity = getTargetEntity(); + if (entity != null) { + preview = new EntityPreviewWidget( + previewX, + previewY, + previewWidth, + previewHeight, + entity + ); + preview.setAutoRotate(false); + this.addRenderableWidget(preview); + } + } + + private void setupSlider() { + // Slider on right side of panel + int sliderWidth = 40; + int sliderX = + this.leftPos + this.imageWidth - sliderWidth - MARGIN_L - 10; + int sliderY = this.topPos + TITLE_HEIGHT + MARGIN_M; + int sliderHeight = AdjustmentSlider.getRecommendedHeight(120); + + slider = new AdjustmentSlider( + sliderX, + sliderY, + sliderWidth, + sliderHeight, + -4.0f, + 4.0f, + currentValue, + this::onSliderChanged + ); + this.addRenderableWidget(slider); + } + + private void setupModeTabs() { + // Mode tabs below preview and slider area + int tabY = this.topPos + TITLE_HEIGHT + 140 + 10; // Below preview + int tabWidth = 60; + int totalTabsWidth = tabWidth * 3 + MARGIN_S * 2; + int tabStartX = this.leftPos + (this.imageWidth - totalTabsWidth) / 2; + + gagButton = Button.builder( + Component.translatable("gui.tiedup.tab.gag"), + b -> setMode(Mode.GAG) + ) + .bounds(tabStartX, tabY, tabWidth, BUTTON_HEIGHT) + .build(); + this.addRenderableWidget(gagButton); + + blindfoldButton = Button.builder( + Component.translatable("gui.tiedup.tab.blindfold"), + b -> setMode(Mode.BLINDFOLD) + ) + .bounds( + tabStartX + tabWidth + MARGIN_S, + tabY, + tabWidth, + BUTTON_HEIGHT + ) + .build(); + this.addRenderableWidget(blindfoldButton); + + bothButton = Button.builder( + Component.translatable("gui.tiedup.tab.both"), + b -> setMode(Mode.BOTH) + ) + .bounds( + tabStartX + (tabWidth + MARGIN_S) * 2, + tabY, + tabWidth, + BUTTON_HEIGHT + ) + .build(); + this.addRenderableWidget(bothButton); + } + + private void setupActionButtons() { + // Action buttons below tabs + int actionY = gagButton.getY() + BUTTON_HEIGHT + MARGIN_S; + int buttonWidth = 45; + int totalWidth = buttonWidth * 3 + MARGIN_S * 2; + int actionStartX = this.leftPos + (this.imageWidth - totalWidth) / 2; + + resetButton = Button.builder(Component.literal("0"), b -> resetValue()) + .bounds(actionStartX, actionY, buttonWidth, BUTTON_HEIGHT) + .build(); + this.addRenderableWidget(resetButton); + + decrementButton = Button.builder(Component.literal("-0.25"), b -> + slider.decrement() + ) + .bounds( + actionStartX + buttonWidth + MARGIN_S, + actionY, + buttonWidth, + BUTTON_HEIGHT + ) + .build(); + this.addRenderableWidget(decrementButton); + + incrementButton = Button.builder(Component.literal("+0.25"), b -> + slider.increment() + ) + .bounds( + actionStartX + (buttonWidth + MARGIN_S) * 2, + actionY, + buttonWidth, + BUTTON_HEIGHT + ) + .build(); + this.addRenderableWidget(incrementButton); + } + + private void setupScaleButtons() { + // Scale controls below position action buttons + int scaleY = incrementButton.getY() + BUTTON_HEIGHT + MARGIN_S; + int buttonWidth = 45; + int totalWidth = buttonWidth * 3 + MARGIN_S * 2; + int scaleStartX = this.leftPos + (this.imageWidth - totalWidth) / 2; + + scaleResetButton = Button.builder(Component.literal("1x"), b -> + applyScale(AdjustmentHelper.DEFAULT_SCALE) + ) + .bounds(scaleStartX, scaleY, buttonWidth, BUTTON_HEIGHT) + .build(); + this.addRenderableWidget(scaleResetButton); + + scaleDecrementButton = Button.builder(Component.literal("-0.1"), b -> + applyScale(currentScaleValue - AdjustmentHelper.SCALE_STEP) + ) + .bounds( + scaleStartX + buttonWidth + MARGIN_S, + scaleY, + buttonWidth, + BUTTON_HEIGHT + ) + .build(); + this.addRenderableWidget(scaleDecrementButton); + + scaleIncrementButton = Button.builder(Component.literal("+0.1"), b -> + applyScale(currentScaleValue + AdjustmentHelper.SCALE_STEP) + ) + .bounds( + scaleStartX + (buttonWidth + MARGIN_S) * 2, + scaleY, + buttonWidth, + BUTTON_HEIGHT + ) + .build(); + this.addRenderableWidget(scaleIncrementButton); + } + + private void setupDoneButton() { + // Done button at bottom + int doneX = this.leftPos + (this.imageWidth - BUTTON_WIDTH_L) / 2; + int doneY = this.topPos + this.imageHeight - BUTTON_HEIGHT - MARGIN_M; + + doneButton = Button.builder( + Component.translatable("gui.tiedup.done"), + b -> onClose() + ) + .bounds(doneX, doneY, BUTTON_WIDTH_L, BUTTON_HEIGHT) + .build(); + this.addRenderableWidget(doneButton); + } + + // ==================== LOGIC ==================== + + protected void loadCurrentValue() { + ItemStack stack = switch (currentMode) { + case GAG -> getGag(); + case BLINDFOLD -> getBlindfold(); + case BOTH -> { + ItemStack gag = getGag(); + yield gag.isEmpty() ? getBlindfold() : gag; + } + }; + + if (!stack.isEmpty()) { + currentValue = AdjustmentHelper.getAdjustment(stack); + currentScaleValue = AdjustmentHelper.getScale(stack); + } else { + currentValue = 0.0f; + currentScaleValue = AdjustmentHelper.DEFAULT_SCALE; + } + if (slider != null) { + slider.setValue(currentValue); + } + } + + private void onSliderChanged(float newValue) { + this.currentValue = newValue; + applyAdjustment(newValue); + if (preview != null) { + preview.refresh(); + } + } + + protected void applyAdjustment(float value) { + switch (currentMode) { + case GAG -> { + ItemStack gag = getGag(); + if (!gag.isEmpty()) { + AdjustmentHelper.setAdjustment(gag, value); + AdjustmentHelper.setScale(gag, currentScaleValue); + sendAdjustment(Mode.GAG, value, currentScaleValue); + } + } + case BLINDFOLD -> { + ItemStack blind = getBlindfold(); + if (!blind.isEmpty()) { + AdjustmentHelper.setAdjustment(blind, value); + AdjustmentHelper.setScale(blind, currentScaleValue); + sendAdjustment(Mode.BLINDFOLD, value, currentScaleValue); + } + } + case BOTH -> { + ItemStack gag = getGag(); + ItemStack blind = getBlindfold(); + if (!gag.isEmpty()) { + AdjustmentHelper.setAdjustment(gag, value); + AdjustmentHelper.setScale(gag, currentScaleValue); + sendAdjustment(Mode.GAG, value, currentScaleValue); + } + if (!blind.isEmpty()) { + AdjustmentHelper.setAdjustment(blind, value); + AdjustmentHelper.setScale(blind, currentScaleValue); + sendAdjustment(Mode.BLINDFOLD, value, currentScaleValue); + } + } + } + } + + protected void applyScale(float scale) { + this.currentScaleValue = Mth.clamp( + scale, + AdjustmentHelper.MIN_SCALE, + AdjustmentHelper.MAX_SCALE + ); + // Re-apply both values together (scale changed, position stays) + applyAdjustment(currentValue); + if (preview != null) { + preview.refresh(); + } + } + + protected void setMode(Mode mode) { + this.currentMode = mode; + loadCurrentValue(); + updateButtonStates(); + } + + protected void resetValue() { + if (slider != null) { + slider.setValue(0.0f); + } + } + + protected void updateButtonStates() { + boolean hasGag = !getGag().isEmpty(); + boolean hasBlind = !getBlindfold().isEmpty(); + + if (gagButton != null) gagButton.active = hasGag; + if (blindfoldButton != null) blindfoldButton.active = hasBlind; + if (bothButton != null) bothButton.active = hasGag || hasBlind; + } + + // ==================== RENDERING ==================== + + @Override + public void render( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + super.render(graphics, mouseX, mouseY, partialTick); + + // Draw selected mode indicator + renderModeIndicator(graphics); + + // Extra info (e.g., slave name) + String extraInfo = getExtraInfo(); + if (extraInfo != null) { + graphics.drawCenteredString( + this.font, + extraInfo, + this.leftPos + this.imageWidth / 2, + this.topPos + TITLE_HEIGHT - 2, + GuiColors.ACCENT_TAN + ); + } + + // Scale label above scale buttons + if (scaleResetButton != null) { + String scaleLabel = + Component.translatable( + "gui.tiedup.adjustment.scale" + ).getString() + + ": " + + String.format("%.1fx", currentScaleValue); + graphics.drawCenteredString( + this.font, + scaleLabel, + this.leftPos + this.imageWidth / 2, + scaleResetButton.getY() - 10, + GuiColors.TEXT_GRAY + ); + } + + // Current item info + String itemInfo = getCurrentItemInfo(); + graphics.drawCenteredString( + this.font, + itemInfo, + this.leftPos + this.imageWidth / 2, + this.topPos + this.imageHeight - BUTTON_HEIGHT - MARGIN_L - 10, + GuiColors.TEXT_GRAY + ); + } + + private void renderModeIndicator(GuiGraphics graphics) { + Button selectedButton = switch (currentMode) { + case GAG -> gagButton; + case BLINDFOLD -> blindfoldButton; + case BOTH -> bothButton; + }; + + if (selectedButton != null) { + int indicatorY = selectedButton.getY() + selectedButton.getHeight(); + graphics.fill( + selectedButton.getX() + 2, + indicatorY, + selectedButton.getX() + selectedButton.getWidth() - 2, + indicatorY + 2, + TAB_SELECTED + ); + } + } + + protected String getCurrentItemInfo() { + return switch (currentMode) { + case GAG -> { + ItemStack gag = getGag(); + yield gag.isEmpty() + ? Component.translatable( + "gui.tiedup.adjustment.no_gag" + ).getString() + : gag.getHoverName().getString(); + } + case BLINDFOLD -> { + ItemStack blind = getBlindfold(); + yield blind.isEmpty() + ? Component.translatable( + "gui.tiedup.adjustment.no_blindfold" + ).getString() + : blind.getHoverName().getString(); + } + case BOTH -> Component.translatable( + "gui.tiedup.adjustment.both" + ).getString(); + }; + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/screens/BaseInteractionScreen.java b/src/main/java/com/tiedup/remake/client/gui/screens/BaseInteractionScreen.java new file mode 100644 index 0000000..a756256 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/screens/BaseInteractionScreen.java @@ -0,0 +1,128 @@ +package com.tiedup.remake.client.gui.screens; + +import com.tiedup.remake.client.gui.util.GuiTextureHelper; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Base class for interaction screens (Command Wand, Dialogue, Conversation, Pet Request). + * Provides common layout functionality and reduces code duplication. + */ +@OnlyIn(Dist.CLIENT) +public abstract class BaseInteractionScreen extends Screen { + + protected int leftPos; + protected int topPos; + protected final int panelWidth; + protected final int panelHeight; + + protected BaseInteractionScreen( + Component title, + int panelWidth, + int panelHeight + ) { + super(title); + this.panelWidth = panelWidth; + this.panelHeight = panelHeight; + } + + @Override + protected void init() { + super.init(); + // Center the panel on screen + this.leftPos = (this.width - panelWidth) / 2; + this.topPos = (this.height - panelHeight) / 2; + } + + @Override + public void render( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + // Dark background + this.renderBackground(graphics); + + // Main panel with vanilla-style bevel + GuiTextureHelper.renderBeveledPanel( + graphics, + leftPos, + topPos, + panelWidth, + panelHeight + ); + + // Let subclasses render their content + renderContent(graphics, mouseX, mouseY, partialTick); + + // Render widgets (buttons) on top + super.render(graphics, mouseX, mouseY, partialTick); + } + + /** + * Render the screen's content. Called after the panel background is drawn. + * + * @param graphics Graphics context + * @param mouseX Mouse X position + * @param mouseY Mouse Y position + * @param partialTick Partial tick for interpolation + */ + protected abstract void renderContent( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ); + + /** + * Get the X position for content with the given margin. + * + * @param margin Margin from the left edge + * @return Content X position + */ + protected int getContentX(int margin) { + return leftPos + margin; + } + + /** + * Get the width available for content with the given margin. + * + * @param margin Margin on both sides + * @return Content width + */ + protected int getContentWidth(int margin) { + return panelWidth - margin * 2; + } + + /** + * Render a centered title at the given Y position. + * + * @param graphics Graphics context + * @param title Title component + * @param y Y position + * @param color Text color + */ + protected void renderTitle( + GuiGraphics graphics, + Component title, + int y, + int color + ) { + graphics.drawCenteredString( + this.font, + title, + leftPos + panelWidth / 2, + y, + color + ); + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/screens/BaseScreen.java b/src/main/java/com/tiedup/remake/client/gui/screens/BaseScreen.java new file mode 100644 index 0000000..615fcfc --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/screens/BaseScreen.java @@ -0,0 +1,158 @@ +package com.tiedup.remake.client.gui.screens; + +import com.tiedup.remake.client.gui.util.GuiColors; +import com.tiedup.remake.client.gui.util.GuiLayoutConstants; +import com.tiedup.remake.client.gui.util.GuiRenderUtil; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Base abstract class for TiedUp! GUI screens. + * Handles responsive layout, background rendering, and common utilities. + * + * Refactored for cleaner architecture. + */ +@OnlyIn(Dist.CLIENT) +public abstract class BaseScreen extends Screen { + + // Content area bounds + protected int leftPos; + protected int topPos; + protected int imageWidth; + protected int imageHeight; + + protected BaseScreen(Component title) { + super(title); + } + + /** + * Defines the preferred percentage width of the screen (0.0 to 1.0) + * or a fixed pixel width if context requires. + * Default implementation uses a responsive approach. + */ + protected int getPreferredWidth() { + // Default: 60% of screen, min 300px, max 450px + return GuiLayoutConstants.getResponsiveWidth( + this.width, + 0.6f, + 300, + 450 + ); + } + + /** + * Defines the preferred percentage height of the screen. + */ + protected int getPreferredHeight() { + // Default: 70% of screen, min 220px, max 400px + return GuiLayoutConstants.getResponsiveHeight( + this.height, + 0.7f, + 220, + 400 + ); + } + + @Override + protected void init() { + super.init(); + + // Calculate dimensions + this.imageWidth = getPreferredWidth(); + this.imageHeight = getPreferredHeight(); + + // Center the panel + this.leftPos = (this.width - this.imageWidth) / 2; + this.topPos = (this.height - this.imageHeight) / 2; + } + + @Override + public void render( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + this.renderBackground(graphics); + + // Draw Main Panel Background + graphics.fill( + leftPos, + topPos, + leftPos + imageWidth, + topPos + imageHeight, + GuiColors.BG_MEDIUM + ); + + // Draw Borders + GuiRenderUtil.drawBorder( + graphics, + leftPos, + topPos, + imageWidth, + imageHeight, + GuiColors.BORDER_LIGHT + ); + + // Title + graphics.drawCenteredString( + this.font, + this.title, + this.width / 2, + this.topPos + GuiLayoutConstants.MARGIN_M, + GuiColors.TEXT_WHITE + ); + + // Template method: subclasses render custom content between background and widgets + renderContent(graphics, mouseX, mouseY, partialTick); + + super.render(graphics, mouseX, mouseY, partialTick); + } + + /** + * Template method for subclasses to render custom content between the + * background/title and the widgets. Called after background, panel fill, + * border, and title are drawn, but before {@code super.render()} draws + * widgets and tooltips. + * + *

Default implementation is a no-op so existing subclasses that + * override {@code render()} directly are unaffected.

+ * + *

Important: Subclasses should use EITHER this method OR a + * {@code render()} override, not both — combining them would render + * custom content twice.

+ */ + protected void renderContent( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + // No-op by default — subclasses override to render custom content + } + + @Override + public boolean isPauseScreen() { + return false; + } + + /** + * Truncate text with ellipsis if too long. + */ + protected String truncateText(String text, int maxWidth) { + if (this.font.width(text) <= maxWidth) { + return text; + } + String ellipsis = "..."; + int ellipsisWidth = this.font.width(ellipsis); + int availableWidth = maxWidth - ellipsisWidth; + if (availableWidth <= 0) { + return ellipsis; + } + // Basic truncation + return this.font.plainSubstrByWidth(text, availableWidth) + ellipsis; + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/screens/BountyListScreen.java b/src/main/java/com/tiedup/remake/client/gui/screens/BountyListScreen.java new file mode 100644 index 0000000..ae3b416 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/screens/BountyListScreen.java @@ -0,0 +1,256 @@ +package com.tiedup.remake.client.gui.screens; + +import com.tiedup.remake.bounty.Bounty; +import com.tiedup.remake.client.gui.util.GuiColors; +import com.tiedup.remake.client.gui.util.GuiLayoutConstants; +import com.tiedup.remake.client.gui.widgets.BountyEntryWidget; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.bounty.PacketDeleteBounty; +import java.util.List; +import java.util.UUID; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.ContainerObjectSelectionList; +import net.minecraft.network.chat.Component; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Screen displaying all active bounties. + * Refactored to use standard ContainerObjectSelectionList. + */ +@OnlyIn(Dist.CLIENT) +public class BountyListScreen extends BaseScreen { + + private final List bounties; + private final boolean isAdmin; + + private BountyList bountyList; + private Button closeButton; + private Button deleteButton; + + public BountyListScreen(List bounties, boolean isAdmin) { + super(Component.translatable("gui.tiedup.bounties.title")); + this.bounties = bounties; + this.isAdmin = isAdmin; + } + + @Override + protected int getPreferredWidth() { + return GuiLayoutConstants.getResponsiveWidth( + this.width, + 0.7f, + 350, + 500 + ); + } + + @Override + protected int getPreferredHeight() { + return GuiLayoutConstants.getResponsiveHeight( + this.height, + 0.8f, + 250, + 400 + ); + } + + @Override + protected void init() { + super.init(); + + int listTop = this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 20; + int listBottom = + this.topPos + + this.imageHeight - + GuiLayoutConstants.BUTTON_HEIGHT * 2 - + 20; + int listHeight = listBottom - listTop; + + // Initialize List + bountyList = new BountyList( + minecraft, + this.imageWidth - 20, + listHeight, + listTop, + listBottom + ); + bountyList.setLeftPos(this.leftPos + 10); + + // Populate List + for (Bounty bounty : bounties) { + bountyList.addEntryPublic( + new BountyEntryWidget(bounty, this::onEntrySelected) + ); + } + this.addRenderableWidget(bountyList); + + // Buttons + int btnWidth = GuiLayoutConstants.BUTTON_WIDTH_XL; + int btnX = this.leftPos + (this.imageWidth - btnWidth) / 2; + int btnY = listBottom + 10; + + deleteButton = Button.builder( + Component.translatable("gui.tiedup.bounties.delete"), + b -> onDeleteClicked() + ) + .bounds(btnX, btnY, btnWidth, GuiLayoutConstants.BUTTON_HEIGHT) + .build(); + deleteButton.active = false; + this.addRenderableWidget(deleteButton); + + closeButton = Button.builder( + Component.translatable("gui.tiedup.close"), + b -> onClose() + ) + .bounds( + btnX, + btnY + GuiLayoutConstants.BUTTON_HEIGHT + 4, + btnWidth, + GuiLayoutConstants.BUTTON_HEIGHT + ) + .build(); + this.addRenderableWidget(closeButton); + } + + private void onEntrySelected(BountyEntryWidget entry) { + bountyList.setSelected(entry); + updateDeleteButton(); + } + + private void updateDeleteButton() { + BountyEntryWidget selected = bountyList.getSelected(); + if (selected == null) { + deleteButton.active = false; + return; + } + + Bounty bounty = selected.getBounty(); + if (minecraft.player == null) { + deleteButton.active = false; + return; + } + UUID playerId = minecraft.player.getUUID(); + + // Can delete if admin or bounty client + deleteButton.active = isAdmin || bounty.isClient(playerId); + + // Update visual selection state in all widgets + for (BountyEntryWidget w : bountyList.children()) { + w.setSelected(w == selected); + } + } + + private void onDeleteClicked() { + BountyEntryWidget selected = bountyList.getSelected(); + if (selected == null) return; + + Bounty bounty = selected.getBounty(); + ModNetwork.sendToServer(new PacketDeleteBounty(bounty.getId())); + + // Remove from list + bounties.remove(bounty); + bountyList.removeEntryPublic(selected); + + // Reset selection + bountyList.setSelected(null); + updateDeleteButton(); + } + + @Override + public void render( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + super.render(graphics, mouseX, mouseY, partialTick); + + // Bounty count + String countText = + bounties.size() + " bounti" + (bounties.size() != 1 ? "es" : "y"); + graphics.drawString( + this.font, + countText, + this.leftPos + GuiLayoutConstants.MARGIN_M, + this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 2, + GuiColors.TEXT_GRAY + ); + + // Empty state + if (bounties.isEmpty()) { + graphics.drawCenteredString( + this.font, + Component.translatable("gui.tiedup.bounties.noEntries"), + this.leftPos + this.imageWidth / 2, + this.topPos + this.imageHeight / 2, + GuiColors.TEXT_DISABLED + ); + } + + // Render tooltip for hovered entry + BountyEntryWidget hovered = bountyList.getEntryAtPositionPublic( + mouseX, + mouseY + ); + if (hovered != null) { + graphics.renderTooltip( + this.font, + hovered.getBounty().getReward(), + mouseX, + mouseY + ); + } + } + + // ==================== INNER CLASS: LIST ==================== + + class BountyList extends ContainerObjectSelectionList { + + public BountyList( + Minecraft mc, + int width, + int height, + int top, + int bottom + ) { + super(mc, width, height, top, bottom, 55); // 55 = item height + this.centerListVertically = false; + this.setRenderBackground(false); + this.setRenderTopAndBottom(false); + } + + public void addEntryPublic(BountyEntryWidget entry) { + this.addEntry(entry); + } + + public void removeEntryPublic(BountyEntryWidget entry) { + this.removeEntry(entry); + } + + public BountyEntryWidget getEntryAtPositionPublic(double x, double y) { + return super.getEntryAtPosition(x, y); + } + + @Override + public int getRowWidth() { + return this.width - 20; + } + + @Override + protected int getScrollbarPosition() { + return this.getLeft() + this.width - 6; + } + + public void setLeftPos(int left) { + this.x0 = left; + this.x1 = left + this.width; + } + + @Override + public int getRowLeft() { + return BountyListScreen.this.leftPos + 10; + } + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/screens/CellCoreScreen.java b/src/main/java/com/tiedup/remake/client/gui/screens/CellCoreScreen.java new file mode 100644 index 0000000..b3fb44c --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/screens/CellCoreScreen.java @@ -0,0 +1,405 @@ +package com.tiedup.remake.client.gui.screens; + +import com.tiedup.remake.client.gui.util.GuiColors; +import com.tiedup.remake.client.gui.util.GuiRenderUtil; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.cell.PacketCoreMenuAction; +import com.tiedup.remake.network.cell.PacketRenameCell; +import java.util.UUID; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Compact Cell Core menu GUI. + * Fixed-size panel (~180x220px) with action buttons and cell info. + */ +@OnlyIn(Dist.CLIENT) +public class CellCoreScreen extends Screen { + + private static final int PANEL_WIDTH = 180; + private static final int PANEL_HEIGHT = 243; + private static final int BUTTON_WIDTH = 160; + private static final int BUTTON_HEIGHT = 20; + private static final int MARGIN = 8; + + private final BlockPos corePos; + private final UUID cellId; + private final String cellName; + private final String stateName; + private final int interiorVolume; + private final int wallCount; + private final int breachCount; + private final int prisonerCount; + private final int bedCount; + private final int doorCount; + private final int anchorCount; + private final boolean hasSpawn; + private final boolean hasDelivery; + private final boolean hasDisguise; + + private boolean showInfo = false; + private boolean renameMode = false; + private EditBox renameBox; + private int leftPos; + private int topPos; + + public CellCoreScreen( + BlockPos corePos, + UUID cellId, + String cellName, + String stateName, + int interiorVolume, + int wallCount, + int breachCount, + int prisonerCount, + int bedCount, + int doorCount, + int anchorCount, + boolean hasSpawn, + boolean hasDelivery, + boolean hasDisguise + ) { + super(Component.translatable("gui.tiedup.cell_core")); + this.corePos = corePos; + this.cellId = cellId; + this.cellName = + cellName != null && !cellName.isEmpty() ? cellName : "Cell Core"; + this.stateName = stateName; + this.interiorVolume = interiorVolume; + this.wallCount = wallCount; + this.breachCount = breachCount; + this.prisonerCount = prisonerCount; + this.bedCount = bedCount; + this.doorCount = doorCount; + this.anchorCount = anchorCount; + this.hasSpawn = hasSpawn; + this.hasDelivery = hasDelivery; + this.hasDisguise = hasDisguise; + } + + @Override + protected void init() { + super.init(); + + int panelHeight = showInfo ? PANEL_HEIGHT + 90 : PANEL_HEIGHT; + this.leftPos = (this.width - PANEL_WIDTH) / 2; + this.topPos = (this.height - panelHeight) / 2; + + int btnX = leftPos + (PANEL_WIDTH - BUTTON_WIDTH) / 2; + int currentY = topPos + 24; + + // Set Spawn + addRenderableWidget( + Button.builder( + Component.translatable("gui.tiedup.cell_core.set_spawn"), + b -> { + ModNetwork.sendToServer( + new PacketCoreMenuAction( + corePos, + PacketCoreMenuAction.Action.SET_SPAWN + ) + ); + onClose(); + } + ) + .bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT) + .build() + ); + currentY += BUTTON_HEIGHT + 3; + + // Set Delivery + addRenderableWidget( + Button.builder( + Component.translatable("gui.tiedup.cell_core.set_delivery"), + b -> { + ModNetwork.sendToServer( + new PacketCoreMenuAction( + corePos, + PacketCoreMenuAction.Action.SET_DELIVERY + ) + ); + onClose(); + } + ) + .bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT) + .build() + ); + currentY += BUTTON_HEIGHT + 3; + + // Set Disguise + addRenderableWidget( + Button.builder( + Component.translatable("gui.tiedup.cell_core.set_disguise"), + b -> { + ModNetwork.sendToServer( + new PacketCoreMenuAction( + corePos, + PacketCoreMenuAction.Action.SET_DISGUISE + ) + ); + onClose(); + } + ) + .bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT) + .build() + ); + currentY += BUTTON_HEIGHT + 3; + + // Rename + if (renameMode) { + renameBox = new EditBox( + this.font, + btnX, + currentY, + BUTTON_WIDTH - 50, + BUTTON_HEIGHT, + Component.translatable("gui.tiedup.cell_core.cell_name") + ); + renameBox.setMaxLength(32); + String currentName = cellName.equals("Cell Core") ? "" : cellName; + renameBox.setValue(currentName); + addRenderableWidget(renameBox); + renameBox.setFocused(true); + setFocused(renameBox); + + addRenderableWidget( + Button.builder( + Component.translatable("gui.tiedup.cell_core.button.ok"), + b -> { + String newName = renameBox.getValue().trim(); + ModNetwork.sendToServer( + new PacketRenameCell(cellId, newName) + ); + renameMode = false; + onClose(); + } + ) + .bounds( + btnX + BUTTON_WIDTH - 46, + currentY, + 46, + BUTTON_HEIGHT + ) + .build() + ); + currentY += BUTTON_HEIGHT + 3; + } else { + addRenderableWidget( + Button.builder( + Component.translatable("gui.tiedup.cell_core.rename"), + b -> { + renameMode = true; + rebuildWidgets(); + } + ) + .bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT) + .build() + ); + currentY += BUTTON_HEIGHT + 3; + } + + // Re-scan + addRenderableWidget( + Button.builder( + Component.translatable("gui.tiedup.cell_core.rescan"), + b -> { + ModNetwork.sendToServer( + new PacketCoreMenuAction( + corePos, + PacketCoreMenuAction.Action.RESCAN + ) + ); + } + ) + .bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT) + .build() + ); + currentY += BUTTON_HEIGHT + 3; + + // Info toggle + addRenderableWidget( + Button.builder( + Component.translatable("gui.tiedup.cell_core.info"), + b -> { + showInfo = !showInfo; + rebuildWidgets(); + } + ) + .bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT) + .build() + ); + currentY += BUTTON_HEIGHT + 6; + + // Close + addRenderableWidget( + Button.builder( + Component.translatable("gui.tiedup.cell_core.close"), + b -> onClose() + ) + .bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT) + .build() + ); + } + + @Override + public void render( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + this.renderBackground(graphics); + + int panelHeight = showInfo ? PANEL_HEIGHT + 90 : PANEL_HEIGHT; + int panelTopPos = (this.height - panelHeight) / 2; + + // Panel background + graphics.fill( + leftPos, + panelTopPos, + leftPos + PANEL_WIDTH, + panelTopPos + panelHeight, + GuiColors.BG_MEDIUM + ); + GuiRenderUtil.drawBorder( + graphics, + leftPos, + panelTopPos, + PANEL_WIDTH, + panelHeight, + GuiColors.BORDER_LIGHT + ); + + // Title + graphics.drawCenteredString( + this.font, + cellName, + leftPos + PANEL_WIDTH / 2, + panelTopPos + MARGIN, + GuiColors.TEXT_WHITE + ); + + // Info section (below buttons) + if (showInfo) { + int infoY = panelTopPos + PANEL_HEIGHT - 10; + int infoX = leftPos + MARGIN; + int lineH = 10; + + graphics.drawString( + this.font, + Component.translatable( + "gui.tiedup.cell_core.info.state", + stateName + ).getString(), + infoX, + infoY, + getStateColor() + ); + infoY += lineH; + graphics.drawString( + this.font, + Component.translatable( + "gui.tiedup.cell_core.info.interior", + interiorVolume + ).getString(), + infoX, + infoY, + GuiColors.TEXT_GRAY + ); + infoY += lineH; + String wallStr = + breachCount > 0 + ? Component.translatable( + "gui.tiedup.cell_core.info.walls_breached", + wallCount, + breachCount + ).getString() + : Component.translatable( + "gui.tiedup.cell_core.info.walls", + wallCount + ).getString(); + graphics.drawString( + this.font, + wallStr, + infoX, + infoY, + breachCount > 0 ? GuiColors.WARNING : GuiColors.TEXT_GRAY + ); + infoY += lineH; + graphics.drawString( + this.font, + Component.translatable( + "gui.tiedup.cell_core.info.prisoners", + prisonerCount + ).getString(), + infoX, + infoY, + GuiColors.TEXT_GRAY + ); + infoY += lineH; + graphics.drawString( + this.font, + Component.translatable( + "gui.tiedup.cell_core.info.beds_doors_anchors", + bedCount, + doorCount, + anchorCount + ).getString(), + infoX, + infoY, + GuiColors.TEXT_GRAY + ); + infoY += lineH; + + String features = ""; + if (hasSpawn) features += + Component.translatable( + "gui.tiedup.cell_core.info.feature_spawn" + ).getString() + + " "; + if (hasDelivery) features += + Component.translatable( + "gui.tiedup.cell_core.info.feature_delivery" + ).getString() + + " "; + if (hasDisguise) features += Component.translatable( + "gui.tiedup.cell_core.info.feature_disguise" + ).getString(); + if (features.isEmpty()) features = Component.translatable( + "gui.tiedup.cell_core.info.none_set" + ).getString(); + graphics.drawString( + this.font, + Component.translatable( + "gui.tiedup.cell_core.info.set", + features.trim() + ).getString(), + infoX, + infoY, + GuiColors.TEXT_GRAY + ); + } + + super.render(graphics, mouseX, mouseY, partialTick); + } + + private int getStateColor() { + return switch (stateName) { + case "intact" -> GuiColors.SUCCESS; + case "breached" -> GuiColors.WARNING; + case "compromised" -> GuiColors.ERROR; + default -> GuiColors.TEXT_GRAY; + }; + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/screens/CellManagerScreen.java b/src/main/java/com/tiedup/remake/client/gui/screens/CellManagerScreen.java new file mode 100644 index 0000000..0556832 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/screens/CellManagerScreen.java @@ -0,0 +1,684 @@ +package com.tiedup.remake.client.gui.screens; + +import com.tiedup.remake.client.gui.util.GuiColors; +import com.tiedup.remake.client.gui.util.GuiLayoutConstants; +import com.tiedup.remake.client.gui.widgets.CellListRenderer; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.cell.PacketCellAction; +import com.tiedup.remake.network.cell.PacketOpenCellManager.CellSyncData; +import com.tiedup.remake.network.cell.PacketOpenCellManager.PrisonerInfo; +import com.tiedup.remake.network.cell.PacketRenameCell; +import java.util.List; +import org.jetbrains.annotations.Nullable; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.network.chat.Component; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Screen for managing player-owned cells. + * Shows list of cells with prisoners and management actions. + * + * Operators (OP) can see and manage ALL cells. + * Non-operators can only manage their own cells. + */ +@OnlyIn(Dist.CLIENT) +public class CellManagerScreen extends BaseScreen { + + private final List cells; + private final boolean isOperator; + + // Selection state + @Nullable + private CellSyncData selectedCell; + + @Nullable + private PrisonerInfo selectedPrisoner; + + // UI state + private boolean renameMode = false; + private EditBox renameBox; + + // Layout + private int listStartY; + private int listHeight; + private int cellEntryHeight = 24; + private int prisonerEntryHeight = 18; + private int scrollOffset = 0; + + // Buttons + private Button closeButton; + private Button renameButton; + private Button deleteButton; + private Button releaseButton; + private Button teleportButton; + + /** + * Create the cell manager screen. + * + * @param cells List of cells to display + * @param isOperator True if the viewing player is an operator (can manage all cells) + */ + public CellManagerScreen(List cells, boolean isOperator) { + super(Component.translatable("gui.tiedup.cell_manager")); + this.cells = cells; + this.isOperator = isOperator; + } + + /** + * Legacy constructor (assumes not operator). + */ + public CellManagerScreen(List cells) { + this(cells, false); + } + + @Override + protected int getPreferredWidth() { + return GuiLayoutConstants.getResponsiveWidth( + this.width, + 0.7f, + 400, + 550 + ); + } + + @Override + protected int getPreferredHeight() { + return GuiLayoutConstants.getResponsiveHeight( + this.height, + 0.8f, + 350, + 500 + ); + } + + @Override + protected void init() { + super.init(); + + int contentTop = + this.topPos + + GuiLayoutConstants.TITLE_HEIGHT + + GuiLayoutConstants.MARGIN_M; + listStartY = contentTop; + + // Calculate list height (leave room for buttons at bottom) + int buttonsAreaHeight = + GuiLayoutConstants.BUTTON_HEIGHT * 2 + + GuiLayoutConstants.MARGIN_L + + GuiLayoutConstants.MARGIN_M; + listHeight = + this.imageHeight - (listStartY - this.topPos) - buttonsAreaHeight; + + // === Action Buttons (bottom) === + int buttonY = + this.topPos + + this.imageHeight - + GuiLayoutConstants.BUTTON_HEIGHT * 2 - + GuiLayoutConstants.MARGIN_L; + int buttonWidth = 80; + int buttonSpacing = 6; + int buttonsStartX = this.leftPos + GuiLayoutConstants.MARGIN_M; + + // Row 1: Rename, Delete + renameButton = Button.builder( + Component.translatable("gui.tiedup.cell_manager.button.rename"), + b -> toggleRenameMode() + ) + .bounds( + buttonsStartX, + buttonY, + buttonWidth, + GuiLayoutConstants.BUTTON_HEIGHT + ) + .build(); + this.addRenderableWidget(renameButton); + + deleteButton = Button.builder( + Component.translatable( + "gui.tiedup.cell_manager.button.delete" + ).withStyle(ChatFormatting.RED), + b -> deleteSelectedCell() + ) + .bounds( + buttonsStartX + buttonWidth + buttonSpacing, + buttonY, + buttonWidth, + GuiLayoutConstants.BUTTON_HEIGHT + ) + .build(); + this.addRenderableWidget(deleteButton); + + // Row 2: Release, Teleport + int row2Y = + buttonY + + GuiLayoutConstants.BUTTON_HEIGHT + + GuiLayoutConstants.MARGIN_S; + + releaseButton = Button.builder( + Component.translatable("gui.tiedup.cell_manager.button.release"), + b -> releaseSelectedPrisoner() + ) + .bounds( + buttonsStartX, + row2Y, + buttonWidth, + GuiLayoutConstants.BUTTON_HEIGHT + ) + .build(); + this.addRenderableWidget(releaseButton); + + teleportButton = Button.builder( + Component.translatable("gui.tiedup.cell_manager.button.teleport"), + b -> teleportSelectedPrisoner() + ) + .bounds( + buttonsStartX + buttonWidth + buttonSpacing, + row2Y, + buttonWidth, + GuiLayoutConstants.BUTTON_HEIGHT + ) + .build(); + this.addRenderableWidget(teleportButton); + + // Close button (right side) + closeButton = Button.builder( + Component.translatable("gui.tiedup.close"), + b -> onClose() + ) + .bounds( + this.leftPos + + this.imageWidth - + GuiLayoutConstants.BUTTON_WIDTH_M - + GuiLayoutConstants.MARGIN_M, + row2Y, + GuiLayoutConstants.BUTTON_WIDTH_M, + GuiLayoutConstants.BUTTON_HEIGHT + ) + .build(); + this.addRenderableWidget(closeButton); + + // Rename edit box (hidden by default) + renameBox = new EditBox( + this.font, + this.leftPos + GuiLayoutConstants.MARGIN_M, + buttonY - 25, + this.imageWidth - GuiLayoutConstants.MARGIN_M * 2 - 60, + 20, + Component.translatable("gui.tiedup.cell_manager.cell_name") + ); + renameBox.setMaxLength(32); + renameBox.setVisible(false); + this.addRenderableWidget(renameBox); + + updateButtonStates(); + } + + private void updateButtonStates() { + boolean hasCellSelected = selectedCell != null; + boolean hasPrisonerSelected = selectedPrisoner != null; + + // Determine if player can manage the selected cell + // OP can manage any cell, non-OP can only manage their owned cells + boolean canManageCell = + hasCellSelected && (isOperator || selectedCell.isOwned); + + renameButton.active = canManageCell && !renameMode; + deleteButton.active = canManageCell && !renameMode; + releaseButton.active = + canManageCell && hasPrisonerSelected && !renameMode; + teleportButton.active = + canManageCell && hasPrisonerSelected && !renameMode; + } + + private void toggleRenameMode() { + if (selectedCell == null) return; + + renameMode = !renameMode; + renameBox.setVisible(renameMode); + + if (renameMode) { + String currentName = + selectedCell.name != null ? selectedCell.name : ""; + renameBox.setValue(currentName); + renameBox.setFocused(true); + renameButton.setMessage( + Component.translatable( + "gui.tiedup.cell_manager.button.save" + ).withStyle(ChatFormatting.GREEN) + ); + } else { + // Save the name + String newName = renameBox.getValue().trim(); + ModNetwork.sendToServer( + new PacketRenameCell(selectedCell.cellId, newName) + ); + renameButton.setMessage( + Component.translatable("gui.tiedup.cell_manager.button.rename") + ); + } + + updateButtonStates(); + } + + private void deleteSelectedCell() { + if (selectedCell == null) return; + + ModNetwork.sendToServer( + new PacketCellAction( + PacketCellAction.Action.DELETE_CELL, + selectedCell.cellId, + null, + null + ) + ); + + // Remove from local list and deselect + cells.remove(selectedCell); + selectedCell = null; + selectedPrisoner = null; + updateButtonStates(); + } + + private void releaseSelectedPrisoner() { + if (selectedCell == null || selectedPrisoner == null) return; + + ModNetwork.sendToServer( + new PacketCellAction( + PacketCellAction.Action.RELEASE, + selectedCell.cellId, + selectedPrisoner.prisonerId, + null + ) + ); + + // Remove prisoner from local state + selectedCell.prisoners.remove(selectedPrisoner); + selectedPrisoner = null; + updateButtonStates(); + } + + private void teleportSelectedPrisoner() { + if (selectedCell == null || selectedPrisoner == null) return; + + ModNetwork.sendToServer( + new PacketCellAction( + PacketCellAction.Action.TELEPORT, + selectedCell.cellId, + selectedPrisoner.prisonerId, + null + ) + ); + } + + @Override + protected void renderContent( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + // Cell count subtitle (show OP mode indicator if operator) + Component subtitle = Component.translatable( + "gui.tiedup.cell_manager.label.cell_count", + cells.size() + ); + if (isOperator) { + subtitle = subtitle + .copy() + .append(" ") + .append( + Component.translatable( + "gui.tiedup.cell_manager.label.op_mode" + ) + ); + } + graphics.drawCenteredString( + this.font, + subtitle + .copy() + .withStyle( + isOperator ? ChatFormatting.GOLD : ChatFormatting.GRAY + ), + this.leftPos + this.imageWidth / 2, + this.topPos + GuiLayoutConstants.MARGIN_M + 12, + isOperator ? GuiColors.WARNING : GuiColors.TEXT_GRAY + ); + + // Render cell list + renderCellList(graphics, mouseX, mouseY); + + // Render rename mode hint + if (renameMode) { + graphics.drawString( + this.font, + Component.translatable( + "gui.tiedup.cell_manager.status.rename_hint" + ).getString(), + this.leftPos + GuiLayoutConstants.MARGIN_M, + renameBox.getY() - 12, + GuiColors.TEXT_GRAY + ); + } + } + + /** + * Calculate the total pixel height of all cell entries. + */ + private int getTotalContentHeight() { + int total = 0; + for (CellSyncData cell : cells) { + total += cellEntryHeight; + int prisoners = cell.prisoners.isEmpty() + ? 1 + : cell.prisoners.size(); + total += prisoners * prisonerEntryHeight; + total += 4; // spacing + } + return total; + } + + private int getMaxScrollOffset() { + int totalHeight = getTotalContentHeight(); + return Math.max(0, totalHeight - listHeight); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double delta) { + int maxScroll = getMaxScrollOffset(); + if (maxScroll > 0) { + int scrollAmount = (int) (delta * 20); + scrollOffset = Math.max( + 0, + Math.min(maxScroll, scrollOffset - scrollAmount) + ); + return true; + } + return super.mouseScrolled(mouseX, mouseY, delta); + } + + private void renderCellList(GuiGraphics graphics, int mouseX, int mouseY) { + int listX = this.leftPos + GuiLayoutConstants.MARGIN_M; + int listWidth = this.imageWidth - GuiLayoutConstants.MARGIN_M * 2; + + // Apply scroll offset to starting Y + int currentY = listStartY - scrollOffset; + + for (CellSyncData cell : cells) { + if (currentY > listStartY + listHeight) break; + if (currentY + cellEntryHeight < listStartY) { + currentY += + cellEntryHeight + + cell.prisoners.size() * prisonerEntryHeight; + continue; + } + + // Cell header background + boolean cellSelected = cell == selectedCell; + boolean cellHovered = + mouseX >= listX && + mouseX < listX + listWidth && + mouseY >= currentY && + mouseY < currentY + cellEntryHeight; + + int bgColor; + if (cellSelected) { + bgColor = GuiColors.SLOT_SELECTED; + } else if (cellHovered) { + bgColor = GuiColors.SLOT_HOVER; + } else { + bgColor = GuiColors.BG_LIGHT; + } + + graphics.fill( + listX, + currentY, + listX + listWidth, + currentY + cellEntryHeight, + bgColor + ); + + // Cell icon - different color for owned vs not-owned cells + int iconColor = cell.isOwned + ? GuiColors.TYPE_COLLAR + : GuiColors.TEXT_DISABLED; + graphics.fill( + listX + 4, + currentY + 4, + listX + 18, + currentY + 18, + iconColor + ); + + // Lock icon overlay if not owned and not OP + if (!cell.isOwned && !isOperator) { + graphics.drawString( + this.font, + "\uD83D\uDD12", + listX + 5, + currentY + 5, + GuiColors.TEXT_DISABLED + ); // Lock emoji + } + + // Cell name + String displayName = cell.getDisplayName(); + int nameColor = cell.isOwned + ? GuiColors.TEXT_WHITE + : GuiColors.TEXT_GRAY; + graphics.drawString( + this.font, + displayName, + listX + 24, + currentY + 4, + nameColor + ); + + // Prisoner count badge + CellListRenderer.renderCountBadge( + graphics, + this.font, + cell.prisonerCount, + cell.maxPrisoners, + listX + listWidth, + currentY + 4 + ); + + // Location + owner info for non-owned cells + String locStr = "@ " + cell.spawnPoint.toShortString(); + if (!cell.isOwned && cell.ownerName != null) { + locStr += + " (" + + Component.translatable( + "gui.tiedup.cell_manager.label.owner", + cell.ownerName + ).getString() + + ")"; + } + graphics.drawString( + this.font, + locStr, + listX + 24, + currentY + 14, + GuiColors.TEXT_DISABLED + ); + + currentY += cellEntryHeight; + + // Render prisoners under this cell + for (PrisonerInfo prisoner : cell.prisoners) { + if (currentY > listStartY + listHeight) break; + + boolean prisonerSelected = + prisoner == selectedPrisoner && cell == selectedCell; + boolean prisonerHovered = + mouseX >= listX + 20 && + mouseX < listX + listWidth && + mouseY >= currentY && + mouseY < currentY + prisonerEntryHeight; + + int prisonerBg; + if (prisonerSelected) { + prisonerBg = GuiColors.lighten( + GuiColors.SLOT_SELECTED, + 0.2f + ); + } else if (prisonerHovered) { + prisonerBg = GuiColors.SLOT_HOVER; + } else { + prisonerBg = GuiColors.BG_DARK; + } + + graphics.fill( + listX + 20, + currentY, + listX + listWidth, + currentY + prisonerEntryHeight, + prisonerBg + ); + + // Prisoner indicator + graphics.drawString( + this.font, + " \u2514\u2500 ", + listX, + currentY + 2, + GuiColors.TEXT_DISABLED + ); + + // Prisoner name + graphics.drawString( + this.font, + prisoner.prisonerName, + listX + 40, + currentY + 4, + GuiColors.TEXT_WHITE + ); + + currentY += prisonerEntryHeight; + } + + // If no prisoners, show "(empty)" + if (cell.prisoners.isEmpty()) { + graphics.drawString( + this.font, + Component.literal(" \u2514\u2500 ") + .append( + Component.translatable( + "gui.tiedup.cell_manager.label.empty" + ) + ) + .withStyle(ChatFormatting.ITALIC), + listX, + currentY + 2, + GuiColors.TEXT_DISABLED + ); + currentY += prisonerEntryHeight; + } + + // Spacing between cells + currentY += 4; + } + + // If no cells + if (cells.isEmpty()) { + CellListRenderer.renderEmptyState( + graphics, + this.font, + this.leftPos + this.imageWidth / 2, + listStartY, + "gui.tiedup.cell_manager.status.no_cells", + "gui.tiedup.cell_manager.status.use_cellwand" + ); + } + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (button == 0 && !renameMode) { + // Only handle clicks within the visible list area + if (mouseY < listStartY || mouseY > listStartY + listHeight) { + return super.mouseClicked(mouseX, mouseY, button); + } + + // Handle cell/prisoner selection + int listX = this.leftPos + GuiLayoutConstants.MARGIN_M; + int listWidth = this.imageWidth - GuiLayoutConstants.MARGIN_M * 2; + int currentY = listStartY - scrollOffset; + + for (CellSyncData cell : cells) { + if (currentY > listStartY + listHeight) break; + + // Check cell header click + if ( + mouseX >= listX && + mouseX < listX + listWidth && + mouseY >= currentY && + mouseY < currentY + cellEntryHeight + ) { + selectedCell = cell; + selectedPrisoner = null; + updateButtonStates(); + return true; + } + + currentY += cellEntryHeight; + + // Check prisoner clicks + for (PrisonerInfo prisoner : cell.prisoners) { + if (currentY > listStartY + listHeight) break; + + if ( + mouseX >= listX + 20 && + mouseX < listX + listWidth && + mouseY >= currentY && + mouseY < currentY + prisonerEntryHeight + ) { + selectedCell = cell; + selectedPrisoner = prisoner; + updateButtonStates(); + return true; + } + + currentY += prisonerEntryHeight; + } + + // Empty cell placeholder height + if (cell.prisoners.isEmpty()) { + currentY += prisonerEntryHeight; + } + + currentY += 4; // Spacing + } + } + + return super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (renameMode) { + if (keyCode == 257) { + // Enter + toggleRenameMode(); // Save + return true; + } else if (keyCode == 256) { + // Escape + renameMode = false; + renameBox.setVisible(false); + renameButton.setMessage( + Component.translatable( + "gui.tiedup.cell_manager.button.rename" + ) + ); + updateButtonStates(); + return true; + } + } + + return super.keyPressed(keyCode, scanCode, modifiers); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/screens/CellSelectorScreen.java b/src/main/java/com/tiedup/remake/client/gui/screens/CellSelectorScreen.java new file mode 100644 index 0000000..f23cc13 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/screens/CellSelectorScreen.java @@ -0,0 +1,321 @@ +package com.tiedup.remake.client.gui.screens; + +import com.tiedup.remake.client.gui.util.GuiColors; +import com.tiedup.remake.client.gui.util.GuiLayoutConstants; +import com.tiedup.remake.client.gui.widgets.CellListRenderer; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.cell.PacketAssignCellToCollar; +import com.tiedup.remake.network.cell.PacketOpenCellSelector.CellOption; +import java.util.List; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.network.chat.Component; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Modal screen for selecting a cell to assign to a collar. + * Opens from SlaveItemManagementScreen when clicking "Cell" button. + */ +@OnlyIn(Dist.CLIENT) +public class CellSelectorScreen extends BaseScreen { + + private final UUID targetEntityUUID; + private final List cells; + + @Nullable + private CellOption selectedCell; + + // Layout + private int listStartY; + private int listHeight; + private int entryHeight = 28; + + // Buttons + private Button confirmButton; + private Button clearButton; + private Button backButton; + + public CellSelectorScreen(UUID targetEntityUUID, List cells) { + super(Component.translatable("gui.tiedup.select_cell")); + this.targetEntityUUID = targetEntityUUID; + this.cells = cells; + } + + @Override + protected int getPreferredWidth() { + return GuiLayoutConstants.getResponsiveWidth( + this.width, + 0.5f, + 280, + 380 + ); + } + + @Override + protected int getPreferredHeight() { + return GuiLayoutConstants.getResponsiveHeight( + this.height, + 0.6f, + 250, + 350 + ); + } + + @Override + protected void init() { + super.init(); + + int contentTop = + this.topPos + + GuiLayoutConstants.TITLE_HEIGHT + + GuiLayoutConstants.MARGIN_M; + listStartY = contentTop; + + // Calculate list height + int buttonsAreaHeight = + GuiLayoutConstants.BUTTON_HEIGHT + GuiLayoutConstants.MARGIN_L; + listHeight = + this.imageHeight - (listStartY - this.topPos) - buttonsAreaHeight; + + // === Buttons at bottom === + int buttonY = + this.topPos + + this.imageHeight - + GuiLayoutConstants.BUTTON_HEIGHT - + GuiLayoutConstants.MARGIN_M; + int buttonWidth = 70; + int buttonSpacing = 8; + int totalButtonsWidth = buttonWidth * 3 + buttonSpacing * 2; + int buttonsStartX = + this.leftPos + (this.imageWidth - totalButtonsWidth) / 2; + + confirmButton = Button.builder( + Component.translatable( + "gui.tiedup.cell_selector.button.confirm" + ).withStyle(ChatFormatting.GREEN), + b -> confirmSelection() + ) + .bounds( + buttonsStartX, + buttonY, + buttonWidth, + GuiLayoutConstants.BUTTON_HEIGHT + ) + .build(); + this.addRenderableWidget(confirmButton); + + clearButton = Button.builder( + Component.translatable( + "gui.tiedup.cell_selector.button.clear" + ).withStyle(ChatFormatting.YELLOW), + b -> clearSelection() + ) + .bounds( + buttonsStartX + buttonWidth + buttonSpacing, + buttonY, + buttonWidth, + GuiLayoutConstants.BUTTON_HEIGHT + ) + .build(); + this.addRenderableWidget(clearButton); + + backButton = Button.builder( + Component.translatable("gui.tiedup.cell_selector.button.back"), + b -> onClose() + ) + .bounds( + buttonsStartX + (buttonWidth + buttonSpacing) * 2, + buttonY, + buttonWidth, + GuiLayoutConstants.BUTTON_HEIGHT + ) + .build(); + this.addRenderableWidget(backButton); + + updateButtonStates(); + } + + private void updateButtonStates() { + confirmButton.active = selectedCell != null; + } + + private void confirmSelection() { + if (selectedCell == null) return; + + // Send packet to assign cell + ModNetwork.sendToServer( + new PacketAssignCellToCollar(targetEntityUUID, selectedCell.cellId) + ); + onClose(); + } + + private void clearSelection() { + // Send packet to clear cell (null cellId) + ModNetwork.sendToServer( + new PacketAssignCellToCollar(targetEntityUUID, null) + ); + onClose(); + } + + @Override + protected void renderContent( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + // Render cell list + renderCellList(graphics, mouseX, mouseY); + } + + private void renderCellList(GuiGraphics graphics, int mouseX, int mouseY) { + int listX = this.leftPos + GuiLayoutConstants.MARGIN_M; + int listWidth = this.imageWidth - GuiLayoutConstants.MARGIN_M * 2; + + int currentY = listStartY; + + for (int i = 0; i < cells.size(); i++) { + CellOption cell = cells.get(i); + + if (currentY + entryHeight > listStartY + listHeight) break; + + boolean isSelected = cell == selectedCell; + boolean isHovered = + mouseX >= listX && + mouseX < listX + listWidth && + mouseY >= currentY && + mouseY < currentY + entryHeight; + + // Background + int bgColor; + if (isSelected) { + bgColor = GuiColors.SLOT_SELECTED; + } else if (isHovered) { + bgColor = GuiColors.SLOT_HOVER; + } else { + bgColor = (i % 2 == 0) + ? GuiColors.SLOT_EMPTY + : GuiColors.BG_LIGHT; + } + + graphics.fill( + listX, + currentY, + listX + listWidth, + currentY + entryHeight - 2, + bgColor + ); + + // Radio button indicator + int radioX = listX + 8; + int radioY = currentY + entryHeight / 2 - 4; + if (isSelected) { + // Filled circle + graphics.fill( + radioX, + radioY, + radioX + 8, + radioY + 8, + GuiColors.SUCCESS + ); + } else { + // Empty circle (border only) + graphics.fill( + radioX, + radioY, + radioX + 8, + radioY + 8, + GuiColors.TEXT_GRAY + ); + graphics.fill( + radioX + 1, + radioY + 1, + radioX + 7, + radioY + 7, + bgColor + ); + } + + // Cell name + graphics.drawString( + this.font, + cell.displayName, + listX + 24, + currentY + 4, + GuiColors.TEXT_WHITE + ); + + // Prisoner count badge + CellListRenderer.renderCountBadge( + graphics, + this.font, + cell.prisonerCount, + cell.maxPrisoners, + listX + listWidth, + currentY + 4 + ); + + // Full indicator + if (cell.prisonerCount >= cell.maxPrisoners) { + graphics.drawString( + this.font, + Component.translatable( + "gui.tiedup.cell_selector.status.full" + ).withStyle(ChatFormatting.RED), + listX + 24, + currentY + 16, + GuiColors.ERROR + ); + } + + currentY += entryHeight; + } + + // If no cells + if (cells.isEmpty()) { + CellListRenderer.renderEmptyState( + graphics, + this.font, + this.leftPos + this.imageWidth / 2, + listStartY, + "gui.tiedup.cell_selector.status.no_cells", + "gui.tiedup.cell_selector.status.use_cellwand" + ); + } + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (button == 0) { + int listX = this.leftPos + GuiLayoutConstants.MARGIN_M; + int listWidth = this.imageWidth - GuiLayoutConstants.MARGIN_M * 2; + int currentY = listStartY; + + for (CellOption cell : cells) { + if (currentY + entryHeight > listStartY + listHeight) break; + + if ( + mouseX >= listX && + mouseX < listX + listWidth && + mouseY >= currentY && + mouseY < currentY + entryHeight + ) { + // Don't allow selecting full cells + if (cell.prisonerCount < cell.maxPrisoners) { + selectedCell = cell; + updateButtonStates(); + } + return true; + } + + currentY += entryHeight; + } + } + + return super.mouseClicked(mouseX, mouseY, button); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/screens/CommandWandScreen.java b/src/main/java/com/tiedup/remake/client/gui/screens/CommandWandScreen.java new file mode 100644 index 0000000..29fdc80 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/screens/CommandWandScreen.java @@ -0,0 +1,1148 @@ +package com.tiedup.remake.client.gui.screens; + +import com.tiedup.remake.client.gui.util.GuiStatsRenderer; +import com.tiedup.remake.client.gui.util.GuiTextureHelper; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.cell.PacketRequestCellList; +import com.tiedup.remake.network.personality.PacketDisciplineAction; +import com.tiedup.remake.network.personality.PacketNpcCommand; +import com.tiedup.remake.network.personality.PacketRequestNpcInventory; +import com.tiedup.remake.personality.DisciplineType; +import com.tiedup.remake.personality.NpcCommand; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +@OnlyIn(Dist.CLIENT) +public class CommandWandScreen extends Screen { + + private enum Tab { + STATUS, + COMMAND, + JOBS, + } + + private Tab currentTab = Tab.STATUS; + + // NPC data from packet + private final java.util.UUID entityUUID; + private final String npcName; + private final String personalityName; + private final String activeCommand; + private final float hunger; + private final float rest; + private final float mood; + private final String followDistanceMode; + private final String homeType; + private final boolean autoRestEnabled; + private final String cellName; + private final String cellQualityName; + private final String activeJobLevelName; + private final int activeJobXp; + private final int activeJobXpMax; + + // Layout constants + private static final int PANEL_WIDTH = 260; + private static final int PANEL_HEIGHT = 330; + private static final int MARGIN = 8; + private static final int LINE_HEIGHT = 10; + private static final int BUTTON_HEIGHT = 15; + private static final int BTN_SPACING = 1; + private static final int TAB_HEIGHT = 20; + + // Color constants + private static final int COLOR_WHITE = 0xFFFFFF; + private static final int COLOR_GRAY = 0xAAAAAA; + private static final int COLOR_ORANGE = 0xFF9800; + + // Calculated positions + private int leftPos; + private int topPos; + + public CommandWandScreen( + java.util.UUID entityUUID, + String npcName, + String personalityName, + String activeCommand, + float hunger, + float rest, + float mood, + String followDistanceMode, + String homeType, + boolean autoRestEnabled + ) { + this( + entityUUID, + npcName, + personalityName, + activeCommand, + hunger, + rest, + mood, + followDistanceMode, + homeType, + autoRestEnabled, + "", + "", + "", + 0, + 0 + ); + } + + public CommandWandScreen( + java.util.UUID entityUUID, + String npcName, + String personalityName, + String activeCommand, + float hunger, + float rest, + float mood, + String followDistanceMode, + String homeType, + boolean autoRestEnabled, + String cellName, + String cellQualityName + ) { + this( + entityUUID, + npcName, + personalityName, + activeCommand, + hunger, + rest, + mood, + followDistanceMode, + homeType, + autoRestEnabled, + cellName, + cellQualityName, + "", + 0, + 0 + ); + } + + public CommandWandScreen( + java.util.UUID entityUUID, + String npcName, + String personalityName, + String activeCommand, + float hunger, + float rest, + float mood, + String followDistanceMode, + String homeType, + boolean autoRestEnabled, + String cellName, + String cellQualityName, + String activeJobLevelName, + int activeJobXp, + int activeJobXpMax + ) { + super(Component.translatable("gui.tiedup.command_wand")); + this.entityUUID = entityUUID; + this.npcName = npcName; + this.personalityName = personalityName; + this.activeCommand = activeCommand; + this.hunger = hunger; + this.rest = rest; + this.mood = mood; + this.followDistanceMode = followDistanceMode; + this.homeType = homeType; + this.autoRestEnabled = autoRestEnabled; + this.cellName = cellName; + this.cellQualityName = cellQualityName; + this.activeJobLevelName = activeJobLevelName; + this.activeJobXp = activeJobXp; + this.activeJobXpMax = activeJobXpMax; + } + + @Override + protected void init() { + super.init(); + this.leftPos = (this.width - PANEL_WIDTH) / 2; + this.topPos = (this.height - PANEL_HEIGHT) / 2; + rebuildButtons(); + } + + private void rebuildButtons() { + this.clearWidgets(); + + int contentX = leftPos + MARGIN; + int contentWidth = PANEL_WIDTH - MARGIN * 2; + + // Tab buttons + int tabWidth = contentWidth / 3; + int tabY = topPos + 30; + + Button statusTab = Button.builder( + Component.translatable("gui.tiedup.command_wand.tab.status"), + b -> { + currentTab = Tab.STATUS; + rebuildButtons(); + } + ) + .bounds(contentX, tabY, tabWidth, TAB_HEIGHT) + .build(); + statusTab.active = currentTab != Tab.STATUS; + + Button commandTab = Button.builder( + Component.translatable("gui.tiedup.command_wand.tab.command"), + b -> { + currentTab = Tab.COMMAND; + rebuildButtons(); + } + ) + .bounds(contentX + tabWidth, tabY, tabWidth, TAB_HEIGHT) + .build(); + commandTab.active = currentTab != Tab.COMMAND; + + Button jobsTab = Button.builder( + Component.translatable("gui.tiedup.command_wand.tab.jobs"), + b -> { + currentTab = Tab.JOBS; + rebuildButtons(); + } + ) + .bounds( + contentX + tabWidth * 2, + tabY, + contentWidth - tabWidth * 2, + TAB_HEIGHT + ) + .build(); + jobsTab.active = currentTab != Tab.JOBS; + + this.addRenderableWidget(statusTab); + this.addRenderableWidget(commandTab); + this.addRenderableWidget(jobsTab); + + int contentY = tabY + TAB_HEIGHT + 4; + + switch (currentTab) { + case STATUS -> buildStatusTab(contentX, contentWidth, contentY); + case COMMAND -> buildCommandTab(contentX, contentWidth, contentY); + case JOBS -> buildJobsTab(contentX, contentWidth, contentY); + } + + addBottomButtons(); + } + + // ========================================================================= + // Status Tab + // ========================================================================= + + /** Calculate Y position after all rendered status content (needs, mood, cell, home) */ + private int getStatusButtonsY(int startY) { + int y = startY; + y += 14; // needs section header + y += 8 + 6; // need bar height + padding + y += LINE_HEIGHT + 2; // mood line + y += LINE_HEIGHT + 2; // cell line + y += LINE_HEIGHT + 4; // home line + padding + return y; + } + + private void buildStatusTab(int contentX, int contentWidth, int y) { + int halfWidth = (contentWidth - BTN_SPACING) / 2; + + // Calculate where rendered content ends + y = getStatusButtonsY(y); + + // Auto-Rest + Follow Distance side by side + addAutoRestButton(contentX, y, halfWidth); + addFollowDistanceButton( + contentX + halfWidth + BTN_SPACING, + y, + halfWidth + ); + y += BUTTON_HEIGHT + 4; + + // Current Activity section header + content + y += 14; // section header + y += LINE_HEIGHT; // command name line + + // If active job, skip space for XP bar + NpcCommand cmd = NpcCommand.fromString(activeCommand); + if (cmd.isActiveJob() && !activeJobLevelName.isEmpty()) { + y += LINE_HEIGHT; // tier text + y += 6; // xp bar + } + + y += 8; + + // Discipline section + int thirdWidth = (contentWidth - BTN_SPACING * 2) / 3; + + Button praiseBtn = Button.builder( + Component.translatable("gui.tiedup.dialogue.praise"), + b -> sendDiscipline(DisciplineType.PRAISE) + ) + .bounds(contentX, y, thirdWidth, BUTTON_HEIGHT) + .tooltip( + Tooltip.create( + Component.translatable("gui.tiedup.dialogue.praise.tooltip") + ) + ) + .build(); + this.addRenderableWidget(praiseBtn); + + Button scoldBtn = Button.builder( + Component.translatable("gui.tiedup.dialogue.scold"), + b -> sendDiscipline(DisciplineType.SCOLD) + ) + .bounds( + contentX + thirdWidth + BTN_SPACING, + y, + thirdWidth, + BUTTON_HEIGHT + ) + .tooltip( + Tooltip.create( + Component.translatable("gui.tiedup.dialogue.scold.tooltip") + ) + ) + .build(); + this.addRenderableWidget(scoldBtn); + + Button threatenBtn = Button.builder( + Component.translatable("gui.tiedup.dialogue.threaten"), + b -> sendDiscipline(DisciplineType.THREATEN) + ) + .bounds( + contentX + (thirdWidth + BTN_SPACING) * 2, + y, + thirdWidth, + BUTTON_HEIGHT + ) + .tooltip( + Tooltip.create( + Component.translatable( + "gui.tiedup.dialogue.threaten.tooltip" + ) + ) + ) + .build(); + this.addRenderableWidget(threatenBtn); + } + + // ========================================================================= + // Commands Tab + // ========================================================================= + + private static final int SECTION_HEADER_H = 16; // header line + text + padding + + private void buildCommandTab(int contentX, int contentWidth, int y) { + // -- Movement section -- + y += SECTION_HEADER_H; // space for rendered section header + + int followW = 50; + int distW = 18; + int comeW = 50; + int goHomeW = contentWidth - followW - distW - comeW - 6; + + int x = contentX; + addCommandButton(x, y, NpcCommand.FOLLOW, followW); + x += followW + 2; + addFollowDistanceCycleButton(x, y, distW); + x += distW + 2; + addCommandButton(x, y, NpcCommand.COME, comeW); + x += comeW + 2; + addCommandButton(x, y, NpcCommand.GO_HOME, goHomeW); + y += BUTTON_HEIGHT + 4; + + // -- Position section -- + y += SECTION_HEADER_H; // space for rendered section header + + int quarterWidth = (contentWidth - BTN_SPACING * 3) / 4; + addCommandButton(contentX, y, NpcCommand.STAY, quarterWidth); + addCommandButton( + contentX + (quarterWidth + BTN_SPACING), + y, + NpcCommand.SIT, + quarterWidth + ); + addCommandButton( + contentX + (quarterWidth + BTN_SPACING) * 2, + y, + NpcCommand.KNEEL, + quarterWidth + ); + addCommandButton( + contentX + (quarterWidth + BTN_SPACING) * 3, + y, + NpcCommand.IDLE, + quarterWidth + ); + } + + // ========================================================================= + // Jobs Tab + // ========================================================================= + + private void buildJobsTab(int contentX, int contentWidth, int y) { + int thirdWidth = (contentWidth - BTN_SPACING * 2) / 3; + int halfWidth = (contentWidth - BTN_SPACING) / 2; + + // -- Resource section -- (Farm, Mine, Fish) + y += SECTION_HEADER_H; + addWorkCommandButton(contentX, y, NpcCommand.FARM, thirdWidth); + addWorkCommandButton( + contentX + thirdWidth + BTN_SPACING, + y, + NpcCommand.MINE, + thirdWidth + ); + addWorkCommandButton( + contentX + (thirdWidth + BTN_SPACING) * 2, + y, + NpcCommand.FISH, + thirdWidth + ); + y += BUTTON_HEIGHT + 4; + + // -- Animal section -- (Breed, Shear) + y += SECTION_HEADER_H; + addWorkCommandButton(contentX, y, NpcCommand.BREED, halfWidth); + addWorkCommandButton( + contentX + halfWidth + BTN_SPACING, + y, + NpcCommand.SHEAR, + halfWidth + ); + y += BUTTON_HEIGHT + 4; + + // -- Logistics section -- (Cook, Transfer, Sort) + y += SECTION_HEADER_H; + addWorkCommandButton(contentX, y, NpcCommand.COOK, thirdWidth); + addWorkCommandButton( + contentX + thirdWidth + BTN_SPACING, + y, + NpcCommand.TRANSFER, + thirdWidth + ); + addWorkCommandButton( + contentX + (thirdWidth + BTN_SPACING) * 2, + y, + NpcCommand.SORT, + thirdWidth + ); + y += BUTTON_HEIGHT + 4; + + // -- Security section -- (Patrol, Guard, Collect) + y += SECTION_HEADER_H; + addWorkCommandButton(contentX, y, NpcCommand.PATROL, thirdWidth); + addWorkCommandButton( + contentX + thirdWidth + BTN_SPACING, + y, + NpcCommand.GUARD, + thirdWidth + ); + addWorkCommandButton( + contentX + (thirdWidth + BTN_SPACING) * 2, + y, + NpcCommand.COLLECT, + thirdWidth + ); + y += BUTTON_HEIGHT + 4; + + // -- Utility section -- (Assign Cell, Fetch) + y += SECTION_HEADER_H; + addAssignCellButton(contentX, y, halfWidth); + addCommandButton( + contentX + halfWidth + BTN_SPACING, + y, + NpcCommand.FETCH, + halfWidth + ); + } + + // ========================================================================= + // Bottom Buttons (all tabs) + // ========================================================================= + + private void addBottomButtons() { + int bottomBtnWidth = 90; + int bottomBtnSpacing = 4; + int totalBottomWidth = bottomBtnWidth * 2 + bottomBtnSpacing; + int bottomBtnX = leftPos + (PANEL_WIDTH - totalBottomWidth) / 2; + + int closeY = topPos + PANEL_HEIGHT - MARGIN - BUTTON_HEIGHT; + Button closeBtn = Button.builder( + Component.translatable("gui.tiedup.close"), + b -> onClose() + ) + .bounds(bottomBtnX, closeY, totalBottomWidth, BUTTON_HEIGHT) + .build(); + this.addRenderableWidget(closeBtn); + + int actionY = closeY - BUTTON_HEIGHT - 3; + Button inventoryBtn = Button.builder( + Component.translatable("gui.tiedup.command_wand.inventory"), + b -> openInventory() + ) + .bounds(bottomBtnX, actionY, bottomBtnWidth, BUTTON_HEIGHT) + .build(); + this.addRenderableWidget(inventoryBtn); + + Button stopBtn = Button.builder( + Component.translatable("gui.tiedup.command_wand.stop_action"), + b -> cancelCommand() + ) + .bounds( + bottomBtnX + bottomBtnWidth + bottomBtnSpacing, + actionY, + bottomBtnWidth, + BUTTON_HEIGHT + ) + .build(); + stopBtn.active = !activeCommand.equals("NONE"); + this.addRenderableWidget(stopBtn); + } + + // ========================================================================= + // Button helpers + // ========================================================================= + + private void addCommandButton(int x, int y, NpcCommand command, int width) { + boolean isActive = activeCommand.equals(command.name()); + Component label = Component.translatable( + "tiedup.command." + command.name().toLowerCase() + ); + Button btn = Button.builder(label, b -> sendCommand(command)) + .bounds(x, y, width, BUTTON_HEIGHT) + .tooltip( + Tooltip.create( + Component.translatable( + "tiedup.command." + + command.name().toLowerCase() + + ".tooltip" + ) + ) + ) + .build(); + btn.active = !isActive; + this.addRenderableWidget(btn); + } + + private void addWorkCommandButton( + int x, + int y, + NpcCommand command, + int width + ) { + boolean isActive = activeCommand.equals(command.name()); + Component label = Component.translatable( + "tiedup.command." + command.name().toLowerCase() + ); + Button btn = Button.builder(label, b -> selectWorkCommand(command)) + .bounds(x, y, width, BUTTON_HEIGHT) + .tooltip( + Tooltip.create( + Component.translatable( + "tiedup.command." + + command.name().toLowerCase() + + ".tooltip" + ) + ) + ) + .build(); + btn.active = !isActive; + this.addRenderableWidget(btn); + } + + private void addFollowDistanceCycleButton(int x, int y, int width) { + String modeText = switch (followDistanceMode) { + case "HEEL" -> "H"; + case "CLOSE" -> "C"; + case "FAR" -> "F"; + default -> "?"; + }; + + Component tooltip = Component.translatable( + "gui.tiedup.command_wand.follow_distance.tooltip" + ) + .append("\n") + .append( + Component.translatable( + "gui.tiedup.command_wand.follow_distance.current" + ) + ) + .append(": ") + .append( + Component.translatable( + "tiedup.follow_distance." + followDistanceMode.toLowerCase() + ) + ); + + Button btn = Button.builder(Component.literal(modeText), b -> + cycleFollowDistance() + ) + .bounds(x, y, width, BUTTON_HEIGHT) + .tooltip(Tooltip.create(tooltip)) + .build(); + this.addRenderableWidget(btn); + } + + private void addFollowDistanceButton(int contentX, int y, int width) { + String modeText = switch (followDistanceMode) { + case "HEEL" -> "H"; + case "CLOSE" -> "C"; + case "FAR" -> "F"; + default -> "?"; + }; + + Component label = Component.translatable( + "gui.tiedup.command_wand.follow_distance" + ).append(": " + modeText); + + Component tooltip = Component.translatable( + "gui.tiedup.command_wand.follow_distance.tooltip" + ) + .append("\n") + .append( + Component.translatable( + "gui.tiedup.command_wand.follow_distance.current" + ) + ) + .append(": ") + .append( + Component.translatable( + "tiedup.follow_distance." + followDistanceMode.toLowerCase() + ) + ); + + Button btn = Button.builder(label, b -> cycleFollowDistance()) + .bounds(contentX, y, width, BUTTON_HEIGHT) + .tooltip(Tooltip.create(tooltip)) + .build(); + this.addRenderableWidget(btn); + } + + private void addAutoRestButton(int x, int y, int width) { + String stateText = autoRestEnabled ? "ON" : "OFF"; + Component label = Component.translatable( + "gui.tiedup.command_wand.auto_rest" + ).append(": " + stateText); + + Button btn = Button.builder(label, b -> toggleAutoRest()) + .bounds(x, y, width, BUTTON_HEIGHT) + .tooltip( + Tooltip.create( + Component.translatable( + "gui.tiedup.command_wand.auto_rest.tooltip" + ) + ) + ) + .build(); + btn.active = !cellName.isEmpty(); + this.addRenderableWidget(btn); + } + + private void addAssignCellButton(int x, int y, int width) { + Component label = cellName.isEmpty() + ? Component.translatable("gui.tiedup.command_wand.assign_cell") + : Component.translatable( + "gui.tiedup.command_wand.assign_cell" + ).append(" \u2713"); + + Component tooltip = cellName.isEmpty() + ? Component.translatable( + "gui.tiedup.command_wand.assign_cell.tooltip" + ) + : Component.translatable( + "gui.tiedup.command_wand.assign_cell.tooltip.assigned" + ).append(": " + cellName); + + Button btn = Button.builder(label, b -> openCellSelector()) + .bounds(x, y, width, BUTTON_HEIGHT) + .tooltip(Tooltip.create(tooltip)) + .build(); + this.addRenderableWidget(btn); + } + + // ========================================================================= + // Network actions + // ========================================================================= + + private void sendCommand(NpcCommand command) { + ModNetwork.sendToServer( + PacketNpcCommand.giveCommand(entityUUID, command, null) + ); + onClose(); + } + + private void selectWorkCommand(NpcCommand command) { + ModNetwork.sendToServer( + PacketNpcCommand.selectJob(entityUUID, command) + ); + onClose(); + } + + private void cycleFollowDistance() { + ModNetwork.sendToServer( + PacketNpcCommand.cycleFollowDistance(entityUUID, true) + ); + } + + private void toggleAutoRest() { + ModNetwork.sendToServer(PacketNpcCommand.toggleAutoRest(entityUUID, true)); + } + + private void openInventory() { + ModNetwork.sendToServer(new PacketRequestNpcInventory(entityUUID)); + } + + private void openCellSelector() { + ModNetwork.sendToServer(new PacketRequestCellList(entityUUID)); + } + + private void cancelCommand() { + ModNetwork.sendToServer(PacketNpcCommand.cancelCommand(entityUUID)); + onClose(); + } + + private void sendDiscipline(DisciplineType type) { + int entityId = getEntityIdFromUUID(entityUUID); + if (entityId != -1) { + ModNetwork.sendToServer(new PacketDisciplineAction(entityId, type)); + onClose(); + } + } + + private int getEntityIdFromUUID(java.util.UUID uuid) { + Minecraft mc = Minecraft.getInstance(); + if (mc.level != null) { + for (net.minecraft.world.entity.Entity entity : mc.level.entitiesForRendering()) { + if (entity.getUUID().equals(uuid)) { + return entity.getId(); + } + } + } + return -1; + } + + // ========================================================================= + // Render + // ========================================================================= + + @Override + public void render( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + this.renderBackground(graphics); + + GuiTextureHelper.renderBeveledPanel( + graphics, + leftPos, + topPos, + PANEL_WIDTH, + PANEL_HEIGHT + ); + + int contentX = leftPos + MARGIN; + int contentWidth = PANEL_WIDTH - MARGIN * 2; + int y = topPos + MARGIN; + + // --- Header: NPC name --- + graphics.drawCenteredString( + this.font, + npcName, + leftPos + PANEL_WIDTH / 2, + y, + COLOR_WHITE + ); + y += LINE_HEIGHT; + + // --- Header: Personality + mood color dot --- + int moodDotColor = + mood >= 70 ? 0xFF4CAF50 : mood >= 40 ? 0xFFFF9800 : 0xFFF44336; + String personalityText = personalityName; + int personalityWidth = this.font.width(personalityText); + int headerCenterX = leftPos + PANEL_WIDTH / 2; + int textStartX = headerCenterX - (personalityWidth + 8) / 2; + graphics.drawString( + this.font, + personalityText, + textStartX + 8, + y, + COLOR_GRAY, + false + ); + graphics.fill(textStartX, y + 2, textStartX + 5, y + 7, moodDotColor); + y += LINE_HEIGHT + 3; + + // Tab content area starts after tabs + y = topPos + 30 + TAB_HEIGHT + 4; + + switch (currentTab) { + case STATUS -> renderStatusContent( + graphics, + contentX, + contentWidth, + y, + mouseX, + mouseY + ); + case COMMAND -> renderCommandContent( + graphics, + contentX, + contentWidth, + y + ); + case JOBS -> renderJobsContent(graphics, contentX, contentWidth, y); + } + + super.render(graphics, mouseX, mouseY, partialTick); + } + + // ========================================================================= + // Render: Status Tab + // ========================================================================= + + private void renderStatusContent( + GuiGraphics graphics, + int contentX, + int contentWidth, + int y, + int mouseX, + int mouseY + ) { + // -- Needs section -- + renderSectionHeader( + graphics, + contentX, + contentWidth, + y, + "gui.tiedup.command_wand.needs" + ); + y += 14; + + // Hunger bar + int barW = 80; + int barH = 8; + int hungerLabelW = + this.font.width( + Component.translatable("tiedup.need.hunger").getString() + ) + + 4; + graphics.drawString( + this.font, + Component.translatable("tiedup.need.hunger"), + contentX, + y, + COLOR_GRAY, + false + ); + renderNeedBar(graphics, contentX + hungerLabelW, y, barW, barH, hunger); + + // Rest bar + int restX = contentX + contentWidth / 2 + 4; + int restLabelW = + this.font.width( + Component.translatable("tiedup.need.rest").getString() + ) + + 4; + graphics.drawString( + this.font, + Component.translatable("tiedup.need.rest"), + restX, + y, + COLOR_GRAY, + false + ); + renderNeedBar(graphics, restX + restLabelW, y, barW, barH, rest); + + // Hunger bar tooltip + if ( + mouseX >= contentX + hungerLabelW && + mouseX <= contentX + hungerLabelW + barW && + mouseY >= y && + mouseY <= y + barH + ) { + graphics.renderTooltip( + this.font, + Component.literal((int) hunger + "%"), + mouseX, + mouseY + ); + } + // Rest bar tooltip + if ( + mouseX >= restX + restLabelW && + mouseX <= restX + restLabelW + barW && + mouseY >= y && + mouseY <= y + barH + ) { + graphics.renderTooltip( + this.font, + Component.literal((int) rest + "%"), + mouseX, + mouseY + ); + } + + y += barH + 6; + + // -- Mood -- + int moodColor = GuiStatsRenderer.getValueColor(mood); + String moodWord = mood >= 70 ? "Happy" : mood >= 40 ? "Neutral" : "Sad"; + Component moodKey = Component.translatable( + "gui.tiedup.command_wand.mood." + moodWord.toLowerCase() + ); + graphics.drawString( + this.font, + Component.translatable("gui.tiedup.command_wand.mood") + .append(": " + (int) mood + "% (") + .append(moodKey) + .append(")"), + contentX, + y, + moodColor, + false + ); + y += LINE_HEIGHT + 2; + + // -- Cell info -- + String cellDisplay = cellName.isEmpty() ? "-" : cellName; + if (!cellQualityName.isEmpty()) cellDisplay += + " (" + cellQualityName + ")"; + graphics.drawString( + this.font, + Component.translatable("gui.tiedup.command_wand.cell").append( + ": " + cellDisplay + ), + contentX, + y, + COLOR_GRAY, + false + ); + y += LINE_HEIGHT + 2; + + // -- Home type -- + graphics.drawString( + this.font, + Component.translatable("gui.tiedup.command_wand.home_type") + .append(": ") + .append( + Component.translatable( + "gui.tiedup.command_wand.home_type." + + homeType.toLowerCase() + ) + ), + contentX, + y, + COLOR_GRAY, + false + ); + y += LINE_HEIGHT + 4; + + // Auto-Rest and Follow Distance buttons are placed by buildStatusTab at this Y + y += BUTTON_HEIGHT + 4; + + // -- Current Activity section -- + renderSectionHeader( + graphics, + contentX, + contentWidth, + y, + "gui.tiedup.command_wand.section.activity" + ); + y += 14; + + NpcCommand cmd = NpcCommand.fromString(activeCommand); + if (!activeCommand.equals("NONE")) { + graphics.drawString( + this.font, + Component.translatable( + "tiedup.command." + activeCommand.toLowerCase() + ), + contentX, + y, + COLOR_ORANGE, + false + ); + } else { + graphics.drawString( + this.font, + Component.translatable("tiedup.command.none"), + contentX, + y, + 0x666666, + false + ); + } + y += LINE_HEIGHT; + + // Job XP bar (only if active command is a job) + if (cmd.isActiveJob() && !activeJobLevelName.isEmpty()) { + graphics.drawString( + this.font, + activeJobLevelName + + " (" + + activeJobXp + + "/" + + activeJobXpMax + + ")", + contentX, + y, + COLOR_GRAY, + false + ); + y += LINE_HEIGHT; + int xpBarW = contentWidth; + float xpProgress = + activeJobXpMax > 0 ? (float) activeJobXp / activeJobXpMax : 0f; + GuiTextureHelper.renderXpBar( + graphics, + contentX, + y, + xpBarW, + xpProgress + ); + } + } + + private void renderNeedBar( + GuiGraphics graphics, + int x, + int y, + int w, + int h, + float value + ) { + graphics.fill(x, y, x + w, y + h, 0xFF000000); + int fillW = (int) (w * (value / 100f)); + int barColor = + value >= 70 ? 0xFF4CAF50 : value >= 40 ? 0xFFFF9800 : 0xFFF44336; + if (fillW > 0) { + graphics.fill(x, y, x + fillW, y + h, barColor); + } + } + + // ========================================================================= + // Render: Commands Tab + // ========================================================================= + + private void renderCommandContent( + GuiGraphics graphics, + int contentX, + int contentWidth, + int y + ) { + // Movement section header + renderSectionHeader( + graphics, + contentX, + contentWidth, + y, + "gui.tiedup.command_wand.section.movement" + ); + y += SECTION_HEADER_H + BUTTON_HEIGHT + 4; + + // Position section header + renderSectionHeader( + graphics, + contentX, + contentWidth, + y, + "gui.tiedup.command_wand.section.position" + ); + } + + // ========================================================================= + // Render: Jobs Tab + // ========================================================================= + + private void renderJobsContent( + GuiGraphics graphics, + int contentX, + int contentWidth, + int y + ) { + // Resource section (1 row of 3) + renderSectionHeader( + graphics, + contentX, + contentWidth, + y, + "gui.tiedup.command_wand.section.resource" + ); + y += SECTION_HEADER_H + BUTTON_HEIGHT + 4; + + // Animal section (1 row of 2) + renderSectionHeader( + graphics, + contentX, + contentWidth, + y, + "gui.tiedup.command_wand.section.animal" + ); + y += SECTION_HEADER_H + BUTTON_HEIGHT + 4; + + // Logistics section (1 row of 3) + renderSectionHeader( + graphics, + contentX, + contentWidth, + y, + "gui.tiedup.command_wand.section.logistics" + ); + y += SECTION_HEADER_H + BUTTON_HEIGHT + 4; + + // Security section (1 row of 3) + renderSectionHeader( + graphics, + contentX, + contentWidth, + y, + "gui.tiedup.command_wand.section.security" + ); + y += SECTION_HEADER_H + BUTTON_HEIGHT + 4; + + // Utility section (1 row of 2) + renderSectionHeader( + graphics, + contentX, + contentWidth, + y, + "gui.tiedup.command_wand.section.utility" + ); + } + + // ========================================================================= + // Render helpers + // ========================================================================= + + private void renderSectionHeader( + GuiGraphics graphics, + int x, + int contentWidth, + int y, + String translationKey + ) { + graphics.fill(x, y, x + contentWidth, y + 1, 0xFF373737); + graphics.drawString( + this.font, + Component.translatable(translationKey), + x, + y + 4, + COLOR_WHITE, + false + ); + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/screens/ContinuousStruggleMiniGameScreen.java b/src/main/java/com/tiedup/remake/client/gui/screens/ContinuousStruggleMiniGameScreen.java new file mode 100644 index 0000000..6c4f4a9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/screens/ContinuousStruggleMiniGameScreen.java @@ -0,0 +1,544 @@ +package com.tiedup.remake.client.gui.screens; + +import com.mojang.blaze3d.platform.InputConstants; +import com.tiedup.remake.client.ModKeybindings; +import com.tiedup.remake.client.gui.util.GuiColors; +import com.tiedup.remake.client.gui.util.GuiLayoutConstants; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.minigame.PacketContinuousStruggleHold; +import com.tiedup.remake.network.minigame.PacketContinuousStruggleStop; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.lwjgl.glfw.GLFW; + +/** + * Client-side GUI for the Continuous Struggle mini-game. + * + * Layout: + * ┌─────────────────────────────────────┐ + * │ STRUGGLING... │ + * │ │ + * │ ┌─────┐ │ + * │ │ ↑ │ ← Arrow │ + * │ │ W │ direction │ + * │ └─────┘ │ + * │ │ + * │ HOLD [W] to struggle! │ + * │ │ + * │ ████████████░░░░░░░░░ 45/100 │ ← Progress bar + * │ │ + * │ Press ESC to stop │ + * └─────────────────────────────────────┘ + */ +@OnlyIn(Dist.CLIENT) +public class ContinuousStruggleMiniGameScreen extends BaseScreen { + + private UUID sessionId; + private int currentDirection; + private int currentResistance; + private int maxResistance; + private boolean isLocked; + + // Visual state + private float animatedResistance; + private int flashTicks; + private boolean showDirectionChangeFlash; + private boolean showShockEffect; + private int shockEffectTicks; + private boolean gameComplete; + private boolean gameSuccess; + private int completionDelayTicks; + + // Input state + private int heldDirection = -1; + private int ticksSinceLastUpdate = 0; + + // Colors + private static final int ARROW_BG_NORMAL = 0xFF444444; + private static final int ARROW_BG_HOLDING = 0xFF006600; + private static final int ARROW_BG_WRONG = 0xFF664400; + private static final int ARROW_BG_SHOCKED = 0xFF660000; + private static final int ARROW_TEXT_COLOR = 0xFFFFFFFF; + private static final int PROGRESS_BAR_BG = 0xFF442222; + private static final int PROGRESS_BAR_FILL = 0xFF44AA44; + private static final int PROGRESS_BAR_EMPTY = 0xFFAA4444; + + // Update interval (every 5 ticks = 4 times per second) + private static final int UPDATE_INTERVAL_TICKS = 5; + + public ContinuousStruggleMiniGameScreen( + UUID sessionId, + int currentDirection, + int currentResistance, + int maxResistance, + boolean isLocked + ) { + super(Component.translatable("gui.tiedup.continuous_struggle")); + this.sessionId = sessionId; + this.currentDirection = currentDirection; + this.currentResistance = currentResistance; + this.maxResistance = maxResistance; + this.isLocked = isLocked; + this.animatedResistance = currentResistance; + } + + @Override + protected int getPreferredWidth() { + return GuiLayoutConstants.getResponsiveWidth( + this.width, + 0.5f, + 280, + 350 + ); + } + + @Override + protected int getPreferredHeight() { + return GuiLayoutConstants.getResponsiveHeight( + this.height, + 0.45f, + 200, + 280 + ); + } + + @Override + protected void init() { + super.init(); + TiedUpMod.LOGGER.info( + "[ContinuousStruggleMiniGameScreen] Screen initialized: dir={}, resistance={}/{}, locked={}", + currentDirection, + currentResistance, + maxResistance, + isLocked + ); + } + + // ==================== STATE UPDATES FROM SERVER ==================== + + /** + * Called when direction changes. + */ + public void onDirectionChange(int newDirection) { + this.currentDirection = newDirection; + this.showDirectionChangeFlash = true; + this.flashTicks = 15; + + TiedUpMod.LOGGER.debug( + "[ContinuousStruggleMiniGameScreen] Direction changed to {}", + getDirectionKeyName(newDirection) + ); + } + + /** + * Called when resistance updates. + */ + public void onResistanceUpdate(int newResistance) { + this.currentResistance = newResistance; + } + + /** + * Called when shock collar triggers. + */ + public void onShock() { + this.showShockEffect = true; + this.shockEffectTicks = 20; + } + + /** + * Called when player escapes successfully. + */ + public void onEscape() { + this.gameComplete = true; + this.gameSuccess = true; + this.completionDelayTicks = 40; // 2 seconds + } + + /** + * Called when session ends. + */ + public void onEnd() { + this.gameComplete = true; + this.gameSuccess = false; + this.onClose(); + } + + // ==================== TICK AND RENDER ==================== + + @Override + public void tick() { + super.tick(); + + // Animate resistance bar + animatedResistance = lerp(animatedResistance, currentResistance, 0.15f); + + // Flash timer + if (flashTicks > 0) { + flashTicks--; + if (flashTicks == 0) { + showDirectionChangeFlash = false; + } + } + + // Shock effect timer + if (shockEffectTicks > 0) { + shockEffectTicks--; + if (shockEffectTicks == 0) { + showShockEffect = false; + } + } + + // Completion delay + if (gameComplete && completionDelayTicks > 0) { + completionDelayTicks--; + if (completionDelayTicks <= 0) { + this.onClose(); + } + return; // Don't send updates after game complete + } + + // Check held keys and send updates to server + if (!gameComplete) { + updateHeldDirection(); + + ticksSinceLastUpdate++; + if (ticksSinceLastUpdate >= UPDATE_INTERVAL_TICKS) { + ticksSinceLastUpdate = 0; + sendHoldUpdate(); + } + } + } + + /** + * Check which direction key is currently held. + * Uses InputConstants.isKeyDown() directly because keyMapping.isDown() + * doesn't work properly when a Screen is open. + */ + private void updateHeldDirection() { + if (this.minecraft == null) return; + + long windowHandle = this.minecraft.getWindow().getWindow(); + int newHeldDirection = -1; + + // Check each direction key using direct GLFW input + for (int i = 0; i < 4; i++) { + KeyMapping keyMapping = ModKeybindings.getStruggleDirectionKey(i); + if (keyMapping != null) { + // Get the key code from the KeyMapping + InputConstants.Key key = keyMapping.getKey(); + if (key.getType() == InputConstants.Type.KEYSYM) { + if ( + InputConstants.isKeyDown(windowHandle, key.getValue()) + ) { + newHeldDirection = i; + break; + } + } + } + } + + this.heldDirection = newHeldDirection; + } + + /** + * Send held direction update to server. + */ + private void sendHoldUpdate() { + boolean isHolding = heldDirection >= 0; + ModNetwork.sendToServer( + new PacketContinuousStruggleHold( + sessionId, + heldDirection, + isHolding + ) + ); + } + + @Override + public void render( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + try { + super.render(graphics, mouseX, mouseY, partialTick); + + int centerX = this.width / 2; + int y = this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 15; + + // Direction arrow + renderDirectionArrow(graphics, centerX, y); + y += 80; + + // Hold instruction + renderHoldInstruction(graphics, centerX, y); + y += 25; + + // Progress bar + renderProgressBar(graphics, y); + y += 40; + + // Lock indicator + if (isLocked) { + Component lockText = Component.translatable( + "gui.tiedup.continuous_struggle.status.locked" + ).withStyle(ChatFormatting.RED, ChatFormatting.BOLD); + int lockWidth = this.font.width(lockText); + graphics.drawString( + this.font, + lockText, + centerX - lockWidth / 2, + y, + 0xFFFFFFFF + ); + y += 18; + } + + // Status / instructions + renderStatus(graphics, centerX, y); + + // Shock overlay + if (showShockEffect) { + renderShockOverlay(graphics); + } + } catch (Exception e) { + TiedUpMod.LOGGER.error( + "[ContinuousStruggleMiniGameScreen] Render error: ", + e + ); + } + } + + private void renderDirectionArrow( + GuiGraphics graphics, + int centerX, + int y + ) { + int arrowSize = 60; + int arrowX = centerX - arrowSize / 2; + + // Determine background color + int bgColor; + if (showShockEffect) { + bgColor = ARROW_BG_SHOCKED; + } else if (showDirectionChangeFlash) { + bgColor = (flashTicks % 4 < 2) ? 0xFF666600 : ARROW_BG_NORMAL; + } else if (heldDirection == currentDirection) { + bgColor = ARROW_BG_HOLDING; + } else if (heldDirection >= 0 && heldDirection != currentDirection) { + bgColor = ARROW_BG_WRONG; + } else { + bgColor = ARROW_BG_NORMAL; + } + + // Draw arrow box + graphics.fill(arrowX, y, arrowX + arrowSize, y + arrowSize, bgColor); + graphics.renderOutline(arrowX, y, arrowSize, arrowSize, 0xFFAAAAAA); + + // Draw arrow symbol + String arrowSymbol = getDirectionArrow(currentDirection); + int arrowWidth = this.font.width(arrowSymbol); + graphics.drawString( + this.font, + arrowSymbol, + centerX - arrowWidth / 2, + y + 12, + ARROW_TEXT_COLOR + ); + + // Draw key name + String keyName = getDirectionKeyName(currentDirection); + int keyWidth = this.font.width(keyName); + graphics.drawString( + this.font, + keyName, + centerX - keyWidth / 2, + y + arrowSize - 20, + ARROW_TEXT_COLOR + ); + } + + private void renderHoldInstruction( + GuiGraphics graphics, + int centerX, + int y + ) { + String keyName = getDirectionKeyName(currentDirection); + Component instruction; + + if (showShockEffect) { + instruction = Component.translatable( + "gui.tiedup.continuous_struggle.status.shocked" + ).withStyle(ChatFormatting.RED, ChatFormatting.BOLD); + } else if (heldDirection == currentDirection) { + instruction = Component.translatable( + "gui.tiedup.continuous_struggle.status.struggling" + ).withStyle(ChatFormatting.GREEN); + } else { + instruction = Component.translatable( + "gui.tiedup.continuous_struggle.hold_key", + keyName + ).withStyle(ChatFormatting.YELLOW); + } + + int instructionWidth = this.font.width(instruction); + graphics.drawString( + this.font, + instruction, + centerX - instructionWidth / 2, + y, + 0xFFFFFFFF + ); + } + + private void renderProgressBar(GuiGraphics graphics, int y) { + int barWidth = 200; + int barHeight = 18; + int barX = (this.width - barWidth) / 2; + + // Calculate progress (inverted: 0 resistance = full progress) + float progress = + maxResistance > 0 + ? 1.0f - (animatedResistance / maxResistance) + : 0.0f; + progress = Math.max(0.0f, Math.min(1.0f, progress)); + + // Background (empty portion) + graphics.fill(barX, y, barX + barWidth, y + barHeight, PROGRESS_BAR_BG); + + // Fill (progress portion) + int fillWidth = (int) (barWidth * progress); + if (fillWidth > 0) { + graphics.fill( + barX, + y, + barX + fillWidth, + y + barHeight, + PROGRESS_BAR_FILL + ); + } + + // Border + graphics.renderOutline(barX, y, barWidth, barHeight, 0xFF888888); + + // Text showing resistance + String progressText = String.format( + "%d/%d", + (int) animatedResistance, + maxResistance + ); + int textWidth = this.font.width(progressText); + graphics.drawString( + this.font, + progressText, + barX + barWidth + 8, + y + 5, + GuiColors.TEXT_WHITE + ); + + // Label above bar + Component label = Component.translatable( + "gui.tiedup.continuous_struggle.label.resistance" + ).withStyle(ChatFormatting.GRAY); + graphics.drawString( + this.font, + label, + barX, + y - 12, + GuiColors.TEXT_WHITE + ); + } + + private void renderStatus(GuiGraphics graphics, int centerX, int y) { + Component status; + + if (gameComplete && gameSuccess) { + status = Component.translatable( + "gui.tiedup.continuous_struggle.status.escaped" + ).withStyle(ChatFormatting.GREEN, ChatFormatting.BOLD); + } else if (gameComplete) { + status = Component.translatable( + "gui.tiedup.continuous_struggle.status.stopped" + ).withStyle(ChatFormatting.GRAY); + } else { + status = Component.translatable( + "gui.tiedup.continuous_struggle.status.press_esc" + ).withStyle(ChatFormatting.DARK_GRAY); + } + + int statusWidth = this.font.width(status); + graphics.drawString( + this.font, + status, + centerX - statusWidth / 2, + y, + 0xFFFFFFFF + ); + } + + private void renderShockOverlay(GuiGraphics graphics) { + // Red flash overlay + int alpha = (int) (100 * ((float) shockEffectTicks / 20.0f)); + int overlayColor = (alpha << 24) | 0xFF0000; + graphics.fill(0, 0, this.width, this.height, overlayColor); + } + + // ==================== INPUT HANDLING ==================== + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + // ESC to stop — onClose() handles sending the stop packet + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + this.onClose(); + return true; + } + + return super.keyPressed(keyCode, scanCode, modifiers); + } + + @Override + public void onClose() { + // Send stop packet if not already complete + if (!gameComplete) { + ModNetwork.sendToServer( + new PacketContinuousStruggleStop(sessionId) + ); + } + super.onClose(); + } + + @Override + public boolean isPauseScreen() { + return false; + } + + // ==================== HELPERS ==================== + + private String getDirectionArrow(int direction) { + return switch (direction) { + case 0 -> "\u2191"; // ↑ + case 1 -> "\u2190"; // ← + case 2 -> "\u2193"; // ↓ + case 3 -> "\u2192"; // → + default -> "?"; + }; + } + + private String getDirectionKeyName(int direction) { + return ModKeybindings.getStruggleDirectionKeyName(direction); + } + + private float lerp(float current, float target, float speed) { + if (Math.abs(current - target) < 0.5f) { + return target; + } + return current + (target - current) * speed; + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/screens/ConversationScreen.java b/src/main/java/com/tiedup/remake/client/gui/screens/ConversationScreen.java new file mode 100644 index 0000000..3ad5e17 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/screens/ConversationScreen.java @@ -0,0 +1,274 @@ +package com.tiedup.remake.client.gui.screens; + +import com.tiedup.remake.dialogue.conversation.ConversationTopic; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.conversation.PacketEndConversationC2S; +import com.tiedup.remake.network.conversation.PacketSelectTopic; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.network.chat.Component; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * GUI screen for interactive conversations with NPCs. + * Displays available conversation topics with effectiveness indicators. + * + * Phase 5: Enhanced Conversation System + * Phase 2: Refactored to extend BaseInteractionScreen + * + * DISABLED: Conversation system not in use. Kept because PacketEndConversationS2C + * references this class in an instanceof check. + */ +@OnlyIn(Dist.CLIENT) +public class ConversationScreen extends BaseInteractionScreen { + + // Layout constants + private static final int PANEL_WIDTH = 260; + private static final int PANEL_HEIGHT = 220; + private static final int MARGIN = 12; + private static final int BUTTON_HEIGHT = 22; + private static final int BUTTON_SPACING = 4; + private static final int CATEGORY_SPACING = 8; + + // Color constants + private static final int TITLE_COLOR = 0xFFFFD700; + private static final int CATEGORY_COLOR = 0xFF88AAFF; + private static final int TEXT_WHITE = 0xFFFFFFFF; + private static final int TEXT_DIM = 0xFFAAAAAA; + + // Effectiveness colors (green -> yellow -> orange -> red) + private static final int EFF_100 = 0xFF55FF55; // Green (100%) + private static final int EFF_80 = 0xFFAAFF55; // Yellow-green (80%) + private static final int EFF_60 = 0xFFFFFF55; // Yellow (60%) + private static final int EFF_40 = 0xFFFFAA55; // Orange (40%) + private static final int EFF_20 = 0xFFFF5555; // Red (20%) + + // Data + private final int entityId; + private final String npcName; + private final List availableTopics; + private final Map< + ConversationTopic.Category, + List + > topicsByCategory; + + // Effectiveness tracking (sent from server or estimated client-side) + private final Map topicEffectiveness; + + // Rapport level (0-100 display) + private int rapportLevel = 50; + + public ConversationScreen( + int entityId, + String npcName, + List topics + ) { + super( + Component.translatable("gui.tiedup.conversation.title", npcName), + PANEL_WIDTH, + PANEL_HEIGHT + ); + this.entityId = entityId; + this.npcName = npcName; + this.availableTopics = topics; + this.topicEffectiveness = new LinkedHashMap<>(); + + // Initialize all topics as 100% effective (will be updated from server) + for (ConversationTopic topic : topics) { + topicEffectiveness.put(topic, 1.0f); + } + + // Organize topics by category + this.topicsByCategory = new LinkedHashMap<>(); + for (ConversationTopic.Category category : ConversationTopic.Category.values()) { + topicsByCategory.put(category, new ArrayList<>()); + } + for (ConversationTopic topic : topics) { + topicsByCategory.get(topic.getCategory()).add(topic); + } + // Remove empty categories + topicsByCategory + .entrySet() + .removeIf(entry -> entry.getValue().isEmpty()); + } + + /** + * Update topic effectiveness (called from packet handler). + * + * @param topic The topic + * @param effectiveness Effectiveness value (0.2 to 1.0) + */ + public void updateEffectiveness( + ConversationTopic topic, + float effectiveness + ) { + topicEffectiveness.put(topic, effectiveness); + } + + /** + * Update rapport level (called from packet handler). + * + * @param rapport Rapport value (-100 to 100) + */ + public void updateRapport(int rapport) { + this.rapportLevel = rapport; + } + + @Override + protected void init() { + super.init(); // Centers the panel (sets leftPos and topPos) + rebuildButtons(); + } + + private void rebuildButtons() { + this.clearWidgets(); + + int contentX = getContentX(MARGIN); + int contentWidth = getContentWidth(MARGIN); + int y = topPos + 40; // After title and WIP badge + + // WIP placeholder buttons + Button wipBtn1 = Button.builder( + Component.translatable( + "gui.tiedup.conversation.wip.small_talk" + ).withStyle(net.minecraft.ChatFormatting.GRAY), + b -> {} + ) + .bounds(contentX, y, contentWidth, BUTTON_HEIGHT) + .build(); + wipBtn1.active = false; + addRenderableWidget(wipBtn1); + y += BUTTON_HEIGHT + BUTTON_SPACING; + + Button wipBtn2 = Button.builder( + Component.translatable( + "gui.tiedup.conversation.wip.deep_topics" + ).withStyle(net.minecraft.ChatFormatting.GRAY), + b -> {} + ) + .bounds(contentX, y, contentWidth, BUTTON_HEIGHT) + .build(); + wipBtn2.active = false; + addRenderableWidget(wipBtn2); + y += BUTTON_HEIGHT + BUTTON_SPACING; + + Button wipBtn3 = Button.builder( + Component.translatable( + "gui.tiedup.conversation.wip.flirting" + ).withStyle(net.minecraft.ChatFormatting.GRAY), + b -> {} + ) + .bounds(contentX, y, contentWidth, BUTTON_HEIGHT) + .build(); + wipBtn3.active = false; + addRenderableWidget(wipBtn3); + y += BUTTON_HEIGHT + BUTTON_SPACING; + + Button wipBtn4 = Button.builder( + Component.translatable( + "gui.tiedup.conversation.wip.requests" + ).withStyle(net.minecraft.ChatFormatting.GRAY), + b -> {} + ) + .bounds(contentX, y, contentWidth, BUTTON_HEIGHT) + .build(); + wipBtn4.active = false; + addRenderableWidget(wipBtn4); + + // Close button at bottom + int closeBtnY = topPos + panelHeight - 28; + addRenderableWidget( + Button.builder(Component.translatable("gui.tiedup.close"), btn -> + onClose() + ) + .bounds(leftPos + panelWidth / 2 - 40, closeBtnY, 80, 20) + .build() + ); + } + + private int getEffectivenessColor(float effectiveness) { + if (effectiveness >= 1.0f) return EFF_100; + if (effectiveness >= 0.8f) return EFF_80; + if (effectiveness >= 0.6f) return EFF_60; + if (effectiveness >= 0.4f) return EFF_40; + return EFF_20; + } + + private String getEffectivenessSymbol(float effectiveness) { + return (int) (effectiveness * 100) + "%"; + } + + private void selectTopic(ConversationTopic topic) { + // Send packet to server + ModNetwork.sendToServer(new PacketSelectTopic(entityId, topic)); + + // Decrease local effectiveness estimate (will be corrected by server) + float current = topicEffectiveness.getOrDefault(topic, 1.0f); + float next = Math.max(0.2f, current - 0.2f); + topicEffectiveness.put(topic, next); + + // Don't rebuild buttons immediately - wait for server response + } + + @Override + protected void renderContent( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + // Draw simple title with WIP badge + renderTitle( + graphics, + Component.translatable("gui.tiedup.conversation.heading"), + topPos + 8, + TITLE_COLOR + ); + + // WIP badge + Component wipBadge = Component.translatable( + "gui.tiedup.conversation.wip_badge" + ).withStyle( + net.minecraft.ChatFormatting.YELLOW, + net.minecraft.ChatFormatting.BOLD + ); + graphics.drawCenteredString( + font, + wipBadge, + leftPos + panelWidth / 2, + topPos + 18, + 0xFFFFAA00 + ); + + // Info text + Component infoText = Component.translatable( + "gui.tiedup.conversation.status.coming_soon" + ).withStyle( + net.minecraft.ChatFormatting.GRAY, + net.minecraft.ChatFormatting.ITALIC + ); + graphics.drawCenteredString( + font, + infoText, + leftPos + panelWidth / 2, + topPos + 30, + TEXT_DIM + ); + } + + @Override + public void onClose() { + // Notify server that conversation ended + ModNetwork.sendToServer(new PacketEndConversationC2S(entityId)); + super.onClose(); + } + + public int getEntityId() { + return entityId; + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/screens/LockpickMiniGameScreen.java b/src/main/java/com/tiedup/remake/client/gui/screens/LockpickMiniGameScreen.java new file mode 100644 index 0000000..b1911a4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/screens/LockpickMiniGameScreen.java @@ -0,0 +1,511 @@ +package com.tiedup.remake.client.gui.screens; + +import com.tiedup.remake.client.gui.util.GuiColors; +import com.tiedup.remake.client.gui.util.GuiLayoutConstants; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.minigame.PacketLockpickAttempt; +import com.tiedup.remake.network.minigame.PacketLockpickMiniGameMove; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.lwjgl.glfw.GLFW; + +/** + * Phase 2.5: Client-side GUI for Lockpick mini-game (Skyrim-style). + * + * Features: + * - Sweet spot is HIDDEN (uniform gray bar) + * - NO feedback during movement (A/D) + * - Feedback ONLY when testing (SPACE): tension bar animation + * - Tension bar fills up based on proximity, then bounces back if miss + * - Success = tension bar fills to 100% and stays + * + * Visual layout: + * ┌─────────────────────────────────────────┐ + * │ LOCKPICKING │ + * │ │ + * │ Position: │ + * │ ┌─────────────────────────────────┐ │ + * │ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ ← Uniform gray (sweet spot hidden) + * │ └─────────────────────────────────┘ │ + * │ ▲ │ + * │ │ + * │ Tension: │ + * │ ┌─────────────────────────────────┐ │ + * │ │████████████░░░░░░░░░░░░░░░░░░░░░│ │ ← Animates on test + * │ └─────────────────────────────────┘ │ + * │ │ + * │ [A/D] Move | [SPACE] Test | ◉◉◉◎◎ │ + * └─────────────────────────────────────────┘ + */ +@OnlyIn(Dist.CLIENT) +public class LockpickMiniGameScreen extends BaseScreen { + + private UUID sessionId; + private float sweetSpotCenter; + private float sweetSpotWidth; + private float currentPosition; + private int remainingUses; + private int maxUses; + + // Visual state + private boolean gameComplete; + private boolean gameSuccess; + private int completionDelayTicks; + + // Animation + private float animatedPosition; + + // Movement + private static final float MOVE_SPEED = 0.015f; + private boolean movingLeft; + private boolean movingRight; + + // Sync throttle + private long lastSyncTime; + private static final long SYNC_INTERVAL_MS = 50; // 20 updates per second max + + // ==================== TENSION BAR ANIMATION ==================== + + /** Whether a tension animation is currently playing */ + private boolean isTesting = false; + + /** Target fill level for tension bar (0.0-1.0) */ + private float tensionFillTarget = 0f; + + /** Current fill level of tension bar (0.0-1.0) */ + private float tensionCurrent = 0f; + + /** Is the tension bar rising or falling (bouncing) */ + private boolean tensionRising = true; + + /** Speed of tension bar fill */ + private static final float FILL_SPEED = 0.025f; + + /** Speed of tension bar bounce-back (slower than fill) */ + private static final float BOUNCE_SPEED = 0.015f; + + /** Color for tension bar based on fill target */ + private int tensionColor = 0xFFAA0000; // Red by default + + public LockpickMiniGameScreen( + UUID sessionId, + float sweetSpotCenter, + float sweetSpotWidth, + float currentPosition, + int remainingUses + ) { + super(Component.translatable("gui.tiedup.lockpick_minigame")); + this.sessionId = sessionId; + this.sweetSpotCenter = sweetSpotCenter; + this.sweetSpotWidth = sweetSpotWidth; + this.currentPosition = currentPosition; + this.animatedPosition = currentPosition; + this.remainingUses = remainingUses; + this.maxUses = remainingUses; + + this.gameComplete = false; + this.gameSuccess = false; + } + + @Override + protected int getPreferredWidth() { + return GuiLayoutConstants.getResponsiveWidth( + this.width, + 0.5f, + 300, + 380 + ); + } + + @Override + protected int getPreferredHeight() { + return GuiLayoutConstants.getResponsiveHeight( + this.height, + 0.45f, + 200, + 260 + ); + } + + @Override + protected void init() { + super.init(); + } + + @Override + public void tick() { + super.tick(); + + // Handle continuous movement + if (!gameComplete && !isTesting) { + if (movingLeft && !movingRight) { + movePosition(-MOVE_SPEED); + } else if (movingRight && !movingLeft) { + movePosition(MOVE_SPEED); + } + } + + // Animate position indicator + animatedPosition = lerp(animatedPosition, currentPosition, 0.3f); + + // Animate tension bar + if (isTesting) { + if (tensionRising) { + tensionCurrent += FILL_SPEED; + if (tensionCurrent >= tensionFillTarget) { + tensionCurrent = tensionFillTarget; + if (tensionFillTarget < 1.0f) { + // Not success - start bouncing back + tensionRising = false; + } + // If 100%, keep it (success animation handled separately) + } + } else { + // Bouncing back + tensionCurrent -= BOUNCE_SPEED; + if (tensionCurrent <= 0) { + tensionCurrent = 0; + isTesting = false; // Animation complete + } + } + } + + // Completion delay + if (gameComplete && completionDelayTicks > 0) { + completionDelayTicks--; + if (completionDelayTicks <= 0) { + this.onClose(); + } + } + } + + private void movePosition(float delta) { + float newPos = Math.max(0.0f, Math.min(1.0f, currentPosition + delta)); + if (newPos != currentPosition) { + currentPosition = newPos; + syncPositionToServer(); + } + } + + private void syncPositionToServer() { + long now = System.currentTimeMillis(); + if (now - lastSyncTime >= SYNC_INTERVAL_MS) { + ModNetwork.sendToServer( + new PacketLockpickMiniGameMove(sessionId, currentPosition) + ); + lastSyncTime = now; + } + } + + /** + * Called when the lock was successfully picked. + * Triggers success animation (tension bar fills to 100% and stays). + */ + public void onSuccess() { + this.gameComplete = true; + this.gameSuccess = true; + this.completionDelayTicks = 60; // Wait for animation + + // Success animation: fill to 100% + triggerSuccessAnimation(); + } + + /** + * Called when the player missed the sweet spot. + * Triggers tension bar animation based on distance. + */ + public void onMissed(int newRemainingUses, float distance) { + this.remainingUses = newRemainingUses; + + // Trigger tension animation based on distance + triggerTensionAnimation(distance); + } + + /** + * Called when the player ran out of lockpicks. + */ + public void onOutOfPicks() { + this.gameComplete = true; + this.gameSuccess = false; + this.remainingUses = 0; + this.completionDelayTicks = 40; + + // Final failed animation + tensionFillTarget = 0.1f; + tensionCurrent = 0f; + tensionRising = true; + tensionColor = 0xFFAA0000; // Red + isTesting = true; + } + + /** + * Called when the session was cancelled. + */ + public void onCancelled() { + this.onClose(); + } + + /** + * Trigger tension bar animation for a MISS based on distance. + * Closer = fills more (but still bounces back). + */ + private void triggerTensionAnimation(float distance) { + isTesting = true; + tensionRising = true; + tensionCurrent = 0f; + + // Calculate fill target based on distance (sweet spot = 3% = 0.015 radius) + if (distance < 0.05f) { + tensionFillTarget = 0.85f; // Very close - fills a lot + tensionColor = 0xFF00AA00; // Green + } else if (distance < 0.10f) { + tensionFillTarget = 0.60f; // Close + tensionColor = 0xFFAAAA00; // Yellow + } else if (distance < 0.20f) { + tensionFillTarget = 0.35f; // Medium + tensionColor = 0xFFFF8800; // Orange + } else { + tensionFillTarget = 0.15f; // Far - fills little + tensionColor = 0xFFAA0000; // Red + } + + TiedUpMod.LOGGER.debug( + "[LockpickMiniGameScreen] Tension animation: distance={}, fillTarget={}", + distance, + tensionFillTarget + ); + } + + /** + * Trigger success animation - fills to 100% and stays. + */ + private void triggerSuccessAnimation() { + isTesting = true; + tensionRising = true; + tensionCurrent = 0f; + tensionFillTarget = 1.0f; // Full! + tensionColor = 0xFF00FF00; // Bright green + } + + @Override + public void render( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + super.render(graphics, mouseX, mouseY, partialTick); + + int centerX = this.width / 2; + int y = this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 15; + + // Position bar (uniform - sweet spot hidden) + Component posLabel = Component.translatable( + "gui.tiedup.lockpick_minigame.label.position" + ).withStyle(ChatFormatting.GRAY); + graphics.drawString(this.font, posLabel, centerX - 120, y, 0xFFFFFFFF); + y += 12; + renderPositionBar(graphics, y); + y += 55; + + // Tension bar + Component tensionLabel = Component.translatable( + "gui.tiedup.lockpick_minigame.label.tension" + ).withStyle(ChatFormatting.GRAY); + graphics.drawString( + this.font, + tensionLabel, + centerX - 120, + y, + 0xFFFFFFFF + ); + y += 12; + renderTensionBar(graphics, y); + y += 35; + + // Uses indicator + renderUsesIndicator(graphics, y); + y += 25; + + // Instructions or result + if (gameComplete) { + Component result = gameSuccess + ? Component.translatable( + "gui.tiedup.lockpick_minigame.status.unlocked" + ).withStyle(ChatFormatting.GREEN, ChatFormatting.BOLD) + : Component.translatable( + "gui.tiedup.lockpick_minigame.status.out_of_picks" + ).withStyle(ChatFormatting.RED, ChatFormatting.BOLD); + int resultWidth = this.font.width(result); + graphics.drawString( + this.font, + result, + centerX - resultWidth / 2, + y, + 0xFFFFFFFF + ); + } else { + Component hint = Component.translatable( + "gui.tiedup.lockpick_minigame.hint" + ).withStyle(ChatFormatting.GRAY); + int hintWidth = this.font.width(hint); + graphics.drawString( + this.font, + hint, + centerX - hintWidth / 2, + y, + 0xFFFFFFFF + ); + } + } + + /** + * Render the position bar (uniform gray - sweet spot hidden). + */ + private void renderPositionBar(GuiGraphics graphics, int y) { + int barWidth = 240; + int barHeight = 30; + int barX = (this.width - barWidth) / 2; + + // Background - uniform dark gray (sweet spot is INVISIBLE) + graphics.fill(barX, y, barX + barWidth, y + barHeight, 0xFF333333); + + // Border + graphics.renderOutline(barX, y, barWidth, barHeight, 0xFF666666); + + // Lockpick indicator (position marker) + int pickX = barX + (int) (animatedPosition * barWidth); + int pickColor = 0xFFFFFF00; // Yellow + + // Draw lockpick as a triangle pointing down + int pickWidth = 10; + int pickHeight = 14; + graphics.fill( + pickX - pickWidth / 2, + y - pickHeight, + pickX + pickWidth / 2, + y - 2, + pickColor + ); + graphics.fill(pickX - 1, y - 2, pickX + 1, y + 4, pickColor); + + // Draw pick position line + graphics.fill(pickX - 1, y, pickX + 1, y + barHeight, 0xAAFFFFFF); + } + + /** + * Render the tension bar (animates during test). + */ + private void renderTensionBar(GuiGraphics graphics, int y) { + int barWidth = 240; + int barHeight = 20; + int barX = (this.width - barWidth) / 2; + + // Background + graphics.fill(barX, y, barX + barWidth, y + barHeight, 0xFF222222); + + // Fill based on current tension + if (tensionCurrent > 0) { + int fillWidth = (int) (barWidth * tensionCurrent); + graphics.fill( + barX, + y, + barX + fillWidth, + y + barHeight, + tensionColor + ); + } + + // Border + graphics.renderOutline(barX, y, barWidth, barHeight, 0xFF555555); + + // Target line (100% marker) + int targetX = barX + barWidth - 2; + graphics.fill(targetX, y, targetX + 2, y + barHeight, 0xFFFFFFFF); + } + + private void renderUsesIndicator(GuiGraphics graphics, int y) { + int centerX = this.width / 2; + + // Draw use pips centered + int totalPipWidth = maxUses * 14; + int pipX = centerX - totalPipWidth / 2; + + for (int i = 0; i < maxUses; i++) { + int pipColor = i < remainingUses ? 0xFF00AA00 : 0xFF333333; + graphics.fill( + pipX + i * 14, + y, + pipX + i * 14 + 10, + y + 10, + pipColor + ); + graphics.renderOutline(pipX + i * 14, y, 10, 10, 0xFF666666); + } + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + // ESC to cancel + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + this.onClose(); + return true; + } + + if (gameComplete || isTesting) { + return super.keyPressed(keyCode, scanCode, modifiers); + } + + // Movement keys + if (keyCode == GLFW.GLFW_KEY_A || keyCode == GLFW.GLFW_KEY_LEFT) { + movingLeft = true; + return true; + } + if (keyCode == GLFW.GLFW_KEY_D || keyCode == GLFW.GLFW_KEY_RIGHT) { + movingRight = true; + return true; + } + + // Test key + if (keyCode == GLFW.GLFW_KEY_SPACE) { + // Final sync before test + ModNetwork.sendToServer( + new PacketLockpickMiniGameMove(sessionId, currentPosition) + ); + ModNetwork.sendToServer(new PacketLockpickAttempt(sessionId)); + return true; + } + + return super.keyPressed(keyCode, scanCode, modifiers); + } + + @Override + public boolean keyReleased(int keyCode, int scanCode, int modifiers) { + if (keyCode == GLFW.GLFW_KEY_A || keyCode == GLFW.GLFW_KEY_LEFT) { + movingLeft = false; + return true; + } + if (keyCode == GLFW.GLFW_KEY_D || keyCode == GLFW.GLFW_KEY_RIGHT) { + movingRight = false; + return true; + } + return super.keyReleased(keyCode, scanCode, modifiers); + } + + @Override + public boolean isPauseScreen() { + return false; + } + + private float lerp(float current, float target, float speed) { + if (Math.abs(current - target) < 0.001f) { + return target; + } + return current + (target - current) * speed; + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/screens/MerchantTradingScreen.java b/src/main/java/com/tiedup/remake/client/gui/screens/MerchantTradingScreen.java new file mode 100644 index 0000000..b34aed9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/screens/MerchantTradingScreen.java @@ -0,0 +1,426 @@ +package com.tiedup.remake.client.gui.screens; + +import com.tiedup.remake.client.gui.util.GuiColors; +import com.tiedup.remake.client.gui.util.GuiLayoutConstants; +import com.tiedup.remake.entities.MerchantTrade; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.merchant.PacketPurchaseTrade; +import java.util.List; +import java.util.UUID; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Items; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Trading screen for EntityKidnapperMerchant. + * Displays available trades and allows purchasing items for gold. + */ +@OnlyIn(Dist.CLIENT) +public class MerchantTradingScreen extends BaseScreen { + + private final UUID merchantUUID; + private final List trades; + private int selectedTradeIndex = -1; + + private Button buyButton; + private Button cancelButton; + + private static final int TRADE_BUTTON_HEIGHT = 30; + private static final int TRADE_BUTTON_SPACING = 4; + + // Scroll state + private int scrollOffset = 0; + private int maxScrollOffset = 0; + private int tradeListStartY; + private int tradeListHeight; + + public MerchantTradingScreen( + UUID merchantUUID, + List trades + ) { + super(Component.translatable("gui.tiedup.merchant.title")); + this.merchantUUID = merchantUUID; + this.trades = trades; + } + + @Override + protected int getPreferredWidth() { + // 60% width, min 320px, max 500px + return GuiLayoutConstants.getResponsiveWidth( + this.width, + 0.6f, + 320, + 500 + ); + } + + @Override + protected int getPreferredHeight() { + // 70% height, min 300px, max 450px + return GuiLayoutConstants.getResponsiveHeight( + this.height, + 0.7f, + 300, + 450 + ); + } + + @Override + protected void init() { + super.init(); + + // Gold count area (just below title) + int goldDisplayY = this.topPos + 25; + + // Trade list area (minimal spacing - only 8px gap) + this.tradeListStartY = goldDisplayY + 18; + this.tradeListHeight = this.imageHeight - 80; // Maximum space for trades + + // Calculate max scroll offset + int totalTradeListHeight = + trades.size() * (TRADE_BUTTON_HEIGHT + TRADE_BUTTON_SPACING); + this.maxScrollOffset = Math.max( + 0, + totalTradeListHeight - tradeListHeight + ); + + // Create trade buttons + int tradeButtonWidth = this.imageWidth - 40; + int tradeButtonX = this.leftPos + 20; + + for (int i = 0; i < trades.size(); i++) { + final int tradeIndex = i; + MerchantTrade trade = trades.get(i); + + TradeButton button = new TradeButton( + tradeButtonX, + 0, + tradeButtonWidth, + TRADE_BUTTON_HEIGHT, + trade, + tradeIndex, + btn -> onTradeClicked(tradeIndex) + ); + this.addRenderableWidget(button); + } + + // Bottom buttons + int buttonY = + this.topPos + + this.imageHeight - + GuiLayoutConstants.BUTTON_HEIGHT - + 10; + int buttonSpacing = 10; + int totalButtonWidth = + GuiLayoutConstants.BUTTON_WIDTH_L * 2 + buttonSpacing; + int buttonStartX = + this.leftPos + (this.imageWidth - totalButtonWidth) / 2; + + // Buy button (disabled by default) + buyButton = Button.builder( + Component.translatable("gui.tiedup.merchant.buy"), + b -> onBuyClicked() + ) + .bounds( + buttonStartX, + buttonY, + GuiLayoutConstants.BUTTON_WIDTH_L, + GuiLayoutConstants.BUTTON_HEIGHT + ) + .build(); + buyButton.active = false; + this.addRenderableWidget(buyButton); + + // Cancel button + cancelButton = Button.builder( + Component.translatable("gui.tiedup.cancel"), + b -> onClose() + ) + .bounds( + buttonStartX + + GuiLayoutConstants.BUTTON_WIDTH_L + + buttonSpacing, + buttonY, + GuiLayoutConstants.BUTTON_WIDTH_L, + GuiLayoutConstants.BUTTON_HEIGHT + ) + .build(); + this.addRenderableWidget(cancelButton); + } + + private void onTradeClicked(int index) { + selectedTradeIndex = index; + buyButton.active = true; + } + + private void onBuyClicked() { + if (selectedTradeIndex >= 0 && selectedTradeIndex < trades.size()) { + ModNetwork.sendToServer( + new PacketPurchaseTrade(merchantUUID, selectedTradeIndex) + ); + onClose(); + } + } + + @Override + public void onClose() { + // Notify server that we closed the trading screen + ModNetwork.sendToServer( + new com.tiedup.remake.network.merchant.PacketCloseMerchantScreen( + merchantUUID + ) + ); + super.onClose(); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double delta) { + if (maxScrollOffset > 0) { + // Scroll by 20 pixels per tick + int scrollAmount = (int) (delta * 20); + scrollOffset = Math.max( + 0, + Math.min(maxScrollOffset, scrollOffset - scrollAmount) + ); + return true; + } + return super.mouseScrolled(mouseX, mouseY, delta); + } + + @Override + public void render( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + super.render(graphics, mouseX, mouseY, partialTick); + + // Title (blue color for merchant) + graphics.drawCenteredString( + this.font, + this.title, + this.leftPos + this.imageWidth / 2, + this.topPos + GuiLayoutConstants.MARGIN_M, + GuiColors.INFO + ); + + // Gold display (matches goldDisplayY from init) + Player player = Minecraft.getInstance().player; + if (player != null) { + int goldIngots = countItemInInventory(player, Items.GOLD_INGOT); + int goldNuggets = countItemInInventory(player, Items.GOLD_NUGGET); + + Component goldText = Component.literal("Your Gold: ") + .append( + Component.literal(goldIngots + "x ").withStyle(style -> + style.withColor(0xFFFFD700) + ) + ) + .append( + Component.literal("⚜ ").withStyle(style -> + style.withColor(0xFFFFD700) + ) + ) + .append( + Component.literal("+ " + goldNuggets + "x ").withStyle( + style -> style.withColor(0xFFFFA500) + ) + ) + .append( + Component.literal("✦").withStyle(style -> + style.withColor(0xFFFFA500) + ) + ); + + graphics.drawCenteredString( + this.font, + goldText, + this.leftPos + this.imageWidth / 2, + this.topPos + 25, + GuiColors.TEXT_WHITE + ); + } + + // Update trade button positions based on scroll + updateTradeButtonPositions(); + } + + /** + * Update trade button positions based on scroll offset. + */ + private void updateTradeButtonPositions() { + int tradeButtonWidth = this.imageWidth - 40; + int tradeButtonX = this.leftPos + 20; + + // Get all renderable widgets (includes our trade buttons) + for (int i = 0; i < this.renderables.size(); i++) { + if (this.renderables.get(i) instanceof TradeButton tradeButton) { + int tradeIndex = tradeButton.getTradeIndex(); + + // Calculate Y position with scroll offset + // tradeListStartY already includes topPos, don't add it twice! + int y = + this.tradeListStartY + + tradeIndex * (TRADE_BUTTON_HEIGHT + TRADE_BUTTON_SPACING) - + scrollOffset; + + // Update button position + tradeButton.setPosition(tradeButtonX, y); + + // Check if button is visible in the scroll area + boolean isVisible = + y >= this.tradeListStartY && + y + TRADE_BUTTON_HEIGHT <= + this.tradeListStartY + this.tradeListHeight; + tradeButton.visible = isVisible; + } + } + } + + /** + * Count how many of a specific item are in the player's inventory. + */ + private int countItemInInventory( + Player player, + net.minecraft.world.item.Item item + ) { + int count = 0; + for (net.minecraft.world.item.ItemStack stack : player.getInventory().items) { + if (stack.is(item)) { + count += stack.getCount(); + } + } + return count; + } + + /** + * Custom button for displaying a trade. + */ + private class TradeButton extends Button { + + private final MerchantTrade trade; + private final int tradeIndex; + + public TradeButton( + int x, + int y, + int width, + int height, + MerchantTrade trade, + int tradeIndex, + OnPress onPress + ) { + super( + x, + y, + width, + height, + Component.empty(), + onPress, + DEFAULT_NARRATION + ); + this.trade = trade; + this.tradeIndex = tradeIndex; + } + + public int getTradeIndex() { + return this.tradeIndex; + } + + @Override + public void renderWidget( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + boolean selected = (tradeIndex == selectedTradeIndex); + boolean hovered = this.isHovered(); + + // Background color + int bgColor; + if (selected) { + bgColor = GuiColors.SLOT_SELECTED; // Brown for selected + } else if (hovered) { + bgColor = GuiColors.SLOT_HOVER; + } else { + bgColor = GuiColors.BG_LIGHT; + } + + graphics.fill( + getX(), + getY(), + getX() + width, + getY() + height, + bgColor + ); + + // Border + int borderColor = selected + ? GuiColors.ACCENT_TAN + : GuiColors.BORDER_LIGHT; + graphics.fill( + getX(), + getY(), + getX() + width, + getY() + 1, + borderColor + ); + graphics.fill( + getX(), + getY() + height - 1, + getX() + width, + getY() + height, + borderColor + ); + graphics.fill( + getX(), + getY(), + getX() + 1, + getY() + height, + borderColor + ); + graphics.fill( + getX() + width - 1, + getY(), + getX() + width, + getY() + height, + borderColor + ); + + // Item preview (left side) + net.minecraft.world.item.ItemStack itemStack = trade.getItem(); + graphics.renderItem(itemStack, getX() + 5, getY() + 7); + + // Item name (after item preview) + Component itemName = trade.getItemName(); + String itemText = font.plainSubstrByWidth( + itemName.getString(), + width - 140 + ); + graphics.drawString( + font, + itemText, + getX() + 28, + getY() + 5, + GuiColors.TEXT_WHITE + ); + + // Price (after item name, below item preview) + Component priceText = trade.getPriceDisplay(); + graphics.drawString( + font, + priceText, + getX() + 28, + getY() + 17, + GuiColors.TEXT_GRAY + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/screens/NpcInventoryScreen.java b/src/main/java/com/tiedup/remake/client/gui/screens/NpcInventoryScreen.java new file mode 100644 index 0000000..c946c03 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/screens/NpcInventoryScreen.java @@ -0,0 +1,222 @@ +package com.tiedup.remake.client.gui.screens; + +import com.tiedup.remake.client.gui.util.GuiTextureHelper; +import com.tiedup.remake.client.gui.widgets.EntityPreviewWidget; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.entities.NpcInventoryMenu; +import org.jetbrains.annotations.Nullable; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Inventory; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Screen for viewing and managing NPC inventory. + * Uses vanilla chest texture for consistent Minecraft look. + * + * Layout: + * - Left: Vanilla chest-style container (176px wide) + * - Right: Equipment panel (armor + main hand, 26px wide) + */ +@OnlyIn(Dist.CLIENT) +public class NpcInventoryScreen + extends AbstractContainerScreen +{ + + /** Equipment panel width (slot 18px + 4px padding each side) */ + private static final int EQUIP_PANEL_WIDTH = 28; + + /** Gap between main container and equipment panel */ + private static final int EQUIP_PANEL_GAP = 4; + + /** Equipment panel height (5 slots + header + padding) */ + private static final int EQUIP_PANEL_HEIGHT = 5 * 18 + 18; // 108px + + /** Entity preview widget */ + @Nullable + private EntityPreviewWidget preview; + + /** Number of NPC inventory rows */ + private final int npcRows; + + /** + * Create NPC inventory screen. + * + * @param menu The container menu + * @param playerInventory Player's inventory + * @param title Screen title (will be replaced with NPC name) + */ + public NpcInventoryScreen( + NpcInventoryMenu menu, + Inventory playerInventory, + Component title + ) { + super(menu, playerInventory, Component.literal(menu.getNpcName())); + // Calculate rows from NPC inventory size + this.npcRows = (menu.getNpcSlotCount() + 8) / 9; + + // Standard vanilla chest dimensions + this.imageWidth = GuiTextureHelper.CHEST_WIDTH; + this.imageHeight = GuiTextureHelper.getChestHeight(npcRows); + + // Player inventory label position (relative to container) + // In vanilla chest, player inv label is at y = header + rows*18 + 3 + this.inventoryLabelY = + GuiTextureHelper.CHEST_HEADER_HEIGHT + + npcRows * GuiTextureHelper.SLOT_SIZE + + 3; + } + + @Override + protected void init() { + super.init(); + + // Add entity preview if NPC available and there's room + EntityDamsel npc = this.menu.getNpc(); + if (npc != null) { + int previewSize = 50; + int previewX = this.leftPos - previewSize - 10; + int previewY = this.topPos + 10; + + if (previewX > 0) { + preview = new EntityPreviewWidget( + previewX, + previewY, + previewSize, + previewSize, + npc + ); + preview.setAutoRotate(true); + preview.setAutoRotateSpeed(0.5f); + this.addRenderableWidget(preview); + } + } + } + + @Override + public void render( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + this.renderBackground(graphics); + super.render(graphics, mouseX, mouseY, partialTick); + this.renderTooltip(graphics, mouseX, mouseY); + } + + @Override + protected void renderBg( + GuiGraphics graphics, + float partialTick, + int mouseX, + int mouseY + ) { + // Render main container with vanilla chest texture + GuiTextureHelper.renderChestBackground( + graphics, + this.leftPos, + this.topPos, + this.npcRows + ); + + // Render equipment panel on the right + renderEquipmentPanel(graphics); + } + + /** + * Render the equipment panel (armor + main hand) on the right side. + */ + private void renderEquipmentPanel(GuiGraphics graphics) { + int panelX = + this.leftPos + GuiTextureHelper.CHEST_WIDTH + EQUIP_PANEL_GAP; + int panelY = this.topPos + GuiTextureHelper.CHEST_HEADER_HEIGHT; + + // Panel background (vanilla inventory style) + graphics.fill( + panelX - 1, + panelY - 1, + panelX + EQUIP_PANEL_WIDTH + 1, + panelY + EQUIP_PANEL_HEIGHT + 1, + 0xFF000000 + ); + graphics.fill( + panelX, + panelY, + panelX + EQUIP_PANEL_WIDTH, + panelY + EQUIP_PANEL_HEIGHT, + 0xFFC6C6C6 + ); + + // Title centered + String title = "Gear"; + int titleWidth = this.font.width(title); + graphics.drawString( + this.font, + title, + panelX + (EQUIP_PANEL_WIDTH - titleWidth) / 2, + panelY + 3, + 0x404040, + false + ); + + // Slot backgrounds (4 armor + 1 main hand) + // Position slots to match menu: slotX = panelX + 5, first slot at y = panelY + 12 + int slotX = panelX + 5; + int slotStartY = panelY + 12; + for (int i = 0; i < 4; i++) { + renderSlot( + graphics, + slotX, + slotStartY + i * GuiTextureHelper.SLOT_SIZE + ); + } + + // Main hand slot (with small gap after armor) + int handY = slotStartY + 4 * GuiTextureHelper.SLOT_SIZE + 4; + renderSlot(graphics, slotX, handY); + } + + /** + * Render a single slot background in vanilla style. + */ + private void renderSlot(GuiGraphics graphics, int x, int y) { + // Vanilla slot style: dark top-left border, light bottom-right + graphics.fill(x, y, x + 18, y + 18, 0xFF8B8B8B); // Base gray + graphics.fill(x, y, x + 17, y + 1, 0xFF373737); // Top dark + graphics.fill(x, y, x + 1, y + 17, 0xFF373737); // Left dark + graphics.fill(x + 1, y + 17, x + 18, y + 18, 0xFFFFFFFF); // Bottom light + graphics.fill(x + 17, y + 1, x + 18, y + 18, 0xFFFFFFFF); // Right light + graphics.fill(x + 1, y + 1, x + 17, y + 17, 0xFF8B8B8B); // Inner + } + + @Override + protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { + // NPC name in title area + graphics.drawString( + this.font, + this.title, + this.titleLabelX, + this.titleLabelY, + 0x404040, + false + ); + + // Player inventory label + graphics.drawString( + this.font, + this.playerInventoryTitle, + this.inventoryLabelX, + this.inventoryLabelY, + 0x404040, + false + ); + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/screens/PetRequestScreen.java b/src/main/java/com/tiedup/remake/client/gui/screens/PetRequestScreen.java new file mode 100644 index 0000000..a68d862 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/screens/PetRequestScreen.java @@ -0,0 +1,232 @@ +package com.tiedup.remake.client.gui.screens; + +import com.tiedup.remake.dialogue.conversation.PetRequest; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.master.PacketPetRequest; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.network.chat.Component; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Screen for pet players to make requests to their Master. + * + * Displays 7 request options: + * - Ask for food + * - Ask to rest + * - Request walk (you lead) + * - Request walk (Master leads) + * - Ask to be tied + * - Ask to be untied + * - End conversation + * + * Phase 2: Refactored to extend BaseInteractionScreen + */ +@OnlyIn(Dist.CLIENT) +public class PetRequestScreen extends BaseInteractionScreen { + + private final int entityId; + private final String masterName; + + // Layout constants + private static final int PANEL_WIDTH = 200; + private static final int PANEL_HEIGHT = 220; + private static final int MARGIN = 10; + private static final int BUTTON_HEIGHT = 20; + private static final int BUTTON_SPACING = 4; + + // Color constants + private static final int COLOR_WHITE = 0xFFFFFF; + private static final int COLOR_TITLE = 0x8B008B; // Dark purple (Master color) + + public PetRequestScreen(int entityId, String masterName) { + super( + Component.translatable("gui.tiedup.pet_request.title", masterName), + PANEL_WIDTH, + PANEL_HEIGHT + ); + this.entityId = entityId; + this.masterName = masterName; + } + + @Override + protected void init() { + super.init(); // Center the panel (sets leftPos and topPos) + + int contentX = getContentX(MARGIN); + int contentWidth = getContentWidth(MARGIN); + int btnY = topPos + 35; + + // === Request Buttons === + + // Row 1: Ask for food + addRequestButton( + contentX, + btnY, + contentWidth, + PetRequest.REQUEST_FOOD, + "gui.tiedup.pet_request.food", + "gui.tiedup.pet_request.food.tooltip" + ); + btnY += BUTTON_HEIGHT + BUTTON_SPACING; + + // Row 2: Ask to rest + addRequestButton( + contentX, + btnY, + contentWidth, + PetRequest.REQUEST_SLEEP, + "gui.tiedup.pet_request.sleep", + "gui.tiedup.pet_request.sleep.tooltip" + ); + btnY += BUTTON_HEIGHT + BUTTON_SPACING; + + // Row 3: Walk options (side by side) + int halfWidth = (contentWidth - BUTTON_SPACING) / 2; + + // Walk (you lead) + Button walkPassiveBtn = Button.builder( + Component.translatable("gui.tiedup.pet_request.walk_passive"), + b -> sendRequest(PetRequest.REQUEST_WALK_PASSIVE) + ) + .bounds(contentX, btnY, halfWidth, BUTTON_HEIGHT) + .tooltip( + Tooltip.create( + Component.translatable( + "gui.tiedup.pet_request.walk_passive.tooltip" + ) + ) + ) + .build(); + this.addRenderableWidget(walkPassiveBtn); + + // Walk (Master leads) + Button walkActiveBtn = Button.builder( + Component.translatable("gui.tiedup.pet_request.walk_active"), + b -> sendRequest(PetRequest.REQUEST_WALK_ACTIVE) + ) + .bounds( + contentX + halfWidth + BUTTON_SPACING, + btnY, + halfWidth, + BUTTON_HEIGHT + ) + .tooltip( + Tooltip.create( + Component.translatable( + "gui.tiedup.pet_request.walk_active.tooltip" + ) + ) + ) + .build(); + this.addRenderableWidget(walkActiveBtn); + btnY += BUTTON_HEIGHT + BUTTON_SPACING; + + // Row 4: Tie/Untie options (side by side) + Button tieBtn = Button.builder( + Component.translatable("gui.tiedup.pet_request.tie"), + b -> sendRequest(PetRequest.REQUEST_TIE) + ) + .bounds(contentX, btnY, halfWidth, BUTTON_HEIGHT) + .tooltip( + Tooltip.create( + Component.translatable("gui.tiedup.pet_request.tie.tooltip") + ) + ) + .build(); + this.addRenderableWidget(tieBtn); + + Button untieBtn = Button.builder( + Component.translatable("gui.tiedup.pet_request.untie"), + b -> sendRequest(PetRequest.REQUEST_UNTIE) + ) + .bounds( + contentX + halfWidth + BUTTON_SPACING, + btnY, + halfWidth, + BUTTON_HEIGHT + ) + .tooltip( + Tooltip.create( + Component.translatable( + "gui.tiedup.pet_request.untie.tooltip" + ) + ) + ) + .build(); + this.addRenderableWidget(untieBtn); + btnY += BUTTON_HEIGHT + BUTTON_SPACING + 10; + + // Row 5: End conversation + addRequestButton( + contentX, + btnY, + contentWidth, + PetRequest.END_CONVERSATION, + "gui.tiedup.pet_request.end", + "gui.tiedup.pet_request.end.tooltip" + ); + btnY += BUTTON_HEIGHT + BUTTON_SPACING + 10; + + // Row 6: Cancel button + Button cancelBtn = Button.builder( + Component.translatable("gui.tiedup.pet_request.cancel"), + b -> onClose() + ) + .bounds(contentX, btnY, contentWidth, BUTTON_HEIGHT) + .build(); + this.addRenderableWidget(cancelBtn); + } + + /** + * Add a request button to the screen. + */ + private void addRequestButton( + int x, + int y, + int width, + PetRequest request, + String translationKey, + String tooltipKey + ) { + Button btn = Button.builder(Component.translatable(translationKey), b -> + sendRequest(request) + ) + .bounds(x, y, width, BUTTON_HEIGHT) + .tooltip(Tooltip.create(Component.translatable(tooltipKey))) + .build(); + this.addRenderableWidget(btn); + } + + @Override + protected void renderContent( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + // Title + renderTitle(graphics, this.title, topPos + 10, COLOR_TITLE); + + // Subtitle + graphics.drawCenteredString( + this.font, + Component.translatable("gui.tiedup.pet_request.subtitle"), + leftPos + panelWidth / 2, + topPos + 22, + COLOR_WHITE + ); + } + + /** + * Send a request to the server. + */ + private void sendRequest(PetRequest request) { + ModNetwork.sendToServer(new PacketPetRequest(entityId, request)); + + // Close screen after sending request + onClose(); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/screens/RemoteAdjustmentScreen.java b/src/main/java/com/tiedup/remake/client/gui/screens/RemoteAdjustmentScreen.java new file mode 100644 index 0000000..cdbe971 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/screens/RemoteAdjustmentScreen.java @@ -0,0 +1,74 @@ +package com.tiedup.remake.client.gui.screens; + +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.item.PacketAdjustRemote; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.state.IBondageState; +import java.util.UUID; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Screen for remotely adjusting Y position of a slave's gags and blindfolds. + * Similar to AdjustmentScreen but operates on a slave entity. + * + * Phase 16b: GUI Refactoring - Simplified using BaseAdjustmentScreen + */ +@OnlyIn(Dist.CLIENT) +public class RemoteAdjustmentScreen extends BaseAdjustmentScreen { + + // Target slave + private final IBondageState slave; + private final UUID slaveId; + + public RemoteAdjustmentScreen(IBondageState slave, UUID slaveId) { + super(Component.translatable("gui.tiedup.adjust_position")); + this.slave = slave; + this.slaveId = slaveId; + } + + // ==================== ABSTRACT IMPLEMENTATIONS ==================== + + @Override + protected LivingEntity getTargetEntity() { + return slave.asLivingEntity(); + } + + @Override + protected ItemStack getGag() { + return slave.getEquipment(BodyRegionV2.MOUTH); + } + + @Override + protected ItemStack getBlindfold() { + return slave.getEquipment(BodyRegionV2.EYES); + } + + @Override + protected void sendAdjustment(Mode mode, float value, float scale) { + BodyRegionV2 region = switch (mode) { + case GAG -> BodyRegionV2.MOUTH; + case BLINDFOLD -> BodyRegionV2.EYES; + case BOTH -> null; // Handled separately in applyAdjustment + }; + + if (region != null) { + ModNetwork.sendToServer( + new PacketAdjustRemote(slaveId, region, value, scale) + ); + } + } + + @Override + protected String getExtraInfo() { + return "Adjusting: " + slave.getKidnappedName(); + } + + @Override + public void onClose() { + super.onClose(); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/screens/SlaveManagementScreen.java b/src/main/java/com/tiedup/remake/client/gui/screens/SlaveManagementScreen.java new file mode 100644 index 0000000..dccd36a --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/screens/SlaveManagementScreen.java @@ -0,0 +1,321 @@ +package com.tiedup.remake.client.gui.screens; + +import com.tiedup.remake.client.gui.util.GuiColors; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.client.gui.util.GuiLayoutConstants; +import com.tiedup.remake.client.gui.widgets.SlaveEntryWidget; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.slave.PacketSlaveAction; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.state.PlayerCaptorManager; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.ContainerObjectSelectionList; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.AABB; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Dashboard screen for masters to manage all their slaves. + * Refactored to use standard ContainerObjectSelectionList. + */ +@OnlyIn(Dist.CLIENT) +public class SlaveManagementScreen extends BaseScreen { + + private SlaveList slaveList; + private Button closeButton; + + public SlaveManagementScreen() { + super(Component.translatable("gui.tiedup.slave_management")); + } + + @Override + protected int getPreferredWidth() { + return GuiLayoutConstants.getResponsiveWidth( + this.width, + 0.7f, + 350, + 500 + ); + } + + @Override + protected int getPreferredHeight() { + return GuiLayoutConstants.getResponsiveHeight( + this.height, + 0.8f, + 250, + 400 + ); + } + + @Override + protected void init() { + super.init(); + + int listLeft = this.leftPos + 10; + int listWidth = this.imageWidth - 20; + int listTop = this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 20; // Title + count + int listBottom = + this.topPos + + this.imageHeight - + GuiLayoutConstants.BUTTON_HEIGHT - + 10; + + // Initialize the list with proper bounds + slaveList = new SlaveList( + minecraft, + listWidth, + listTop, + listBottom, + listLeft + ); + + refreshSlaveList(); + this.addRenderableWidget(slaveList); + + // Close button + int btnWidth = GuiLayoutConstants.BUTTON_WIDTH_XL; + closeButton = Button.builder( + Component.translatable("gui.tiedup.close"), + b -> onClose() + ) + .bounds( + this.leftPos + (this.imageWidth - btnWidth) / 2, + this.topPos + + this.imageHeight - + GuiLayoutConstants.BUTTON_HEIGHT - + 6, + btnWidth, + GuiLayoutConstants.BUTTON_HEIGHT + ) + .build(); + this.addRenderableWidget(closeButton); + } + + public static boolean shouldShow() { + Minecraft mc = Minecraft.getInstance(); + return mc.player != null; + } + + private void refreshSlaveList() { + slaveList.clearEntriesPublic(); + + if (this.minecraft == null || this.minecraft.player == null) return; + + Player player = this.minecraft.player; + Set addedUUIDs = new HashSet<>(); + + // 1. Add captives from PlayerCaptorManager + PlayerBindState state = PlayerBindState.getInstance(player); + if (state != null) { + PlayerCaptorManager manager = state.getCaptorManager(); + if (manager != null) { + for (IBondageState captive : manager.getCaptives()) { + addSlaveEntry(captive); + LivingEntity entity = captive.asLivingEntity(); + if (entity != null) { + addedUUIDs.add(entity.getUUID()); + } + } + } + } + + // 2. Add nearby collar-linked entities (50-block radius matches server validation) + AABB searchBox = player.getBoundingBox().inflate(50); + for (LivingEntity entity : player + .level() + .getEntitiesOfClass(LivingEntity.class, searchBox)) { + if (entity == player) continue; + if (addedUUIDs.contains(entity.getUUID())) continue; + + IBondageState kidnapped = KidnappedHelper.getKidnappedState(entity); + if (kidnapped != null && kidnapped.hasCollar()) { + ItemStack collarStack = kidnapped.getEquipment(BodyRegionV2.NECK); + if (collarStack.getItem() instanceof ItemCollar collar) { + if (collar.isOwner(collarStack, player)) { + addSlaveEntry(kidnapped); + addedUUIDs.add(entity.getUUID()); + } + } + } + } + } + + private void addSlaveEntry(IBondageState slave) { + slaveList.addEntryPublic( + new SlaveEntryWidget( + slave, + this::onAdjustClicked, + this::onShockClicked, + this::onLocateClicked, + this::onFreeClicked + ) + ); + } + + // ==================== ACTIONS ==================== + + private void onAdjustClicked(IBondageState slave) { + LivingEntity entity = slave.asLivingEntity(); + if (entity != null) { + this.minecraft.setScreen( + new RemoteAdjustmentScreen(slave, entity.getUUID()) + ); + } + } + + private void onShockClicked(IBondageState slave) { + LivingEntity entity = slave.asLivingEntity(); + if (entity != null) { + ModNetwork.sendToServer( + new PacketSlaveAction( + entity.getUUID(), + PacketSlaveAction.Action.SHOCK + ) + ); + } + } + + private void onLocateClicked(IBondageState slave) { + LivingEntity entity = slave.asLivingEntity(); + if (entity != null) { + ModNetwork.sendToServer( + new PacketSlaveAction( + entity.getUUID(), + PacketSlaveAction.Action.LOCATE + ) + ); + } + } + + private void onFreeClicked(IBondageState slave) { + LivingEntity entity = slave.asLivingEntity(); + if (entity != null) { + ModNetwork.sendToServer( + new PacketSlaveAction( + entity.getUUID(), + PacketSlaveAction.Action.FREE + ) + ); + // Refresh list after a short delay or immediately (server sync latency might require delay) + // For now, immediate refresh to remove from UI + refreshSlaveList(); + } + } + + // ==================== RENDERING ==================== + + @Override + public void render( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + super.render(graphics, mouseX, mouseY, partialTick); + + // Slave count text + String countText = + slaveList.children().size() + + " slave" + + (slaveList.children().size() != 1 ? "s" : ""); + graphics.drawString( + this.font, + countText, + this.leftPos + GuiLayoutConstants.MARGIN_M, + this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 2, + GuiColors.TEXT_GRAY + ); + + // Empty state + if (slaveList.children().isEmpty()) { + graphics.drawCenteredString( + this.font, + Component.translatable("gui.tiedup.slave_management.no_slaves"), + this.leftPos + this.imageWidth / 2, + this.topPos + this.imageHeight / 2, + GuiColors.TEXT_DISABLED + ); + } + } + + // ==================== INNER CLASS: LIST ==================== + + class SlaveList extends ContainerObjectSelectionList { + + private final int listLeft; + private final int listWidth; + + public SlaveList( + Minecraft mc, + int width, + int top, + int bottom, + int left + ) { + super( + mc, + width, + bottom - top, + top, + bottom, + GuiLayoutConstants.ENTRY_HEIGHT + ); + this.listLeft = left; + this.listWidth = width; + this.centerListVertically = false; + this.setRenderBackground(false); + this.setRenderTopAndBottom(false); + + // Set horizontal bounds directly (x0/x1 are protected in AbstractSelectionList) + this.x0 = left; + this.x1 = left + width; + } + + public void addEntryPublic(SlaveEntryWidget entry) { + this.addEntry(entry); + } + + public void clearEntriesPublic() { + this.clearEntries(); + } + + @Override + public int getRowWidth() { + return this.listWidth - 12; // Leave space for scrollbar + } + + @Override + protected int getScrollbarPosition() { + return this.listLeft + this.listWidth - 6; + } + + @Override + public int getRowLeft() { + return this.listLeft; + } + } + + public static boolean canOpen() { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null) return false; + PlayerBindState state = PlayerBindState.getInstance(mc.player); + return ( + state != null && + state.getCaptorManager() != null && + state.getCaptorManager().hasCaptives() + ); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/screens/SlaveTraderScreen.java b/src/main/java/com/tiedup/remake/client/gui/screens/SlaveTraderScreen.java new file mode 100644 index 0000000..488cfc2 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/screens/SlaveTraderScreen.java @@ -0,0 +1,242 @@ +package com.tiedup.remake.client.gui.screens; + +import com.tiedup.remake.client.gui.util.GuiColors; +import com.tiedup.remake.client.gui.util.GuiLayoutConstants; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.trader.PacketBuyCaptive; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.network.chat.Component; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Trading screen for the SlaveTrader. + * + * Displays list of captives available for purchase and allows buying. + * + * Layout: + * ┌─────────────────────────────────────────┐ + * │ SLAVE TRADER │ + * │ │ + * │ [Name] - [Price] [BUY] │ + * │ [Name] - [Price] [BUY] │ + * │ [Name] - [Price] [BUY] │ + * │ │ + * │ [Close] │ + * └─────────────────────────────────────────┘ + */ +@OnlyIn(Dist.CLIENT) +public class SlaveTraderScreen extends BaseScreen { + + private final int traderEntityId; + private final String traderName; + private final List offers; + + private static final int ROW_HEIGHT = 28; + private static final int BUY_BUTTON_WIDTH = 50; + private static final int BUY_BUTTON_HEIGHT = 18; + + /** + * Data class representing a captive available for purchase. + */ + public static class CaptiveOffer { + + public final UUID captiveId; + public final String captiveName; + public final String priceDescription; + public final int priceAmount; + public final String priceItemId; + + public CaptiveOffer( + UUID captiveId, + String captiveName, + String priceDescription, + int priceAmount, + String priceItemId + ) { + this.captiveId = captiveId; + this.captiveName = captiveName; + this.priceDescription = priceDescription; + this.priceAmount = priceAmount; + this.priceItemId = priceItemId; + } + } + + public SlaveTraderScreen( + int traderEntityId, + String traderName, + List offers + ) { + super(Component.translatable("gui.tiedup.slave_trader")); + this.traderEntityId = traderEntityId; + this.traderName = traderName; + this.offers = offers != null ? offers : new ArrayList<>(); + } + + @Override + protected int getPreferredWidth() { + return GuiLayoutConstants.getResponsiveWidth( + this.width, + 0.5f, + 320, + 400 + ); + } + + @Override + protected int getPreferredHeight() { + int offerHeight = Math.max(1, offers.size()) * ROW_HEIGHT; + int baseHeight = + GuiLayoutConstants.TITLE_HEIGHT + + 50 + + GuiLayoutConstants.BUTTON_HEIGHT + + 30; + return GuiLayoutConstants.getResponsiveHeight( + this.height, + 0.8f, + 180, + Math.min(400, baseHeight + offerHeight) + ); + } + + @Override + protected void init() { + super.init(); + + int contentTop = this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 15; + int centerX = this.leftPos + this.imageWidth / 2; + + // Add buy button for each offer + int y = contentTop; + for (int i = 0; i < offers.size(); i++) { + CaptiveOffer offer = offers.get(i); + int buttonX = + this.leftPos + this.imageWidth - BUY_BUTTON_WIDTH - 15; + int buttonY = y + (ROW_HEIGHT - BUY_BUTTON_HEIGHT) / 2; + + final int index = i; + Button buyBtn = Button.builder( + Component.translatable("gui.tiedup.buy"), + b -> onBuy(index) + ) + .bounds(buttonX, buttonY, BUY_BUTTON_WIDTH, BUY_BUTTON_HEIGHT) + .build(); + this.addRenderableWidget(buyBtn); + + y += ROW_HEIGHT; + } + + // Close button at bottom + int closeY = + this.topPos + + this.imageHeight - + GuiLayoutConstants.BUTTON_HEIGHT - + 10; + Button closeBtn = Button.builder( + Component.translatable("gui.tiedup.close"), + b -> onClose() + ) + .bounds(centerX - 40, closeY, 80, GuiLayoutConstants.BUTTON_HEIGHT) + .build(); + this.addRenderableWidget(closeBtn); + } + + private void onBuy(int offerIndex) { + if (offerIndex < 0 || offerIndex >= offers.size()) { + return; + } + + CaptiveOffer offer = offers.get(offerIndex); + + TiedUpMod.LOGGER.info( + "[SlaveTraderScreen] Attempting to buy captive {} from trader {}", + offer.captiveId.toString().substring(0, 8), + traderEntityId + ); + + // Send buy request to server + ModNetwork.sendToServer( + new PacketBuyCaptive(traderEntityId, offer.captiveId) + ); + + // Close screen after purchase attempt + onClose(); + } + + @Override + public void render( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + super.render(graphics, mouseX, mouseY, partialTick); + + int contentTop = this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 15; + int leftX = this.leftPos + 15; + + // Render trader name + graphics.drawString( + this.font, + traderName, + leftX, + this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 2, + GuiColors.TEXT_WHITE + ); + + // Render offers + if (offers.isEmpty()) { + graphics.drawString( + this.font, + Component.translatable( + "gui.tiedup.no_captives_available" + ).withStyle(ChatFormatting.GRAY), + leftX, + contentTop + 10, + GuiColors.TEXT_GRAY + ); + } else { + int y = contentTop; + for (CaptiveOffer offer : offers) { + renderOfferRow(graphics, offer, leftX, y); + y += ROW_HEIGHT; + } + } + } + + private void renderOfferRow( + GuiGraphics graphics, + CaptiveOffer offer, + int x, + int y + ) { + // Captive name + graphics.drawString( + this.font, + offer.captiveName, + x, + y + 2, + GuiColors.TEXT_WHITE + ); + + // Price + graphics.drawString( + this.font, + offer.priceDescription, + x, + y + 14, + GuiColors.TEXT_GRAY + ); + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/screens/UnifiedBondageScreen.java b/src/main/java/com/tiedup/remake/client/gui/screens/UnifiedBondageScreen.java new file mode 100644 index 0000000..274eebd --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/screens/UnifiedBondageScreen.java @@ -0,0 +1,375 @@ +package com.tiedup.remake.client.gui.screens; + +import com.tiedup.remake.client.gui.util.GuiLayoutConstants; +import com.tiedup.remake.client.gui.util.GuiRenderUtil; +import com.tiedup.remake.client.gui.widgets.*; +import com.tiedup.remake.items.ItemKey; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.cell.PacketRequestCellList; +import com.tiedup.remake.network.slave.PacketMasterEquip; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import com.tiedup.remake.v2.bondage.network.PacketV2SelfEquip; +import java.util.UUID; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Unified bondage equipment screen replacing BondageInventoryScreen, + * SlaveItemManagementScreen, and StruggleChoiceScreen. + */ +@OnlyIn(Dist.CLIENT) +public class UnifiedBondageScreen extends BaseScreen { + + // Visual theme colors (vanilla MC style) + private static final int TITLE_COLOR = 0xFF404040; + private static final int MODE_SELF_BG = 0xFF8B8B8B; + private static final int MODE_MASTER_BG = 0xFF707070; + + // Full-body view for all tabs (zoom per-tab was too finicky to get right) + private static final float[] TAB_SCALES = { 1.0f, 1.0f, 1.0f, 1.0f, 1.0f }; + private static final float[] TAB_OFFSETS = { 0.0f, 0.0f, 0.0f, 0.0f, 0.0f }; + + private final ActionPanel.ScreenMode mode; + private final LivingEntity targetEntity; + private final UUID targetEntityUUID; + + // Widgets + private RegionTabBar tabBar; + private EntityPreviewWidget preview; + private RegionSlotWidget[] currentSlots; + private ActionPanel actionPanel; + private ItemPickerOverlay pickerOverlay; + private StatusBarWidget statusBar; + + // State + private RegionSlotWidget selectedSlot; + private int refreshCountdown = -1; + + // Key info (for master mode) + private UUID keyUUID; + private boolean isMasterKey; + + /** + * Open in SELF mode. + */ + public UnifiedBondageScreen() { + this(ActionPanel.ScreenMode.SELF, null); + } + + /** + * Open in MASTER mode targeting a specific entity. + */ + public UnifiedBondageScreen(LivingEntity target) { + this(ActionPanel.ScreenMode.MASTER, target); + } + + private UnifiedBondageScreen(ActionPanel.ScreenMode mode, LivingEntity target) { + super(Component.translatable("gui.tiedup.unified_bondage")); + this.mode = mode; + this.targetEntity = target; + this.targetEntityUUID = (target != null) ? target.getUUID() : null; + } + + private LivingEntity getTarget() { + return (mode == ActionPanel.ScreenMode.SELF) ? minecraft.player : targetEntity; + } + + @Override + protected int getPreferredWidth() { + return GuiLayoutConstants.getResponsiveWidth(this.width, 0.65f, 420, 600); + } + + @Override + protected int getPreferredHeight() { + return GuiLayoutConstants.getResponsiveHeight(this.height, 0.75f, 350, 500); + } + + @Override + protected void init() { + super.init(); + + // Resolve key info for master mode by iterating player inventory + if (mode == ActionPanel.ScreenMode.MASTER && minecraft.player != null) { + ItemStack keyStack = findFirstKey(minecraft.player); + if (!keyStack.isEmpty()) { + if (keyStack.getItem() instanceof ItemKey key) { + this.keyUUID = key.getKeyUUID(keyStack); + this.isMasterKey = false; + } else if (keyStack.is(ModItems.MASTER_KEY.get())) { + this.keyUUID = null; + this.isMasterKey = true; + } + } + } + + int contentTop = topPos + GuiLayoutConstants.TITLE_HEIGHT + GuiLayoutConstants.MARGIN_M; + + // === Tab Bar === + tabBar = new RegionTabBar(leftPos + 2, contentTop, imageWidth - 4); + tabBar.setTargetEntity(getTarget()); + tabBar.setOnTabChanged(this::onTabChanged); + addRenderableWidget(tabBar); + + int belowTabs = contentTop + 30; + int statusBarHeight = 46; + int mainContentHeight = imageHeight - (belowTabs - topPos) - statusBarHeight - GuiLayoutConstants.MARGIN_S; + + // === Preview (Left, 40%) === + int previewWidth = (int)((imageWidth - GuiLayoutConstants.MARGIN_M * 3) * 0.40f); + LivingEntity target = getTarget(); + if (target != null) { + preview = new EntityPreviewWidget( + leftPos + GuiLayoutConstants.MARGIN_M, belowTabs, + previewWidth, mainContentHeight, target + ); + preview.setAutoRotate(true); + preview.setAutoRotateSpeed(0.3f); + preview.setZoomTarget(TAB_SCALES[0], TAB_OFFSETS[0]); + addRenderableWidget(preview); + } + + // === Right panel area === + int rightX = leftPos + GuiLayoutConstants.MARGIN_M + previewWidth + GuiLayoutConstants.MARGIN_M; + int rightWidth = imageWidth - (rightX - leftPos) - GuiLayoutConstants.MARGIN_M; + + // Action panel height + int actionPanelHeight = 84; + int slotsHeight = mainContentHeight - actionPanelHeight - GuiLayoutConstants.MARGIN_S; + + // === Region Slots === + buildSlots(rightX, belowTabs, rightWidth, slotsHeight); + + // === Action Panel === + actionPanel = new ActionPanel(rightX, belowTabs + slotsHeight + GuiLayoutConstants.MARGIN_S, + rightWidth, actionPanelHeight); + actionPanel.setMode(mode); + actionPanel.setTargetEntity(getTarget()); + actionPanel.setKeyInfo(keyUUID, isMasterKey); + actionPanel.setOnAdjustRequested(region -> { + if (AdjustmentScreen.canOpen()) minecraft.setScreen(new AdjustmentScreen()); + }); + actionPanel.setOnEquipRequested(this::openPicker); + actionPanel.setOnCellAssignRequested(() -> { + if (targetEntityUUID != null) ModNetwork.sendToServer(new PacketRequestCellList(targetEntityUUID)); + }); + actionPanel.setOnCloseRequested(this::onClose); + actionPanel.clearContext(); + addRenderableWidget(actionPanel); + + // === Status Bar === + int statusY = topPos + imageHeight - statusBarHeight; + statusBar = new StatusBarWidget(leftPos, statusY, imageWidth, statusBarHeight); + statusBar.setMode(mode); + statusBar.setTargetEntity(getTarget()); + statusBar.setOnCloseClicked(this::onClose); + addRenderableWidget(statusBar); + + // === Picker Overlay (created but hidden) === + pickerOverlay = new ItemPickerOverlay(); + pickerOverlay.setOnItemSelected(this::onPickerItemSelected); + pickerOverlay.setOnCancelled(() -> {}); // No-op, just close + addRenderableWidget(pickerOverlay); + + // Auto-select first occupied slot + autoSelectFirstOccupied(); + } + + /** + * Find the first key (ItemKey or ItemMasterKey) in the player's inventory. + * Regular ItemKey is preferred; falls back to master key if none found. + */ + private static ItemStack findFirstKey(Player player) { + ItemStack masterKeyStack = ItemStack.EMPTY; + for (int i = 0; i < player.getInventory().getContainerSize(); i++) { + ItemStack stack = player.getInventory().getItem(i); + if (stack.isEmpty()) continue; + if (stack.getItem() instanceof ItemKey) { + return stack; // Regular key takes priority + } + if (masterKeyStack.isEmpty() && stack.is(ModItems.MASTER_KEY.get())) { + masterKeyStack = stack; // Remember master key as fallback + } + } + return masterKeyStack; // Empty or master key + } + + private void buildSlots(int x, int y, int width, int totalHeight) { + RegionTabBar.BodyTab tab = tabBar.getActiveTab(); + BodyRegionV2[] regions = tab.getRegions().toArray(new BodyRegionV2[0]); + currentSlots = new RegionSlotWidget[regions.length]; + + int slotHeight = Math.min(52, (totalHeight - 4) / regions.length); + LivingEntity target = getTarget(); + + for (int i = 0; i < regions.length; i++) { + BodyRegionV2 region = regions[i]; + int slotY = y + i * slotHeight; + + RegionSlotWidget slot = new RegionSlotWidget(x, slotY, width, slotHeight - 2, + region, () -> target != null ? V2EquipmentHelper.getInRegion(target, region) : ItemStack.EMPTY); + slot.setOnClick(this::onSlotClicked); + slot.setShowEquipButton(true); + slot.setOnEquipClick(s -> openPicker(s.getRegion())); + + // Adjust button for MOUTH/EYES + if (region == BodyRegionV2.MOUTH || region == BodyRegionV2.EYES) { + slot.setShowAdjustButton(true); + } + + currentSlots[i] = slot; + addRenderableWidget(slot); + } + } + + private void onTabChanged(RegionTabBar.BodyTab tab) { + // Update preview zoom + int tabIdx = tab.ordinal(); + if (preview != null) { + preview.setZoomTarget(TAB_SCALES[tabIdx], TAB_OFFSETS[tabIdx]); + } + + // Rebuild slots — clear old ones and re-init + if (currentSlots != null) { + for (RegionSlotWidget slot : currentSlots) { + removeWidget(slot); + } + } + selectedSlot = null; + actionPanel.clearContext(); + + // Recalculate layout for slots + int contentTop = topPos + GuiLayoutConstants.TITLE_HEIGHT + GuiLayoutConstants.MARGIN_M; + int belowTabs = contentTop + 30; + int statusBarHeight = 46; + int mainContentHeight = imageHeight - (belowTabs - topPos) - statusBarHeight - GuiLayoutConstants.MARGIN_S; + int previewWidth = (int)((imageWidth - GuiLayoutConstants.MARGIN_M * 3) * 0.40f); + int rightX = leftPos + GuiLayoutConstants.MARGIN_M + previewWidth + GuiLayoutConstants.MARGIN_M; + int rightWidth = imageWidth - (rightX - leftPos) - GuiLayoutConstants.MARGIN_M; + int actionPanelHeight = 84; + int slotsHeight = mainContentHeight - actionPanelHeight - GuiLayoutConstants.MARGIN_S; + + buildSlots(rightX, belowTabs, rightWidth, slotsHeight); + autoSelectFirstOccupied(); + } + + private void onSlotClicked(RegionSlotWidget slot) { + // Deselect previous + if (selectedSlot != null) selectedSlot.setSelected(false); + // Select new + selectedSlot = slot; + slot.setSelected(true); + // Update action panel + actionPanel.setContext(slot.getRegion(), slot.getItem()); + } + + private void autoSelectFirstOccupied() { + if (currentSlots == null) return; + for (RegionSlotWidget slot : currentSlots) { + if (!slot.getItem().isEmpty()) { + onSlotClicked(slot); + return; + } + } + // No occupied slots — select first slot anyway for equip + if (currentSlots.length > 0) { + onSlotClicked(currentSlots[0]); + } + } + + private void openPicker(BodyRegionV2 region) { + pickerOverlay.open(region, mode == ActionPanel.ScreenMode.SELF, this.width, this.height); + } + + private void onPickerItemSelected(BodyRegionV2 region, int inventorySlot) { + if (mode == ActionPanel.ScreenMode.SELF) { + ModNetwork.sendToServer(new PacketV2SelfEquip(region, inventorySlot)); + } else { + ModNetwork.sendToServer(new PacketMasterEquip(targetEntityUUID, region, inventorySlot)); + } + refreshCountdown = 10; // Refresh after server processes + } + + @Override + public void tick() { + super.tick(); + if (preview != null) preview.tickZoom(); + + if (refreshCountdown > 0) { + refreshCountdown--; + } else if (refreshCountdown == 0) { + refreshCountdown = -1; + rebuildCurrentTab(); + } + } + + private void rebuildCurrentTab() { + onTabChanged(tabBar.getActiveTab()); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + this.renderBackground(graphics); + + // MC-style raised panel + GuiRenderUtil.drawMCPanel(graphics, leftPos, topPos, imageWidth, imageHeight); + + // Title (dark text, vanilla style) + String titleText = this.title.getString(); + if (mode == ActionPanel.ScreenMode.MASTER && targetEntity != null) { + titleText += " \u2014 " + targetEntity.getName().getString(); + } + GuiRenderUtil.drawCenteredStringNoShadow(graphics, font, titleText, + leftPos + imageWidth / 2, topPos + GuiLayoutConstants.MARGIN_M, TITLE_COLOR); + + // Mode badge (top-right) — sober gray badge + int badgeWidth = 90; + int badgeX = leftPos + imageWidth - badgeWidth - GuiLayoutConstants.MARGIN_M; + int badgeY = topPos + GuiLayoutConstants.MARGIN_S; + int badgeBg = mode == ActionPanel.ScreenMode.MASTER ? MODE_MASTER_BG : MODE_SELF_BG; + graphics.fill(badgeX, badgeY, badgeX + badgeWidth, badgeY + 16, badgeBg); + String badgeText = mode == ActionPanel.ScreenMode.MASTER + ? Component.translatable("gui.tiedup.mode.master").getString() + : Component.translatable("gui.tiedup.mode.self").getString(); + GuiRenderUtil.drawCenteredStringNoShadow(graphics, font, badgeText, badgeX + badgeWidth / 2, badgeY + 4, GuiRenderUtil.MC_TEXT_DARK); + + // Render all widgets + super.render(graphics, mouseX, mouseY, partialTick); + + // Picker overlay renders on top of everything + if (pickerOverlay.isOverlayVisible()) { + pickerOverlay.render(graphics, mouseX, mouseY, partialTick); + } + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + // Picker overlay captures all clicks when visible + if (pickerOverlay.isOverlayVisible()) { + return pickerOverlay.mouseClicked(mouseX, mouseY, button); + } + return super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (pickerOverlay.isOverlayVisible()) { + return pickerOverlay.keyPressed(keyCode, scanCode, modifiers); + } + return super.keyPressed(keyCode, scanCode, modifiers); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double delta) { + if (pickerOverlay.isOverlayVisible()) { + return pickerOverlay.mouseScrolled(mouseX, mouseY, delta); + } + return super.mouseScrolled(mouseX, mouseY, delta); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/util/GuiColors.java b/src/main/java/com/tiedup/remake/client/gui/util/GuiColors.java new file mode 100644 index 0000000..a495bf9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/util/GuiColors.java @@ -0,0 +1,154 @@ +package com.tiedup.remake.client.gui.util; + +import com.tiedup.remake.v2.BodyRegionV2; + +/** + * Color constants for TiedUp! GUI elements. + * All colors are in ARGB format (0xAARRGGBB). + * + * Phase 16: GUI Revamp + */ +public class GuiColors { + + // === Backgrounds === + public static final int BG_DARK = 0xFF1A1A1A; // #1A1A1A + public static final int BG_MEDIUM = 0xFF2D2D2D; // #2D2D2D + public static final int BG_LIGHT = 0xFF3D3D3D; // #3D3D3D + + // === Accents (leather/rope theme) === + public static final int ACCENT_BROWN = 0xFF8B4513; // #8B4513 SaddleBrown + public static final int ACCENT_TAN = 0xFFCD853F; // #CD853F Peru + public static final int ACCENT_ROPE = 0xFFD2691E; // #D2691E Chocolate + + // === Text === + public static final int TEXT_WHITE = 0xFFFFFFFF; + public static final int TEXT_GRAY = 0xFFAAAAAA; + public static final int TEXT_DISABLED = 0xFF666666; + + // === States === + public static final int SUCCESS = 0xFF4CAF50; // Green + public static final int WARNING = 0xFFFF9800; // Orange + public static final int ERROR = 0xFFF44336; // Red + public static final int INFO = 0xFF2196F3; // Blue + + // === Borders === + public static final int BORDER_DARK = 0xFF0A0A0A; + public static final int BORDER_LIGHT = 0xFF4A4A4A; + + // === Slot states === + public static final int SLOT_EMPTY = 0xFF555555; + public static final int SLOT_FILLED = 0xFF666666; + public static final int SLOT_HOVER = 0xFF777777; + public static final int SLOT_SELECTED = 0xFF8B4513; + + // === Bondage Type Colors === + public static final int TYPE_BIND = 0xFF8B4513; // Brown (rope) + public static final int TYPE_GAG = 0xFFFF6B6B; // Red + public static final int TYPE_BLINDFOLD = 0xFF333333; // Dark gray + public static final int TYPE_EARPLUGS = 0xFFFFD93D; // Yellow + public static final int TYPE_COLLAR = 0xFF6BCB77; // Green + public static final int TYPE_CLOTHES = 0xFF4D96FF; // Blue + public static final int TYPE_MITTENS = 0xFFFF9F43; // Orange + + // === Action Hover Colors === + public static final int HOVER_REMOVE = 0xFF5D2020; // Dark red + public static final int HOVER_LOCK = 0xFF5D4520; // Dark orange + public static final int HOVER_UNLOCK = 0xFF205D20; // Dark green + public static final int BUTTON_REMOVE = 0xFFCC4444; + public static final int BUTTON_REMOVE_HOVER = 0xFFFF6B6B; + + /** + * Get the color for a V2 body region. + * + * @param region The body region + * @return The corresponding color + */ + public static int getRegionColor(BodyRegionV2 region) { + return switch (region) { + case HEAD -> 0xFF9C27B0; // Purple + case EYES -> TYPE_BLINDFOLD; + case EARS -> TYPE_EARPLUGS; + case MOUTH -> TYPE_GAG; + case NECK -> TYPE_COLLAR; + case TORSO -> TYPE_CLOTHES; + case ARMS -> TYPE_BIND; + case HANDS -> TYPE_MITTENS; + case FINGERS -> 0xFFFFAB91; // Light orange + case WAIST -> 0xFF795548; // Brown + case LEGS -> 0xFF607D8B; // Blue-gray + case FEET -> 0xFF78909C; // Light blue-gray + case TAIL -> 0xFFCE93D8; // Light purple + case WINGS -> 0xFF80DEEA; // Light cyan + }; + } + + /** + * Get the color for a bondage item type. + * + * @param type The bondage item type name (lowercase) + * @return The corresponding color + */ + public static int getTypeColor(String type) { + return switch (type.toLowerCase()) { + case "bind" -> TYPE_BIND; + case "gag" -> TYPE_GAG; + case "blindfold" -> TYPE_BLINDFOLD; + case "earplugs" -> TYPE_EARPLUGS; + case "collar" -> TYPE_COLLAR; + case "clothes" -> TYPE_CLOTHES; + case "mittens" -> TYPE_MITTENS; + default -> TEXT_WHITE; + }; + } + + /** + * Create a color with custom alpha. + * + * @param color Base color (ARGB) + * @param alpha Alpha value (0-255) + * @return Color with new alpha + */ + public static int withAlpha(int color, int alpha) { + return (color & 0x00FFFFFF) | (alpha << 24); + } + + /** + * Lighten a color by a factor. + * + * @param color Base color (ARGB) + * @param factor Factor (0.0 = no change, 1.0 = white) + * @return Lightened color + */ + public static int lighten(int color, float factor) { + int a = (color >> 24) & 0xFF; + int r = (color >> 16) & 0xFF; + int g = (color >> 8) & 0xFF; + int b = color & 0xFF; + + r = (int) (r + (255 - r) * factor); + g = (int) (g + (255 - g) * factor); + b = (int) (b + (255 - b) * factor); + + return (a << 24) | (r << 16) | (g << 8) | b; + } + + /** + * Darken a color by a factor. + * + * @param color Base color (ARGB) + * @param factor Factor (0.0 = no change, 1.0 = black) + * @return Darkened color + */ + public static int darken(int color, float factor) { + int a = (color >> 24) & 0xFF; + int r = (color >> 16) & 0xFF; + int g = (color >> 8) & 0xFF; + int b = color & 0xFF; + + r = (int) (r * (1 - factor)); + g = (int) (g * (1 - factor)); + b = (int) (b * (1 - factor)); + + return (a << 24) | (r << 16) | (g << 8) | b; + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/util/GuiLayoutConstants.java b/src/main/java/com/tiedup/remake/client/gui/util/GuiLayoutConstants.java new file mode 100644 index 0000000..9fa727f --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/util/GuiLayoutConstants.java @@ -0,0 +1,116 @@ +package com.tiedup.remake.client.gui.util; + +import net.minecraft.util.Mth; + +/** + * Centralized layout constants for all TiedUp! GUI screens and widgets. + * Provides standard spacing and responsive calculation utilities. + * + * Refactored for Responsive Design. + */ +public final class GuiLayoutConstants { + + private GuiLayoutConstants() {} // No instantiation + + // ==================== MARGINS & PADDING ==================== + + public static final int MARGIN_XS = 2; + public static final int MARGIN_S = 4; + public static final int MARGIN_M = 8; + public static final int MARGIN_L = 16; + public static final int SCREEN_EDGE_MARGIN = 10; + + public static final int LINE_HEIGHT = 10; + public static final int TITLE_HEIGHT = 20; + + // ==================== WIDGET DIMENSIONS ==================== + + public static final int BUTTON_HEIGHT = 20; + public static final int BUTTON_WIDTH_S = 40; + public static final int BUTTON_WIDTH_M = 80; + public static final int BUTTON_WIDTH_L = 120; + public static final int BUTTON_WIDTH_XL = 160; + + public static final int SLOT_SIZE = 24; // Standard square slot + public static final int SLOT_HEIGHT = 24; + public static final int SLOT_SPACING = 3; + public static final int ICON_SIZE = 16; + + public static final int SCROLLBAR_WIDTH = 6; + + // ==================== STATUS ICONS ==================== + + public static final int STATUS_ICON_SIZE = 14; + public static final int STATUS_ICON_SPACING = 4; + + // ==================== ENTRY/LIST DIMENSIONS ==================== + + public static final int ENTRY_HEIGHT = 65; + public static final int ENTRY_SPACING = 4; + + // ==================== PREVIEW SIZES ==================== + + public static final int PREVIEW_WIDTH_S = 50; + public static final int PREVIEW_WIDTH_M = 100; + public static final int PREVIEW_WIDTH_L = 120; + public static final int PREVIEW_HEIGHT = 160; + + // Slider specific dimensions + public static final int SLIDER_THUMB_WIDTH = 8; + public static final int SLIDER_THUMB_HEIGHT = 20; + public static final int SLIDER_TRACK_WIDTH = 4; + + // ==================== LAYOUT HELPERS ==================== + + /** + * Calculates a responsive width constrained by min/max values. + * @param screenWidth The current screen width + * @param percentTarget Target percentage of screen width (0.0 - 1.0) + * @param minWidth Minimum pixel width + * @param maxWidth Maximum pixel width + * @return Calculated width + */ + public static int getResponsiveWidth( + int screenWidth, + float percentTarget, + int minWidth, + int maxWidth + ) { + int target = (int) (screenWidth * percentTarget); + int available = screenWidth - (SCREEN_EDGE_MARGIN * 2); + return Mth.clamp(target, minWidth, Math.min(maxWidth, available)); + } + + /** + * Calculates a responsive height constrained by min/max values. + * @param screenHeight The current screen height + * @param percentTarget Target percentage of screen height (0.0 - 1.0) + * @param minHeight Minimum pixel height + * @param maxHeight Maximum pixel height + * @return Calculated height + */ + public static int getResponsiveHeight( + int screenHeight, + float percentTarget, + int minHeight, + int maxHeight + ) { + int target = (int) (screenHeight * percentTarget); + int available = screenHeight - (SCREEN_EDGE_MARGIN * 2); + return Mth.clamp(target, minHeight, Math.min(maxHeight, available)); + } + + /** + * Centers an element coordinate X. + */ + public static int centerX(int containerWidth, int elementWidth) { + return (containerWidth - elementWidth) / 2; + } + + /** + * Centers an element coordinate Y. + */ + public static int centerY(int containerHeight, int elementHeight) { + return (containerHeight - elementHeight) / 2; + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/util/GuiRenderUtil.java b/src/main/java/com/tiedup/remake/client/gui/util/GuiRenderUtil.java new file mode 100644 index 0000000..eeb28d9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/util/GuiRenderUtil.java @@ -0,0 +1,196 @@ +package com.tiedup.remake.client.gui.util; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Utility methods for GUI rendering. + */ +@OnlyIn(Dist.CLIENT) +public final class GuiRenderUtil { + + // Vanilla MC 3D border colors + public static final int MC_PANEL_BG = 0xFFC6C6C6; + public static final int MC_HIGHLIGHT_OUTER = 0xFFFFFFFF; + public static final int MC_HIGHLIGHT_INNER = 0xFFDBDBDB; + public static final int MC_SHADOW_INNER = 0xFF555555; + public static final int MC_SHADOW_OUTER = 0xFF373737; + public static final int MC_SLOT_BG = 0xFF8B8B8B; + public static final int MC_TEXT_DARK = 0xFF404040; + public static final int MC_TEXT_GRAY = 0xFF555555; + + private GuiRenderUtil() {} + + /** + * Draw a vanilla MC-style raised panel (light gray with 3D beveled borders). + */ + public static void drawMCPanel(GuiGraphics graphics, int x, int y, int width, int height) { + // Fill + graphics.fill(x, y, x + width, y + height, MC_PANEL_BG); + // Top highlight (outer white, inner light) + graphics.fill(x, y, x + width, y + 1, MC_HIGHLIGHT_OUTER); + graphics.fill(x + 1, y + 1, x + width - 1, y + 2, MC_HIGHLIGHT_INNER); + // Left highlight + graphics.fill(x, y, x + 1, y + height, MC_HIGHLIGHT_OUTER); + graphics.fill(x + 1, y + 1, x + 2, y + height - 1, MC_HIGHLIGHT_INNER); + // Bottom shadow + graphics.fill(x, y + height - 1, x + width, y + height, MC_SHADOW_OUTER); + graphics.fill(x + 1, y + height - 2, x + width - 1, y + height - 1, MC_SHADOW_INNER); + // Right shadow + graphics.fill(x + width - 1, y, x + width, y + height, MC_SHADOW_OUTER); + graphics.fill(x + width - 2, y + 1, x + width - 1, y + height - 1, MC_SHADOW_INNER); + } + + /** + * Draw a vanilla MC-style sunken panel (inverted 3D borders — dark outside, light inside). + */ + public static void drawMCSunkenPanel(GuiGraphics graphics, int x, int y, int width, int height) { + graphics.fill(x, y, x + width, y + height, MC_SLOT_BG); + // Top shadow (dark) + graphics.fill(x, y, x + width, y + 1, MC_SHADOW_OUTER); + graphics.fill(x + 1, y + 1, x + width - 1, y + 2, MC_SHADOW_INNER); + // Left shadow (dark) + graphics.fill(x, y, x + 1, y + height, MC_SHADOW_OUTER); + graphics.fill(x + 1, y + 1, x + 2, y + height - 1, MC_SHADOW_INNER); + // Bottom highlight (light) + graphics.fill(x, y + height - 1, x + width, y + height, MC_HIGHLIGHT_OUTER); + graphics.fill(x + 1, y + height - 2, x + width - 1, y + height - 1, MC_HIGHLIGHT_INNER); + // Right highlight (light) + graphics.fill(x + width - 1, y, x + width, y + height, MC_HIGHLIGHT_OUTER); + graphics.fill(x + width - 2, y + 1, x + width - 1, y + height - 1, MC_HIGHLIGHT_INNER); + } + + /** + * Draw a vanilla MC-style sunken slot. + */ + public static void drawMCSlot(GuiGraphics graphics, int x, int y, int width, int height) { + graphics.fill(x, y, x + width, y + height, MC_SLOT_BG); + // Top shadow + graphics.fill(x, y, x + width, y + 1, MC_SHADOW_OUTER); + // Left shadow + graphics.fill(x, y, x + 1, y + height, MC_SHADOW_OUTER); + // Bottom highlight + graphics.fill(x, y + height - 1, x + width, y + height, MC_HIGHLIGHT_OUTER); + // Right highlight + graphics.fill(x + width - 1, y, x + width, y + height, MC_HIGHLIGHT_OUTER); + } + + /** + * Draw vanilla-style slot hover overlay (white semi-transparent). + */ + public static void drawSlotHover(GuiGraphics graphics, int x, int y, int width, int height) { + graphics.fill(x + 1, y + 1, x + width - 1, y + height - 1, 0x80FFFFFF); + } + + /** + * Draw a vanilla MC-style button (raised 3D appearance). + */ + public static void drawMCButton(GuiGraphics graphics, int x, int y, int width, int height, boolean hovered, boolean enabled) { + int bg = enabled ? (hovered ? 0xFFA0A0A0 : MC_SLOT_BG) : 0xFF606060; + graphics.fill(x, y, x + width, y + height, bg); + if (enabled) { + // Top highlight + graphics.fill(x, y, x + width, y + 1, MC_HIGHLIGHT_OUTER); + // Left highlight + graphics.fill(x, y, x + 1, y + height, MC_HIGHLIGHT_OUTER); + // Bottom shadow + graphics.fill(x, y + height - 1, x + width, y + height, MC_SHADOW_OUTER); + // Right shadow + graphics.fill(x + width - 1, y, x + width, y + height, MC_SHADOW_OUTER); + } else { + // Flat dark border for disabled + graphics.fill(x, y, x + width, y + 1, 0xFF505050); + graphics.fill(x, y + height - 1, x + width, y + height, 0xFF505050); + graphics.fill(x, y, x + 1, y + height, 0xFF505050); + graphics.fill(x + width - 1, y, x + width, y + height, 0xFF505050); + } + } + + /** + * Draw a selected slot highlight border (gold/yellow). + */ + public static void drawSelectedBorder(GuiGraphics graphics, int x, int y, int width, int height) { + int gold = 0xFFFFD700; + // Top + graphics.fill(x, y, x + width, y + 1, gold); + graphics.fill(x, y + 1, x + width, y + 2, gold); + // Bottom + graphics.fill(x, y + height - 2, x + width, y + height - 1, gold); + graphics.fill(x, y + height - 1, x + width, y + height, gold); + // Left + graphics.fill(x, y, x + 1, y + height, gold); + graphics.fill(x + 1, y, x + 2, y + height, gold); + // Right + graphics.fill(x + width - 2, y, x + width - 1, y + height, gold); + graphics.fill(x + width - 1, y, x + width, y + height, gold); + } + + /** + * Draw centered text WITHOUT shadow (vanilla drawCenteredString always adds shadow). + * Use this for dark text on light MC panels. + */ + public static void drawCenteredStringNoShadow(GuiGraphics graphics, net.minecraft.client.gui.Font font, String text, int centerX, int y, int color) { + int textWidth = font.width(text); + graphics.drawString(font, text, centerX - textWidth / 2, y, color, false); + } + + /** + * Draw a 1-pixel border around a rectangle. + * + * @param graphics The graphics context + * @param x Left position + * @param y Top position + * @param width Rectangle width + * @param height Rectangle height + * @param color Border color (ARGB) + */ + public static void drawBorder( + GuiGraphics graphics, + int x, + int y, + int width, + int height, + int color + ) { + // Top + graphics.fill(x, y, x + width, y + 1, color); + // Bottom + graphics.fill(x, y + height - 1, x + width, y + height, color); + // Left + graphics.fill(x, y, x + 1, y + height, color); + // Right + graphics.fill(x + width - 1, y, x + width, y + height, color); + } + + /** + * Draw a border with custom thickness around a rectangle. + * + * @param graphics The graphics context + * @param x Left position + * @param y Top position + * @param width Rectangle width + * @param height Rectangle height + * @param thickness Border thickness in pixels + * @param color Border color (ARGB) + */ + public static void drawBorder( + GuiGraphics graphics, + int x, + int y, + int width, + int height, + int thickness, + int color + ) { + // Top + graphics.fill(x, y, x + width, y + thickness, color); + // Bottom + graphics.fill(x, y + height - thickness, x + width, y + height, color); + // Left + graphics.fill(x, y, x + thickness, y + height, color); + // Right + graphics.fill(x + width - thickness, y, x + width, y + height, color); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/util/GuiStatsRenderer.java b/src/main/java/com/tiedup/remake/client/gui/util/GuiStatsRenderer.java new file mode 100644 index 0000000..a98d97b --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/util/GuiStatsRenderer.java @@ -0,0 +1,93 @@ +package com.tiedup.remake.client.gui.util; + +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Utility class for rendering stat bars and value-colored elements in GUIs. + * Centralizes stat bar rendering logic to reduce code duplication. + */ +@OnlyIn(Dist.CLIENT) +public final class GuiStatsRenderer { + + private GuiStatsRenderer() {} + + // ARGB colors for stat values (green -> orange -> red) + private static final int COLOR_GREEN = 0xFF4CAF50; + private static final int COLOR_ORANGE = 0xFFFF9800; + private static final int COLOR_RED = 0xFFF44336; + + /** + * Render a stat bar with label and value-based coloring. + * + * @param graphics Graphics context + * @param font Font renderer + * @param x Left position + * @param y Top position + * @param label Stat label (e.g., "Hunger", "Willpower") + * @param value Stat value (0-100) + * @param totalWidth Total width including label and bar + */ + public static void renderStatBar( + GuiGraphics graphics, + Font font, + int x, + int y, + String label, + float value, + int totalWidth + ) { + // Draw label + int labelWidth = font.width(label) + 2; + graphics.drawString(font, label, x, y, 0xFFAAAAAA, false); + + // Calculate bar dimensions + int barX = x + labelWidth; + int barW = totalWidth - labelWidth; + int barH = 6; + + // Background (black) + graphics.fill(barX, y + 1, barX + barW, y + 1 + barH, 0xFF000000); + + // Fill based on value (0-100) + int fillWidth = (int) (barW * (value / 100f)); + int fillColor = getValueColorArgb(value); + if (fillWidth > 0) { + graphics.fill( + barX + 1, + y + 2, + barX + 1 + Math.min(fillWidth, barW - 2), + y + barH, + fillColor + ); + } + } + + /** + * Get RGB color for a stat value (green -> orange -> red). + * Used for text rendering. + * + * @param value Stat value (0-100) + * @return RGB color value (0xRRGGBB) + */ + public static int getValueColor(float value) { + if (value >= 70) return 0x4CAF50; // Green + if (value >= 40) return 0xFF9800; // Orange + return 0xF44336; // Red + } + + /** + * Get ARGB color for a stat value (green -> orange -> red). + * Used for fill operations. + * + * @param value Stat value (0-100) + * @return ARGB color value (0xAARRGGBB) + */ + public static int getValueColorArgb(float value) { + if (value >= 70) return COLOR_GREEN; + if (value >= 40) return COLOR_ORANGE; + return COLOR_RED; + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/util/GuiTextureHelper.java b/src/main/java/com/tiedup/remake/client/gui/util/GuiTextureHelper.java new file mode 100644 index 0000000..46f2205 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/util/GuiTextureHelper.java @@ -0,0 +1,268 @@ +package com.tiedup.remake.client.gui.util; + +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Utility class for rendering vanilla-style GUI elements. + * Provides methods to draw backgrounds using Minecraft's textures. + */ +@OnlyIn(Dist.CLIENT) +public final class GuiTextureHelper { + + private GuiTextureHelper() {} + + // === Vanilla Textures === + public static final ResourceLocation CHEST_BACKGROUND = + ResourceLocation.fromNamespaceAndPath( + "minecraft", + "textures/gui/container/generic_54.png" + ); + + public static final ResourceLocation INVENTORY_BACKGROUND = + ResourceLocation.fromNamespaceAndPath( + "minecraft", + "textures/gui/container/inventory.png" + ); + + // === Chest Texture Dimensions === + public static final int CHEST_WIDTH = 176; + public static final int CHEST_ROW_HEIGHT = 18; + public static final int CHEST_HEADER_HEIGHT = 17; + public static final int CHEST_PLAYER_INV_HEIGHT = 96; + public static final int SLOT_SIZE = 18; + + /** + * Render a chest-style background with variable number of rows. + * Uses the vanilla generic_54.png texture (large chest). + * + * @param graphics GuiGraphics context + * @param x Left position + * @param y Top position + * @param rows Number of inventory rows (1-6) + */ + public static void renderChestBackground( + GuiGraphics graphics, + int x, + int y, + int rows + ) { + rows = Math.min(6, Math.max(1, rows)); + + // Header section (title area) - top 17 pixels + graphics.blit( + CHEST_BACKGROUND, + x, + y, + 0, + 0, + CHEST_WIDTH, + CHEST_HEADER_HEIGHT + ); + + // Inventory rows - 18 pixels each + for (int i = 0; i < rows; i++) { + graphics.blit( + CHEST_BACKGROUND, + x, + y + CHEST_HEADER_HEIGHT + i * CHEST_ROW_HEIGHT, + 0, + CHEST_HEADER_HEIGHT, + CHEST_WIDTH, + CHEST_ROW_HEIGHT + ); + } + + // Player inventory section (bottom part with separator + 3 rows + hotbar) + // In generic_54.png, player section starts at y=125 and is 96 pixels tall + graphics.blit( + CHEST_BACKGROUND, + x, + y + CHEST_HEADER_HEIGHT + rows * CHEST_ROW_HEIGHT, + 0, + 125, + CHEST_WIDTH, + CHEST_PLAYER_INV_HEIGHT + ); + } + + /** + * Calculate total height for a chest-style container with N rows. + * + * @param rows Number of inventory rows + * @return Total height in pixels + */ + public static int getChestHeight(int rows) { + return ( + CHEST_HEADER_HEIGHT + + rows * CHEST_ROW_HEIGHT + + CHEST_PLAYER_INV_HEIGHT + ); + } + + /** + * Render a vanilla-style 3D beveled panel. + * Creates a dark panel with highlight on top/left and shadow on bottom/right. + * + * @param graphics GuiGraphics context + * @param x Left position + * @param y Top position + * @param width Panel width + * @param height Panel height + */ + public static void renderBeveledPanel( + GuiGraphics graphics, + int x, + int y, + int width, + int height + ) { + // Main background (dark gray) + graphics.fill(x, y, x + width, y + height, 0xC0101010); + + // Top highlight (light gray) + graphics.fill(x, y, x + width, y + 2, 0xFF373737); + // Left highlight + graphics.fill(x, y, x + 2, y + height, 0xFF373737); + + // Bottom shadow (black) + graphics.fill(x, y + height - 2, x + width, y + height, 0xFF000000); + // Right shadow + graphics.fill(x + width - 2, y, x + width, y + height, 0xFF000000); + + // Inner fill (slightly lighter) + graphics.fill(x + 2, y + 2, x + width - 2, y + height - 2, 0xFF2D2D2D); + } + + /** + * Render a small equipment panel (for armor/weapon slots). + * + * @param graphics GuiGraphics context + * @param x Left position + * @param y Top position + * @param slots Number of slots vertically + */ + public static void renderEquipmentPanel( + GuiGraphics graphics, + int x, + int y, + int slots + ) { + int width = 26; + int height = 8 + slots * SLOT_SIZE; + + // Background + graphics.fill(x, y, x + width, y + height, 0xC0101010); + + // Border + graphics.fill(x, y, x + width, y + 1, 0xFF373737); + graphics.fill(x, y, x + 1, y + height, 0xFF373737); + graphics.fill(x, y + height - 1, x + width, y + height, 0xFF000000); + graphics.fill(x + width - 1, y, x + width, y + height, 0xFF000000); + + // Inner + graphics.fill(x + 1, y + 1, x + width - 1, y + height - 1, 0xFF2D2D2D); + + // Slot backgrounds + for (int i = 0; i < slots; i++) { + int slotX = x + 4; + int slotY = y + 4 + i * SLOT_SIZE; + renderSlotBackground(graphics, slotX, slotY); + } + } + + /** + * Render a single slot background (18x18 with inner shadow). + * + * @param graphics GuiGraphics context + * @param x Left position + * @param y Top position + */ + public static void renderSlotBackground( + GuiGraphics graphics, + int x, + int y + ) { + // Outer dark border + graphics.fill(x, y, x + SLOT_SIZE, y + SLOT_SIZE, 0xFF373737); + // Inner lighter area + graphics.fill( + x + 1, + y + 1, + x + SLOT_SIZE - 1, + y + SLOT_SIZE - 1, + 0xFF8B8B8B + ); + // Slot center (dark) + graphics.fill( + x + 1, + y + 1, + x + SLOT_SIZE - 1, + y + SLOT_SIZE - 1, + 0xFF373737 + ); + } + + /** + * Render a vanilla-style XP progress bar. + * + * @param graphics GuiGraphics context + * @param x Left position + * @param y Top position + * @param width Bar width + * @param progress Progress value (0.0 to 1.0) + * @param color Fill color (ARGB) + */ + public static void renderProgressBar( + GuiGraphics graphics, + int x, + int y, + int width, + float progress, + int color + ) { + // Background (black with inner dark gray) + graphics.fill(x, y, x + width, y + 5, 0xFF000000); + graphics.fill(x + 1, y + 1, x + width - 1, y + 4, 0xFF2E2E2E); + + // Fill + int fillWidth = (int) ((width - 2) * + Math.min(1.0f, Math.max(0.0f, progress))); + if (fillWidth > 0) { + graphics.fill(x + 1, y + 1, x + 1 + fillWidth, y + 4, color); + } + } + + /** + * Render a vanilla-style XP bar with green color. + */ + public static void renderXpBar( + GuiGraphics graphics, + int x, + int y, + int width, + float progress + ) { + renderProgressBar(graphics, x, y, width, progress, 0xFF80FF20); + } + + /** + * Render a horizontal separator line. + * + * @param graphics GuiGraphics context + * @param x Left position + * @param y Y position + * @param width Line width + */ + public static void renderSeparator( + GuiGraphics graphics, + int x, + int y, + int width + ) { + graphics.fill(x, y, x + width, y + 1, 0xFF373737); + graphics.fill(x, y + 1, x + width, y + 2, 0xFF000000); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/widgets/ActionPanel.java b/src/main/java/com/tiedup/remake/client/gui/widgets/ActionPanel.java new file mode 100644 index 0000000..0cecb32 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/widgets/ActionPanel.java @@ -0,0 +1,406 @@ +package com.tiedup.remake.client.gui.widgets; + +import com.tiedup.remake.client.gui.util.GuiRenderUtil; +import com.tiedup.remake.items.GenericKnife; +import com.tiedup.remake.items.ItemKey; +import com.tiedup.remake.items.ItemLockpick; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.IHasResistance; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.action.PacketSetKnifeCutTarget; +import com.tiedup.remake.network.minigame.PacketLockpickMiniGameStart; +import com.tiedup.remake.network.slave.PacketMasterEquip; +import com.tiedup.remake.network.slave.PacketSlaveItemManage; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import com.tiedup.remake.v2.bondage.network.PacketV2SelfEquip; +import com.tiedup.remake.v2.bondage.network.PacketV2SelfLock; +import com.tiedup.remake.v2.bondage.network.PacketV2SelfRemove; +import com.tiedup.remake.v2.bondage.network.PacketV2SelfUnlock; +import com.tiedup.remake.v2.bondage.network.PacketV2StruggleStart; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.narration.NarratedElementType; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Contextual action panel for the unified bondage screen. + * Shows different actions based on mode (SELF/MASTER) and selected item state. + */ +@OnlyIn(Dist.CLIENT) +public class ActionPanel extends AbstractWidget { + + public enum ScreenMode { SELF, MASTER } + + private record ActionEntry( + String labelKey, + boolean enabled, + String disabledReasonKey, + Runnable onClick + ) {} + + // Layout + private static final int BUTTON_WIDTH = 80; + private static final int BUTTON_HEIGHT = 22; + private static final int BUTTON_SPACING = 6; + private static final int COLUMNS = 3; + private static final int PADDING = 8; + + // Colors (vanilla MC style) + private static final int TEXT_ENABLED = 0xFF404040; + private static final int TEXT_DISABLED = 0xFF909090; + private static final int TITLE_COLOR = 0xFF404040; + + private ScreenMode mode = ScreenMode.SELF; + private BodyRegionV2 selectedRegion; + private ItemStack selectedItem = ItemStack.EMPTY; + private LivingEntity targetEntity; + private UUID targetEntityUUID; + private UUID keyUUID; + private boolean isMasterKey; + private final List actions = new ArrayList<>(); + + // Callbacks for actions that open other screens + private Consumer onAdjustRequested; + private Consumer onEquipRequested; + private Runnable onCellAssignRequested; + private Runnable onCloseRequested; + + // Hovered button index for tooltip + private int hoveredIndex = -1; + + public ActionPanel(int x, int y, int width, int height) { + super(x, y, width, height, Component.literal("Actions")); + } + + public void setMode(ScreenMode mode) { this.mode = mode; } + public void setTargetEntity(LivingEntity entity) { + this.targetEntity = entity; + this.targetEntityUUID = entity != null ? entity.getUUID() : null; + } + public void setKeyInfo(UUID keyUUID, boolean isMasterKey) { + this.keyUUID = keyUUID; + this.isMasterKey = isMasterKey; + } + public void setOnAdjustRequested(Consumer cb) { this.onAdjustRequested = cb; } + public void setOnEquipRequested(Consumer cb) { this.onEquipRequested = cb; } + public void setOnCellAssignRequested(Runnable cb) { this.onCellAssignRequested = cb; } + public void setOnCloseRequested(Runnable cb) { this.onCloseRequested = cb; } + + /** + * Update the action panel context. Call when the selected slot changes. + */ + public void setContext(BodyRegionV2 region, ItemStack item) { + this.selectedRegion = region; + this.selectedItem = item != null ? item : ItemStack.EMPTY; + rebuildActions(); + } + + /** Clear the selection — no slot selected. */ + public void clearContext() { + this.selectedRegion = null; + this.selectedItem = ItemStack.EMPTY; + actions.clear(); + } + + private void rebuildActions() { + actions.clear(); + if (selectedRegion == null) return; + + Player localPlayer = Minecraft.getInstance().player; + if (localPlayer == null) return; + + if (mode == ScreenMode.SELF) { + buildSelfActions(localPlayer); + } else { + buildMasterActions(localPlayer); + } + } + + /** + * Find a key item in the player's inventory. + * ItemKey.findKeyInInventory does not exist, so we implement it inline. + */ + private static ItemStack findKeyInInventory(Player player) { + ItemStack masterKeyStack = ItemStack.EMPTY; + for (int i = 0; i < player.getInventory().getContainerSize(); i++) { + ItemStack stack = player.getInventory().getItem(i); + if (stack.isEmpty()) continue; + if (stack.getItem() instanceof ItemKey) { + return stack; // Regular key takes priority + } + if (masterKeyStack.isEmpty() && stack.is(ModItems.MASTER_KEY.get())) { + masterKeyStack = stack; // Remember master key as fallback + } + } + return masterKeyStack; // Empty or master key + } + + private void buildSelfActions(Player player) { + boolean isEmpty = selectedItem.isEmpty(); + boolean armsOccupied = V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS); + boolean handsOccupied = V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.HANDS); + boolean armsFree = !armsOccupied; + boolean handsFree = !handsOccupied; + + boolean isLocked = false; + boolean isLockable = false; + boolean isJammed = false; + boolean hasMittens = V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.HANDS); + if (!isEmpty && selectedItem.getItem() instanceof ILockable lockable) { + isLocked = lockable.isLocked(selectedItem); + isLockable = lockable.isLockable(selectedItem); + isJammed = lockable.isJammed(selectedItem); + } + + boolean hasLockpick = !ItemLockpick.findLockpickInInventory(player).isEmpty(); + boolean hasKnife = !GenericKnife.findKnifeInInventory(player).isEmpty(); + ItemStack foundKey = findKeyInInventory(player); + boolean hasKey = !foundKey.isEmpty(); + boolean foundKeyIsMaster = hasKey && foundKey.is(ModItems.MASTER_KEY.get()); + + if (isEmpty) { + // Equip action for empty slot + actions.add(new ActionEntry("gui.tiedup.action.equip", true, null, + () -> { if (onEquipRequested != null) onEquipRequested.accept(selectedRegion); })); + return; + } + + // Remove + boolean canRemove = armsFree && handsFree && !isLocked && selectedRegion != BodyRegionV2.ARMS; + String removeReason = isLocked ? "gui.tiedup.reason.locked" : + !armsFree ? "gui.tiedup.reason.arms_bound" : + !handsFree ? "gui.tiedup.reason.hands_bound" : + selectedRegion == BodyRegionV2.ARMS ? "gui.tiedup.reason.use_struggle" : null; + actions.add(new ActionEntry("gui.tiedup.action.remove", canRemove, removeReason, + () -> ModNetwork.sendToServer(new PacketV2SelfRemove(selectedRegion)))); + + // Struggle (locked items only) + if (isLocked) { + actions.add(new ActionEntry("gui.tiedup.action.struggle", true, null, + () -> { + ModNetwork.sendToServer(new PacketV2StruggleStart(selectedRegion)); + if (onCloseRequested != null) onCloseRequested.run(); + })); + } + + // Lockpick + if (isLocked) { + boolean canPick = hasLockpick && !hasMittens && !isJammed; + String pickReason = !hasLockpick ? "gui.tiedup.reason.no_lockpick" : + hasMittens ? "gui.tiedup.reason.mittens" : + isJammed ? "gui.tiedup.reason.jammed" : null; + actions.add(new ActionEntry("gui.tiedup.action.lockpick", canPick, pickReason, + () -> { + ModNetwork.sendToServer(new PacketLockpickMiniGameStart(selectedRegion)); + if (onCloseRequested != null) onCloseRequested.run(); + })); + } + + // Cut + if (isLocked) { + boolean canCut = hasKnife && !hasMittens; + String cutReason = !hasKnife ? "gui.tiedup.reason.no_knife" : + hasMittens ? "gui.tiedup.reason.mittens" : null; + actions.add(new ActionEntry("gui.tiedup.action.cut", canCut, cutReason, + () -> { + ModNetwork.sendToServer(new PacketSetKnifeCutTarget(selectedRegion)); + if (onCloseRequested != null) onCloseRequested.run(); + })); + } + + // Adjust (MOUTH, EYES only) + if (selectedRegion == BodyRegionV2.MOUTH || selectedRegion == BodyRegionV2.EYES) { + actions.add(new ActionEntry("gui.tiedup.action.adjust", true, null, + () -> { if (onAdjustRequested != null) onAdjustRequested.accept(selectedRegion); })); + } + + // Lock (self, with key, arms free) + if (isLockable && !isLocked) { + boolean canLock = hasKey && armsFree; + String lockReason = !hasKey ? "gui.tiedup.reason.no_key" : + !armsFree ? "gui.tiedup.reason.arms_bound" : null; + actions.add(new ActionEntry("gui.tiedup.action.lock", canLock, lockReason, + () -> ModNetwork.sendToServer(new PacketV2SelfLock(selectedRegion)))); + } + + // Unlock (self, with matching key, arms free) + if (isLocked) { + boolean keyMatches = false; + if (hasKey && selectedItem.getItem() instanceof ILockable lockable) { + if (foundKeyIsMaster) { + keyMatches = true; // Master key matches all locks + } else if (foundKey.getItem() instanceof ItemKey itemKey) { + UUID lockKeyUUID = lockable.getLockedByKeyUUID(selectedItem); + UUID foundKeyUUID = itemKey.getKeyUUID(foundKey); + keyMatches = lockKeyUUID == null || lockKeyUUID.equals(foundKeyUUID); + } + } + boolean canUnlock = hasKey && armsFree && keyMatches; + String unlockReason = !hasKey ? "gui.tiedup.reason.no_key" : + !armsFree ? "gui.tiedup.reason.arms_bound" : + !keyMatches ? "gui.tiedup.reason.wrong_key" : null; + actions.add(new ActionEntry("gui.tiedup.action.unlock", canUnlock, unlockReason, + () -> ModNetwork.sendToServer(new PacketV2SelfUnlock(selectedRegion)))); + } + } + + private void buildMasterActions(Player master) { + boolean isEmpty = selectedItem.isEmpty(); + + boolean isLocked = false; + boolean isLockable = false; + if (!isEmpty && selectedItem.getItem() instanceof ILockable lockable) { + isLocked = lockable.isLocked(selectedItem); + isLockable = lockable.isLockable(selectedItem); + } + + if (isEmpty) { + actions.add(new ActionEntry("gui.tiedup.action.equip", true, null, + () -> { if (onEquipRequested != null) onEquipRequested.accept(selectedRegion); })); + return; + } + + // Remove + actions.add(new ActionEntry("gui.tiedup.action.remove", !isLocked, + isLocked ? "gui.tiedup.reason.locked" : null, + () -> ModNetwork.sendToServer(new PacketSlaveItemManage( + targetEntityUUID, selectedRegion, PacketSlaveItemManage.Action.REMOVE, keyUUID, isMasterKey)))); + + // Lock + if (isLockable && !isLocked) { + actions.add(new ActionEntry("gui.tiedup.action.lock", true, null, + () -> ModNetwork.sendToServer(new PacketSlaveItemManage( + targetEntityUUID, selectedRegion, PacketSlaveItemManage.Action.LOCK, keyUUID, isMasterKey)))); + } + + // Unlock + if (isLocked) { + actions.add(new ActionEntry("gui.tiedup.action.unlock", true, null, + () -> ModNetwork.sendToServer(new PacketSlaveItemManage( + targetEntityUUID, selectedRegion, PacketSlaveItemManage.Action.UNLOCK, keyUUID, isMasterKey)))); + } + + // Adjust (MOUTH, EYES) + if (selectedRegion == BodyRegionV2.MOUTH || selectedRegion == BodyRegionV2.EYES) { + actions.add(new ActionEntry("gui.tiedup.action.adjust", true, null, + () -> { if (onAdjustRequested != null) onAdjustRequested.accept(selectedRegion); })); + } + + // Bondage Service toggle (NECK collar only, prison configured) + if (selectedRegion == BodyRegionV2.NECK && selectedItem.getItem() instanceof ItemCollar collar) { + if (collar.hasCellAssigned(selectedItem)) { + boolean svcEnabled = collar.isBondageServiceEnabled(selectedItem); + String svcKey = svcEnabled ? "gui.tiedup.action.svc_off" : "gui.tiedup.action.svc_on"; + actions.add(new ActionEntry(svcKey, true, null, + () -> ModNetwork.sendToServer(new PacketSlaveItemManage( + targetEntityUUID, selectedRegion, + PacketSlaveItemManage.Action.TOGGLE_BONDAGE_SERVICE, keyUUID, isMasterKey)))); + } + // Cell assign + actions.add(new ActionEntry("gui.tiedup.action.cell_assign", true, null, + () -> { if (onCellAssignRequested != null) onCellAssignRequested.run(); })); + } + } + + @Override + protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + Minecraft mc = Minecraft.getInstance(); + + // MC-style sunken panel background + GuiRenderUtil.drawMCSunkenPanel(graphics, getX(), getY(), width, height); + + // Title + String title; + if (selectedRegion == null) { + title = Component.translatable("gui.tiedup.action.no_selection").getString(); + } else if (selectedItem.isEmpty()) { + title = Component.translatable("gui.tiedup.action.title_empty", + Component.translatable("gui.tiedup.region." + selectedRegion.name().toLowerCase())).getString(); + } else { + title = (mode == ScreenMode.MASTER ? "\u265B " : "") + selectedItem.getHoverName().getString(); + } + graphics.drawString(mc.font, title, getX() + PADDING, getY() + PADDING, TITLE_COLOR, false); + + // Action buttons grid (MC-style buttons) + hoveredIndex = -1; + int startY = getY() + PADDING + mc.font.lineHeight + 6; + int startX = getX() + PADDING; + + for (int i = 0; i < actions.size(); i++) { + ActionEntry action = actions.get(i); + int col = i % COLUMNS; + int row = i / COLUMNS; + int btnX = startX + col * (BUTTON_WIDTH + BUTTON_SPACING); + int btnY = startY + row * (BUTTON_HEIGHT + 4); + + boolean hovered = mouseX >= btnX && mouseX < btnX + BUTTON_WIDTH + && mouseY >= btnY && mouseY < btnY + BUTTON_HEIGHT; + if (hovered) hoveredIndex = i; + + int textColor = action.enabled() ? TEXT_ENABLED : TEXT_DISABLED; + GuiRenderUtil.drawMCButton(graphics, btnX, btnY, BUTTON_WIDTH, BUTTON_HEIGHT, hovered, action.enabled()); + + String label = Component.translatable(action.labelKey()).getString(); + int textX = btnX + (BUTTON_WIDTH - mc.font.width(label)) / 2; + int textY = btnY + (BUTTON_HEIGHT - mc.font.lineHeight) / 2; + graphics.drawString(mc.font, label, textX, textY, textColor, false); + } + + // Tooltip for disabled button + if (hoveredIndex >= 0 && hoveredIndex < actions.size()) { + ActionEntry hoverAction = actions.get(hoveredIndex); + if (!hoverAction.enabled() && hoverAction.disabledReasonKey() != null) { + graphics.renderTooltip(mc.font, + Component.translatable(hoverAction.disabledReasonKey()), + mouseX, mouseY); + } + } + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (!isMouseOver(mouseX, mouseY) || button != 0) return false; + + Minecraft mc = Minecraft.getInstance(); + int startY = getY() + PADDING + mc.font.lineHeight + 6; + int startX = getX() + PADDING; + + for (int i = 0; i < actions.size(); i++) { + ActionEntry action = actions.get(i); + int col = i % COLUMNS; + int row = i / COLUMNS; + int btnX = startX + col * (BUTTON_WIDTH + BUTTON_SPACING); + int btnY = startY + row * (BUTTON_HEIGHT + 4); + + if (mouseX >= btnX && mouseX < btnX + BUTTON_WIDTH + && mouseY >= btnY && mouseY < btnY + BUTTON_HEIGHT) { + if (action.enabled() && action.onClick() != null) { + action.onClick().run(); + playDownSound(mc.getSoundManager()); + return true; + } + return false; // Clicked disabled button + } + } + return false; + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput output) { + output.add(NarratedElementType.TITLE, Component.translatable("gui.tiedup.action_panel")); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/widgets/AdjustmentSlider.java b/src/main/java/com/tiedup/remake/client/gui/widgets/AdjustmentSlider.java new file mode 100644 index 0000000..4597850 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/widgets/AdjustmentSlider.java @@ -0,0 +1,362 @@ +package com.tiedup.remake.client.gui.widgets; + +import static com.tiedup.remake.client.gui.util.GuiLayoutConstants.*; + +import com.tiedup.remake.client.gui.util.GuiColors; +import com.tiedup.remake.client.gui.util.GuiLayoutConstants; +import java.util.function.Consumer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.narration.NarratedElementType; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.network.chat.Component; +import net.minecraft.util.Mth; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Vertical slider widget for adjusting Y position of items. + * Displays current value and supports mouse drag and scroll wheel. + * + * Phase 16b: GUI Refactoring - Fixed alignment and layout + */ +@OnlyIn(Dist.CLIENT) +public class AdjustmentSlider extends AbstractWidget { + + private float minValue; + private float maxValue; + private float value; + private float step; + private Consumer onChange; + private boolean dragging = false; + + // Visual settings + private int trackColor = GuiColors.BG_LIGHT; + private int thumbColor = GuiColors.ACCENT_BROWN; + private int thumbHighlightColor = GuiColors.ACCENT_TAN; + private int textColor = GuiColors.TEXT_WHITE; + private int labelColor = GuiColors.TEXT_GRAY; + + // Layout constants - use centralized values + private static final int THUMB_WIDTH = + GuiLayoutConstants.SLIDER_THUMB_WIDTH; // 8px + private static final int THUMB_HEIGHT = + GuiLayoutConstants.SLIDER_THUMB_HEIGHT; // 20px + private static final int TRACK_WIDTH = + GuiLayoutConstants.SLIDER_TRACK_WIDTH; // 4px + + // Space reserved for labels (top and bottom) + private static final int LABEL_SPACE = 14; + + /** + * Create a new adjustment slider. + * + * @param x X position + * @param y Y position + * @param width Widget width + * @param height Widget height (includes label space at top and bottom) + * @param min Minimum value + * @param max Maximum value + * @param initial Initial value + * @param onChange Callback when value changes + */ + public AdjustmentSlider( + int x, + int y, + int width, + int height, + float min, + float max, + float initial, + Consumer onChange + ) { + super(x, y, width, height, Component.empty()); + this.minValue = min; + this.maxValue = max; + this.value = Mth.clamp(initial, min, max); + this.step = 0.25f; + this.onChange = onChange; + } + + /** + * Set the step size for scroll wheel and button adjustments. + */ + public void setStep(float step) { + this.step = step; + } + + /** + * Get the current value. + */ + public float getValue() { + return value; + } + + /** + * Set the current value (clamped to range). + */ + public void setValue(float newValue) { + float oldValue = this.value; + this.value = Mth.clamp(newValue, minValue, maxValue); + + if (this.value != oldValue && onChange != null) { + onChange.accept(this.value); + } + } + + /** + * Increase value by step. + */ + public void increment() { + setValue(value + step); + } + + /** + * Decrease value by step. + */ + public void decrement() { + setValue(value - step); + } + + /** + * Reset to center (0.0). + */ + public void reset() { + setValue(0.0f); + } + + @Override + protected void renderWidget( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + Minecraft mc = Minecraft.getInstance(); + + // Calculate center X for all elements (ensures perfect alignment) + int centerX = getX() + width / 2; + + // Track area (excluding label space at top and bottom) + int trackY = getY() + LABEL_SPACE; + int trackHeight = + height - LABEL_SPACE * 2 - GuiLayoutConstants.LINE_HEIGHT; // Leave room for value text + + // Draw track background (centered) + int trackX = centerX - TRACK_WIDTH / 2; + graphics.fill( + trackX, + trackY, + trackX + TRACK_WIDTH, + trackY + trackHeight, + trackColor + ); + + // Track border for visibility + graphics.fill( + trackX - 1, + trackY, + trackX, + trackY + trackHeight, + GuiColors.darken(trackColor, 0.3f) + ); + graphics.fill( + trackX + TRACK_WIDTH, + trackY, + trackX + TRACK_WIDTH + 1, + trackY + trackHeight, + GuiColors.darken(trackColor, 0.3f) + ); + + // Draw center line marker (zero point) + if (minValue < 0 && maxValue > 0) { + float zeroNormalized = (0 - minValue) / (maxValue - minValue); + int zeroY = + trackY + + (int) ((1 - zeroNormalized) * (trackHeight - THUMB_HEIGHT)) + + THUMB_HEIGHT / 2; + graphics.fill( + centerX - THUMB_WIDTH / 2 - 2, + zeroY - 1, + centerX + THUMB_WIDTH / 2 + 2, + zeroY + 1, + GuiColors.BORDER_LIGHT + ); + } + + // Thumb position (inverted because Y+ is down in screen space) + float normalized = (value - minValue) / (maxValue - minValue); + int thumbY = + trackY + (int) ((1 - normalized) * (trackHeight - THUMB_HEIGHT)); + int thumbX = centerX - THUMB_WIDTH / 2; // Centered on same axis as track + + // Draw thumb + boolean hovered = isMouseOver(mouseX, mouseY); + int currentThumbColor = (dragging || hovered) + ? thumbHighlightColor + : thumbColor; + + // Thumb shadow + graphics.fill( + thumbX + 1, + thumbY + 1, + thumbX + THUMB_WIDTH + 1, + thumbY + THUMB_HEIGHT + 1, + GuiColors.darken(currentThumbColor, 0.5f) + ); + + // Thumb main body + graphics.fill( + thumbX, + thumbY, + thumbX + THUMB_WIDTH, + thumbY + THUMB_HEIGHT, + currentThumbColor + ); + + // Thumb highlight edge (top) + graphics.fill( + thumbX, + thumbY, + thumbX + THUMB_WIDTH, + thumbY + 1, + GuiColors.lighten(currentThumbColor, 0.3f) + ); + + // Thumb center grip lines + int gripY = thumbY + THUMB_HEIGHT / 2; + graphics.fill( + thumbX + 2, + gripY - 2, + thumbX + THUMB_WIDTH - 2, + gripY - 1, + GuiColors.darken(currentThumbColor, 0.2f) + ); + graphics.fill( + thumbX + 2, + gripY + 1, + thumbX + THUMB_WIDTH - 2, + gripY + 2, + GuiColors.darken(currentThumbColor, 0.2f) + ); + + // Draw max label at top (within widget bounds) + String maxStr = String.format("+%.1f", maxValue); + int maxTextWidth = mc.font.width(maxStr); + graphics.drawString( + mc.font, + maxStr, + centerX - maxTextWidth / 2, + getY() + 2, + labelColor + ); + + // Draw min label at bottom (within widget bounds) + String minStr = String.format("%.1f", minValue); + int minTextWidth = mc.font.width(minStr); + graphics.drawString( + mc.font, + minStr, + centerX - minTextWidth / 2, + trackY + trackHeight + 2, + labelColor + ); + + // Draw current value text (below min label) + String valueStr = String.format("%.2f", value); + int valueTextWidth = mc.font.width(valueStr); + int valueColor = + Math.abs(value) < 0.01f ? GuiColors.TEXT_GRAY : textColor; + graphics.drawString( + mc.font, + valueStr, + centerX - valueTextWidth / 2, + getY() + height - GuiLayoutConstants.LINE_HEIGHT, + valueColor + ); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (isMouseOver(mouseX, mouseY) && button == 0) { + dragging = true; + updateValueFromMouse(mouseY); + return true; + } + return false; + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + if (button == 0) { + dragging = false; + } + return super.mouseReleased(mouseX, mouseY, button); + } + + @Override + public boolean mouseDragged( + double mouseX, + double mouseY, + int button, + double dragX, + double dragY + ) { + if (dragging && button == 0) { + updateValueFromMouse(mouseY); + return true; + } + return false; + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double delta) { + if (isMouseOver(mouseX, mouseY)) { + // Scroll up = increase value (positive delta) + setValue(value + (float) delta * step); + return true; + } + return false; + } + + /** + * Update value based on mouse Y position. + */ + private void updateValueFromMouse(double mouseY) { + // Calculate track area + int trackY = getY() + LABEL_SPACE; + int trackHeight = + height - LABEL_SPACE * 2 - GuiLayoutConstants.LINE_HEIGHT; + + // Account for thumb height + float usableHeight = trackHeight - THUMB_HEIGHT; + float relativeY = (float) (mouseY - trackY - THUMB_HEIGHT / 2.0); + + // Invert because screen Y increases downward + float normalized = 1 - Mth.clamp(relativeY / usableHeight, 0, 1); + + float newValue = minValue + normalized * (maxValue - minValue); + + // Optionally snap to step + newValue = Math.round(newValue / step) * step; + + setValue(newValue); + } + + /** + * Get the recommended widget height including labels. + * Use this when creating the slider to ensure proper sizing. + */ + public static int getRecommendedHeight(int trackHeight) { + return trackHeight + LABEL_SPACE * 2 + GuiLayoutConstants.LINE_HEIGHT; + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput output) { + output.add( + NarratedElementType.TITLE, + Component.translatable("gui.tiedup.adjustment_slider", value) + ); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/widgets/BountyEntryWidget.java b/src/main/java/com/tiedup/remake/client/gui/widgets/BountyEntryWidget.java new file mode 100644 index 0000000..fce321e --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/widgets/BountyEntryWidget.java @@ -0,0 +1,180 @@ +package com.tiedup.remake.client.gui.widgets; + +import com.google.common.collect.ImmutableList; +import com.tiedup.remake.bounty.Bounty; +import com.tiedup.remake.client.gui.util.GuiColors; +import com.tiedup.remake.client.gui.util.GuiLayoutConstants; +import java.util.List; +import java.util.function.Consumer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.ContainerObjectSelectionList; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.narration.NarratableEntry; +import net.minecraft.client.gui.narration.NarratedElementType; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * List Entry for BountyListScreen. + * Displays bounty details (target, reward, time). + * Refactored to be a ContainerObjectSelectionList.Entry. + */ +@OnlyIn(Dist.CLIENT) +public class BountyEntryWidget + extends ContainerObjectSelectionList.Entry +{ + + private final Bounty bounty; + private final Consumer onSelect; + private boolean selected = false; + + public BountyEntryWidget( + Bounty bounty, + Consumer onSelect + ) { + this.bounty = bounty; + this.onSelect = onSelect; + } + + public Bounty getBounty() { + return bounty; + } + + public void setSelected(boolean selected) { + this.selected = selected; + } + + @Override + public void render( + GuiGraphics graphics, + int index, + int top, + int left, + int width, + int height, + int mouseX, + int mouseY, + boolean hovering, + float partialTick + ) { + Minecraft mc = Minecraft.getInstance(); + Font font = mc.font; + + // Background + int bgColor = selected + ? GuiColors.ACCENT_BROWN + : (hovering ? GuiColors.BG_LIGHT : GuiColors.BG_DARK); + graphics.fill(left, top, left + width, top + height, bgColor); + + // Border + int borderColor = selected + ? GuiColors.ACCENT_TAN + : GuiColors.BORDER_LIGHT; + graphics.fill( + left, + top + height - 1, + left + width, + top + height, + borderColor + ); // Bottom separator + + // === LEFT: Reward item icon === + int iconX = left + GuiLayoutConstants.MARGIN_S; + int iconY = top + (height - 16) / 2; + ItemStack reward = bounty.getReward(); + if (!reward.isEmpty()) { + graphics.renderItem(reward, iconX, iconY); + graphics.renderItemDecorations(font, reward, iconX, iconY); + } + + // === CENTER: Bounty info === + int textX = + left + + GuiLayoutConstants.MARGIN_S + + 20 + + GuiLayoutConstants.MARGIN_M; + int textY = top + GuiLayoutConstants.MARGIN_S; + + // Line 1: Client + String clientLabel = Component.translatable( + "gui.tiedup.bounties.client", + bounty.getClientName() + ).getString(); + graphics.drawString( + font, + clientLabel, + textX, + textY, + GuiColors.TEXT_GRAY + ); + + // Line 2: Target (highlighted) + textY += GuiLayoutConstants.LINE_HEIGHT + 2; + String targetLabel = Component.translatable( + "gui.tiedup.bounties.target", + bounty.getTargetName() + ).getString(); + graphics.drawString( + font, + targetLabel, + textX, + textY, + GuiColors.TEXT_WHITE + ); + + // Line 3: Reward description + textY += GuiLayoutConstants.LINE_HEIGHT + 2; + String rewardLabel = Component.translatable( + "gui.tiedup.bounties.reward", + bounty.getRewardDescription() + ).getString(); + graphics.drawString( + font, + rewardLabel, + textX, + textY, + GuiColors.ACCENT_TAN + ); + + // Line 4: Time remaining + textY += GuiLayoutConstants.LINE_HEIGHT + 2; + int[] time = bounty.getRemainingTime(); + String timeLabel = Component.translatable( + "gui.tiedup.bounties.time", + String.valueOf(time[0]), + String.valueOf(time[1]) + ).getString(); + int timeColor = bounty.isExpired() + ? GuiColors.ERROR + : (time[0] == 0 && time[1] < 30 + ? GuiColors.WARNING + : GuiColors.TEXT_GRAY); + graphics.drawString(font, timeLabel, textX, textY, timeColor); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (button == 0) { + if (onSelect != null) { + onSelect.accept(this); + } + return true; + } + return false; + } + + @Override + public List children() { + return ImmutableList.of(); + } + + @Override + public List narratables() { + return ImmutableList.of(); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/widgets/CellListRenderer.java b/src/main/java/com/tiedup/remake/client/gui/widgets/CellListRenderer.java new file mode 100644 index 0000000..196857d --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/widgets/CellListRenderer.java @@ -0,0 +1,87 @@ +package com.tiedup.remake.client.gui.widgets; + +import com.tiedup.remake.client.gui.util.GuiColors; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Shared rendering utilities for cell list screens (CellManagerScreen, CellSelectorScreen). + * Extracts duplicated rendering logic for empty states and count badges. + */ +@OnlyIn(Dist.CLIENT) +public final class CellListRenderer { + + private CellListRenderer() { + // Utility class — no instantiation + } + + /** + * Renders a two-line empty state message centered in the list area. + * + * @param graphics the graphics context + * @param font the font renderer + * @param centerX horizontal center of the list area + * @param startY vertical start of the list area + * @param noItemsKey translation key for the "no items" message (line 1) + * @param hintKey translation key for the hint message (line 2, italic) + */ + public static void renderEmptyState( + GuiGraphics graphics, + Font font, + int centerX, + int startY, + String noItemsKey, + String hintKey + ) { + graphics.drawCenteredString( + font, + Component.translatable(noItemsKey) + .withStyle(ChatFormatting.GRAY), + centerX, + startY + 20, + GuiColors.TEXT_DISABLED + ); + graphics.drawCenteredString( + font, + Component.translatable(hintKey) + .withStyle(ChatFormatting.ITALIC), + centerX, + startY + 35, + GuiColors.TEXT_DISABLED + ); + } + + /** + * Renders a {@code [count/max]} badge right-aligned at the given position. + * Uses {@link GuiColors#WARNING} color when count reaches max capacity. + * + * @param graphics the graphics context + * @param font the font renderer + * @param count current prisoner count + * @param max maximum prisoner capacity + * @param rightX right edge X coordinate (badge is right-aligned to this minus 8px) + * @param y Y coordinate for the text + */ + public static void renderCountBadge( + GuiGraphics graphics, + Font font, + int count, + int max, + int rightX, + int y + ) { + String countStr = "[" + count + "/" + max + "]"; + int color = count >= max ? GuiColors.WARNING : GuiColors.TEXT_GRAY; + graphics.drawString( + font, + countStr, + rightX - font.width(countStr) - 8, + y, + color + ); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/widgets/EntityPreviewWidget.java b/src/main/java/com/tiedup/remake/client/gui/widgets/EntityPreviewWidget.java new file mode 100644 index 0000000..3100cf7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/widgets/EntityPreviewWidget.java @@ -0,0 +1,353 @@ +package com.tiedup.remake.client.gui.widgets; + +import com.mojang.blaze3d.systems.RenderSystem; +import com.tiedup.remake.client.gui.util.GuiColors; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.narration.NarratedElementType; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.client.gui.screens.inventory.InventoryScreen; +import net.minecraft.network.chat.Component; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.LivingEntity; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.joml.Quaternionf; + +/** + * Widget that displays a 3D preview of a LivingEntity. + * Supports mouse drag rotation and optional auto-rotation. + * + * Phase 16: GUI Revamp - Reusable entity preview widget + */ +@OnlyIn(Dist.CLIENT) +public class EntityPreviewWidget extends AbstractWidget { + + private LivingEntity entity; + private float rotationY = 0; + private float rotationX = 0; + private boolean isDragging = false; + private double lastMouseX, lastMouseY; + + // Auto-rotation + private boolean autoRotate = false; + private float autoRotateSpeed = 0.5f; + + // Zoom target (for per-tab camera zoom) + private float targetScale = 1.0f; + private float targetOffsetY = 0.0f; + private float currentScale = 1.0f; + private float currentOffsetY = 0.0f; + private static final float LERP_SPEED = 0.35f; // ~300ms to settle + + // Visual settings + private boolean drawBackground = true; + private int backgroundColor = GuiColors.BG_DARK; + private boolean drawBorder = true; + private int borderColor = GuiColors.BORDER_LIGHT; + + /** + * Create a new entity preview widget. + * + * @param x X position + * @param y Y position + * @param width Widget width + * @param height Widget height + * @param entity Entity to display + */ + public EntityPreviewWidget( + int x, + int y, + int width, + int height, + LivingEntity entity + ) { + super(x, y, width, height, Component.empty()); + this.entity = entity; + } + + /** + * Set the entity to display. + */ + public void setEntity(LivingEntity entity) { + this.entity = entity; + } + + /** + * Get the current entity. + */ + public LivingEntity getEntity() { + return entity; + } + + /** + * Enable/disable auto-rotation. + */ + public void setAutoRotate(boolean autoRotate) { + this.autoRotate = autoRotate; + } + + /** + * Set auto-rotation speed (degrees per frame). + */ + public void setAutoRotateSpeed(float speed) { + this.autoRotateSpeed = speed; + } + + /** + * Enable/disable background drawing. + */ + public void setDrawBackground(boolean drawBackground) { + this.drawBackground = drawBackground; + } + + /** + * Set background color. + */ + public void setBackgroundColor(int color) { + this.backgroundColor = color; + } + + /** + * Enable/disable border drawing. + */ + public void setDrawBorder(boolean drawBorder) { + this.drawBorder = drawBorder; + } + + /** + * Set rotation angles directly. + */ + public void setRotation(float yaw, float pitch) { + this.rotationY = yaw; + this.rotationX = Mth.clamp(pitch, -30, 30); + } + + /** + * Reset rotation to default (facing forward). + */ + public void resetRotation() { + this.rotationY = 0; + this.rotationX = 0; + } + + /** + * Refresh the preview (call after entity equipment changes). + */ + public void refresh() { + // The entity's equipment is read directly during render, + // so no special action needed here. This method exists + // for API consistency. + } + + /** + * Set the zoom target for animated transition. + * @param scale Scale multiplier (1.0 = full body, 1.8 = face zoom) + * @param offsetY Vertical offset (-0.6 = look up at head, +0.4 = look down at legs) + */ + public void setZoomTarget(float scale, float offsetY) { + this.targetScale = scale; + this.targetOffsetY = offsetY; + } + + /** + * Tick the zoom lerp animation. Call each frame from the parent screen. + */ + public void tickZoom() { + currentScale += (targetScale - currentScale) * LERP_SPEED; + currentOffsetY += (targetOffsetY - currentOffsetY) * LERP_SPEED; + } + + @Override + protected void renderWidget( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + if (entity == null) { + return; + } + + // Auto-rotation + if (autoRotate && !isDragging) { + rotationY += autoRotateSpeed; + } + + // Background + if (drawBackground) { + graphics.fill( + getX(), + getY(), + getX() + width, + getY() + height, + backgroundColor + ); + } + + // Border + if (drawBorder) { + // Top + graphics.fill( + getX(), + getY(), + getX() + width, + getY() + 1, + borderColor + ); + // Bottom + graphics.fill( + getX(), + getY() + height - 1, + getX() + width, + getY() + height, + borderColor + ); + // Left + graphics.fill( + getX(), + getY(), + getX() + 1, + getY() + height, + borderColor + ); + // Right + graphics.fill( + getX() + width - 1, + getY(), + getX() + width, + getY() + height, + borderColor + ); + } + + // Calculate entity render position — feet at widget bottom, fill height + int centerX = getX() + width / 2; + int entityScale = (int) (height / 1.95f); + int centerY = getY() + height - 4; + + // Enable scissor to clip entity to widget bounds + RenderSystem.enableScissor( + (int) (getX() * minecraft.getWindow().getGuiScale()), + (int) ((minecraft.getWindow().getGuiScaledHeight() - + getY() - + height) * + minecraft.getWindow().getGuiScale()), + (int) (width * minecraft.getWindow().getGuiScale()), + (int) (height * minecraft.getWindow().getGuiScale()) + ); + + // Save original entity rotations + float origBodyRot = entity.yBodyRot; + float origHeadRot = entity.yHeadRot; + float origYRot = entity.getYRot(); + float origXRot = entity.getXRot(); + float origYHeadRotO = entity.yHeadRotO; + float origYBodyRotO = entity.yBodyRotO; + float origXRotO = entity.xRotO; + + // Set neutral pose (facing forward, not looking up/down) + entity.yBodyRot = 180f + rotationY; // Face the camera + user rotation + entity.yHeadRot = 180f + rotationY; // Head same as body + entity.setYRot(180f + rotationY); + entity.setXRot(rotationX); // User-controlled pitch + entity.yHeadRotO = entity.yHeadRot; + entity.yBodyRotO = entity.yBodyRot; + entity.xRotO = entity.getXRot(); // Prevent pitch interpolation + + // Render entity using renderEntityInInventory with Quaternionf + // X rotation flips upright, Y rotation faces camera + Quaternionf poseRotation = new Quaternionf() + .rotationX((float) Math.PI) // Flip upright + .rotateY((float) Math.PI); // Face camera + Quaternionf cameraRotation = new Quaternionf(); + + InventoryScreen.renderEntityInInventory( + graphics, + centerX, + centerY, + entityScale, + poseRotation, + cameraRotation, + entity + ); + + // Restore original rotations + entity.yBodyRot = origBodyRot; + entity.yHeadRot = origHeadRot; + entity.setYRot(origYRot); + entity.setXRot(origXRot); + entity.yHeadRotO = origYHeadRotO; + entity.yBodyRotO = origYBodyRotO; + entity.xRotO = origXRotO; + + RenderSystem.disableScissor(); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (isMouseOver(mouseX, mouseY) && button == 0) { + isDragging = true; + lastMouseX = mouseX; + lastMouseY = mouseY; + return true; + } + return false; + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + if (button == 0) { + isDragging = false; + } + return super.mouseReleased(mouseX, mouseY, button); + } + + @Override + public boolean mouseDragged( + double mouseX, + double mouseY, + int button, + double dragX, + double dragY + ) { + if (isDragging && button == 0) { + rotationY += (float) (mouseX - lastMouseX) * 0.8f; + rotationX = Mth.clamp( + rotationX + (float) (mouseY - lastMouseY) * 0.5f, + -30, + 30 + ); + lastMouseX = mouseX; + lastMouseY = mouseY; + return true; + } + return false; + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double delta) { + if (isMouseOver(mouseX, mouseY)) { + // Could be used for zoom in the future + return true; + } + return false; + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput output) { + // Narration for accessibility + if (entity != null) { + output.add( + NarratedElementType.TITLE, + Component.translatable("gui.tiedup.preview", entity.getName()) + ); + } + } + + /** + * Get the current Minecraft instance (from AbstractWidget). + */ + private net.minecraft.client.Minecraft minecraft = + net.minecraft.client.Minecraft.getInstance(); +} diff --git a/src/main/java/com/tiedup/remake/client/gui/widgets/ItemPickerOverlay.java b/src/main/java/com/tiedup/remake/client/gui/widgets/ItemPickerOverlay.java new file mode 100644 index 0000000..4622f9b --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/widgets/ItemPickerOverlay.java @@ -0,0 +1,284 @@ +package com.tiedup.remake.client.gui.widgets; + +import com.tiedup.remake.client.gui.util.GuiRenderUtil; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiConsumer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.narration.NarratedElementType; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Modal overlay for selecting a bondage item to equip on a body region. + * Filters the player's inventory for compatible IV2BondageItem instances. + */ +@OnlyIn(Dist.CLIENT) +public class ItemPickerOverlay extends AbstractWidget { + + private record PickerEntry(ItemStack stack, int inventorySlot) {} + + private static final int ENTRY_HEIGHT = 28; + private static final int PADDING = 10; + private static final int CANCEL_BTN_HEIGHT = 22; + private static final int CANCEL_BTN_WIDTH = 80; + private static final int OVERLAY_BG = 0xCC000000; // Semi-transparent black + private static final int WARNING_COLOR = 0xFFF44336; + + private BodyRegionV2 targetRegion; + private final List entries = new ArrayList<>(); + private BiConsumer onItemSelected; // region, inventorySlot + private Runnable onCancelled; + private boolean visible = false; + + // ARMS self-equip warning: click-twice-to-confirm + private int armsWarningSlot = -1; // -1 = no warning shown + private boolean isSelfMode; + + // Scroll + private int scrollOffset = 0; + + // Screen dimensions for overlay sizing + private int screenWidth; + private int screenHeight; + + public ItemPickerOverlay() { + super(0, 0, 0, 0, Component.literal("Item Picker")); + this.active = false; + this.visible = false; + } + + public void setOnItemSelected(BiConsumer cb) { this.onItemSelected = cb; } + public void setOnCancelled(Runnable cb) { this.onCancelled = cb; } + public boolean isOverlayVisible() { return visible; } + + /** + * Open the picker overlay for a specific region. + */ + public void open(BodyRegionV2 region, boolean selfMode, int screenWidth, int screenHeight) { + this.targetRegion = region; + this.isSelfMode = selfMode; + this.screenWidth = screenWidth; + this.screenHeight = screenHeight; + this.scrollOffset = 0; + this.armsWarningSlot = -1; + scanInventory(); + this.visible = true; + this.active = true; + } + + public void close() { + this.visible = false; + this.active = false; + entries.clear(); + } + + private void scanInventory() { + entries.clear(); + Player player = Minecraft.getInstance().player; + if (player == null || targetRegion == null) return; + + for (int i = 0; i < player.getInventory().getContainerSize(); i++) { + ItemStack stack = player.getInventory().getItem(i); + if (stack.isEmpty()) continue; + if (!(stack.getItem() instanceof IV2BondageItem bondageItem)) continue; + if (!bondageItem.getOccupiedRegions(stack).contains(targetRegion)) continue; + entries.add(new PickerEntry(stack, i)); + } + } + + // Panel bounds (centered on screen) + private int getPanelWidth() { return Math.min(280, screenWidth - 40); } + private int getPanelHeight() { + int contentH = entries.size() * ENTRY_HEIGHT + CANCEL_BTN_HEIGHT + PADDING * 3 + 20; + return Math.min(contentH, screenHeight - 60); + } + private int getPanelX() { return (screenWidth - getPanelWidth()) / 2; } + private int getPanelY() { return (screenHeight - getPanelHeight()) / 2; } + + @Override + protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + if (!visible) return; + Minecraft mc = Minecraft.getInstance(); + + // Full-screen overlay + graphics.fill(0, 0, screenWidth, screenHeight, OVERLAY_BG); + + int panelX = getPanelX(); + int panelY = getPanelY(); + int panelW = getPanelWidth(); + int panelH = getPanelHeight(); + + // MC-style raised panel + GuiRenderUtil.drawMCPanel(graphics, panelX, panelY, panelW, panelH); + + // Title (dark text, vanilla style) + String title = Component.translatable("gui.tiedup.picker.title", + Component.translatable("gui.tiedup.region." + targetRegion.name().toLowerCase())).getString(); + graphics.drawString(mc.font, title, panelX + PADDING, panelY + PADDING, GuiRenderUtil.MC_TEXT_DARK, false); + + // Entries + int listY = panelY + PADDING + mc.font.lineHeight + 8; + int maxVisible = (panelH - PADDING * 3 - mc.font.lineHeight - 8 - CANCEL_BTN_HEIGHT) / ENTRY_HEIGHT; + + for (int i = 0; i < Math.min(entries.size(), maxVisible); i++) { + int idx = i + scrollOffset; + if (idx >= entries.size()) break; + PickerEntry entry = entries.get(idx); + int entryY = listY + i * ENTRY_HEIGHT; + int entryX = panelX + PADDING; + int entryW = panelW - PADDING * 2; + + boolean hovered = mouseX >= entryX && mouseX < entryX + entryW + && mouseY >= entryY && mouseY < entryY + ENTRY_HEIGHT; + boolean isArmsWarning = (armsWarningSlot == entry.inventorySlot); + + // MC-style slot for each entry + GuiRenderUtil.drawMCSlot(graphics, entryX, entryY, entryW, ENTRY_HEIGHT - 2); + + // Gold border for ARMS warning confirmation + if (isArmsWarning) { + GuiRenderUtil.drawSelectedBorder(graphics, entryX, entryY, entryW, ENTRY_HEIGHT - 2); + } + + // Vanilla hover overlay (white semi-transparent) + if (hovered && !isArmsWarning) { + GuiRenderUtil.drawSlotHover(graphics, entryX, entryY, entryW, ENTRY_HEIGHT - 2); + } + + // Item icon (16x16) + graphics.renderItem(entry.stack, entryX + 4, entryY + (ENTRY_HEIGHT - 18) / 2); + + // Item name + String name = entry.stack.getHoverName().getString(); + graphics.drawString(mc.font, name, entryX + 24, entryY + (ENTRY_HEIGHT - mc.font.lineHeight) / 2, + GuiRenderUtil.MC_TEXT_DARK, false); + } + + // ARMS warning text + if (armsWarningSlot >= 0) { + String warning = Component.translatable("gui.tiedup.picker.arms_warning").getString(); + int warningY = listY + Math.min(entries.size(), maxVisible) * ENTRY_HEIGHT + 2; + graphics.drawString(mc.font, warning, panelX + PADDING, warningY, WARNING_COLOR, false); + } + + // Empty state + if (entries.isEmpty()) { + String empty = Component.translatable("gui.tiedup.picker.empty").getString(); + GuiRenderUtil.drawCenteredStringNoShadow(graphics, mc.font, empty, + panelX + panelW / 2, listY + 20, GuiRenderUtil.MC_TEXT_GRAY); + } + + // Cancel button (MC-style) + int cancelX = panelX + (panelW - CANCEL_BTN_WIDTH) / 2; + int cancelY = panelY + panelH - CANCEL_BTN_HEIGHT - PADDING; + boolean cancelHovered = mouseX >= cancelX && mouseX < cancelX + CANCEL_BTN_WIDTH + && mouseY >= cancelY && mouseY < cancelY + CANCEL_BTN_HEIGHT; + GuiRenderUtil.drawMCButton(graphics, cancelX, cancelY, CANCEL_BTN_WIDTH, CANCEL_BTN_HEIGHT, cancelHovered, true); + String cancelText = Component.translatable("gui.tiedup.cancel").getString(); + GuiRenderUtil.drawCenteredStringNoShadow(graphics, mc.font, cancelText, + cancelX + CANCEL_BTN_WIDTH / 2, cancelY + (CANCEL_BTN_HEIGHT - mc.font.lineHeight) / 2, + GuiRenderUtil.MC_TEXT_DARK); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (!visible || button != 0) return false; + Minecraft mc = Minecraft.getInstance(); + + int panelX = getPanelX(); + int panelY = getPanelY(); + int panelW = getPanelWidth(); + int panelH = getPanelHeight(); + + // Cancel button + int cancelX = panelX + (panelW - CANCEL_BTN_WIDTH) / 2; + int cancelY = panelY + panelH - CANCEL_BTN_HEIGHT - PADDING; + if (mouseX >= cancelX && mouseX < cancelX + CANCEL_BTN_WIDTH + && mouseY >= cancelY && mouseY < cancelY + CANCEL_BTN_HEIGHT) { + close(); + if (onCancelled != null) onCancelled.run(); + return true; + } + + // Entry clicks + int listY = panelY + PADDING + mc.font.lineHeight + 8; + int maxVisible = (panelH - PADDING * 3 - mc.font.lineHeight - 8 - CANCEL_BTN_HEIGHT) / ENTRY_HEIGHT; + + for (int i = 0; i < Math.min(entries.size(), maxVisible); i++) { + int idx = i + scrollOffset; + if (idx >= entries.size()) break; + PickerEntry entry = entries.get(idx); + int entryY = listY + i * ENTRY_HEIGHT; + int entryX = panelX + PADDING; + int entryW = panelW - PADDING * 2; + + if (mouseX >= entryX && mouseX < entryX + entryW + && mouseY >= entryY && mouseY < entryY + ENTRY_HEIGHT) { + + // ARMS self-equip warning: double-click confirmation + if (isSelfMode && targetRegion == BodyRegionV2.ARMS) { + if (armsWarningSlot == entry.inventorySlot) { + // Second click — confirm + if (onItemSelected != null) onItemSelected.accept(targetRegion, entry.inventorySlot); + close(); + } else { + // First click — show warning + armsWarningSlot = entry.inventorySlot; + } + } else { + if (onItemSelected != null) onItemSelected.accept(targetRegion, entry.inventorySlot); + close(); + } + playDownSound(mc.getSoundManager()); + return true; + } + } + + // Click outside panel = cancel + if (mouseX < panelX || mouseX > panelX + panelW + || mouseY < panelY || mouseY > panelY + panelH) { + close(); + if (onCancelled != null) onCancelled.run(); + return true; + } + + return false; + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double delta) { + if (!visible) return false; + int panelH = getPanelHeight(); + int maxVisible = (panelH - PADDING * 3 - Minecraft.getInstance().font.lineHeight - 8 - CANCEL_BTN_HEIGHT) / ENTRY_HEIGHT; + int maxScroll = Math.max(0, entries.size() - maxVisible); + scrollOffset = Math.max(0, Math.min(maxScroll, scrollOffset - (int) delta)); + return true; + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (!visible) return false; + // ESC closes overlay + if (keyCode == 256) { // GLFW_KEY_ESCAPE + close(); + if (onCancelled != null) onCancelled.run(); + return true; + } + return false; + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput output) { + output.add(NarratedElementType.TITLE, Component.translatable("gui.tiedup.picker.title", + targetRegion != null ? Component.translatable("gui.tiedup.region." + targetRegion.name().toLowerCase()) : "")); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/widgets/RegionSlotWidget.java b/src/main/java/com/tiedup/remake/client/gui/widgets/RegionSlotWidget.java new file mode 100644 index 0000000..e4ee49b --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/widgets/RegionSlotWidget.java @@ -0,0 +1,506 @@ +package com.tiedup.remake.client.gui.widgets; + +import com.tiedup.remake.client.gui.util.GuiRenderUtil; +import com.tiedup.remake.items.base.IHasResistance; +import com.tiedup.remake.v2.BodyRegionV2; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.narration.NarratedElementType; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * V2 widget representing a single body region equipment slot. + * Uses {@link BodyRegionV2} for body region mapping. + * + * Epic 6A: V2 migration of BondageSlotWidget. + */ +@OnlyIn(Dist.CLIENT) +public class RegionSlotWidget extends AbstractWidget { + + private final BodyRegionV2 region; + private final Supplier itemGetter; + private Consumer onClick; + private Consumer onAdjustClick; + private Consumer onRemoveClick; + private Consumer onEquipClick; + + // Visual settings + private boolean showAdjustButton = false; + private boolean showRemoveButton = false; + private boolean showEquipButton = false; + private boolean selected = false; + + // Layout constants + private static final int ICON_SIZE = 16; + private static final int PADDING = 4; + private static final int ADJUST_BUTTON_SIZE = 14; + private static final int REMOVE_BUTTON_SIZE = 14; + private static final int EQUIP_BUTTON_WIDTH = 50; + private static final int EQUIP_BUTTON_HEIGHT = 16; + private static final int RESISTANCE_BAR_WIDTH = 50; + private static final int RESISTANCE_BAR_HEIGHT = 4; + + /** + * Create a new region slot widget. + * + * @param x X position + * @param y Y position + * @param width Widget width + * @param height Widget height + * @param region The V2 body region + * @param itemGetter Supplier that returns the current ItemStack in this region + */ + public RegionSlotWidget( + int x, + int y, + int width, + int height, + BodyRegionV2 region, + Supplier itemGetter + ) { + super( + x, + y, + width, + height, + Component.translatable( + "gui.tiedup.region." + region.name().toLowerCase() + ) + ); + this.region = region; + this.itemGetter = itemGetter; + } + + /** + * Set click handler for the main slot area. + */ + public void setOnClick(Consumer onClick) { + this.onClick = onClick; + } + + /** + * Set click handler for the adjust button. + */ + public void setOnAdjustClick(Consumer onAdjustClick) { + this.onAdjustClick = onAdjustClick; + } + + /** + * Enable/disable the adjust button (for gags/blindfolds). + */ + public void setShowAdjustButton(boolean show) { + this.showAdjustButton = show; + } + + /** + * Set click handler for the remove button. + */ + public void setOnRemoveClick(Consumer onRemoveClick) { + this.onRemoveClick = onRemoveClick; + } + + /** + * Enable/disable the remove button. + */ + public void setShowRemoveButton(boolean show) { + this.showRemoveButton = show; + } + + /** + * Set click handler for the equip button (shown for empty slots). + */ + public void setOnEquipClick(Consumer onEquipClick) { + this.onEquipClick = onEquipClick; + } + + /** + * Enable/disable the equip button for empty slots. + */ + public void setShowEquipButton(boolean show) { + this.showEquipButton = show; + } + + /** + * Set selected state. + */ + public void setSelected(boolean selected) { + this.selected = selected; + } + + /** + * Get the body region. + */ + public BodyRegionV2 getRegion() { + return region; + } + + /** + * Get the current item in this region. + */ + public ItemStack getItem() { + return itemGetter.get(); + } + + /** + * Check if adjust button is being hovered. + */ + private boolean isAdjustButtonHovered(double mouseX, double mouseY) { + if (!showAdjustButton) return false; + + int buttonX = getAdjustButtonX(); + int buttonY = getY() + (height - ADJUST_BUTTON_SIZE) / 2; + + return ( + mouseX >= buttonX && + mouseX < buttonX + ADJUST_BUTTON_SIZE && + mouseY >= buttonY && + mouseY < buttonY + ADJUST_BUTTON_SIZE + ); + } + + /** + * Check if remove button is being hovered. + */ + private boolean isRemoveButtonHovered(double mouseX, double mouseY) { + if (!showRemoveButton) return false; + + int buttonX = getRemoveButtonX(); + int buttonY = getY() + (height - REMOVE_BUTTON_SIZE) / 2; + + return ( + mouseX >= buttonX && + mouseX < buttonX + REMOVE_BUTTON_SIZE && + mouseY >= buttonY && + mouseY < buttonY + REMOVE_BUTTON_SIZE + ); + } + + /** + * Get X position for adjust button. + */ + private int getAdjustButtonX() { + if (showRemoveButton) { + return ( + getX() + + width - + ADJUST_BUTTON_SIZE - + PADDING - + REMOVE_BUTTON_SIZE - + PADDING + ); + } + return getX() + width - ADJUST_BUTTON_SIZE - PADDING; + } + + /** + * Get X position for remove button (always rightmost). + */ + private int getRemoveButtonX() { + return getX() + width - REMOVE_BUTTON_SIZE - PADDING; + } + + /** + * Get X position for the equip button (right-aligned in empty slot). + */ + private int getEquipButtonX() { + return getX() + width - EQUIP_BUTTON_WIDTH - PADDING; + } + + /** + * Get Y position for the equip button (centered vertically). + */ + private int getEquipButtonY() { + return getY() + (height - EQUIP_BUTTON_HEIGHT) / 2; + } + + /** + * Check if the equip button is being hovered. + */ + private boolean isEquipButtonHovered(double mouseX, double mouseY) { + if (!showEquipButton || !itemGetter.get().isEmpty()) return false; + int bx = getEquipButtonX(); + int by = getEquipButtonY(); + return mouseX >= bx && mouseX < bx + EQUIP_BUTTON_WIDTH + && mouseY >= by && mouseY < by + EQUIP_BUTTON_HEIGHT; + } + + @Override + protected void renderWidget( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick + ) { + Minecraft mc = Minecraft.getInstance(); + ItemStack stack = itemGetter.get(); + boolean hasItem = !stack.isEmpty(); + boolean hovered = isMouseOver(mouseX, mouseY); + boolean adjustHovered = isAdjustButtonHovered(mouseX, mouseY); + boolean removeHovered = isRemoveButtonHovered(mouseX, mouseY); + boolean equipHovered = isEquipButtonHovered(mouseX, mouseY); + boolean anyButtonHovered = adjustHovered || removeHovered || equipHovered; + + // MC-style sunken slot background + GuiRenderUtil.drawMCSlot(graphics, getX(), getY(), width, height); + + // Selected: gold highlight border on top of the slot + if (selected) { + GuiRenderUtil.drawSelectedBorder(graphics, getX(), getY(), width, height); + } + + // Hover overlay (vanilla white semi-transparent) + if (hovered && !anyButtonHovered && !selected) { + GuiRenderUtil.drawSlotHover(graphics, getX(), getY(), width, height); + } + + // Region icon (uniform dark gray square) + int iconX = getX() + PADDING; + int iconY = getY() + (height - ICON_SIZE) / 2; + graphics.fill( + iconX, + iconY, + iconX + ICON_SIZE, + iconY + ICON_SIZE, + 0xFF555555 + ); + + // Region label + String typeLabel = getSlotLabel(); + int labelX = iconX + ICON_SIZE + PADDING; + int labelY = getY() + PADDING; + graphics.drawString( + mc.font, + typeLabel, + labelX, + labelY, + GuiRenderUtil.MC_TEXT_GRAY, + false + ); + + // Item name or "(empty)" + Component itemText; + int itemTextColor; + if (hasItem) { + itemText = stack.getHoverName(); + itemTextColor = GuiRenderUtil.MC_TEXT_DARK; + } else { + itemText = Component.translatable("gui.tiedup.empty").withStyle( + ChatFormatting.ITALIC + ); + itemTextColor = 0xFF808080; + } + + int textY = labelY + mc.font.lineHeight + 2; + int maxTextWidth = width - ICON_SIZE - PADDING * 3; + if (showAdjustButton && hasItem) { + maxTextWidth -= ADJUST_BUTTON_SIZE + PADDING; + } + if (showRemoveButton && hasItem) { + maxTextWidth -= REMOVE_BUTTON_SIZE + PADDING; + } + + // Trim text if too long + String trimmedText = mc.font.plainSubstrByWidth( + itemText.getString(), + maxTextWidth + ); + graphics.drawString( + mc.font, + trimmedText, + labelX, + textY, + itemTextColor, + false + ); + + // Resistance bar (for items that implement IHasResistance) + if (hasItem && stack.getItem() instanceof IHasResistance resistanceItem) { + Player player = mc.player; + if (player != null) { + int current = resistanceItem.getCurrentResistance(stack, player); + int base = resistanceItem.getBaseResistance(player); + if (base > 0) { + float ratio = Math.max(0f, Math.min(1f, (float) current / base)); + int barX = getX() + width - RESISTANCE_BAR_WIDTH - PADDING; + int barY = getY() + height - RESISTANCE_BAR_HEIGHT - PADDING; + // Sunken bar background + graphics.fill(barX, barY, barX + RESISTANCE_BAR_WIDTH, barY + RESISTANCE_BAR_HEIGHT, 0xFF373737); + // Colored fill: red below 30%, green otherwise + int fillColor = (ratio < 0.30f) ? 0xFFFF4444 : 0xFF44CC44; + int fillWidth = Math.round(RESISTANCE_BAR_WIDTH * ratio); + if (fillWidth > 0) { + graphics.fill(barX, barY, barX + fillWidth, barY + RESISTANCE_BAR_HEIGHT, fillColor); + } + } + } + } + + // Equip button for empty slots (MC-style button) + if (!hasItem && showEquipButton) { + int bx = getEquipButtonX(); + int by = getEquipButtonY(); + GuiRenderUtil.drawMCButton(graphics, bx, by, EQUIP_BUTTON_WIDTH, EQUIP_BUTTON_HEIGHT, equipHovered, true); + String equipLabel = Component.translatable("gui.tiedup.equip").getString(); + GuiRenderUtil.drawCenteredStringNoShadow( + graphics, + mc.font, + equipLabel, + bx + EQUIP_BUTTON_WIDTH / 2, + by + (EQUIP_BUTTON_HEIGHT - mc.font.lineHeight) / 2, + GuiRenderUtil.MC_TEXT_DARK + ); + } + + // Adjust button (for gags/blindfolds) — MC-style small button + if (showAdjustButton && hasItem) { + int buttonX = getAdjustButtonX(); + int buttonY = getY() + (height - ADJUST_BUTTON_SIZE) / 2; + GuiRenderUtil.drawMCButton(graphics, buttonX, buttonY, ADJUST_BUTTON_SIZE, ADJUST_BUTTON_SIZE, adjustHovered, true); + + // Gear icon placeholder + GuiRenderUtil.drawCenteredStringNoShadow( + graphics, + mc.font, + "\u2699", + buttonX + ADJUST_BUTTON_SIZE / 2, + buttonY + (ADJUST_BUTTON_SIZE - mc.font.lineHeight) / 2 + 1, + GuiRenderUtil.MC_TEXT_DARK + ); + } + + // Remove button — MC-style with red tint + if (showRemoveButton && hasItem) { + int buttonX = getRemoveButtonX(); + int buttonY = getY() + (height - REMOVE_BUTTON_SIZE) / 2; + GuiRenderUtil.drawMCButton(graphics, buttonX, buttonY, REMOVE_BUTTON_SIZE, REMOVE_BUTTON_SIZE, removeHovered, true); + // Red-tinted overlay + graphics.fill(buttonX + 1, buttonY + 1, buttonX + REMOVE_BUTTON_SIZE - 1, buttonY + REMOVE_BUTTON_SIZE - 1, + removeHovered ? 0x40FF0000 : 0x20FF0000); + + // X icon + GuiRenderUtil.drawCenteredStringNoShadow( + graphics, + mc.font, + "X", + buttonX + REMOVE_BUTTON_SIZE / 2, + buttonY + (REMOVE_BUTTON_SIZE - mc.font.lineHeight) / 2 + 1, + 0xFFCC4444 + ); + } + + // Tooltip on hover + if (hovered && hasItem && !anyButtonHovered) { + renderTooltip(graphics, mouseX, mouseY, stack); + } + } + + /** + * Get the slot label text from the region's translation key. + */ + private String getSlotLabel() { + return Component.translatable( + "gui.tiedup.region." + region.name().toLowerCase() + ).getString(); + } + + /** + * Render item tooltip. + */ + private void renderTooltip( + GuiGraphics graphics, + int mouseX, + int mouseY, + ItemStack stack + ) { + Minecraft mc = Minecraft.getInstance(); + List tooltip = new ArrayList<>(); + + // Item name + tooltip.add(stack.getHoverName()); + + // Add region info + tooltip.add( + Component.translatable( + "gui.tiedup.region." + region.name().toLowerCase() + ).withStyle(ChatFormatting.GRAY) + ); + + graphics.renderTooltip( + mc.font, + tooltip, + java.util.Optional.empty(), + mouseX, + mouseY + ); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (isMouseOver(mouseX, mouseY) && button == 0) { + // Check if equip button was clicked (empty slot only) + if (isEquipButtonHovered(mouseX, mouseY)) { + if (onEquipClick != null) { + onEquipClick.accept(this); + playDownSound(Minecraft.getInstance().getSoundManager()); + return true; + } + } + // Check if remove button was clicked + else if (isRemoveButtonHovered(mouseX, mouseY)) { + if (onRemoveClick != null) { + onRemoveClick.accept(this); + playDownSound(Minecraft.getInstance().getSoundManager()); + return true; + } + } + // Check if adjust button was clicked + else if (isAdjustButtonHovered(mouseX, mouseY)) { + if (onAdjustClick != null) { + onAdjustClick.accept(this); + playDownSound(Minecraft.getInstance().getSoundManager()); + return true; + } + } else { + // Main slot click + if (onClick != null) { + onClick.accept(this); + playDownSound(Minecraft.getInstance().getSoundManager()); + return true; + } + } + } + return false; + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput output) { + ItemStack stack = itemGetter.get(); + Component narration; + + if (stack.isEmpty()) { + narration = Component.translatable( + "gui.tiedup.slot_empty", + getSlotLabel() + ); + } else { + narration = Component.translatable( + "gui.tiedup.slot_item", + getSlotLabel(), + stack.getHoverName() + ); + } + + output.add(NarratedElementType.TITLE, narration); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/widgets/RegionTabBar.java b/src/main/java/com/tiedup/remake/client/gui/widgets/RegionTabBar.java new file mode 100644 index 0000000..875b78d --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/widgets/RegionTabBar.java @@ -0,0 +1,169 @@ +package com.tiedup.remake.client.gui.widgets; + +import com.tiedup.remake.client.gui.util.GuiRenderUtil; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.EnumSet; +import java.util.Set; +import java.util.function.Consumer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.narration.NarratedElementType; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.LivingEntity; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Tab bar for the unified bondage screen. 5 body-zone tabs with occupied indicators. + */ +@OnlyIn(Dist.CLIENT) +public class RegionTabBar extends AbstractWidget { + + /** + * Body zone tabs grouping the 14 BodyRegionV2 values. + */ + public enum BodyTab { + HEAD("gui.tiedup.tab.head", BodyRegionV2.HEAD, BodyRegionV2.EYES, BodyRegionV2.EARS, BodyRegionV2.MOUTH), + UPPER("gui.tiedup.tab.upper", BodyRegionV2.NECK, BodyRegionV2.TORSO), + ARMS("gui.tiedup.tab.arms", BodyRegionV2.ARMS, BodyRegionV2.HANDS, BodyRegionV2.FINGERS), + LOWER("gui.tiedup.tab.lower", BodyRegionV2.WAIST, BodyRegionV2.LEGS, BodyRegionV2.FEET), + SPECIAL("gui.tiedup.tab.special", BodyRegionV2.TAIL, BodyRegionV2.WINGS); + + private final String translationKey; + private final Set regions; + + BodyTab(String translationKey, BodyRegionV2... regions) { + this.translationKey = translationKey; + this.regions = EnumSet.noneOf(BodyRegionV2.class); + for (BodyRegionV2 r : regions) this.regions.add(r); + } + + public String getTranslationKey() { return translationKey; } + public Set getRegions() { return regions; } + + /** Check if any region in this tab has an equipped item on the entity. */ + public boolean hasEquippedItems(LivingEntity entity) { + for (BodyRegionV2 region : regions) { + if (V2EquipmentHelper.isRegionOccupied(entity, region)) return true; + } + return false; + } + } + + // Visual constants + private static final int TAB_HEIGHT = 26; + private static final int TAB_SPACING = 4; + private static final int DOT_RADIUS = 3; + + // Colors (vanilla MC style) + private static final int BG_ACTIVE = 0xFFC6C6C6; // Same as main panel + private static final int BG_INACTIVE = 0xFF8B8B8B; // Darker, slot-like + private static final int BG_HOVER = 0xFFA0A0A0; // Between active and inactive + private static final int TEXT_ACTIVE = 0xFF404040; // Dark text + private static final int TEXT_INACTIVE = 0xFF555555; // Gray text + private static final int BAR_BG = 0xFFA0A0A0; + + private BodyTab activeTab = BodyTab.HEAD; + private Consumer onTabChanged; + private LivingEntity targetEntity; + + public RegionTabBar(int x, int y, int width) { + super(x, y, width, TAB_HEIGHT, Component.literal("Tab Bar")); + } + + public void setOnTabChanged(Consumer callback) { this.onTabChanged = callback; } + public void setTargetEntity(LivingEntity entity) { this.targetEntity = entity; } + public BodyTab getActiveTab() { return activeTab; } + + public void setActiveTab(BodyTab tab) { + if (this.activeTab != tab) { + this.activeTab = tab; + if (onTabChanged != null) onTabChanged.accept(tab); + } + } + + @Override + protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + Minecraft mc = Minecraft.getInstance(); + + // Background bar + graphics.fill(getX(), getY(), getX() + width, getY() + height, BAR_BG); + + BodyTab[] tabs = BodyTab.values(); + int tabWidth = (width - TAB_SPACING * (tabs.length - 1)) / tabs.length; + + for (int i = 0; i < tabs.length; i++) { + BodyTab tab = tabs[i]; + int tabX = getX() + i * (tabWidth + TAB_SPACING); + boolean isActive = (tab == activeTab); + boolean isHovered = mouseX >= tabX && mouseX < tabX + tabWidth + && mouseY >= getY() && mouseY < getY() + height; + + int bgColor = isActive ? BG_ACTIVE : (isHovered ? BG_HOVER : BG_INACTIVE); + graphics.fill(tabX, getY(), tabX + tabWidth, getY() + height, bgColor); + + if (isActive) { + // Active tab: raised 3D look, no bottom border (connects to panel below) + // Top highlight + graphics.fill(tabX, getY(), tabX + tabWidth, getY() + 1, GuiRenderUtil.MC_HIGHLIGHT_OUTER); + // Left highlight + graphics.fill(tabX, getY(), tabX + 1, getY() + height, GuiRenderUtil.MC_HIGHLIGHT_OUTER); + // Right shadow + graphics.fill(tabX + tabWidth - 1, getY(), tabX + tabWidth, getY() + height, GuiRenderUtil.MC_SHADOW_OUTER); + } else { + // Inactive tab: full 3D sunken borders + // Top shadow + graphics.fill(tabX, getY(), tabX + tabWidth, getY() + 1, GuiRenderUtil.MC_SHADOW_OUTER); + // Left shadow + graphics.fill(tabX, getY(), tabX + 1, getY() + height, GuiRenderUtil.MC_SHADOW_OUTER); + // Bottom highlight + graphics.fill(tabX, getY() + height - 1, tabX + tabWidth, getY() + height, GuiRenderUtil.MC_HIGHLIGHT_OUTER); + // Right highlight + graphics.fill(tabX + tabWidth - 1, getY(), tabX + tabWidth, getY() + height, GuiRenderUtil.MC_HIGHLIGHT_OUTER); + } + + // Tab label + String label = Component.translatable(tab.getTranslationKey()).getString(); + int textColor = isActive ? TEXT_ACTIVE : TEXT_INACTIVE; + int textX = tabX + (tabWidth - mc.font.width(label)) / 2; + int textY = getY() + (height - mc.font.lineHeight) / 2; + graphics.drawString(mc.font, label, textX, textY, textColor, false); + + // Occupied dot indicator (top-right corner of tab) — white/light gray + if (targetEntity != null && tab.hasEquippedItems(targetEntity)) { + int dotX = tabX + tabWidth - DOT_RADIUS - 4; + int dotY = getY() + 4; + graphics.fill(dotX - DOT_RADIUS, dotY - DOT_RADIUS, + dotX + DOT_RADIUS, dotY + DOT_RADIUS, 0xFFCCCCCC); + } + } + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (!isMouseOver(mouseX, mouseY) || button != 0) return false; + + BodyTab[] tabs = BodyTab.values(); + int tabWidth = (width - TAB_SPACING * (tabs.length - 1)) / tabs.length; + + for (int i = 0; i < tabs.length; i++) { + int tabX = getX() + i * (tabWidth + TAB_SPACING); + if (mouseX >= tabX && mouseX < tabX + tabWidth) { + setActiveTab(tabs[i]); + playDownSound(Minecraft.getInstance().getSoundManager()); + return true; + } + } + return false; + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput output) { + output.add(NarratedElementType.TITLE, + Component.translatable("gui.tiedup.tab_bar", + Component.translatable(activeTab.getTranslationKey()))); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/widgets/SlaveEntryWidget.java b/src/main/java/com/tiedup/remake/client/gui/widgets/SlaveEntryWidget.java new file mode 100644 index 0000000..634403c --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/widgets/SlaveEntryWidget.java @@ -0,0 +1,625 @@ +package com.tiedup.remake.client.gui.widgets; + +import static com.tiedup.remake.client.gui.util.GuiLayoutConstants.*; +import com.tiedup.remake.v2.BodyRegionV2; + +import com.google.common.collect.ImmutableList; +import com.tiedup.remake.client.gui.util.GuiColors; +import com.tiedup.remake.items.ItemGpsCollar; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.state.IBondageState; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.ContainerObjectSelectionList; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.narration.NarratableEntry; +import net.minecraft.client.gui.screens.inventory.InventoryScreen; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.joml.Quaternionf; + +/** + * List Entry for SlaveManagementScreen. + * Displays slave info, preview, status icons, and action buttons. + * + * Uses ContainerObjectSelectionList.Entry for Minecraft's built-in list scrolling. + */ +@OnlyIn(Dist.CLIENT) +public class SlaveEntryWidget + extends ContainerObjectSelectionList.Entry +{ + + private final IBondageState slave; + private final Minecraft mc; + + // Actions + private final Consumer onAdjust; + private final Consumer onShock; + private final Consumer onLocate; + private final Consumer onFree; + + // Layout constants - 2-column grid layout for buttons + private static final int BUTTON_W = 42; + private static final int BUTTON_H = 16; + private static final int BUTTON_GAP = 3; + private static final int BUTTON_COL_GAP = 4; + private static final int BUTTON_GRID_W = BUTTON_W * 2 + BUTTON_COL_GAP; + + // Preview size + private static final int PREVIEW_SIZE = PREVIEW_WIDTH_S; // 50px + + // Status icon colors (use centralized GuiColors) + private static final int COLOR_BOUND = GuiColors.TYPE_BIND; + private static final int COLOR_GAGGED = GuiColors.TYPE_GAG; + private static final int COLOR_BLIND = GuiColors.TYPE_BLINDFOLD; + private static final int COLOR_DEAF = GuiColors.TYPE_EARPLUGS; + private static final int COLOR_COLLAR = GuiColors.TYPE_COLLAR; + private static final int COLOR_MITTENS = GuiColors.TYPE_MITTENS; + + // Cached button info for click handling + private List cachedButtons; + private int lastRenderLeft, lastRenderTop, lastRenderWidth; + + public SlaveEntryWidget( + IBondageState slave, + Consumer onAdjust, + Consumer onShock, + Consumer onLocate, + Consumer onFree + ) { + this.slave = slave; + this.mc = Minecraft.getInstance(); + this.onAdjust = onAdjust; + this.onShock = onShock; + this.onLocate = onLocate; + this.onFree = onFree; + } + + @Override + public void render( + GuiGraphics graphics, + int index, + int top, + int left, + int width, + int height, + int mouseX, + int mouseY, + boolean hovering, + float partialTick + ) { + // Cache render positions for click handling + this.lastRenderLeft = left; + this.lastRenderTop = top; + this.lastRenderWidth = width; + + Font font = mc.font; + + // Background + int bgColor = hovering ? GuiColors.BG_LIGHT : GuiColors.BG_DARK; + graphics.fill(left, top, left + width, top + height, bgColor); + + // Border + int borderColor = GuiColors.BORDER_LIGHT; + graphics.fill(left, top, left + width, top + 1, borderColor); + graphics.fill( + left, + top + height - 1, + left + width, + top + height, + borderColor + ); + graphics.fill(left, top, left + 1, top + height, borderColor); + graphics.fill( + left + width - 1, + top, + left + width, + top + height, + borderColor + ); + + // === LEFT SECTION: Entity Preview === + renderEntityPreview(graphics, font, left, top, height); + + // === MIDDLE SECTION: Info === + int infoX = left + PREVIEW_SIZE + MARGIN_M; + int infoWidth = width - PREVIEW_SIZE - BUTTON_GRID_W - MARGIN_M * 3; + renderInfo(graphics, font, infoX, top, infoWidth, height); + + // === RIGHT SECTION: Action Buttons (2-column grid) === + renderButtons(graphics, font, mouseX, mouseY, left, top, width); + } + + private void renderEntityPreview( + GuiGraphics graphics, + Font font, + int left, + int top, + int height + ) { + LivingEntity entity = slave.asLivingEntity(); + if (entity == null) { + // Fallback placeholder + graphics.fill( + left + MARGIN_S, + top + MARGIN_S, + left + MARGIN_S + PREVIEW_SIZE, + top + height - MARGIN_S, + GuiColors.BG_MEDIUM + ); + graphics.drawCenteredString( + font, + "?", + left + MARGIN_S + PREVIEW_SIZE / 2, + top + height / 2 - 4, + GuiColors.TEXT_GRAY + ); + return; + } + + int previewX = left + MARGIN_S + PREVIEW_SIZE / 2; + int previewY = top + height - MARGIN_XS; + int scale = 26; + + try { + // Save original entity rotations + float origBodyRot = entity.yBodyRot; + float origHeadRot = entity.yHeadRot; + float origYRot = entity.getYRot(); + float origXRot = entity.getXRot(); + float origYHeadRotO = entity.yHeadRotO; + float origYBodyRotO = entity.yBodyRotO; + float origXRotO = entity.xRotO; + + // Set neutral pose + entity.yBodyRot = 180f; + entity.yHeadRot = 180f; + entity.setYRot(180f); + entity.setXRot(0f); + entity.yHeadRotO = entity.yHeadRot; + entity.yBodyRotO = entity.yBodyRot; + entity.xRotO = entity.getXRot(); + + // Render entity + Quaternionf poseRotation = new Quaternionf() + .rotationX((float) Math.PI) + .rotateY((float) Math.PI); + Quaternionf cameraRotation = new Quaternionf(); + InventoryScreen.renderEntityInInventory( + graphics, + previewX, + previewY, + scale, + poseRotation, + cameraRotation, + entity + ); + + // Restore rotations + entity.yBodyRot = origBodyRot; + entity.yHeadRot = origHeadRot; + entity.setYRot(origYRot); + entity.setXRot(origXRot); + entity.yHeadRotO = origYHeadRotO; + entity.yBodyRotO = origYBodyRotO; + entity.xRotO = origXRotO; + } catch (Exception e) { + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[SlaveEntryWidget] Failed to render entity preview", + e + ); + graphics.fill( + left + MARGIN_S, + top + MARGIN_S, + left + MARGIN_S + PREVIEW_SIZE, + top + ENTRY_HEIGHT - MARGIN_S, + GuiColors.BG_MEDIUM + ); + } + } + + private void renderInfo( + GuiGraphics graphics, + Font font, + int infoX, + int top, + int infoWidth, + int height + ) { + LivingEntity entity = slave.asLivingEntity(); + + // === Row 1: Name === + int row1Y = top + MARGIN_S; + String name = slave.getKidnappedName(); + if (font.width(name) > infoWidth) { + name = + font.plainSubstrByWidth(name, infoWidth - font.width("...")) + + "..."; + } + graphics.drawString(font, name, infoX, row1Y, GuiColors.TEXT_WHITE); + + // === Row 2: Collar nickname (if present) === + int row2Y = row1Y + LINE_HEIGHT + 2; + if (slave.hasNamedCollar()) { + String collarName = slave.getNameFromCollar(); + if (!collarName.isEmpty()) { + String displayName = "\"" + collarName + "\""; + if (font.width(displayName) > infoWidth) { + displayName = + font.plainSubstrByWidth( + displayName, + infoWidth - font.width("...") + ) + + "..."; + } + graphics.drawString( + font, + displayName, + infoX, + row2Y, + GuiColors.ACCENT_TAN + ); + } + } + + // === Row 3: Status icons === + int row3Y = row2Y + LINE_HEIGHT + 4; + List statuses = buildStatusList(); + int iconX = infoX; + + for (StatusIcon status : statuses) { + // Icon background + graphics.fill( + iconX, + row3Y, + iconX + STATUS_ICON_SIZE, + row3Y + STATUS_ICON_SIZE, + status.color + ); + graphics.fill( + iconX, + row3Y, + iconX + STATUS_ICON_SIZE, + row3Y + 1, + GuiColors.darken(status.color, 0.3f) + ); + + // Letter + graphics.drawCenteredString( + font, + status.letter, + iconX + STATUS_ICON_SIZE / 2, + row3Y + 3, + GuiColors.TEXT_WHITE + ); + + iconX += STATUS_ICON_SIZE + STATUS_ICON_SPACING; + } + + // If no statuses, show "(no restraints)" + if (statuses.isEmpty()) { + graphics.drawString( + font, + "(no restraints)", + infoX, + row3Y + 2, + GuiColors.TEXT_DISABLED + ); + } + + // Distance (right after status icons, only if GPS collar) + if (hasGPSCollar() && mc.player != null && entity != null) { + double dist = mc.player.distanceTo(entity); + String distText = String.format("%.0fm", dist); + int distX = iconX + MARGIN_S; + graphics.drawString( + font, + distText, + distX, + row3Y + 2, + GuiColors.TEXT_GRAY + ); + } + + // === Row 4: Health bar === + if (entity != null) { + int row4Y = row3Y + STATUS_ICON_SIZE + 4; + float healthRatio = entity.getHealth() / entity.getMaxHealth(); + int barWidth = 50; + + // Bar background + graphics.fill( + infoX, + row4Y, + infoX + barWidth, + row4Y + 4, + GuiColors.BG_DARK + ); + + // Bar fill + int healthColor = + healthRatio > 0.5f + ? GuiColors.SUCCESS + : (healthRatio > 0.25f + ? GuiColors.WARNING + : GuiColors.ERROR); + graphics.fill( + infoX, + row4Y, + infoX + (int) (barWidth * healthRatio), + row4Y + 4, + healthColor + ); + + // Health percentage + String healthText = String.format("%.0f%%", healthRatio * 100); + graphics.drawString( + font, + healthText, + infoX + barWidth + MARGIN_S, + row4Y - 2, + GuiColors.TEXT_GRAY + ); + + // GPS zone status (right of health) + if (hasGPSCollar()) { + ItemStack collarStack = slave.getEquipment(BodyRegionV2.NECK); + if (collarStack.getItem() instanceof ItemGpsCollar gps) { + boolean inSafeZone = isInAnySafeZone( + gps, + collarStack, + entity + ); + String gpsText = inSafeZone ? "Safe" : "Outside!"; + int gpsColor = inSafeZone + ? GuiColors.SUCCESS + : GuiColors.ERROR; + int gpsX = + infoX + + barWidth + + MARGIN_S + + font.width(healthText) + + MARGIN_M; + graphics.drawString( + font, + gpsText, + gpsX, + row4Y - 2, + gpsColor + ); + } + } + } + } + + private record ButtonInfo( + String text, + int color, + Runnable action, + int x, + int y + ) {} + + private List getVisibleButtons(int left, int top, int width) { + List buttons = new ArrayList<>(); + int gridX = left + width - BUTTON_GRID_W - MARGIN_S; + int baseY = top + MARGIN_S; + + int index = 0; + if (slave.isGagged() || slave.isBlindfolded()) { + int col = index % 2; + int row = index / 2; + buttons.add( + new ButtonInfo( + "Adjust", + GuiColors.INFO, + () -> { + if (onAdjust != null) onAdjust.accept(slave); + }, + gridX + col * (BUTTON_W + BUTTON_COL_GAP), + baseY + row * (BUTTON_H + BUTTON_GAP) + ) + ); + index++; + } + if (hasShockCollar()) { + int col = index % 2; + int row = index / 2; + buttons.add( + new ButtonInfo( + "Shock", + GuiColors.WARNING, + () -> { + if (onShock != null) onShock.accept(slave); + }, + gridX + col * (BUTTON_W + BUTTON_COL_GAP), + baseY + row * (BUTTON_H + BUTTON_GAP) + ) + ); + index++; + } + if (hasGPSCollar()) { + int col = index % 2; + int row = index / 2; + buttons.add( + new ButtonInfo( + "Locate", + GuiColors.SUCCESS, + () -> { + if (onLocate != null) onLocate.accept(slave); + }, + gridX + col * (BUTTON_W + BUTTON_COL_GAP), + baseY + row * (BUTTON_H + BUTTON_GAP) + ) + ); + index++; + } + if (slave.isCaptive()) { + int col = index % 2; + int row = index / 2; + buttons.add( + new ButtonInfo( + "Free", + GuiColors.ERROR, + () -> { + if (onFree != null) onFree.accept(slave); + }, + gridX + col * (BUTTON_W + BUTTON_COL_GAP), + baseY + row * (BUTTON_H + BUTTON_GAP) + ) + ); + } + + return buttons; + } + + private void renderButtons( + GuiGraphics graphics, + Font font, + int mouseX, + int mouseY, + int left, + int top, + int width + ) { + cachedButtons = getVisibleButtons(left, top, width); + if (cachedButtons.isEmpty()) return; + + for (ButtonInfo btn : cachedButtons) { + boolean hovered = + mouseX >= btn.x && + mouseX < btn.x + BUTTON_W && + mouseY >= btn.y && + mouseY < btn.y + BUTTON_H; + + int bgColor = hovered + ? GuiColors.lighten(btn.color, 0.2f) + : btn.color; + graphics.fill( + btn.x, + btn.y, + btn.x + BUTTON_W, + btn.y + BUTTON_H, + bgColor + ); + + // Border + graphics.fill( + btn.x, + btn.y, + btn.x + BUTTON_W, + btn.y + 1, + GuiColors.darken(btn.color, 0.3f) + ); + graphics.fill( + btn.x, + btn.y + BUTTON_H - 1, + btn.x + BUTTON_W, + btn.y + BUTTON_H, + GuiColors.darken(btn.color, 0.3f) + ); + + // Text + graphics.drawCenteredString( + font, + btn.text, + btn.x + BUTTON_W / 2, + btn.y + (BUTTON_H - 8) / 2, + GuiColors.TEXT_WHITE + ); + } + } + + // ==================== STATUS HELPERS ==================== + + private record StatusIcon(String letter, int color) {} + + private List buildStatusList() { + List statuses = new ArrayList<>(); + + if (slave.isTiedUp()) statuses.add(new StatusIcon("B", COLOR_BOUND)); + if (slave.isGagged()) statuses.add(new StatusIcon("G", COLOR_GAGGED)); + if (slave.isBlindfolded()) statuses.add( + new StatusIcon("X", COLOR_BLIND) + ); + if (slave.hasEarplugs()) statuses.add(new StatusIcon("D", COLOR_DEAF)); + if (slave.hasCollar()) statuses.add(new StatusIcon("C", COLOR_COLLAR)); + if (slave.hasMittens()) statuses.add( + new StatusIcon("M", COLOR_MITTENS) + ); + + return statuses; + } + + private boolean hasShockCollar() { + if (!slave.hasCollar()) return false; + ItemStack collar = slave.getEquipment(BodyRegionV2.NECK); + return ( + collar.getItem() instanceof ItemCollar itemCollar && + itemCollar.canShock() + ); + } + + private boolean hasGPSCollar() { + if (!slave.hasCollar()) return false; + ItemStack collar = slave.getEquipment(BodyRegionV2.NECK); + return ( + collar.getItem() instanceof ItemCollar itemCollar && + itemCollar.hasGPS() + ); + } + + private boolean isInAnySafeZone( + ItemGpsCollar gps, + ItemStack collarStack, + LivingEntity entity + ) { + if (!gps.isActive(collarStack)) return true; + + var safeSpots = gps.getSafeSpots(collarStack); + if (safeSpots.isEmpty()) return true; + + for (var spot : safeSpots) { + if (spot.isInside(entity)) { + return true; + } + } + return false; + } + + // ==================== INPUT HANDLING ==================== + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (button != 0 || cachedButtons == null) return false; + + for (ButtonInfo btn : cachedButtons) { + if ( + mouseX >= btn.x && + mouseX < btn.x + BUTTON_W && + mouseY >= btn.y && + mouseY < btn.y + BUTTON_H + ) { + btn.action.run(); + return true; + } + } + return false; + } + + @Override + public List children() { + return ImmutableList.of(); + } + + @Override + public List narratables() { + return ImmutableList.of(); + } +} diff --git a/src/main/java/com/tiedup/remake/client/gui/widgets/StatusBarWidget.java b/src/main/java/com/tiedup/remake/client/gui/widgets/StatusBarWidget.java new file mode 100644 index 0000000..50dbd24 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gui/widgets/StatusBarWidget.java @@ -0,0 +1,164 @@ +package com.tiedup.remake.client.gui.widgets; + +import com.tiedup.remake.client.gui.util.GuiColors; +import com.tiedup.remake.client.gui.util.GuiRenderUtil; +import com.tiedup.remake.items.GenericKnife; +import com.tiedup.remake.items.ItemKey; +import com.tiedup.remake.items.ItemLockpick; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.IHasResistance; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.narration.NarratedElementType; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Bottom status bar for the unified bondage screen. + * Self mode: tool status + resistance info. + * Master mode: key info + target info. + */ +@OnlyIn(Dist.CLIENT) +public class StatusBarWidget extends AbstractWidget { + + private static final int PADDING = 8; + + private ActionPanel.ScreenMode mode = ActionPanel.ScreenMode.SELF; + private LivingEntity targetEntity; + private Runnable onCloseClicked; + + // Close button layout + private static final int CLOSE_BTN_WIDTH = 100; + private static final int CLOSE_BTN_HEIGHT = 22; + + public StatusBarWidget(int x, int y, int width, int height) { + super(x, y, width, height, Component.literal("Status Bar")); + } + + public void setMode(ActionPanel.ScreenMode mode) { this.mode = mode; } + public void setTargetEntity(LivingEntity entity) { this.targetEntity = entity; } + public void setOnCloseClicked(Runnable cb) { this.onCloseClicked = cb; } + + @Override + protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + Minecraft mc = Minecraft.getInstance(); + Player player = mc.player; + if (player == null) return; + + // MC-style sunken inset panel (slightly darker than main) + GuiRenderUtil.drawMCSunkenPanel(graphics, getX(), getY(), width, height); + + int textY1 = getY() + PADDING; + int textY2 = textY1 + mc.font.lineHeight + 2; + + if (mode == ActionPanel.ScreenMode.SELF) { + renderSelfStatus(graphics, player, textY1, textY2); + } else { + renderMasterStatus(graphics, player, textY1, textY2); + } + + // Close button (right side, MC-style button) + int closeBtnX = getX() + width - CLOSE_BTN_WIDTH - PADDING; + int closeBtnY = getY() + (height - CLOSE_BTN_HEIGHT) / 2; + boolean closeHovered = mouseX >= closeBtnX && mouseX < closeBtnX + CLOSE_BTN_WIDTH + && mouseY >= closeBtnY && mouseY < closeBtnY + CLOSE_BTN_HEIGHT; + GuiRenderUtil.drawMCButton(graphics, closeBtnX, closeBtnY, CLOSE_BTN_WIDTH, CLOSE_BTN_HEIGHT, closeHovered, true); + String closeText = Component.translatable("gui.tiedup.close_esc").getString(); + GuiRenderUtil.drawCenteredStringNoShadow(graphics, mc.font, closeText, + closeBtnX + CLOSE_BTN_WIDTH / 2, closeBtnY + (CLOSE_BTN_HEIGHT - mc.font.lineHeight) / 2, + GuiRenderUtil.MC_TEXT_DARK); + } + + private void renderSelfStatus(GuiGraphics graphics, Player player, int y1, int y2) { + Minecraft mc = Minecraft.getInstance(); + int x = getX() + PADDING; + + // Tool status line 1 + ItemStack lockpick = ItemLockpick.findLockpickInInventory(player); + String pickText = lockpick.isEmpty() + ? Component.translatable("gui.tiedup.status.no_lockpick").getString() + : Component.translatable("gui.tiedup.status.lockpick_uses", + lockpick.getMaxDamage() - lockpick.getDamageValue()).getString(); + graphics.drawString(mc.font, pickText, x, y1, GuiRenderUtil.MC_TEXT_DARK, false); + + ItemStack knife = GenericKnife.findKnifeInInventory(player); + String knifeText = knife.isEmpty() + ? Component.translatable("gui.tiedup.status.no_knife").getString() + : Component.translatable("gui.tiedup.status.knife_uses", + knife.getMaxDamage() - knife.getDamageValue()).getString(); + graphics.drawString(mc.font, knifeText, x + 150, y1, GuiRenderUtil.MC_TEXT_DARK, false); + + // Arms resistance line 2 + ItemStack armsBind = V2EquipmentHelper.getInRegion(player, BodyRegionV2.ARMS); + if (!armsBind.isEmpty() && armsBind.getItem() instanceof IHasResistance res) { + int curr = res.getCurrentResistance(armsBind, player); + int max = res.getBaseResistance(player); + String resText = Component.translatable("gui.tiedup.status.arms_resistance", curr, max).getString(); + int color = curr < max * 0.3 ? GuiColors.ERROR : GuiColors.SUCCESS; + graphics.drawString(mc.font, resText, x, y2, color, false); + } + } + + private void renderMasterStatus(GuiGraphics graphics, Player player, int y1, int y2) { + Minecraft mc = Minecraft.getInstance(); + int x = getX() + PADDING; + + // Key info — check for ItemKey first, then master key as fallback + ItemStack keyStack = ItemStack.EMPTY; + for (int i = 0; i < player.getInventory().getContainerSize(); i++) { + ItemStack stack = player.getInventory().getItem(i); + if (stack.isEmpty()) continue; + if (stack.getItem() instanceof ItemKey) { + keyStack = stack; + break; // Regular key takes priority + } + if (keyStack.isEmpty() && stack.is(ModItems.MASTER_KEY.get())) { + keyStack = stack; // Remember master key as fallback, keep scanning + } + } + + String keyText; + if (keyStack.isEmpty()) { + keyText = Component.translatable("gui.tiedup.status.no_key").getString(); + } else { + keyText = Component.translatable("gui.tiedup.status.key_info", + keyStack.getHoverName().getString()).getString(); + } + graphics.drawString(mc.font, keyText, x, y1, GuiRenderUtil.MC_TEXT_DARK, false); + + // Target info + if (targetEntity != null) { + String targetText = Component.translatable("gui.tiedup.status.target_info", + targetEntity.getName().getString()).getString(); + graphics.drawString(mc.font, targetText, x, y2, GuiRenderUtil.MC_TEXT_DARK, false); + } + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (!isMouseOver(mouseX, mouseY) || button != 0) return false; + + int closeBtnX = getX() + width - CLOSE_BTN_WIDTH - PADDING; + int closeBtnY = getY() + (height - CLOSE_BTN_HEIGHT) / 2; + if (mouseX >= closeBtnX && mouseX < closeBtnX + CLOSE_BTN_WIDTH + && mouseY >= closeBtnY && mouseY < closeBtnY + CLOSE_BTN_HEIGHT) { + if (onCloseClicked != null) onCloseClicked.run(); + playDownSound(Minecraft.getInstance().getSoundManager()); + return true; + } + return false; + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput output) { + output.add(NarratedElementType.TITLE, Component.translatable("gui.tiedup.status_bar")); + } +} diff --git a/src/main/java/com/tiedup/remake/client/model/CellCoreBakedModel.java b/src/main/java/com/tiedup/remake/client/model/CellCoreBakedModel.java new file mode 100644 index 0000000..2986fac --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/model/CellCoreBakedModel.java @@ -0,0 +1,140 @@ +package com.tiedup.remake.client.model; + +import java.util.List; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.block.model.BakedQuad; +import net.minecraft.client.renderer.block.model.ItemOverrides; +import net.minecraft.client.renderer.block.model.ItemTransforms; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.client.resources.model.BakedModel; +import net.minecraft.core.Direction; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.client.ChunkRenderTypeSet; +import net.minecraftforge.client.model.IDynamicBakedModel; +import net.minecraftforge.client.model.data.ModelData; +import net.minecraftforge.client.model.data.ModelProperty; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Dynamic baked model for CellCore that supports camouflage. + * + * When a disguise BlockState is provided via ModelData, renders + * the disguise block's quads instead of the default cell_core model. + * This makes the Cell Core visually blend into surrounding walls. + */ +public class CellCoreBakedModel implements IDynamicBakedModel { + + /** @deprecated Use {@link com.tiedup.remake.blocks.entity.CellCoreBlockEntity#DISGUISE_PROPERTY} */ + @Deprecated + public static final ModelProperty DISGUISE_PROPERTY = + com.tiedup.remake.blocks.entity.CellCoreBlockEntity.DISGUISE_PROPERTY; + + private final BakedModel original; + + public CellCoreBakedModel(BakedModel original) { + this.original = original; + } + + @Override + public @NotNull List getQuads( + @Nullable BlockState state, + @Nullable Direction side, + @NotNull RandomSource rand, + @NotNull ModelData modelData, + @Nullable RenderType renderType + ) { + BlockState disguise = modelData.get(DISGUISE_PROPERTY); + if (disguise != null) { + BakedModel disguiseModel = + net.minecraft.client.Minecraft.getInstance() + .getBlockRenderer() + .getBlockModel(disguise); + return disguiseModel.getQuads( + disguise, + side, + rand, + ModelData.EMPTY, + renderType + ); + } + return original.getQuads( + state, + side, + rand, + ModelData.EMPTY, + renderType + ); + } + + @Override + public boolean useAmbientOcclusion() { + return original.useAmbientOcclusion(); + } + + @Override + public boolean isGui3d() { + return original.isGui3d(); + } + + @Override + public boolean usesBlockLight() { + return original.usesBlockLight(); + } + + @Override + public boolean isCustomRenderer() { + return original.isCustomRenderer(); + } + + @Override + public TextureAtlasSprite getParticleIcon() { + return original.getParticleIcon(); + } + + @Override + public TextureAtlasSprite getParticleIcon(@NotNull ModelData modelData) { + BlockState disguise = modelData.get(DISGUISE_PROPERTY); + if (disguise != null) { + BakedModel disguiseModel = + net.minecraft.client.Minecraft.getInstance() + .getBlockRenderer() + .getBlockModel(disguise); + return disguiseModel.getParticleIcon(ModelData.EMPTY); + } + return original.getParticleIcon(); + } + + @Override + public ItemOverrides getOverrides() { + return original.getOverrides(); + } + + @SuppressWarnings("deprecation") + @Override + public ItemTransforms getTransforms() { + return original.getTransforms(); + } + + @Override + public @NotNull ChunkRenderTypeSet getRenderTypes( + @NotNull BlockState state, + @NotNull RandomSource rand, + @NotNull ModelData data + ) { + BlockState disguise = data.get(DISGUISE_PROPERTY); + if (disguise != null) { + BakedModel disguiseModel = + net.minecraft.client.Minecraft.getInstance() + .getBlockRenderer() + .getBlockModel(disguise); + return disguiseModel.getRenderTypes( + disguise, + rand, + ModelData.EMPTY + ); + } + return original.getRenderTypes(state, rand, ModelData.EMPTY); + } +} diff --git a/src/main/java/com/tiedup/remake/client/model/DamselModel.java b/src/main/java/com/tiedup/remake/client/model/DamselModel.java new file mode 100644 index 0000000..19e81c0 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/model/DamselModel.java @@ -0,0 +1,582 @@ +package com.tiedup.remake.client.model; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.logging.LogUtils; +import com.tiedup.remake.client.animation.StaticPoseApplier; +import com.tiedup.remake.client.animation.util.DogPoseHelper; +import com.tiedup.remake.entities.AbstractTiedUpNpc; +import com.tiedup.remake.entities.EntityKidnapperArcher; +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.entities.ai.master.MasterState; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.items.base.PoseType; +import com.tiedup.remake.items.clothes.GenericClothes; +import dev.kosmx.playerAnim.core.impl.AnimationProcessor; +import dev.kosmx.playerAnim.core.util.SetableSupplier; +import dev.kosmx.playerAnim.impl.Helper; +import dev.kosmx.playerAnim.impl.IMutableModel; +import dev.kosmx.playerAnim.impl.IUpperPartHelper; +import dev.kosmx.playerAnim.impl.animation.AnimationApplier; +import dev.kosmx.playerAnim.impl.animation.IBendHelper; +import net.minecraft.client.model.HumanoidModel; +import net.minecraft.client.model.PlayerModel; +import net.minecraft.client.model.geom.ModelPart; +import net.minecraft.core.Direction; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.slf4j.Logger; + +/** + * Model for AbstractTiedUpNpc - Humanoid female NPC. + * + * Phase 14.2.3: Rendering system + * Phase 19: Extends PlayerModel for full layer support (hat, jacket, sleeves, pants) + * + * Features: + * - Extends PlayerModel for player-like rendering with outer layers + * - Supports both normal (4px) and slim (3px) arm widths + * - Modifies animations based on bondage state + * - Has jacket, sleeves, pants layers like player skins + * + * Uses vanilla ModelLayers.PLAYER / PLAYER_SLIM for geometry. + */ +@OnlyIn(Dist.CLIENT) +public class DamselModel + extends PlayerModel + implements IMutableModel +{ + + private static final Logger LOGGER = LogUtils.getLogger(); + private static boolean loggedBendyStatus = false; + + /** Track if bendy-lib has been initialized for this model */ + private boolean bendyLibInitialized = false; + + /** Emote supplier for bending support - required by IMutableModel */ + private final SetableSupplier emoteSupplier = + new SetableSupplier<>(); + + /** + * Create model from baked model part. + * + * @param root The root model part (baked from vanilla PLAYER layer) + * @param slim Whether this is a slim (Alex) arms model + */ + public DamselModel(ModelPart root, boolean slim) { + super(root, slim); + initBendyLib(root); + } + + /** + * Initialize bendy-lib bend points on model parts. + * + *

This enables visual bending of knees and elbows when animations + * specify bend values. Without this initialization, bend values in + * animation JSON files have no visual effect. + * + *

Also marks upper parts (head, arms, hat) for proper bend rendering. + * + * @param root The root model part + */ + private void initBendyLib(ModelPart root) { + if (bendyLibInitialized) { + return; + } + + try { + // Check if bendy-lib is available + if (IBendHelper.INSTANCE == null) { + if (!loggedBendyStatus) { + LOGGER.warn( + "[DamselModel] IBendHelper.INSTANCE is null - bendy-lib not available" + ); + loggedBendyStatus = true; + } + return; + } + + // Log bendy-lib status + if (!loggedBendyStatus) { + LOGGER.info( + "[DamselModel] IBendHelper.INSTANCE class: {}", + IBendHelper.INSTANCE.getClass().getName() + ); + LOGGER.info( + "[DamselModel] Helper.isBendEnabled(): {}", + Helper.isBendEnabled() + ); + loggedBendyStatus = true; + } + + // Initialize bend points for each body part + // Direction indicates which end of the limb bends + IBendHelper.INSTANCE.initBend( + root.getChild("body"), + Direction.DOWN + ); + IBendHelper.INSTANCE.initBend( + root.getChild("right_arm"), + Direction.UP + ); + IBendHelper.INSTANCE.initBend( + root.getChild("left_arm"), + Direction.UP + ); + IBendHelper.INSTANCE.initBend( + root.getChild("right_leg"), + Direction.UP + ); + IBendHelper.INSTANCE.initBend( + root.getChild("left_leg"), + Direction.UP + ); + + // Mark upper parts for proper bend rendering + // These parts will be rendered after applying body bend rotation + ((IUpperPartHelper) (Object) this.rightArm).setUpperPart(true); + ((IUpperPartHelper) (Object) this.leftArm).setUpperPart(true); + ((IUpperPartHelper) (Object) this.head).setUpperPart(true); + ((IUpperPartHelper) (Object) this.hat).setUpperPart(true); + + LOGGER.info("[DamselModel] bendy-lib initialized successfully"); + bendyLibInitialized = true; + } catch (Exception e) { + LOGGER.error("[DamselModel] bendy-lib initialization failed", e); + // bendy-lib not available or initialization failed + // Animations will still work, just without visual bending + } + } + + // ======================================== + // IMutableModel Implementation + // ======================================== + + @Override + public void setEmoteSupplier(SetableSupplier supplier) { + if (supplier != null && supplier.get() != null) { + this.emoteSupplier.set(supplier.get()); + } + } + + @Override + public SetableSupplier getEmoteSupplier() { + return this.emoteSupplier; + } + + /** + * Setup animations for the damsel. + * + * Modifies arm and leg positions based on bondage state: + * - Tied up: Arms behind back, legs frozen (or variant pose based on bind type) + * - Free: Normal humanoid animations + * + * Phase 15: Different poses for different bind types (straitjacket, wrap, latex_sack) + * Phase 15.1: Hide arms for wrap/latex_sack (matching original mod) + * + * @param entity AbstractTiedUpNpc instance + * @param limbSwing Limb swing animation value + * @param limbSwingAmount Limb swing amount + * @param ageInTicks Age in ticks for idle animations + * @param netHeadYaw Head yaw rotation + * @param headPitch Head pitch rotation + */ + @Override + public void setupAnim( + AbstractTiedUpNpc entity, + float limbSwing, + float limbSwingAmount, + float ageInTicks, + float netHeadYaw, + float headPitch + ) { + // Phase 18: Handle archer arm poses BEFORE super call + // Only show bow animation when in ranged mode (has active shooting target) + if (entity instanceof EntityKidnapperArcher archer) { + if (archer.isInRangedMode()) { + // In ranged mode: show bow animation + // isAiming() indicates full draw, otherwise ready position + this.rightArmPose = HumanoidModel.ArmPose.BOW_AND_ARROW; + this.leftArmPose = HumanoidModel.ArmPose.BOW_AND_ARROW; + } else { + // Not in ranged mode: reset to normal poses (no bow animation) + this.rightArmPose = HumanoidModel.ArmPose.EMPTY; + this.leftArmPose = HumanoidModel.ArmPose.EMPTY; + } + } + + // Call parent to setup base humanoid animations + super.setupAnim( + entity, + limbSwing, + limbSwingAmount, + ageInTicks, + netHeadYaw, + headPitch + ); + + // Reset all visibility (may have been hidden in previous frame) + // Arms + this.leftArm.visible = true; + this.rightArm.visible = true; + // Outer layers (Phase 19) + this.hat.visible = true; + this.jacket.visible = true; + this.leftSleeve.visible = true; + this.rightSleeve.visible = true; + this.leftPants.visible = true; + this.rightPants.visible = true; + + // Animation triggering is handled by NpcAnimationTickHandler (tick-based). + // This method only applies transforms, static pose fallback, and layer syncing. + boolean inPose = + entity.isTiedUp() || entity.isSitting() || entity.isKneeling(); + + if (inPose) { + ItemStack bind = entity.getEquipment(BodyRegionV2.ARMS); + PoseType poseType = PoseType.STANDARD; + + if (bind.getItem() instanceof ItemBind itemBind) { + poseType = itemBind.getPoseType(); + } + + // Hide arms for wrap/latex_sack poses + if (poseType == PoseType.WRAP || poseType == PoseType.LATEX_SACK) { + this.leftArm.visible = false; + this.rightArm.visible = false; + } + } + + // Apply animation transforms via PlayerAnimator's emote.updatePart() + // AbstractTiedUpNpc implements IAnimatedPlayer, so we can call directly + AnimationApplier emote = entity.playerAnimator_getAnimation(); + boolean emoteActive = emote != null && emote.isActive(); + + // Track current pose type for DOG pose compensation + PoseType currentPoseType = PoseType.STANDARD; + if (inPose) { + ItemStack bindForPoseType = entity.getEquipment(BodyRegionV2.ARMS); + if (bindForPoseType.getItem() instanceof ItemBind itemBindForType) { + currentPoseType = itemBindForType.getPoseType(); + } + } + + // Check if this is a Master in human chair mode (head should look around freely) + boolean isMasterChairAnim = + entity instanceof EntityMaster masterEnt && + masterEnt.getMasterState() == MasterState.HUMAN_CHAIR && + masterEnt.isSitting(); + + if (emoteActive) { + // Animation is active - apply transforms via AnimationApplier + this.emoteSupplier.set(emote); + + // Apply transforms to each body part + // Skip head for master chair animation — let vanilla look-at control the head + if (!isMasterChairAnim) { + emote.updatePart("head", this.head); + } + emote.updatePart("torso", this.body); + emote.updatePart("leftArm", this.leftArm); + emote.updatePart("rightArm", this.rightArm); + emote.updatePart("leftLeg", this.leftLeg); + emote.updatePart("rightLeg", this.rightLeg); + + // DOG pose: PoseStack handles body rotation in setupRotations() + // Reset body.xRot to prevent double rotation from animation + // Apply head compensation so head looks forward instead of at ground + if (currentPoseType == PoseType.DOG) { + // Reset body rotation (PoseStack already rotates everything) + this.body.xRot = 0; + + // Head compensation: body is horizontal via PoseStack + // Head needs to look forward instead of at the ground + DogPoseHelper.applyHeadCompensation( + this.head, + null, // hat is synced via copyFrom below + headPitch, + netHeadYaw + ); + } + + // Sync outer layers to their parents (Phase 19) + this.hat.copyFrom(this.head); + this.jacket.copyFrom(this.body); + this.leftSleeve.copyFrom(this.leftArm); + this.rightSleeve.copyFrom(this.rightArm); + this.leftPants.copyFrom(this.leftLeg); + this.rightPants.copyFrom(this.rightLeg); + } else if (inPose) { + // Animation not yet active (1-frame delay) - apply static pose as fallback + // This ensures immediate visual feedback when bind is applied + ItemStack bind = entity.getEquipment(BodyRegionV2.ARMS); + PoseType fallbackPoseType = PoseType.STANDARD; + + if (bind.getItem() instanceof ItemBind itemBind) { + fallbackPoseType = itemBind.getPoseType(); + } + + // Derive bound state from V2 regions (AbstractTiedUpNpc implements IV2EquipmentHolder) + boolean armsBound = V2EquipmentHelper.isRegionOccupied(entity, BodyRegionV2.ARMS); + boolean legsBound = V2EquipmentHelper.isRegionOccupied(entity, BodyRegionV2.LEGS); + + if (!armsBound && !legsBound && bind.getItem() instanceof ItemBind) { + armsBound = ItemBind.hasArmsBound(bind); + legsBound = ItemBind.hasLegsBound(bind); + } + + // Apply static pose directly to model parts + StaticPoseApplier.applyStaticPose( + this, + fallbackPoseType, + armsBound, + legsBound + ); + + // DOG pose: Apply head compensation for horizontal body (same as emote case) + if (fallbackPoseType == PoseType.DOG) { + // Reset body rotation (PoseStack handles it) + this.body.xRot = 0; + + // Head compensation for horizontal body + DogPoseHelper.applyHeadCompensation( + this.head, + null, // hat is synced via copyFrom below + headPitch, + netHeadYaw + ); + } + + // Sync outer layers after static pose + this.hat.copyFrom(this.head); + this.jacket.copyFrom(this.body); + this.leftSleeve.copyFrom(this.leftArm); + this.rightSleeve.copyFrom(this.rightArm); + this.leftPants.copyFrom(this.leftLeg); + this.rightPants.copyFrom(this.rightLeg); + + // Clear emote supplier since we're using static pose + this.emoteSupplier.set(null); + } else { + // Not in pose and no animation - clear emote supplier and reset bends + this.emoteSupplier.set(null); + resetBends(); + + // Sync outer layers + this.hat.copyFrom(this.head); + this.jacket.copyFrom(this.body); + this.leftSleeve.copyFrom(this.leftArm); + this.rightSleeve.copyFrom(this.rightArm); + this.leftPants.copyFrom(this.leftLeg); + this.rightPants.copyFrom(this.rightLeg); + } + + // Phase 19: Hide wearer's outer layers based on clothes settings + // This MUST happen after super.setupAnim() which can reset visibility + hideWearerLayersForClothes(entity); + } + + /** + * Hide wearer's outer layers when clothes are equipped. + * Called at the end of setupAnim() to ensure it happens after any visibility resets. + * + *

Logic: When clothes are equipped, hide ALL wearer's outer layers. + * The clothes will render their own layers on top. + * Exception: If keepHead is enabled, the head/hat layers remain visible. + * + * @param entity The entity wearing clothes + */ + private void hideWearerLayersForClothes(AbstractTiedUpNpc entity) { + ItemStack clothes = entity.getEquipment(BodyRegionV2.TORSO); + if ( + clothes.isEmpty() || + !(clothes.getItem() instanceof GenericClothes gc) + ) { + return; + } + + // Check if keepHead is enabled + boolean keepHead = gc.isKeepHeadEnabled(clothes); + + // When wearing clothes, hide wearer's outer layers + // Exception: if keepHead is true, don't hide head/hat + if (!keepHead) { + this.hat.visible = false; + } + this.jacket.visible = false; + this.leftSleeve.visible = false; + this.rightSleeve.visible = false; + this.leftPants.visible = false; + this.rightPants.visible = false; + } + + /** + * Reset bend values on all body parts. + * Called when animation stops to prevent lingering bend effects. + */ + private void resetBends() { + if (IBendHelper.INSTANCE == null) { + return; + } + try { + IBendHelper.INSTANCE.bend(this.body, null); + IBendHelper.INSTANCE.bend(this.leftArm, null); + IBendHelper.INSTANCE.bend(this.rightArm, null); + IBendHelper.INSTANCE.bend(this.leftLeg, null); + IBendHelper.INSTANCE.bend(this.rightLeg, null); + } catch (Exception e) { + LOGGER.debug( + "[DamselModel] bendy-lib not available for bend reset", + e + ); + } + } + + /** + * Override renderToBuffer to apply body bend rotation. + * + *

When an animation has a body bend value, we need to: + * 1. Render non-upper parts (legs) normally + * 2. Apply the bend rotation to the matrix stack + * 3. Render upper parts (head, arms, body) with the rotation applied + * + *

This creates the visual effect of the body bending (like kneeling). + */ + @Override + public void renderToBuffer( + PoseStack matrices, + VertexConsumer vertices, + int light, + int overlay, + float red, + float green, + float blue, + float alpha + ) { + // Check if we should use bend rendering + if ( + Helper.isBendEnabled() && + emoteSupplier.get() != null && + emoteSupplier.get().isActive() + ) { + // Render with bend support + renderWithBend( + matrices, + vertices, + light, + overlay, + red, + green, + blue, + alpha + ); + } else { + // Normal rendering + super.renderToBuffer( + matrices, + vertices, + light, + overlay, + red, + green, + blue, + alpha + ); + } + } + + /** + * Render model parts with body bend applied. + * + *

Based on PlayerAnimator's bendRenderToBuffer logic: + * - First render non-upper parts (legs) normally + * - Then apply body bend rotation + * - Then render upper parts (head, body, arms) with rotation + */ + private void renderWithBend( + PoseStack matrices, + VertexConsumer vertices, + int light, + int overlay, + float red, + float green, + float blue, + float alpha + ) { + // Get all body parts + Iterable headParts = headParts(); + Iterable bodyParts = bodyParts(); + + // First pass: render non-upper parts (legs) + for (ModelPart part : headParts) { + if (!((IUpperPartHelper) (Object) part).isUpperPart()) { + part.render( + matrices, + vertices, + light, + overlay, + red, + green, + blue, + alpha + ); + } + } + for (ModelPart part : bodyParts) { + if (!((IUpperPartHelper) (Object) part).isUpperPart()) { + part.render( + matrices, + vertices, + light, + overlay, + red, + green, + blue, + alpha + ); + } + } + + // Apply body bend rotation + matrices.pushPose(); + IBendHelper.rotateMatrixStack( + matrices, + emoteSupplier.get().getBend("body") + ); + + // Second pass: render upper parts (head, body, arms) with bend applied + for (ModelPart part : headParts) { + if (((IUpperPartHelper) (Object) part).isUpperPart()) { + part.render( + matrices, + vertices, + light, + overlay, + red, + green, + blue, + alpha + ); + } + } + for (ModelPart part : bodyParts) { + if (((IUpperPartHelper) (Object) part).isUpperPart()) { + part.render( + matrices, + vertices, + light, + overlay, + red, + green, + blue, + alpha + ); + } + } + + matrices.popPose(); + } +} diff --git a/src/main/java/com/tiedup/remake/client/network/ClientPacketHandler.java b/src/main/java/com/tiedup/remake/client/network/ClientPacketHandler.java new file mode 100644 index 0000000..7789823 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/network/ClientPacketHandler.java @@ -0,0 +1,40 @@ +package com.tiedup.remake.client.network; + +import com.tiedup.remake.client.gui.screens.PetRequestScreen; +import java.util.List; +// import com.tiedup.remake.client.gui.screens.ConversationScreen; // DISABLED: Conversation system not in use +// import com.tiedup.remake.dialogue.conversation.ConversationTopic; // DISABLED: Conversation system not in use +import net.minecraft.client.Minecraft; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Client-side packet handling helper. + * + * This class is ONLY loaded on the client, preventing server-side crashes + * from attempting to load client-only classes like Screen. + */ +@OnlyIn(Dist.CLIENT) +public class ClientPacketHandler { + + /** + * Open the conversation screen. + * DISABLED: Conversation system not in use + */ + /* + public static void openConversationScreen(int entityId, String npcName, List topics) { + Minecraft.getInstance().setScreen( + new ConversationScreen(entityId, npcName, topics) + ); + } + */ + + /** + * Open the pet request menu. + */ + public static void openPetRequestScreen(int entityId, String masterName) { + Minecraft.getInstance().setScreen( + new PetRequestScreen(entityId, masterName) + ); + } +} diff --git a/src/main/java/com/tiedup/remake/client/renderer/CellCoreRenderer.java b/src/main/java/com/tiedup/remake/client/renderer/CellCoreRenderer.java new file mode 100644 index 0000000..aaa8d47 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/renderer/CellCoreRenderer.java @@ -0,0 +1,194 @@ +package com.tiedup.remake.client.renderer; + +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.blaze3d.vertex.VertexFormat; +import com.tiedup.remake.blocks.entity.CellCoreBlockEntity; +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderStateShard; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.core.Direction; +import net.minecraft.world.level.block.SlabBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.SlabType; +import org.joml.Matrix4f; + +/** + * Block entity renderer for CellCore. + * + * Renders a small pulsing cyan diamond indicator on the interior face + * of the Cell Core block. This helps players identify which side of the + * block faces into the cell, and confirms the block is a Cell Core. + * + * Extends RenderStateShard to access protected render state fields + * (standard Forge pattern for custom RenderTypes). + */ +public class CellCoreRenderer + extends RenderStateShard + implements BlockEntityRenderer +{ + + private static final float DIAMOND_SIZE = 0.15f; + private static final float FACE_OFFSET = 0.001f; + + // Cyan indicator color (#44FFFF) + private static final float COLOR_R = 0.267f; + private static final float COLOR_G = 1.0f; + private static final float COLOR_B = 1.0f; + + /** + * Custom RenderType: POSITION_COLOR quads with translucent blending, no texture. + * Avoids block atlas UV issues that made the diamond invisible. + */ + private static final RenderType INDICATOR = RenderType.create( + "tiedup_indicator", + DefaultVertexFormat.POSITION_COLOR, + VertexFormat.Mode.QUADS, + 256, + false, + true, + RenderType.CompositeState.builder() + .setShaderState( + new ShaderStateShard(GameRenderer::getPositionColorShader) + ) + .setTransparencyState(TRANSLUCENT_TRANSPARENCY) + .setCullState(NO_CULL) + .setDepthTestState(LEQUAL_DEPTH_TEST) + .setWriteMaskState(COLOR_DEPTH_WRITE) + .createCompositeState(false) + ); + + public CellCoreRenderer(BlockEntityRendererProvider.Context context) { + super("tiedup_cell_core_renderer", () -> {}, () -> {}); + } + + @Override + public void render( + CellCoreBlockEntity blockEntity, + float partialTick, + PoseStack poseStack, + MultiBufferSource bufferSource, + int packedLight, + int packedOverlay + ) { + Direction interiorFace = blockEntity.getInteriorFace(); + if (interiorFace == null) return; + if (blockEntity.getLevel() == null) return; + + // Calculate pulsing alpha synced to game time (pauses when game pauses) + float time = blockEntity.getLevel().getGameTime() + partialTick; + float alpha = 0.4f + 0.2f * (float) Math.sin(time * 0.15); + + // Compute vertical center: adapt to slab shape if disguised as a slab + float centerY = 0.5f; + BlockState disguise = blockEntity.getDisguiseState(); + if (disguise == null && blockEntity.getLevel() != null) { + // Auto-detect: check resolved model data (same logic as CellCoreBlockEntity) + disguise = blockEntity + .getModelData() + .get( + com.tiedup.remake.client.model.CellCoreBakedModel.DISGUISE_PROPERTY + ); + } + if (disguise != null && disguise.getBlock() instanceof SlabBlock) { + SlabType slabType = disguise.getValue(SlabBlock.TYPE); + if (slabType == SlabType.BOTTOM) { + centerY = 0.25f; // lower half + } else if (slabType == SlabType.TOP) { + centerY = 0.75f; // upper half + } + // DOUBLE = 0.5f (full block) + } + + poseStack.pushPose(); + + VertexConsumer consumer = bufferSource.getBuffer(INDICATOR); + Matrix4f pose = poseStack.last().pose(); + + renderDiamond(consumer, pose, interiorFace, alpha, centerY); + + poseStack.popPose(); + } + + private void renderDiamond( + VertexConsumer consumer, + Matrix4f pose, + Direction face, + float alpha, + float centerY + ) { + float[][] verts = getDiamondVertices(face, 0.5f, centerY, 0.5f); + + for (float[] v : verts) { + consumer + .vertex(pose, v[0], v[1], v[2]) + .color(COLOR_R, COLOR_G, COLOR_B, alpha) + .endVertex(); + } + } + + /** + * Get the 4 vertices of a diamond shape on the given face. + * The diamond is centered on the face and offset slightly outward to avoid z-fighting. + */ + private float[][] getDiamondVertices( + Direction face, + float cx, + float cy, + float cz + ) { + float s = DIAMOND_SIZE; + float o = FACE_OFFSET; + + return switch (face) { + case NORTH -> new float[][] { + { cx, cy + s, 0.0f - o }, // top + { cx + s, cy, 0.0f - o }, // right + { cx, cy - s, 0.0f - o }, // bottom + { cx - s, cy, 0.0f - o }, // left + }; + case SOUTH -> new float[][] { + { cx, cy + s, 1.0f + o }, + { cx - s, cy, 1.0f + o }, + { cx, cy - s, 1.0f + o }, + { cx + s, cy, 1.0f + o }, + }; + case WEST -> new float[][] { + { 0.0f - o, cy + s, cz }, + { 0.0f - o, cy, cz - s }, + { 0.0f - o, cy - s, cz }, + { 0.0f - o, cy, cz + s }, + }; + case EAST -> new float[][] { + { 1.0f + o, cy + s, cz }, + { 1.0f + o, cy, cz + s }, + { 1.0f + o, cy - s, cz }, + { 1.0f + o, cy, cz - s }, + }; + case DOWN -> { + // Bottom face: y=0.0 for full blocks & bottom slabs, y=0.5 for top slabs only + float downY = (cy >= 0.75f) ? 0.5f - o : 0.0f - o; + yield new float[][] { + { cx, downY, cz + s }, + { cx + s, downY, cz }, + { cx, downY, cz - s }, + { cx - s, downY, cz }, + }; + } + case UP -> { + // Top face: y=1.0 for full blocks & top slabs, y=0.5 for bottom slabs only + float upY = (cy <= 0.25f) ? 0.5f + o : 1.0f + o; + yield new float[][] { + { cx, upY, cz - s }, + { cx + s, upY, cz }, + { cx, upY, cz + s }, + { cx - s, upY, cz }, + }; + } + }; + } +} diff --git a/src/main/java/com/tiedup/remake/client/renderer/CellOutlineRenderer.java b/src/main/java/com/tiedup/remake/client/renderer/CellOutlineRenderer.java new file mode 100644 index 0000000..fba1b41 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/renderer/CellOutlineRenderer.java @@ -0,0 +1,361 @@ +package com.tiedup.remake.client.renderer; + +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.*; +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.MarkerType; +import java.util.Collection; +import java.util.Set; +import net.minecraft.client.Camera; +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.core.BlockPos; +import net.minecraft.world.phys.Vec3; +import org.joml.Matrix4f; + +/** + * Utility class for rendering cell outlines in the world. + * + * Simplified renderer - only filled blocks, no wireframe outlines. + * Each marker type gets a semi-transparent colored block overlay. + * Spawn point has a pulsating effect. + * + * Features: + * - Depth test enabled (blocks hidden behind world geometry) + * - Simple filled block rendering per marker type + * - Pulsating spawn point indicator + */ +public class CellOutlineRenderer { + + /** Enable depth test (true = blocks hidden behind world blocks) */ + public static boolean DEPTH_TEST_ENABLED = true; + + // Simple colors per type (RGBA) + private static final float[] COLOR_WALL = { 0.3f, 0.5f, 1.0f, 1.0f }; // Blue + private static final float[] COLOR_ANCHOR = { 1.0f, 0.2f, 0.2f, 1.0f }; // Red + private static final float[] COLOR_BED = { 0.9f, 0.4f, 0.9f, 1.0f }; // Violet + private static final float[] COLOR_DOOR = { 0.2f, 0.9f, 0.9f, 1.0f }; // Cyan + private static final float[] COLOR_ENTRANCE = { 0.2f, 1.0f, 0.4f, 1.0f }; // Green + private static final float[] COLOR_PATROL = { 1.0f, 1.0f, 0.2f, 1.0f }; // Yellow + private static final float[] COLOR_LOOT = { 1.0f, 0.7f, 0.0f, 1.0f }; // Gold + private static final float[] COLOR_SPAWNER = { 0.8f, 0.1f, 0.1f, 1.0f }; // Dark red + private static final float[] COLOR_TRADER_SPAWN = { + 1.0f, + 0.84f, + 0.0f, + 1.0f, + }; // Gold + private static final float[] COLOR_MAID_SPAWN = { + 1.0f, + 0.41f, + 0.71f, + 1.0f, + }; // Hot pink + private static final float[] COLOR_MERCHANT_SPAWN = { + 0.2f, + 0.9f, + 0.9f, + 1.0f, + }; // Cyan + private static final float[] COLOR_DELIVERY = { 1.0f, 0.8f, 0.2f, 1.0f }; // Orange/Yellow + private static final float[] COLOR_SPAWN = { 1.0f, 0.0f, 1.0f, 1.0f }; // Magenta + private static final float[] COLOR_WAYPOINT = { 0.0f, 1.0f, 0.5f, 1.0f }; // Bright green + + /** + * Render filled blocks for all positions in a cell. + * Renders V2 cell data: walls, anchors, beds, doors, and spawn/core positions. + * + * @param poseStack The pose stack from the render event + * @param cell The cell data to render + * @param camera The camera for view offset calculation + */ + public static void renderCellOutlines( + PoseStack poseStack, + CellDataV2 cell, + Camera camera + ) { + if (cell == null) return; + + Vec3 cameraPos = camera.getPosition(); + + // Setup rendering state + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); + RenderSystem.enableDepthTest(); + RenderSystem.depthMask(false); + RenderSystem.setShader(GameRenderer::getPositionColorShader); + + // 1. Render spawn point or core pos (pulsating magenta) + BlockPos spawnPoint = + cell.getSpawnPoint() != null + ? cell.getSpawnPoint() + : cell.getCorePos(); + float pulse = + 0.4f + 0.2f * (float) Math.sin(System.currentTimeMillis() / 300.0); + float[] spawnColor = { + COLOR_SPAWN[0], + COLOR_SPAWN[1], + COLOR_SPAWN[2], + pulse, + }; + renderFilledBlock(poseStack, spawnPoint, cameraPos, spawnColor); + + // 2. Render wall blocks + renderPositionCollection( + poseStack, + cell.getWallBlocks(), + cameraPos, + COLOR_WALL + ); + + // 3. Render anchors + renderPositionCollection( + poseStack, + cell.getAnchors(), + cameraPos, + COLOR_ANCHOR + ); + + // 4. Render beds + renderPositionCollection( + poseStack, + cell.getBeds(), + cameraPos, + COLOR_BED + ); + + // 5. Render doors + renderPositionCollection( + poseStack, + cell.getDoors(), + cameraPos, + COLOR_DOOR + ); + + // 6. Render delivery point if present + BlockPos deliveryPoint = cell.getDeliveryPoint(); + if (deliveryPoint != null) { + float[] deliveryFillColor = { + COLOR_DELIVERY[0], + COLOR_DELIVERY[1], + COLOR_DELIVERY[2], + 0.35f, + }; + renderFilledBlock( + poseStack, + deliveryPoint, + cameraPos, + deliveryFillColor + ); + } + + // 7. Render path waypoints with pulsating effect and numbers + java.util.List waypoints = cell.getPathWaypoints(); + if (!waypoints.isEmpty()) { + float waypointPulse = + 0.5f + + 0.3f * (float) Math.sin(System.currentTimeMillis() / 200.0); + float[] waypointColor = { + COLOR_WAYPOINT[0], + COLOR_WAYPOINT[1], + COLOR_WAYPOINT[2], + waypointPulse, + }; + + for (int i = 0; i < waypoints.size(); i++) { + BlockPos wp = waypoints.get(i); + renderFilledBlock(poseStack, wp, cameraPos, waypointColor); + // Render number above waypoint + renderWaypointNumber(poseStack, wp, cameraPos, i + 1); + } + } + + // Restore rendering state + RenderSystem.depthMask(true); + RenderSystem.disableBlend(); + } + + /** + * Render a collection of positions with the given base color at 0.35 alpha. + */ + private static void renderPositionCollection( + PoseStack poseStack, + Collection positions, + Vec3 cameraPos, + float[] baseColor + ) { + if (positions == null || positions.isEmpty()) return; + + float[] fillColor = { baseColor[0], baseColor[1], baseColor[2], 0.35f }; + + for (BlockPos pos : positions) { + renderFilledBlock(poseStack, pos, cameraPos, fillColor); + } + } + + /** + * Render a filled block (semi-transparent cube). + */ + public static void renderFilledBlock( + PoseStack poseStack, + BlockPos pos, + Vec3 cameraPos, + float[] color + ) { + poseStack.pushPose(); + + double x = pos.getX() - cameraPos.x; + double y = pos.getY() - cameraPos.y; + double z = pos.getZ() - cameraPos.z; + + poseStack.translate(x, y, z); + + Matrix4f matrix = poseStack.last().pose(); + + Tesselator tesselator = Tesselator.getInstance(); + BufferBuilder buffer = tesselator.getBuilder(); + + buffer.begin( + VertexFormat.Mode.QUADS, + DefaultVertexFormat.POSITION_COLOR + ); + + float r = color[0]; + float g = color[1]; + float b = color[2]; + float a = color[3]; + + float min = 0.0f; + float max = 1.0f; + + // Bottom face + vertex(buffer, matrix, min, min, min, r, g, b, a); + vertex(buffer, matrix, max, min, min, r, g, b, a); + vertex(buffer, matrix, max, min, max, r, g, b, a); + vertex(buffer, matrix, min, min, max, r, g, b, a); + + // Top face + vertex(buffer, matrix, min, max, min, r, g, b, a); + vertex(buffer, matrix, min, max, max, r, g, b, a); + vertex(buffer, matrix, max, max, max, r, g, b, a); + vertex(buffer, matrix, max, max, min, r, g, b, a); + + // North face + vertex(buffer, matrix, min, min, min, r, g, b, a); + vertex(buffer, matrix, min, max, min, r, g, b, a); + vertex(buffer, matrix, max, max, min, r, g, b, a); + vertex(buffer, matrix, max, min, min, r, g, b, a); + + // South face + vertex(buffer, matrix, min, min, max, r, g, b, a); + vertex(buffer, matrix, max, min, max, r, g, b, a); + vertex(buffer, matrix, max, max, max, r, g, b, a); + vertex(buffer, matrix, min, max, max, r, g, b, a); + + // West face + vertex(buffer, matrix, min, min, min, r, g, b, a); + vertex(buffer, matrix, min, min, max, r, g, b, a); + vertex(buffer, matrix, min, max, max, r, g, b, a); + vertex(buffer, matrix, min, max, min, r, g, b, a); + + // East face + vertex(buffer, matrix, max, min, min, r, g, b, a); + vertex(buffer, matrix, max, max, min, r, g, b, a); + vertex(buffer, matrix, max, max, max, r, g, b, a); + vertex(buffer, matrix, max, min, max, r, g, b, a); + + tesselator.end(); + + poseStack.popPose(); + } + + private static void vertex( + BufferBuilder buffer, + Matrix4f matrix, + float x, + float y, + float z, + float r, + float g, + float b, + float a + ) { + buffer.vertex(matrix, x, y, z).color(r, g, b, a).endVertex(); + } + + /** + * Render a waypoint number floating above the block. + */ + public static void renderWaypointNumber( + PoseStack poseStack, + BlockPos pos, + Vec3 cameraPos, + int number + ) { + poseStack.pushPose(); + + double x = pos.getX() + 0.5 - cameraPos.x; + double y = pos.getY() + 1.5 - cameraPos.y; + double z = pos.getZ() + 0.5 - cameraPos.z; + + poseStack.translate(x, y, z); + + // Billboard effect - face the camera + net.minecraft.client.Minecraft mc = + net.minecraft.client.Minecraft.getInstance(); + poseStack.mulPose(mc.getEntityRenderDispatcher().cameraOrientation()); + poseStack.scale(-0.05f, -0.05f, 0.05f); + + // Render the number + String text = String.valueOf(number); + net.minecraft.client.gui.Font font = mc.font; + float textWidth = font.width(text); + + // Background + net.minecraft.client.renderer.MultiBufferSource.BufferSource buffer = mc + .renderBuffers() + .bufferSource(); + + font.drawInBatch( + text, + -textWidth / 2, + 0, + 0x00FF80, // Bright green + false, + poseStack.last().pose(), + buffer, + net.minecraft.client.gui.Font.DisplayMode.NORMAL, + 0x80000000, // Semi-transparent black background + 15728880 + ); + buffer.endBatch(); + + poseStack.popPose(); + } + + /** + * Get the color for a marker type. + */ + public static float[] getColorForType(MarkerType type) { + return switch (type) { + case WALL -> COLOR_WALL; + case ANCHOR -> COLOR_ANCHOR; + case BED -> COLOR_BED; + case DOOR -> COLOR_DOOR; + case DELIVERY -> COLOR_DELIVERY; + case ENTRANCE -> COLOR_ENTRANCE; + case PATROL -> COLOR_PATROL; + case LOOT -> COLOR_LOOT; + case SPAWNER -> COLOR_SPAWNER; + case TRADER_SPAWN -> COLOR_TRADER_SPAWN; + case MAID_SPAWN -> COLOR_MAID_SPAWN; + case MERCHANT_SPAWN -> COLOR_MERCHANT_SPAWN; + }; + } + + /** + * Get the spawn point color. + */ + public static float[] getSpawnColor() { + return COLOR_SPAWN; + } +} diff --git a/src/main/java/com/tiedup/remake/client/renderer/DamselRenderer.java b/src/main/java/com/tiedup/remake/client/renderer/DamselRenderer.java new file mode 100644 index 0000000..8b4577a --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/renderer/DamselRenderer.java @@ -0,0 +1,224 @@ +package com.tiedup.remake.client.renderer; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import com.tiedup.remake.client.animation.render.RenderConstants; +import com.tiedup.remake.client.model.DamselModel; +import com.tiedup.remake.compat.wildfire.WildfireCompat; +import com.tiedup.remake.compat.wildfire.render.WildfireDamselLayer; +import com.tiedup.remake.entities.AbstractTiedUpNpc; +import net.minecraft.client.model.HumanoidArmorModel; +import net.minecraft.client.model.geom.ModelLayers; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.HumanoidMobRenderer; +import net.minecraft.client.renderer.entity.layers.HumanoidArmorLayer; +import net.minecraft.client.renderer.entity.layers.ItemInHandLayer; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Renderer for AbstractTiedUpNpc and all subtypes (Kidnapper, Elite, Archer, Merchant, Shiny). + * + *

Uses ISkinnedEntity interface for polymorphic texture lookup. + * Each entity subclass overrides getSkinTexture() to return the appropriate texture. + * + *

Issue #19 fix: Replaced 6+ instanceof checks with single interface call. + */ +@OnlyIn(Dist.CLIENT) +public class DamselRenderer + extends HumanoidMobRenderer +{ + + /** + * Normal arms model (4px wide - Steve model). + */ + private final DamselModel normalModel; + + /** + * Slim arms model (3px wide - Alex model). + */ + private final DamselModel slimModel; + + /** + * Create renderer. + * + * Phase 19: Uses vanilla ModelLayers.PLAYER for full layer support (jacket, sleeves, pants). + */ + public DamselRenderer(EntityRendererProvider.Context context) { + super( + context, + new DamselModel(context.bakeLayer(ModelLayers.PLAYER), false), + 0.5f // Shadow radius + ); + // Store both models for runtime swapping + this.normalModel = this.getModel(); + this.slimModel = new DamselModel( + context.bakeLayer(ModelLayers.PLAYER_SLIM), + true + ); + + // Add armor render layer (renders equipped armor) + this.addLayer( + new HumanoidArmorLayer<>( + this, + new HumanoidArmorModel<>( + context.bakeLayer(ModelLayers.PLAYER_INNER_ARMOR) + ), + new HumanoidArmorModel<>( + context.bakeLayer(ModelLayers.PLAYER_OUTER_ARMOR) + ), + context.getModelManager() + ) + ); + + // Add item in hand layer (renders held items) + this.addLayer( + new ItemInHandLayer<>(this, context.getItemInHandRenderer()) + ); + + // Add Wildfire breast layer BEFORE bondage (so bondage renders on top of breasts) + if (WildfireCompat.isLoaded()) { + this.addLayer( + new WildfireDamselLayer<>(this, context.getModelSet()) + ); + } + + // Add V2 bondage render layer (GLB-based V2 equipment rendering) + this.addLayer(new com.tiedup.remake.v2.bondage.client.V2BondageRenderLayer<>(this)); + } + + /** + * Render the entity. + * Uses entity's hasSlimArms() for model selection. + * + * Phase 19: Wearer layer hiding is now handled in DamselModel.setupAnim() + * to ensure it happens after visibility resets. + * + * DOG pose: X rotation is applied in setupRotations() AFTER Y rotation, + * so the "belly down" direction follows entity facing. + * Head compensation is applied in DamselModel.setupAnim(). + * Body rotation smoothing is handled in AbstractTiedUpNpc.tick(). + */ + @Override + public void render( + AbstractTiedUpNpc entity, + float entityYaw, + float partialTicks, + PoseStack poseStack, + MultiBufferSource buffer, + int packedLight + ) { + // Use entity's hasSlimArms() - each entity type overrides this appropriately + boolean useSlim = entity.hasSlimArms(); + + // Swap to appropriate model + this.model = useSlim ? this.slimModel : this.normalModel; + + // Apply vertical offset for sitting/kneeling/dog poses + // This ensures the model AND all layers (gag, blindfold, etc.) move together + float verticalOffset = getVerticalOffset(entity); + boolean pushedPose = false; + if (verticalOffset != 0) { + poseStack.pushPose(); + pushedPose = true; + // Convert from model units (16 = 1 block) to render units + poseStack.translate(0, verticalOffset / 16.0, 0); + } + + // Call parent render + // Note: Wearer layer hiding happens in DamselModel.setupAnim() + super.render( + entity, + entityYaw, + partialTicks, + poseStack, + buffer, + packedLight + ); + + if (pushedPose) { + poseStack.popPose(); + } + } + + /** + * Get vertical offset for sitting/kneeling/dog poses. + * Returns offset in model units (16 units = 1 block). + * + * @param entity The entity to check + * @return Vertical offset (negative = down) + */ + private float getVerticalOffset(AbstractTiedUpNpc entity) { + if (entity.isSitting()) { + return RenderConstants.DAMSEL_SIT_OFFSET; + } else if (entity.isKneeling()) { + return RenderConstants.DAMSEL_KNEEL_OFFSET; + } else if (entity.isDogPose()) { + return RenderConstants.DAMSEL_DOG_OFFSET; + } + return 0; + } + + /** + * Get the texture location based on entity type. + * + *

Issue #19 fix: Uses ISkinnedEntity interface instead of instanceof cascade. + * Each entity subclass implements getSkinTexture() to return appropriate texture. + */ + @Override + public ResourceLocation getTextureLocation(AbstractTiedUpNpc entity) { + // ISkinnedEntity provides polymorphic skin texture lookup + // Each entity type (Damsel, Kidnapper, Elite, Archer, Merchant, Shiny) + // overrides getSkinTexture() to return the correct texture + return entity.getSkinTexture(); + } + + /** + * Apply scale transformation. + */ + @Override + protected void scale( + AbstractTiedUpNpc entity, + PoseStack poseStack, + float partialTick + ) { + poseStack.scale(0.9375f, 0.9375f, 0.9375f); + } + + /** + * Setup rotations for the entity. + * + * DOG pose: After Y rotation is applied by parent, add X rotation + * to make the body horizontal. This is applied in entity-local space, + * so the "belly down" direction follows the entity's facing. + */ + @Override + protected void setupRotations( + AbstractTiedUpNpc entity, + PoseStack poseStack, + float ageInTicks, + float rotationYaw, + float partialTicks + ) { + // Call parent to apply Y rotation (body facing) + super.setupRotations( + entity, + poseStack, + ageInTicks, + rotationYaw, + partialTicks + ); + + // DOG pose: Apply X rotation to make body horizontal + // This happens AFTER Y rotation, so it's in entity-local space + if (entity.isDogPose()) { + // Rotate -90° on X axis around the model's pivot point + // Pivot at waist height (12 model units = 0.75 blocks up from feet) + poseStack.translate(0, 0.75, 0); + poseStack.mulPose(Axis.XP.rotationDegrees(-90)); + poseStack.translate(0, -0.75, 0); + } + } +} diff --git a/src/main/java/com/tiedup/remake/client/renderer/KidnapBombRenderer.java b/src/main/java/com/tiedup/remake/client/renderer/KidnapBombRenderer.java new file mode 100644 index 0000000..e03fa5d --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/renderer/KidnapBombRenderer.java @@ -0,0 +1,85 @@ +package com.tiedup.remake.client.renderer; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import com.tiedup.remake.blocks.ModBlocks; +import com.tiedup.remake.entities.EntityKidnapBomb; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.block.BlockRenderDispatcher; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.TntMinecartRenderer; +import net.minecraft.client.renderer.texture.TextureAtlas; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.Mth; + +/** + * Renderer for EntityKidnapBomb. + * + * Phase 16: Blocks + * + * Renders the primed kidnap bomb using our custom block texture. + */ +public class KidnapBombRenderer extends EntityRenderer { + + private final BlockRenderDispatcher blockRenderer; + + public KidnapBombRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.5F; + this.blockRenderer = context.getBlockRenderDispatcher(); + } + + @Override + public void render( + EntityKidnapBomb entity, + float entityYaw, + float partialTicks, + PoseStack poseStack, + MultiBufferSource buffer, + int packedLight + ) { + poseStack.pushPose(); + poseStack.translate(0.0F, 0.5F, 0.0F); + + int fuse = entity.getFuse(); + if ((float) fuse - partialTicks + 1.0F < 10.0F) { + float scale = 1.0F - ((float) fuse - partialTicks + 1.0F) / 10.0F; + scale = Mth.clamp(scale, 0.0F, 1.0F); + scale *= scale; + scale *= scale; + float expand = 1.0F + scale * 0.3F; + poseStack.scale(expand, expand, expand); + } + + poseStack.mulPose(Axis.YP.rotationDegrees(-90.0F)); + poseStack.translate(-0.5F, -0.5F, 0.5F); + poseStack.mulPose(Axis.YP.rotationDegrees(90.0F)); + + // Render our custom block instead of vanilla TNT + TntMinecartRenderer.renderWhiteSolidBlock( + this.blockRenderer, + ModBlocks.KIDNAP_BOMB.get().defaultBlockState(), + poseStack, + buffer, + packedLight, + (fuse / 5) % 2 == 0 + ); + + poseStack.popPose(); + super.render( + entity, + entityYaw, + partialTicks, + poseStack, + buffer, + packedLight + ); + } + + @Override + @SuppressWarnings("deprecation") + public ResourceLocation getTextureLocation(EntityKidnapBomb entity) { + return TextureAtlas.LOCATION_BLOCKS; + } +} diff --git a/src/main/java/com/tiedup/remake/client/renderer/NpcFishingBobberRenderer.java b/src/main/java/com/tiedup/remake/client/renderer/NpcFishingBobberRenderer.java new file mode 100644 index 0000000..c725203 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/renderer/NpcFishingBobberRenderer.java @@ -0,0 +1,94 @@ +package com.tiedup.remake.client.renderer; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.math.Axis; +import com.tiedup.remake.entities.NpcFishingBobber; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.ResourceLocation; +import org.joml.Matrix3f; +import org.joml.Matrix4f; + +/** + * Renderer for NpcFishingBobber. + * + * Renders a textured quad using the vanilla fishing hook texture. + * Billboard-style: always faces the camera. + */ +public class NpcFishingBobberRenderer extends EntityRenderer { + + private static final ResourceLocation TEXTURE = new ResourceLocation( + "textures/entity/fishing_hook.png" + ); + private static final RenderType RENDER_TYPE = RenderType.entityCutout( + TEXTURE + ); + + public NpcFishingBobberRenderer(EntityRendererProvider.Context ctx) { + super(ctx); + } + + @Override + public void render( + NpcFishingBobber entity, + float yaw, + float partialTick, + PoseStack poseStack, + MultiBufferSource bufferSource, + int packedLight + ) { + poseStack.pushPose(); + poseStack.scale(0.5F, 0.5F, 0.5F); + poseStack.mulPose(this.entityRenderDispatcher.cameraOrientation()); + poseStack.mulPose(Axis.YP.rotationDegrees(180.0F)); + + VertexConsumer vertexConsumer = bufferSource.getBuffer(RENDER_TYPE); + PoseStack.Pose pose = poseStack.last(); + Matrix4f matrix4f = pose.pose(); + Matrix3f matrix3f = pose.normal(); + + vertex(vertexConsumer, matrix4f, matrix3f, packedLight, 0.0F, 0, 0, 1); + vertex(vertexConsumer, matrix4f, matrix3f, packedLight, 1.0F, 0, 1, 1); + vertex(vertexConsumer, matrix4f, matrix3f, packedLight, 1.0F, 1, 1, 0); + vertex(vertexConsumer, matrix4f, matrix3f, packedLight, 0.0F, 1, 0, 0); + + poseStack.popPose(); + super.render( + entity, + yaw, + partialTick, + poseStack, + bufferSource, + packedLight + ); + } + + private static void vertex( + VertexConsumer consumer, + Matrix4f matrix4f, + Matrix3f matrix3f, + int packedLight, + float x, + int y, + int u, + int v + ) { + consumer + .vertex(matrix4f, x - 0.5F, y - 0.5F, 0.0F) + .color(255, 255, 255, 255) + .uv((float) u, (float) v) + .overlayCoords(OverlayTexture.NO_OVERLAY) + .uv2(packedLight) + .normal(matrix3f, 0.0F, 1.0F, 0.0F) + .endVertex(); + } + + @Override + public ResourceLocation getTextureLocation(NpcFishingBobber entity) { + return TEXTURE; + } +} diff --git a/src/main/java/com/tiedup/remake/client/renderer/RopeArrowRenderer.java b/src/main/java/com/tiedup/remake/client/renderer/RopeArrowRenderer.java new file mode 100644 index 0000000..9718eb1 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/renderer/RopeArrowRenderer.java @@ -0,0 +1,32 @@ +package com.tiedup.remake.client.renderer; + +import com.tiedup.remake.entities.EntityRopeArrow; +import net.minecraft.client.renderer.entity.ArrowRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Renderer for EntityRopeArrow. + * Phase 15: Uses vanilla arrow texture for simplicity. + */ +@OnlyIn(Dist.CLIENT) +public class RopeArrowRenderer extends ArrowRenderer { + + /** Texture for the rope arrow (uses vanilla arrow texture) */ + private static final ResourceLocation TEXTURE = + ResourceLocation.fromNamespaceAndPath( + "minecraft", + "textures/entity/projectiles/arrow.png" + ); + + public RopeArrowRenderer(EntityRendererProvider.Context context) { + super(context); + } + + @Override + public ResourceLocation getTextureLocation(EntityRopeArrow entity) { + return TEXTURE; + } +} diff --git a/src/main/java/com/tiedup/remake/client/renderer/layers/ClothesModelCache.java b/src/main/java/com/tiedup/remake/client/renderer/layers/ClothesModelCache.java new file mode 100644 index 0000000..87f9b34 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/renderer/layers/ClothesModelCache.java @@ -0,0 +1,80 @@ +package com.tiedup.remake.client.renderer.layers; + +import com.tiedup.remake.core.TiedUpMod; +import net.minecraft.client.model.PlayerModel; +import net.minecraft.client.model.geom.EntityModelSet; +import net.minecraft.client.model.geom.ModelLayers; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Cache for PlayerModel instances used for clothes rendering. + * + *

Clothes textures are standard Minecraft skins (64x64) and need to be + * rendered using PlayerModel with correct UV mappings, NOT the bondage item models. + * + *

Two models are cached: + *

    + *
  • Normal (Steve) - 4px wide arms
  • + *
  • Slim (Alex) - 3px wide arms
  • + *
+ * + *

Initialize via {@link #init(EntityModelSet)} during AddLayers event. + */ +@OnlyIn(Dist.CLIENT) +public class ClothesModelCache { + + private static PlayerModel normalModel; + private static PlayerModel slimModel; + private static boolean initialized = false; + + /** + * Initialize the model cache. + * Must be called during EntityRenderersEvent.AddLayers. + * + * @param modelSet The entity model set from the event + */ + public static void init(EntityModelSet modelSet) { + if (initialized) { + return; + } + + normalModel = new PlayerModel<>( + modelSet.bakeLayer(ModelLayers.PLAYER), + false + ); + slimModel = new PlayerModel<>( + modelSet.bakeLayer(ModelLayers.PLAYER_SLIM), + true + ); + initialized = true; + + TiedUpMod.LOGGER.info( + "[ClothesModelCache] Initialized normal and slim player models for clothes rendering" + ); + } + + /** + * Get the appropriate player model for clothes rendering. + * + * @param slim true for slim (Alex) arms, false for normal (Steve) arms + * @return The cached PlayerModel + */ + public static PlayerModel getModel(boolean slim) { + if (!initialized) { + TiedUpMod.LOGGER.warn( + "[ClothesModelCache] Not initialized! Returning null." + ); + return null; + } + return slim ? slimModel : normalModel; + } + + /** + * Check if the cache is initialized. + */ + public static boolean isInitialized() { + return initialized; + } +} diff --git a/src/main/java/com/tiedup/remake/client/renderer/layers/ClothesRenderHelper.java b/src/main/java/com/tiedup/remake/client/renderer/layers/ClothesRenderHelper.java new file mode 100644 index 0000000..42309c2 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/renderer/layers/ClothesRenderHelper.java @@ -0,0 +1,609 @@ +package com.tiedup.remake.client.renderer.layers; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.tiedup.remake.client.state.ClothesClientCache; +import com.tiedup.remake.client.texture.DynamicTextureManager; +import com.tiedup.remake.items.clothes.ClothesProperties; +import com.tiedup.remake.items.clothes.GenericClothes; +import java.util.EnumSet; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.client.model.HumanoidModel; +import net.minecraft.client.model.PlayerModel; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Helper class for rendering clothes with dynamic textures. + * + *

IMPORTANT: Clothes are rendered using a PlayerModel (not bondage item model) + * because clothes textures are standard Minecraft skins (64x64) with player UV mappings. + * + *

Handles: + *

    + *
  • Dynamic texture URL rendering
  • + *
  • Full-skin mode (covers entire player model)
  • + *
  • Small arms mode (Alex/slim arms)
  • + *
  • Layer visibility control
  • + *
  • Steve vs Alex model selection
  • + *
+ * + *

This is called from BondageItemRenderLayer when rendering CLOTHES slot. + */ +@OnlyIn(Dist.CLIENT) +public class ClothesRenderHelper { + + /** + * Attempt to render clothes with dynamic texture. + * Uses PlayerModel (not bondage model) because clothes use skin UV mappings. + * + * @param clothes The clothes ItemStack + * @param entity The entity wearing the clothes + * @param poseStack The pose stack + * @param buffer The render buffer + * @param packedLight The packed light value + * @param packedOverlay The packed overlay value (for hit flash effect) + * @param parentModel The parent PlayerModel to copy pose from + * @return true if dynamic texture was rendered, false otherwise + */ + public static boolean tryRenderDynamic( + ItemStack clothes, + LivingEntity entity, + PoseStack poseStack, + MultiBufferSource buffer, + int packedLight, + int packedOverlay, + PlayerModel parentModel + ) { + if ( + clothes.isEmpty() || + !(clothes.getItem() instanceof GenericClothes gc) + ) { + return false; + } + + // Check if ClothesModelCache is initialized + if (!ClothesModelCache.isInitialized()) { + return false; + } + + // Get properties from equipped clothes or remote player cache + ClothesProperties props = getClothesProperties(clothes, entity); + if (props == null) { + return false; + } + + String dynamicUrl = props.dynamicTextureUrl(); + if (dynamicUrl == null || dynamicUrl.isEmpty()) { + return false; + } + + // Get texture from manager + DynamicTextureManager texManager = DynamicTextureManager.getInstance(); + ResourceLocation texLocation = texManager.getTextureLocation( + dynamicUrl, + props.fullSkin() + ); + + if (texLocation == null) { + // Texture not loaded yet (downloading) + return false; + } + + // Determine if we should use slim (Alex) arms + boolean useSlim = shouldUseSlimArms(clothes, entity, props); + + // Get the appropriate PlayerModel from cache + PlayerModel clothesModel = + ClothesModelCache.getModel(useSlim); + if (clothesModel == null) { + return false; + } + + // Copy pose from parent model to clothes model + copyPose(parentModel, clothesModel); + + // Apply layer visibility settings + applyLayerVisibility( + clothesModel, + props.visibleLayers(), + props.keepHead() + ); + + // Render the clothes model with dynamic texture + // Use entityTranslucent for layered rendering (clothes on top of player) + VertexConsumer vertexConsumer = buffer.getBuffer( + RenderType.entityTranslucent(texLocation) + ); + clothesModel.renderToBuffer( + poseStack, + vertexConsumer, + packedLight, + packedOverlay, + 1.0f, + 1.0f, + 1.0f, + 1.0f + ); + + // Restore visibility (model is shared, need to reset for next render) + restoreLayerVisibility(clothesModel); + + return true; + } + + /** + * Render clothes for NPCs (Damsel, etc.) + * Uses PlayerModel from cache, copies pose from parent HumanoidModel. + * + * @param clothes The clothes ItemStack + * @param entity The NPC entity wearing clothes + * @param poseStack The pose stack + * @param buffer The render buffer + * @param packedLight The packed light value + * @param packedOverlay The packed overlay value (for hit flash effect) + * @param parentModel The parent HumanoidModel to copy pose from + * @param hasSlimArms Whether the NPC uses slim arms + * @return true if rendered successfully + */ + public static boolean tryRenderDynamicForNPC( + ItemStack clothes, + LivingEntity entity, + PoseStack poseStack, + MultiBufferSource buffer, + int packedLight, + int packedOverlay, + HumanoidModel parentModel, + boolean hasSlimArms + ) { + if ( + clothes.isEmpty() || !(clothes.getItem() instanceof GenericClothes) + ) { + return false; + } + + if (!ClothesModelCache.isInitialized()) { + return false; + } + + // Get properties directly from item (NPCs don't use remote cache) + ClothesProperties props = ClothesProperties.fromStack(clothes); + if (props == null) { + return false; + } + + String dynamicUrl = props.dynamicTextureUrl(); + if (dynamicUrl == null || dynamicUrl.isEmpty()) { + return false; + } + + // Get texture from manager + ResourceLocation texLocation = + DynamicTextureManager.getInstance().getTextureLocation( + dynamicUrl, + props.fullSkin() + ); + if (texLocation == null) { + return false; // Still downloading + } + + // Use slim if: clothes force it OR NPC has slim arms + boolean useSlim = props.smallArms() || hasSlimArms; + + // Get PlayerModel from cache (same cache as players) + PlayerModel clothesModel = + ClothesModelCache.getModel(useSlim); + if (clothesModel == null) { + return false; + } + + // Copy pose from HumanoidModel (NPC) to PlayerModel (clothes) + copyPoseFromHumanoid(parentModel, clothesModel); + + // Apply layer visibility + applyLayerVisibility( + clothesModel, + props.visibleLayers(), + props.keepHead() + ); + + // Render + VertexConsumer vertexConsumer = buffer.getBuffer( + RenderType.entityTranslucent(texLocation) + ); + clothesModel.renderToBuffer( + poseStack, + vertexConsumer, + packedLight, + packedOverlay, + 1.0f, + 1.0f, + 1.0f, + 1.0f + ); + + // Restore visibility + restoreLayerVisibility(clothesModel); + + return true; + } + + /** + * Copy pose from HumanoidModel (NPC) to PlayerModel (clothes). + * PlayerModel has extra parts (jacket, sleeves, pants) that HumanoidModel lacks, + * so we copy those from the corresponding base parts. + */ + private static void copyPoseFromHumanoid( + HumanoidModel source, + PlayerModel dest + ) { + // Base parts (exist in both models) + dest.head.copyFrom(source.head); + dest.hat.copyFrom(source.hat); + dest.body.copyFrom(source.body); + dest.rightArm.copyFrom(source.rightArm); + dest.leftArm.copyFrom(source.leftArm); + dest.rightLeg.copyFrom(source.rightLeg); + dest.leftLeg.copyFrom(source.leftLeg); + + // PlayerModel-only parts: copy from corresponding base parts + dest.jacket.copyFrom(source.body); + dest.leftSleeve.copyFrom(source.leftArm); + dest.rightSleeve.copyFrom(source.rightArm); + dest.leftPants.copyFrom(source.leftLeg); + dest.rightPants.copyFrom(source.rightLeg); + + // Animation flags + dest.crouching = source.crouching; + dest.riding = source.riding; + dest.young = source.young; + } + + /** + * Copy pose from source PlayerModel to destination PlayerModel. + * This ensures the clothes model matches the player's current pose/animation. + */ + private static void copyPose(PlayerModel source, PlayerModel dest) { + // Main body parts + dest.head.copyFrom(source.head); + dest.hat.copyFrom(source.hat); + dest.body.copyFrom(source.body); + dest.rightArm.copyFrom(source.rightArm); + dest.leftArm.copyFrom(source.leftArm); + dest.rightLeg.copyFrom(source.rightLeg); + dest.leftLeg.copyFrom(source.leftLeg); + + // Outer layer parts (jacket, sleeves, pants) + dest.jacket.copyFrom(source.jacket); + dest.leftSleeve.copyFrom(source.leftSleeve); + dest.rightSleeve.copyFrom(source.rightSleeve); + dest.leftPants.copyFrom(source.leftPants); + dest.rightPants.copyFrom(source.rightPants); + + // Copy other properties + dest.crouching = source.crouching; + dest.young = source.young; + } + + /** + * Determine if slim (Alex) arms should be used. + * Priority: 1) Clothes force small arms, 2) Player's actual model type + */ + private static boolean shouldUseSlimArms( + ItemStack clothes, + LivingEntity entity, + ClothesProperties props + ) { + // 1. Check if clothes item forces small arms + if (props.smallArms()) { + return true; + } + + // 2. Check local item setting + if (clothes.getItem() instanceof GenericClothes gc) { + if (gc.shouldForceSmallArms(clothes)) { + return true; + } + } + + // 3. Follow player's actual model type + if (entity instanceof AbstractClientPlayer player) { + String modelName = player.getModelName(); + return "slim".equals(modelName); + } + + return false; // Default: normal (Steve) arms + } + + /** + * Get clothes properties, checking both equipped item and remote player cache. + */ + @Nullable + private static ClothesProperties getClothesProperties( + ItemStack clothes, + LivingEntity entity + ) { + // First try to get from the equipped item directly + ClothesProperties localProps = ClothesProperties.fromStack(clothes); + + // If entity is a player, also check remote cache (for other players' clothes) + if (entity instanceof AbstractClientPlayer player) { + UUID playerUUID = player.getUUID(); + ClothesClientCache.CachedClothesData cached = + ClothesClientCache.getPlayerClothes(playerUUID); + + if (cached != null && cached.hasDynamicTexture()) { + // Use cached data (synced from server) + return new ClothesProperties( + cached.dynamicUrl(), + cached.fullSkin(), + cached.smallArms(), + cached.keepHead(), + cached.visibleLayers() + ); + } + } + + return localProps; + } + + /** + * Apply layer visibility from clothes properties. + * Controls which parts of the outer layer (jacket, sleeves, pants) are visible. + * + * @param model The clothes PlayerModel + * @param visible Which layers are enabled on the clothes + * @param keepHead If true, hide the clothes head/hat (wearer's head shows instead) + */ + private static void applyLayerVisibility( + PlayerModel model, + EnumSet visible, + boolean keepHead + ) { + // Main body parts visibility + // If keepHead is true, hide clothes head so wearer's head shows through + model.head.visible = !keepHead; + model.body.visible = true; + model.rightArm.visible = true; + model.leftArm.visible = true; + model.rightLeg.visible = true; + model.leftLeg.visible = true; + + // Outer layer parts controlled by settings + // If keepHead is true, hide clothes hat so wearer's hat shows through + model.hat.visible = + !keepHead && visible.contains(ClothesProperties.LayerPart.HEAD); + model.jacket.visible = visible.contains( + ClothesProperties.LayerPart.BODY + ); + model.leftSleeve.visible = visible.contains( + ClothesProperties.LayerPart.LEFT_ARM + ); + model.rightSleeve.visible = visible.contains( + ClothesProperties.LayerPart.RIGHT_ARM + ); + model.leftPants.visible = visible.contains( + ClothesProperties.LayerPart.LEFT_LEG + ); + model.rightPants.visible = visible.contains( + ClothesProperties.LayerPart.RIGHT_LEG + ); + } + + /** + * Restore all layer visibility to default (all visible). + * Important because the model is cached and shared. + */ + private static void restoreLayerVisibility(PlayerModel model) { + model.head.visible = true; + model.hat.visible = true; + model.body.visible = true; + model.jacket.visible = true; + model.rightArm.visible = true; + model.leftArm.visible = true; + model.rightSleeve.visible = true; + model.leftSleeve.visible = true; + model.rightLeg.visible = true; + model.leftLeg.visible = true; + model.rightPants.visible = true; + model.leftPants.visible = true; + } + + /** + * Check if clothes should force small arms rendering. + * Public method for BondageItemRenderLayer. + */ + public static boolean shouldUseSmallArms( + ItemStack clothes, + LivingEntity entity + ) { + if ( + clothes.isEmpty() || + !(clothes.getItem() instanceof GenericClothes gc) + ) { + return false; + } + + // Check local item + if (gc.shouldForceSmallArms(clothes)) { + return true; + } + + // Check remote cache + if (entity instanceof AbstractClientPlayer player) { + return ClothesClientCache.isSmallArmsForced(player.getUUID()); + } + + return false; + } + + /** + * Check if clothes have a dynamic texture (for deciding render path). + */ + public static boolean hasDynamicTexture( + ItemStack clothes, + LivingEntity entity + ) { + if ( + clothes.isEmpty() || + !(clothes.getItem() instanceof GenericClothes gc) + ) { + return false; + } + + // Check local item + String localUrl = gc.getDynamicTextureUrl(clothes); + if (localUrl != null && !localUrl.isEmpty()) { + return true; + } + + // Check remote cache + if (entity instanceof AbstractClientPlayer player) { + String cachedUrl = ClothesClientCache.getPlayerDynamicUrl( + player.getUUID() + ); + return cachedUrl != null && !cachedUrl.isEmpty(); + } + + return false; + } + + /** + * Check if full-skin mode is enabled for clothes. + */ + public static boolean isFullSkinMode( + ItemStack clothes, + LivingEntity entity + ) { + if ( + clothes.isEmpty() || + !(clothes.getItem() instanceof GenericClothes gc) + ) { + return false; + } + + // Check local item + if (gc.isFullSkinEnabled(clothes)) { + return true; + } + + // Check remote cache + if (entity instanceof AbstractClientPlayer player) { + return ClothesClientCache.isFullSkinEnabled(player.getUUID()); + } + + return false; + } + + // ==================== Wearer Layer Hiding ==================== + + /** + * Hide wearer's outer layers when clothes are equipped. + * Called BEFORE rendering the base model. + * + *

Logic: When clothes are equipped, hide ALL wearer's outer layers. + * The clothes will render their own layers on top. + * Exception: If keepHead is enabled, the head/hat layers remain visible. + * + * @param model The wearer's PlayerModel + * @param props Clothes properties (used to confirm clothes are valid) + * @return Original visibility state for restoration (6 booleans) + */ + public static boolean[] hideWearerLayers( + PlayerModel model, + ClothesProperties props + ) { + if (props == null) { + return null; + } + + // Save original state + boolean[] original = { + model.hat.visible, + model.jacket.visible, + model.leftSleeve.visible, + model.rightSleeve.visible, + model.leftPants.visible, + model.rightPants.visible, + }; + + // When wearing clothes, hide wearer's outer layers + // Exception: if keepHead is true, don't hide head/hat + if (!props.keepHead()) { + model.hat.visible = false; + } + model.jacket.visible = false; + model.leftSleeve.visible = false; + model.rightSleeve.visible = false; + model.leftPants.visible = false; + model.rightPants.visible = false; + + return original; + } + + /** + * Restore wearer's layer visibility after rendering. + * + * @param model The wearer's PlayerModel + * @param original Original visibility state from hideWearerLayers() + */ + public static void restoreWearerLayers( + PlayerModel model, + boolean[] original + ) { + if (original == null || original.length != 6) { + return; + } + + model.hat.visible = original[0]; + model.jacket.visible = original[1]; + model.leftSleeve.visible = original[2]; + model.rightSleeve.visible = original[3]; + model.leftPants.visible = original[4]; + model.rightPants.visible = original[5]; + } + + /** + * Get clothes properties for wearer layer hiding. + * Works for both players (with remote cache) and NPCs (direct from item). + * + * @param clothes The clothes ItemStack + * @param entity The entity wearing clothes + * @return ClothesProperties or null if not available + */ + public static ClothesProperties getPropsForLayerHiding( + ItemStack clothes, + LivingEntity entity + ) { + if ( + clothes.isEmpty() || !(clothes.getItem() instanceof GenericClothes) + ) { + return null; + } + + // For players, check remote cache first + if (entity instanceof AbstractClientPlayer player) { + ClothesClientCache.CachedClothesData cached = + ClothesClientCache.getPlayerClothes(player.getUUID()); + if (cached != null) { + return new ClothesProperties( + cached.dynamicUrl(), + cached.fullSkin(), + cached.smallArms(), + cached.keepHead(), + cached.visibleLayers() + ); + } + } + + // Fall back to direct item properties + return ClothesProperties.fromStack(clothes); + } +} diff --git a/src/main/java/com/tiedup/remake/client/renderer/models/BondageLayerDefinitions.java b/src/main/java/com/tiedup/remake/client/renderer/models/BondageLayerDefinitions.java new file mode 100644 index 0000000..9bf052a --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/renderer/models/BondageLayerDefinitions.java @@ -0,0 +1,144 @@ +package com.tiedup.remake.client.renderer.models; + +import net.minecraft.client.model.geom.ModelLayerLocation; +import net.minecraft.client.model.geom.PartPose; +import net.minecraft.client.model.geom.builders.CubeDeformation; +import net.minecraft.client.model.geom.builders.CubeListBuilder; +import net.minecraft.client.model.geom.builders.LayerDefinition; +import net.minecraft.client.model.geom.builders.MeshDefinition; +import net.minecraft.client.model.geom.builders.PartDefinition; +import net.minecraft.resources.ResourceLocation; + +public class BondageLayerDefinitions { + + public static final ModelLayerLocation BONDAGE_LAYER = + new ModelLayerLocation( + ResourceLocation.fromNamespaceAndPath("tiedup", "bondage_layer"), + "main" + ); + + public static final ModelLayerLocation BONDAGE_LAYER_SLIM = + new ModelLayerLocation( + ResourceLocation.fromNamespaceAndPath( + "tiedup", + "bondage_layer_slim" + ), + "main" + ); + + /** + * Create bondage layer for normal arms (4px wide - Steve model). + */ + public static LayerDefinition createBodyLayer() { + return createBondageLayer(4); + } + + /** + * Create bondage layer for slim arms (3px wide - Alex model). + */ + public static LayerDefinition createSlimBodyLayer() { + return createBondageLayer(3); + } + + /** + * Create bondage layer with specified arm width. + * + * @param armWidth Arm width in pixels (4 for Steve, 3 for Alex) + */ + private static LayerDefinition createBondageLayer(int armWidth) { + MeshDefinition meshdefinition = new MeshDefinition(); + PartDefinition partdefinition = meshdefinition.getRoot(); + + // Inflation for tight fit on body + CubeDeformation deformation = new CubeDeformation(0.35F); + + // Head at standard position + partdefinition.addOrReplaceChild( + "head", + CubeListBuilder.create() + .texOffs(0, 0) + .addBox(-4.0F, -8.0F, -4.0F, 8.0F, 8.0F, 8.0F, deformation), + PartPose.offset(0.0F, 0.0F, 0.0F) + ); + + partdefinition.addOrReplaceChild( + "hat", + CubeListBuilder.create() + .texOffs(32, 0) + .addBox( + -4.0F, + -8.0F, + -4.0F, + 8.0F, + 8.0F, + 8.0F, + deformation.extend(0.1F) + ), + PartPose.offset(0.0F, 0.0F, 0.0F) + ); + + partdefinition.addOrReplaceChild( + "body", + CubeListBuilder.create() + .texOffs(16, 16) + .addBox(-4.0F, 0.0F, -2.0F, 8.0F, 12.0F, 4.0F, deformation), + PartPose.offset(0.0F, 0.0F, 0.0F) + ); + + // Arms - width varies based on model type + float armWidthF = (float) armWidth; + float rightArmX = -(armWidthF - 1.0F); // -3 for 4px, -2 for 3px + + partdefinition.addOrReplaceChild( + "right_arm", + CubeListBuilder.create() + .texOffs(40, 16) + .addBox( + rightArmX, + -2.0F, + -2.0F, + armWidthF, + 12.0F, + 4.0F, + deformation + ), + PartPose.offset(-5.0F, 2.0F, 0.0F) + ); + + partdefinition.addOrReplaceChild( + "left_arm", + CubeListBuilder.create() + .texOffs(40, 16) + .mirror() + .addBox( + -1.0F, + -2.0F, + -2.0F, + armWidthF, + 12.0F, + 4.0F, + deformation + ), + PartPose.offset(5.0F, 2.0F, 0.0F) + ); + + partdefinition.addOrReplaceChild( + "right_leg", + CubeListBuilder.create() + .texOffs(0, 16) + .addBox(-2.0F, 0.0F, -2.0F, 4.0F, 12.0F, 4.0F, deformation), + PartPose.offset(-1.9F, 12.0F, 0.0F) + ); + + partdefinition.addOrReplaceChild( + "left_leg", + CubeListBuilder.create() + .texOffs(0, 16) + .mirror() + .addBox(-2.0F, 0.0F, -2.0F, 4.0F, 12.0F, 4.0F, deformation), + PartPose.offset(1.9F, 12.0F, 0.0F) + ); + + return LayerDefinition.create(meshdefinition, 64, 32); + } +} diff --git a/src/main/java/com/tiedup/remake/client/renderer/obj/ObjFace.java b/src/main/java/com/tiedup/remake/client/renderer/obj/ObjFace.java new file mode 100644 index 0000000..8a71c53 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/renderer/obj/ObjFace.java @@ -0,0 +1,44 @@ +package com.tiedup.remake.client.renderer.obj; + +/** + * Immutable record representing a triangular face in an OBJ model. + * Contains exactly 3 vertices forming a triangle. + */ +public record ObjFace(ObjVertex v0, ObjVertex v1, ObjVertex v2) { + /** + * Get the vertices as an array for iteration. + */ + public ObjVertex[] vertices() { + return new ObjVertex[] { v0, v1, v2 }; + } + + /** + * Calculate the face normal from vertices (cross product of edges). + * Useful when the OBJ file doesn't provide normals. + */ + public float[] calculateNormal() { + // Edge vectors + float e1x = v1.x() - v0.x(); + float e1y = v1.y() - v0.y(); + float e1z = v1.z() - v0.z(); + + float e2x = v2.x() - v0.x(); + float e2y = v2.y() - v0.y(); + float e2z = v2.z() - v0.z(); + + // Cross product + float nx = e1y * e2z - e1z * e2y; + float ny = e1z * e2x - e1x * e2z; + float nz = e1x * e2y - e1y * e2x; + + // Normalize + float len = (float) Math.sqrt(nx * nx + ny * ny + nz * nz); + if (len > 0.0001f) { + nx /= len; + ny /= len; + nz /= len; + } + + return new float[] { nx, ny, nz }; + } +} diff --git a/src/main/java/com/tiedup/remake/client/renderer/obj/ObjMaterial.java b/src/main/java/com/tiedup/remake/client/renderer/obj/ObjMaterial.java new file mode 100644 index 0000000..2183f79 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/renderer/obj/ObjMaterial.java @@ -0,0 +1,57 @@ +package com.tiedup.remake.client.renderer.obj; + +import org.jetbrains.annotations.Nullable; + +/** + * Immutable record representing a material from an MTL file. + * Contains the material name, diffuse color (Kd), and optional diffuse texture (map_Kd). + */ +public record ObjMaterial( + String name, + float r, + float g, + float b, + @Nullable String texturePath +) { + /** + * Default white material used when no material is specified. + */ + public static final ObjMaterial DEFAULT = new ObjMaterial( + "default", + 1.0f, + 1.0f, + 1.0f, + null + ); + + /** + * Create a material with just color (no texture). + */ + public ObjMaterial(String name, float r, float g, float b) { + this(name, r, g, b, null); + } + + /** + * Create a material with grayscale color. + */ + public static ObjMaterial ofGrayscale(String name, float value) { + return new ObjMaterial(name, value, value, value, null); + } + + /** + * Check if this material has a diffuse texture. + */ + public boolean hasTexture() { + return texturePath != null && !texturePath.isEmpty(); + } + + /** + * Get the color as a packed ARGB int (with full alpha). + */ + public int toARGB() { + int ri = (int) (r * 255) & 0xFF; + int gi = (int) (g * 255) & 0xFF; + int bi = (int) (b * 255) & 0xFF; + return 0xFF000000 | (ri << 16) | (gi << 8) | bi; + } +} diff --git a/src/main/java/com/tiedup/remake/client/renderer/obj/ObjModel.java b/src/main/java/com/tiedup/remake/client/renderer/obj/ObjModel.java new file mode 100644 index 0000000..5294ded --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/renderer/obj/ObjModel.java @@ -0,0 +1,258 @@ +package com.tiedup.remake.client.renderer.obj; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.resources.ResourceLocation; +import org.jetbrains.annotations.Nullable; + +/** + * Container for a loaded OBJ model. + * Holds faces grouped by material and material definitions. + * Immutable after construction. + */ +public class ObjModel { + + private final Map> facesByMaterial; + private final Map materials; + private final int totalFaces; + private final int totalVertices; + + /** Base path for resolving relative texture paths (e.g., "tiedup:textures/models/obj/") */ + @Nullable + private final String textureBasePath; + + // Bounding box (AABB) + private final float minX, minY, minZ; + private final float maxX, maxY, maxZ; + + private ObjModel(Builder builder) { + this.facesByMaterial = Collections.unmodifiableMap( + new HashMap<>(builder.facesByMaterial) + ); + this.materials = Collections.unmodifiableMap( + new HashMap<>(builder.materials) + ); + this.textureBasePath = builder.textureBasePath; + + // Calculate totals + int faces = 0; + for (List faceList : this.facesByMaterial.values()) { + faces += faceList.size(); + } + this.totalFaces = faces; + this.totalVertices = faces * 3; + + // Calculate AABB + float minX = Float.MAX_VALUE, + minY = Float.MAX_VALUE, + minZ = Float.MAX_VALUE; + float maxX = -Float.MAX_VALUE, + maxY = -Float.MAX_VALUE, + maxZ = -Float.MAX_VALUE; + + for (List faceList : this.facesByMaterial.values()) { + for (ObjFace face : faceList) { + for (ObjVertex v : face.vertices()) { + minX = Math.min(minX, v.x()); + minY = Math.min(minY, v.y()); + minZ = Math.min(minZ, v.z()); + maxX = Math.max(maxX, v.x()); + maxY = Math.max(maxY, v.y()); + maxZ = Math.max(maxZ, v.z()); + } + } + } + + this.minX = minX == Float.MAX_VALUE ? 0 : minX; + this.minY = minY == Float.MAX_VALUE ? 0 : minY; + this.minZ = minZ == Float.MAX_VALUE ? 0 : minZ; + this.maxX = maxX == -Float.MAX_VALUE ? 0 : maxX; + this.maxY = maxY == -Float.MAX_VALUE ? 0 : maxY; + this.maxZ = maxZ == -Float.MAX_VALUE ? 0 : maxZ; + } + + /** + * Get all faces grouped by material name. + */ + public Map> getFacesByMaterial() { + return facesByMaterial; + } + + /** + * Get a material by name. + * Returns default white material if not found. + */ + public ObjMaterial getMaterial(String name) { + return materials.getOrDefault(name, ObjMaterial.DEFAULT); + } + + /** + * Get all materials. + */ + public Map getMaterials() { + return materials; + } + + /** + * Get total number of triangles in the model. + */ + public int getTotalFaces() { + return totalFaces; + } + + /** + * Get total number of vertices (faces * 3). + */ + public int getTotalVertices() { + return totalVertices; + } + + // AABB getters + public float getMinX() { + return minX; + } + + public float getMinY() { + return minY; + } + + public float getMinZ() { + return minZ; + } + + public float getMaxX() { + return maxX; + } + + public float getMaxY() { + return maxY; + } + + public float getMaxZ() { + return maxZ; + } + + /** + * Get the center point of the model's bounding box. + */ + public float[] getCenter() { + return new float[] { + (minX + maxX) / 2f, + (minY + maxY) / 2f, + (minZ + maxZ) / 2f, + }; + } + + /** + * Get the dimensions of the bounding box. + */ + public float[] getDimensions() { + return new float[] { maxX - minX, maxY - minY, maxZ - minZ }; + } + + /** + * Resolve a texture filename to a full ResourceLocation. + * Uses the textureBasePath set during loading. + * + * @param filename The texture filename (e.g., "ball_gag.png") + * @return Full ResourceLocation, or null if no base path is set + */ + @Nullable + public ResourceLocation resolveTexture(String filename) { + if (textureBasePath == null || filename == null) { + return null; + } + return ResourceLocation.fromNamespaceAndPath( + "tiedup", + textureBasePath + filename + ); + } + + /** + * Resolve a texture filename with a color suffix inserted before the extension. + * Example: "texture.png" + "red" -> "texture_red.png" + * + * @param filename The base texture filename (e.g., "texture.png") + * @param colorSuffix The color suffix to insert (e.g., "red"), or null for no suffix + * @return Full ResourceLocation with color suffix, or null if no base path is set + */ + @Nullable + public ResourceLocation resolveTextureWithColorSuffix( + String filename, + @Nullable String colorSuffix + ) { + if (textureBasePath == null || filename == null) { + return null; + } + if (colorSuffix == null || colorSuffix.isEmpty()) { + return resolveTexture(filename); + } + // Insert color suffix before extension: "texture.png" -> "texture_red.png" + int dotIndex = filename.lastIndexOf('.'); + String newFilename; + if (dotIndex > 0) { + newFilename = + filename.substring(0, dotIndex) + + "_" + + colorSuffix + + filename.substring(dotIndex); + } else { + newFilename = filename + "_" + colorSuffix; + } + return ResourceLocation.fromNamespaceAndPath( + "tiedup", + textureBasePath + newFilename + ); + } + + /** + * Get the texture base path. + */ + @Nullable + public String getTextureBasePath() { + return textureBasePath; + } + + /** + * Builder for constructing ObjModel instances. + */ + public static class Builder { + + private final Map> facesByMaterial = + new HashMap<>(); + private final Map materials = new HashMap<>(); + private String textureBasePath; + + public Builder addFaces(String materialName, List faces) { + if (faces != null && !faces.isEmpty()) { + facesByMaterial.put(materialName, List.copyOf(faces)); + } + return this; + } + + public Builder addMaterial(ObjMaterial material) { + if (material != null) { + materials.put(material.name(), material); + } + return this; + } + + public Builder setTextureBasePath(String path) { + this.textureBasePath = path; + return this; + } + + public ObjModel build() { + return new ObjModel(this); + } + } + + /** + * Create a new builder. + */ + public static Builder builder() { + return new Builder(); + } +} diff --git a/src/main/java/com/tiedup/remake/client/renderer/obj/ObjModelLoader.java b/src/main/java/com/tiedup/remake/client/renderer/obj/ObjModelLoader.java new file mode 100644 index 0000000..5733510 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/renderer/obj/ObjModelLoader.java @@ -0,0 +1,417 @@ +package com.tiedup.remake.client.renderer.obj; + +import com.tiedup.remake.core.TiedUpMod; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.minecraft.client.Minecraft; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import org.jetbrains.annotations.Nullable; + +/** + * Parses .obj and .mtl files from ResourceLocations. + * Supports: + * - Vertex positions (v) + * - Texture coordinates (vt) + * - Vertex normals (vn) + * - Faces (f) with v/vt/vn format, triangles and quads + * - Material library (mtllib) + * - Material assignment (usemtl) + * - MTL diffuse color (Kd) + */ +public class ObjModelLoader { + + private ObjModelLoader() { + // Utility class + } + + /** + * Load an OBJ model from a ResourceLocation. + * + * @param location ResourceLocation pointing to the .obj file + * @return Loaded ObjModel, or null if loading failed + */ + @Nullable + public static ObjModel load(ResourceLocation location) { + ResourceManager resourceManager = + Minecraft.getInstance().getResourceManager(); + + try { + Optional resourceOpt = resourceManager.getResource( + location + ); + if (resourceOpt.isEmpty()) { + TiedUpMod.LOGGER.warn("OBJ file not found: {}", location); + return null; + } + + Resource resource = resourceOpt.get(); + + // Parse context + List positions = new ArrayList<>(); + List texCoords = new ArrayList<>(); + List normals = new ArrayList<>(); + Map> facesByMaterial = new HashMap<>(); + Map materials = new HashMap<>(); + + String currentMaterial = "default"; + String mtlLib = null; + + // Parse OBJ file + try ( + BufferedReader reader = new BufferedReader( + new InputStreamReader( + resource.open(), + StandardCharsets.UTF_8 + ) + ) + ) { + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + + String[] parts = line.split("\\s+"); + if (parts.length == 0) continue; + + switch (parts[0]) { + case "v" -> { + // Vertex position: v x y z + if (parts.length >= 4) { + positions.add( + new float[] { + parseFloat(parts[1]), + parseFloat(parts[2]), + parseFloat(parts[3]), + } + ); + } + } + case "vt" -> { + // Texture coordinate: vt u v + if (parts.length >= 3) { + texCoords.add( + new float[] { + parseFloat(parts[1]), + parseFloat(parts[2]), + } + ); + } + } + case "vn" -> { + // Vertex normal: vn x y z + if (parts.length >= 4) { + normals.add( + new float[] { + parseFloat(parts[1]), + parseFloat(parts[2]), + parseFloat(parts[3]), + } + ); + } + } + case "mtllib" -> { + // Material library: mtllib filename.mtl + if (parts.length >= 2) { + mtlLib = parts[1]; + } + } + case "usemtl" -> { + // Use material: usemtl MaterialName + if (parts.length >= 2) { + currentMaterial = parts[1]; + } + } + case "f" -> { + // Face: f v/vt/vn v/vt/vn v/vt/vn [v/vt/vn] + List faceVerts = new ArrayList<>(); + for (int i = 1; i < parts.length; i++) { + ObjVertex vert = parseVertex( + parts[i], + positions, + texCoords, + normals + ); + if (vert != null) { + faceVerts.add(vert); + } + } + + // Triangulate if needed (quad -> 2 triangles) + List faces = triangulate(faceVerts); + facesByMaterial + .computeIfAbsent(currentMaterial, k -> + new ArrayList<>() + ) + .addAll(faces); + } + } + } + } + + // Load MTL file if referenced + if (mtlLib != null) { + ResourceLocation mtlLocation = resolveMtlPath(location, mtlLib); + Map loadedMaterials = loadMtl(mtlLocation); + materials.putAll(loadedMaterials); + } + + // Build and return model + ObjModel.Builder builder = ObjModel.builder(); + for (Map.Entry< + String, + List + > entry : facesByMaterial.entrySet()) { + builder.addFaces(entry.getKey(), entry.getValue()); + } + for (ObjMaterial mat : materials.values()) { + builder.addMaterial(mat); + } + + // Set texture base path (textures are now in the same directory as the OBJ) + String objPath = location.getPath(); + int lastSlash = objPath.lastIndexOf('/'); + String directory = + lastSlash >= 0 ? objPath.substring(0, lastSlash + 1) : ""; + // Textures are in the same folder as the OBJ file + String textureBasePath = directory; + builder.setTextureBasePath(textureBasePath); + + ObjModel model = builder.build(); + TiedUpMod.LOGGER.info( + "Loaded OBJ model: {} ({} faces, {} materials)", + location, + model.getTotalFaces(), + materials.size() + ); + + return model; + } catch (Exception e) { + TiedUpMod.LOGGER.error("Failed to load OBJ model: {}", location, e); + return null; + } + } + + /** + * Parse a single vertex from face data. + * Format: v/vt/vn or v//vn or v/vt or v + */ + @Nullable + private static ObjVertex parseVertex( + String data, + List positions, + List texCoords, + List normals + ) { + String[] indices = data.split("/"); + if (indices.length == 0) return null; + + // Position index (required) + int posIdx = parseInt(indices[0]) - 1; // OBJ indices are 1-based + if (posIdx < 0 || posIdx >= positions.size()) return null; + float[] pos = positions.get(posIdx); + + // Texture coordinate index (optional) + float u = 0, + v = 0; + if (indices.length >= 2 && !indices[1].isEmpty()) { + int texIdx = parseInt(indices[1]) - 1; + if (texIdx >= 0 && texIdx < texCoords.size()) { + float[] tex = texCoords.get(texIdx); + u = tex[0]; + v = tex[1]; + } + } + + // Normal index (optional) + float nx = 0, + ny = 1, + nz = 0; + if (indices.length >= 3 && !indices[2].isEmpty()) { + int normIdx = parseInt(indices[2]) - 1; + if (normIdx >= 0 && normIdx < normals.size()) { + float[] norm = normals.get(normIdx); + nx = norm[0]; + ny = norm[1]; + nz = norm[2]; + } + } + + return new ObjVertex(pos[0], pos[1], pos[2], u, v, nx, ny, nz); + } + + /** + * Triangulate a face (convert quad to 2 triangles). + */ + private static List triangulate(List vertices) { + List result = new ArrayList<>(); + + if (vertices.size() < 3) { + return result; + } + + // Triangle + if (vertices.size() == 3) { + result.add( + new ObjFace(vertices.get(0), vertices.get(1), vertices.get(2)) + ); + } + // Quad -> 2 triangles (fan triangulation) + else if (vertices.size() >= 4) { + ObjVertex v0 = vertices.get(0); + for (int i = 1; i < vertices.size() - 1; i++) { + result.add( + new ObjFace(v0, vertices.get(i), vertices.get(i + 1)) + ); + } + } + + return result; + } + + /** + * Resolve the MTL file path relative to the OBJ file. + */ + private static ResourceLocation resolveMtlPath( + ResourceLocation objLocation, + String mtlFileName + ) { + String objPath = objLocation.getPath(); + int lastSlash = objPath.lastIndexOf('/'); + String directory = + lastSlash >= 0 ? objPath.substring(0, lastSlash + 1) : ""; + return ResourceLocation.fromNamespaceAndPath( + objLocation.getNamespace(), + directory + mtlFileName + ); + } + + /** + * Load materials from an MTL file. + */ + private static Map loadMtl(ResourceLocation location) { + Map materials = new HashMap<>(); + ResourceManager resourceManager = + Minecraft.getInstance().getResourceManager(); + + try { + Optional resourceOpt = resourceManager.getResource( + location + ); + if (resourceOpt.isEmpty()) { + TiedUpMod.LOGGER.warn("MTL file not found: {}", location); + return materials; + } + + Resource resource = resourceOpt.get(); + String currentName = null; + float r = 1, + g = 1, + b = 1; + String texturePath = null; + + try ( + BufferedReader reader = new BufferedReader( + new InputStreamReader( + resource.open(), + StandardCharsets.UTF_8 + ) + ) + ) { + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + + String[] parts = line.split("\\s+"); + if (parts.length == 0) continue; + + switch (parts[0]) { + case "newmtl" -> { + // Save previous material + if (currentName != null) { + materials.put( + currentName, + new ObjMaterial( + currentName, + r, + g, + b, + texturePath + ) + ); + } + // Start new material + currentName = + parts.length >= 2 ? parts[1] : "unnamed"; + r = 1; + g = 1; + b = 1; // Reset to default + texturePath = null; + } + case "Kd" -> { + // Diffuse color: Kd r g b + if (parts.length >= 4) { + r = parseFloat(parts[1]); + g = parseFloat(parts[2]); + b = parseFloat(parts[3]); + } + } + case "map_Kd" -> { + // Diffuse texture map: map_Kd filename.png + if (parts.length >= 2) { + texturePath = parts[1]; + } + } + } + } + + // Save last material + if (currentName != null) { + materials.put( + currentName, + new ObjMaterial(currentName, r, g, b, texturePath) + ); + } + } + + TiedUpMod.LOGGER.debug( + "Loaded {} materials from MTL: {}", + materials.size(), + location + ); + } catch (IOException e) { + TiedUpMod.LOGGER.error("Failed to load MTL file: {}", location, e); + } + + return materials; + } + + private static float parseFloat(String s) { + try { + return Float.parseFloat(s); + } catch (NumberFormatException e) { + return 0f; + } + } + + private static int parseInt(String s) { + try { + return Integer.parseInt(s); + } catch (NumberFormatException e) { + return 0; + } + } +} diff --git a/src/main/java/com/tiedup/remake/client/renderer/obj/ObjModelRegistry.java b/src/main/java/com/tiedup/remake/client/renderer/obj/ObjModelRegistry.java new file mode 100644 index 0000000..d8edef5 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/renderer/obj/ObjModelRegistry.java @@ -0,0 +1,117 @@ +package com.tiedup.remake.client.renderer.obj; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.HashMap; +import java.util.Map; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.jetbrains.annotations.Nullable; + +/** + * Singleton registry/cache for loaded OBJ models. + * Models are loaded on-demand and cached for reuse. + * Client-side only. + */ +@OnlyIn(Dist.CLIENT) +public class ObjModelRegistry { + + private static final Map CACHE = + new HashMap<>(); + private static boolean initialized = false; + + private ObjModelRegistry() { + // Singleton utility class + } + + /** + * Initialize the registry and preload known models. + * Called during FMLClientSetupEvent. + */ + public static void init() { + if (initialized) { + return; + } + + TiedUpMod.LOGGER.info("Initializing ObjModelRegistry..."); + + // Preload known 3D models + preloadModel("tiedup:models/obj/ball_gag/model.obj"); + preloadModel("tiedup:models/obj/choke_collar_leather/model.obj"); + + initialized = true; + TiedUpMod.LOGGER.info( + "ObjModelRegistry initialized with {} models", + CACHE.size() + ); + } + + /** + * Preload a model into the cache. + * + * @param path Resource path (e.g., "tiedup:models/obj/ball_gag.obj") + */ + private static void preloadModel(String path) { + ResourceLocation location = ResourceLocation.tryParse(path); + if (location != null) { + ObjModel model = ObjModelLoader.load(location); + if (model != null) { + CACHE.put(location, model); + TiedUpMod.LOGGER.debug("Preloaded OBJ model: {}", path); + } else { + TiedUpMod.LOGGER.warn("Failed to preload OBJ model: {}", path); + } + } + } + + /** + * Get a model from the cache, loading it if not present. + * + * @param location ResourceLocation of the .obj file + * @return The loaded model, or null if not found/failed to load + */ + @Nullable + public static ObjModel get(ResourceLocation location) { + if (location == null) { + return null; + } + + return CACHE.computeIfAbsent(location, ObjModelLoader::load); + } + + /** + * Get a model by string path. + * + * @param path Resource path (e.g., "tiedup:models/obj/ball_gag.obj") + * @return The loaded model, or null if not found/failed to load + */ + @Nullable + public static ObjModel get(String path) { + ResourceLocation location = ResourceLocation.tryParse(path); + return location != null ? get(location) : null; + } + + /** + * Check if a model is loaded in the cache. + */ + public static boolean isLoaded(ResourceLocation location) { + return CACHE.containsKey(location); + } + + /** + * Clear the cache. + * Useful for resource reload events. + */ + public static void clearCache() { + CACHE.clear(); + initialized = false; + TiedUpMod.LOGGER.info("ObjModelRegistry cache cleared"); + } + + /** + * Get the number of loaded models. + */ + public static int getCachedCount() { + return CACHE.size(); + } +} diff --git a/src/main/java/com/tiedup/remake/client/renderer/obj/ObjModelRenderer.java b/src/main/java/com/tiedup/remake/client/renderer/obj/ObjModelRenderer.java new file mode 100644 index 0000000..6119a94 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/renderer/obj/ObjModelRenderer.java @@ -0,0 +1,406 @@ +package com.tiedup.remake.client.renderer.obj; + +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.blaze3d.vertex.VertexFormat; +import java.util.List; +import java.util.Map; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderStateShard; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.joml.Matrix3f; +import org.joml.Matrix4f; + +/** + * Renders OBJ models using Minecraft's rendering system. + * Uses VertexConsumer for hardware-accelerated rendering. + * Client-side only. + * + *

Important: Uses TRIANGLES mode RenderType, not QUADS, + * because OBJ models are triangulated during loading. + */ +@OnlyIn(Dist.CLIENT) +public class ObjModelRenderer extends RenderStateShard { + + /** White texture for vertex color rendering */ + public static final ResourceLocation WHITE_TEXTURE = + ResourceLocation.fromNamespaceAndPath( + "tiedup", + "models/obj/shared/white.png" + ); + + private ObjModelRenderer() { + super("tiedup_obj_renderer", () -> {}, () -> {}); + // Utility class - extends RenderStateShard to access protected members + } + + /** + * Create a TRIANGLES-mode RenderType for OBJ rendering. + * Standard entityCutoutNoCull uses QUADS which causes spiky artifacts + * when we submit triangles. + */ + private static RenderType createTriangleRenderType( + ResourceLocation texture + ) { + RenderType.CompositeState state = RenderType.CompositeState.builder() + .setShaderState(RENDERTYPE_ENTITY_CUTOUT_NO_CULL_SHADER) + .setTextureState( + new RenderStateShard.TextureStateShard(texture, false, false) + ) + .setTransparencyState(NO_TRANSPARENCY) + .setCullState(NO_CULL) + .setLightmapState(LIGHTMAP) + .setOverlayState(OVERLAY) + .createCompositeState(true); + + return RenderType.create( + "tiedup_obj_triangles", + DefaultVertexFormat.NEW_ENTITY, + VertexFormat.Mode.TRIANGLES, // Key fix: TRIANGLES not QUADS + 1536, + true, + false, + state + ); + } + + /** + * Render an OBJ model with vertex colors from materials. + * + * @param model The OBJ model to render + * @param poseStack The current pose stack (with transformations applied) + * @param buffer The multi-buffer source + * @param packedLight Packed light value + * @param packedOverlay Packed overlay value (for hit flash etc.) + */ + public static void render( + ObjModel model, + PoseStack poseStack, + MultiBufferSource buffer, + int packedLight, + int packedOverlay + ) { + render( + model, + poseStack, + buffer, + packedLight, + packedOverlay, + 1.0f, + 1.0f, + 1.0f, + 1.0f + ); + } + + /** + * Render an OBJ model with textures from materials (map_Kd) or vertex colors as fallback. + * + * @param model The OBJ model to render + * @param poseStack The current pose stack (with transformations applied) + * @param buffer The multi-buffer source + * @param packedLight Packed light value + * @param packedOverlay Packed overlay value (for hit flash etc.) + * @param tintR Red tint multiplier (0-1) + * @param tintG Green tint multiplier (0-1) + * @param tintB Blue tint multiplier (0-1) + * @param alpha Alpha value (0-1) + */ + public static void render( + ObjModel model, + PoseStack poseStack, + MultiBufferSource buffer, + int packedLight, + int packedOverlay, + float tintR, + float tintG, + float tintB, + float alpha + ) { + if (model == null) { + return; + } + + Matrix4f pose = poseStack.last().pose(); + Matrix3f normal = poseStack.last().normal(); + + // Render faces grouped by material + for (Map.Entry> entry : model + .getFacesByMaterial() + .entrySet()) { + ObjMaterial material = model.getMaterial(entry.getKey()); + + // Determine texture and color based on material + ResourceLocation texture; + float r, g, b; + + if (material.hasTexture()) { + // Use texture from map_Kd - resolve to full path + texture = model.resolveTexture(material.texturePath()); + if (texture == null) { + texture = WHITE_TEXTURE; + } + // White color to let texture show through, with tint applied + r = tintR; + g = tintG; + b = tintB; + } else { + // Use vertex color from Kd + texture = WHITE_TEXTURE; + r = material.r() * tintR; + g = material.g() * tintG; + b = material.b() * tintB; + } + + // Get buffer for this material's texture (TRIANGLES mode) + VertexConsumer vertexConsumer = buffer.getBuffer( + createTriangleRenderType(texture) + ); + + for (ObjFace face : entry.getValue()) { + ObjVertex[] verts = face.vertices(); + + // Use vertex normals from the OBJ file for smooth shading + for (ObjVertex vertex : verts) { + vertexConsumer + .vertex(pose, vertex.x(), vertex.y(), vertex.z()) + .color(r, g, b, alpha) + .uv(vertex.u(), 1.0f - vertex.v()) + .overlayCoords(packedOverlay) + .uv2(packedLight) + .normal(normal, vertex.nx(), vertex.ny(), vertex.nz()) + .endVertex(); + } + } + } + } + + /** + * Render an OBJ model with selective material tinting. + * Only materials in the tintMaterials set will be tinted. + * + * @param model The OBJ model to render + * @param poseStack The current pose stack + * @param buffer The multi-buffer source + * @param packedLight Packed light value + * @param packedOverlay Packed overlay value + * @param tintR Red tint (0-1) + * @param tintG Green tint (0-1) + * @param tintB Blue tint (0-1) + * @param tintMaterials Set of material names to apply tint to (e.g., "Ball") + */ + public static void renderWithSelectiveTint( + ObjModel model, + PoseStack poseStack, + MultiBufferSource buffer, + int packedLight, + int packedOverlay, + float tintR, + float tintG, + float tintB, + java.util.Set tintMaterials + ) { + if (model == null) { + return; + } + + Matrix4f pose = poseStack.last().pose(); + Matrix3f normal = poseStack.last().normal(); + + for (Map.Entry> entry : model + .getFacesByMaterial() + .entrySet()) { + String materialName = entry.getKey(); + ObjMaterial material = model.getMaterial(materialName); + + // Check if this material should be tinted + boolean shouldTint = tintMaterials.contains(materialName); + + ResourceLocation texture; + float r, g, b; + + if (material.hasTexture()) { + texture = model.resolveTexture(material.texturePath()); + if (texture == null) { + texture = WHITE_TEXTURE; + } + // Apply tint only to specified materials + if (shouldTint) { + r = tintR; + g = tintG; + b = tintB; + } else { + r = 1.0f; + g = 1.0f; + b = 1.0f; + } + } else { + // Vertex color from Kd + texture = WHITE_TEXTURE; + if (shouldTint) { + r = material.r() * tintR; + g = material.g() * tintG; + b = material.b() * tintB; + } else { + r = material.r(); + g = material.g(); + b = material.b(); + } + } + + VertexConsumer vertexConsumer = buffer.getBuffer( + createTriangleRenderType(texture) + ); + + for (ObjFace face : entry.getValue()) { + for (ObjVertex vertex : face.vertices()) { + vertexConsumer + .vertex(pose, vertex.x(), vertex.y(), vertex.z()) + .color(r, g, b, 1.0f) + .uv(vertex.u(), 1.0f - vertex.v()) + .overlayCoords(packedOverlay) + .uv2(packedLight) + .normal(normal, vertex.nx(), vertex.ny(), vertex.nz()) + .endVertex(); + } + } + } + } + + /** + * Render an OBJ model with color-suffixed textures. + * When colorSuffix is provided, ALL textured materials use texture_COLOR.png. + * + * @param model The OBJ model to render + * @param poseStack The current pose stack + * @param buffer The multi-buffer source + * @param packedLight Packed light value + * @param packedOverlay Packed overlay value + * @param colorSuffix The color suffix (e.g., "red", "blue"), or null for default texture + */ + public static void renderWithColoredTextures( + ObjModel model, + PoseStack poseStack, + MultiBufferSource buffer, + int packedLight, + int packedOverlay, + String colorSuffix + ) { + if (model == null) { + return; + } + + Matrix4f pose = poseStack.last().pose(); + Matrix3f normal = poseStack.last().normal(); + + for (Map.Entry> entry : model + .getFacesByMaterial() + .entrySet()) { + ObjMaterial material = model.getMaterial(entry.getKey()); + + ResourceLocation texture; + + if (material.hasTexture()) { + // Apply color suffix to texture path if provided + if (colorSuffix != null && !colorSuffix.isEmpty()) { + texture = model.resolveTextureWithColorSuffix( + material.texturePath(), + colorSuffix + ); + } else { + texture = model.resolveTexture(material.texturePath()); + } + if (texture == null) { + texture = WHITE_TEXTURE; + } + } else { + // No texture - use vertex color from Kd + texture = WHITE_TEXTURE; + } + + VertexConsumer vertexConsumer = buffer.getBuffer( + createTriangleRenderType(texture) + ); + + // Determine vertex color - white for textured, Kd for untextured + float r, g, b; + if (material.hasTexture()) { + r = 1.0f; + g = 1.0f; + b = 1.0f; + } else { + r = material.r(); + g = material.g(); + b = material.b(); + } + + for (ObjFace face : entry.getValue()) { + for (ObjVertex vertex : face.vertices()) { + vertexConsumer + .vertex(pose, vertex.x(), vertex.y(), vertex.z()) + .color(r, g, b, 1.0f) + .uv(vertex.u(), 1.0f - vertex.v()) + .overlayCoords(packedOverlay) + .uv2(packedLight) + .normal(normal, vertex.nx(), vertex.ny(), vertex.nz()) + .endVertex(); + } + } + } + } + + /** + * Render an OBJ model with a specific texture instead of vertex colors. + * + * @param model The OBJ model to render + * @param poseStack The current pose stack (with transformations applied) + * @param buffer The multi-buffer source + * @param packedLight Packed light value + * @param packedOverlay Packed overlay value + * @param texture The texture to apply + */ + public static void renderTextured( + ObjModel model, + PoseStack poseStack, + MultiBufferSource buffer, + int packedLight, + int packedOverlay, + ResourceLocation texture + ) { + if (model == null) { + return; + } + + Matrix4f pose = poseStack.last().pose(); + Matrix3f normal = poseStack.last().normal(); + + // Use custom TRIANGLES-mode RenderType + VertexConsumer vertexConsumer = buffer.getBuffer( + createTriangleRenderType(texture) + ); + + // Render all faces with white color (let texture provide color) + for (Map.Entry> entry : model + .getFacesByMaterial() + .entrySet()) { + for (ObjFace face : entry.getValue()) { + ObjVertex[] verts = face.vertices(); + + for (ObjVertex vertex : verts) { + vertexConsumer + .vertex(pose, vertex.x(), vertex.y(), vertex.z()) + .color(1.0f, 1.0f, 1.0f, 1.0f) + .uv(vertex.u(), 1.0f - vertex.v()) + .overlayCoords(packedOverlay) + .uv2(packedLight) + .normal(normal, vertex.nx(), vertex.ny(), vertex.nz()) + .endVertex(); + } + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/client/renderer/obj/ObjVertex.java b/src/main/java/com/tiedup/remake/client/renderer/obj/ObjVertex.java new file mode 100644 index 0000000..ddaf244 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/renderer/obj/ObjVertex.java @@ -0,0 +1,38 @@ +package com.tiedup.remake.client.renderer.obj; + +/** + * Immutable record representing a single vertex in an OBJ model. + * Contains position (x, y, z), texture coordinates (u, v), and normal (nx, ny, nz). + */ +public record ObjVertex( + float x, + float y, + float z, + float u, + float v, + float nx, + float ny, + float nz +) { + /** + * Create a vertex with only position data. + * UV and normal will be set to defaults. + */ + public static ObjVertex ofPosition(float x, float y, float z) { + return new ObjVertex(x, y, z, 0f, 0f, 0f, 1f, 0f); + } + + /** + * Create a vertex with position and UV. + * Normal will be set to default (pointing up). + */ + public static ObjVertex ofPositionUV( + float x, + float y, + float z, + float u, + float v + ) { + return new ObjVertex(x, y, z, u, v, 0f, 1f, 0f); + } +} diff --git a/src/main/java/com/tiedup/remake/client/state/ClientLaborState.java b/src/main/java/com/tiedup/remake/client/state/ClientLaborState.java new file mode 100644 index 0000000..44b93f0 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/state/ClientLaborState.java @@ -0,0 +1,100 @@ +package com.tiedup.remake.client.state; + +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Client-side state for tracking active labor task progress. + * Synchronized from server via PacketSyncLaborProgress. + */ +@OnlyIn(Dist.CLIENT) +public class ClientLaborState { + + private static boolean hasTask = false; + private static String taskDescription = ""; + private static int progress = 0; + private static int quota = 0; + private static int valueEmeralds = 0; + + /** + * Set the current labor task. + * + * @param description Task description (e.g., "Kill 8 Spiders") + * @param currentProgress Current progress count + * @param targetQuota Target quota to complete + * @param value Task value in emeralds + */ + public static void setTask( + String description, + int currentProgress, + int targetQuota, + int value + ) { + hasTask = true; + taskDescription = description; + progress = currentProgress; + quota = targetQuota; + valueEmeralds = value; + } + + /** + * Clear the current task (no active task). + */ + public static void clearTask() { + hasTask = false; + taskDescription = ""; + progress = 0; + quota = 0; + valueEmeralds = 0; + } + + /** + * Check if there is an active labor task. + */ + public static boolean hasActiveTask() { + return hasTask; + } + + /** + * Get the task description. + */ + public static String getTaskDescription() { + return taskDescription; + } + + /** + * Get the current progress. + */ + public static int getProgress() { + return progress; + } + + /** + * Get the target quota. + */ + public static int getQuota() { + return quota; + } + + /** + * Get the task value in emeralds. + */ + public static int getValueEmeralds() { + return valueEmeralds; + } + + /** + * Get the progress as a float (0.0 to 1.0). + */ + public static float getProgressFraction() { + if (quota <= 0) return 0.0f; + return Math.min(1.0f, (float) progress / quota); + } + + /** + * Get a formatted progress string (e.g., "5/10"). + */ + public static String getProgressString() { + return progress + "/" + quota; + } +} diff --git a/src/main/java/com/tiedup/remake/client/state/ClothesClientCache.java b/src/main/java/com/tiedup/remake/client/state/ClothesClientCache.java new file mode 100644 index 0000000..c169c38 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/state/ClothesClientCache.java @@ -0,0 +1,196 @@ +package com.tiedup.remake.client.state; + +import com.tiedup.remake.items.clothes.ClothesProperties; +import java.util.EnumSet; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.Nullable; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Client-side cache for storing clothes configuration of remote players. + * This allows rendering dynamic textures and settings for other players + * without requiring constant server queries. + * + * Thread-safe: Uses ConcurrentHashMap for multi-threaded access + * (network thread writes, render thread reads). + */ +@OnlyIn(Dist.CLIENT) +public class ClothesClientCache { + + /** + * Cache entry storing all clothes configuration for a player. + */ + public record CachedClothesData( + @Nullable String dynamicUrl, + boolean fullSkin, + boolean smallArms, + boolean keepHead, + EnumSet visibleLayers, + long timestamp + ) { + public boolean hasDynamicTexture() { + return dynamicUrl != null && !dynamicUrl.isEmpty(); + } + } + + // Main cache: playerUUID -> clothes data + private static final Map playerClothesCache = + new ConcurrentHashMap<>(); + + // Cache expiry time (5 minutes) - entries older than this may be considered stale + private static final long CACHE_EXPIRY_MS = 5 * 60 * 1000; + + /** + * Update or add clothes data for a player. + * Called when receiving PacketSyncClothesConfig from server. + * + * @param playerUUID The player's UUID + * @param dynamicUrl The dynamic texture URL (may be null) + * @param fullSkin Whether full-skin mode is enabled + * @param smallArms Whether small arms are forced + * @param keepHead Whether to keep the wearer's head/hat + * @param visibleLayers Which layers are visible + */ + public static void updatePlayerClothes( + UUID playerUUID, + @Nullable String dynamicUrl, + boolean fullSkin, + boolean smallArms, + boolean keepHead, + EnumSet visibleLayers + ) { + playerClothesCache.put( + playerUUID, + new CachedClothesData( + dynamicUrl, + fullSkin, + smallArms, + keepHead, + visibleLayers, + System.currentTimeMillis() + ) + ); + } + + /** + * Remove clothes data for a player. + * Called when a player unequips clothes or disconnects. + * + * @param playerUUID The player's UUID + */ + public static void removePlayerClothes(UUID playerUUID) { + playerClothesCache.remove(playerUUID); + } + + /** + * Get cached clothes data for a player. + * + * @param playerUUID The player's UUID + * @return The cached data, or null if not cached + */ + @Nullable + public static CachedClothesData getPlayerClothes(UUID playerUUID) { + return playerClothesCache.get(playerUUID); + } + + /** + * Check if a player has cached clothes data. + * + * @param playerUUID The player's UUID + * @return true if data exists in cache + */ + public static boolean hasPlayerClothes(UUID playerUUID) { + return playerClothesCache.containsKey(playerUUID); + } + + /** + * Get the dynamic texture URL for a player, if available. + * + * @param playerUUID The player's UUID + * @return The URL or null if not available + */ + @Nullable + public static String getPlayerDynamicUrl(UUID playerUUID) { + CachedClothesData data = playerClothesCache.get(playerUUID); + return data != null ? data.dynamicUrl() : null; + } + + /** + * Check if a player has full-skin mode enabled. + * + * @param playerUUID The player's UUID + * @return true if full-skin mode is enabled + */ + public static boolean isFullSkinEnabled(UUID playerUUID) { + CachedClothesData data = playerClothesCache.get(playerUUID); + return data != null && data.fullSkin(); + } + + /** + * Check if a player has small arms forced. + * + * @param playerUUID The player's UUID + * @return true if small arms are forced + */ + public static boolean isSmallArmsForced(UUID playerUUID) { + CachedClothesData data = playerClothesCache.get(playerUUID); + return data != null && data.smallArms(); + } + + /** + * Check if a player has keep head mode enabled. + * + * @param playerUUID The player's UUID + * @return true if keep head is enabled + */ + public static boolean isKeepHeadEnabled(UUID playerUUID) { + CachedClothesData data = playerClothesCache.get(playerUUID); + return data != null && data.keepHead(); + } + + /** + * Get visible layers for a player. + * + * @param playerUUID The player's UUID + * @return The visible layers, or all layers if not cached + */ + public static EnumSet getVisibleLayers( + UUID playerUUID + ) { + CachedClothesData data = playerClothesCache.get(playerUUID); + return data != null + ? data.visibleLayers() + : EnumSet.allOf(ClothesProperties.LayerPart.class); + } + + /** + * Clear all cached data. + * Called on world unload or disconnect. + */ + public static void clearAll() { + playerClothesCache.clear(); + } + + /** + * Remove stale entries from cache. + * Can be called periodically to prevent memory leaks. + */ + public static void cleanupStale() { + long now = System.currentTimeMillis(); + playerClothesCache + .entrySet() + .removeIf( + entry -> (now - entry.getValue().timestamp()) > CACHE_EXPIRY_MS + ); + } + + /** + * Get the number of cached entries (for debugging). + */ + public static int getCacheSize() { + return playerClothesCache.size(); + } +} diff --git a/src/main/java/com/tiedup/remake/client/state/CollarRegistryClient.java b/src/main/java/com/tiedup/remake/client/state/CollarRegistryClient.java new file mode 100644 index 0000000..37509bc --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/state/CollarRegistryClient.java @@ -0,0 +1,98 @@ +package com.tiedup.remake.client.state; + +import java.util.Collections; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Client-side cache for collar registry data. + * + * This cache stores the UUIDs of slaves (collar wearers) owned by the local player. + * It is synchronized from the server via PacketSyncCollarRegistry. + * + * Used by SlaveManagementScreen to display owned slaves without spatial queries. + * + * Phase 17: Terminology Refactoring - Global Collar Registry + */ +@OnlyIn(Dist.CLIENT) +public class CollarRegistryClient { + + // Set of slave (collar wearer) UUIDs owned by the local player + private static final Set ownedSlaves = ConcurrentHashMap.newKeySet(); + + // ==================== SYNC FROM SERVER ==================== + + /** + * Full sync: Replace all known slaves with the provided set. + * Called when player logs in or for full registry refresh. + */ + public static void setSlaves(Set slaveUUIDs) { + ownedSlaves.clear(); + ownedSlaves.addAll(slaveUUIDs); + } + + /** + * Incremental update: Add and remove specific slaves. + * Called when collar ownership changes. + */ + public static void updateSlaves(Set toAdd, Set toRemove) { + ownedSlaves.removeAll(toRemove); + ownedSlaves.addAll(toAdd); + } + + /** + * Add a single slave to the cache. + */ + public static void addSlave(UUID slaveUUID) { + ownedSlaves.add(slaveUUID); + } + + /** + * Remove a single slave from the cache. + */ + public static void removeSlave(UUID slaveUUID) { + ownedSlaves.remove(slaveUUID); + } + + /** + * Clear all slaves from the cache. + * Called on logout or dimension change. + */ + public static void clear() { + ownedSlaves.clear(); + } + + // ==================== QUERIES ==================== + + /** + * Get all slave UUIDs owned by the local player. + * Returns an unmodifiable view. + */ + public static Set getSlaves() { + return Collections.unmodifiableSet(ownedSlaves); + } + + /** + * Check if the local player owns a specific slave. + */ + public static boolean ownsSlave(UUID slaveUUID) { + return ownedSlaves.contains(slaveUUID); + } + + /** + * Get the number of slaves owned by the local player. + */ + public static int getSlaveCount() { + return ownedSlaves.size(); + } + + /** + * Check if the local player has any slaves. + */ + public static boolean hasSlaves() { + return !ownedSlaves.isEmpty(); + } +} diff --git a/src/main/java/com/tiedup/remake/client/state/MovementStyleClientState.java b/src/main/java/com/tiedup/remake/client/state/MovementStyleClientState.java new file mode 100644 index 0000000..b855af3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/state/MovementStyleClientState.java @@ -0,0 +1,63 @@ +package com.tiedup.remake.client.state; + +import com.tiedup.remake.v2.bondage.movement.MovementStyle; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.jetbrains.annotations.Nullable; + +/** + * Client-side cache of active movement style per player UUID. + * + *

Populated by {@link com.tiedup.remake.network.sync.PacketSyncMovementStyle}. + * Read by {@link com.tiedup.remake.client.animation.context.AnimationContextResolver} + * and {@link com.tiedup.remake.client.animation.tick.AnimationTickHandler}.

+ * + *

Thread-safe via ConcurrentHashMap (same pattern as + * {@link PetBedClientState}).

+ */ +@OnlyIn(Dist.CLIENT) +public class MovementStyleClientState { + + private static final Map styles = new ConcurrentHashMap<>(); + + /** + * Set the active movement style for a player. + * + * @param uuid the player's UUID + * @param style the active style (must not be null; use {@link #clear} for no style) + */ + public static void set(UUID uuid, MovementStyle style) { + styles.put(uuid, style); + } + + /** + * Get the active movement style for a player. + * + * @param uuid the player's UUID + * @return the active style, or null if no movement style is active + */ + @Nullable + public static MovementStyle get(UUID uuid) { + return styles.get(uuid); + } + + /** + * Clear the movement style for a player (no style active). + * + * @param uuid the player's UUID + */ + public static void clear(UUID uuid) { + styles.remove(uuid); + } + + /** + * Clear all cached movement styles. + * Called on world unload to prevent memory leaks and stale data. + */ + public static void clearAll() { + styles.clear(); + } +} diff --git a/src/main/java/com/tiedup/remake/client/state/PetBedClientState.java b/src/main/java/com/tiedup/remake/client/state/PetBedClientState.java new file mode 100644 index 0000000..799f2fe --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/state/PetBedClientState.java @@ -0,0 +1,42 @@ +package com.tiedup.remake.client.state; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Client-side cache of pet bed mode + facing per player. + * Used by AnimationTickHandler to avoid overriding pet bed animations with bondage animations + * and to lock body rotation to bed facing. + */ +@OnlyIn(Dist.CLIENT) +public class PetBedClientState { + + private record Entry(byte mode, float facingYRot) {} + + private static final Map states = new ConcurrentHashMap<>(); + + public static void set(UUID uuid, byte mode, float facingYRot) { + states.put(uuid, new Entry(mode, facingYRot)); + } + + public static byte get(UUID uuid) { + Entry e = states.get(uuid); + return e != null ? e.mode : 0; + } + + public static float getFacing(UUID uuid) { + Entry e = states.get(uuid); + return e != null ? e.facingYRot : 0f; + } + + public static void clear(UUID uuid) { + states.remove(uuid); + } + + public static void clearAll() { + states.clear(); + } +} diff --git a/src/main/java/com/tiedup/remake/client/texture/DynamicOnlineTexture.java b/src/main/java/com/tiedup/remake/client/texture/DynamicOnlineTexture.java new file mode 100644 index 0000000..2aa9323 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/texture/DynamicOnlineTexture.java @@ -0,0 +1,384 @@ +package com.tiedup.remake.client.texture; + +import com.mojang.blaze3d.platform.NativeImage; +import com.mojang.blaze3d.systems.RenderSystem; +import com.tiedup.remake.core.TiedUpMod; +import org.jetbrains.annotations.Nullable; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Handles a dynamically loaded online texture for clothes. + * Supports standard skins (64x32/64x64) and scales to proper format. + * + * Creates multiple texture variants: + * - Base texture: Original image as uploaded + * - Clothes texture: Resized for 64x64 format if needed (old 64x32 format) + * - Full-skin texture: For full-skin mode (covers entire player) + * + * Thread-safe: Texture uploads are scheduled on render thread. + */ +@OnlyIn(Dist.CLIENT) +public class DynamicOnlineTexture { + + private static final int MAX_DIMENSION = 1280; + + // Texture objects managed by Minecraft's texture manager + private DynamicTexture baseTexture; + private DynamicTexture clothesTexture; + private DynamicTexture fullSkinTexture; + + private boolean needsResize = false; + private boolean valid = false; + + private final String sourceUrl; + + /** + * Create a new dynamic texture from a downloaded image. + * + * @param image The downloaded image + * @param sourceUrl The source URL (for logging) + */ + public DynamicOnlineTexture(NativeImage image, String sourceUrl) { + this.sourceUrl = sourceUrl; + if (image != null) { + this.valid = processImage(image); + } + } + + /** + * Process the downloaded image and create texture variants. + * + * @param image The source image + * @return true if processing succeeded + */ + private boolean processImage(NativeImage image) { + int width = image.getWidth(); + int height = image.getHeight(); + + // Validate dimensions + if (width == 0 || height == 0) { + TiedUpMod.LOGGER.warn( + "[DynamicOnlineTexture] Invalid image dimensions: {}x{} from {}", + width, + height, + sourceUrl + ); + return false; + } + + // Check for valid skin format (must be divisible by 64 width, 32 height) + if (height % 32 != 0 || width % 64 != 0) { + TiedUpMod.LOGGER.warn( + "[DynamicOnlineTexture] Invalid skin dimensions: {}x{} (must be 64x32 or 64x64 multiples) from {}", + width, + height, + sourceUrl + ); + return false; + } + + // Check maximum size + if (width > MAX_DIMENSION || height > MAX_DIMENSION) { + TiedUpMod.LOGGER.warn( + "[DynamicOnlineTexture] Image too large: {}x{} (max {}) from {}", + width, + height, + MAX_DIMENSION, + sourceUrl + ); + return false; + } + + try { + // Create base texture (original image) + NativeImage baseImage = copyImage(image); + + // Create clothes texture (resized if needed for old 64x32 format) + NativeImage clothesImage; + if (height * 2 == width) { + // New format (64x64), use as-is + clothesImage = copyImage(image); + } else { + // Old format (64x32), needs conversion to 64x64 + needsResize = true; + clothesImage = convertOldFormat(image); + } + + // Create full-skin texture (square format for full coverage) + NativeImage fullSkinImage = createFullSkinImage( + clothesImage, + width + ); + + // Schedule texture upload on render thread + RenderSystem.recordRenderCall(() -> { + this.baseTexture = new DynamicTexture(baseImage); + this.clothesTexture = needsResize + ? new DynamicTexture(clothesImage) + : this.baseTexture; + this.fullSkinTexture = new DynamicTexture(fullSkinImage); + }); + + return true; + } catch (Exception e) { + TiedUpMod.LOGGER.error( + "[DynamicOnlineTexture] Failed to process image from {}: {}", + sourceUrl, + e.getMessage() + ); + return false; + } + } + + /** + * Create a copy of a NativeImage. + */ + private NativeImage copyImage(NativeImage source) { + NativeImage copy = new NativeImage( + source.format(), + source.getWidth(), + source.getHeight(), + false + ); + copy.copyFrom(source); + return copy; + } + + /** + * Convert old 64x32 format to modern 64x64 format. + * This copies the arm and leg textures to the correct locations. + */ + private NativeImage convertOldFormat(NativeImage source) { + int width = source.getWidth(); + int height = source.getHeight(); + int scale = width / 64; + + // Create new 64x64 (scaled) image + NativeImage result = new NativeImage( + source.format(), + width, + width, + false + ); + + // Copy top half (head, body, arms - same as before) + for (int x = 0; x < width; x++) { + for (int y = 0; y < height; y++) { + result.setPixelRGBA(x, y, source.getPixelRGBA(x, y)); + } + } + + // Copy left arm from right arm location (mirrored) + // Old format has arms in row 2, col 10 (44-47, 16-32) + // New format left arm goes to row 3-4, col 8 (32-48, 48-64) + copyRegion( + source, + result, + 44 * scale, + 16 * scale, + 32 * scale, + 48 * scale, + 4 * scale, + 16 * scale + ); + + // Copy left leg from right leg location (mirrored) + // Old format has leg in row 0-1 (0-16, 16-32) + // New format left leg goes to row 3-4, col 4 (16-32, 48-64) + copyRegion( + source, + result, + 0 * scale, + 16 * scale, + 16 * scale, + 48 * scale, + 16 * scale, + 16 * scale + ); + + return result; + } + + /** + * Copy a region from source to destination image. + */ + private void copyRegion( + NativeImage src, + NativeImage dst, + int srcX, + int srcY, + int dstX, + int dstY, + int width, + int height + ) { + for (int x = 0; x < width; x++) { + for (int y = 0; y < height; y++) { + if ( + srcX + x < src.getWidth() && + srcY + y < src.getHeight() && + dstX + x < dst.getWidth() && + dstY + y < dst.getHeight() + ) { + dst.setPixelRGBA( + dstX + x, + dstY + y, + src.getPixelRGBA(srcX + x, srcY + y) + ); + } + } + } + } + + /** + * Create full-skin image (square format, opaque in key areas). + */ + private NativeImage createFullSkinImage(NativeImage source, int width) { + NativeImage fullSkin = new NativeImage( + source.format(), + width, + width, + false + ); + fullSkin.copyFrom(source); + + // Make key areas fully opaque for full-skin mode + int quarter = width / 4; + setAreaOpaque(fullSkin, 0, 0, quarter, quarter); + setAreaOpaque(fullSkin, 0, quarter, width, quarter * 2); + setAreaOpaque(fullSkin, quarter, quarter * 3, quarter * 3, width); + + return fullSkin; + } + + /** + * Make an area of the image fully opaque. + */ + private void setAreaOpaque( + NativeImage image, + int x1, + int y1, + int x2, + int y2 + ) { + for (int x = x1; x < x2 && x < image.getWidth(); x++) { + for (int y = y1; y < y2 && y < image.getHeight(); y++) { + int pixel = image.getPixelRGBA(x, y); + // Set alpha to 255 (fully opaque) + pixel |= 0xFF000000; + image.setPixelRGBA(x, y, pixel); + } + } + } + + /** + * Get the base texture ID (original image). + */ + public int getBaseTextureId() { + return baseTexture != null ? baseTexture.getId() : -1; + } + + /** + * Get the clothes texture ID (resized for modern format). + */ + public int getClothesTextureId() { + return clothesTexture != null + ? clothesTexture.getId() + : getBaseTextureId(); + } + + /** + * Get the full-skin texture ID. + */ + public int getFullSkinTextureId() { + return fullSkinTexture != null ? fullSkinTexture.getId() : -1; + } + + /** + * Bind the clothes texture for rendering. + */ + public void bindClothes() { + int id = getClothesTextureId(); + if (id != -1) { + RenderSystem.setShaderTexture(0, id); + } + } + + /** + * Bind the full-skin texture for rendering. + */ + public void bindFullSkin() { + int id = getFullSkinTextureId(); + if (id != -1) { + RenderSystem.setShaderTexture(0, id); + } + } + + /** + * Bind the base texture for rendering. + */ + public void bindBase() { + int id = getBaseTextureId(); + if (id != -1) { + RenderSystem.setShaderTexture(0, id); + } + } + + /** + * Check if the texture was successfully loaded. + */ + public boolean isValid() { + return valid; + } + + /** + * Check if the image needed resizing (old 64x32 format). + */ + public boolean wasResized() { + return needsResize; + } + + /** + * Get the source URL for debugging. + */ + public String getSourceUrl() { + return sourceUrl; + } + + /** + * Release texture resources. + */ + public void close() { + if (baseTexture != null) { + baseTexture.close(); + baseTexture = null; + } + if (clothesTexture != null && clothesTexture != baseTexture) { + clothesTexture.close(); + clothesTexture = null; + } + if (fullSkinTexture != null) { + fullSkinTexture.close(); + fullSkinTexture = null; + } + valid = false; + } + + /** + * Get the DynamicTexture for clothes (for ResourceLocation registration). + */ + @Nullable + public DynamicTexture getClothesTexture() { + return clothesTexture; + } + + /** + * Get the DynamicTexture for full-skin mode. + */ + @Nullable + public DynamicTexture getFullSkinTexture() { + return fullSkinTexture; + } +} diff --git a/src/main/java/com/tiedup/remake/client/texture/DynamicTextureManager.java b/src/main/java/com/tiedup/remake/client/texture/DynamicTextureManager.java new file mode 100644 index 0000000..1f2d2e2 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/texture/DynamicTextureManager.java @@ -0,0 +1,507 @@ +package com.tiedup.remake.client.texture; + +import com.mojang.blaze3d.platform.NativeImage; +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.core.TiedUpMod; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import org.jetbrains.annotations.Nullable; +import javax.imageio.ImageIO; +import net.minecraft.client.Minecraft; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Manager for dynamically loaded online textures. + * + * Features: + * - Async HTTP download with thread pool + * - Host whitelist validation + * - Memory cache with LRU-style expiration + * - Failed URL tracking to prevent retry spam + * - Thread-safe operations + * + * Singleton pattern - access via getInstance(). + */ +@OnlyIn(Dist.CLIENT) +public class DynamicTextureManager { + + private static DynamicTextureManager INSTANCE; + + // Configuration + private static final int CONNECT_TIMEOUT_MS = 10_000; // 10 seconds + private static final int READ_TIMEOUT_MS = 30_000; // 30 seconds + private static final int MAX_CACHE_SIZE = 50; + private static final long FAILED_URL_RETRY_MS = 5 * 60 * 1000; // 5 minutes + + // Default whitelist of allowed hosts + private static final Set DEFAULT_WHITELIST = Set.of( + "i.imgur.com", + "cdn.discordapp.com", + "media.discordapp.net", + "raw.githubusercontent.com", + "user-images.githubusercontent.com", + "64.media.tumblr.com", + "www.minecraftskins.com", + "minecraftskins.com", + "textures.minecraft.net", + "crafatar.com", + "mc-heads.net", + "minotar.net", + "novaskin.me", + "t.novaskin.me", + "s.namemc.com", + "namemc.com" + ); + + // Thread pool for async downloads + private final Executor downloadExecutor = Executors.newFixedThreadPool( + 2, + r -> { + Thread t = new Thread(r, "TiedUp-TextureDownloader"); + t.setDaemon(true); + return t; + } + ); + + // Cache: URL -> loaded texture + private final Map textureCache = + new ConcurrentHashMap<>(); + + // Pending downloads: URL -> future (to avoid duplicate downloads) + private final Map< + String, + CompletableFuture + > pendingDownloads = new ConcurrentHashMap<>(); + + // Failed URLs: URL -> timestamp of failure (to avoid retry spam) + private final Map failedUrls = new ConcurrentHashMap<>(); + + // Registered textures with Minecraft's texture manager + private final Map registeredTextures = + new ConcurrentHashMap<>(); + + private DynamicTextureManager() {} + + /** + * Get the singleton instance. + */ + public static DynamicTextureManager getInstance() { + if (INSTANCE == null) { + INSTANCE = new DynamicTextureManager(); + } + return INSTANCE; + } + + /** + * Get a texture for a URL, loading it asynchronously if needed. + * + * @param url The texture URL + * @return The texture if cached, or null if loading/failed + */ + @Nullable + public DynamicOnlineTexture getTexture(String url) { + if (url == null || url.isEmpty()) return null; + + // Check config + if (!ModConfig.CLIENT.enableDynamicTextures.get()) { + return null; + } + + // Check cache + DynamicOnlineTexture cached = textureCache.get(url); + if (cached != null) { + return cached; + } + + // Check if failed recently + Long failTime = failedUrls.get(url); + if ( + failTime != null && + System.currentTimeMillis() - failTime < FAILED_URL_RETRY_MS + ) { + return null; + } + + // Start async download if not already pending + if (!pendingDownloads.containsKey(url)) { + downloadAsync(url); + } + + return null; + } + + /** + * Check if a texture is loaded and ready. + */ + public boolean isTextureReady(String url) { + if (url == null || url.isEmpty()) return false; + DynamicOnlineTexture tex = textureCache.get(url); + return tex != null && tex.isValid(); + } + + /** + * Start an async download for a URL. + */ + private void downloadAsync(String url) { + CompletableFuture future = + CompletableFuture.supplyAsync( + () -> { + try { + return downloadTexture(url); + } catch (Exception e) { + TiedUpMod.LOGGER.warn( + "[DynamicTextureManager] Download failed for {}: {}", + url, + e.getMessage() + ); + return null; + } + }, + downloadExecutor + ); + + pendingDownloads.put(url, future); + + future.thenAccept(texture -> { + pendingDownloads.remove(url); + + if (texture != null && texture.isValid()) { + // Cache management - remove oldest if at capacity + if (textureCache.size() >= MAX_CACHE_SIZE) { + evictOldest(); + } + textureCache.put(url, texture); + TiedUpMod.LOGGER.info( + "[DynamicTextureManager] Loaded texture from {}", + url + ); + } else { + failedUrls.put(url, System.currentTimeMillis()); + TiedUpMod.LOGGER.warn( + "[DynamicTextureManager] Failed to load texture from {}", + url + ); + } + }); + } + + /** + * Download a texture from a URL. + * Validates host whitelist and performs the HTTP download. + */ + @Nullable + private DynamicOnlineTexture downloadTexture(String urlString) { + try { + // Validate URL format + if (!urlString.startsWith("https://")) { + TiedUpMod.LOGGER.warn( + "[DynamicTextureManager] Rejected non-HTTPS URL: {}", + urlString + ); + return null; + } + + URL url = URI.create(urlString).toURL(); + String host = url.getHost().toLowerCase(); + + // Validate host whitelist + if (ModConfig.CLIENT.useTextureHostWhitelist.get()) { + if (!isHostWhitelisted(host)) { + TiedUpMod.LOGGER.warn( + "[DynamicTextureManager] Host not whitelisted: {}", + host + ); + return null; + } + } + + // Open connection with browser-like headers + // Many CDNs (including Tumblr) block requests without proper headers + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setRequestProperty( + "User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + ); + conn.setRequestProperty( + "Accept", + "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8" + ); + conn.setRequestProperty("Accept-Language", "en-US,en;q=0.9"); + conn.setInstanceFollowRedirects(true); + + int responseCode = conn.getResponseCode(); + if (responseCode != 200) { + TiedUpMod.LOGGER.warn( + "[DynamicTextureManager] HTTP {} for {}", + responseCode, + urlString + ); + return null; + } + + String contentType = conn.getContentType(); + TiedUpMod.LOGGER.debug( + "[DynamicTextureManager] Content-Type: {} for {}", + contentType, + urlString + ); + + // Read image - convert any format to PNG for NativeImage compatibility + try (InputStream is = conn.getInputStream()) { + // Buffer the stream so we can inspect/retry if needed + byte[] imageBytes = is.readAllBytes(); + TiedUpMod.LOGGER.info( + "[DynamicTextureManager] Downloaded {} bytes (Content-Type: {}) from {}", + imageBytes.length, + contentType, + urlString + ); + + if (imageBytes.length == 0) { + TiedUpMod.LOGGER.warn( + "[DynamicTextureManager] Empty response from {}", + urlString + ); + return null; + } + + // Check if response looks like HTML (error page) instead of image + if (imageBytes.length > 0 && imageBytes[0] == '<') { + String preview = new String( + imageBytes, + 0, + Math.min(200, imageBytes.length) + ); + TiedUpMod.LOGGER.warn( + "[DynamicTextureManager] Response looks like HTML, not an image: {}", + preview + ); + return null; + } + + NativeImage image = readImageAsNative( + new ByteArrayInputStream(imageBytes), + urlString + ); + if (image == null) { + // Log first bytes to help debug + StringBuilder hexPreview = new StringBuilder(); + for (int i = 0; i < Math.min(16, imageBytes.length); i++) { + hexPreview.append( + String.format("%02X ", imageBytes[i]) + ); + } + TiedUpMod.LOGGER.warn( + "[DynamicTextureManager] First 16 bytes: {}", + hexPreview + ); + return null; + } + return new DynamicOnlineTexture(image, urlString); + } + } catch (Exception e) { + TiedUpMod.LOGGER.error( + "[DynamicTextureManager] Error downloading {}: {}", + urlString, + e.getMessage() + ); + return null; + } + } + + /** + * Read an image from InputStream, converting any format (JPG, PNG, etc.) to NativeImage. + * NativeImage only supports PNG natively, so we use ImageIO to read other formats + * and convert them to PNG bytes. + * + * @param is The input stream containing image data + * @param urlString The URL (for logging) + * @return NativeImage or null if failed + */ + @Nullable + private NativeImage readImageAsNative(InputStream is, String urlString) { + try { + // First, try reading with ImageIO (supports JPG, PNG, GIF, BMP, etc.) + BufferedImage bufferedImage = ImageIO.read(is); + if (bufferedImage == null) { + // Try to peek at the first bytes to see what we got + TiedUpMod.LOGGER.warn( + "[DynamicTextureManager] ImageIO could not read image from {} - format may be unsupported or data is not an image", + urlString + ); + return null; + } + + TiedUpMod.LOGGER.debug( + "[DynamicTextureManager] Read image {}x{} from {}", + bufferedImage.getWidth(), + bufferedImage.getHeight(), + urlString + ); + + // Convert BufferedImage to PNG bytes, then to NativeImage + ByteArrayOutputStream pngOutput = new ByteArrayOutputStream(); + ImageIO.write(bufferedImage, "PNG", pngOutput); + byte[] pngBytes = pngOutput.toByteArray(); + + TiedUpMod.LOGGER.debug( + "[DynamicTextureManager] Converted to {} PNG bytes", + pngBytes.length + ); + + // Now read as NativeImage (PNG format is supported) + try ( + ByteArrayInputStream pngInput = new ByteArrayInputStream( + pngBytes + ) + ) { + return NativeImage.read(pngInput); + } + } catch (Exception e) { + TiedUpMod.LOGGER.error( + "[DynamicTextureManager] Failed to convert image from {}: {}", + urlString, + e.getMessage(), + e + ); + return null; + } + } + + /** + * Check if a host is in the whitelist. + */ + private boolean isHostWhitelisted(String host) { + // Check default whitelist + if (DEFAULT_WHITELIST.contains(host)) { + return true; + } + + // Check config additional whitelist + for (String allowed : ModConfig.CLIENT.textureHostWhitelist.get()) { + if ( + host.equals(allowed.toLowerCase()) || + host.endsWith("." + allowed.toLowerCase()) + ) { + return true; + } + } + + return false; + } + + /** + * Evict the oldest texture from cache. + */ + private void evictOldest() { + // Simple eviction: remove first entry + var iterator = textureCache.entrySet().iterator(); + if (iterator.hasNext()) { + var entry = iterator.next(); + entry.getValue().close(); + iterator.remove(); + registeredTextures.remove(entry.getKey()); + } + } + + /** + * Get or create a ResourceLocation for a dynamic texture. + * Registers the texture with Minecraft's texture manager. + * + * @param url The texture URL + * @param fullSkin Whether to use full-skin mode texture + * @return The ResourceLocation, or null if not available + */ + @Nullable + public ResourceLocation getTextureLocation(String url, boolean fullSkin) { + DynamicOnlineTexture texture = getTexture(url); + if (texture == null || !texture.isValid()) return null; + + String key = url + (fullSkin ? "_fullskin" : "_clothes"); + + return registeredTextures.computeIfAbsent(key, k -> { + var dynTex = fullSkin + ? texture.getFullSkinTexture() + : texture.getClothesTexture(); + if (dynTex == null) return null; + + // Create unique ResourceLocation + int hash = Math.abs(key.hashCode()); + ResourceLocation loc = ResourceLocation.fromNamespaceAndPath( + TiedUpMod.MOD_ID, + "dynamic/clothes_" + hash + ); + + // Register with Minecraft + Minecraft.getInstance().getTextureManager().register(loc, dynTex); + return loc; + }); + } + + /** + * Invalidate a specific URL from cache. + */ + public void invalidate(String url) { + DynamicOnlineTexture tex = textureCache.remove(url); + if (tex != null) { + tex.close(); + } + pendingDownloads.remove(url); + failedUrls.remove(url); + registeredTextures.remove(url + "_clothes"); + registeredTextures.remove(url + "_fullskin"); + } + + /** + * Clear all caches. Called on world unload. + */ + public void clearAll() { + for (DynamicOnlineTexture tex : textureCache.values()) { + tex.close(); + } + textureCache.clear(); + pendingDownloads.clear(); + failedUrls.clear(); + registeredTextures.clear(); + TiedUpMod.LOGGER.info( + "[DynamicTextureManager] Cleared all texture caches" + ); + } + + /** + * Clean up stale failed URL entries. + */ + public void cleanupStale() { + long now = System.currentTimeMillis(); + failedUrls + .entrySet() + .removeIf(e -> now - e.getValue() > FAILED_URL_RETRY_MS * 2); + } + + /** + * Get cache statistics for debugging. + */ + public String getCacheStats() { + return String.format( + "Cache: %d textures, %d pending, %d failed", + textureCache.size(), + pendingDownloads.size(), + failedUrls.size() + ); + } +} diff --git a/src/main/java/com/tiedup/remake/commands/BountyCommand.java b/src/main/java/com/tiedup/remake/commands/BountyCommand.java new file mode 100644 index 0000000..53cd1f6 --- /dev/null +++ b/src/main/java/com/tiedup/remake/commands/BountyCommand.java @@ -0,0 +1,176 @@ +package com.tiedup.remake.commands; + +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.tiedup.remake.bounty.Bounty; +import com.tiedup.remake.bounty.BountyManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.core.SettingsAccessor; +import java.util.Optional; +import net.minecraft.ChatFormatting; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; + +/** + * Command: /bounty + * + * Phase 17: Bounty System + * + * Creates a bounty on a target player using the held item as reward. + * + * Requirements: + * - Must hold an item (the reward) + * - Cannot put bounty on yourself + * - Respects max bounties limit + * - Cannot be tied up when creating bounty + */ +public class BountyCommand { + + public static void register( + CommandDispatcher dispatcher + ) { + dispatcher.register(createBountyCommand()); + TiedUpMod.LOGGER.info("Registered /bounty command"); + } + + /** + * Create the bounty command builder (for use as subcommand of /tiedup). + * @return The command builder + */ + public static com.mojang.brigadier.builder.LiteralArgumentBuilder< + CommandSourceStack + > createBountyCommand() { + return Commands.literal("bounty").then( + Commands.argument("target", EntityArgument.player()).executes( + BountyCommand::execute + ) + ); + } + + private static int execute(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + + // Must be a player + Optional playerOpt = CommandHelper.getPlayerOrFail( + source + ); + if (playerOpt.isEmpty()) return 0; + ServerPlayer player = playerOpt.get(); + + ServerPlayer target = EntityArgument.getPlayer(context, "target"); + + // Cannot bounty yourself + if (player.getUUID().equals(target.getUUID())) { + source.sendFailure( + Component.literal( + "You cannot put a bounty on yourself!" + ).withStyle(ChatFormatting.RED) + ); + return 0; + } + + // Check if player is tied up + IBondageState playerState = KidnappedHelper.getKidnappedState(player); + if (playerState != null && playerState.isTiedUp()) { + source.sendFailure( + Component.literal( + "You cannot create bounties while tied up!" + ).withStyle(ChatFormatting.RED) + ); + return 0; + } + + // Get bounty manager + BountyManager manager = BountyManager.get(player.serverLevel()); + + // Check bounty limit + if (!manager.canCreateBounty(player, player.serverLevel())) { + int max = SettingsAccessor.getMaxBounties( + player.serverLevel().getGameRules() + ); + source.sendFailure( + Component.literal( + "Maximum number (" + max + ") of active bounties reached!" + ).withStyle(ChatFormatting.RED) + ); + return 0; + } + + // Must hold an item as reward + ItemStack heldItem = player.getMainHandItem(); + if (heldItem.isEmpty()) { + source.sendFailure( + Component.literal( + "You must hold an item as the reward!" + ).withStyle(ChatFormatting.RED) + ); + return 0; + } + + // Get bounty duration + int duration = SettingsAccessor.getBountyDuration( + player.serverLevel().getGameRules() + ); + + // SECURITY FIX: Create reward with count=1 to prevent item duplication + // Bug: If player held 64 diamonds, bounty would be 64 diamonds but only cost 1 + ItemStack rewardItem = heldItem.copy(); + rewardItem.setCount(1); + + // Create the bounty + Bounty bounty = new Bounty( + player.getUUID(), + player.getName().getString(), + target.getUUID(), + target.getName().getString(), + rewardItem, + duration + ); + + // Consume one item from the stack (not the entire stack!) + player.getMainHandItem().shrink(1); + + // Add bounty + manager.addBounty(bounty); + + // Notify player + source.sendSuccess( + () -> + Component.literal( + "Bounty created on " + target.getName().getString() + "!" + ).withStyle(ChatFormatting.GREEN), + false + ); + + // Broadcast to all players + player.server + .getPlayerList() + .broadcastSystemMessage( + Component.literal( + "[Bounty] " + + player.getName().getString() + + " has put a bounty on " + + target.getName().getString() + + "!" + ).withStyle(ChatFormatting.GOLD), + false + ); + + TiedUpMod.LOGGER.info( + "[BOUNTY] {} created bounty on {} with reward {}", + player.getName().getString(), + target.getName().getString(), + bounty.getRewardDescription() + ); + + return 1; + } +} diff --git a/src/main/java/com/tiedup/remake/commands/CaptivityDebugCommand.java b/src/main/java/com/tiedup/remake/commands/CaptivityDebugCommand.java new file mode 100644 index 0000000..01e517d --- /dev/null +++ b/src/main/java/com/tiedup/remake/commands/CaptivityDebugCommand.java @@ -0,0 +1,332 @@ +package com.tiedup.remake.commands; + +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.tiedup.remake.cells.CampOwnership; +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerRecord; +import java.util.List; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; + +/** + * Debug commands for the unified captivity system. + * + * Commands: + * /tiedup debug prisoner - Show captivity state for a player + * /tiedup debug validate - Validate captivity system consistency + * /tiedup debug repair - Repair inconsistencies (WARNING: modifies data) + * /tiedup debug camp - Show camp info and indexed cells + */ +public class CaptivityDebugCommand { + + /** + * Create the /tiedup debug command tree. + */ + public static LiteralArgumentBuilder< + CommandSourceStack + > createDebugCommand() { + return Commands.literal("debug") + .requires(CommandHelper.REQUIRES_OP) // Admin only + // /tiedup debug prisoner + .then( + Commands.literal("prisoner").then( + Commands.argument( + "player", + EntityArgument.player() + ).executes(CaptivityDebugCommand::debugPrisoner) + ) + ) + // /tiedup debug validate + .then( + Commands.literal("validate").executes( + CaptivityDebugCommand::validateSystem + ) + ) + // /tiedup debug repair + .then( + Commands.literal("repair").executes( + CaptivityDebugCommand::repairSystem + ) + ) + // /tiedup debug camp + .then( + Commands.literal("camp").then( + Commands.argument( + "campIdPrefix", + StringArgumentType.string() + ).executes(CaptivityDebugCommand::debugCamp) + ) + ); + } + + /** + * Show captivity state for a player. + * /tiedup debug prisoner + */ + private static int debugPrisoner(CommandContext ctx) { + try { + ServerPlayer target = EntityArgument.getPlayer(ctx, "player"); + ServerLevel level = target.serverLevel(); + + PrisonerManager manager = PrisonerManager.get(level); + PrisonerRecord record = manager.getRecord(target.getUUID()); + + StringBuilder debugInfo = new StringBuilder(); + debugInfo + .append("Player: ") + .append(target.getName().getString()) + .append("\n"); + debugInfo.append("State: ").append(record.getState()).append("\n"); + debugInfo + .append("Camp ID: ") + .append(record.getCampId()) + .append("\n"); + debugInfo + .append("Cell ID: ") + .append(record.getCellId()) + .append("\n"); + debugInfo + .append("Captor ID: ") + .append(record.getCaptorId()) + .append("\n"); + debugInfo + .append("Protection Expiry: ") + .append(record.getProtectionExpiry()) + .append("\n"); + debugInfo + .append("Is Protected: ") + .append(record.isProtected(level.getGameTime())) + .append("\n"); + debugInfo + .append("Is Captive: ") + .append(record.isCaptive()) + .append("\n"); + + // Send debug info to command executor + ctx + .getSource() + .sendSuccess( + () -> + Component.literal( + "=== Captivity Debug Info ===\n" + debugInfo + ).withStyle(ChatFormatting.YELLOW), + false + ); + + return 1; // Success + } catch (Exception e) { + ctx + .getSource() + .sendFailure( + Component.literal("Error: " + e.getMessage()).withStyle( + ChatFormatting.RED + ) + ); + return 0; // Failure + } + } + + /** + * Validate captivity system consistency. + * /tiedup debug validate + * NOTE: CaptivitySystemValidator was removed. This now shows a summary of the prison system. + */ + private static int validateSystem(CommandContext ctx) { + try { + ServerLevel level = ctx.getSource().getLevel(); + + ctx + .getSource() + .sendSuccess( + () -> + Component.literal( + "Checking captivity system..." + ).withStyle(ChatFormatting.YELLOW), + true + ); + + // Show PrisonerManager stats instead + PrisonerManager manager = PrisonerManager.get(level); + String debugInfo = manager.toDebugString(); + + ctx + .getSource() + .sendSuccess( + () -> + Component.literal(debugInfo).withStyle( + ChatFormatting.GREEN + ), + true + ); + + return 1; // Success + } catch (Exception e) { + ctx + .getSource() + .sendFailure( + Component.literal( + "Error during validation: " + e.getMessage() + ).withStyle(ChatFormatting.RED) + ); + return 0; // Failure + } + } + + /** + * Repair captivity system inconsistencies. + * /tiedup debug repair + * NOTE: CaptivitySystemValidator was removed. This command is now a placeholder. + */ + private static int repairSystem(CommandContext ctx) { + try { + ctx + .getSource() + .sendSuccess( + () -> + Component.literal( + "Repair functionality has been simplified with the new PrisonerManager system." + ).withStyle(ChatFormatting.YELLOW), + true + ); + + ctx + .getSource() + .sendSuccess( + () -> + Component.literal( + "The new system maintains consistency automatically." + ).withStyle(ChatFormatting.GREEN), + true + ); + + return 1; // Success + } catch (Exception e) { + ctx + .getSource() + .sendFailure( + Component.literal("Error: " + e.getMessage()).withStyle( + ChatFormatting.RED + ) + ); + return 0; // Failure + } + } + + /** + * Debug camp information and indexed cells. + * /tiedup debug camp + */ + private static int debugCamp(CommandContext ctx) { + try { + ServerLevel level = ctx.getSource().getLevel(); + String campIdPrefix = StringArgumentType.getString( + ctx, + "campIdPrefix" + ); + + CampOwnership ownership = CampOwnership.get(level); + CellRegistryV2 cellRegistry = CellRegistryV2.get(level); + + // Find camps matching prefix + UUID matchingCamp = null; + for (CampOwnership.CampData camp : ownership.getAllCamps()) { + String campIdStr = camp.getCampId().toString(); + if (campIdStr.startsWith(campIdPrefix)) { + matchingCamp = camp.getCampId(); + break; + } + } + + if (matchingCamp == null) { + ctx + .getSource() + .sendFailure( + Component.literal( + "No camp found with ID prefix: " + campIdPrefix + ).withStyle(ChatFormatting.RED) + ); + return 0; + } + + // Get camp info + CampOwnership.CampData campData = ownership.getCamp(matchingCamp); + StringBuilder sb = new StringBuilder(); + sb.append( + String.format( + "=== Camp %s ===\n", + matchingCamp.toString().substring(0, 8) + ) + ); + sb.append(String.format("Trader: %s\n", campData.getTraderUUID())); + sb.append(String.format("Maid: %s\n", campData.getMaidUUID())); + sb.append(String.format("Alive: %s\n", campData.isAlive())); + sb.append(String.format("Center: %s\n", campData.getCenter())); + + // Get indexed cells + List cells = cellRegistry.getCellsByCamp(matchingCamp); + sb.append( + String.format("\n=== Indexed Cells (%d) ===\n", cells.size()) + ); + + if (cells.isEmpty()) { + sb.append("⚠ NO CELLS INDEXED for this camp!\n"); + } else { + for (CellDataV2 cell : cells.subList( + 0, + Math.min(cells.size(), 10) + )) { + sb.append( + String.format( + "- Cell %s at %s (type=%s, owner=%s, interior=%d, walls=%d)\n", + cell.getId().toString().substring(0, 8), + cell.getCorePos().toShortString(), + cell.isCampOwned() ? "CAMP" : "PLAYER", + cell.getOwnerId() != null + ? cell.getOwnerId().toString().substring(0, 8) + : "null", + cell.getInteriorBlocks().size(), + cell.getWallBlocks().size() + ) + ); + } + if (cells.size() > 10) { + sb.append( + String.format("... and %d more\n", cells.size() - 10) + ); + } + } + + final String campInfo = sb.toString(); + ctx + .getSource() + .sendSuccess( + () -> + Component.literal(campInfo).withStyle( + ChatFormatting.YELLOW + ), + false + ); + + return 1; // Success + } catch (Exception e) { + ctx + .getSource() + .sendFailure( + Component.literal("Error: " + e.getMessage()).withStyle( + ChatFormatting.RED + ) + ); + return 0; // Failure + } + } +} diff --git a/src/main/java/com/tiedup/remake/commands/CellCommand.java b/src/main/java/com/tiedup/remake/commands/CellCommand.java new file mode 100644 index 0000000..71caac3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/commands/CellCommand.java @@ -0,0 +1,678 @@ +package com.tiedup.remake.commands; + +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.tiedup.remake.blocks.entity.MarkerBlockEntity; +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellOwnerType; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.items.ItemAdminWand; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.entity.BlockEntity; + +/** + * Cell management commands. + * + * Phase: Kidnapper Revamp - Cell System + * + * Commands: + * /tiedup cell name - Name the selected cell (from wand) and link to player + * /tiedup cell list [owner] - List all cells, optionally filtered by owner + * /tiedup cell info [name] - Show info about selected cell or by name + * /tiedup cell delete - Delete the selected cell + * + * Requires OP level 2. + */ +public class CellCommand { + + /** + * Create the cell command builder (for use as subcommand of /tiedup). + */ + public static com.mojang.brigadier.builder.LiteralArgumentBuilder< + CommandSourceStack + > createCellCommand() { + return Commands.literal("cell") + .requires(CommandHelper.REQUIRES_OP) + // /tiedup cell name + .then( + Commands.literal("name").then( + Commands.argument( + "name", + StringArgumentType.word() + ).executes(CellCommand::setName) + ) + ) + // /tiedup cell list [owner] + .then( + Commands.literal("list") + .executes(CellCommand::listAll) + .then( + Commands.argument( + "owner", + EntityArgument.player() + ).executes(CellCommand::listByOwner) + ) + ) + // /tiedup cell info [name] + .then( + Commands.literal("info") + .executes(CellCommand::infoSelected) + .then( + Commands.argument( + "name", + StringArgumentType.word() + ).executes(CellCommand::infoByName) + ) + ) + // /tiedup cell delete + .then( + Commands.literal("delete").executes(CellCommand::deleteSelected) + ) + // /tiedup cell resetspawns [radius] + .then( + Commands.literal("resetspawns") + .executes(ctx -> resetSpawns(ctx, 100)) // Default 100 block radius + .then( + Commands.argument( + "radius", + IntegerArgumentType.integer(1, 500) + ).executes(ctx -> + resetSpawns( + ctx, + IntegerArgumentType.getInteger(ctx, "radius") + ) + ) + ) + ); + } + + /** + * /tiedup cell name + * + * Name the selected cell (from wand) and link it to the executing player. + */ + private static int setName(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + String name = StringArgumentType.getString(context, "name"); + + // Must be a player + if (!(source.getEntity() instanceof ServerPlayer player)) { + source.sendFailure(Component.literal("Must be a player")); + return 0; + } + + ServerLevel serverLevel = player.serverLevel(); + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + + // Get selected cell from wand + UUID selectedCellId = getSelectedCellFromWand(player); + if (selectedCellId == null) { + source.sendFailure( + Component.literal( + "No cell selected. Use the Admin Wand on a Cell Core first." + ) + ); + return 0; + } + + CellDataV2 cell = registry.getCell(selectedCellId); + if (cell == null) { + source.sendFailure( + Component.literal("Selected cell no longer exists") + ); + return 0; + } + + // Check name uniqueness + CellDataV2 existingCell = registry.getCellByName(name); + if ( + existingCell != null && !existingCell.getId().equals(selectedCellId) + ) { + source.sendFailure( + Component.literal("Cell name '" + name + "' already exists") + ); + return 0; + } + + // Set name and owner + cell.setName(name); + + // MEDIUM FIX: Update camp index when changing ownership + // Store old ownerId before changing (in case it was camp-owned) + UUID oldOwnerId = cell.isCampOwned() ? cell.getOwnerId() : null; + cell.setOwnerId(player.getUUID()); + cell.setOwnerType(CellOwnerType.PLAYER); + registry.updateCampIndex(cell, oldOwnerId); + + registry.setDirty(); + + source.sendSuccess( + () -> + Component.literal( + "Named cell '" + name + "' and linked to you" + ), + true + ); + return 1; + } + + /** + * /tiedup cell list + * + * List all registered cells. + */ + private static int listAll(CommandContext context) { + CommandSourceStack source = context.getSource(); + ServerLevel serverLevel = source.getLevel(); + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + + Collection cells = registry.getAllCells(); + if (cells.isEmpty()) { + source.sendSuccess( + () -> Component.literal("No cells registered"), + false + ); + return 1; + } + + source.sendSuccess( + () -> Component.literal("=== Cells (" + cells.size() + ") ==="), + false + ); + + for (CellDataV2 cell : cells) { + String info = formatCellInfo(cell, serverLevel); + source.sendSuccess(() -> Component.literal(info), false); + } + + return 1; + } + + /** + * /tiedup cell list + * + * List cells owned by a specific player. + */ + private static int listByOwner(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + ServerPlayer owner = EntityArgument.getPlayer(context, "owner"); + ServerLevel serverLevel = source.getLevel(); + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + + List cells = registry.getCellsByOwner(owner.getUUID()); + if (cells.isEmpty()) { + source.sendSuccess( + () -> + Component.literal( + owner.getName().getString() + " has no cells" + ), + false + ); + return 1; + } + + source.sendSuccess( + () -> + Component.literal( + "=== Cells owned by " + + owner.getName().getString() + + " (" + + cells.size() + + ") ===" + ), + false + ); + + for (CellDataV2 cell : cells) { + String info = formatCellInfo(cell, serverLevel); + source.sendSuccess(() -> Component.literal(info), false); + } + + return 1; + } + + /** + * /tiedup cell info + * + * Show info about the selected cell (from wand). + */ + private static int infoSelected( + CommandContext context + ) { + CommandSourceStack source = context.getSource(); + + // Must be a player + if (!(source.getEntity() instanceof ServerPlayer player)) { + source.sendFailure(Component.literal("Must be a player")); + return 0; + } + + ServerLevel serverLevel = player.serverLevel(); + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + + UUID selectedCellId = getSelectedCellFromWand(player); + if (selectedCellId == null) { + source.sendFailure( + Component.literal( + "No cell selected. Use the Admin Wand on a Cell Core first." + ) + ); + return 0; + } + + CellDataV2 cell = registry.getCell(selectedCellId); + if (cell == null) { + source.sendFailure( + Component.literal("Selected cell no longer exists") + ); + return 0; + } + + displayCellInfo(source, cell, serverLevel); + return 1; + } + + /** + * /tiedup cell info + * + * Show info about a cell by name. + */ + private static int infoByName(CommandContext context) { + CommandSourceStack source = context.getSource(); + String name = StringArgumentType.getString(context, "name"); + ServerLevel serverLevel = source.getLevel(); + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + + CellDataV2 cell = registry.getCellByName(name); + if (cell == null) { + source.sendFailure( + Component.literal("Cell '" + name + "' not found") + ); + return 0; + } + + displayCellInfo(source, cell, serverLevel); + return 1; + } + + /** + * /tiedup cell delete + * + * Delete the selected cell (from wand). + */ + private static int deleteSelected( + CommandContext context + ) { + CommandSourceStack source = context.getSource(); + + // Must be a player + if (!(source.getEntity() instanceof ServerPlayer player)) { + source.sendFailure(Component.literal("Must be a player")); + return 0; + } + + ServerLevel serverLevel = player.serverLevel(); + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + + UUID selectedCellId = getSelectedCellFromWand(player); + if (selectedCellId == null) { + source.sendFailure( + Component.literal( + "No cell selected. Use the Admin Wand on a Cell Core first." + ) + ); + return 0; + } + + CellDataV2 cell = registry.getCell(selectedCellId); + if (cell == null) { + source.sendFailure( + Component.literal("Selected cell no longer exists") + ); + return 0; + } + + String cellName = + cell.getName() != null + ? cell.getName() + : cell.getId().toString().substring(0, 8) + "..."; + + // Remove cell from registry + registry.removeCell(selectedCellId); + + // Clear selection from wand + ItemStack mainHand = player.getMainHandItem(); + if (mainHand.getItem() instanceof ItemAdminWand) { + ItemAdminWand.setActiveCellId(mainHand, null); + } + ItemStack offHand = player.getOffhandItem(); + if (offHand.getItem() instanceof ItemAdminWand) { + ItemAdminWand.setActiveCellId(offHand, null); + } + + source.sendSuccess( + () -> Component.literal("Deleted cell '" + cellName + "'"), + true + ); + return 1; + } + + /** + * /tiedup cell resetspawns [radius] + * + * Reset the hasSpawned flag on all spawn markers within radius. + * Use this before saving a structure to ensure NPCs will spawn when placed. + * + * Performance: Iterates over loaded chunks and their BlockEntities instead of + * individual block positions to avoid server crash on large radii. + */ + private static int resetSpawns( + CommandContext context, + int radius + ) { + CommandSourceStack source = context.getSource(); + ServerLevel serverLevel = source.getLevel(); + BlockPos center = BlockPos.containing(source.getPosition()); + + int resetCount = 0; + int spawnMarkerCount = 0; + + // Convert block radius to chunk radius (16 blocks per chunk) + int chunkRadius = (radius + 15) >> 4; + int centerChunkX = center.getX() >> 4; + int centerChunkZ = center.getZ() >> 4; + + // Iterate over chunks instead of individual blocks (much faster) + for ( + int cx = centerChunkX - chunkRadius; + cx <= centerChunkX + chunkRadius; + cx++ + ) { + for ( + int cz = centerChunkZ - chunkRadius; + cz <= centerChunkZ + chunkRadius; + cz++ + ) { + // Only process loaded chunks + net.minecraft.world.level.chunk.LevelChunk chunk = serverLevel + .getChunkSource() + .getChunkNow(cx, cz); + if (chunk == null) continue; + + // Iterate over all BlockEntities in this chunk + for (BlockEntity be : chunk.getBlockEntities().values()) { + if (!(be instanceof MarkerBlockEntity marker)) continue; + + // Check if within radius (squared distance for performance) + BlockPos pos = be.getBlockPos(); + double distSq = center.distSqr(pos); + if (distSq > (double) radius * radius) continue; + + if (marker.isSpawnMarker()) { + spawnMarkerCount++; + if (marker.hasSpawned()) { + marker.resetHasSpawned(); + resetCount++; + } + } + } + } + } + + final int finalResetCount = resetCount; + final int finalSpawnMarkerCount = spawnMarkerCount; + + source.sendSuccess( + () -> + Component.literal( + "Reset " + + finalResetCount + + " spawn markers (found " + + finalSpawnMarkerCount + + " total spawn markers in " + + radius + + " block radius)" + ), + true + ); + + if (resetCount > 0) { + source.sendSuccess( + () -> + Component.literal( + "You can now save the structure - NPCs will spawn when it's placed." + ), + false + ); + } + + return 1; + } + + // ==================== HELPERS ==================== + + /** + * Get the selected cell ID from the player's admin wand. + */ + private static UUID getSelectedCellFromWand(ServerPlayer player) { + // Check main hand + ItemStack mainHand = player.getMainHandItem(); + if (mainHand.getItem() instanceof ItemAdminWand) { + UUID cellId = ItemAdminWand.getActiveCellId(mainHand); + if (cellId != null) return cellId; + } + + // Check offhand + ItemStack offHand = player.getOffhandItem(); + if (offHand.getItem() instanceof ItemAdminWand) { + return ItemAdminWand.getActiveCellId(offHand); + } + + return null; + } + + /** + * Format cell info for list display. + */ + private static String formatCellInfo(CellDataV2 cell, ServerLevel level) { + StringBuilder sb = new StringBuilder(); + + // Name or ID + if (cell.getName() != null) { + sb.append(" ").append(cell.getName()); + } else { + sb + .append(" ") + .append(cell.getId().toString().substring(0, 8)) + .append("..."); + } + + // Position + sb.append(" @ ").append(cell.getCorePos().toShortString()); + + // Owner + if (cell.hasOwner()) { + ServerPlayer owner = level + .getServer() + .getPlayerList() + .getPlayer(cell.getOwnerId()); + if (owner != null) { + sb.append(" (").append(owner.getName().getString()).append(")"); + } else { + sb.append(" (offline)"); + } + } else { + sb.append(" (world)"); + } + + // Prisoners + if (cell.isOccupied()) { + sb + .append(" [") + .append(cell.getPrisonerCount()) + .append(" prisoners]"); + } + + return sb.toString(); + } + + /** + * Display detailed cell info (V2). + */ + private static void displayCellInfo( + CommandSourceStack source, + CellDataV2 cell, + ServerLevel level + ) { + String nameDisplay = + cell.getName() != null ? cell.getName() : "(unnamed)"; + source.sendSuccess( + () -> Component.literal("=== Cell: " + nameDisplay + " ==="), + false + ); + + source.sendSuccess( + () -> Component.literal("ID: " + cell.getId().toString()), + false + ); + + source.sendSuccess( + () -> Component.literal("State: " + cell.getState()), + false + ); + + source.sendSuccess( + () -> + Component.literal( + "Core Position: " + cell.getCorePos().toShortString() + ), + false + ); + + // Spawn point (may be null in V2) + if (cell.getSpawnPoint() != null) { + source.sendSuccess( + () -> + Component.literal( + "Spawn Point: " + cell.getSpawnPoint().toShortString() + ), + false + ); + } + + // Owner info + if (cell.hasOwner()) { + ServerPlayer owner = level + .getServer() + .getPlayerList() + .getPlayer(cell.getOwnerId()); + String ownerName = + owner != null ? owner.getName().getString() : "(offline)"; + source.sendSuccess( + () -> + Component.literal( + "Owner: " + + ownerName + + " (" + + cell.getOwnerId().toString().substring(0, 8) + + "...)" + ), + false + ); + } else { + source.sendSuccess( + () -> Component.literal("Owner: (world-generated)"), + false + ); + } + + // Geometry + source.sendSuccess( + () -> + Component.literal( + "Interior blocks: " + cell.getInteriorBlocks().size() + ), + false + ); + source.sendSuccess( + () -> + Component.literal( + "Wall blocks: " + cell.getWallBlocks().size() + ), + false + ); + + // Breach info + if (!cell.getBreachedPositions().isEmpty()) { + source.sendSuccess( + () -> + Component.literal( + "Breaches: " + + cell.getBreachedPositions().size() + + " (" + + String.format( + "%.1f", + cell.getBreachPercentage() * 100 + ) + + "%)" + ), + false + ); + } + + // Features + if (!cell.getBeds().isEmpty()) { + source.sendSuccess( + () -> Component.literal("Beds: " + cell.getBeds().size()), + false + ); + } + if (!cell.getAnchors().isEmpty()) { + source.sendSuccess( + () -> Component.literal("Anchors: " + cell.getAnchors().size()), + false + ); + } + if (!cell.getDoors().isEmpty()) { + source.sendSuccess( + () -> Component.literal("Doors: " + cell.getDoors().size()), + false + ); + } + + // Prisoners + source.sendSuccess( + () -> + Component.literal( + "Prisoners: " + cell.getPrisonerCount() + "/4" + ), + false + ); + for (UUID prisonerId : cell.getPrisonerIds()) { + ServerPlayer prisoner = level + .getServer() + .getPlayerList() + .getPlayer(prisonerId); + String prisonerName = + prisoner != null ? prisoner.getName().getString() : "(offline)"; + source.sendSuccess( + () -> Component.literal(" - " + prisonerName), + false + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/commands/ClothesCommand.java b/src/main/java/com/tiedup/remake/commands/ClothesCommand.java new file mode 100644 index 0000000..1d2b4e5 --- /dev/null +++ b/src/main/java/com/tiedup/remake/commands/ClothesCommand.java @@ -0,0 +1,311 @@ +package com.tiedup.remake.commands; + +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.tiedup.remake.items.clothes.GenericClothes; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; + +/** + * Command handler for clothes configuration. + * + * Subcommands: + * /tiedup clothes url set - Set dynamic texture URL on held clothes + * /tiedup clothes url reset - Remove dynamic texture URL + * /tiedup clothes fullskin - Toggle full-skin mode + * /tiedup clothes smallarms - Toggle small arms forcing + * /tiedup clothes keephead - Toggle keep head mode (preserves wearer's head) + * /tiedup clothes layer - Toggle layer visibility + * + * All commands operate on the clothes item held in main hand. + */ +public class ClothesCommand { + + /** + * Create the /tiedup clothes ... command tree. + * + * @return The command builder + */ + public static LiteralArgumentBuilder< + CommandSourceStack + > createClothesCommand() { + return Commands.literal("clothes") + // /tiedup clothes url set + // /tiedup clothes url reset + .then( + Commands.literal("url") + .then( + Commands.literal("set").then( + Commands.argument( + "url", + StringArgumentType.greedyString() + ).executes(ClothesCommand::setUrl) + ) + ) + .then( + Commands.literal("reset").executes( + ClothesCommand::resetUrl + ) + ) + ) + // /tiedup clothes fullskin + .then( + Commands.literal("fullskin").executes( + ClothesCommand::toggleFullSkin + ) + ) + // /tiedup clothes smallarms + .then( + Commands.literal("smallarms").executes( + ClothesCommand::toggleSmallArms + ) + ) + // /tiedup clothes keephead + .then( + Commands.literal("keephead").executes( + ClothesCommand::toggleKeepHead + ) + ) + // /tiedup clothes layer + .then( + Commands.literal("layer").then( + Commands.argument("part", StringArgumentType.word()) + .suggests((ctx, builder) -> { + builder.suggest("head"); + builder.suggest("body"); + builder.suggest("leftarm"); + builder.suggest("rightarm"); + builder.suggest("leftleg"); + builder.suggest("rightleg"); + return builder.buildFuture(); + }) + .executes(ClothesCommand::toggleLayer) + ) + ); + } + + /** + * Get the clothes ItemStack from player's main hand. + * Returns null and sends error message if not holding clothes. + */ + private static ItemStack getHeldClothes( + CommandContext ctx + ) throws CommandSyntaxException { + ServerPlayer player = ctx.getSource().getPlayerOrException(); + ItemStack held = player.getMainHandItem(); + + if (held.isEmpty() || !(held.getItem() instanceof GenericClothes)) { + ctx + .getSource() + .sendFailure( + Component.translatable("command.tiedup.clothes.not_holding") + ); + return null; + } + return held; + } + + /** + * /tiedup clothes url set + */ + private static int setUrl(CommandContext ctx) + throws CommandSyntaxException { + ItemStack clothes = getHeldClothes(ctx); + if (clothes == null) return 0; + + String url = StringArgumentType.getString(ctx, "url"); + + // Basic URL validation + if (!url.startsWith("https://")) { + ctx + .getSource() + .sendFailure( + Component.translatable( + "command.tiedup.clothes.url_must_https" + ) + ); + return 0; + } + + // Check URL length + if (url.length() > 2048) { + ctx + .getSource() + .sendFailure( + Component.translatable( + "command.tiedup.clothes.url_too_long" + ) + ); + return 0; + } + + GenericClothes item = (GenericClothes) clothes.getItem(); + item.setDynamicTextureUrl(clothes, url); + + ctx + .getSource() + .sendSuccess( + () -> Component.translatable("command.tiedup.clothes.url_set"), + false + ); + return 1; + } + + /** + * /tiedup clothes url reset + */ + private static int resetUrl(CommandContext ctx) + throws CommandSyntaxException { + ItemStack clothes = getHeldClothes(ctx); + if (clothes == null) return 0; + + GenericClothes item = (GenericClothes) clothes.getItem(); + item.removeDynamicTextureUrl(clothes); + + ctx + .getSource() + .sendSuccess( + () -> + Component.translatable("command.tiedup.clothes.url_reset"), + false + ); + return 1; + } + + /** + * /tiedup clothes fullskin + */ + private static int toggleFullSkin(CommandContext ctx) + throws CommandSyntaxException { + ItemStack clothes = getHeldClothes(ctx); + if (clothes == null) return 0; + + GenericClothes item = (GenericClothes) clothes.getItem(); + boolean newState = !item.isFullSkinEnabled(clothes); + item.setFullSkinEnabled(clothes, newState); + + String stateKey = newState ? "enabled" : "disabled"; + ctx + .getSource() + .sendSuccess( + () -> + Component.translatable( + "command.tiedup.clothes.fullskin_" + stateKey + ), + false + ); + return 1; + } + + /** + * /tiedup clothes smallarms + */ + private static int toggleSmallArms(CommandContext ctx) + throws CommandSyntaxException { + ItemStack clothes = getHeldClothes(ctx); + if (clothes == null) return 0; + + GenericClothes item = (GenericClothes) clothes.getItem(); + boolean newState = !item.shouldForceSmallArms(clothes); + item.setForceSmallArms(clothes, newState); + + String stateKey = newState ? "enabled" : "disabled"; + ctx + .getSource() + .sendSuccess( + () -> + Component.translatable( + "command.tiedup.clothes.smallarms_" + stateKey + ), + false + ); + return 1; + } + + /** + * /tiedup clothes keephead + * When enabled, the wearer's head/hat is preserved instead of being replaced by clothes. + */ + private static int toggleKeepHead(CommandContext ctx) + throws CommandSyntaxException { + ItemStack clothes = getHeldClothes(ctx); + if (clothes == null) return 0; + + GenericClothes item = (GenericClothes) clothes.getItem(); + boolean newState = !item.isKeepHeadEnabled(clothes); + item.setKeepHeadEnabled(clothes, newState); + + String stateKey = newState ? "enabled" : "disabled"; + ctx + .getSource() + .sendSuccess( + () -> + Component.translatable( + "command.tiedup.clothes.keephead_" + stateKey + ), + false + ); + return 1; + } + + /** + * /tiedup clothes layer + */ + private static int toggleLayer(CommandContext ctx) + throws CommandSyntaxException { + ItemStack clothes = getHeldClothes(ctx); + if (clothes == null) return 0; + + String part = StringArgumentType.getString(ctx, "part").toLowerCase(); + String layerKey = mapPartToLayerKey(part); + + if (layerKey == null) { + ctx + .getSource() + .sendFailure( + Component.translatable( + "command.tiedup.clothes.unknown_layer", + part + ) + ); + return 0; + } + + GenericClothes item = (GenericClothes) clothes.getItem(); + boolean newState = !item.isLayerEnabled(clothes, layerKey); + item.setLayerEnabled(clothes, layerKey, newState); + + String stateKey = newState ? "visible" : "hidden"; + ctx + .getSource() + .sendSuccess( + () -> + Component.translatable( + "command.tiedup.clothes.layer_" + stateKey, + part + ), + false + ); + return 1; + } + + /** + * Map user-friendly part name to NBT layer key. + */ + private static String mapPartToLayerKey(String part) { + return switch (part) { + case "head" -> GenericClothes.LAYER_HEAD; + case "body" -> GenericClothes.LAYER_BODY; + case "leftarm" -> GenericClothes.LAYER_LEFT_ARM; + case "rightarm" -> GenericClothes.LAYER_RIGHT_ARM; + case "leftleg" -> GenericClothes.LAYER_LEFT_LEG; + case "rightleg" -> GenericClothes.LAYER_RIGHT_LEG; + default -> null; + }; + } +} diff --git a/src/main/java/com/tiedup/remake/commands/CollarCommand.java b/src/main/java/com/tiedup/remake/commands/CollarCommand.java new file mode 100644 index 0000000..3cd246f --- /dev/null +++ b/src/main/java/com/tiedup/remake/commands/CollarCommand.java @@ -0,0 +1,526 @@ +package com.tiedup.remake.commands; + +import com.mojang.brigadier.CommandDispatcher; +import com.tiedup.remake.v2.BodyRegionV2; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.util.teleport.Position; +import com.tiedup.remake.util.teleport.TeleportHelper; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; + +/** + * Collar management commands for Phase 18. + * + * Commands: + * /collar claim - Claim ownership of a player's collar + * /collar unclaim - Remove your ownership + * /collar rename - Set alias for collar + * /collar addowner - Add another owner + * /collar removeowner - Remove an owner + * /collar sethome - Set home location + * /collar setprison - Set prison location (legacy) + * /collar setcell - Assign cell to collar (preferred) + * /collar prisonradius - Set prison fence radius (legacy) + * /collar prisonfence [on/off] - Toggle prison fence (legacy) + * /collar backhome - Teleport slave back home + * /collar info - Show collar info + * + * Requires OP level 2. + */ +public class CollarCommand { + + public static void register( + CommandDispatcher dispatcher + ) { + dispatcher.register(createCollarCommand()); + } + + /** + * Create the collar command builder (for use as subcommand of /tiedup). + * @return The command builder + */ + public static com.mojang.brigadier.builder.LiteralArgumentBuilder< + CommandSourceStack + > createCollarCommand() { + return Commands.literal("collar") + .requires(CommandHelper.REQUIRES_OP) + // /collar claim + .then( + Commands.literal("claim").then( + Commands.argument( + "player", + EntityArgument.player() + ).executes(CollarCommand::claim) + ) + ) + // /collar unclaim + .then( + Commands.literal("unclaim").then( + Commands.argument( + "player", + EntityArgument.player() + ).executes(CollarCommand::unclaim) + ) + ) + // /collar rename + .then( + Commands.literal("rename").then( + Commands.argument("player", EntityArgument.player()).then( + Commands.argument( + "name", + StringArgumentType.greedyString() + ).executes(CollarCommand::rename) + ) + ) + ) + // /collar addowner + .then( + Commands.literal("addowner").then( + Commands.argument("target", EntityArgument.player()).then( + Commands.argument( + "owner", + EntityArgument.player() + ).executes(CollarCommand::addOwner) + ) + ) + ) + // /collar removeowner + .then( + Commands.literal("removeowner").then( + Commands.argument("target", EntityArgument.player()).then( + Commands.argument( + "owner", + EntityArgument.player() + ).executes(CollarCommand::removeOwner) + ) + ) + ) + // /collar setcell (assigns cell) + .then( + Commands.literal("setcell").then( + Commands.argument("player", EntityArgument.player()).then( + Commands.argument( + "cellname", + StringArgumentType.word() + ).executes(CollarCommand::setCell) + ) + ) + ) + // /collar tocell (teleport to assigned cell) + .then( + Commands.literal("tocell").then( + Commands.argument( + "player", + EntityArgument.player() + ).executes(CollarCommand::teleportToCell) + ) + ) + // /collar info + .then( + Commands.literal("info").then( + Commands.argument( + "player", + EntityArgument.player() + ).executes(CollarCommand::info) + ) + ); + } + + private static ItemStack getPlayerCollar(ServerPlayer player) { + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null || !state.hasCollar()) return ItemStack.EMPTY; + return state.getEquipment(BodyRegionV2.NECK); + } + + private static int claim(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + ServerPlayer target = EntityArgument.getPlayer(context, "player"); + + ItemStack collar = getPlayerCollar(target); + if (collar.isEmpty()) { + source.sendFailure( + Component.literal( + target.getName().getString() + " does not have a collar" + ) + ); + return 0; + } + + if (source.getEntity() instanceof ServerPlayer executor) { + if (collar.getItem() instanceof ItemCollar collarItem) { + collarItem.addOwner(collar, executor); + source.sendSuccess( + () -> + Component.literal( + "§aClaimed " + + target.getName().getString() + + "'s collar" + ), + true + ); + return 1; + } + } + + source.sendFailure(Component.literal("Failed to claim collar")); + return 0; + } + + private static int unclaim(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + ServerPlayer target = EntityArgument.getPlayer(context, "player"); + + ItemStack collar = getPlayerCollar(target); + if (collar.isEmpty()) { + source.sendFailure( + Component.literal( + target.getName().getString() + " does not have a collar" + ) + ); + return 0; + } + + if (source.getEntity() instanceof ServerPlayer executor) { + if (collar.getItem() instanceof ItemCollar collarItem) { + collarItem.removeOwner(collar, executor.getUUID()); + source.sendSuccess( + () -> + Component.literal( + "§aRemoved your ownership from " + + target.getName().getString() + + "'s collar" + ), + true + ); + return 1; + } + } + + source.sendFailure(Component.literal("Failed to unclaim collar")); + return 0; + } + + private static int rename(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + ServerPlayer target = EntityArgument.getPlayer(context, "player"); + String name = StringArgumentType.getString(context, "name"); + + ItemStack collar = getPlayerCollar(target); + if (collar.isEmpty()) { + source.sendFailure( + Component.literal( + target.getName().getString() + " does not have a collar" + ) + ); + return 0; + } + + if (collar.getItem() instanceof ItemCollar collarItem) { + collarItem.setNickname(collar, name); + source.sendSuccess( + () -> + Component.literal( + "§aSet collar nickname to '" + name + "'" + ), + true + ); + return 1; + } + + return 0; + } + + private static int addOwner(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + ServerPlayer target = EntityArgument.getPlayer(context, "target"); + ServerPlayer owner = EntityArgument.getPlayer(context, "owner"); + + ItemStack collar = getPlayerCollar(target); + if (collar.isEmpty()) { + source.sendFailure( + Component.literal( + target.getName().getString() + " does not have a collar" + ) + ); + return 0; + } + + if (collar.getItem() instanceof ItemCollar collarItem) { + collarItem.addOwner(collar, owner); + source.sendSuccess( + () -> + Component.literal( + "§aAdded " + + owner.getName().getString() + + " as owner of " + + target.getName().getString() + + "'s collar" + ), + true + ); + return 1; + } + + return 0; + } + + private static int removeOwner(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + ServerPlayer target = EntityArgument.getPlayer(context, "target"); + ServerPlayer owner = EntityArgument.getPlayer(context, "owner"); + + ItemStack collar = getPlayerCollar(target); + if (collar.isEmpty()) { + source.sendFailure( + Component.literal( + target.getName().getString() + " does not have a collar" + ) + ); + return 0; + } + + if (collar.getItem() instanceof ItemCollar collarItem) { + collarItem.removeOwner(collar, owner.getUUID()); + source.sendSuccess( + () -> + Component.literal( + "§aRemoved " + + owner.getName().getString() + + " as owner of " + + target.getName().getString() + + "'s collar" + ), + true + ); + return 1; + } + + return 0; + } + + /** + * /collar setcell + * + * Assign a named cell to a player's collar. + */ + private static int setCell(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + ServerPlayer target = EntityArgument.getPlayer(context, "player"); + String cellName = StringArgumentType.getString(context, "cellname"); + + ItemStack collar = getPlayerCollar(target); + if (collar.isEmpty()) { + source.sendFailure( + Component.literal( + target.getName().getString() + " does not have a collar" + ) + ); + return 0; + } + + // Get the cell by name + ServerLevel serverLevel = source.getLevel(); + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + CellDataV2 cell = registry.getCellByName(cellName); + + if (cell == null) { + source.sendFailure( + Component.literal("Cell '" + cellName + "' not found") + ); + return 0; + } + + if (collar.getItem() instanceof ItemCollar collarItem) { + collarItem.setCellId(collar, cell.getId()); + source.sendSuccess( + () -> + Component.literal( + "§aAssigned cell '" + + cellName + + "' to " + + target.getName().getString() + + "'s collar" + ), + true + ); + return 1; + } + + return 0; + } + + /** + * /collar tocell + * + * Teleport player to their assigned cell. + */ + private static int teleportToCell( + CommandContext context + ) throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + ServerPlayer target = EntityArgument.getPlayer(context, "player"); + + ItemStack collar = getPlayerCollar(target); + if (collar.isEmpty()) { + source.sendFailure( + Component.literal( + target.getName().getString() + " does not have a collar" + ) + ); + return 0; + } + + if (collar.getItem() instanceof ItemCollar collarItem) { + if (!collarItem.hasCellAssigned(collar)) { + source.sendFailure( + Component.literal("No cell assigned to collar") + ); + return 0; + } + + // Get cell position and teleport + java.util.UUID cellId = collarItem.getCellId(collar); + ServerLevel serverLevel = source.getLevel(); + CellDataV2 cell = CellRegistryV2.get(serverLevel).getCell(cellId); + + if (cell == null) { + source.sendFailure( + Component.literal("Assigned cell no longer exists") + ); + return 0; + } + + net.minecraft.core.BlockPos teleportTarget = + cell.getSpawnPoint() != null + ? cell.getSpawnPoint() + : cell.getCorePos().above(); + Position cellPos = new Position( + teleportTarget, + serverLevel.dimension() + ); + TeleportHelper.teleportEntity(target, cellPos); + + source.sendSuccess( + () -> + Component.literal( + "§aTeleported " + + target.getName().getString() + + " to cell at " + + cell.getCorePos().toShortString() + ), + true + ); + return 1; + } + + return 0; + } + + private static int info(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + ServerPlayer target = EntityArgument.getPlayer(context, "player"); + + ItemStack collar = getPlayerCollar(target); + if (collar.isEmpty()) { + source.sendFailure( + Component.literal( + target.getName().getString() + " does not have a collar" + ) + ); + return 0; + } + + if (collar.getItem() instanceof ItemCollar collarItem) { + source.sendSuccess( + () -> + Component.literal( + "§6=== Collar Info for " + + target.getName().getString() + + " ===" + ), + false + ); + + String nickname = collarItem.getNickname(collar); + source.sendSuccess( + () -> + Component.literal( + "§7Nickname: §f" + + (nickname.isEmpty() ? "None" : nickname) + ), + false + ); + source.sendSuccess( + () -> + Component.literal( + "§7Has Owner: §f" + collarItem.hasOwner(collar) + ), + false + ); + // Cell assignment + java.util.UUID cellId = collarItem.getCellId(collar); + if (cellId != null) { + ServerLevel serverLevel = source.getLevel(); + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + CellDataV2 cell = registry.getCell(cellId); + if (cell != null) { + String cellDisplay = + cell.getName() != null + ? cell.getName() + : cellId.toString().substring(0, 8) + "..."; + source.sendSuccess( + () -> + Component.literal( + "§7Assigned Cell: §a" + + cellDisplay + + " §7@ " + + cell.getCorePos().toShortString() + ), + false + ); + } else { + source.sendSuccess( + () -> Component.literal("§7Assigned Cell: §c(deleted)"), + false + ); + } + } else { + source.sendSuccess( + () -> Component.literal("§7Assigned Cell: §fNone"), + false + ); + } + + source.sendSuccess( + () -> + Component.literal( + "§7Locked: §f" + collarItem.isLocked(collar) + ), + false + ); + + return 1; + } + + return 0; + } +} diff --git a/src/main/java/com/tiedup/remake/commands/CommandHelper.java b/src/main/java/com/tiedup/remake/commands/CommandHelper.java new file mode 100644 index 0000000..e825d14 --- /dev/null +++ b/src/main/java/com/tiedup/remake/commands/CommandHelper.java @@ -0,0 +1,91 @@ +package com.tiedup.remake.commands; + +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.sync.PacketSyncBindState; +import com.tiedup.remake.state.PlayerBindState; +import java.util.Optional; +import java.util.function.Predicate; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +/** + * Utility methods for command handling. + */ +public final class CommandHelper { + + /** + * Permission level 2 (OP) requirement for commands. + * Use with: .requires(CommandHelper.REQUIRES_OP) + */ + public static final Predicate REQUIRES_OP = source -> + source.hasPermission(2); + + private CommandHelper() {} + + /** + * Get the player from command source, or send failure message and return empty. + * + * Usage: + *
+     * var playerOpt = CommandHelper.getPlayerOrFail(source);
+     * if (playerOpt.isEmpty()) return 0;
+     * ServerPlayer player = playerOpt.get();
+     * 
+ * + * @param source The command source + * @return Optional containing the player, or empty if source is not a player + */ + public static Optional getPlayerOrFail( + CommandSourceStack source + ) { + if (source.getEntity() instanceof ServerPlayer player) { + return Optional.of(player); + } + source.sendFailure(Component.literal("Must be a player")); + return Optional.empty(); + } + + /** + * Get the player from command source without sending failure message. + * + * @param source The command source + * @return Optional containing the player, or empty if source is not a player + */ + public static Optional getPlayer(CommandSourceStack source) { + if (source.getEntity() instanceof ServerPlayer player) { + return Optional.of(player); + } + return Optional.empty(); + } + + /** + * Check if source is a player. + * + * @param source The command source + * @return true if source is a player + */ + public static boolean isPlayer(CommandSourceStack source) { + return source.getEntity() instanceof ServerPlayer; + } + + /** + * Sync player state to client after command changes. + * + * @param player The player to sync + * @param state The player's bind state + */ + public static void syncPlayerState( + ServerPlayer player, + PlayerBindState state + ) { + // Sync V2 equipment + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.sync(player); + + // Sync bind state + PacketSyncBindState statePacket = PacketSyncBindState.fromPlayer(player); + if (statePacket != null) { + ModNetwork.sendToPlayer(statePacket, player); + } + } +} diff --git a/src/main/java/com/tiedup/remake/commands/KeyCommand.java b/src/main/java/com/tiedup/remake/commands/KeyCommand.java new file mode 100644 index 0000000..2100481 --- /dev/null +++ b/src/main/java/com/tiedup/remake/commands/KeyCommand.java @@ -0,0 +1,294 @@ +package com.tiedup.remake.commands; + +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.tiedup.remake.items.ModItems; +import java.util.Optional; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; + +/** + * Key management commands for Phase 18. + * + * Commands: + * /key claim - Claim the key you're holding + * /key unclaim - Remove ownership from your key + * /key assign - Assign your key to a player + * /key public - Make key publicly usable + * /key info - Show key info + * + * Must hold a collar key in main hand. + */ +public class KeyCommand { + + private static final String TAG_OWNER = "Owner"; + private static final String TAG_OWNER_NAME = "OwnerName"; + private static final String TAG_TARGET = "Target"; + private static final String TAG_TARGET_NAME = "TargetName"; + private static final String TAG_PUBLIC = "Public"; + + public static void register( + CommandDispatcher dispatcher + ) { + dispatcher.register(createKeyCommand()); + } + + /** + * Create the key command builder (for use as subcommand of /tiedup). + * @return The command builder + */ + public static com.mojang.brigadier.builder.LiteralArgumentBuilder< + CommandSourceStack + > createKeyCommand() { + return Commands.literal("key") + // /key claim + .then(Commands.literal("claim").executes(KeyCommand::claim)) + // /key unclaim + .then(Commands.literal("unclaim").executes(KeyCommand::unclaim)) + // /key assign + .then( + Commands.literal("assign").then( + Commands.argument( + "player", + EntityArgument.player() + ).executes(KeyCommand::assign) + ) + ) + // /key public + .then(Commands.literal("public").executes(KeyCommand::togglePublic)) + // /key info + .then(Commands.literal("info").executes(KeyCommand::info)); + } + + private static ItemStack getHeldKey(ServerPlayer player) { + ItemStack held = player.getMainHandItem(); + if ( + held.is(ModItems.COLLAR_KEY.get()) || + held.is(ModItems.MASTER_KEY.get()) + ) { + return held; + } + return ItemStack.EMPTY; + } + + private static int claim(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + + Optional playerOpt = CommandHelper.getPlayerOrFail( + source + ); + if (playerOpt.isEmpty()) return 0; + ServerPlayer player = playerOpt.get(); + + ItemStack key = getHeldKey(player); + if (key.isEmpty()) { + source.sendFailure(Component.literal("You must hold a collar key")); + return 0; + } + + CompoundTag tag = key.getOrCreateTag(); + + // Check if already claimed by someone else + if ( + tag.hasUUID(TAG_OWNER) && + !tag.getUUID(TAG_OWNER).equals(player.getUUID()) + ) { + source.sendFailure( + Component.literal("This key is already claimed by someone else") + ); + return 0; + } + + tag.putUUID(TAG_OWNER, player.getUUID()); + tag.putString(TAG_OWNER_NAME, player.getName().getString()); + + source.sendSuccess( + () -> Component.literal("§aYou have claimed this key"), + false + ); + + return 1; + } + + private static int unclaim(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + + Optional playerOpt = CommandHelper.getPlayerOrFail( + source + ); + if (playerOpt.isEmpty()) return 0; + ServerPlayer player = playerOpt.get(); + + ItemStack key = getHeldKey(player); + if (key.isEmpty()) { + source.sendFailure(Component.literal("You must hold a collar key")); + return 0; + } + + CompoundTag tag = key.getOrCreateTag(); + + if (!tag.hasUUID(TAG_OWNER)) { + source.sendFailure(Component.literal("This key is not claimed")); + return 0; + } + + if (!tag.getUUID(TAG_OWNER).equals(player.getUUID())) { + source.sendFailure(Component.literal("You do not own this key")); + return 0; + } + + tag.remove(TAG_OWNER); + tag.remove(TAG_OWNER_NAME); + + source.sendSuccess( + () -> Component.literal("§aYou have unclaimed this key"), + false + ); + + return 1; + } + + private static int assign(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + + Optional playerOpt = CommandHelper.getPlayerOrFail( + source + ); + if (playerOpt.isEmpty()) return 0; + ServerPlayer player = playerOpt.get(); + + ServerPlayer target = EntityArgument.getPlayer(context, "player"); + + ItemStack key = getHeldKey(player); + if (key.isEmpty()) { + source.sendFailure(Component.literal("You must hold a collar key")); + return 0; + } + + CompoundTag tag = key.getOrCreateTag(); + + // Must be owner to assign + if ( + tag.hasUUID(TAG_OWNER) && + !tag.getUUID(TAG_OWNER).equals(player.getUUID()) + ) { + source.sendFailure(Component.literal("You do not own this key")); + return 0; + } + + tag.putUUID(TAG_TARGET, target.getUUID()); + tag.putString(TAG_TARGET_NAME, target.getName().getString()); + + source.sendSuccess( + () -> + Component.literal( + "§aAssigned key to " + target.getName().getString() + ), + false + ); + + return 1; + } + + private static int togglePublic(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + + Optional playerOpt = CommandHelper.getPlayerOrFail( + source + ); + if (playerOpt.isEmpty()) return 0; + ServerPlayer player = playerOpt.get(); + + ItemStack key = getHeldKey(player); + if (key.isEmpty()) { + source.sendFailure(Component.literal("You must hold a collar key")); + return 0; + } + + CompoundTag tag = key.getOrCreateTag(); + + // Must be owner to toggle + if ( + tag.hasUUID(TAG_OWNER) && + !tag.getUUID(TAG_OWNER).equals(player.getUUID()) + ) { + source.sendFailure(Component.literal("You do not own this key")); + return 0; + } + + boolean isPublic = !tag.getBoolean(TAG_PUBLIC); + tag.putBoolean(TAG_PUBLIC, isPublic); + + source.sendSuccess( + () -> + Component.literal( + "§aKey is now " + (isPublic ? "public" : "private") + ), + false + ); + + return 1; + } + + private static int info(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + + Optional playerOpt = CommandHelper.getPlayerOrFail( + source + ); + if (playerOpt.isEmpty()) return 0; + ServerPlayer player = playerOpt.get(); + + ItemStack key = getHeldKey(player); + if (key.isEmpty()) { + source.sendFailure(Component.literal("You must hold a collar key")); + return 0; + } + + CompoundTag tag = key.getOrCreateTag(); + + source.sendSuccess( + () -> Component.literal("§6=== Key Info ==="), + false + ); + + String ownerName = tag.getString(TAG_OWNER_NAME); + source.sendSuccess( + () -> + Component.literal( + "§7Owner: §f" + + (ownerName.isEmpty() ? "Not claimed" : ownerName) + ), + false + ); + + String targetName = tag.getString(TAG_TARGET_NAME); + source.sendSuccess( + () -> + Component.literal( + "§7Assigned to: §f" + + (targetName.isEmpty() ? "Not assigned" : targetName) + ), + false + ); + + boolean isPublic = tag.getBoolean(TAG_PUBLIC); + source.sendSuccess( + () -> Component.literal("§7Public: §f" + (isPublic ? "Yes" : "No")), + false + ); + + return 1; + } +} diff --git a/src/main/java/com/tiedup/remake/commands/KidnapSetCommand.java b/src/main/java/com/tiedup/remake/commands/KidnapSetCommand.java new file mode 100644 index 0000000..f49a8b8 --- /dev/null +++ b/src/main/java/com/tiedup/remake/commands/KidnapSetCommand.java @@ -0,0 +1,234 @@ +package com.tiedup.remake.commands; + +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.BindVariant; +import com.tiedup.remake.items.base.BlindfoldVariant; +import com.tiedup.remake.items.base.EarplugsVariant; +import com.tiedup.remake.items.base.GagVariant; +import com.tiedup.remake.items.base.KnifeVariant; +import java.util.Optional; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; + +/** + * Utility commands for Phase 18. + * + * Commands: + * /kidnapset - Get a starter kit of mod items + * /kidnapreload [type] - Reload data files (jobs, sales, gagtalk) + */ +public class KidnapSetCommand { + + public static void register( + CommandDispatcher dispatcher + ) { + dispatcher.register(createKidnapSetCommand()); + dispatcher.register(createKidnapReloadCommand()); + } + + /** + * Create the kidnapset command builder (for use as subcommand of /tiedup). + * @return The command builder + */ + public static com.mojang.brigadier.builder.LiteralArgumentBuilder< + CommandSourceStack + > createKidnapSetCommand() { + return Commands.literal("kidnapset") + .requires(CommandHelper.REQUIRES_OP) + .executes(KidnapSetCommand::giveSet); + } + + /** + * Create the kidnapreload command builder (for use as subcommand of /tiedup). + * @return The command builder + */ + public static com.mojang.brigadier.builder.LiteralArgumentBuilder< + CommandSourceStack + > createKidnapReloadCommand() { + return Commands.literal("kidnapreload") + .requires(CommandHelper.REQUIRES_OP) + .executes(ctx -> reload(ctx, "all")) + .then(Commands.literal("jobs").executes(ctx -> reload(ctx, "jobs"))) + .then( + Commands.literal("sales").executes(ctx -> reload(ctx, "sales")) + ) + .then( + Commands.literal("gagtalk").executes(ctx -> + reload(ctx, "gagtalk") + ) + ) + .then(Commands.literal("all").executes(ctx -> reload(ctx, "all"))); + } + + /** + * Give a starter kit of mod items to the player. + */ + private static int giveSet(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + + Optional playerOpt = CommandHelper.getPlayerOrFail( + source + ); + if (playerOpt.isEmpty()) return 0; + ServerPlayer player = playerOpt.get(); + + int given = 0; + + // Binds + given += giveItem( + player, + new ItemStack(ModItems.getBind(BindVariant.ROPES), 8) + ); + given += giveItem( + player, + new ItemStack(ModItems.getBind(BindVariant.CHAIN), 4) + ); + given += giveItem( + player, + new ItemStack(ModItems.getBind(BindVariant.LEATHER_STRAPS), 4) + ); + + // Gags + given += giveItem( + player, + new ItemStack(ModItems.getGag(GagVariant.CLOTH_GAG), 4) + ); + given += giveItem( + player, + new ItemStack(ModItems.getGag(GagVariant.BALL_GAG), 4) + ); + given += giveItem( + player, + new ItemStack(ModItems.getGag(GagVariant.TAPE_GAG), 4) + ); + + // Blindfolds + given += giveItem( + player, + new ItemStack(ModItems.getBlindfold(BlindfoldVariant.CLASSIC), 4) + ); + given += giveItem( + player, + new ItemStack(ModItems.getBlindfold(BlindfoldVariant.MASK), 2) + ); + + // Collars + given += giveItem( + player, + new ItemStack(ModItems.CLASSIC_COLLAR.get(), 4) + ); + given += giveItem( + player, + new ItemStack(ModItems.SHOCK_COLLAR.get(), 2) + ); + given += giveItem(player, new ItemStack(ModItems.GPS_COLLAR.get(), 2)); + + // Tools + given += giveItem( + player, + new ItemStack(ModItems.getKnife(KnifeVariant.IRON), 2) + ); + given += giveItem( + player, + new ItemStack(ModItems.getKnife(KnifeVariant.GOLDEN), 1) + ); + given += giveItem(player, new ItemStack(ModItems.WHIP.get(), 1)); + given += giveItem(player, new ItemStack(ModItems.PADDLE.get(), 1)); + + // Controllers + given += giveItem( + player, + new ItemStack(ModItems.SHOCKER_CONTROLLER.get(), 1) + ); + given += giveItem(player, new ItemStack(ModItems.GPS_LOCATOR.get(), 1)); + + // Keys and locks + given += giveItem(player, new ItemStack(ModItems.PADLOCK.get(), 4)); + given += giveItem(player, new ItemStack(ModItems.COLLAR_KEY.get(), 2)); + given += giveItem(player, new ItemStack(ModItems.MASTER_KEY.get(), 1)); + + // Earplugs + given += giveItem( + player, + new ItemStack(ModItems.getEarplugs(EarplugsVariant.CLASSIC), 4) + ); + + // Rope arrows + given += giveItem(player, new ItemStack(ModItems.ROPE_ARROW.get(), 16)); + + // Chloroform + given += giveItem( + player, + new ItemStack(ModItems.CHLOROFORM_BOTTLE.get(), 2) + ); + given += giveItem(player, new ItemStack(ModItems.RAG.get(), 4)); + + int finalGiven = given; + source.sendSuccess( + () -> + Component.literal( + "§aGave kidnap set (" + finalGiven + " item stacks)" + ), + true + ); + + return finalGiven; + } + + private static int giveItem(ServerPlayer player, ItemStack stack) { + if (!player.getInventory().add(stack)) { + // Drop on ground if inventory full + player.drop(stack, false); + } + return 1; + } + + /** + * Reload data files. + */ + private static int reload( + CommandContext context, + String type + ) { + CommandSourceStack source = context.getSource(); + int reloaded = 0; + + if (type.equals("all") || type.equals("jobs")) { + com.tiedup.remake.util.tasks.JobLoader.init(); + reloaded++; + } + + if (type.equals("all") || type.equals("sales")) { + com.tiedup.remake.util.tasks.SaleLoader.init(); + reloaded++; + } + + if (type.equals("all") || type.equals("gagtalk")) { + // GagTalkManager is code-based (no external data files) + // No reload needed - materials/logic defined in GagMaterial enum + reloaded++; + } + + int finalReloaded = reloaded; + source.sendSuccess( + () -> + Component.literal( + "§aReloaded " + + (type.equals("all") ? "all data files" : type) + + " (" + + finalReloaded + + " files)" + ), + true + ); + + return reloaded; + } +} diff --git a/src/main/java/com/tiedup/remake/commands/NPCCommand.java b/src/main/java/com/tiedup/remake/commands/NPCCommand.java new file mode 100644 index 0000000..0ef417a --- /dev/null +++ b/src/main/java/com/tiedup/remake/commands/NPCCommand.java @@ -0,0 +1,754 @@ +package com.tiedup.remake.commands; + +import com.mojang.brigadier.CommandDispatcher; +import com.tiedup.remake.v2.BodyRegionV2; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.tiedup.remake.entities.*; +import com.tiedup.remake.entities.skins.EliteKidnapperSkinManager; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.BindVariant; +import com.tiedup.remake.items.base.BlindfoldVariant; +import com.tiedup.remake.items.base.EarplugsVariant; +import com.tiedup.remake.items.base.GagVariant; +import com.tiedup.remake.state.IBondageState; +import java.util.List; +import java.util.Optional; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.MobSpawnType; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.AABB; + +/** + * NPC management commands for Phase 18. + * + * Commands: + * /npc spawn kidnapper [player] - Spawn a kidnapper at location + * /npc spawn elite [player] - Spawn an elite kidnapper + * /npc spawn archer [player] - Spawn an archer kidnapper + * /npc spawn damsel [player] - Spawn a damsel NPC + * /npc kill [radius] - Kill all mod NPCs in radius + * /npc tie - Tie the nearest NPC + * /npc gag - Gag the nearest NPC + * /npc blindfold - Blindfold the nearest NPC + * /npc collar - Collar the nearest NPC + * /npc untie - Untie the nearest NPC (remove all) + * /npc state - Show state of nearest NPC + * /npc full - Apply full bondage to nearest NPC + * + * All commands require OP level 2. + */ +public class NPCCommand { + + public static void register( + CommandDispatcher dispatcher + ) { + dispatcher.register(createNPCCommand()); + } + + /** + * Create the NPC command builder (for use as subcommand of /tiedup). + * @return The command builder + */ + public static com.mojang.brigadier.builder.LiteralArgumentBuilder< + CommandSourceStack + > createNPCCommand() { + return Commands.literal("npc") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.literal("spawn") + // /npc spawn kidnapper [player] + .then( + Commands.literal("kidnapper") + .executes(ctx -> spawnKidnapper(ctx, null)) + .then( + Commands.argument( + "player", + EntityArgument.player() + ).executes(ctx -> + spawnKidnapper( + ctx, + EntityArgument.getPlayer(ctx, "player") + ) + ) + ) + ) + // /npc spawn elite [player] + .then( + Commands.literal("elite").then( + Commands.argument("name", StringArgumentType.word()) + .suggests((ctx, builder) -> { + for (KidnapperVariant v : EliteKidnapperSkinManager.CORE.getAllVariants()) { + builder.suggest(v.id()); + } + return builder.buildFuture(); + }) + .executes(ctx -> spawnElite(ctx, null)) + .then( + Commands.argument( + "player", + EntityArgument.player() + ).executes(ctx -> + spawnElite( + ctx, + EntityArgument.getPlayer( + ctx, + "player" + ) + ) + ) + ) + ) + ) + // /npc spawn archer [player] + .then( + Commands.literal("archer") + .executes(ctx -> spawnArcher(ctx, null)) + .then( + Commands.argument( + "player", + EntityArgument.player() + ).executes(ctx -> + spawnArcher( + ctx, + EntityArgument.getPlayer(ctx, "player") + ) + ) + ) + ) + // /npc spawn damsel [player] + .then( + Commands.literal("damsel") + .executes(ctx -> spawnDamsel(ctx, null)) + .then( + Commands.argument( + "player", + EntityArgument.player() + ).executes(ctx -> + spawnDamsel( + ctx, + EntityArgument.getPlayer(ctx, "player") + ) + ) + ) + ) + ) + // /npc kill - Kill all mod NPCs in radius + .then( + Commands.literal("kill") + .executes(ctx -> killNPCs(ctx, 10)) + .then( + Commands.argument( + "radius", + com.mojang.brigadier.arguments.IntegerArgumentType.integer( + 1, + 100 + ) + ).executes(ctx -> + killNPCs( + ctx, + com.mojang.brigadier.arguments.IntegerArgumentType.getInteger( + ctx, + "radius" + ) + ) + ) + ) + ) + // /npc tie - Tie nearest NPC + .then(Commands.literal("tie").executes(NPCCommand::tieNPC)) + // /npc gag - Gag nearest NPC + .then(Commands.literal("gag").executes(NPCCommand::gagNPC)) + // /npc blindfold - Blindfold nearest NPC + .then( + Commands.literal("blindfold").executes(NPCCommand::blindfoldNPC) + ) + // /npc collar - Collar nearest NPC + .then(Commands.literal("collar").executes(NPCCommand::collarNPC)) + // /npc untie - Untie nearest NPC + .then(Commands.literal("untie").executes(NPCCommand::untieNPC)) + // /npc state - Show NPC state + .then(Commands.literal("state").executes(NPCCommand::showNPCState)) + // /npc full - Full bondage on nearest NPC + .then( + Commands.literal("full").executes(NPCCommand::fullBondageNPC) + ); + } + + private static int spawnKidnapper( + CommandContext context, + ServerPlayer targetPlayer + ) throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + ServerLevel level = source.getLevel(); + + // Get spawn location + double x, y, z; + if (targetPlayer != null) { + x = targetPlayer.getX(); + y = targetPlayer.getY(); + z = targetPlayer.getZ(); + } else if (source.getEntity() instanceof ServerPlayer player) { + x = player.getX(); + y = player.getY(); + z = player.getZ(); + } else { + source.sendFailure( + Component.literal("Must specify a player or be a player") + ); + return 0; + } + + // Spawn the kidnapper + EntityKidnapper kidnapper = ModEntities.KIDNAPPER.get().create(level); + if (kidnapper != null) { + kidnapper.moveTo(x, y, z, level.random.nextFloat() * 360F, 0.0F); + kidnapper.finalizeSpawn( + level, + level.getCurrentDifficultyAt(kidnapper.blockPosition()), + MobSpawnType.COMMAND, + null, + null + ); + level.addFreshEntity(kidnapper); + + source.sendSuccess( + () -> + Component.literal( + "§aSpawned Kidnapper at " + formatPos(x, y, z) + ), + true + ); + return 1; + } + + source.sendFailure(Component.literal("Failed to spawn Kidnapper")); + return 0; + } + + private static int spawnElite( + CommandContext context, + ServerPlayer targetPlayer + ) throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + ServerLevel level = source.getLevel(); + String name = StringArgumentType.getString(context, "name"); + + // Parse variant + KidnapperVariant variant = EliteKidnapperSkinManager.CORE.getVariant( + name + ); + if (variant == null) { + source.sendFailure( + Component.literal( + "Unknown elite variant: " + + name + + ". Available: suki, carol, athena, evelyn" + ) + ); + return 0; + } + + // Get spawn location + double x, y, z; + if (targetPlayer != null) { + x = targetPlayer.getX(); + y = targetPlayer.getY(); + z = targetPlayer.getZ(); + } else if (source.getEntity() instanceof ServerPlayer player) { + x = player.getX(); + y = player.getY(); + z = player.getZ(); + } else { + source.sendFailure( + Component.literal("Must specify a player or be a player") + ); + return 0; + } + + // Spawn the elite kidnapper + EntityKidnapperElite elite = ModEntities.KIDNAPPER_ELITE.get().create( + level + ); + if (elite != null) { + elite.moveTo(x, y, z, level.random.nextFloat() * 360F, 0.0F); + elite.setKidnapperVariant(variant); + elite.finalizeSpawn( + level, + level.getCurrentDifficultyAt(elite.blockPosition()), + MobSpawnType.COMMAND, + null, + null + ); + level.addFreshEntity(elite); + + source.sendSuccess( + () -> + Component.literal( + "§aSpawned Elite Kidnapper '" + + variant.defaultName() + + "' at " + + formatPos(x, y, z) + ), + true + ); + return 1; + } + + source.sendFailure( + Component.literal("Failed to spawn Elite Kidnapper") + ); + return 0; + } + + private static int spawnArcher( + CommandContext context, + ServerPlayer targetPlayer + ) throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + ServerLevel level = source.getLevel(); + + // Get spawn location + double x, y, z; + if (targetPlayer != null) { + x = targetPlayer.getX(); + y = targetPlayer.getY(); + z = targetPlayer.getZ(); + } else if (source.getEntity() instanceof ServerPlayer player) { + x = player.getX(); + y = player.getY(); + z = player.getZ(); + } else { + source.sendFailure( + Component.literal("Must specify a player or be a player") + ); + return 0; + } + + // Spawn the archer + EntityKidnapperArcher archer = + ModEntities.KIDNAPPER_ARCHER.get().create(level); + if (archer != null) { + archer.moveTo(x, y, z, level.random.nextFloat() * 360F, 0.0F); + archer.finalizeSpawn( + level, + level.getCurrentDifficultyAt(archer.blockPosition()), + MobSpawnType.COMMAND, + null, + null + ); + level.addFreshEntity(archer); + + source.sendSuccess( + () -> + Component.literal( + "§aSpawned Archer Kidnapper at " + formatPos(x, y, z) + ), + true + ); + return 1; + } + + source.sendFailure( + Component.literal("Failed to spawn Archer Kidnapper") + ); + return 0; + } + + private static int spawnDamsel( + CommandContext context, + ServerPlayer targetPlayer + ) throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + ServerLevel level = source.getLevel(); + + // Get spawn location + double x, y, z; + if (targetPlayer != null) { + x = targetPlayer.getX(); + y = targetPlayer.getY(); + z = targetPlayer.getZ(); + } else if (source.getEntity() instanceof ServerPlayer player) { + x = player.getX(); + y = player.getY(); + z = player.getZ(); + } else { + source.sendFailure( + Component.literal("Must specify a player or be a player") + ); + return 0; + } + + // Spawn the damsel + EntityDamsel damsel = ModEntities.DAMSEL.get().create(level); + if (damsel != null) { + damsel.moveTo(x, y, z, level.random.nextFloat() * 360F, 0.0F); + damsel.finalizeSpawn( + level, + level.getCurrentDifficultyAt(damsel.blockPosition()), + MobSpawnType.COMMAND, + null, + null + ); + level.addFreshEntity(damsel); + + source.sendSuccess( + () -> + Component.literal( + "§aSpawned Damsel at " + formatPos(x, y, z) + ), + true + ); + return 1; + } + + source.sendFailure(Component.literal("Failed to spawn Damsel")); + return 0; + } + + private static int killNPCs( + CommandContext context, + int radius + ) throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + + Optional playerOpt = CommandHelper.getPlayerOrFail( + source + ); + if (playerOpt.isEmpty()) return 0; + ServerPlayer player = playerOpt.get(); + + ServerLevel level = player.serverLevel(); + int killed = 0; + + // Find and kill all mod NPCs in radius + var entities = level.getEntitiesOfClass( + net.minecraft.world.entity.LivingEntity.class, + player.getBoundingBox().inflate(radius), + e -> + e instanceof com.tiedup.remake.entities.AbstractTiedUpNpc + ); + + for (var entity : entities) { + entity.discard(); + killed++; + } + + int finalKilled = killed; + source.sendSuccess( + () -> + Component.literal( + "§aKilled " + finalKilled + " mod NPCs in radius " + radius + ), + true + ); + + return killed; + } + + private static String formatPos(double x, double y, double z) { + return String.format("(%.1f, %.1f, %.1f)", x, y, z); + } + + // ======================================== + // NPC Bondage Commands (from DamselTestCommand) + // ======================================== + + /** + * Find the nearest mod NPC (Damsel or Kidnapper) within 10 blocks. + */ + private static IBondageState findNearestNPC( + CommandContext context + ) { + Entity source = context.getSource().getEntity(); + if (source == null) return null; + + AABB searchBox = source.getBoundingBox().inflate(10.0); + + // Search for any IBondageState entity (EntityDamsel implements this) + List npcs = source + .level() + .getEntitiesOfClass(EntityDamsel.class, searchBox); + + if (npcs.isEmpty()) { + return null; + } + + // Return closest NPC + return npcs + .stream() + .min((a, b) -> + Double.compare(a.distanceToSqr(source), b.distanceToSqr(source)) + ) + .orElse(null); + } + + private static int tieNPC(CommandContext context) { + IBondageState npc = findNearestNPC(context); + if (npc == null) { + context + .getSource() + .sendFailure( + Component.literal("No mod NPC found within 10 blocks") + ); + return 0; + } + + if (npc.isTiedUp()) { + context + .getSource() + .sendFailure(Component.literal("NPC is already tied up")); + return 0; + } + + npc.equip(BodyRegionV2.ARMS, new ItemStack(ModItems.getBind(BindVariant.ROPES))); + context + .getSource() + .sendSuccess( + () -> Component.literal("§aTied up " + npc.getKidnappedName()), + true + ); + return 1; + } + + private static int gagNPC(CommandContext context) { + IBondageState npc = findNearestNPC(context); + if (npc == null) { + context + .getSource() + .sendFailure( + Component.literal("No mod NPC found within 10 blocks") + ); + return 0; + } + + if (npc.isGagged()) { + context + .getSource() + .sendFailure(Component.literal("NPC is already gagged")); + return 0; + } + + npc.equip(BodyRegionV2.MOUTH, new ItemStack(ModItems.getGag(GagVariant.CLOTH_GAG))); + context + .getSource() + .sendSuccess( + () -> Component.literal("§aGagged " + npc.getKidnappedName()), + true + ); + return 1; + } + + private static int blindfoldNPC( + CommandContext context + ) { + IBondageState npc = findNearestNPC(context); + if (npc == null) { + context + .getSource() + .sendFailure( + Component.literal("No mod NPC found within 10 blocks") + ); + return 0; + } + + if (npc.isBlindfolded()) { + context + .getSource() + .sendFailure(Component.literal("NPC is already blindfolded")); + return 0; + } + + npc.equip(BodyRegionV2.EYES, + new ItemStack(ModItems.getBlindfold(BlindfoldVariant.CLASSIC)) + ); + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "§aBlindfolded " + npc.getKidnappedName() + ), + true + ); + return 1; + } + + private static int collarNPC(CommandContext context) { + IBondageState npc = findNearestNPC(context); + if (npc == null) { + context + .getSource() + .sendFailure( + Component.literal("No mod NPC found within 10 blocks") + ); + return 0; + } + + if (npc.hasCollar()) { + context + .getSource() + .sendFailure(Component.literal("NPC already has a collar")); + return 0; + } + + npc.equip(BodyRegionV2.NECK, new ItemStack(ModItems.CLASSIC_COLLAR.get())); + context + .getSource() + .sendSuccess( + () -> Component.literal("§aCollared " + npc.getKidnappedName()), + true + ); + return 1; + } + + private static int untieNPC(CommandContext context) { + IBondageState npc = findNearestNPC(context); + if (npc == null) { + context + .getSource() + .sendFailure( + Component.literal("No mod NPC found within 10 blocks") + ); + return 0; + } + + npc.untie(true); // Drop items + context + .getSource() + .sendSuccess( + () -> Component.literal("§aUntied " + npc.getKidnappedName()), + true + ); + return 1; + } + + private static int fullBondageNPC( + CommandContext context + ) { + IBondageState npc = findNearestNPC(context); + if (npc == null) { + context + .getSource() + .sendFailure( + Component.literal("No mod NPC found within 10 blocks") + ); + return 0; + } + + // Apply full bondage using AbstractTiedUpNpc method + if (npc instanceof com.tiedup.remake.entities.AbstractTiedUpNpc npcEntity) { + npcEntity.applyBondage( + new ItemStack(ModItems.getBind(BindVariant.ROPES)), + new ItemStack(ModItems.getGag(GagVariant.CLOTH_GAG)), + new ItemStack(ModItems.getBlindfold(BlindfoldVariant.CLASSIC)), + new ItemStack(ModItems.getEarplugs(EarplugsVariant.CLASSIC)), + new ItemStack(ModItems.CLASSIC_COLLAR.get()), + ItemStack.EMPTY // No clothes + ); + } + + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "§aFully restrained " + npc.getKidnappedName() + ), + true + ); + return 1; + } + + private static int showNPCState( + CommandContext context + ) { + IBondageState npc = findNearestNPC(context); + if (npc == null) { + context + .getSource() + .sendFailure( + Component.literal("No mod NPC found within 10 blocks") + ); + return 0; + } + + CommandSourceStack source = context.getSource(); + + source.sendSuccess( + () -> + Component.literal( + "§6=== NPC State: " + npc.getKidnappedName() + " ===" + ), + false + ); + + if (npc instanceof EntityDamsel damsel) { + source.sendSuccess( + () -> + Component.literal("§eVariant: §f" + damsel.getVariantId()), + false + ); + source.sendSuccess( + () -> + Component.literal( + "§eSlim Arms: " + + (damsel.hasSlimArms() ? "§aYes" : "§7No") + ), + false + ); + } + + source.sendSuccess( + () -> + Component.literal( + "§eTied Up: " + (npc.isTiedUp() ? "§aYes" : "§7No") + ), + false + ); + source.sendSuccess( + () -> + Component.literal( + "§eGagged: " + (npc.isGagged() ? "§aYes" : "§7No") + ), + false + ); + source.sendSuccess( + () -> + Component.literal( + "§eBlindfolded: " + (npc.isBlindfolded() ? "§aYes" : "§7No") + ), + false + ); + source.sendSuccess( + () -> + Component.literal( + "§eHas Collar: " + (npc.hasCollar() ? "§aYes" : "§7No") + ), + false + ); + source.sendSuccess( + () -> + Component.literal( + "§eHas Earplugs: " + (npc.hasEarplugs() ? "§aYes" : "§7No") + ), + false + ); + source.sendSuccess( + () -> + Component.literal( + "§eIs Captive: " + (npc.isCaptive() ? "§aYes" : "§7No") + ), + false + ); + + return 1; + } +} diff --git a/src/main/java/com/tiedup/remake/commands/SocialCommand.java b/src/main/java/com/tiedup/remake/commands/SocialCommand.java new file mode 100644 index 0000000..cbb072c --- /dev/null +++ b/src/main/java/com/tiedup/remake/commands/SocialCommand.java @@ -0,0 +1,511 @@ +package com.tiedup.remake.commands; + +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.SocialData; +import com.tiedup.remake.util.MessageDispatcher; +import java.util.*; +import java.util.Optional; +import net.minecraft.ChatFormatting; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.phys.AABB; + +/** + * Social and RP commands for Phase 18. + * + * Commands: + * /blockplayer - Block a player from interacting with you + * /unblockplayer - Unblock a player + * /checkblocked - Check if a player has blocked you + * /norp - Announce non-consent to current RP (cooldown 45s) + * /me - Roleplay action message in local area + * /pm - Private message to a player + * /talkarea [distance] - Set local chat area distance + * + * These commands are usable while tied up. + * + * Data persistence: Block lists and talk area settings are stored in SocialData + * (SavedData) and persist across server restarts. + */ +public class SocialCommand { + + // Cooldowns for /norp (UUID -> last use timestamp) + // Note: Cooldowns are intentionally NOT persisted - they reset on server restart + private static final Map NORP_COOLDOWNS = new HashMap<>(); + private static final long NORP_COOLDOWN_MS = 45000; // 45 seconds + + /** Remove player cooldown on disconnect to prevent memory leak. */ + public static void cleanupPlayer(UUID playerId) { + NORP_COOLDOWNS.remove(playerId); + } + + public static void register( + CommandDispatcher dispatcher + ) { + dispatcher.register(createBlockPlayerCommand()); + dispatcher.register(createUnblockPlayerCommand()); + dispatcher.register(createCheckBlockedCommand()); + dispatcher.register(createNoRPCommand()); + dispatcher.register(createMeCommand()); + dispatcher.register(createPMCommand()); + dispatcher.register(createTalkAreaCommand()); + dispatcher.register(createTalkInfoCommand()); + } + + // === Command Builders (for use as subcommands of /tiedup) === + + public static com.mojang.brigadier.builder.LiteralArgumentBuilder< + CommandSourceStack + > createBlockPlayerCommand() { + return Commands.literal("blockplayer").then( + Commands.argument("player", EntityArgument.player()).executes( + SocialCommand::blockPlayer + ) + ); + } + + public static com.mojang.brigadier.builder.LiteralArgumentBuilder< + CommandSourceStack + > createUnblockPlayerCommand() { + return Commands.literal("unblockplayer").then( + Commands.argument("player", EntityArgument.player()).executes( + SocialCommand::unblockPlayer + ) + ); + } + + public static com.mojang.brigadier.builder.LiteralArgumentBuilder< + CommandSourceStack + > createCheckBlockedCommand() { + return Commands.literal("checkblocked").then( + Commands.argument("player", EntityArgument.player()).executes( + SocialCommand::checkBlocked + ) + ); + } + + public static com.mojang.brigadier.builder.LiteralArgumentBuilder< + CommandSourceStack + > createNoRPCommand() { + return Commands.literal("norp").executes(SocialCommand::noRP); + } + + public static com.mojang.brigadier.builder.LiteralArgumentBuilder< + CommandSourceStack + > createMeCommand() { + return Commands.literal("me").then( + Commands.argument( + "action", + StringArgumentType.greedyString() + ).executes(SocialCommand::meAction) + ); + } + + public static com.mojang.brigadier.builder.LiteralArgumentBuilder< + CommandSourceStack + > createPMCommand() { + return Commands.literal("pm").then( + Commands.argument("player", EntityArgument.player()).then( + Commands.argument( + "message", + StringArgumentType.greedyString() + ).executes(SocialCommand::privateMessage) + ) + ); + } + + public static com.mojang.brigadier.builder.LiteralArgumentBuilder< + CommandSourceStack + > createTalkAreaCommand() { + return Commands.literal("talkarea") + .executes(ctx -> setTalkArea(ctx, 0)) // Disable + .then( + Commands.argument( + "distance", + IntegerArgumentType.integer(1, 100) + ).executes(ctx -> + setTalkArea( + ctx, + IntegerArgumentType.getInteger(ctx, "distance") + ) + ) + ); + } + + public static com.mojang.brigadier.builder.LiteralArgumentBuilder< + CommandSourceStack + > createTalkInfoCommand() { + return Commands.literal("talkinfo").executes(SocialCommand::talkInfo); + } + + // ======================================== + // Block System + // ======================================== + + private static int blockPlayer(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + + Optional playerOpt = CommandHelper.getPlayerOrFail( + source + ); + if (playerOpt.isEmpty()) return 0; + ServerPlayer player = playerOpt.get(); + + ServerPlayer target = EntityArgument.getPlayer(context, "player"); + + if (player.getUUID().equals(target.getUUID())) { + source.sendFailure(Component.literal("You cannot block yourself")); + return 0; + } + + SocialData data = SocialData.get(player.serverLevel()); + + if (data.isBlocked(player.getUUID(), target.getUUID())) { + source.sendFailure( + Component.literal( + target.getName().getString() + " is already blocked" + ) + ); + return 0; + } + + data.addBlock(player.getUUID(), target.getUUID()); + source.sendSuccess( + () -> + Component.literal("§aBlocked " + target.getName().getString()), + false + ); + + TiedUpMod.LOGGER.info( + "[SOCIAL] {} blocked {}", + player.getName().getString(), + target.getName().getString() + ); + return 1; + } + + private static int unblockPlayer(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + + Optional playerOpt = CommandHelper.getPlayerOrFail( + source + ); + if (playerOpt.isEmpty()) return 0; + ServerPlayer player = playerOpt.get(); + + ServerPlayer target = EntityArgument.getPlayer(context, "player"); + + SocialData data = SocialData.get(player.serverLevel()); + + if (!data.isBlocked(player.getUUID(), target.getUUID())) { + source.sendFailure( + Component.literal( + target.getName().getString() + " is not blocked" + ) + ); + return 0; + } + + data.removeBlock(player.getUUID(), target.getUUID()); + source.sendSuccess( + () -> + Component.literal( + "§aUnblocked " + target.getName().getString() + ), + false + ); + + return 1; + } + + private static int checkBlocked(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + + Optional playerOpt = CommandHelper.getPlayerOrFail( + source + ); + if (playerOpt.isEmpty()) return 0; + ServerPlayer player = playerOpt.get(); + + ServerPlayer target = EntityArgument.getPlayer(context, "player"); + + SocialData data = SocialData.get(player.serverLevel()); + boolean blocked = data.isBlocked(target.getUUID(), player.getUUID()); + + if (blocked) { + source.sendSuccess( + () -> + Component.literal( + "§c" + target.getName().getString() + " has blocked you" + ), + false + ); + } else { + source.sendSuccess( + () -> + Component.literal( + "§a" + + target.getName().getString() + + " has not blocked you" + ), + false + ); + } + + return 1; + } + + /** + * Check if a player is blocked by another. + * Can be used by other systems to check interaction permissions. + * + * @param level The server level to get SocialData from + * @param blocker The player who may have blocked + * @param blocked The player who may be blocked + * @return true if blocker has blocked blocked + */ + public static boolean isBlocked( + ServerLevel level, + UUID blocker, + UUID blocked + ) { + return SocialData.get(level).isBlocked(blocker, blocked); + } + + // ======================================== + // RP Commands + // ======================================== + + private static int noRP(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + + Optional playerOpt = CommandHelper.getPlayerOrFail( + source + ); + if (playerOpt.isEmpty()) return 0; + ServerPlayer player = playerOpt.get(); + + // Check cooldown + long now = System.currentTimeMillis(); + Long lastUse = NORP_COOLDOWNS.get(player.getUUID()); + if (lastUse != null && now - lastUse < NORP_COOLDOWN_MS) { + long remaining = (NORP_COOLDOWN_MS - (now - lastUse)) / 1000; + source.sendFailure( + Component.literal( + "Please wait " + + remaining + + " seconds before using /norp again" + ) + ); + return 0; + } + + // Set cooldown + NORP_COOLDOWNS.put(player.getUUID(), now); + + // Broadcast to all players + Component message = Component.literal("") + .append( + Component.literal("[NoRP] ").withStyle( + ChatFormatting.RED, + ChatFormatting.BOLD + ) + ) + .append( + Component.literal(player.getName().getString()).withStyle( + ChatFormatting.YELLOW + ) + ) + .append( + Component.literal( + " has announced non-consent to current RP" + ).withStyle(ChatFormatting.RED) + ); + + // Broadcast to all players (earplug-aware) + for (ServerPlayer p : player.server.getPlayerList().getPlayers()) { + MessageDispatcher.sendTo(p, message); + } + + TiedUpMod.LOGGER.info( + "[SOCIAL] {} used /norp", + player.getName().getString() + ); + return 1; + } + + private static int meAction(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + + Optional playerOpt = CommandHelper.getPlayerOrFail( + source + ); + if (playerOpt.isEmpty()) return 0; + ServerPlayer player = playerOpt.get(); + + String action = StringArgumentType.getString(context, "action"); + + // Get talk area for local chat + SocialData data = SocialData.get(player.serverLevel()); + int talkArea = data.getTalkArea(player.getUUID()); + + Component message = Component.literal("") + .append( + Component.literal("* ").withStyle(ChatFormatting.LIGHT_PURPLE) + ) + .append( + Component.literal(player.getName().getString()).withStyle( + ChatFormatting.LIGHT_PURPLE + ) + ) + .append( + Component.literal(" " + action).withStyle( + ChatFormatting.LIGHT_PURPLE + ) + ); + + if (talkArea > 0) { + // Local chat - send to nearby players (earplug-aware) + AABB area = player.getBoundingBox().inflate(talkArea); + List nearby = player + .serverLevel() + .getEntitiesOfClass(ServerPlayer.class, area); + for (ServerPlayer p : nearby) { + MessageDispatcher.sendTo(p, message); + } + } else { + // Global chat (earplug-aware) + for (ServerPlayer p : player.server.getPlayerList().getPlayers()) { + MessageDispatcher.sendTo(p, message); + } + } + + return 1; + } + + private static int privateMessage( + CommandContext context + ) throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + + Optional senderOpt = CommandHelper.getPlayerOrFail( + source + ); + if (senderOpt.isEmpty()) return 0; + ServerPlayer sender = senderOpt.get(); + + ServerPlayer target = EntityArgument.getPlayer(context, "player"); + String message = StringArgumentType.getString(context, "message"); + + // Check if blocked + SocialData data = SocialData.get(sender.serverLevel()); + if (data.isBlocked(target.getUUID(), sender.getUUID())) { + source.sendFailure( + Component.literal("This player has blocked you") + ); + return 0; + } + + // Send to target (earplug-aware) + Component toTarget = Component.literal("") + .append( + Component.literal( + "[PM from " + sender.getName().getString() + "] " + ).withStyle(ChatFormatting.LIGHT_PURPLE) + ) + .append(Component.literal(message).withStyle(ChatFormatting.WHITE)); + MessageDispatcher.sendFrom(sender, target, toTarget); + + // Confirm to sender (always show - they're the one sending) + Component toSender = Component.literal("") + .append( + Component.literal( + "[PM to " + target.getName().getString() + "] " + ).withStyle(ChatFormatting.GRAY) + ) + .append(Component.literal(message).withStyle(ChatFormatting.WHITE)); + MessageDispatcher.sendSystemMessage(sender, toSender); + + return 1; + } + + // ======================================== + // Talk Area + // ======================================== + + private static int setTalkArea( + CommandContext context, + int distance + ) throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + + Optional playerOpt = CommandHelper.getPlayerOrFail( + source + ); + if (playerOpt.isEmpty()) return 0; + ServerPlayer player = playerOpt.get(); + + SocialData data = SocialData.get(player.serverLevel()); + data.setTalkArea(player.getUUID(), distance); + + if (distance == 0) { + source.sendSuccess( + () -> Component.literal("§aTalk area disabled (global chat)"), + false + ); + } else { + source.sendSuccess( + () -> + Component.literal( + "§aTalk area set to " + distance + " blocks" + ), + false + ); + } + + return 1; + } + + private static int talkInfo(CommandContext context) + throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + + Optional playerOpt = CommandHelper.getPlayerOrFail( + source + ); + if (playerOpt.isEmpty()) return 0; + ServerPlayer player = playerOpt.get(); + + SocialData data = SocialData.get(player.serverLevel()); + int talkArea = data.getTalkArea(player.getUUID()); + + if (talkArea == 0) { + source.sendSuccess( + () -> + Component.literal("Talk area: §edisabled §7(global chat)"), + false + ); + } else { + source.sendSuccess( + () -> Component.literal("Talk area: §e" + talkArea + " blocks"), + false + ); + } + + return 1; + } +} diff --git a/src/main/java/com/tiedup/remake/commands/TiedUpCommand.java b/src/main/java/com/tiedup/remake/commands/TiedUpCommand.java new file mode 100644 index 0000000..5303554 --- /dev/null +++ b/src/main/java/com/tiedup/remake/commands/TiedUpCommand.java @@ -0,0 +1,80 @@ +package com.tiedup.remake.commands; + +import com.mojang.brigadier.CommandDispatcher; +import com.tiedup.remake.commands.subcommands.BondageSubCommand; +import com.tiedup.remake.commands.subcommands.DebtSubCommand; +import com.tiedup.remake.commands.subcommands.InventorySubCommand; +import com.tiedup.remake.commands.subcommands.MasterTestSubCommand; +import com.tiedup.remake.commands.subcommands.TestAnimSubCommand; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; + +/** + * Main TiedUp! command suite — registration hub. + * + * All command implementations are delegated to domain-specific sub-command classes + * in the {@code subcommands/} package. This class only builds the root node and + * wires together the sub-trees. + * + * Commands are grouped by domain: + * - Bondage: tie, untie, gag, ungag, blindfold, unblind, collar, takecollar, + * takeearplugs, putearplugs, takeclothes, putclothes, fullyrestrain, enslave, free, adjust + * - Debt: debt show/set/add/remove + * - Master test: mastertest, masterchair, mastertask + * - Test animation: testanim /stop + * - Inventory: returnstuff + * - Plus existing delegated commands: bounty, npc, key, collar, clothes, + * kidnapset, kidnapreload, cell, social, debug + */ +@SuppressWarnings("null") +public class TiedUpCommand { + + /** + * Register all TiedUp commands with the command dispatcher. + * + * @param dispatcher The command dispatcher + */ + public static void register( + CommandDispatcher dispatcher + ) { + var root = Commands.literal("tiedup"); + + // === Domain sub-commands (implemented in subcommands/ package) === + BondageSubCommand.register(root); + DebtSubCommand.register(root); + MasterTestSubCommand.register(root); + TestAnimSubCommand.register(root); + InventorySubCommand.register(root); + + // === Existing delegated commands (each has its own class) === + // /tiedup bounty - Bounty system + root.then(BountyCommand.createBountyCommand()); + // /tiedup npc ... - NPC management + root.then(NPCCommand.createNPCCommand()); + // /tiedup key ... - Key management + root.then(KeyCommand.createKeyCommand()); + // /tiedup collar ... - Collar management + root.then(CollarCommand.createCollarCommand()); + // /tiedup clothes ... - Clothes configuration + root.then(ClothesCommand.createClothesCommand()); + // /tiedup kidnapset - Configuration reload + root.then(KidnapSetCommand.createKidnapSetCommand()); + // /tiedup kidnapreload - Configuration reload (alias) + root.then(KidnapSetCommand.createKidnapReloadCommand()); + // /tiedup cell ... - Cell management + root.then(CellCommand.createCellCommand()); + // === Social Commands === + root.then(SocialCommand.createBlockPlayerCommand()); + root.then(SocialCommand.createUnblockPlayerCommand()); + root.then(SocialCommand.createCheckBlockedCommand()); + root.then(SocialCommand.createNoRPCommand()); + root.then(SocialCommand.createMeCommand()); + root.then(SocialCommand.createPMCommand()); + root.then(SocialCommand.createTalkAreaCommand()); + root.then(SocialCommand.createTalkInfoCommand()); + // /tiedup debug ... - Captivity system debugging + root.then(CaptivityDebugCommand.createDebugCommand()); + + dispatcher.register(root); + } +} diff --git a/src/main/java/com/tiedup/remake/commands/subcommands/BondageSubCommand.java b/src/main/java/com/tiedup/remake/commands/subcommands/BondageSubCommand.java new file mode 100644 index 0000000..3f3f8ce --- /dev/null +++ b/src/main/java/com/tiedup/remake/commands/subcommands/BondageSubCommand.java @@ -0,0 +1,1199 @@ +package com.tiedup.remake.commands.subcommands; + +import com.mojang.brigadier.arguments.FloatArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.tiedup.remake.commands.CommandHelper; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.AdjustmentHelper; +import com.tiedup.remake.items.base.BlindfoldVariant; +import com.tiedup.remake.items.base.BindVariant; +import com.tiedup.remake.items.base.EarplugsVariant; +import com.tiedup.remake.items.base.GagVariant; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.sync.PacketSyncBindState; +import com.tiedup.remake.state.PlayerBindState; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; + +/** + * Bondage-related sub-commands for /tiedup. + * Handles: tie, untie, gag, ungag, blindfold, unblind, collar, takecollar, + * takeearplugs, putearplugs, takeclothes, putclothes, fullyrestrain, enslave, free, adjust + */ +@SuppressWarnings("null") +public class BondageSubCommand { + + public static void register(LiteralArgumentBuilder root) { + // /tiedup tie + root.then( + Commands.literal("tie") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.argument("player", EntityArgument.player()) + .executes(BondageSubCommand::tie) + ) + ); + // /tiedup untie + root.then( + Commands.literal("untie") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.argument("player", EntityArgument.player()) + .executes(BondageSubCommand::untie) + ) + ); + // /tiedup gag + root.then( + Commands.literal("gag") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.argument("player", EntityArgument.player()) + .executes(BondageSubCommand::gag) + ) + ); + // /tiedup ungag + root.then( + Commands.literal("ungag") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.argument("player", EntityArgument.player()) + .executes(BondageSubCommand::ungag) + ) + ); + // /tiedup blindfold + root.then( + Commands.literal("blindfold") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.argument("player", EntityArgument.player()) + .executes(BondageSubCommand::blindfold) + ) + ); + // /tiedup unblind + root.then( + Commands.literal("unblind") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.argument("player", EntityArgument.player()) + .executes(BondageSubCommand::unblind) + ) + ); + // /tiedup collar + root.then( + Commands.literal("collar") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.argument("player", EntityArgument.player()) + .executes(BondageSubCommand::collar) + ) + ); + // /tiedup takecollar + root.then( + Commands.literal("takecollar") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.argument("player", EntityArgument.player()) + .executes(BondageSubCommand::takecollar) + ) + ); + // /tiedup takeearplugs + root.then( + Commands.literal("takeearplugs") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.argument("player", EntityArgument.player()) + .executes(BondageSubCommand::takeearplugs) + ) + ); + // /tiedup putearplugs + root.then( + Commands.literal("putearplugs") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.argument("player", EntityArgument.player()) + .executes(BondageSubCommand::putearplugs) + ) + ); + // /tiedup takeclothes + root.then( + Commands.literal("takeclothes") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.argument("player", EntityArgument.player()) + .executes(BondageSubCommand::takeclothes) + ) + ); + // /tiedup putclothes + root.then( + Commands.literal("putclothes") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.argument("player", EntityArgument.player()) + .executes(BondageSubCommand::putclothes) + ) + ); + // /tiedup fullyrestrain + root.then( + Commands.literal("fullyrestrain") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.argument("player", EntityArgument.player()) + .executes(BondageSubCommand::fullyrestrain) + ) + ); + // /tiedup enslave + root.then( + Commands.literal("enslave") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.argument("player", EntityArgument.player()) + .executes(BondageSubCommand::enslave) + ) + ); + // /tiedup free + root.then( + Commands.literal("free") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.argument("player", EntityArgument.player()) + .executes(BondageSubCommand::free) + ) + ); + // /tiedup adjust + root.then( + Commands.literal("adjust") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.argument("player", EntityArgument.player()) + .then( + Commands.argument("type", StringArgumentType.word()) + .suggests((ctx, builder) -> { + builder.suggest("gag"); + builder.suggest("blindfold"); + builder.suggest("all"); + return builder.buildFuture(); + }) + .then( + Commands.argument( + "value", + FloatArgumentType.floatArg(-4.0f, 4.0f) + ).executes(BondageSubCommand::adjust) + ) + ) + ) + ); + } + + // ======================================== + // Command Implementations + // ======================================== + + /** + * /tiedup tie + * + * Forcefully tie a player with ropes (default bind). + * Uses ItemRopes from ModItems. + */ + private static int tie(CommandContext context) + throws CommandSyntaxException { + ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player"); + PlayerBindState state = PlayerBindState.getInstance(targetPlayer); + + if (state == null) { + context + .getSource() + .sendFailure(Component.literal("Failed to get player state")); + return 0; + } + + if (state.isTiedUp()) { + context + .getSource() + .sendFailure( + Component.literal( + targetPlayer.getName().getString() + + " is already tied up" + ) + ); + return 0; + } + + ItemStack ropes = new ItemStack(ModItems.getBind(BindVariant.ROPES)); + state.putBindOn(ropes); + + CommandHelper.syncPlayerState(targetPlayer, state); + + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "\u00a7a" + + targetPlayer.getName().getString() + + " has been tied up" + ), + true + ); + SystemMessageManager.sendTiedUp( + context.getSource().getEntity(), + targetPlayer + ); + + return 1; + } + + /** + * /tiedup untie + * + * Forcefully untie a player, removing ALL bondage equipment. + */ + private static int untie(CommandContext context) + throws CommandSyntaxException { + ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player"); + PlayerBindState state = PlayerBindState.getInstance(targetPlayer); + + if (state == null) { + context + .getSource() + .sendFailure(Component.literal("Failed to get player state")); + return 0; + } + + if ( + !state.isTiedUp() && + !state.isGagged() && + !state.isBlindfolded() && + !state.hasCollar() + ) { + context + .getSource() + .sendFailure( + Component.literal( + targetPlayer.getName().getString() + + " is not restrained" + ) + ); + return 0; + } + + boolean removed = false; + if (state.isTiedUp()) { + state.takeBindOff(); + removed = true; + } + if (state.isGagged()) { + state.takeGagOff(); + removed = true; + } + if (state.isBlindfolded()) { + state.takeBlindfoldOff(); + removed = true; + } + if (state.hasCollar()) { + state.takeCollarOff(); + removed = true; + } + if (state.hasEarplugs()) { + state.takeEarplugsOff(); + removed = true; + } + + if (removed) { + CommandHelper.syncPlayerState(targetPlayer, state); + + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "\u00a7a" + + targetPlayer.getName().getString() + + " has been freed from all restraints" + ), + true + ); + SystemMessageManager.sendFreed(targetPlayer); + + return 1; + } + + return 0; + } + + /** + * /tiedup gag + * + * Forcefully gag a player with cloth gag (default). + */ + private static int gag(CommandContext context) + throws CommandSyntaxException { + ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player"); + PlayerBindState state = PlayerBindState.getInstance(targetPlayer); + + if (state == null) { + context + .getSource() + .sendFailure(Component.literal("Failed to get player state")); + return 0; + } + + if (state.isGagged()) { + context + .getSource() + .sendFailure( + Component.literal( + targetPlayer.getName().getString() + + " is already gagged" + ) + ); + return 0; + } + + ItemStack gag = new ItemStack(ModItems.getGag(GagVariant.CLOTH_GAG)); + state.putGagOn(gag); + + CommandHelper.syncPlayerState(targetPlayer, state); + + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "\u00a7a" + + targetPlayer.getName().getString() + + " has been gagged" + ), + true + ); + SystemMessageManager.sendGagged( + context.getSource().getEntity(), + targetPlayer + ); + + return 1; + } + + /** + * /tiedup blindfold + * + * Forcefully blindfold a player with classic blindfold (default). + */ + private static int blindfold(CommandContext context) + throws CommandSyntaxException { + ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player"); + PlayerBindState state = PlayerBindState.getInstance(targetPlayer); + + if (state == null) { + context + .getSource() + .sendFailure(Component.literal("Failed to get player state")); + return 0; + } + + if (state.isBlindfolded()) { + context + .getSource() + .sendFailure( + Component.literal( + targetPlayer.getName().getString() + + " is already blindfolded" + ) + ); + return 0; + } + + ItemStack blindfold = new ItemStack( + ModItems.getBlindfold(BlindfoldVariant.CLASSIC) + ); + state.putBlindfoldOn(blindfold); + + CommandHelper.syncPlayerState(targetPlayer, state); + + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "\u00a7a" + + targetPlayer.getName().getString() + + " has been blindfolded" + ), + true + ); + SystemMessageManager.sendToTarget( + context.getSource().getEntity(), + targetPlayer, + SystemMessageManager.MessageCategory.BLINDFOLDED + ); + + return 1; + } + + /** + * /tiedup collar + * + * Give a collar to a player (forces collar even if not tied). + * The command executor becomes the owner of the collar. + */ + private static int collar(CommandContext context) + throws CommandSyntaxException { + ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player"); + PlayerBindState state = PlayerBindState.getInstance(targetPlayer); + + if (state == null) { + context + .getSource() + .sendFailure(Component.literal("Failed to get player state")); + return 0; + } + + if (state.hasCollar()) { + context + .getSource() + .sendFailure( + Component.literal( + targetPlayer.getName().getString() + + " already has a collar" + ) + ); + return 0; + } + + ItemStack collar = new ItemStack(ModItems.CLASSIC_COLLAR.get()); + + if (context.getSource().getEntity() instanceof ServerPlayer executor) { + ItemCollar collarItem = (ItemCollar) collar.getItem(); + collarItem.addOwner(collar, executor); + } + + state.putCollarOn(collar); + + CommandHelper.syncPlayerState(targetPlayer, state); + + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "\u00a7a" + + targetPlayer.getName().getString() + + " has been collared" + ), + true + ); + SystemMessageManager.sendToTarget( + context.getSource().getEntity(), + targetPlayer, + SystemMessageManager.MessageCategory.COLLARED + ); + + return 1; + } + + /** + * /tiedup free + * + * Free a player from slavery (removes master relationship). + * Does NOT remove collar or other equipment. + */ + private static int free(CommandContext context) + throws CommandSyntaxException { + ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player"); + PlayerBindState state = PlayerBindState.getInstance(targetPlayer); + + if (state == null) { + context + .getSource() + .sendFailure(Component.literal("Failed to get player state")); + return 0; + } + + if (!state.isCaptive()) { + context + .getSource() + .sendFailure( + Component.literal( + targetPlayer.getName().getString() + " is not captured" + ) + ); + return 0; + } + + state.free(true); + + PacketSyncBindState statePacket = PacketSyncBindState.fromPlayer( + targetPlayer + ); + if (statePacket != null) { + ModNetwork.sendToPlayer(statePacket, targetPlayer); + } + + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "\u00a7a" + + targetPlayer.getName().getString() + + " has been freed from slavery" + ), + true + ); + SystemMessageManager.sendFreed(targetPlayer); + + return 1; + } + + /** + * /tiedup adjust + * + * Adjust the Y position of gags and/or blindfolds on a player. + * Value range: -4.0 to +4.0 (pixels, 1 pixel = 1/16 block) + * + * Types: + * - gag: Adjust only gag + * - blindfold: Adjust only blindfold + * - all: Adjust both gag and blindfold + */ + private static int adjust(CommandContext context) + throws CommandSyntaxException { + ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player"); + String type = StringArgumentType.getString(context, "type"); + float value = FloatArgumentType.getFloat(context, "value"); + + PlayerBindState state = PlayerBindState.getInstance(targetPlayer); + if (state == null) { + context + .getSource() + .sendFailure(Component.literal("Failed to get player state")); + return 0; + } + + boolean adjustedGag = false; + boolean adjustedBlindfold = false; + + if (type.equals("gag") || type.equals("all")) { + ItemStack gag = state.getEquipment(com.tiedup.remake.v2.BodyRegionV2.MOUTH); + if (!gag.isEmpty()) { + AdjustmentHelper.setAdjustment(gag, value); + adjustedGag = true; + } + } + + if (type.equals("blindfold") || type.equals("all")) { + ItemStack blindfold = state.getEquipment(com.tiedup.remake.v2.BodyRegionV2.EYES); + if (!blindfold.isEmpty()) { + AdjustmentHelper.setAdjustment(blindfold, value); + adjustedBlindfold = true; + } + } + + if ( + !type.equals("gag") && + !type.equals("blindfold") && + !type.equals("all") + ) { + context + .getSource() + .sendFailure( + Component.literal( + "Invalid type. Use: gag, blindfold, or all" + ) + ); + return 0; + } + + if (!adjustedGag && !adjustedBlindfold) { + context + .getSource() + .sendFailure( + Component.literal( + targetPlayer.getName().getString() + + " has no " + + type + + " to adjust" + ) + ); + return 0; + } + + CommandHelper.syncPlayerState(targetPlayer, state); + + String items = + adjustedGag && adjustedBlindfold + ? "gag and blindfold" + : adjustedGag + ? "gag" + : "blindfold"; + String valueStr = String.format("%.2f", value); + + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "\u00a7aAdjusted " + + items + + " for " + + targetPlayer.getName().getString() + + " to " + + valueStr + + " pixels" + ), + true + ); + + return 1; + } + + /** + * /tiedup ungag + * + * Remove gag from a player. + */ + private static int ungag(CommandContext context) + throws CommandSyntaxException { + ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player"); + PlayerBindState state = PlayerBindState.getInstance(targetPlayer); + + if (state == null) { + context + .getSource() + .sendFailure(Component.literal("Failed to get player state")); + return 0; + } + + if (!state.isGagged()) { + context + .getSource() + .sendFailure( + Component.literal( + targetPlayer.getName().getString() + " is not gagged" + ) + ); + return 0; + } + + state.takeGagOff(); + CommandHelper.syncPlayerState(targetPlayer, state); + + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "\u00a7a" + + targetPlayer.getName().getString() + + "'s gag has been removed" + ), + true + ); + SystemMessageManager.sendToTarget( + context.getSource().getEntity(), + targetPlayer, + SystemMessageManager.MessageCategory.UNGAGGED + ); + + return 1; + } + + /** + * /tiedup unblind + * + * Remove blindfold from a player. + */ + private static int unblind(CommandContext context) + throws CommandSyntaxException { + ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player"); + PlayerBindState state = PlayerBindState.getInstance(targetPlayer); + + if (state == null) { + context + .getSource() + .sendFailure(Component.literal("Failed to get player state")); + return 0; + } + + if (!state.isBlindfolded()) { + context + .getSource() + .sendFailure( + Component.literal( + targetPlayer.getName().getString() + + " is not blindfolded" + ) + ); + return 0; + } + + state.takeBlindfoldOff(); + CommandHelper.syncPlayerState(targetPlayer, state); + + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "\u00a7a" + + targetPlayer.getName().getString() + + "'s blindfold has been removed" + ), + true + ); + SystemMessageManager.sendToTarget( + context.getSource().getEntity(), + targetPlayer, + SystemMessageManager.MessageCategory.UNBLINDFOLDED + ); + + return 1; + } + + /** + * /tiedup takecollar + * + * Remove collar from a player. + */ + private static int takecollar(CommandContext context) + throws CommandSyntaxException { + ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player"); + PlayerBindState state = PlayerBindState.getInstance(targetPlayer); + + if (state == null) { + context + .getSource() + .sendFailure(Component.literal("Failed to get player state")); + return 0; + } + + if (!state.hasCollar()) { + context + .getSource() + .sendFailure( + Component.literal( + targetPlayer.getName().getString() + + " does not have a collar" + ) + ); + return 0; + } + + state.takeCollarOff(); + CommandHelper.syncPlayerState(targetPlayer, state); + + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "\u00a7a" + + targetPlayer.getName().getString() + + "'s collar has been removed" + ), + true + ); + SystemMessageManager.sendToTarget( + context.getSource().getEntity(), + targetPlayer, + SystemMessageManager.MessageCategory.UNCOLLARED + ); + + return 1; + } + + /** + * /tiedup takeearplugs + * + * Remove earplugs from a player. + */ + private static int takeearplugs(CommandContext context) + throws CommandSyntaxException { + ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player"); + PlayerBindState state = PlayerBindState.getInstance(targetPlayer); + + if (state == null) { + context + .getSource() + .sendFailure(Component.literal("Failed to get player state")); + return 0; + } + + if (!state.hasEarplugs()) { + context + .getSource() + .sendFailure( + Component.literal( + targetPlayer.getName().getString() + + " does not have earplugs" + ) + ); + return 0; + } + + state.takeEarplugsOff(); + CommandHelper.syncPlayerState(targetPlayer, state); + + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "\u00a7a" + + targetPlayer.getName().getString() + + "'s earplugs have been removed" + ), + true + ); + SystemMessageManager.sendToPlayer( + targetPlayer, + SystemMessageManager.MessageCategory.INFO, + "Your earplugs have been removed!" + ); + + return 1; + } + + /** + * /tiedup takeclothes + * + * Remove clothes from a player. + */ + private static int takeclothes(CommandContext context) + throws CommandSyntaxException { + ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player"); + PlayerBindState state = PlayerBindState.getInstance(targetPlayer); + + if (state == null) { + context + .getSource() + .sendFailure(Component.literal("Failed to get player state")); + return 0; + } + + if (!state.hasClothes()) { + context + .getSource() + .sendFailure( + Component.literal( + targetPlayer.getName().getString() + + " is not wearing clothes" + ) + ); + return 0; + } + + ItemStack removed = state.takeClothesOff(); + CommandHelper.syncPlayerState(targetPlayer, state); + + if (!removed.isEmpty()) { + targetPlayer.drop(removed, false); + } + + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "Removed clothes from " + + targetPlayer.getName().getString() + ), + true + ); + return 1; + } + + /** + * /tiedup putclothes + * + * Put clothes on a player. + */ + private static int putclothes(CommandContext context) + throws CommandSyntaxException { + ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player"); + PlayerBindState state = PlayerBindState.getInstance(targetPlayer); + + if (state == null) { + context + .getSource() + .sendFailure(Component.literal("Failed to get player state")); + return 0; + } + + if (state.hasClothes()) { + context + .getSource() + .sendFailure( + Component.literal( + targetPlayer.getName().getString() + + " already has clothes" + ) + ); + return 0; + } + + ItemStack clothes = new ItemStack(ModItems.CLOTHES.get()); + state.putClothesOn(clothes); + CommandHelper.syncPlayerState(targetPlayer, state); + + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "\u00a7a" + + targetPlayer.getName().getString() + + " has been given clothes" + ), + true + ); + + return 1; + } + + /** + * /tiedup putearplugs + * + * Put earplugs on a player. + */ + private static int putearplugs(CommandContext context) + throws CommandSyntaxException { + ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player"); + PlayerBindState state = PlayerBindState.getInstance(targetPlayer); + + if (state == null) { + context + .getSource() + .sendFailure(Component.literal("Failed to get player state")); + return 0; + } + + if (state.hasEarplugs()) { + context + .getSource() + .sendFailure( + Component.literal( + targetPlayer.getName().getString() + + " already has earplugs" + ) + ); + return 0; + } + + ItemStack earplugs = new ItemStack( + ModItems.getEarplugs(EarplugsVariant.CLASSIC) + ); + state.putEarplugsOn(earplugs); + CommandHelper.syncPlayerState(targetPlayer, state); + + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "\u00a7a" + + targetPlayer.getName().getString() + + " has been given earplugs" + ), + true + ); + SystemMessageManager.sendToTarget( + context.getSource().getEntity(), + targetPlayer, + SystemMessageManager.MessageCategory.EARPLUGS_ON + ); + + return 1; + } + + /** + * /tiedup fullyrestrain + * + * Apply full bondage: bind + gag + blindfold + collar + earplugs + */ + private static int fullyrestrain(CommandContext context) + throws CommandSyntaxException { + ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player"); + PlayerBindState state = PlayerBindState.getInstance(targetPlayer); + + if (state == null) { + context + .getSource() + .sendFailure(Component.literal("Failed to get player state")); + return 0; + } + + int applied = 0; + + if (!state.isTiedUp()) { + ItemStack ropes = new ItemStack( + ModItems.getBind(BindVariant.ROPES) + ); + state.putBindOn(ropes); + applied++; + } + + if (!state.isGagged()) { + ItemStack gag = new ItemStack( + ModItems.getGag(GagVariant.CLOTH_GAG) + ); + state.putGagOn(gag); + applied++; + } + + if (!state.isBlindfolded()) { + ItemStack blindfold = new ItemStack( + ModItems.getBlindfold(BlindfoldVariant.CLASSIC) + ); + state.putBlindfoldOn(blindfold); + applied++; + } + + if (!state.hasCollar()) { + ItemStack collar = new ItemStack(ModItems.CLASSIC_COLLAR.get()); + if ( + context.getSource().getEntity() instanceof ServerPlayer executor + ) { + ItemCollar collarItem = (ItemCollar) collar.getItem(); + collarItem.addOwner(collar, executor); + } + state.putCollarOn(collar); + applied++; + } + + if (!state.hasEarplugs()) { + ItemStack earplugs = new ItemStack( + ModItems.getEarplugs(EarplugsVariant.CLASSIC) + ); + state.putEarplugsOn(earplugs); + applied++; + } + + if (applied == 0) { + context + .getSource() + .sendFailure( + Component.literal( + targetPlayer.getName().getString() + + " is already fully restrained" + ) + ); + return 0; + } + + CommandHelper.syncPlayerState(targetPlayer, state); + + int finalApplied = applied; + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "\u00a7a" + + targetPlayer.getName().getString() + + " has been fully restrained (" + + finalApplied + + " items applied)" + ), + true + ); + SystemMessageManager.sendToPlayer( + targetPlayer, + SystemMessageManager.MessageCategory.INFO, + "You have been fully restrained!" + ); + + return 1; + } + + /** + * /tiedup enslave + * + * Fully restrain and enslave a player. + * The command executor becomes the master. + */ + private static int enslave(CommandContext context) + throws CommandSyntaxException { + ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player"); + PlayerBindState state = PlayerBindState.getInstance(targetPlayer); + + if (state == null) { + context + .getSource() + .sendFailure(Component.literal("Failed to get player state")); + return 0; + } + + // First fully restrain + if (!state.isTiedUp()) { + ItemStack ropes = new ItemStack( + ModItems.getBind(BindVariant.ROPES) + ); + state.putBindOn(ropes); + } + if (!state.isGagged()) { + ItemStack gag = new ItemStack( + ModItems.getGag(GagVariant.CLOTH_GAG) + ); + state.putGagOn(gag); + } + if (!state.isBlindfolded()) { + ItemStack blindfold = new ItemStack( + ModItems.getBlindfold(BlindfoldVariant.CLASSIC) + ); + state.putBlindfoldOn(blindfold); + } + if (!state.hasCollar()) { + ItemStack collar = new ItemStack(ModItems.CLASSIC_COLLAR.get()); + if ( + context.getSource().getEntity() instanceof ServerPlayer executor + ) { + ItemCollar collarItem = (ItemCollar) collar.getItem(); + collarItem.addOwner(collar, executor); + } + state.putCollarOn(collar); + } + if (!state.hasEarplugs()) { + ItemStack earplugs = new ItemStack( + ModItems.getEarplugs(EarplugsVariant.CLASSIC) + ); + state.putEarplugsOn(earplugs); + } + + // Capture target (this makes them a captive) + if (context.getSource().getEntity() instanceof ServerPlayer master) { + PlayerBindState masterState = PlayerBindState.getInstance(master); + if (masterState != null && masterState.getCaptorManager() != null) { + masterState.getCaptorManager().addCaptive(state); + } + } + + CommandHelper.syncPlayerState(targetPlayer, state); + + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "\u00a7a" + + targetPlayer.getName().getString() + + " has been enslaved" + ), + true + ); + SystemMessageManager.sendEnslaved( + context.getSource().getEntity(), + targetPlayer + ); + + return 1; + } +} diff --git a/src/main/java/com/tiedup/remake/commands/subcommands/DebtSubCommand.java b/src/main/java/com/tiedup/remake/commands/subcommands/DebtSubCommand.java new file mode 100644 index 0000000..6a4dbb3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/commands/subcommands/DebtSubCommand.java @@ -0,0 +1,223 @@ +package com.tiedup.remake.commands.subcommands; + +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.tiedup.remake.commands.CommandHelper; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.RansomRecord; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; + +/** + * Debt management sub-commands for /tiedup. + * Handles: debt show, debt set, debt add, debt remove + */ +@SuppressWarnings("null") +public class DebtSubCommand { + + public static void register(LiteralArgumentBuilder root) { + // /tiedup debt [set|add|remove ] + root.then( + Commands.literal("debt") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.argument("player", EntityArgument.player()) + // /tiedup debt -> show debt + .executes(DebtSubCommand::debtShow) + // /tiedup debt set + .then( + Commands.literal("set").then( + Commands.argument( + "amount", + IntegerArgumentType.integer(0) + ).executes(DebtSubCommand::debtSet) + ) + ) + // /tiedup debt add + .then( + Commands.literal("add").then( + Commands.argument( + "amount", + IntegerArgumentType.integer(0) + ).executes(DebtSubCommand::debtAdd) + ) + ) + // /tiedup debt remove + .then( + Commands.literal("remove").then( + Commands.argument( + "amount", + IntegerArgumentType.integer(0) + ).executes(DebtSubCommand::debtRemove) + ) + ) + ) + ); + } + + // ======================================== + // Command Implementations + // ======================================== + + private static int debtShow(CommandContext context) + throws CommandSyntaxException { + ServerPlayer target = EntityArgument.getPlayer(context, "player"); + ServerLevel level = context.getSource().getLevel(); + PrisonerManager manager = PrisonerManager.get(level); + RansomRecord ransom = manager.getRansomRecord(target.getUUID()); + + if (ransom == null) { + context + .getSource() + .sendSuccess( + () -> + Component.literal( + target.getName().getString() + + " has no debt record." + ), + false + ); + return 1; + } + + int total = ransom.getTotalDebt(); + int paid = ransom.getAmountPaid(); + int remaining = ransom.getRemainingDebt(); + + context + .getSource() + .sendSuccess( + () -> + Component.literal( + target.getName().getString() + + " \u2014 Debt: " + + total + + " | Paid: " + + paid + + " | Remaining: " + + remaining + + " emeralds" + ), + false + ); + return 1; + } + + private static int debtSet(CommandContext context) + throws CommandSyntaxException { + ServerPlayer target = EntityArgument.getPlayer(context, "player"); + int amount = IntegerArgumentType.getInteger(context, "amount"); + ServerLevel level = context.getSource().getLevel(); + PrisonerManager manager = PrisonerManager.get(level); + RansomRecord ransom = manager.getRansomRecord(target.getUUID()); + + if (ransom == null) { + context + .getSource() + .sendFailure( + Component.literal( + target.getName().getString() + " has no debt record." + ) + ); + return 0; + } + + ransom.setTotalDebt(amount); + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "Set " + + target.getName().getString() + + "'s total debt to " + + amount + + " emeralds." + ), + true + ); + return 1; + } + + private static int debtAdd(CommandContext context) + throws CommandSyntaxException { + ServerPlayer target = EntityArgument.getPlayer(context, "player"); + int amount = IntegerArgumentType.getInteger(context, "amount"); + ServerLevel level = context.getSource().getLevel(); + PrisonerManager manager = PrisonerManager.get(level); + RansomRecord ransom = manager.getRansomRecord(target.getUUID()); + + if (ransom == null) { + context + .getSource() + .sendFailure( + Component.literal( + target.getName().getString() + " has no debt record." + ) + ); + return 0; + } + + ransom.increaseDebt(amount); + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "Added " + + amount + + " emeralds to " + + target.getName().getString() + + "'s debt. Remaining: " + + ransom.getRemainingDebt() + ), + true + ); + return 1; + } + + private static int debtRemove(CommandContext context) + throws CommandSyntaxException { + ServerPlayer target = EntityArgument.getPlayer(context, "player"); + int amount = IntegerArgumentType.getInteger(context, "amount"); + ServerLevel level = context.getSource().getLevel(); + PrisonerManager manager = PrisonerManager.get(level); + RansomRecord ransom = manager.getRansomRecord(target.getUUID()); + + if (ransom == null) { + context + .getSource() + .sendFailure( + Component.literal( + target.getName().getString() + " has no debt record." + ) + ); + return 0; + } + + ransom.addPayment(amount, null); + boolean paid = ransom.isPaid(); + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "Removed " + + amount + + " emeralds from " + + target.getName().getString() + + "'s debt. Remaining: " + + ransom.getRemainingDebt() + + (paid ? " (PAID OFF!)" : "") + ), + true + ); + return 1; + } +} diff --git a/src/main/java/com/tiedup/remake/commands/subcommands/InventorySubCommand.java b/src/main/java/com/tiedup/remake/commands/subcommands/InventorySubCommand.java new file mode 100644 index 0000000..5692875 --- /dev/null +++ b/src/main/java/com/tiedup/remake/commands/subcommands/InventorySubCommand.java @@ -0,0 +1,96 @@ +package com.tiedup.remake.commands.subcommands; + +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.tiedup.remake.cells.ConfiscatedInventoryRegistry; +import com.tiedup.remake.commands.CommandHelper; +import com.tiedup.remake.core.SystemMessageManager; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; + +/** + * Inventory management sub-commands for /tiedup. + * Handles: returnstuff + */ +@SuppressWarnings("null") +public class InventorySubCommand { + + public static void register(LiteralArgumentBuilder root) { + // /tiedup returnstuff + root.then( + Commands.literal("returnstuff") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.argument("player", EntityArgument.player()) + .executes(InventorySubCommand::returnstuff) + ) + ); + } + + // ======================================== + // Command Implementations + // ======================================== + + /** + * /tiedup returnstuff + * + * Restore a player's confiscated inventory from the NBT backup. + * Items are given directly to the player, bypassing the chest. + */ + private static int returnstuff(CommandContext context) + throws CommandSyntaxException { + ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player"); + ServerLevel level = context.getSource().getLevel(); + + ConfiscatedInventoryRegistry registry = + ConfiscatedInventoryRegistry.get(level); + + if (!registry.hasConfiscatedInventory(targetPlayer.getUUID())) { + context + .getSource() + .sendFailure( + Component.literal( + targetPlayer.getName().getString() + + " has no confiscated inventory to restore" + ) + ); + return 0; + } + + boolean restored = registry.restoreInventory(targetPlayer); + + if (restored) { + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "\u00a7aRestored confiscated inventory to " + + targetPlayer.getName().getString() + ), + true + ); + SystemMessageManager.sendToPlayer( + targetPlayer, + SystemMessageManager.MessageCategory.INFO, + "Your confiscated items have been returned!" + ); + return 1; + } + + context + .getSource() + .sendFailure( + Component.literal( + "Failed to restore inventory for " + + targetPlayer.getName().getString() + ) + ); + return 0; + } +} diff --git a/src/main/java/com/tiedup/remake/commands/subcommands/MasterTestSubCommand.java b/src/main/java/com/tiedup/remake/commands/subcommands/MasterTestSubCommand.java new file mode 100644 index 0000000..0221b80 --- /dev/null +++ b/src/main/java/com/tiedup/remake/commands/subcommands/MasterTestSubCommand.java @@ -0,0 +1,243 @@ +package com.tiedup.remake.commands.subcommands; + +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.tiedup.remake.commands.CommandHelper; +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.entities.ModEntities; +import com.tiedup.remake.entities.ai.master.MasterState; +import com.tiedup.remake.state.PlayerBindState; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.MobSpawnType; + +/** + * Master/pet-play test sub-commands for /tiedup. + * Handles: mastertest, masterchair, mastertask + */ +@SuppressWarnings("null") +public class MasterTestSubCommand { + + public static void register(LiteralArgumentBuilder root) { + // /tiedup mastertest + root.then( + Commands.literal("mastertest") + .requires(CommandHelper.REQUIRES_OP) + .executes(MasterTestSubCommand::mastertest) + ); + // /tiedup masterchair + root.then( + Commands.literal("masterchair") + .requires(CommandHelper.REQUIRES_OP) + .executes(MasterTestSubCommand::masterchair) + ); + // /tiedup mastertask + root.then( + Commands.literal("mastertask") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.argument("task", StringArgumentType.word()) + .suggests((ctx, builder) -> { + for (MasterState s : MasterState.values()) { + builder.suggest(s.name().toLowerCase()); + } + return builder.buildFuture(); + }) + .executes(MasterTestSubCommand::mastertask) + ) + ); + } + + // ======================================== + // Command Implementations + // ======================================== + + /** + * /tiedup mastertest + * + * Spawn a Master NPC nearby and immediately become its pet. + * For testing the pet play system without going through capture. + */ + private static int mastertest(CommandContext context) + throws CommandSyntaxException { + ServerPlayer player = context.getSource().getPlayerOrException(); + ServerLevel level = context.getSource().getLevel(); + + double x = player.getX() + player.getLookAngle().x * 2; + double y = player.getY(); + double z = player.getZ() + player.getLookAngle().z * 2; + + EntityMaster master = ModEntities.MASTER.get().create(level); + if (master == null) { + context + .getSource() + .sendFailure( + Component.literal("Failed to create Master entity") + ); + return 0; + } + + master.moveTo(x, y, z, player.getYRot() + 180F, 0.0F); + master.finalizeSpawn( + level, + level.getCurrentDifficultyAt(master.blockPosition()), + MobSpawnType.COMMAND, + null, + null + ); + level.addFreshEntity(master); + + master.setPetPlayer(player); + master.putPetCollar(player); + + CommandHelper.syncPlayerState(player, PlayerBindState.getInstance(player)); + + String masterName = master.getNpcName(); + if (masterName == null || masterName.isEmpty()) masterName = "Master"; + + String finalName = masterName; + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "Spawned Master '" + + finalName + + "' \u2014 you are now their pet." + ), + true + ); + return 1; + } + + /** + * /tiedup masterchair + * + * Force the nearest Master NPC into HUMAN_CHAIR state. + * Requires the player to be that Master's pet. + */ + private static int masterchair(CommandContext context) + throws CommandSyntaxException { + ServerPlayer player = context.getSource().getPlayerOrException(); + EntityMaster master = findNearestMaster(player); + + if (master == null) { + context + .getSource() + .sendFailure( + Component.literal("No Master NPC found within 20 blocks") + ); + return 0; + } + + if (!master.hasPet()) { + master.setPetPlayer(player); + master.putPetCollar(player); + CommandHelper.syncPlayerState(player, PlayerBindState.getInstance(player)); + } + + master.setMasterState(MasterState.HUMAN_CHAIR); + + String masterName = master.getNpcName(); + if (masterName == null || masterName.isEmpty()) masterName = "Master"; + String finalName = masterName; + + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "Forced " + finalName + " into HUMAN_CHAIR state" + ), + true + ); + return 1; + } + + /** + * /tiedup mastertask + * + * Force the nearest Master NPC into any MasterState. + * Useful for testing specific master behaviors. + */ + private static int mastertask(CommandContext context) + throws CommandSyntaxException { + ServerPlayer player = context.getSource().getPlayerOrException(); + String taskName = StringArgumentType.getString(context, "task"); + + MasterState targetState; + try { + targetState = MasterState.valueOf(taskName.toUpperCase()); + } catch (IllegalArgumentException e) { + context + .getSource() + .sendFailure( + Component.literal("Unknown MasterState: " + taskName) + ); + return 0; + } + + EntityMaster master = findNearestMaster(player); + if (master == null) { + context + .getSource() + .sendFailure( + Component.literal("No Master NPC found within 20 blocks") + ); + return 0; + } + + if (!master.hasPet()) { + master.setPetPlayer(player); + master.putPetCollar(player); + CommandHelper.syncPlayerState(player, PlayerBindState.getInstance(player)); + } + + master.setMasterState(targetState); + + String masterName = master.getNpcName(); + if (masterName == null || masterName.isEmpty()) masterName = "Master"; + String finalName = masterName; + + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "Forced " + + finalName + + " into " + + targetState.name() + + " state" + ), + true + ); + return 1; + } + + /** + * Find the nearest EntityMaster within 20 blocks of a player. + */ + @javax.annotation.Nullable + private static EntityMaster findNearestMaster(ServerPlayer player) { + var masters = player + .level() + .getEntitiesOfClass( + EntityMaster.class, + player.getBoundingBox().inflate(20.0), + m -> m.isAlive() + ); + if (masters.isEmpty()) return null; + return masters + .stream() + .min((a, b) -> + Double.compare(a.distanceToSqr(player), b.distanceToSqr(player)) + ) + .orElse(null); + } +} diff --git a/src/main/java/com/tiedup/remake/commands/subcommands/TestAnimSubCommand.java b/src/main/java/com/tiedup/remake/commands/subcommands/TestAnimSubCommand.java new file mode 100644 index 0000000..e522249 --- /dev/null +++ b/src/main/java/com/tiedup/remake/commands/subcommands/TestAnimSubCommand.java @@ -0,0 +1,120 @@ +package com.tiedup.remake.commands.subcommands; + +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.tiedup.remake.commands.CommandHelper; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.sync.PacketPlayTestAnimation; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +/** + * Test animation sub-commands for /tiedup. + * Handles: testanim [player], testanim stop [player] + */ +@SuppressWarnings("null") +public class TestAnimSubCommand { + + public static void register(LiteralArgumentBuilder root) { + // /tiedup testanim [player] + // /tiedup testanim stop [player] + root.then( + Commands.literal("testanim") + .requires(CommandHelper.REQUIRES_OP) + .then( + Commands.literal("stop") + .executes(ctx -> testAnimStop(ctx, null)) + .then( + Commands.argument("player", EntityArgument.player()) + .executes(ctx -> + testAnimStop( + ctx, + EntityArgument.getPlayer(ctx, "player") + ) + ) + ) + ) + .then( + Commands.argument("animId", StringArgumentType.string()) + .executes(ctx -> testAnim(ctx, null)) + .then( + Commands.argument("player", EntityArgument.player()) + .executes(ctx -> + testAnim( + ctx, + EntityArgument.getPlayer(ctx, "player") + ) + ) + ) + ) + ); + } + + // ======================================== + // Command Implementations + // ======================================== + + /** + * /tiedup testanim [player] + * Play an animation from player_animation/ on a player. + */ + private static int testAnim( + CommandContext context, + ServerPlayer target + ) throws CommandSyntaxException { + if (target == null) { + target = context.getSource().getPlayerOrException(); + } + String animId = StringArgumentType.getString(context, "animId"); + + ModNetwork.sendToAllTrackingAndSelf( + new PacketPlayTestAnimation(target.getUUID(), animId), + target + ); + + final String name = target.getName().getString(); + final String anim = animId; + context + .getSource() + .sendSuccess( + () -> + Component.literal( + "Playing animation '" + anim + "' on " + name + ), + false + ); + return 1; + } + + /** + * /tiedup testanim stop [player] + * Stop animation on a player. + */ + private static int testAnimStop( + CommandContext context, + ServerPlayer target + ) throws CommandSyntaxException { + if (target == null) { + target = context.getSource().getPlayerOrException(); + } + + ModNetwork.sendToAllTrackingAndSelf( + new PacketPlayTestAnimation(target.getUUID(), ""), + target + ); + + final String name = target.getName().getString(); + context + .getSource() + .sendSuccess( + () -> Component.literal("Stopped animation on " + name), + false + ); + return 1; + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/MCABondageManager.java b/src/main/java/com/tiedup/remake/compat/mca/MCABondageManager.java new file mode 100644 index 0000000..3baef59 --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/MCABondageManager.java @@ -0,0 +1,460 @@ +package com.tiedup.remake.compat.mca; + +import com.tiedup.remake.compat.mca.ai.MCABondageAIController; +import com.tiedup.remake.compat.mca.ai.MCABondageAILevel; +import com.tiedup.remake.compat.mca.dialogue.MCADialogueManager; +import com.tiedup.remake.compat.mca.personality.MCAMoodManager; +import com.tiedup.remake.compat.mca.personality.MCAPersonality; +import com.tiedup.remake.compat.mca.personality.MCAPersonalityManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.IRestrainable; +import java.util.Map; +import java.util.WeakHashMap; +import org.jetbrains.annotations.Nullable; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; + +/** + * Central coordinator for all MCA-TiedUp integration. + * + * Responsibilities: + * - Manages AI state for tied MCA villagers + * - Coordinates sync operations + * - Provides unified API for bondage operations + * - Handles capture/release lifecycle + * + * This is the main entry point for all MCA bondage operations. + * Other code should go through this manager rather than directly + * manipulating capabilities or AI. + */ +public class MCABondageManager { + + // Singleton instance + private static final MCABondageManager INSTANCE = new MCABondageManager(); + + // Track AI controllers for villagers (weak ref to avoid memory leaks) + private final Map aiControllers = + new WeakHashMap<>(); + + private MCABondageManager() { + // Private constructor for singleton + } + + /** + * Get the singleton instance. + */ + public static MCABondageManager getInstance() { + return INSTANCE; + } + + // ======================================== + // LIFECYCLE EVENTS + // ======================================== + + /** Dialogue broadcast radius in blocks */ + private static final double DIALOGUE_RADIUS = 16.0; + + /** + * Called when MCA villager is tied up. + * Initializes or updates AI control and syncs state. + * + * @param villager The MCA villager entity + * @param bind The bind item being applied + */ + public void onVillagerTied(LivingEntity villager, ItemStack bind) { + if (!MCACompat.isMCAVillager(villager)) return; + + MCAPersonality personality = + MCAPersonalityManager.getInstance().getPersonality(villager); + + TiedUpMod.LOGGER.debug( + "[MCA Manager] Villager tied: {} with {} (personality: {})", + villager.getName().getString(), + bind.getItem().getClass().getSimpleName(), + personality.getMcaId() + ); + + // Update AI level with personality + MCABondageAIController controller = getOrCreateAIController(villager); + controller.setPersonality(personality); + controller.updateAILevel(); + + // Mood change + MCAMoodManager.getInstance().onTied(villager); + + // Dialogue + String dialogue = MCADialogueManager.getBeingTiedDialogue(villager); + MCADialogueManager.broadcastDialogue( + villager, + dialogue, + DIALOGUE_RADIUS + ); + + // Sync to clients + syncBondageState(villager); + } + + /** + * Called when MCA villager is untied. + * Updates AI control and syncs state. + * + * @param villager The MCA villager entity + */ + public void onVillagerUntied(LivingEntity villager) { + if (!MCACompat.isMCAVillager(villager)) return; + + TiedUpMod.LOGGER.debug( + "[MCA Manager] Villager untied: {}", + villager.getName().getString() + ); + + // Update AI level (may restore normal behavior) + MCABondageAIController controller = aiControllers.get(villager); + if (controller != null) { + controller.updateAILevel(); + + // If fully free, cleanup controller + if (controller.getCurrentLevel() == MCABondageAILevel.NONE) { + controller.cleanup(); + aiControllers.remove(villager); + } + } + + // Sync to clients + syncBondageState(villager); + } + + /** + * Called when MCA villager is captured (leashed to a captor). + * + * @param villager The MCA villager entity + * @param captor The entity capturing the villager + */ + public void onVillagerCaptured(LivingEntity villager, Entity captor) { + if (!MCACompat.isMCAVillager(villager)) return; + + TiedUpMod.LOGGER.debug( + "[MCA Manager] Villager captured: {} by {}", + villager.getName().getString(), + captor.getName().getString() + ); + + // Update AI to include follow behavior + MCABondageAIController controller = getOrCreateAIController(villager); + controller.updateAILevel(); + + // Sync to clients + syncBondageState(villager); + } + + /** + * Called when MCA villager is freed from capture. + * + * @param villager The MCA villager entity + */ + public void onVillagerFreed(LivingEntity villager) { + if (!MCACompat.isMCAVillager(villager)) return; + + TiedUpMod.LOGGER.debug( + "[MCA Manager] Villager freed: {}", + villager.getName().getString() + ); + + // Update AI level + MCABondageAIController controller = aiControllers.get(villager); + if (controller != null) { + controller.updateAILevel(); + } + + // Mood change (happy to be freed, usually) + MCAMoodManager.getInstance().onFreed(villager); + + // Dialogue + String dialogue = MCADialogueManager.getFreedDialogue(villager); + MCADialogueManager.broadcastDialogue( + villager, + dialogue, + DIALOGUE_RADIUS + ); + + // Sync to clients + syncBondageState(villager); + } + + /** + * Called when collar is added/removed. + * + * @param villager The MCA villager entity + * @param hasCollar Whether the villager now has a collar + */ + public void onCollarChanged(LivingEntity villager, boolean hasCollar) { + if (!MCACompat.isMCAVillager(villager)) return; + + TiedUpMod.LOGGER.debug( + "[MCA Manager] Collar changed for {}: {}", + villager.getName().getString(), + hasCollar ? "added" : "removed" + ); + + // Update AI level + MCABondageAIController controller = getOrCreateAIController(villager); + controller.updateAILevel(); + + // Mood and dialogue + if (hasCollar) { + MCAMoodManager.getInstance().onCollared(villager); + String dialogue = MCADialogueManager.getCollarPutOnDialogue( + villager + ); + MCADialogueManager.broadcastDialogue( + villager, + dialogue, + DIALOGUE_RADIUS + ); + } else { + MCAMoodManager.getInstance().onCollarRemoved(villager); + } + + // Sync to clients + syncBondageState(villager); + } + + /** + * Called when gag state changes. + * + * @param villager The MCA villager entity + * @param isGagged Whether the villager is now gagged + */ + public void onGagChanged(LivingEntity villager, boolean isGagged) { + if (!MCACompat.isMCAVillager(villager)) return; + + // Update AI level (gagged+blindfolded = OVERRIDE) + MCABondageAIController controller = aiControllers.get(villager); + if (controller != null) { + controller.updateAILevel(); + } + + // Mood change + if (isGagged) { + MCAMoodManager.getInstance().onGagged(villager); + } + + // Sync to clients + syncBondageState(villager); + } + + /** + * Called when blindfold state changes. + * + * @param villager The MCA villager entity + * @param isBlindfolded Whether the villager is now blindfolded + */ + public void onBlindfoldChanged( + LivingEntity villager, + boolean isBlindfolded + ) { + if (!MCACompat.isMCAVillager(villager)) return; + + // Update AI level (gagged+blindfolded = OVERRIDE) + MCABondageAIController controller = aiControllers.get(villager); + if (controller != null) { + controller.updateAILevel(); + } + + // Mood change + if (isBlindfolded) { + MCAMoodManager.getInstance().onBlindfolded(villager); + } + + // Sync to clients + syncBondageState(villager); + } + + /** + * Called when any sensory restriction changes (gag/blindfold). + * Legacy method for compatibility. + * + * @param villager The MCA villager entity + */ + public void onSensoryRestrictionChanged(LivingEntity villager) { + if (!MCACompat.isMCAVillager(villager)) return; + + // Update AI level (gagged+blindfolded = OVERRIDE) + MCABondageAIController controller = aiControllers.get(villager); + if (controller != null) { + controller.updateAILevel(); + } + + // Sync to clients + syncBondageState(villager); + } + + // ======================================== + // AI CONTROL + // ======================================== + + /** + * Get or create AI controller for villager. + * + * @param villager The MCA villager entity + * @return The AI controller (never null for valid MCA villagers) + */ + public MCABondageAIController getOrCreateAIController( + LivingEntity villager + ) { + return aiControllers.computeIfAbsent( + villager, + MCABondageAIController::new + ); + } + + /** + * Get AI controller for villager if it exists. + * + * @param villager The MCA villager entity + * @return The AI controller, or null if none exists + */ + @Nullable + public MCABondageAIController getAIController(LivingEntity villager) { + return aiControllers.get(villager); + } + + /** + * Get current AI level for villager. + * + * @param villager The MCA villager entity + * @return The AI level (NONE if no controller exists) + */ + public MCABondageAILevel getAILevel(LivingEntity villager) { + MCABondageAIController controller = aiControllers.get(villager); + return controller != null + ? controller.getCurrentLevel() + : MCABondageAILevel.NONE; + } + + /** + * Force a specific AI level (for debugging/commands). + * + * @param villager The MCA villager entity + * @param level The AI level to set + */ + public void setAILevel(LivingEntity villager, MCABondageAILevel level) { + MCABondageAIController controller = getOrCreateAIController(villager); + controller.setLevel(level); + } + + /** + * Check if villager should have restricted AI. + * + * @param villager The MCA villager entity + * @return true if AI is restricted in any way + */ + public boolean shouldRestrictAI(LivingEntity villager) { + return getAILevel(villager) != MCABondageAILevel.NONE; + } + + // ======================================== + // SYNC + // ======================================== + + /** + * Sync all bondage state to tracking clients. + * Delegates to MCANetworkHandler. + * + * @param villager The MCA villager entity + */ + public void syncBondageState(LivingEntity villager) { + if (villager.level().isClientSide()) return; + if (!MCACompat.isMCAVillager(villager)) return; + + villager + .getCapability(MCACompat.MCA_KIDNAPPED) + .ifPresent(cap -> { + com.tiedup.remake.compat.mca.network.MCANetworkHandler.syncBondageState( + villager, + cap + ); + }); + } + + /** + * Sync bondage state to a specific player. + * Used when player starts tracking the villager. + * + * @param villager The MCA villager entity + * @param tracker The player to sync to + */ + public void syncBondageStateTo( + LivingEntity villager, + net.minecraft.server.level.ServerPlayer tracker + ) { + if (villager.level().isClientSide()) return; + if (!MCACompat.isMCAVillager(villager)) return; + + villager + .getCapability(MCACompat.MCA_KIDNAPPED) + .ifPresent(cap -> { + com.tiedup.remake.compat.mca.network.MCANetworkHandler.syncBondageStateTo( + villager, + cap, + tracker + ); + }); + } + + // ======================================== + // QUERIES + // ======================================== + + /** + * Get IRestrainable adapter for MCA villager. + * Convenience method that delegates to MCACompat. + * + * @param villager The MCA villager entity + * @return IRestrainable adapter, or null if not an MCA villager + */ + @Nullable + public IRestrainable getKidnappedState(LivingEntity villager) { + return MCACompat.getKidnappedState(villager); + } + + /** + * Check if entity is a managed MCA villager. + * + * @param entity The entity to check + * @return true if this entity is tracked by the manager + */ + public boolean isManaged(Entity entity) { + if (!(entity instanceof LivingEntity living)) return false; + return aiControllers.containsKey(living); + } + + // ======================================== + // CLEANUP + // ======================================== + + /** + * Remove all tracking for a villager. + * Called when villager dies or is removed. + * + * @param villager The MCA villager entity + */ + public void removeVillager(LivingEntity villager) { + MCABondageAIController controller = aiControllers.remove(villager); + if (controller != null) { + controller.cleanup(); + } + } + + /** + * Clear all tracking data. + * Called on world unload. + */ + public void clearAll() { + for (MCABondageAIController controller : aiControllers.values()) { + controller.cleanup(); + } + aiControllers.clear(); + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/MCACompat.java b/src/main/java/com/tiedup/remake/compat/mca/MCACompat.java new file mode 100644 index 0000000..adc69ae --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/MCACompat.java @@ -0,0 +1,118 @@ +package com.tiedup.remake.compat.mca; + +import com.tiedup.remake.compat.mca.capability.MCAKidnappedCapability; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.IRestrainable; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.LivingEntity; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.EntityRenderersEvent; +import net.minecraftforge.common.capabilities.Capability; +import net.minecraftforge.common.capabilities.CapabilityManager; +import net.minecraftforge.common.capabilities.CapabilityToken; +import net.minecraftforge.fml.ModList; +import net.minecraftforge.registries.ForgeRegistries; +import org.jetbrains.annotations.Nullable; + +/** + * MCA (Minecraft Comes Alive) compatibility module. + * Proxy that delegates to MCAHandler only if mod is loaded. + */ +public class MCACompat { + + public static final Capability MCA_KIDNAPPED = + CapabilityManager.get(new CapabilityToken<>() {}); + public static final ResourceLocation MCA_KIDNAPPED_CAP_ID = + ResourceLocation.fromNamespaceAndPath( + TiedUpMod.MOD_ID, + "mca_kidnapped" + ); + private static final String MCA_MOD_ID = "mca"; + private static boolean mcaLoaded = false; + + public static void init() { + mcaLoaded = ModList.get().isLoaded(MCA_MOD_ID); + if (mcaLoaded) { + TiedUpMod.LOGGER.info( + "[MCA Compat] MCA detected! Initializing handler." + ); + try { + MCAHandler.init(); + } catch (Throwable e) { + TiedUpMod.LOGGER.error( + "[MCA Compat] Failed to load MCA handler (class missing?)", + e + ); + mcaLoaded = false; + } + } + } + + public static boolean isMCALoaded() { + return mcaLoaded; + } + + public static boolean isMCAVillager(Entity entity) { + return mcaLoaded && MCAHandler.isMCAVillager(entity); + } + + @Nullable + public static IRestrainable getKidnappedState(LivingEntity entity) { + if (!mcaLoaded) return null; + return MCAHandler.getKidnappedState(entity); + } + + public static boolean shouldAttachCapability(Entity entity) { + return isMCAVillager(entity); + } + + public static void syncBondageState(LivingEntity entity) { + MCABondageManager.getInstance().syncBondageState(entity); + } + + @OnlyIn(Dist.CLIENT) + public static void registerRenderLayers( + EntityRenderersEvent.AddLayers event + ) { + if (!mcaLoaded) return; + + int layersAdded = 0; + for (EntityType< + ? + > entityType : ForgeRegistries.ENTITY_TYPES.getValues()) { + ResourceLocation typeId = ForgeRegistries.ENTITY_TYPES.getKey( + entityType + ); + if ( + typeId == null || !MCA_MOD_ID.equals(typeId.getNamespace()) + ) continue; + + // Check if it's a villager type + if (!typeId.getPath().contains("villager")) continue; + + try { + var renderer = event.getRenderer((EntityType) entityType); + if (renderer != null) { + MCAHandler.addBondageLayer(renderer, event); + layersAdded++; + } + } catch (Exception e) { + TiedUpMod.LOGGER.warn( + "[MCA Compat] Failed to add layer to {}: {}", + typeId, + e.getMessage() + ); + } + } + + if (layersAdded > 0) { + TiedUpMod.LOGGER.info( + "[MCA Compat] Added layers to {} MCA renderers", + layersAdded + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/MCAHandler.java b/src/main/java/com/tiedup/remake/compat/mca/MCAHandler.java new file mode 100644 index 0000000..ea7b791 --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/MCAHandler.java @@ -0,0 +1,87 @@ +package com.tiedup.remake.compat.mca; + +import com.tiedup.remake.compat.mca.capability.MCAKidnappedAdapter; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.IRestrainable; +import net.minecraft.client.model.HumanoidModel; +import net.minecraft.client.renderer.entity.LivingEntityRenderer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.EntityRenderersEvent; + +/** + * MCA Handler using reflection to avoid compilation issues with obfuscated jars. + */ +public class MCAHandler { + + private static Class villagerLikeClass; + private static Class villagerEntityClass; + + public static void init() { + try { + // MCA Forge uses "forge.net.mca" package prefix + villagerLikeClass = Class.forName( + "forge.net.mca.entity.VillagerLike" + ); + villagerEntityClass = Class.forName( + "forge.net.mca.entity.VillagerEntityMCA" + ); + TiedUpMod.LOGGER.info( + "[MCA Compat] Handler initialized successfully via reflection" + ); + } catch (ClassNotFoundException e) { + TiedUpMod.LOGGER.error( + "[MCA Compat] Failed to load MCA classes via reflection", + e + ); + } + } + + public static boolean isMCAVillager(Entity entity) { + if (entity == null) return false; + + // 1. Check loaded classes via reflection + if ( + villagerLikeClass != null && villagerLikeClass.isInstance(entity) + ) return true; + if ( + villagerEntityClass != null && + villagerEntityClass.isInstance(entity) + ) return true; + + // 2. Check EntityType namespace (fallback if classes not found/loaded) + net.minecraft.resources.ResourceLocation typeId = + net.minecraftforge.registries.ForgeRegistries.ENTITY_TYPES.getKey( + entity.getType() + ); + if (typeId != null && "mca".equals(typeId.getNamespace())) { + String path = typeId.getPath(); + if (path.contains("villager")) { + return true; + } + } + + // 3. Check class name string (ultimate fallback) + String className = entity.getClass().getName(); + return className.contains("mca") && className.contains("Villager"); + } + + public static IRestrainable getKidnappedState(LivingEntity entity) { + if (!isMCAVillager(entity)) return null; + + return entity + .getCapability(MCACompat.MCA_KIDNAPPED) + .map(cap -> new MCAKidnappedAdapter(entity, cap)) + .orElse(null); + } + + @OnlyIn(Dist.CLIENT) + public static void addBondageLayer( + LivingEntityRenderer renderer, + EntityRenderersEvent.AddLayers event + ) { + // V1 MCA bondage render layer removed — V2 render layer handles MCA villagers + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/ai/MCABondageAIController.java b/src/main/java/com/tiedup/remake/compat/mca/ai/MCABondageAIController.java new file mode 100644 index 0000000..1892360 --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/ai/MCABondageAIController.java @@ -0,0 +1,532 @@ +package com.tiedup.remake.compat.mca.ai; + +import com.tiedup.remake.compat.mca.MCABondageManager; +import com.tiedup.remake.compat.mca.MCACompat; +import com.tiedup.remake.compat.mca.ai.goals.MCAFleeGoal; +import com.tiedup.remake.compat.mca.ai.goals.MCAFollowCaptorGoal; +import com.tiedup.remake.compat.mca.ai.goals.MCAPanicGoal; +import com.tiedup.remake.compat.mca.ai.goals.MCAStayGoal; +import com.tiedup.remake.compat.mca.personality.MCAPersonality; +import com.tiedup.remake.compat.mca.personality.MCAPersonalityManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.RestraintEffectUtils; +import java.lang.ref.WeakReference; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.PathfinderMob; +import net.minecraft.world.entity.ai.goal.FloatGoal; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.ai.goal.WrappedGoal; +import net.minecraft.world.entity.player.Player; + +/** + * Controls AI behavior for a single MCA villager during bondage. + * + * Non-invasive design: + * - Stores original AI state before modification + * - Restores fully when freed + * - Configurable per capture state + * + * The controller manages transitions between AI levels, + * injecting and removing goals as needed. + */ +public class MCABondageAIController { + + private final WeakReference villagerRef; + private MCABondageAILevel currentLevel = MCABondageAILevel.NONE; + + // Backup of original goals (for restoration in OVERRIDE mode) + private Set originalGoals = null; + private boolean mcaBrainSuspended = false; + + // TiedUp-specific goals we've added + private final List injectedGoals = new ArrayList<>(); + + // Personality for behavior modification + private MCAPersonality personality = MCAPersonality.UNKNOWN; + + /** + * Create AI controller for an MCA villager. + * + * @param villager The MCA villager entity + */ + public MCABondageAIController(LivingEntity villager) { + this.villagerRef = new WeakReference<>(villager); + } + + /** + * Get the current AI control level. + */ + public MCABondageAILevel getCurrentLevel() { + return currentLevel; + } + + /** + * Get the villager this controller manages. + */ + public LivingEntity getVillager() { + return villagerRef.get(); + } + + /** + * Get the villager's personality. + */ + public MCAPersonality getPersonality() { + return personality; + } + + /** + * Set the villager's personality. + * This affects AI behavior (flee speed, panic duration, etc.) + */ + public void setPersonality(MCAPersonality personality) { + this.personality = + personality != null ? personality : MCAPersonality.UNKNOWN; + } + + // ======================================== + // LEVEL MANAGEMENT + // ======================================== + + /** + * Recalculate and apply appropriate AI level based on current state. + * Call this after any bondage state change. + */ + public void updateAILevel() { + LivingEntity villager = villagerRef.get(); + if (villager == null) return; + + IBondageState state = MCABondageManager.getInstance().getKidnappedState( + villager + ); + if (state == null) { + setLevel(MCABondageAILevel.NONE); + return; + } + + // Determine level from state + MCABondageAILevel newLevel = calculateLevel(state); + setLevel(newLevel); + } + + /** + * Calculate the appropriate AI level based on bondage state. + */ + private MCABondageAILevel calculateLevel(IBondageState state) { + // OVERRIDE: Fully tied (arms AND legs) OR gagged AND blindfolded + if (state.hasArmsBound() && state.hasLegsBound()) { + return MCABondageAILevel.OVERRIDE; + } + if (state.isGagged() && state.isBlindfolded()) { + return MCABondageAILevel.OVERRIDE; + } + + // MODIFIED: Tied (arms or legs) + if (state.isTiedUp()) { + return MCABondageAILevel.MODIFIED; + } + + // BASIC: Collar only + if (state.hasCollar()) { + return MCABondageAILevel.BASIC; + } + + // NONE: No restrictions + return MCABondageAILevel.NONE; + } + + /** + * Set a specific AI level. + * Handles transitions between levels properly. + * + * @param level The level to set + */ + public void setLevel(MCABondageAILevel level) { + if (level == currentLevel) return; + + LivingEntity villager = villagerRef.get(); + if (villager == null) return; + + TiedUpMod.LOGGER.debug( + "[MCA AI] Level change for {}: {} -> {}", + villager.getName().getString(), + currentLevel, + level + ); + + // Clean up current level effects + transitionFromLevel(currentLevel); + + // Apply new level effects + MCABondageAILevel oldLevel = currentLevel; + currentLevel = level; + transitionToLevel(level, oldLevel); + } + + /** + * Clean up effects from the previous level. + */ + private void transitionFromLevel(MCABondageAILevel level) { + switch (level) { + case OVERRIDE -> { + restoreMCABrain(); + restoreOriginalGoals(); + } + case MODIFIED -> removeInjectedGoals(); + case BASIC -> removeSpeedReduction(); + case NONE -> { + /* nothing to clean */ + } + } + } + + /** + * Apply effects for the new level. + */ + private void transitionToLevel( + MCABondageAILevel level, + MCABondageAILevel oldLevel + ) { + switch (level) { + case NONE -> { + /* nothing to apply */ + } + case BASIC -> applySpeedReduction(); + case MODIFIED -> { + if (!oldLevel.hasSpeedReduction()) { + applySpeedReduction(); + } + injectBondageGoals(); + } + case OVERRIDE -> { + if (!oldLevel.hasSpeedReduction()) { + applySpeedReduction(); + } + backupAndClearGoals(); + suspendMCABrain(); + injectOverrideGoals(); + } + } + } + + // ======================================== + // SPEED REDUCTION + // ======================================== + + private void applySpeedReduction() { + LivingEntity villager = villagerRef.get(); + if (villager == null || villager.level().isClientSide()) return; + + RestraintEffectUtils.applyBindSpeedReduction(villager); + + // Stop current navigation + if (villager instanceof Mob mob) { + mob.getNavigation().stop(); + } + + TiedUpMod.LOGGER.debug( + "[MCA AI] Applied speed reduction to {}", + villager.getName().getString() + ); + } + + private void removeSpeedReduction() { + LivingEntity villager = villagerRef.get(); + if (villager == null || villager.level().isClientSide()) return; + + RestraintEffectUtils.removeBindSpeedReduction(villager); + + TiedUpMod.LOGGER.debug( + "[MCA AI] Removed speed reduction from {}", + villager.getName().getString() + ); + } + + // ======================================== + // GOAL INJECTION (MODIFIED LEVEL) + // ======================================== + + private void injectBondageGoals() { + LivingEntity villager = villagerRef.get(); + if (!(villager instanceof PathfinderMob mob)) return; + + IBondageState state = MCABondageManager.getInstance().getKidnappedState( + villager + ); + if (state == null) return; + + // Get combined flee/panic speed from personality + float fleeMultiplier = + MCAPersonalityManager.getInstance().getCombinedCompliance(villager); + // Invert compliance for flee speed (compliant = slow flee, rebellious = fast flee) + float fleeSpeedMod = 2.0f - fleeMultiplier; // Range ~0.5 to ~1.7 + fleeSpeedMod = Math.max(0.5f, Math.min(1.5f, fleeSpeedMod)); + + // Priority 1: Follow captor when leashed + MCAFollowCaptorGoal followGoal = new MCAFollowCaptorGoal( + mob, + 1.0, + 10.0f, + 2.0f + ); + mob.goalSelector.addGoal(1, followGoal); + injectedGoals.add(followGoal); + + // Priority 2: Panic when being tied (personality affects speed) + double panicSpeed = 1.2 * personality.getFleeSpeedMultiplier(); + MCAPanicGoal panicGoal = new MCAPanicGoal(mob, panicSpeed); + mob.goalSelector.addGoal(2, panicGoal); + injectedGoals.add(panicGoal); + + // Priority 3: Flee from players if not collared (personality affects speed) + if (!state.hasCollar()) { + double walkSpeed = 1.0 * personality.getFleeSpeedMultiplier(); + double sprintSpeed = 1.2 * personality.getFleeSpeedMultiplier(); + MCAFleeGoal fleeGoal = new MCAFleeGoal( + mob, + Player.class, + 8.0f, + walkSpeed, + sprintSpeed + ); + mob.goalSelector.addGoal(3, fleeGoal); + injectedGoals.add(fleeGoal); + } + + TiedUpMod.LOGGER.debug( + "[MCA AI] Injected {} bondage goals for {} (personality: {}, flee mod: {})", + injectedGoals.size(), + villager.getName().getString(), + personality.getMcaId(), + String.format("%.2f", personality.getFleeSpeedMultiplier()) + ); + } + + private void removeInjectedGoals() { + LivingEntity villager = villagerRef.get(); + if (!(villager instanceof Mob mob)) return; + + for (Goal goal : injectedGoals) { + mob.goalSelector.removeGoal(goal); + } + + int count = injectedGoals.size(); + injectedGoals.clear(); + + TiedUpMod.LOGGER.debug( + "[MCA AI] Removed {} injected goals from {}", + count, + villager.getName().getString() + ); + } + + // ======================================== + // FULL AI OVERRIDE + // ======================================== + + private void backupAndClearGoals() { + LivingEntity villager = villagerRef.get(); + if (!(villager instanceof Mob mob)) return; + + // Backup original goals + originalGoals = new HashSet<>(); + for (WrappedGoal wrappedGoal : mob.goalSelector.getAvailableGoals()) { + originalGoals.add(wrappedGoal.getGoal()); + } + + // Clear all goals + mob.goalSelector.removeAllGoals(g -> true); + + TiedUpMod.LOGGER.debug( + "[MCA AI] Backed up {} goals for {}", + originalGoals.size(), + villager.getName().getString() + ); + } + + private void restoreOriginalGoals() { + if (originalGoals == null) return; + + LivingEntity villager = villagerRef.get(); + if (!(villager instanceof Mob mob)) return; + + // Remove our override goals first + for (Goal goal : injectedGoals) { + mob.goalSelector.removeGoal(goal); + } + injectedGoals.clear(); + + // Restore original goals + // Note: We don't know original priorities, so use default of 5 + for (Goal goal : originalGoals) { + mob.goalSelector.addGoal(5, goal); + } + + int count = originalGoals.size(); + originalGoals = null; + + TiedUpMod.LOGGER.debug( + "[MCA AI] Restored {} original goals for {}", + count, + villager.getName().getString() + ); + } + + private void injectOverrideGoals() { + LivingEntity villager = villagerRef.get(); + if (!(villager instanceof Mob mob)) return; + + // Essential goals only + // Priority 0: Always float (don't drown) + FloatGoal floatGoal = new FloatGoal(mob); + mob.goalSelector.addGoal(0, floatGoal); + injectedGoals.add(floatGoal); + + // Priority 1: Stay in place + MCAStayGoal stayGoal = new MCAStayGoal(mob); + mob.goalSelector.addGoal(1, stayGoal); + injectedGoals.add(stayGoal); + + TiedUpMod.LOGGER.debug( + "[MCA AI] Injected override goals for {}", + villager.getName().getString() + ); + } + + // ======================================== + // MCA BRAIN CONTROL + // ======================================== + + /** + * Suspend MCA's brain activities (for OVERRIDE level). + * Uses reflection to access VillagerBrain if available. + */ + private void suspendMCABrain() { + LivingEntity villager = villagerRef.get(); + if (villager == null) return; + + try { + // MCA villagers implement VillagerLike which has getVillagerBrain() + // We try to set their move state to STAY + Method getVillagerBrain = villager + .getClass() + .getMethod("getVillagerBrain"); + Object mcaBrain = getVillagerBrain.invoke(villager); + + if (mcaBrain != null) { + // Try to find setMoveState method + // MCA uses MoveState enum with values like MOVE, STAY, etc. + for (Method method : mcaBrain.getClass().getMethods()) { + if (method.getName().equals("setMoveState")) { + // Find the MoveState enum + Class[] paramTypes = method.getParameterTypes(); + if (paramTypes.length >= 1 && paramTypes[0].isEnum()) { + Object[] enumConstants = + paramTypes[0].getEnumConstants(); + // Find STAY constant + for (Object constant : enumConstants) { + if (constant.toString().equals("STAY")) { + method.invoke(mcaBrain, constant, null); + mcaBrainSuspended = true; + TiedUpMod.LOGGER.debug( + "[MCA AI] Suspended brain for {}", + villager.getName().getString() + ); + return; + } + } + } + } + } + } + } catch (Exception e) { + TiedUpMod.LOGGER.debug( + "[MCA AI] Could not suspend brain for {}: {}", + villager.getName().getString(), + e.getMessage() + ); + } + } + + /** + * Restore MCA's brain to normal state. + */ + private void restoreMCABrain() { + if (!mcaBrainSuspended) return; + + LivingEntity villager = villagerRef.get(); + if (villager == null) return; + + try { + Method getVillagerBrain = villager + .getClass() + .getMethod("getVillagerBrain"); + Object mcaBrain = getVillagerBrain.invoke(villager); + + if (mcaBrain != null) { + for (Method method : mcaBrain.getClass().getMethods()) { + if (method.getName().equals("setMoveState")) { + Class[] paramTypes = method.getParameterTypes(); + if (paramTypes.length >= 1 && paramTypes[0].isEnum()) { + Object[] enumConstants = + paramTypes[0].getEnumConstants(); + // Find MOVE constant + for (Object constant : enumConstants) { + if (constant.toString().equals("MOVE")) { + method.invoke(mcaBrain, constant, null); + mcaBrainSuspended = false; + TiedUpMod.LOGGER.debug( + "[MCA AI] Restored brain for {}", + villager.getName().getString() + ); + return; + } + } + } + } + } + } + } catch (Exception e) { + TiedUpMod.LOGGER.debug( + "[MCA AI] Could not restore brain for {}: {}", + villager.getName().getString(), + e.getMessage() + ); + } + + mcaBrainSuspended = false; + } + + // ======================================== + // CLEANUP + // ======================================== + + /** + * Clean up all AI modifications. + * Call when villager is freed or removed. + */ + public void cleanup() { + // Restore from current level + transitionFromLevel(currentLevel); + currentLevel = MCABondageAILevel.NONE; + + // Clear any remaining state + injectedGoals.clear(); + originalGoals = null; + mcaBrainSuspended = false; + + LivingEntity villager = villagerRef.get(); + if (villager != null) { + TiedUpMod.LOGGER.debug( + "[MCA AI] Cleaned up controller for {}", + villager.getName().getString() + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/ai/MCABondageAILevel.java b/src/main/java/com/tiedup/remake/compat/mca/ai/MCABondageAILevel.java new file mode 100644 index 0000000..3242475 --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/ai/MCABondageAILevel.java @@ -0,0 +1,90 @@ +package com.tiedup.remake.compat.mca.ai; + +/** + * Defines levels of AI control for MCA villagers during bondage. + * + * The AI system progressively restricts villager behavior based on + * their bondage state, from normal behavior to complete immobilization. + */ +public enum MCABondageAILevel { + /** + * NONE: Normal MCA behavior, no TiedUp interference. + * Used when: Not tied, no collar + */ + NONE, + + /** + * BASIC: Minimal restrictions. + * Effects: + * - Reduced movement speed (via attribute modifier) + * - Navigation stopped when first applied + * Used when: Collar only (not tied) + */ + BASIC, + + /** + * MODIFIED: Custom goals injected alongside MCA goals. + * Effects: + * - All BASIC effects + * - FollowCaptorGoal when leashed + * - PanicGoal when being restrained + * - FleeGoal when not collared + * Used when: Tied up (arms or legs bound) + */ + MODIFIED, + + /** + * OVERRIDE: Complete AI replacement. + * Effects: + * - All MODIFIED effects + * - MCA brain activities suspended + * - Only TiedUp goals active (StayGoal) + * Used when: Fully tied (arms AND legs) OR gagged AND blindfolded + */ + OVERRIDE; + + /** + * Check if this level has speed reduction applied. + */ + public boolean hasSpeedReduction() { + return this != NONE; + } + + /** + * Check if this level injects custom goals. + */ + public boolean hasCustomGoals() { + return this == MODIFIED || this == OVERRIDE; + } + + /** + * Check if this level suspends MCA's brain. + */ + public boolean suspendsMCABrain() { + return this == OVERRIDE; + } + + /** + * Get the next level up (more restrictive). + */ + public MCABondageAILevel nextLevel() { + return switch (this) { + case NONE -> BASIC; + case BASIC -> MODIFIED; + case MODIFIED -> OVERRIDE; + case OVERRIDE -> OVERRIDE; + }; + } + + /** + * Get the previous level (less restrictive). + */ + public MCABondageAILevel previousLevel() { + return switch (this) { + case NONE -> NONE; + case BASIC -> NONE; + case MODIFIED -> BASIC; + case OVERRIDE -> MODIFIED; + }; + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/ai/chatai/TiedUpModule.java b/src/main/java/com/tiedup/remake/compat/mca/ai/chatai/TiedUpModule.java new file mode 100644 index 0000000..b8b07cd --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/ai/chatai/TiedUpModule.java @@ -0,0 +1,129 @@ +package com.tiedup.remake.compat.mca.ai.chatai; + +import com.tiedup.remake.compat.mca.MCACompat; +import com.tiedup.remake.compat.mca.personality.MCAPersonalityManager; +import com.tiedup.remake.compat.mca.personality.TiedUpTrait; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.ICaptor; +import java.util.List; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; + +/** + * AI Context Module for MCA's OpenAI chat system. + * + *

Injects TiedUp bondage state into the AI context so the LLM + * can respond appropriately when a villager is restrained. + * + *

Similar to MCA's PersonalityModule, TraitsModule, etc. + * This module adds contextual information about: + *

    + *
  • Current bondage state (tied, gagged, blindfolded, collared)
  • + *
  • TiedUp trait (MASO, REBELLIOUS, BROKEN, TRAINED)
  • + *
  • Captor relationship
  • + *
+ */ +public class TiedUpModule { + + /** + * Apply TiedUp context to the AI input list. + * + * @param input The list of context strings being built + * @param villager The MCA villager entity + * @param player The player interacting with the villager + */ + public static void apply( + List input, + LivingEntity villager, + Player player + ) { + IBondageState state = MCACompat.getKidnappedState(villager); + if (state == null) return; + + // Skip if no bondage state + if ( + !state.isTiedUp() && + !state.hasCollar() && + !state.isGagged() && + !state.isBlindfolded() + ) { + return; + } + + // Current bondage state + if (state.isTiedUp()) { + input.add("$villager is currently tied up and restrained. "); + + if (state.hasArmsBound() && state.hasLegsBound()) { + input.add( + "$villager cannot move freely - both arms and legs are bound. " + ); + } else if (state.hasArmsBound()) { + input.add("$villager's arms are bound behind their back. "); + } else if (state.hasLegsBound()) { + input.add("$villager's legs are bound together. "); + } + } + + if (state.isGagged()) { + input.add( + "$villager is gagged and cannot speak clearly - only muffled sounds come out. " + ); + } + + if (state.isBlindfolded()) { + input.add( + "$villager is blindfolded and cannot see anything around them. " + ); + } + + if (state.hasCollar()) { + input.add( + "$villager is wearing a collar, marking them as someone's property. " + ); + } + + // TiedUp Trait affects personality/behavior + TiedUpTrait trait = MCAPersonalityManager.getInstance().getTrait( + villager + ); + switch (trait) { + case MASO -> input.add( + "$villager secretly enjoys being restrained and may respond positively to bondage. " + ); + case REBELLIOUS -> input.add( + "$villager is extremely rebellious and defiant - they will never willingly submit. " + ); + case BROKEN -> input.add( + "$villager has been broken psychologically and shows no resistance, responding with empty compliance. " + ); + case TRAINED -> input.add( + "$villager has been trained to be obedient and accepts their situation calmly. " + ); + default -> { + // NONE - no special trait context + } + } + + // Captor relationship + if (state.isCaptive()) { + ICaptor captor = state.getCaptor(); + if (captor != null && player != null) { + // Use getEntity() - returns Entity, not LivingEntity + var captorEntity = captor.getEntity(); + if ( + captorEntity != null && + captorEntity.getUUID().equals(player.getUUID()) + ) { + input.add( + "$player is $villager's captor who tied them up. " + ); + } else { + input.add("$villager has been captured by someone else. "); + } + } else { + input.add("$villager has been captured by someone else. "); + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/ai/goals/MCAFleeGoal.java b/src/main/java/com/tiedup/remake/compat/mca/ai/goals/MCAFleeGoal.java new file mode 100644 index 0000000..6b4d1ec --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/ai/goals/MCAFleeGoal.java @@ -0,0 +1,200 @@ +package com.tiedup.remake.compat.mca.ai.goals; + +import com.tiedup.remake.compat.mca.MCABondageManager; +import com.tiedup.remake.state.IBondageState; +import java.util.EnumSet; +import java.util.List; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.PathfinderMob; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.ai.util.DefaultRandomPos; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; + +/** + * AI Goal: MCA villager flees from entities of a specific class. + * + * Used when villager is tied but not collared (tries to escape). + * Flees from players within detection range. + */ +public class MCAFleeGoal extends Goal { + + private final PathfinderMob mob; + private final Class fleeFromClass; + private final float maxDistance; + private final double walkSpeedModifier; + private final double sprintSpeedModifier; + private LivingEntity fleeTarget; + private int pathRecalcDelay; + + /** How often to recalculate path (in ticks) */ + private static final int PATH_RECALC_INTERVAL = 10; + + /** + * Create flee goal. + * + * @param mob The MCA villager mob (must be PathfinderMob) + * @param fleeFromClass Class of entities to flee from + * @param maxDistance Detection range + * @param walkSpeed Walking speed multiplier + * @param sprintSpeed Sprint speed multiplier (when threat is close) + */ + public MCAFleeGoal( + PathfinderMob mob, + Class fleeFromClass, + float maxDistance, + double walkSpeed, + double sprintSpeed + ) { + this.mob = mob; + this.fleeFromClass = fleeFromClass; + this.maxDistance = maxDistance; + this.walkSpeedModifier = walkSpeed; + this.sprintSpeedModifier = sprintSpeed; + this.setFlags(EnumSet.of(Goal.Flag.MOVE)); + } + + @Override + public boolean canUse() { + // Only flee if NOT collared (collared villagers obey) + IBondageState state = MCABondageManager.getInstance().getKidnappedState( + mob + ); + if (state == null) return false; + if (state.hasCollar()) return false; + + // Find nearest threat + this.fleeTarget = findNearestThreat(); + if (fleeTarget == null) return false; + + // Can we find a path away? + Vec3 fleePos = DefaultRandomPos.getPosAway( + mob, + 16, // Range + 7, // Y variance + fleeTarget.position() + ); + + return fleePos != null; + } + + /** + * Find the nearest entity to flee from. + */ + private LivingEntity findNearestThreat() { + AABB searchBox = mob.getBoundingBox().inflate(maxDistance); + + List threats = mob + .level() + .getEntitiesOfClass( + fleeFromClass, + searchBox, + entity -> + entity != mob && + entity.isAlive() && + mob.distanceTo(entity) <= maxDistance + ); + + if (threats.isEmpty()) return null; + + // Return closest + LivingEntity closest = null; + double closestDist = Double.MAX_VALUE; + for (LivingEntity entity : threats) { + double dist = mob.distanceToSqr(entity); + if (dist < closestDist) { + closestDist = dist; + closest = entity; + } + } + return closest; + } + + @Override + public boolean canContinueToUse() { + // Stop if got collared + IBondageState state = MCABondageManager.getInstance().getKidnappedState( + mob + ); + if (state != null && state.hasCollar()) return false; + + // Stop if target gone + if (fleeTarget == null || !fleeTarget.isAlive()) return false; + + // Continue as long as threat is within range + return fleeTarget.distanceToSqr(mob) < (maxDistance * maxDistance); + } + + @Override + public void start() { + pathRecalcDelay = 0; + navigateAway(); + } + + @Override + public void stop() { + this.fleeTarget = null; + mob.getNavigation().stop(); + } + + @Override + public void tick() { + if (fleeTarget == null) return; + + // Recalculate path periodically + if (--pathRecalcDelay <= 0) { + pathRecalcDelay = PATH_RECALC_INTERVAL; + + // Update threat target + LivingEntity newThreat = findNearestThreat(); + if (newThreat != null) { + this.fleeTarget = newThreat; + } + + navigateAway(); + } + + // Sprint if threat is very close + double distSqr = mob.distanceToSqr(fleeTarget); + double sprintThreshold = 4.0 * 4.0; + + if (distSqr < sprintThreshold) { + mob.getNavigation().setSpeedModifier(sprintSpeedModifier); + } else { + mob.getNavigation().setSpeedModifier(walkSpeedModifier); + } + } + + /** + * Navigate away from the current threat. + */ + private void navigateAway() { + Vec3 fleePos = DefaultRandomPos.getPosAway( + mob, + 16, + 7, + fleeTarget.position() + ); + + if (fleePos != null) { + mob + .getNavigation() + .moveTo(fleePos.x, fleePos.y, fleePos.z, walkSpeedModifier); + } else { + // Fallback: move directly away + Vec3 awayDir = mob + .position() + .subtract(fleeTarget.position()) + .normalize(); + Vec3 fallbackPos = mob.position().add(awayDir.scale(8)); + mob + .getNavigation() + .moveTo( + fallbackPos.x, + mob.getY(), + fallbackPos.z, + walkSpeedModifier + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/ai/goals/MCAFollowCaptorGoal.java b/src/main/java/com/tiedup/remake/compat/mca/ai/goals/MCAFollowCaptorGoal.java new file mode 100644 index 0000000..f6fe96b --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/ai/goals/MCAFollowCaptorGoal.java @@ -0,0 +1,115 @@ +package com.tiedup.remake.compat.mca.ai.goals; + +import java.util.EnumSet; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.ai.goal.Goal; + +/** + * AI Goal: MCA villager follows the entity holding their leash. + * + * Similar to vanilla's FollowOwnerGoal but for leash holders. + * Active when villager is leashed to a captor. + */ +public class MCAFollowCaptorGoal extends Goal { + + private final Mob mob; + private final double speedModifier; + private final float stopDistance; + private final float startDistance; + private LivingEntity captor; + private int timeToRecalcPath; + + /** How often to recalculate path (in ticks) */ + private static final int PATH_RECALC_INTERVAL = 10; + + /** + * Create follow captor goal. + * + * @param mob The MCA villager mob + * @param speedModifier Speed multiplier when following + * @param startDistance Distance at which to start following + * @param stopDistance Distance at which to stop following + */ + public MCAFollowCaptorGoal( + Mob mob, + double speedModifier, + float startDistance, + float stopDistance + ) { + this.mob = mob; + this.speedModifier = speedModifier; + this.startDistance = startDistance; + this.stopDistance = stopDistance; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (!mob.isLeashed()) return false; + + Entity holder = mob.getLeashHolder(); + if (!(holder instanceof LivingEntity living)) return false; + + // Don't follow if too close already + double distance = mob.distanceToSqr(holder); + if (distance < startDistance * startDistance) return false; + + this.captor = living; + return true; + } + + @Override + public boolean canContinueToUse() { + if (!mob.isLeashed()) return false; + if (captor == null || !captor.isAlive()) return false; + + // Stop following if close enough + double distance = mob.distanceToSqr(captor); + return distance > stopDistance * stopDistance; + } + + @Override + public void start() { + this.timeToRecalcPath = 0; + mob.getNavigation().moveTo(captor, speedModifier); + } + + @Override + public void stop() { + this.captor = null; + mob.getNavigation().stop(); + } + + @Override + public void tick() { + // Look at captor + mob + .getLookControl() + .setLookAt(captor, 10.0f, (float) mob.getMaxHeadXRot()); + + // Recalculate path periodically + if (--timeToRecalcPath <= 0) { + timeToRecalcPath = PATH_RECALC_INTERVAL; + + // Navigate towards captor + if (!mob.getNavigation().moveTo(captor, speedModifier)) { + // If can't find path, try teleporting if very far + double distance = mob.distanceToSqr(captor); + if (distance > 256) { + // 16 blocks squared + // Teleport near captor + double x = + captor.getX() + + (mob.getRandom().nextDouble() - 0.5) * 2; + double y = captor.getY(); + double z = + captor.getZ() + + (mob.getRandom().nextDouble() - 0.5) * 2; + mob.teleportTo(x, y, z); + } + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/ai/goals/MCAPanicGoal.java b/src/main/java/com/tiedup/remake/compat/mca/ai/goals/MCAPanicGoal.java new file mode 100644 index 0000000..2f222c3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/ai/goals/MCAPanicGoal.java @@ -0,0 +1,106 @@ +package com.tiedup.remake.compat.mca.ai.goals; + +import com.tiedup.remake.compat.mca.MCABondageManager; +import com.tiedup.remake.state.IBondageState; +import net.minecraft.world.entity.PathfinderMob; +import net.minecraft.world.entity.ai.goal.PanicGoal; + +/** + * AI Goal: MCA villager panics when hurt or restrained. + * + * Extends vanilla PanicGoal with bondage-aware conditions. + * Active when villager is being tied (but can still move). + */ +public class MCAPanicGoal extends PanicGoal { + + private final PathfinderMob mob; + + /** Ticks since last panic trigger (for brief panic after state changes) */ + private int panicCooldown = 0; + + /** How long panic lasts after trigger (in ticks) */ + private static final int PANIC_DURATION = 60; // 3 seconds + + /** + * Create panic goal. + * + * @param mob The MCA villager mob (must be PathfinderMob) + * @param speedModifier Speed multiplier when panicking + */ + public MCAPanicGoal(PathfinderMob mob, double speedModifier) { + super(mob, speedModifier); + this.mob = mob; + } + + /** + * Trigger panic for a short duration. + * Call this when something scary happens (being tied, etc.) + */ + public void triggerPanic() { + this.panicCooldown = PANIC_DURATION; + } + + @Override + public boolean canUse() { + // Check bondage state + IBondageState state = MCABondageManager.getInstance().getKidnappedState( + mob + ); + if (state == null) return false; + + // Can't panic if fully tied (can't move) + if (state.hasArmsBound() && state.hasLegsBound()) { + return false; + } + + // Can't panic if collared (obedient) + if (state.hasCollar()) { + return false; + } + + // Check for recent damage OR active panic cooldown + if (panicCooldown > 0) { + panicCooldown--; + return true; + } + + return super.canUse(); + } + + @Override + public boolean canContinueToUse() { + // Check bondage state + IBondageState state = MCABondageManager.getInstance().getKidnappedState( + mob + ); + if (state == null) return false; + + // Stop if fully tied or collared + if (state.hasArmsBound() && state.hasLegsBound()) { + return false; + } + if (state.hasCollar()) { + return false; + } + + // Continue if cooldown active OR parent says so + if (panicCooldown > 0) { + panicCooldown--; + return true; + } + + return super.canContinueToUse(); + } + + @Override + public void start() { + super.start(); + // Could add sound/particle effects here + } + + @Override + public void stop() { + super.stop(); + panicCooldown = 0; + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/ai/goals/MCAStayGoal.java b/src/main/java/com/tiedup/remake/compat/mca/ai/goals/MCAStayGoal.java new file mode 100644 index 0000000..22ecde3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/ai/goals/MCAStayGoal.java @@ -0,0 +1,67 @@ +package com.tiedup.remake.compat.mca.ai.goals; + +import com.tiedup.remake.compat.mca.MCABondageManager; +import com.tiedup.remake.state.IBondageState; +import java.util.EnumSet; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.ai.goal.Goal; + +/** + * AI Goal: MCA villager stays in place when fully restrained. + * + * Prevents all movement, only allows looking around. + * Used in OVERRIDE AI level when villager is fully tied. + */ +public class MCAStayGoal extends Goal { + + private final Mob mob; + + /** + * Create stay goal. + * + * @param mob The MCA villager mob + */ + public MCAStayGoal(Mob mob) { + this.mob = mob; + // Lock movement and jumping + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.JUMP)); + } + + @Override + public boolean canUse() { + IBondageState state = MCABondageManager.getInstance().getKidnappedState( + mob + ); + if (state == null) return false; + + // Active when tied up (arms and/or legs) + return state.isTiedUp(); + } + + @Override + public boolean canContinueToUse() { + return canUse(); + } + + @Override + public void start() { + // Stop all navigation immediately + mob.getNavigation().stop(); + } + + @Override + public void tick() { + // Keep stopping navigation in case something tries to move + if (!mob.getNavigation().isDone()) { + mob.getNavigation().stop(); + } + + // Zero out any velocity + mob.setDeltaMovement(mob.getDeltaMovement().multiply(0, 1, 0)); + } + + @Override + public void stop() { + // Nothing special on stop + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/capability/MCAKidnappedAdapter.java b/src/main/java/com/tiedup/remake/compat/mca/capability/MCAKidnappedAdapter.java new file mode 100644 index 0000000..3158dfe --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/capability/MCAKidnappedAdapter.java @@ -0,0 +1,928 @@ +package com.tiedup.remake.compat.mca.capability; + +import com.tiedup.remake.compat.mca.MCABondageManager; +import com.tiedup.remake.compat.mca.MCACompat; +import com.tiedup.remake.items.base.IHasBlindingEffect; +import com.tiedup.remake.items.base.IHasGaggingEffect; +import com.tiedup.remake.items.base.IHasResistance; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.state.IRestrainableEntity; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import com.tiedup.remake.state.ICaptor; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.util.tasks.ItemTask; +import com.tiedup.remake.util.teleport.Position; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * Adapter that implements IRestrainable for MCA villagers. + * + * Wraps an MCA villager entity and delegates bondage state + * to MCAKidnappedCapability. Uses MCABondageManager for + * lifecycle events and sync operations. + */ +public class MCAKidnappedAdapter implements IRestrainable { + + private final LivingEntity entity; + private final MCAKidnappedCapability cap; + + public MCAKidnappedAdapter( + LivingEntity entity, + MCAKidnappedCapability cap + ) { + this.entity = entity; + this.cap = cap; + } + + // ======================================== + // 1. CAPTURE LIFECYCLE + // ======================================== + + @Override + public boolean getCapturedBy(ICaptor captor) { + if (!isEnslavable()) { + return false; + } + + // Notify captor (CRITICAL: this updates captor's state so it stops hunting) + captor.addCaptive(this); + + // Set captor UUID + Entity captorEntity = captor.getEntity(); + if (captorEntity != null) { + cap.setCaptorUUID(captorEntity.getUUID()); + } + + // For MCA villagers (which are Mobs), use vanilla leash + if (entity instanceof Mob mob && captorEntity != null) { + mob.setLeashedTo(captorEntity, true); + } + + // Notify manager + MCABondageManager.getInstance().onVillagerCaptured( + entity, + captorEntity + ); + + return true; + } + + @Override + public void free() { + free(true); + } + + @Override + public void free(boolean transportState) { + // Notify captor first, before clearing UUID + ICaptor captor = getCaptor(); + if (captor != null) { + captor.removeCaptive(this, transportState); + } + + cap.setCaptorUUID(null); + + // Remove vanilla leash + if (entity instanceof Mob mob) { + mob.dropLeash(true, transportState); + } + + // Notify manager + MCABondageManager.getInstance().onVillagerFreed(entity); + } + + @Override + public void transferCaptivityTo(ICaptor newCaptor) { + ICaptor currentCaptor = getCaptor(); + if (currentCaptor != null && !currentCaptor.allowCaptiveTransfer()) { + return; + } + + free(false); + getCapturedBy(newCaptor); + } + + // ======================================== + // 2. STATE QUERIES - CAPTURE + // ======================================== + + @Override + public boolean isEnslavable() { + // Can be captured if tied up OR has collar + return isTiedUp() || hasCollar(); + } + + @Override + public boolean isCaptive() { + // Check if entity is leashed (vanilla leash for Mobs) + if (entity instanceof Mob mob) { + return mob.isLeashed(); + } + return cap.isCaptured(); + } + + @Override + public boolean canBeTiedUp() { + return !isTiedUp(); + } + + @Override + public boolean isTiedToPole() { + // MCA villagers can be tied to poles via vanilla leash to fence + if (entity instanceof Mob mob && mob.isLeashed()) { + Entity holder = mob.getLeashHolder(); + return holder != null && !(holder instanceof LivingEntity); + } + return false; + } + + @Override + public boolean tieToClosestPole(int searchRadius) { + // MCA villagers use vanilla leash mechanics + // This would need to find a fence and attach leash + // For now, return false (not implemented for MCA) + return false; + } + + @Override + public boolean isForSell() { + return cap.isForSale(); + } + + @Override + @Nullable + public ItemTask getSalePrice() { + return cap.getSalePrice(); + } + + @Override + public void putForSale(ItemTask price) { + cap.setForSale(true); + cap.setSalePrice(price); + } + + @Override + public void cancelSale() { + cap.setForSale(false); + cap.setSalePrice(null); + } + + @Override + public boolean canBeKidnappedByEvents() { + // MCA villagers can be kidnapped by events + return true; + } + + @Override + @Nullable + public ICaptor getCaptor() { + UUID captorUUID = cap.getCaptorUUID(); + if (captorUUID == null) { + return null; + } + + // Try to find captor entity in world + if (entity.level() instanceof ServerLevel serverLevel) { + Entity captorEntity = serverLevel.getEntity(captorUUID); + if (captorEntity instanceof ICaptor kidnapper) { + return kidnapper; + } + // Players need special handling via PlayerCaptorManager + if (captorEntity instanceof Player player) { + return PlayerBindState.getInstance(player).getCaptorManager(); + } + } + return null; + } + + @Override + @Nullable + public Entity getTransport() { + // MCA villagers use vanilla leash directly, no proxy entity + return null; + } + + // ======================================== + // 3. STATE QUERIES - BONDAGE EQUIPMENT + // ======================================== + + @Override + public boolean isTiedUp() { + return cap.hasBind(); + } + + @Override + public boolean isGagged() { + return cap.hasGag(); + } + + @Override + public boolean isBlindfolded() { + return cap.hasBlindfold(); + } + + @Override + public boolean hasEarplugs() { + return cap.hasEarplugs(); + } + + @Override + public boolean hasCollar() { + return cap.hasCollar(); + } + + @Override + public boolean hasLockedCollar() { + ItemStack collar = cap.getCollar(); + return ( + !collar.isEmpty() && + collar.getItem() instanceof ILockable lockable && + lockable.isLocked(collar) + ); + } + + @Override + public boolean hasNamedCollar() { + ItemStack collar = cap.getCollar(); + return !collar.isEmpty() && collar.hasCustomHoverName(); + } + + @Override + public boolean hasClothes() { + return cap.hasClothes(); + } + + @Override + public boolean hasMittens() { + return cap.hasMittens(); + } + + @Override + public boolean hasClothesWithSmallArms() { + // MCA villagers use their own model, not relevant here + return false; + } + + @Override + public boolean isBoundAndGagged() { + return isTiedUp() && isGagged(); + } + + @Override + public boolean hasGaggingEffect() { + ItemStack gag = cap.getGag(); + return !gag.isEmpty() && gag.getItem() instanceof IHasGaggingEffect; + } + + @Override + public boolean hasBlindingEffect() { + ItemStack blindfold = cap.getBlindfold(); + return ( + !blindfold.isEmpty() && + blindfold.getItem() instanceof IHasBlindingEffect + ); + } + + @Override + public boolean hasKnives() { + // MCA villagers don't have knife inventory by default + return false; + } + + // ======================================== + // 4. EQUIPMENT MANAGEMENT - PUT ON + // ======================================== + + public void putBindOn(ItemStack bind) { + cap.setBind(bind); + + // Set initial resistance from item + if (bind.getItem() instanceof IHasResistance resistance) { + cap.setBindResistance(resistance.getBaseResistance(entity)); + } + + // Notify manager (handles AI changes) + MCABondageManager.getInstance().onVillagerTied(entity, bind); + + checkBindAfterApply(); + } + + public void putGagOn(ItemStack gag) { + cap.setGag(gag); + MCABondageManager.getInstance().onSensoryRestrictionChanged(entity); + checkGagAfterApply(); + } + + public void putBlindfoldOn(ItemStack blindfold) { + cap.setBlindfold(blindfold); + MCABondageManager.getInstance().onSensoryRestrictionChanged(entity); + checkBlindfoldAfterApply(); + } + + public void putEarplugsOn(ItemStack earplugs) { + cap.setEarplugs(earplugs); + checkEarplugsAfterApply(); + } + + public void putCollarOn(ItemStack collar) { + cap.setCollar(collar); + + if (collar.getItem() instanceof IHasResistance resistance) { + cap.setCollarResistance(resistance.getBaseResistance(entity)); + } + + MCABondageManager.getInstance().onCollarChanged(entity, true); + checkCollarAfterApply(); + } + + public void putClothesOn(ItemStack clothes) { + cap.setClothes(clothes); + syncToClients(); + } + + public void putMittensOn(ItemStack mittens) { + cap.setMittens(mittens); + checkMittensAfterApply(); + } + + // ======================================== + // 5. EQUIPMENT MANAGEMENT - UNEQUIP (V2) + // ======================================== + + @Override + public ItemStack unequip(BodyRegionV2 region) { + return switch (region) { + case ARMS -> takeBindOff(); + case MOUTH -> takeGagOff(); + case EYES -> takeBlindfoldOff(); + case EARS -> takeEarplugsOff(); + case NECK -> takeCollarOff(false); + case TORSO -> takeClothesOff(); + case HANDS -> takeMittensOff(); + default -> ItemStack.EMPTY; + }; + } + + @Override + public ItemStack forceUnequip(BodyRegionV2 region) { + // Force-remove: same logic as takeXOff but bypasses isLocked checks. + // NECK and TORSO already have correct handling. See RISK-001. + return switch (region) { + case NECK -> takeCollarOff(true); + case TORSO -> takeClothesOff(); + case ARMS -> { + ItemStack current = cap.getBind(); + if (current.isEmpty()) yield ItemStack.EMPTY; + cap.setBind(ItemStack.EMPTY); + cap.setBindResistance(0); + MCABondageManager.getInstance().onVillagerUntied(entity); + syncToClients(); + yield current; + } + case MOUTH -> { + ItemStack current = cap.getGag(); + if (current.isEmpty()) yield ItemStack.EMPTY; + cap.setGag(ItemStack.EMPTY); + MCABondageManager.getInstance().onSensoryRestrictionChanged(entity); + syncToClients(); + yield current; + } + case EYES -> { + ItemStack current = cap.getBlindfold(); + if (current.isEmpty()) yield ItemStack.EMPTY; + cap.setBlindfold(ItemStack.EMPTY); + MCABondageManager.getInstance().onSensoryRestrictionChanged(entity); + syncToClients(); + yield current; + } + case EARS -> { + ItemStack current = cap.getEarplugs(); + if (current.isEmpty()) yield ItemStack.EMPTY; + cap.setEarplugs(ItemStack.EMPTY); + syncToClients(); + yield current; + } + case HANDS -> { + ItemStack current = cap.getMittens(); + if (current.isEmpty()) yield ItemStack.EMPTY; + cap.setMittens(ItemStack.EMPTY); + syncToClients(); + yield current; + } + default -> ItemStack.EMPTY; + }; + } + + // ======================================== + // 5b. EQUIPMENT MANAGEMENT - TAKE OFF (local helpers) + // ======================================== + + public ItemStack takeBindOff() { + ItemStack current = cap.getBind(); + if (isLocked(current, false)) { + return ItemStack.EMPTY; + } + cap.setBind(ItemStack.EMPTY); + cap.setBindResistance(0); + + // Notify manager (handles AI changes) + MCABondageManager.getInstance().onVillagerUntied(entity); + + syncToClients(); + return current; + } + + public ItemStack takeGagOff() { + ItemStack current = cap.getGag(); + if (isLocked(current, false)) { + return ItemStack.EMPTY; + } + cap.setGag(ItemStack.EMPTY); + MCABondageManager.getInstance().onSensoryRestrictionChanged(entity); + syncToClients(); + return current; + } + + public ItemStack takeBlindfoldOff() { + ItemStack current = cap.getBlindfold(); + if (isLocked(current, false)) { + return ItemStack.EMPTY; + } + cap.setBlindfold(ItemStack.EMPTY); + MCABondageManager.getInstance().onSensoryRestrictionChanged(entity); + syncToClients(); + return current; + } + + public ItemStack takeEarplugsOff() { + ItemStack current = cap.getEarplugs(); + if (isLocked(current, false)) { + return ItemStack.EMPTY; + } + cap.setEarplugs(ItemStack.EMPTY); + syncToClients(); + return current; + } + + public ItemStack takeCollarOff() { + return takeCollarOff(false); + } + + public ItemStack takeCollarOff(boolean force) { + ItemStack current = cap.getCollar(); + if (!force && isLocked(current, false)) { + return ItemStack.EMPTY; + } + cap.setCollar(ItemStack.EMPTY); + cap.setCollarResistance(0); + MCABondageManager.getInstance().onCollarChanged(entity, false); + syncToClients(); + return current; + } + + public ItemStack takeClothesOff() { + ItemStack current = cap.getClothes(); + cap.setClothes(ItemStack.EMPTY); + syncToClients(); + return current; + } + + public ItemStack takeMittensOff() { + ItemStack current = cap.getMittens(); + if (isLocked(current, false)) { + return ItemStack.EMPTY; + } + cap.setMittens(ItemStack.EMPTY); + syncToClients(); + return current; + } + + // ======================================== + // V2 Region-Based Equipment Access + // ======================================== + + @Override + public ItemStack getEquipment(BodyRegionV2 region) { + return switch (region) { + case ARMS -> cap.getBind(); + case MOUTH -> cap.getGag(); + case EYES -> cap.getBlindfold(); + case EARS -> cap.getEarplugs(); + case NECK -> cap.getCollar(); + case TORSO -> cap.getClothes(); + case HANDS -> cap.getMittens(); + default -> ItemStack.EMPTY; + }; + } + + @Override + public void equip(BodyRegionV2 region, ItemStack stack) { + switch (region) { + case ARMS -> putBindOn(stack); + case MOUTH -> putGagOn(stack); + case EYES -> putBlindfoldOn(stack); + case EARS -> putEarplugsOn(stack); + case NECK -> putCollarOn(stack); + case TORSO -> putClothesOn(stack); + case HANDS -> putMittensOn(stack); + default -> {} + } + } + + // ======================================== + // 7. EQUIPMENT MANAGEMENT - REPLACE (V2 region-based) + // ======================================== + + @Override + public ItemStack replaceEquipment(BodyRegionV2 region, ItemStack newStack, boolean force) { + return switch (region) { + case ARMS -> { + ItemStack old = cap.getBind(); + if (!force && isLocked(old, false)) yield ItemStack.EMPTY; + cap.setBind(newStack); + if (newStack.getItem() instanceof IHasResistance resistance) { + cap.setBindResistance(resistance.getBaseResistance(entity)); + } + yield old; + } + case MOUTH -> { + ItemStack old = cap.getGag(); + if (!force && isLocked(old, false)) yield ItemStack.EMPTY; + cap.setGag(newStack); + yield old; + } + case EYES -> { + ItemStack old = cap.getBlindfold(); + if (!force && isLocked(old, false)) yield ItemStack.EMPTY; + cap.setBlindfold(newStack); + yield old; + } + case EARS -> { + ItemStack old = cap.getEarplugs(); + if (!force && isLocked(old, false)) yield ItemStack.EMPTY; + cap.setEarplugs(newStack); + yield old; + } + case NECK -> { + ItemStack old = cap.getCollar(); + if (!force && isLocked(old, false)) yield ItemStack.EMPTY; + cap.setCollar(newStack); + if (newStack.getItem() instanceof IHasResistance resistance) { + cap.setCollarResistance(resistance.getBaseResistance(entity)); + } + yield old; + } + case TORSO -> { + ItemStack old = cap.getClothes(); + cap.setClothes(newStack); + yield old; + } + case HANDS -> { + ItemStack old = cap.getMittens(); + if (!force && isLocked(old, false)) yield ItemStack.EMPTY; + cap.setMittens(newStack); + yield old; + } + default -> ItemStack.EMPTY; + }; + } + + // ======================================== + // 8. BULK OPERATIONS + // ======================================== + + @Override + public void applyBondage( + ItemStack bind, + ItemStack gag, + ItemStack blindfold, + ItemStack earplugs, + ItemStack collar, + ItemStack clothes + ) { + if (!bind.isEmpty()) putBindOn(bind); + if (!gag.isEmpty()) putGagOn(gag); + if (!blindfold.isEmpty()) putBlindfoldOn(blindfold); + if (!earplugs.isEmpty()) putEarplugsOn(earplugs); + if (!collar.isEmpty()) putCollarOn(collar); + if (!clothes.isEmpty()) putClothesOn(clothes); + } + + @Override + public void untie(boolean drop) { + dropBondageItems(drop); + free(); + } + + @Override + public void dropBondageItems(boolean drop) { + dropBondageItems(drop, true, true, true, true, true, true); + } + + @Override + public void dropBondageItems(boolean drop, boolean dropBind) { + dropBondageItems(drop, dropBind, true, true, true, true, true); + } + + @Override + public void dropBondageItems( + boolean drop, + boolean dropBind, + boolean dropGag, + boolean dropBlindfold, + boolean dropEarplugs, + boolean dropCollar, + boolean dropClothes + ) { + if (!drop) return; + + if (dropBind && !isLocked(cap.getBind(), false)) { + kidnappedDropItem(takeBindOff()); + } + if (dropGag && !isLocked(cap.getGag(), false)) { + kidnappedDropItem(takeGagOff()); + } + if (dropBlindfold && !isLocked(cap.getBlindfold(), false)) { + kidnappedDropItem(takeBlindfoldOff()); + } + if (dropEarplugs && !isLocked(cap.getEarplugs(), false)) { + kidnappedDropItem(takeEarplugsOff()); + } + if (dropCollar && !isLocked(cap.getCollar(), false)) { + kidnappedDropItem(takeCollarOff()); + } + if (dropClothes) { + kidnappedDropItem(takeClothesOff()); + } + } + + @Override + public void dropClothes() { + kidnappedDropItem(takeClothesOff()); + } + + @Override + public int getBondageItemsWhichCanBeRemovedCount() { + int count = 0; + if ( + !cap.getBind().isEmpty() && !isLocked(cap.getBind(), false) + ) count++; + if (!cap.getGag().isEmpty() && !isLocked(cap.getGag(), false)) count++; + if ( + !cap.getBlindfold().isEmpty() && + !isLocked(cap.getBlindfold(), false) + ) count++; + if ( + !cap.getEarplugs().isEmpty() && !isLocked(cap.getEarplugs(), false) + ) count++; + if ( + !cap.getCollar().isEmpty() && !isLocked(cap.getCollar(), false) + ) count++; + if (!cap.getClothes().isEmpty()) count++; + return count; + } + + // ======================================== + // 9. CLOTHES PERMISSION SYSTEM + // ======================================== + + @Override + public boolean canTakeOffClothes(Player player) { + // MCA villagers allow anyone to remove clothes + return true; + } + + @Override + public boolean canChangeClothes(Player player) { + return true; + } + + @Override + public boolean canChangeClothes() { + return true; + } + + // ======================================== + // 10. SPECIAL INTERACTIONS + // ======================================== + + @Override + public void tighten(Player tightener) { + // Reset bind resistance to maximum + ItemStack bind = cap.getBind(); + if ( + !bind.isEmpty() && + bind.getItem() instanceof IHasResistance resistance + ) { + cap.setBindResistance(resistance.getBaseResistance(entity)); + } + } + + @Override + public void applyChloroform(int duration) { + // Apply slowness and weakness effects + entity.addEffect( + new net.minecraft.world.effect.MobEffectInstance( + net.minecraft.world.effect.MobEffects.MOVEMENT_SLOWDOWN, + duration, + 4 // Strong slowness + ) + ); + entity.addEffect( + new net.minecraft.world.effect.MobEffectInstance( + net.minecraft.world.effect.MobEffects.WEAKNESS, + duration, + 1 + ) + ); + } + + @Override + public void shockKidnapped() { + shockKidnapped("", 1.0F); + } + + @Override + public void shockKidnapped(String messageAddon, float damage) { + entity.hurt(entity.damageSources().magic(), damage); + // Could play shock sound here + } + + @Override + public void takeBondageItemBy(IRestrainableEntity taker, int slotIndex) { + ItemStack taken = switch (slotIndex) { + case 0 -> takeBindOff(); + case 1 -> takeGagOff(); + case 2 -> takeBlindfoldOff(); + case 3 -> takeEarplugsOff(); + case 4 -> takeCollarOff(); + case 5 -> takeClothesOff(); + default -> ItemStack.EMPTY; + }; + + if (!taken.isEmpty()) { + taker.kidnappedDropItem(taken); + } + } + + // ======================================== + // 11. POST-APPLY CALLBACKS + // ======================================== + + /** + * Sync bondage state to tracking clients. + * Delegates to MCABondageManager. + */ + private void syncToClients() { + MCABondageManager.getInstance().syncBondageState(entity); + } + + @Override + public void checkBindAfterApply() { + syncToClients(); + } + + @Override + public void checkGagAfterApply() { + syncToClients(); + } + + @Override + public void checkBlindfoldAfterApply() { + syncToClients(); + } + + @Override + public void checkEarplugsAfterApply() { + syncToClients(); + } + + @Override + public void checkCollarAfterApply() { + syncToClients(); + } + + @Override + public void checkMittensAfterApply() { + syncToClients(); + } + + // ======================================== + // 12. DEATH & LIFECYCLE + // ======================================== + + @Override + public boolean onDeathKidnapped(Level world) { + // Drop all bondage items on death + dropBondageItems(true); + free(); + + // Cleanup manager state + MCABondageManager.getInstance().removeVillager(entity); + + return true; + } + + // ======================================== + // 13. UTILITY & METADATA + // ======================================== + + @Override + public UUID getKidnappedUniqueId() { + return entity.getUUID(); + } + + @Override + public String getKidnappedName() { + return entity.getName().getString(); + } + + @Override + public String getNameFromCollar() { + ItemStack collar = cap.getCollar(); + if (!collar.isEmpty() && collar.hasCustomHoverName()) { + return collar.getHoverName().getString(); + } + return getKidnappedName(); + } + + @Override + public void kidnappedDropItem(ItemStack stack) { + if (!stack.isEmpty() && !entity.level().isClientSide) { + ItemEntity itemEntity = new ItemEntity( + entity.level(), + entity.getX(), + entity.getY() + 0.5, + entity.getZ(), + stack + ); + itemEntity.setDefaultPickUpDelay(); + entity.level().addFreshEntity(itemEntity); + } + } + + @Override + public void teleportToPosition(Position position) { + if ( + entity instanceof + net.minecraft.server.level.ServerPlayer serverPlayer + ) { + serverPlayer.teleportTo( + position.getX(), + position.getY(), + position.getZ() + ); + } else if (entity.level() instanceof ServerLevel) { + entity.teleportTo( + position.getX(), + position.getY(), + position.getZ() + ); + } + } + + // ======================================== + // 14.5. RESISTANCE SYSTEM + // ======================================== + + @Override + public int getCurrentBindResistance() { + return cap.getBindResistance(); + } + + @Override + public void setCurrentBindResistance(int resistance) { + cap.setBindResistance(resistance); + } + + @Override + public int getCurrentCollarResistance() { + return cap.getCollarResistance(); + } + + @Override + public void setCurrentCollarResistance(int resistance) { + cap.setCollarResistance(resistance); + } + + // ======================================== + // 15. ENTITY REFERENCE + // ======================================== + + @Override + public LivingEntity asLivingEntity() { + return entity; + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/capability/MCAKidnappedCapability.java b/src/main/java/com/tiedup/remake/compat/mca/capability/MCAKidnappedCapability.java new file mode 100644 index 0000000..7228145 --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/capability/MCAKidnappedCapability.java @@ -0,0 +1,405 @@ +package com.tiedup.remake.compat.mca.capability; + +import com.tiedup.remake.compat.mca.personality.TiedUpTrait; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.util.tasks.ItemTask; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.common.util.INBTSerializable; + +/** + * Capability that stores bondage state for MCA villagers. + * + * Stores: + * - 7 bondage equipment slots (bind, gag, blindfold, earplugs, collar, clothes, mittens) + * - Captor UUID + * - For sale state and price + * - Resistance values for struggle system + */ +public class MCAKidnappedCapability implements INBTSerializable { + + // ======================================== + // BONDAGE EQUIPMENT SLOTS + // ======================================== + + private ItemStack bind = ItemStack.EMPTY; + private ItemStack gag = ItemStack.EMPTY; + private ItemStack blindfold = ItemStack.EMPTY; + private ItemStack earplugs = ItemStack.EMPTY; + private ItemStack collar = ItemStack.EMPTY; + private ItemStack clothes = ItemStack.EMPTY; + private ItemStack mittens = ItemStack.EMPTY; + + // ======================================== + // CAPTURE STATE + // ======================================== + + /** UUID of the captor entity (null if not captured) */ + @Nullable + private UUID captorUUID = null; + + /** Whether this villager is for sale */ + private boolean forSale = false; + + /** Sale price (null if not for sale) */ + @Nullable + private ItemTask salePrice = null; + + // ======================================== + // RESISTANCE VALUES + // ======================================== + + private int bindResistance = 0; + private int collarResistance = 0; + + // ======================================== + // TIEDUP TRAIT + // ======================================== + + /** TiedUp-specific trait (MASO, REBELLIOUS, etc.) */ + private TiedUpTrait trait = TiedUpTrait.NONE; + + // ======================================== + // EQUIPMENT GETTERS + // ======================================== + + public ItemStack getBind() { + return bind; + } + + public ItemStack getGag() { + return gag; + } + + public ItemStack getBlindfold() { + return blindfold; + } + + public ItemStack getEarplugs() { + return earplugs; + } + + public ItemStack getCollar() { + return collar; + } + + public ItemStack getClothes() { + return clothes; + } + + public ItemStack getMittens() { + return mittens; + } + + /** + * Get item by body region. + */ + public ItemStack getItem(BodyRegionV2 region) { + return switch (region) { + case ARMS -> bind; + case MOUTH -> gag; + case EYES -> blindfold; + case EARS -> earplugs; + case NECK -> collar; + case TORSO -> clothes; + case HANDS -> mittens; + default -> ItemStack.EMPTY; + }; + } + + // ======================================== + // EQUIPMENT SETTERS + // ======================================== + + public void setBind(ItemStack stack) { + this.bind = stack.copy(); + } + + public void setGag(ItemStack stack) { + this.gag = stack.copy(); + } + + public void setBlindfold(ItemStack stack) { + this.blindfold = stack.copy(); + } + + public void setEarplugs(ItemStack stack) { + this.earplugs = stack.copy(); + } + + public void setCollar(ItemStack stack) { + this.collar = stack.copy(); + } + + public void setClothes(ItemStack stack) { + this.clothes = stack.copy(); + } + + public void setMittens(ItemStack stack) { + this.mittens = stack.copy(); + } + + /** + * Set item by body region. + */ + public void setItem(BodyRegionV2 region, ItemStack stack) { + switch (region) { + case ARMS -> setBind(stack); + case MOUTH -> setGag(stack); + case EYES -> setBlindfold(stack); + case EARS -> setEarplugs(stack); + case NECK -> setCollar(stack); + case TORSO -> setClothes(stack); + case HANDS -> setMittens(stack); + default -> {} // Unsupported region — no-op + } + } + + /** + * Clear a region and return the old item. + */ + public ItemStack clearSlot(BodyRegionV2 region) { + ItemStack old = getItem(region).copy(); + setItem(region, ItemStack.EMPTY); + return old; + } + + // ======================================== + // STATE QUERIES + // ======================================== + + public boolean hasBind() { + return !bind.isEmpty(); + } + + public boolean hasGag() { + return !gag.isEmpty(); + } + + public boolean hasBlindfold() { + return !blindfold.isEmpty(); + } + + public boolean hasEarplugs() { + return !earplugs.isEmpty(); + } + + public boolean hasCollar() { + return !collar.isEmpty(); + } + + public boolean hasClothes() { + return !clothes.isEmpty(); + } + + public boolean hasMittens() { + return !mittens.isEmpty(); + } + + // ======================================== + // CAPTURE STATE + // ======================================== + + @Nullable + public UUID getCaptorUUID() { + return captorUUID; + } + + public void setCaptorUUID(@Nullable UUID uuid) { + this.captorUUID = uuid; + } + + public boolean isCaptured() { + return captorUUID != null; + } + + public boolean isForSale() { + return forSale; + } + + public void setForSale(boolean forSale) { + this.forSale = forSale; + } + + @Nullable + public ItemTask getSalePrice() { + return salePrice; + } + + public void setSalePrice(@Nullable ItemTask price) { + this.salePrice = price; + } + + // ======================================== + // RESISTANCE + // ======================================== + + public int getBindResistance() { + return bindResistance; + } + + public void setBindResistance(int resistance) { + this.bindResistance = Math.max(0, resistance); + } + + public int getCollarResistance() { + return collarResistance; + } + + public void setCollarResistance(int resistance) { + this.collarResistance = Math.max(0, resistance); + } + + // ======================================== + // TIEDUP TRAIT + // ======================================== + + public TiedUpTrait getTrait() { + return trait; + } + + public void setTrait(TiedUpTrait trait) { + this.trait = trait != null ? trait : TiedUpTrait.NONE; + } + + // ======================================== + // CLEAR ALL + // ======================================== + + /** + * Clear all bondage equipment. + */ + public void clearAllEquipment() { + bind = ItemStack.EMPTY; + gag = ItemStack.EMPTY; + blindfold = ItemStack.EMPTY; + earplugs = ItemStack.EMPTY; + collar = ItemStack.EMPTY; + clothes = ItemStack.EMPTY; + mittens = ItemStack.EMPTY; + } + + /** + * Clear all state (equipment + capture state). + * Note: Does NOT clear trait - that persists across captures. + */ + public void clearAll() { + clearAllEquipment(); + captorUUID = null; + forSale = false; + salePrice = null; + bindResistance = 0; + collarResistance = 0; + // trait is NOT cleared - it persists + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + private static final String TAG_BIND = "Bind"; + private static final String TAG_GAG = "Gag"; + private static final String TAG_BLINDFOLD = "Blindfold"; + private static final String TAG_EARPLUGS = "Earplugs"; + private static final String TAG_COLLAR = "Collar"; + private static final String TAG_CLOTHES = "Clothes"; + private static final String TAG_MITTENS = "Mittens"; + private static final String TAG_CAPTOR = "Captor"; + private static final String TAG_FOR_SALE = "ForSale"; + private static final String TAG_SALE_PRICE = "SalePrice"; + private static final String TAG_BIND_RESISTANCE = "BindResistance"; + private static final String TAG_COLLAR_RESISTANCE = "CollarResistance"; + private static final String TAG_TRAIT = "TiedUpTrait"; + + @Override + public CompoundTag serializeNBT() { + CompoundTag tag = new CompoundTag(); + + // Equipment + if (!bind.isEmpty()) { + tag.put(TAG_BIND, bind.save(new CompoundTag())); + } + if (!gag.isEmpty()) { + tag.put(TAG_GAG, gag.save(new CompoundTag())); + } + if (!blindfold.isEmpty()) { + tag.put(TAG_BLINDFOLD, blindfold.save(new CompoundTag())); + } + if (!earplugs.isEmpty()) { + tag.put(TAG_EARPLUGS, earplugs.save(new CompoundTag())); + } + if (!collar.isEmpty()) { + tag.put(TAG_COLLAR, collar.save(new CompoundTag())); + } + if (!clothes.isEmpty()) { + tag.put(TAG_CLOTHES, clothes.save(new CompoundTag())); + } + if (!mittens.isEmpty()) { + tag.put(TAG_MITTENS, mittens.save(new CompoundTag())); + } + + // Capture state + if (captorUUID != null) { + tag.putUUID(TAG_CAPTOR, captorUUID); + } + tag.putBoolean(TAG_FOR_SALE, forSale); + if (salePrice != null) { + tag.put(TAG_SALE_PRICE, salePrice.save()); + } + + // Resistance + tag.putInt(TAG_BIND_RESISTANCE, bindResistance); + tag.putInt(TAG_COLLAR_RESISTANCE, collarResistance); + + // TiedUp Trait + tag.putString(TAG_TRAIT, trait.getId()); + + return tag; + } + + @Override + public void deserializeNBT(CompoundTag tag) { + // Equipment + bind = tag.contains(TAG_BIND) + ? ItemStack.of(tag.getCompound(TAG_BIND)) + : ItemStack.EMPTY; + gag = tag.contains(TAG_GAG) + ? ItemStack.of(tag.getCompound(TAG_GAG)) + : ItemStack.EMPTY; + blindfold = tag.contains(TAG_BLINDFOLD) + ? ItemStack.of(tag.getCompound(TAG_BLINDFOLD)) + : ItemStack.EMPTY; + earplugs = tag.contains(TAG_EARPLUGS) + ? ItemStack.of(tag.getCompound(TAG_EARPLUGS)) + : ItemStack.EMPTY; + collar = tag.contains(TAG_COLLAR) + ? ItemStack.of(tag.getCompound(TAG_COLLAR)) + : ItemStack.EMPTY; + clothes = tag.contains(TAG_CLOTHES) + ? ItemStack.of(tag.getCompound(TAG_CLOTHES)) + : ItemStack.EMPTY; + mittens = tag.contains(TAG_MITTENS) + ? ItemStack.of(tag.getCompound(TAG_MITTENS)) + : ItemStack.EMPTY; + + // Capture state + captorUUID = tag.hasUUID(TAG_CAPTOR) ? tag.getUUID(TAG_CAPTOR) : null; + forSale = tag.getBoolean(TAG_FOR_SALE); + if (tag.contains(TAG_SALE_PRICE)) { + salePrice = ItemTask.load(tag.getCompound(TAG_SALE_PRICE)); + } else { + salePrice = null; + } + + // Resistance + bindResistance = tag.getInt(TAG_BIND_RESISTANCE); + collarResistance = tag.getInt(TAG_COLLAR_RESISTANCE); + + // TiedUp Trait + trait = tag.contains(TAG_TRAIT) + ? TiedUpTrait.fromId(tag.getString(TAG_TRAIT)) + : TiedUpTrait.NONE; + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/capability/MCAKidnappedProvider.java b/src/main/java/com/tiedup/remake/compat/mca/capability/MCAKidnappedProvider.java new file mode 100644 index 0000000..d4e165b --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/capability/MCAKidnappedProvider.java @@ -0,0 +1,58 @@ +package com.tiedup.remake.compat.mca.capability; + +import com.tiedup.remake.compat.mca.MCACompat; +import net.minecraft.core.Direction; +import net.minecraft.nbt.CompoundTag; +import net.minecraftforge.common.capabilities.Capability; +import net.minecraftforge.common.capabilities.ICapabilitySerializable; +import net.minecraftforge.common.util.LazyOptional; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Capability provider for MCA villager bondage state. + * + * Attaches MCAKidnappedCapability to MCA villager entities. + * Handles NBT serialization for persistence. + */ +public class MCAKidnappedProvider + implements ICapabilitySerializable +{ + + private final MCAKidnappedCapability capability; + private final LazyOptional lazyOptional; + + public MCAKidnappedProvider() { + this.capability = new MCAKidnappedCapability(); + this.lazyOptional = LazyOptional.of(() -> capability); + } + + @Override + public @NotNull LazyOptional getCapability( + @NotNull Capability cap, + @Nullable Direction side + ) { + if (cap == MCACompat.MCA_KIDNAPPED) { + return lazyOptional.cast(); + } + return LazyOptional.empty(); + } + + @Override + public CompoundTag serializeNBT() { + return capability.serializeNBT(); + } + + @Override + public void deserializeNBT(CompoundTag tag) { + capability.deserializeNBT(tag); + } + + /** + * Invalidate the LazyOptional when this provider is no longer valid. + * Should be called when the entity is removed. + */ + public void invalidate() { + lazyOptional.invalidate(); + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/dialogue/MCADialogueManager.java b/src/main/java/com/tiedup/remake/compat/mca/dialogue/MCADialogueManager.java new file mode 100644 index 0000000..e232fc1 --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/dialogue/MCADialogueManager.java @@ -0,0 +1,422 @@ +package com.tiedup.remake.compat.mca.dialogue; + +import com.tiedup.remake.compat.mca.personality.MCAMoodManager; +import com.tiedup.remake.compat.mca.personality.MCAPersonality; +import com.tiedup.remake.compat.mca.personality.MCAPersonalityManager; +import com.tiedup.remake.compat.mca.personality.TiedUpTrait; +import java.util.Random; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; + +/** + * Personality-aware dialogue system for MCA villagers. + * + *

Dialogue selection considers: + *

    + *
  • Personality type (affects tone and content)
  • + *
  • Current mood (affects positivity/negativity)
  • + *
  • TiedUp trait (MASO has different reactions)
  • + *
  • Bondage state (tied, gagged, collared)
  • + *
+ */ +public class MCADialogueManager { + + private static final Random RANDOM = new Random(); + + // ======================================== + // BEING TIED DIALOGUES + // ======================================== + + /** + * Get dialogue when being tied up. + */ + public static String getBeingTiedDialogue(LivingEntity villager) { + MCAPersonality personality = + MCAPersonalityManager.getInstance().getPersonality(villager); + TiedUpTrait trait = MCAPersonalityManager.getInstance().getTrait( + villager + ); + + // MASO trait overrides personality + if (trait == TiedUpTrait.MASO) { + return pickRandom( + "Oh yes... tie me up~", + "Mmm, tighter please...", + "I've been waiting for this~", + "Don't stop..." + ); + } + + // BROKEN trait + if (trait == TiedUpTrait.BROKEN) { + return pickRandom("...", "*doesn't resist*", "*stares blankly*"); + } + + // REBELLIOUS trait + if (trait == TiedUpTrait.REBELLIOUS) { + return pickRandom( + "GET YOUR HANDS OFF ME!", + "I'LL NEVER SUBMIT!", + "You'll pay for this!", + "*fights back violently*" + ); + } + + return switch (personality) { + case CONFIDENT -> pickRandom( + "You'll regret this!", + "I won't submit to you!", + "This won't hold me!", + "You're making a mistake!" + ); + case ATHLETIC -> pickRandom( + "You won't hold me for long!", + "I'll break free from this!", + "These bonds can't stop me!", + "*struggles powerfully*" + ); + case SHY -> pickRandom( + "P-please... don't hurt me...", + "*whimpers quietly*", + "No... please no...", + "*trembles*" + ); + case SENSITIVE -> pickRandom( + "*sobs*", + "Why... why are you doing this?", + "Please... I'm scared...", + "*cries*" + ); + case FRIENDLY -> pickRandom( + "Why are you doing this?", + "This isn't necessary...", + "Can't we talk about this?", + "I thought we were friends..." + ); + case FLIRTY -> pickRandom( + "Oh my, how forward of you~", + "I didn't say stop...", + "Mmm, tighter please~", + "If you wanted me tied up, you could have just asked..." + ); + case GRUMPY -> pickRandom( + "Ugh, not this again.", + "Of course this is happening.", + "Just my luck...", + "Great. Just great." + ); + case LAZY -> pickRandom( + "*sighs* Fine...", + "This better not take long...", + "Whatever...", + "At least I don't have to work..." + ); + case ODD -> pickRandom( + "The ropes smell like Tuesday.", + "My left elbow predicts rain.", + "Interesting knot technique.", + "Did you know butterflies can't fly in the rain?" + ); + case GREEDY -> pickRandom( + "I'll pay you to stop!", + "How much to let me go?", + "Name your price!", + "This is bad for business..." + ); + case PEPPY -> pickRandom( + "Hey! That's not nice!", + "Ow ow ow! Too tight!", + "This is NOT fun!", + "*squirms energetically*" + ); + case WITTY -> pickRandom( + "Well, this is a bind.", + "I see you've taken things into your own hands.", + "Tied up with work, I see.", + "Quite the knotty situation." + ); + case GLOOMY -> pickRandom( + "I knew this would happen eventually...", + "My life just gets worse...", + "*sighs deeply*", + "Why do I even bother..." + ); + default -> pickRandom( + "Help! Someone help me!", + "No! Let me go!", + "Please... stop...", + "*struggles*" + ); + }; + } + + // ======================================== + // TIED IDLE DIALOGUES + // ======================================== + + /** + * Get dialogue for idle state while tied. + * Affected by current mood. + */ + public static String getTiedIdleDialogue(LivingEntity villager) { + MCAPersonality personality = + MCAPersonalityManager.getInstance().getPersonality(villager); + TiedUpTrait trait = MCAPersonalityManager.getInstance().getTrait( + villager + ); + int mood = MCAMoodManager.getInstance().getMoodValue(villager); + + // MASO enjoying it + if (trait == TiedUpTrait.MASO) { + return pickRandom( + "*content sigh*", + "This is nice...", + "*relaxes into the bonds*", + "Mmm..." + ); + } + + // BROKEN - no reaction + if (trait == TiedUpTrait.BROKEN) { + return pickRandom("...", "*stares*", "*empty eyes*"); + } + + // Very low mood - desperate + if (mood < -5) { + return pickRandom( + "*sobs quietly*", + "Someone... please...", + "Why is this happening to me...", + "*whimpers*" + ); + } + + return switch (personality) { + case CONFIDENT -> pickRandom( + "*struggles against the bonds*", + "I will get out of this.", + "*glares defiantly*", + "You won't break me." + ); + case SHY -> pickRandom( + "*trembles*", + "*looks around nervously*", + "*stays very quiet*", + "*avoids eye contact*" + ); + case LAZY -> pickRandom( + "*yawns*", + "At least I don't have to work...", + "*naps*", + "Wake me when this is over..." + ); + case FLIRTY -> pickRandom( + "*winks*", + "See something you like?", + "*poses suggestively*", + "Like what you see?" + ); + case ODD -> pickRandom( + "*hums tunelessly*", + "I wonder what clouds taste like...", + "*counts ceiling tiles*", + "The floor has a nice texture." + ); + default -> pickRandom( + "*struggles*", + "*looks around for help*", + "*tests the bonds*", + "*sighs*" + ); + }; + } + + // ======================================== + // STRUGGLE DIALOGUES + // ======================================== + + /** + * Get dialogue for struggling. + */ + public static String getStruggleDialogue( + LivingEntity villager, + boolean success + ) { + MCAPersonality personality = + MCAPersonalityManager.getInstance().getPersonality(villager); + TiedUpTrait trait = MCAPersonalityManager.getInstance().getTrait( + villager + ); + + if (success) { + // MASO disappointed to be free + if (trait == TiedUpTrait.MASO) { + return pickRandom( + "Oh... it's over already?", + "Aww...", + "*slightly disappointed*" + ); + } + + return switch (personality) { + case CONFIDENT -> "I knew I'd break free!"; + case ATHLETIC -> "Finally! I'm free!"; + case SHY -> "I-I did it..."; + case FLIRTY -> "That was... intense~"; + default -> "I'm free!"; + }; + } else { + // MASO happy to fail + if (trait == TiedUpTrait.MASO) { + return pickRandom("Good... still tied~", "*secretly pleased*"); + } + + // REBELLIOUS trait: never give up + if (trait == TiedUpTrait.REBELLIOUS) { + return pickRandom( + "I'LL NEVER STOP TRYING!", + "YOU CAN'T BREAK ME!", + "*struggles violently*" + ); + } + + return switch (personality) { + case CONFIDENT -> "These bonds are tougher than I thought..."; + case ATHLETIC -> "*pants* Need to try harder..."; + case LAZY -> "Eh, not worth the effort..."; + default -> pickRandom( + "*struggles futilely*", + "It's too tight...", + "I can't break free..." + ); + }; + } + } + + // ======================================== + // FREED DIALOGUES + // ======================================== + + /** + * Get dialogue for being freed. + */ + public static String getFreedDialogue(LivingEntity villager) { + MCAPersonality personality = + MCAPersonalityManager.getInstance().getPersonality(villager); + TiedUpTrait trait = MCAPersonalityManager.getInstance().getTrait( + villager + ); + + // MASO disappointed + if (trait == TiedUpTrait.MASO) { + return pickRandom( + "Aww, already?", + "That was... fun~", + "Maybe again sometime?", + "*reluctantly stands up*" + ); + } + + // BROKEN - minimal reaction + if (trait == TiedUpTrait.BROKEN) { + return pickRandom("...thank you.", "*nods slowly*", "..."); + } + + return switch (personality) { + case FRIENDLY -> "Thank you so much! You saved me!"; + case SHY -> "T-thank you... *sniffles*"; + case CONFIDENT -> "About time. I was about to break free anyway."; + case GRUMPY -> "Hmph. Took you long enough."; + case FLIRTY -> "My hero~ How can I ever repay you?"; + case LAZY -> "Finally... that was exhausting."; + case GREEDY -> "I'll remember this! Here's something for your trouble..."; + case SENSITIVE -> "*hugs you* Thank you so much!"; + default -> "Thank you for freeing me!"; + }; + } + + // ======================================== + // COLLAR DIALOGUES + // ======================================== + + /** + * Get dialogue for collar being put on. + */ + public static String getCollarPutOnDialogue(LivingEntity villager) { + MCAPersonality personality = + MCAPersonalityManager.getInstance().getPersonality(villager); + TiedUpTrait trait = MCAPersonalityManager.getInstance().getTrait( + villager + ); + + // MASO loves it + if (trait == TiedUpTrait.MASO) { + return pickRandom( + "Yes... I'm yours now~", + "*happy shiver*", + "Finally... a collar~", + "Mark me as yours..." + ); + } + + // BROKEN accepts it + if (trait == TiedUpTrait.BROKEN) { + return pickRandom("*accepts silently*", "...", "*nods*"); + } + + return switch (personality) { + case CONFIDENT -> "You think a collar makes me yours? Think again."; + case SHY -> "*whimpers* N-no... please not that..."; + case GREEDY -> "Is there something in it for me at least?"; + case FLIRTY -> "A collar? How... possessive of you~"; + case GRUMPY -> "A collar? Really? How degrading."; + case LAZY -> "Ugh, this is uncomfortable..."; + default -> "No... not a collar..."; + }; + } + + // ======================================== + // BROADCAST HELPER + // ======================================== + + /** + * Broadcast a dialogue message from a villager to nearby players. + * + * @param villager The villager speaking + * @param message The dialogue text + * @param radius Radius in blocks + */ + public static void broadcastDialogue( + LivingEntity villager, + String message, + double radius + ) { + if (villager.level().isClientSide()) return; + if (message == null || message.isEmpty()) return; + + String villagerName = villager.getName().getString(); + Component chatMessage = Component.literal( + "<" + villagerName + "> " + message + ); + + villager + .level() + .getEntitiesOfClass( + Player.class, + villager.getBoundingBox().inflate(radius), + player -> true + ) + .forEach(player -> { + player.sendSystemMessage(chatMessage); + }); + } + + // ======================================== + // UTILITY + // ======================================== + + private static String pickRandom(String... options) { + return options[RANDOM.nextInt(options.length)]; + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/event/MCACompatEvents.java b/src/main/java/com/tiedup/remake/compat/mca/event/MCACompatEvents.java new file mode 100644 index 0000000..2514dc9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/event/MCACompatEvents.java @@ -0,0 +1,332 @@ +package com.tiedup.remake.compat.mca.event; + +import com.tiedup.remake.compat.mca.MCACompat; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.compat.mca.capability.MCAKidnappedProvider; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.ItemKey; +import com.tiedup.remake.items.ItemMasterKey; +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.tasks.UntyingPlayerTask; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.core.SettingsAccessor; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.event.AttachCapabilitiesEvent; +import net.minecraftforge.event.entity.living.LivingDeathEvent; +import net.minecraftforge.event.entity.player.PlayerInteractEvent; +import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Event handlers for MCA compatibility. + * + * - Attaches bondage capability to MCA villagers when they spawn. + * - Handles death of tied MCA villagers. + * - Intercepts MCA menu interaction when holding TiedUp items or untying. + */ +@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID) +public class MCACompatEvents { + + /** + * Map to track ongoing untying tasks for MCA villagers. + * Key: Villager UUID, Value: The active untying task + */ + private static final Map mcaUntyingTasks = + new HashMap<>(); + + /** + * Attach bondage capability to MCA villagers. + * + * Called whenever an entity is created and capabilities are attached. + */ + @SubscribeEvent + public static void onAttachCapabilities( + AttachCapabilitiesEvent event + ) { + // Only process if MCA is loaded + if (!MCACompat.isMCALoaded()) { + return; + } + + Entity entity = event.getObject(); + + // Check if this is an MCA villager + if (MCACompat.shouldAttachCapability(entity)) { + event.addCapability( + MCACompat.MCA_KIDNAPPED_CAP_ID, + new MCAKidnappedProvider() + ); + + TiedUpMod.LOGGER.debug( + "[MCA Compat] Attached bondage capability to MCA villager: {} (type: {})", + entity.getName().getString(), + entity.getType().toString() + ); + } + } + + /** + * Handle death of MCA villagers. + * Drops bondage items and frees captive. + */ + @SubscribeEvent + public static void onLivingDeath(LivingDeathEvent event) { + if (!MCACompat.isMCALoaded()) return; + + LivingEntity entity = event.getEntity(); + if (!MCACompat.isMCAVillager(entity)) return; + + IBondageState state = MCACompat.getKidnappedState(entity); + if (state != null) { + // Trigger death logic (drops items, frees captive) + state.onDeathKidnapped(entity.level()); + } + } + + /** + * Intercept MCA villager INTERACT_AT (hitbox interaction) when holding TiedUp bondage items. + * + * MCA handles interactAt() which is triggered by INTERACT_AT packets. + * This event fires BEFORE EntityInteract and is where MCA opens their menu. + * + * Uses HIGHEST priority to run before anything else. + */ + @SubscribeEvent(priority = EventPriority.HIGHEST) + public static void onEntityInteractSpecific( + PlayerInteractEvent.EntityInteractSpecific event + ) { + if ( + !handleMCAInteraction( + event, + event.getTarget(), + event.getEntity(), + event.getHand() + ) + ) { + return; + } + // Event was handled and canceled in handleMCAInteraction + } + + /** + * Intercept MCA villager INTERACT (normal interaction) when holding TiedUp bondage items. + * + * This is a backup in case interactAt doesn't catch it. + * + * Uses HIGHEST priority to run before anything else. + */ + @SubscribeEvent(priority = EventPriority.HIGHEST) + public static void onEntityInteract( + PlayerInteractEvent.EntityInteract event + ) { + if ( + !handleMCAInteraction( + event, + event.getTarget(), + event.getEntity(), + event.getHand() + ) + ) { + return; + } + // Event was handled and canceled in handleMCAInteraction + } + + /** + * Common handler for both interaction types. + * + * @return true if interaction was handled and event should be considered processed + */ + private static boolean handleMCAInteraction( + PlayerInteractEvent event, + Entity target, + Player player, + InteractionHand hand + ) { + // Only process if MCA is loaded + if (!MCACompat.isMCALoaded()) { + return false; + } + + // Only intercept for MCA villagers + if (!MCACompat.isMCAVillager(target)) { + return false; + } + + // Target must be a LivingEntity for bondage items + if ( + !(target instanceof + net.minecraft.world.entity.LivingEntity livingTarget) + ) { + return false; + } + + ItemStack heldItem = player.getItemInHand(hand); + + // 1. Check for TiedUp interaction items (Bondage, Keys) + boolean isTiedUpItem = + heldItem.getItem() instanceof IV2BondageItem || + heldItem.getItem() instanceof ItemKey || + heldItem.getItem() instanceof ItemMasterKey; + + // 2. Check for Shift-Click Untying logic + // If player is crouching AND target is tied/gagged/collared, shift-click should untie/interact + // regardless of held item (unless it's a specific item that overrides shift-click) + boolean isShiftClickUntying = false; + if (player.isCrouching()) { + IBondageState state = MCACompat.getKidnappedState(livingTarget); + if ( + state != null && + (state.isTiedUp() || + state.isGagged() || + state.isBlindfolded() || + state.hasCollar()) + ) { + isShiftClickUntying = true; + } + } + + // If neither case applies, let MCA handle it + if (!isTiedUpItem && !isShiftClickUntying) { + return false; + } + + // Cancel the event FIRST to prevent MCA from handling it + event.setCanceled(true); + + // Manually trigger interaction logic + net.minecraft.world.InteractionResult result = + net.minecraft.world.InteractionResult.PASS; + + if (isTiedUpItem) { + // Let the item handle it + result = heldItem.interactLivingEntity(player, livingTarget, hand); + } else if (isShiftClickUntying) { + // Shift-click interaction logic for untying/ungagging + IBondageState state = MCACompat.getKidnappedState(livingTarget); + if (state != null) { + // Priority: Untie > Ungag > Unblindfold > etc. + // This mimics EntityDamsel logic or ItemBind.interactLivingEntity logic for untying + + // No collar ownership check — any player can untie MCA villagers by design + // (MCA villagers use a separate relationship system, not TiedUp collars) + + boolean actionTaken = false; + + // Try to remove items in order + if (state.isTiedUp()) { + // Check if player can untie (e.g. has knife if needed, or if resistance allows) + // For now, allow simple untying + if (!state.getEquipment(BodyRegionV2.ARMS).isEmpty()) { + // Ensure there is a bind + // Check lock + ItemStack bind = state.getEquipment(BodyRegionV2.ARMS); + if ( + bind.getItem() instanceof + com.tiedup.remake.items.base.ILockable lockable && + lockable.isLocked(bind) + ) { + // Locked - can't untie without key + // Maybe send message? + } else { + // Use timed untying task (same as Damsels - 10 seconds by default) + UUID villagerUuid = livingTarget.getUUID(); + UntyingPlayerTask existingTask = + mcaUntyingTasks.get(villagerUuid); + + int untyingSeconds = + SettingsAccessor.getUntyingPlayerTime( + player.level().getGameRules() + ); + + if ( + existingTask == null || + existingTask.isOutdated() || + existingTask.getTargetEntity() != livingTarget + ) { + // Create new untying task + UntyingPlayerTask newTask = + new UntyingPlayerTask( + state, + livingTarget, + untyingSeconds, + player.level(), + player + ); + mcaUntyingTasks.put(villagerUuid, newTask); + existingTask = newTask; + + TiedUpMod.LOGGER.debug( + "[MCA Compat] Started untying task for {} ({} seconds)", + livingTarget.getName().getString(), + untyingSeconds + ); + } else { + // Continue existing task, update helper reference + existingTask.setHelper(player); + } + + // Update task progress (sends progress packets, completes if timer expired) + existingTask.update(); + + // Clean up completed tasks + if (existingTask.isStopped()) { + mcaUntyingTasks.remove(villagerUuid); + } + + actionTaken = true; + } + } + } else if (state.isGagged()) { + // Check lock + ItemStack gag = state.getEquipment(BodyRegionV2.MOUTH); + if ( + gag.getItem() instanceof + com.tiedup.remake.items.base.ILockable lockable && + lockable.isLocked(gag) + ) { + // Locked + } else { + state.unequip(BodyRegionV2.MOUTH); + state.kidnappedDropItem(gag); + actionTaken = true; + } + } + // Add other removal logic as needed + + if (actionTaken) { + result = net.minecraft.world.InteractionResult.SUCCESS; + player.swing(hand, true); + } else { + // If we couldn't remove anything (e.g. locked), maybe open GUI? + // Or simply PASS but keep event canceled to stop MCA menu + result = net.minecraft.world.InteractionResult.CONSUME; + } + } + } + + event.setCancellationResult(result); + + TiedUpMod.LOGGER.debug( + "[MCA Compat] Intercepted MCA interaction - {} on {} (Item: {}, Shift: {}, Result: {})", + player.getName().getString(), + target.getName().getString(), + isTiedUpItem + ? heldItem.getItem().getClass().getSimpleName() + : "Other", + isShiftClickUntying, + result + ); + + return true; + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/network/MCANetworkHandler.java b/src/main/java/com/tiedup/remake/compat/mca/network/MCANetworkHandler.java new file mode 100644 index 0000000..8de95c9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/network/MCANetworkHandler.java @@ -0,0 +1,85 @@ +package com.tiedup.remake.compat.mca.network; + +import com.tiedup.remake.compat.mca.MCACompat; +import com.tiedup.remake.compat.mca.capability.MCAKidnappedCapability; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.ModNetwork; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.LivingEntity; + +/** + * Centralized network handling for MCA compatibility. + * + * All MCA-related sync operations should go through this handler. + * This replaces scattered sync calls throughout the code. + */ +public class MCANetworkHandler { + + /** + * Sync complete bondage state for a villager to all tracking clients. + * Call this after any bondage equipment change. + * + * @param villager The MCA villager entity + * @param cap The villager's bondage capability + */ + public static void syncBondageState( + LivingEntity villager, + MCAKidnappedCapability cap + ) { + if (villager.level().isClientSide()) return; + + PacketSyncMCABondage packet = PacketSyncMCABondage.fromEntity( + villager, + cap + ); + ModNetwork.sendToAllTrackingEntity(packet, villager); + + TiedUpMod.LOGGER.debug( + "[MCA Network] Synced bondage state for {} to all trackers", + villager.getName().getString() + ); + } + + /** + * Sync bondage state to a specific player. + * Used when player starts tracking the villager (enters render distance). + * + * @param villager The MCA villager entity + * @param cap The villager's bondage capability + * @param tracker The player to sync to + */ + public static void syncBondageStateTo( + LivingEntity villager, + MCAKidnappedCapability cap, + ServerPlayer tracker + ) { + PacketSyncMCABondage packet = PacketSyncMCABondage.fromEntity( + villager, + cap + ); + ModNetwork.sendToPlayer(packet, tracker); + + TiedUpMod.LOGGER.debug( + "[MCA Network] Synced bondage state for {} to tracker {}", + villager.getName().getString(), + tracker.getName().getString() + ); + } + + /** + * Sync bondage state using the villager's capability directly. + * Convenience method that fetches capability internally. + * + * @param villager The MCA villager entity + */ + public static void syncBondageState(LivingEntity villager) { + if (villager.level().isClientSide()) return; + if (!MCACompat.isMCAVillager(villager)) return; + + villager + .getCapability(MCACompat.MCA_KIDNAPPED) + .ifPresent(cap -> { + syncBondageState(villager, cap); + }); + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/network/PacketSyncMCABondage.java b/src/main/java/com/tiedup/remake/compat/mca/network/PacketSyncMCABondage.java new file mode 100644 index 0000000..43a3f24 --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/network/PacketSyncMCABondage.java @@ -0,0 +1,152 @@ +package com.tiedup.remake.compat.mca.network; + +import com.tiedup.remake.compat.mca.MCACompat; +import com.tiedup.remake.compat.mca.capability.MCAKidnappedCapability; +import com.tiedup.remake.core.TiedUpMod; +import java.util.function.Supplier; +import net.minecraft.client.Minecraft; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet to sync MCA villager bondage state from server to client. + * + * Sent when bondage items are added/removed from MCA villagers. + * Contains all 7 bondage slots for the entity. + */ +public class PacketSyncMCABondage { + + private final int entityId; + private final ItemStack bind; + private final ItemStack gag; + private final ItemStack blindfold; + private final ItemStack earplugs; + private final ItemStack collar; + private final ItemStack clothes; + private final ItemStack mittens; + + public PacketSyncMCABondage( + int entityId, + ItemStack bind, + ItemStack gag, + ItemStack blindfold, + ItemStack earplugs, + ItemStack collar, + ItemStack clothes, + ItemStack mittens + ) { + this.entityId = entityId; + this.bind = bind; + this.gag = gag; + this.blindfold = blindfold; + this.earplugs = earplugs; + this.collar = collar; + this.clothes = clothes; + this.mittens = mittens; + } + + /** + * Create packet from MCA villager's capability. + */ + public static PacketSyncMCABondage fromEntity( + Entity entity, + MCAKidnappedCapability cap + ) { + return new PacketSyncMCABondage( + entity.getId(), + cap.getBind(), + cap.getGag(), + cap.getBlindfold(), + cap.getEarplugs(), + cap.getCollar(), + cap.getClothes(), + cap.getMittens() + ); + } + + /** + * Decode packet from network buffer. + */ + public static PacketSyncMCABondage decode(FriendlyByteBuf buf) { + int entityId = buf.readVarInt(); + ItemStack bind = buf.readItem(); + ItemStack gag = buf.readItem(); + ItemStack blindfold = buf.readItem(); + ItemStack earplugs = buf.readItem(); + ItemStack collar = buf.readItem(); + ItemStack clothes = buf.readItem(); + ItemStack mittens = buf.readItem(); + return new PacketSyncMCABondage( + entityId, + bind, + gag, + blindfold, + earplugs, + collar, + clothes, + mittens + ); + } + + /** + * Encode packet to network buffer. + */ + public void encode(FriendlyByteBuf buf) { + buf.writeVarInt(entityId); + buf.writeItem(bind); + buf.writeItem(gag); + buf.writeItem(blindfold); + buf.writeItem(earplugs); + buf.writeItem(collar); + buf.writeItem(clothes); + buf.writeItem(mittens); + } + + /** + * Handle packet on client side. + */ + public void handle(Supplier ctx) { + ctx.get().enqueueWork(() -> handleClient()); + ctx.get().setPacketHandled(true); + } + + @OnlyIn(Dist.CLIENT) + private void handleClient() { + Minecraft mc = Minecraft.getInstance(); + if (mc.level == null) return; + + Entity entity = mc.level.getEntity(entityId); + if (entity == null) { + TiedUpMod.LOGGER.debug( + "[MCA Sync] Entity {} not found on client", + entityId + ); + return; + } + + // Get the capability and update it + entity + .getCapability(MCACompat.MCA_KIDNAPPED) + .ifPresent(cap -> { + cap.setBind(bind); + cap.setGag(gag); + cap.setBlindfold(blindfold); + cap.setEarplugs(earplugs); + cap.setCollar(collar); + cap.setClothes(clothes); + cap.setMittens(mittens); + + TiedUpMod.LOGGER.debug( + "[MCA Sync] Updated bondage for {} (bind: {})", + entity.getName().getString(), + !bind.isEmpty() + ? bind.getItem().getClass().getSimpleName() + : "none" + ); + }); + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/personality/MCAMoodManager.java b/src/main/java/com/tiedup/remake/compat/mca/personality/MCAMoodManager.java new file mode 100644 index 0000000..c1d81d8 --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/personality/MCAMoodManager.java @@ -0,0 +1,334 @@ +package com.tiedup.remake.compat.mca.personality; + +import com.tiedup.remake.compat.mca.MCACompat; +import com.tiedup.remake.core.TiedUpMod; +import java.lang.reflect.Method; +import java.util.Random; +import net.minecraft.world.entity.LivingEntity; + +/** + * Manages MCA mood modifications during bondage events. + * + *

MCA mood system: + *

    + *
  • getMoodValue() -> int (typically -15 to 15)
  • + *
  • modifyMoodValue(int) -> void
  • + *
+ * + *

Mood changes are personality-dependent. Some personalities + * enjoy being restrained (FLIRTY), while others hate it (CONFIDENT, SENSITIVE). + */ +public class MCAMoodManager { + + private static final MCAMoodManager INSTANCE = new MCAMoodManager(); + private static final Random RANDOM = new Random(); + + // Cached reflection methods + private Method modifyMoodMethod; + private Method getMoodValueMethod; + private boolean reflectionInitialized = false; + + public static MCAMoodManager getInstance() { + return INSTANCE; + } + + private MCAMoodManager() {} + + // ======================================== + // EVENT HANDLERS + // ======================================== + + /** + * Called when villager is tied up. + * Mood change depends on personality and trait. + */ + public void onTied(LivingEntity entity) { + if (!MCACompat.isMCAVillager(entity)) return; + + MCAPersonality personality = + MCAPersonalityManager.getInstance().getPersonality(entity); + TiedUpTrait trait = MCAPersonalityManager.getInstance().getTrait( + entity + ); + + int moodChange; + if (personality.isUnpredictable()) { + // ODD: random mood change + moodChange = RANDOM.nextInt(11) - 5; // -5 to +5 + } else { + moodChange = TiedUpTrait.getCombinedMoodTied(personality, trait); + } + + modifyMood(entity, moodChange); + + TiedUpMod.LOGGER.debug( + "[MCA Mood] {} tied: mood {} (personality={}, trait={})", + entity.getName().getString(), + moodChange >= 0 ? "+" + moodChange : moodChange, + personality.getMcaId(), + trait.getId() + ); + } + + /** + * Called when villager is freed. + * Generally positive, but personality affects amount. + */ + public void onFreed(LivingEntity entity) { + if (!MCACompat.isMCAVillager(entity)) return; + + MCAPersonality personality = + MCAPersonalityManager.getInstance().getPersonality(entity); + TiedUpTrait trait = MCAPersonalityManager.getInstance().getTrait( + entity + ); + + int moodChange; + if (personality.isUnpredictable()) { + moodChange = RANDOM.nextInt(11) - 3; // -3 to +7 (slightly positive bias) + } else { + // Base positive + personality modifier + moodChange = switch (personality) { + case SENSITIVE -> +7; + case FRIENDLY -> +6; + case SHY, CONFIDENT -> +5; + case GRUMPY -> +2; + case LAZY -> +1; + case FLIRTY -> +1; // They liked being tied, so meh about freedom + default -> +4; + }; + + // MASO trait: less happy about being freed + if (trait.enjoysBondage()) { + moodChange = Math.max(0, moodChange - 3); + } + } + + modifyMood(entity, moodChange); + + TiedUpMod.LOGGER.debug( + "[MCA Mood] {} freed: mood +{}", + entity.getName().getString(), + moodChange + ); + } + + /** + * Called when collar is put on. + * Generally negative (loss of freedom), but personality-dependent. + */ + public void onCollared(LivingEntity entity) { + if (!MCACompat.isMCAVillager(entity)) return; + + MCAPersonality personality = + MCAPersonalityManager.getInstance().getPersonality(entity); + TiedUpTrait trait = MCAPersonalityManager.getInstance().getTrait( + entity + ); + + int moodChange; + if (personality.isUnpredictable()) { + moodChange = RANDOM.nextInt(13) - 8; // -8 to +4 + } else { + moodChange = switch (personality) { + case SENSITIVE -> -8; + case CONFIDENT -> -6; + case SHY -> -4; + case FRIENDLY -> -3; + case GRUMPY -> -4; + case LAZY -> -1; + case FLIRTY -> +1; // Kinky! + default -> -4; + }; + + // MASO trait bonus + if (trait.enjoysBondage()) { + moodChange += 4; + } + } + + modifyMood(entity, moodChange); + } + + /** + * Called when collar is removed. + */ + public void onCollarRemoved(LivingEntity entity) { + if (!MCACompat.isMCAVillager(entity)) return; + + MCAPersonality personality = + MCAPersonalityManager.getInstance().getPersonality(entity); + TiedUpTrait trait = MCAPersonalityManager.getInstance().getTrait( + entity + ); + + int moodChange = trait.enjoysBondage() ? +1 : +3; + + modifyMood(entity, moodChange); + } + + /** + * Called when struggle fails. + */ + public void onStruggleFailed(LivingEntity entity) { + if (!MCACompat.isMCAVillager(entity)) return; + + MCAPersonality personality = + MCAPersonalityManager.getInstance().getPersonality(entity); + + int moodChange = personality == MCAPersonality.CONFIDENT ? -2 : -1; + modifyMood(entity, moodChange); + } + + /** + * Called when struggle succeeds. + */ + public void onStruggleSuccess(LivingEntity entity) { + if (!MCACompat.isMCAVillager(entity)) return; + + modifyMood(entity, +3); + } + + /** + * Called when gagged. + */ + public void onGagged(LivingEntity entity) { + if (!MCACompat.isMCAVillager(entity)) return; + + MCAPersonality personality = + MCAPersonalityManager.getInstance().getPersonality(entity); + TiedUpTrait trait = MCAPersonalityManager.getInstance().getTrait( + entity + ); + + int moodChange = trait.enjoysBondage() ? +1 : -2; + if (personality == MCAPersonality.SENSITIVE) { + moodChange -= 2; + } + + modifyMood(entity, moodChange); + } + + /** + * Called when blindfolded. + */ + public void onBlindfolded(LivingEntity entity) { + if (!MCACompat.isMCAVillager(entity)) return; + + MCAPersonality personality = + MCAPersonalityManager.getInstance().getPersonality(entity); + TiedUpTrait trait = MCAPersonalityManager.getInstance().getTrait( + entity + ); + + int moodChange = trait.enjoysBondage() ? +1 : -2; + if ( + personality == MCAPersonality.SHY || + personality == MCAPersonality.SENSITIVE + ) { + moodChange -= 2; // Extra scary for anxious personalities + } + + modifyMood(entity, moodChange); + } + + // ======================================== + // REFLECTION-BASED MOOD ACCESS + // ======================================== + + /** + * Modify mood using MCA's modifyMoodValue method via reflection. + */ + public void modifyMood(LivingEntity entity, int amount) { + if (amount == 0) return; + + try { + initializeReflection(entity); + + if (modifyMoodMethod == null) { + return; + } + + Object brain = entity + .getClass() + .getMethod("getVillagerBrain") + .invoke(entity); + if (brain != null) { + modifyMoodMethod.invoke(brain, amount); + } + } catch (Exception e) { + TiedUpMod.LOGGER.debug( + "[MCA Mood] Failed to modify mood: {}", + e.getMessage() + ); + } + } + + /** + * Get current mood value. + * + * @return Mood value (typically -15 to 15), or 0 if not available + */ + public int getMoodValue(LivingEntity entity) { + try { + initializeReflection(entity); + + if (getMoodValueMethod == null) { + return 0; + } + + Object brain = entity + .getClass() + .getMethod("getVillagerBrain") + .invoke(entity); + if (brain != null) { + return (int) getMoodValueMethod.invoke(brain); + } + } catch (Exception e) { + TiedUpMod.LOGGER.debug( + "[MCAMoodManager] Failed to get mood value via reflection", + e + ); + } + return 0; + } + + private void initializeReflection(LivingEntity entity) { + if (reflectionInitialized) return; + reflectionInitialized = true; + + try { + Object brain = entity + .getClass() + .getMethod("getVillagerBrain") + .invoke(entity); + if (brain != null) { + for (Method m : brain.getClass().getMethods()) { + if ( + m.getName().equals("modifyMoodValue") && + m.getParameterCount() == 1 + ) { + modifyMoodMethod = m; + } + if ( + m.getName().equals("getMoodValue") && + m.getParameterCount() == 0 + ) { + getMoodValueMethod = m; + } + } + + if (modifyMoodMethod != null) { + TiedUpMod.LOGGER.debug( + "[MCA Mood] Found mood methods via reflection" + ); + } + } + } catch (Exception e) { + TiedUpMod.LOGGER.debug( + "[MCA Mood] Reflection init failed: {}", + e.getMessage() + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/personality/MCAPersonality.java b/src/main/java/com/tiedup/remake/compat/mca/personality/MCAPersonality.java new file mode 100644 index 0000000..a0cfc10 --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/personality/MCAPersonality.java @@ -0,0 +1,130 @@ +package com.tiedup.remake.compat.mca.personality; + +/** + * Maps MCA personality types to TiedUp behavior modifiers. + * + *

MCA 1.20.1 has these personalities: + * ATHLETIC, CONFIDENT, FRIENDLY, FLIRTY, WITTY, SHY, GLOOMY, + * SENSITIVE, GREEDY, ODD, LAZY, GRUMPY, PEPPY + * + *

Each personality affects: + *

    + *
  • struggleMultiplier - How effectively they struggle against bonds
  • + *
  • complianceMultiplier - How quickly they accept restraints
  • + *
  • fleeSpeedMultiplier - How fast they flee/panic
  • + *
  • baseMoodTied - Mood change when tied (some like it!)
  • + *
+ */ +public enum MCAPersonality { + // Strong personalities - resist more + ATHLETIC("athletic", 1.5f, 0.8f, 1.2f, -3), + CONFIDENT("confident", 1.3f, 1.0f, 1.0f, -4), + + // Social personalities - more cooperative + FRIENDLY("friendly", 0.7f, 1.2f, 0.8f, -2), + FLIRTY("flirty", 0.5f, 1.5f, 0.6f, +2), // Enjoys it! + + // Clever but not strong + WITTY("witty", 0.9f, 1.1f, 1.1f, -2), + + // Anxious personalities - panic more + SHY("shy", 0.8f, 0.7f, 1.4f, -3), + SENSITIVE("sensitive", 0.7f, 0.6f, 1.5f, -6), + GLOOMY("gloomy", 0.6f, 0.8f, 0.9f, -4), + + // Special personalities + GREEDY("greedy", 1.0f, 0.5f, 1.0f, -2), // Can be bribed + ODD("odd", 1.1f, 1.0f, 1.2f, 0), // Unpredictable (mood is random) + LAZY("lazy", 0.5f, 1.0f, 0.6f, 0), // Doesn't care much + GRUMPY("grumpy", 1.2f, 0.3f, 0.7f, -3), // Resists, doesn't flee + PEPPY("peppy", 1.0f, 1.2f, 1.3f, -1), // Energetic + + // Unknown/fallback + UNKNOWN("unknown", 1.0f, 1.0f, 1.0f, -3); + + private final String mcaId; + private final float struggleMultiplier; + private final float complianceMultiplier; + private final float fleeSpeedMultiplier; + private final int baseMoodTied; + + MCAPersonality( + String mcaId, + float struggle, + float compliance, + float flee, + int moodTied + ) { + this.mcaId = mcaId; + this.struggleMultiplier = struggle; + this.complianceMultiplier = compliance; + this.fleeSpeedMultiplier = flee; + this.baseMoodTied = moodTied; + } + + public String getMcaId() { + return mcaId; + } + + /** + * Get struggle effectiveness multiplier. + * Higher = struggles more effectively against bonds. + */ + public float getStruggleMultiplier() { + return struggleMultiplier; + } + + /** + * Get compliance multiplier. + * Higher = accepts restraints more easily (less resistance time). + */ + public float getComplianceMultiplier() { + return complianceMultiplier; + } + + /** + * Get flee/panic speed multiplier. + * Higher = runs away faster when panicking. + */ + public float getFleeSpeedMultiplier() { + return fleeSpeedMultiplier; + } + + /** + * Get base mood change when tied. + * Negative = unhappy, Positive = enjoys it. + */ + public int getBaseMoodTied() { + return baseMoodTied; + } + + /** + * Check if this personality generally enjoys being restrained. + */ + public boolean enjoysBondage() { + return baseMoodTied > 0; + } + + /** + * Check if this personality is unpredictable (ODD). + */ + public boolean isUnpredictable() { + return this == ODD; + } + + /** + * Find personality by MCA ID string (case-insensitive). + * + * @param id The MCA personality ID (e.g., "athletic", "shy") + * @return The matching personality, or UNKNOWN if not found + */ + public static MCAPersonality fromMcaId(String id) { + if (id == null) return UNKNOWN; + for (MCAPersonality p : values()) { + if (p.mcaId.equalsIgnoreCase(id)) { + return p; + } + } + return UNKNOWN; + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/personality/MCAPersonalityManager.java b/src/main/java/com/tiedup/remake/compat/mca/personality/MCAPersonalityManager.java new file mode 100644 index 0000000..15133a6 --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/personality/MCAPersonalityManager.java @@ -0,0 +1,251 @@ +package com.tiedup.remake.compat.mca.personality; + +import com.tiedup.remake.compat.mca.MCACompat; +import com.tiedup.remake.compat.mca.capability.MCAKidnappedCapability; +import com.tiedup.remake.core.TiedUpMod; +import java.lang.reflect.Method; +import java.util.Map; +import java.util.WeakHashMap; +import org.jetbrains.annotations.Nullable; +import net.minecraft.world.entity.LivingEntity; + +/** + * Manages MCA personality and TiedUp trait access for villagers. + * + *

Uses reflection to access MCA's VillagerBrain.getPersonality() method, + * since MCA is an optional dependency. + * + *

Also manages TiedUp traits stored in the villager's capability. + */ +public class MCAPersonalityManager { + + private static final MCAPersonalityManager INSTANCE = + new MCAPersonalityManager(); + + /** Cache personality per entity (weak refs to allow GC) */ + private final Map personalityCache = + new WeakHashMap<>(); + + /** Cached reflection objects */ + private Method getVillagerBrainMethod; + private Method getPersonalityMethod; + private boolean reflectionInitialized = false; + + public static MCAPersonalityManager getInstance() { + return INSTANCE; + } + + private MCAPersonalityManager() {} + + // ======================================== + // MCA PERSONALITY ACCESS (Reflection) + // ======================================== + + /** + * Get the MCA personality for a villager. + * Uses reflection to access MCA's VillagerBrain.getPersonality(). + * + * @param entity The MCA villager entity + * @return The personality, or UNKNOWN if not available + */ + public MCAPersonality getPersonality(LivingEntity entity) { + if (entity == null || !MCACompat.isMCAVillager(entity)) { + return MCAPersonality.UNKNOWN; + } + + // Check cache first + MCAPersonality cached = personalityCache.get(entity); + if (cached != null) { + return cached; + } + + // Try to get via reflection + String personalityId = getPersonalityIdViaReflection(entity); + MCAPersonality personality = MCAPersonality.fromMcaId(personalityId); + + // Cache result (even if UNKNOWN, to avoid repeated reflection failures) + personalityCache.put(entity, personality); + + return personality; + } + + /** + * Get raw personality ID string from MCA via reflection. + * + *

MCA structure: + *

    + *
  • entity.getVillagerBrain() -> VillagerBrain
  • + *
  • villagerBrain.getPersonality() -> Personality enum
  • + *
  • personality.name() -> String
  • + *
+ */ + @Nullable + private String getPersonalityIdViaReflection(LivingEntity entity) { + try { + initializeReflection(entity); + + if (getVillagerBrainMethod == null) { + return null; + } + + // Get VillagerBrain + Object brain = getVillagerBrainMethod.invoke(entity); + if (brain == null) { + return null; + } + + // Get personality from brain + if (getPersonalityMethod == null) { + // Find getPersonality method on brain + for (Method m : brain.getClass().getMethods()) { + if ( + m.getName().equals("getPersonality") && + m.getParameterCount() == 0 + ) { + getPersonalityMethod = m; + break; + } + } + } + + if (getPersonalityMethod == null) { + return null; + } + + Object personality = getPersonalityMethod.invoke(brain); + if (personality != null && personality.getClass().isEnum()) { + return personality.toString().toLowerCase(); + } + } catch (Exception e) { + TiedUpMod.LOGGER.debug( + "[MCA Personality] Failed to get personality for {}: {}", + entity.getName().getString(), + e.getMessage() + ); + } + + return null; + } + + private void initializeReflection(LivingEntity entity) { + if (reflectionInitialized) return; + reflectionInitialized = true; + + try { + // MCA villagers implement VillagerLike which has getVillagerBrain() + getVillagerBrainMethod = entity + .getClass() + .getMethod("getVillagerBrain"); + TiedUpMod.LOGGER.debug( + "[MCA Personality] Found getVillagerBrain method" + ); + } catch (NoSuchMethodException e) { + TiedUpMod.LOGGER.debug( + "[MCA Personality] getVillagerBrain not found" + ); + } + } + + // ======================================== + // TIEDUP TRAIT ACCESS (Capability) + // ======================================== + + /** + * Get the TiedUp trait for a villager. + * Stored in the villager's MCAKidnappedCapability. + * + * @param entity The MCA villager entity + * @return The trait, or NONE if not set + */ + public TiedUpTrait getTrait(LivingEntity entity) { + if (entity == null || !MCACompat.isMCAVillager(entity)) { + return TiedUpTrait.NONE; + } + + return entity + .getCapability(MCACompat.MCA_KIDNAPPED) + .map(MCAKidnappedCapability::getTrait) + .orElse(TiedUpTrait.NONE); + } + + /** + * Set the TiedUp trait for a villager. + * + * @param entity The MCA villager entity + * @param trait The trait to set + */ + public void setTrait(LivingEntity entity, TiedUpTrait trait) { + if (entity == null || !MCACompat.isMCAVillager(entity)) { + return; + } + + entity + .getCapability(MCACompat.MCA_KIDNAPPED) + .ifPresent(cap -> { + cap.setTrait(trait); + TiedUpMod.LOGGER.debug( + "[MCA Personality] Set trait {} for {}", + trait.getId(), + entity.getName().getString() + ); + }); + } + + // ======================================== + // COMBINED CALCULATIONS + // ======================================== + + /** + * Get combined struggle multiplier (personality * trait). + */ + public float getCombinedStruggle(LivingEntity entity) { + MCAPersonality personality = getPersonality(entity); + TiedUpTrait trait = getTrait(entity); + return TiedUpTrait.getCombinedStruggle(personality, trait); + } + + /** + * Get combined compliance multiplier (personality * trait). + */ + public float getCombinedCompliance(LivingEntity entity) { + MCAPersonality personality = getPersonality(entity); + TiedUpTrait trait = getTrait(entity); + return TiedUpTrait.getCombinedCompliance(personality, trait); + } + + /** + * Get combined mood change when tied (personality base + trait modifier). + */ + public int getCombinedMoodTied(LivingEntity entity) { + MCAPersonality personality = getPersonality(entity); + TiedUpTrait trait = getTrait(entity); + return TiedUpTrait.getCombinedMoodTied(personality, trait); + } + + /** + * Check if this villager generally enjoys being restrained + * (based on combined personality and trait). + */ + public boolean enjoysBondage(LivingEntity entity) { + return getCombinedMoodTied(entity) > 0; + } + + // ======================================== + // CACHE MANAGEMENT + // ======================================== + + /** + * Clear personality cache for an entity. + * Call this when entity is removed or personality might have changed. + */ + public void clearCache(LivingEntity entity) { + personalityCache.remove(entity); + } + + /** + * Clear entire personality cache. + */ + public void clearAllCache() { + personalityCache.clear(); + } +} diff --git a/src/main/java/com/tiedup/remake/compat/mca/personality/TiedUpTrait.java b/src/main/java/com/tiedup/remake/compat/mca/personality/TiedUpTrait.java new file mode 100644 index 0000000..f7de233 --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/mca/personality/TiedUpTrait.java @@ -0,0 +1,138 @@ +package com.tiedup.remake.compat.mca.personality; + +/** + * TiedUp-specific traits that combine with MCA base personalities. + * + *

These traits are stored in the MCA villager's capability (NBT) + * and can be assigned via command or acquired naturally through gameplay. + * + *

Multipliers combine with MCA personality: {@code final = personality * trait} + * + *

Trait Acquisition:

+ *
    + *
  • NONE - Default, no special trait
  • + *
  • MASO - Rare discovery or assigned (enjoys bondage)
  • + *
  • REBELLIOUS - After repeated failed captures or strong personality
  • + *
  • BROKEN - After very long captivity without liberation
  • + *
  • TRAINED - After prolonged captivity with good treatment (positive mood)
  • + *
+ */ +public enum TiedUpTrait { + /** No special trait - uses base personality only */ + NONE("none", 1.0f, 1.0f, 0), + + /** Masochistic - enjoys being restrained */ + MASO("maso", 0.3f, 2.0f, +5), + + /** Rebellious - always fights back */ + REBELLIOUS("rebellious", 1.8f, 0.3f, -5), + + /** Broken - has given up resisting (acquired only) */ + BROKEN("broken", 0.1f, 3.0f, 0), + + /** Trained - has accepted their situation (acquired only) */ + TRAINED("trained", 0.5f, 1.5f, +1); + + private final String id; + private final float struggleMultiplier; + private final float complianceMultiplier; + private final int moodModifier; + + TiedUpTrait(String id, float struggle, float compliance, int mood) { + this.id = id; + this.struggleMultiplier = struggle; + this.complianceMultiplier = compliance; + this.moodModifier = mood; + } + + public String getId() { + return id; + } + + /** + * Get struggle multiplier (combines with personality). + */ + public float getStruggleMultiplier() { + return struggleMultiplier; + } + + /** + * Get compliance multiplier (combines with personality). + */ + public float getComplianceMultiplier() { + return complianceMultiplier; + } + + /** + * Get mood modifier when tied. + * Added to personality's base mood change. + */ + public int getMoodModifier() { + return moodModifier; + } + + /** + * Check if this trait makes the villager enjoy bondage. + */ + public boolean enjoysBondage() { + return moodModifier > 0; + } + + /** + * Check if this trait represents a broken will. + */ + public boolean isBroken() { + return this == BROKEN; + } + + /** + * Find trait by ID string (case-insensitive). + * + * @param id The trait ID (e.g., "maso", "trained") + * @return The matching trait, or NONE if not found + */ + public static TiedUpTrait fromId(String id) { + if (id == null) return NONE; + for (TiedUpTrait t : values()) { + if (t.id.equalsIgnoreCase(id)) { + return t; + } + } + return NONE; + } + + /** + * Calculate combined struggle multiplier with personality. + */ + public static float getCombinedStruggle( + MCAPersonality personality, + TiedUpTrait trait + ) { + return ( + personality.getStruggleMultiplier() * trait.getStruggleMultiplier() + ); + } + + /** + * Calculate combined compliance multiplier with personality. + */ + public static float getCombinedCompliance( + MCAPersonality personality, + TiedUpTrait trait + ) { + return ( + personality.getComplianceMultiplier() * + trait.getComplianceMultiplier() + ); + } + + /** + * Calculate combined mood change when tied. + */ + public static int getCombinedMoodTied( + MCAPersonality personality, + TiedUpTrait trait + ) { + return personality.getBaseMoodTied() + trait.getMoodModifier(); + } +} diff --git a/src/main/java/com/tiedup/remake/compat/wildfire/WildfireCompat.java b/src/main/java/com/tiedup/remake/compat/wildfire/WildfireCompat.java new file mode 100644 index 0000000..e5704c3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/wildfire/WildfireCompat.java @@ -0,0 +1,294 @@ +package com.tiedup.remake.compat.wildfire; + +import com.tiedup.remake.compat.wildfire.physics.NpcBreastPhysics; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.AbstractTiedUpNpc; +import com.tiedup.remake.entities.skins.Gender; +import com.wildfire.main.GenderPlayer; +import com.wildfire.main.WildfireGender; +import java.util.Map; +import java.util.WeakHashMap; +import net.minecraft.world.entity.LivingEntity; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.EntityRenderersEvent; +import net.minecraftforge.fml.ModList; +import org.apache.commons.lang3.tuple.Pair; + +/** + * Compatibility module for Wildfire's Female Gender Mod. + */ +public class WildfireCompat { + + public static final String MOD_ID = "wildfire_gender"; + private static boolean loaded = false; + + // Cache for NPC physics to maintain state between frames + // Pair + private static final Map< + LivingEntity, + Pair + > NPC_PHYSICS_CACHE = new WeakHashMap<>(); + + public static void init() { + loaded = ModList.get().isLoaded(MOD_ID); + if (loaded) { + TiedUpMod.LOGGER.info( + "[Wildfire Compat] Wildfire's Gender Mod detected! Enabling physics." + ); + } + } + + public static boolean isLoaded() { + return loaded; + } + + /** + * Get physics instances for an entity. Creates them if needed. + */ + public static Pair getNpcPhysics( + LivingEntity entity, + GenderPlayer genderPlayer + ) { + if (!loaded || genderPlayer == null) return null; + + return NPC_PHYSICS_CACHE.computeIfAbsent(entity, e -> { + return Pair.of( + new NpcBreastPhysics(genderPlayer), + new NpcBreastPhysics(genderPlayer) + ); + }); + } + + // Default values based on user config (displayed values) + // bust size: 100% = 0.8 raw (Wildfire max) + // separation: -2 displayed = -0.2 raw + // height: 0, depth: 0, rotation: 0 + // dualphysics: yes = uniboob: false + private static final float DEFAULT_BUST_SIZE = 0.8f; + private static final float DEFAULT_SEPARATION = -0.2f; // -2 displayed + private static final float DEFAULT_BOUNCE = 0.34f; + private static final float DEFAULT_FLOPPY = 0.75f; + + /** + * Get or create GenderPlayer data for any entity with a UUID. + * Wildfire mod uses UUIDs key, so it works for non-players too! + * Respects entity gender (MALE/FEMALE) and randomizes bust size for females. + */ + public static GenderPlayer getGenderInfo(LivingEntity entity) { + if (!loaded) return null; + try { + GenderPlayer plr = WildfireGender.getOrAddPlayerById( + entity.getUUID() + ); + + if (plr != null && entity instanceof AbstractTiedUpNpc damsel) { + // Determine target gender from entity + Gender modGender = damsel.getGender(); + GenderPlayer.Gender targetGender = (modGender == Gender.MALE) + ? GenderPlayer.Gender.MALE + : GenderPlayer.Gender.FEMALE; + + if (plr.getGender() != targetGender) { + plr.updateGender(targetGender); + } + + // Initialize breast settings for females if not yet configured + // Check if bust size is at Wildfire default (0.6) which means not configured by us + if (targetGender == GenderPlayer.Gender.FEMALE) { + float currentBust = plr.getBustSize(); + // Wildfire default is 0.6f - if close to that, we haven't configured yet + boolean needsInit = Math.abs(currentBust - 0.6f) < 0.01f; + + if (needsInit) { + long seed = entity.getUUID().getLeastSignificantBits(); + java.util.Random rand = new java.util.Random(seed); + + // Bust size: 0.8 (100%) with slight random variation ±0.1 + // Clamped to Wildfire max of 0.8 + float bustVariation = (rand.nextFloat() - 0.5f) * 0.2f; // -0.1 to +0.1 + float randomSize = Math.min( + 0.8f, + DEFAULT_BUST_SIZE + bustVariation + ); + randomSize = Math.max(0.4f, randomSize); // min 50% displayed + plr.updateBustSize(randomSize); + + // Dual physics enabled (uniboob = false) + plr.getBreasts().updateUniboob(false); + + // Separation: -0.2 raw (-2 displayed) with slight variation ±0.05 + float sepVariation = (rand.nextFloat() - 0.5f) * 0.1f; // -0.05 to +0.05 + float separation = DEFAULT_SEPARATION + sepVariation; + separation = Math.max(-0.3f, Math.min(0f, separation)); // clamp -3 to 0 displayed + plr.getBreasts().updateXOffset(separation); + + // Height: 0 (no variation for now) + plr.getBreasts().updateYOffset(0f); + + // Depth: 0 (no variation for now) + plr.getBreasts().updateZOffset(0f); + + // Cleavage/rotation: 0 + plr.getBreasts().updateCleavage(0f); + + // Physics: slight random variation around defaults + float bounceVariation = + (rand.nextFloat() - 0.5f) * 0.1f; + float bounce = Math.max( + 0.2f, + Math.min(0.5f, DEFAULT_BOUNCE + bounceVariation) + ); + plr.updateBounceMultiplier(bounce); + + float floppyVariation = + (rand.nextFloat() - 0.5f) * 0.2f; + float floppy = Math.max( + 0.5f, + Math.min(1.0f, DEFAULT_FLOPPY + floppyVariation) + ); + plr.updateFloppiness(floppy); + + // Enable breast physics + plr.updateBreastPhysics(true); + } + } + } + return plr; + } catch (Exception e) { + return null; + } + } + + /** + * Force an entity to be female in Wildfire system. + * Also initializes physics/randomization if needed. + */ + public static void setGenderFemale(LivingEntity entity) { + if (!loaded) return; + try { + GenderPlayer plr = WildfireGender.getOrAddPlayerById( + entity.getUUID() + ); + if (plr != null) { + boolean changed = false; + if (plr.getGender() != GenderPlayer.Gender.FEMALE) { + plr.updateGender(GenderPlayer.Gender.FEMALE); + changed = true; + } + + // Initialize if gender changed or bust is at Wildfire default + float currentBust = plr.getBustSize(); + boolean needsInit = + changed || Math.abs(currentBust - 0.6f) < 0.01f; + + if (needsInit) { + long seed = entity.getUUID().getLeastSignificantBits(); + java.util.Random rand = new java.util.Random(seed); + + // Same logic as getGenderInfo + float bustVariation = (rand.nextFloat() - 0.5f) * 0.2f; + float randomSize = Math.min( + 0.8f, + DEFAULT_BUST_SIZE + bustVariation + ); + randomSize = Math.max(0.4f, randomSize); + plr.updateBustSize(randomSize); + + plr.getBreasts().updateUniboob(false); + + float sepVariation = (rand.nextFloat() - 0.5f) * 0.1f; + float separation = Math.max( + -0.3f, + Math.min(0f, DEFAULT_SEPARATION + sepVariation) + ); + plr.getBreasts().updateXOffset(separation); + + plr.getBreasts().updateYOffset(0f); + plr.getBreasts().updateZOffset(0f); + plr.getBreasts().updateCleavage(0f); + + float bounceVariation = (rand.nextFloat() - 0.5f) * 0.1f; + float bounce = Math.max( + 0.2f, + Math.min(0.5f, DEFAULT_BOUNCE + bounceVariation) + ); + plr.updateBounceMultiplier(bounce); + + float floppyVariation = (rand.nextFloat() - 0.5f) * 0.2f; + float floppy = Math.max( + 0.5f, + Math.min(1.0f, DEFAULT_FLOPPY + floppyVariation) + ); + plr.updateFloppiness(floppy); + + plr.updateBreastPhysics(true); + } + } + } catch (Exception e) { + TiedUpMod.LOGGER.debug( + "[WildfireCompat] Failed to update breast physics", + e + ); + } + } + + /** + * Register render layers for Wildfire compatibility. + * + * Adds WildfireDamselLayer to PlayerRenderers to render bondage textures + * on top of Wildfire's breast geometry for players. + * + * NOTE: TiedUp entities (Damsel, Kidnapper, etc.) have their WildfireDamselLayer + * added directly in DamselRenderer's constructor. + */ + @OnlyIn(Dist.CLIENT) + public static void registerRenderLayers( + EntityRenderersEvent.AddLayers event + ) { + if (!loaded) return; + + // Add WildfireDamselLayer to player renderers for breast-aware bind rendering + // This renders bind textures ON TOP of Wildfire's breast geometry + + // Default player renderer (Steve) + net.minecraft.client.renderer.entity.EntityRenderer< + ? extends net.minecraft.world.entity.player.Player + > defaultRenderer = event.getSkin("default"); + + if ( + defaultRenderer instanceof + net.minecraft.client.renderer.entity.player.PlayerRenderer playerRenderer + ) { + playerRenderer.addLayer( + new com.tiedup.remake.compat.wildfire.render.WildfireDamselLayer<>( + playerRenderer, + event.getEntityModels() + ) + ); + TiedUpMod.LOGGER.info( + "[Wildfire Compat] Added WildfireDamselLayer to default player renderer" + ); + } + + // Slim player renderer (Alex) + net.minecraft.client.renderer.entity.EntityRenderer< + ? extends net.minecraft.world.entity.player.Player + > slimRenderer = event.getSkin("slim"); + + if ( + slimRenderer instanceof + net.minecraft.client.renderer.entity.player.PlayerRenderer playerRenderer + ) { + playerRenderer.addLayer( + new com.tiedup.remake.compat.wildfire.render.WildfireDamselLayer<>( + playerRenderer, + event.getEntityModels() + ) + ); + TiedUpMod.LOGGER.info( + "[Wildfire Compat] Added WildfireDamselLayer to slim player renderer" + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/compat/wildfire/physics/NpcBreastPhysics.java b/src/main/java/com/tiedup/remake/compat/wildfire/physics/NpcBreastPhysics.java new file mode 100644 index 0000000..afbf8dc --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/wildfire/physics/NpcBreastPhysics.java @@ -0,0 +1,207 @@ +package com.tiedup.remake.compat.wildfire.physics; + +import com.wildfire.api.IGenderArmor; +import com.wildfire.main.GenderPlayer; +import com.wildfire.main.WildfireHelper; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Pose; +import net.minecraft.world.phys.Vec3; + +/** + * Re-implementation of Wildfire's BreastPhysics adapted for LivingEntity (NPCs). + * The original class strictly requires Player, which crashes for EntityDamsel. + */ +public class NpcBreastPhysics { + + private float bounceVel = 0, + targetBounceY = 0, + velocity = 0, + wfg_femaleBreast, + wfg_preBounce; + private float bounceRotVel = 0, + targetRotVel = 0, + rotVelocity = 0, + wfg_bounceRotation, + wfg_preBounceRotation; + private float bounceVelX = 0, + targetBounceX = 0, + velocityX = 0, + wfg_femaleBreastX, + wfg_preBounceX; + + private boolean justSneaking = false; // alreadySleeping logic removed (mobs rarely sleep in beds) + + private float breastSize = 0, + preBreastSize = 0; + + private Vec3 prePos; + private int lastTick = -1; + private final GenderPlayer genderPlayer; + + public NpcBreastPhysics(GenderPlayer genderPlayer) { + this.genderPlayer = genderPlayer; + } + + public void update(LivingEntity entity, IGenderArmor armor) { + // Run physics only once per tick to ensure correct motion delta calculation + if (entity.tickCount == this.lastTick) { + return; + } + this.lastTick = entity.tickCount; + + this.wfg_preBounce = this.wfg_femaleBreast; + this.wfg_preBounceX = this.wfg_femaleBreastX; + this.wfg_preBounceRotation = this.wfg_bounceRotation; + this.preBreastSize = this.breastSize; + + if (this.prePos == null) { + this.prePos = entity.position(); + return; + } + + // Logic copied and adapted from BreastPhysics.update + float breastWeight = genderPlayer.getBustSize() * 1.25f; + float targetBreastSize = genderPlayer.getBustSize(); + + if (!genderPlayer.getGender().canHaveBreasts()) { + targetBreastSize = 0; + } else if (!genderPlayer.getArmorPhysicsOverride()) { + float tightness = Mth.clamp(armor.tightness(), 0, 1); + targetBreastSize *= 1 - 0.15F * tightness; + } + + if (breastSize < targetBreastSize) { + breastSize += Math.abs(breastSize - targetBreastSize) / 2f; + } else { + breastSize -= Math.abs(breastSize - targetBreastSize) / 2f; + } + + Vec3 motion = entity.position().subtract(this.prePos); + this.prePos = entity.position(); + + float bounceIntensity = + (targetBreastSize * 3f) * genderPlayer.getBounceMultiplier(); + if (!genderPlayer.getArmorPhysicsOverride()) { + float resistance = Mth.clamp(armor.physicsResistance(), 0, 1); + bounceIntensity *= 1 - resistance; + } + + if (!genderPlayer.getBreasts().isUniboob()) { + bounceIntensity = + bounceIntensity * WildfireHelper.randFloat(0.5f, 1.5f); + } + + // Falling logic + if (entity.fallDistance > 0) { + // Random bounce removed for NPCs to avoid jitter, simplified falling + } + + this.targetBounceY = (float) motion.y * bounceIntensity; + this.targetBounceY += breastWeight; + + this.targetRotVel = + -((entity.yBodyRot - entity.yBodyRotO) / 15f) * bounceIntensity; + + // Walking bounce simulation + float f = (float) entity.getDeltaMovement().lengthSqr() / 0.2F; + f = f * f * f; + if (f < 1.0F) { + f = 1.0F; + } + + // Use walkAnimation position directly from LivingEntity + this.targetBounceY += + (Mth.cos( + entity.walkAnimation.position() * 0.6662F + (float) Math.PI + ) * + 0.5F * + entity.walkAnimation.speed() * + 0.5F) / + f; + + // Sneaking logic + if (entity.getPose() == Pose.CROUCHING && !this.justSneaking) { + this.justSneaking = true; + this.targetBounceY += bounceIntensity; + } + if (entity.getPose() != Pose.CROUCHING && this.justSneaking) { + this.justSneaking = false; + this.targetBounceY += bounceIntensity; + } + + // Physics calculation + float percent = genderPlayer.getFloppiness(); + float bounceAmount = 0.45f * (1f - percent) + 0.15f; + bounceAmount = Mth.clamp(bounceAmount, 0.15f, 0.6f); + float delta = 2.25f - bounceAmount; + + float distanceFromMin = Math.abs(bounceVel + 0.5f) * 0.5f; + float distanceFromMax = Math.abs(bounceVel - 2.65f) * 0.5f; + + if (bounceVel < -0.5f) { + targetBounceY += distanceFromMin; + } + if (bounceVel > 2.5f) { + targetBounceY -= distanceFromMax; + } + if (targetBounceY < -1.5f) targetBounceY = -1.5f; + if (targetBounceY > 2.5f) targetBounceY = 2.5f; + if (targetRotVel < -25f) targetRotVel = -25f; + if (targetRotVel > 25f) targetRotVel = 25f; + + this.velocity = Mth.lerp( + bounceAmount, + this.velocity, + (this.targetBounceY - this.bounceVel) * delta + ); + this.bounceVel += this.velocity * percent * 1.1625f; + + // X + this.velocityX = Mth.lerp( + bounceAmount, + this.velocityX, + (this.targetBounceX - this.bounceVelX) * delta + ); + this.bounceVelX += this.velocityX * percent; + + this.rotVelocity = Mth.lerp( + bounceAmount, + this.rotVelocity, + (this.targetRotVel - this.bounceRotVel) * delta + ); + this.bounceRotVel += this.rotVelocity * percent; + + this.wfg_bounceRotation = this.bounceRotVel; + this.wfg_femaleBreastX = this.bounceVelX; + this.wfg_femaleBreast = this.bounceVel; + } + + public float getBreastSize(float partialTicks) { + return Mth.lerp(partialTicks, preBreastSize, breastSize); + } + + public float getPreBounceY() { + return this.wfg_preBounce; + } + + public float getBounceY() { + return this.wfg_femaleBreast; + } + + public float getPreBounceX() { + return this.wfg_preBounceX; + } + + public float getBounceX() { + return this.wfg_femaleBreastX; + } + + public float getPreBounceRotation() { + return this.wfg_preBounceRotation; + } + + public float getBounceRotation() { + return this.wfg_bounceRotation; + } +} diff --git a/src/main/java/com/tiedup/remake/compat/wildfire/render/WildfireDamselLayer.java b/src/main/java/com/tiedup/remake/compat/wildfire/render/WildfireDamselLayer.java new file mode 100644 index 0000000..070ddee --- /dev/null +++ b/src/main/java/com/tiedup/remake/compat/wildfire/render/WildfireDamselLayer.java @@ -0,0 +1,986 @@ +package com.tiedup.remake.compat.wildfire.render; + +import com.mojang.blaze3d.systems.RenderSystem; +import com.tiedup.remake.v2.BodyRegionV2; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.tiedup.remake.compat.wildfire.WildfireCompat; +import com.tiedup.remake.compat.wildfire.physics.NpcBreastPhysics; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.entities.KidnapperItemSelector; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import com.wildfire.api.IGenderArmor; +import com.wildfire.main.Breasts; +import com.wildfire.main.GenderPlayer; +import com.wildfire.main.WildfireHelper; +import com.wildfire.main.config.GeneralClientConfig; +import com.wildfire.physics.BreastPhysics; +import com.wildfire.render.WildfireModelRenderer; +import com.wildfire.render.WildfireModelRenderer.BreastModelBox; +import com.wildfire.render.WildfireModelRenderer.OverlayModelBox; +import com.wildfire.render.WildfireModelRenderer.PositionTextureVertex; +import java.util.Locale; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import net.minecraft.client.Minecraft; +import net.minecraft.client.model.HumanoidModel; +import net.minecraft.client.model.geom.EntityModelSet; +import net.minecraft.client.model.geom.ModelPart; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.Sheets; +import net.minecraft.client.renderer.entity.LivingEntityRenderer; +import net.minecraft.client.renderer.entity.RenderLayerParent; +import net.minecraft.client.renderer.entity.layers.RenderLayer; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.client.renderer.texture.TextureAtlas; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.Mth; +import net.minecraft.world.effect.MobEffectUtil; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ArmorItem; +import net.minecraft.world.item.ArmorMaterial; +import net.minecraft.world.item.DyeableLeatherItem; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.armortrim.ArmorTrim; +import net.minecraft.world.level.block.Blocks; +import net.minecraftforge.client.ForgeHooksClient; +import net.minecraftforge.registries.ForgeRegistries; +import org.apache.commons.lang3.tuple.Pair; +import org.joml.Matrix3f; +import org.joml.Matrix4f; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +/** + * Adapted GenderLayer for EntityDamsel / EntityKidnapper. + */ +public class WildfireDamselLayer< + T extends LivingEntity, + M extends HumanoidModel +> extends RenderLayer { + + private final TextureAtlas armorTrimAtlas; + private final RenderLayerParent renderer; + + private BreastModelBox lBreast, rBreast; + private final OverlayModelBox lBreastWear, rBreastWear; + private final BreastModelBox lBoobArmor, rBoobArmor; + + private float preBreastSize = 0f; + + public WildfireDamselLayer( + RenderLayerParent renderer, + EntityModelSet modelSet + ) { + super(renderer); + this.renderer = renderer; + + this.armorTrimAtlas = Minecraft.getInstance() + .getModelManager() + .getAtlas(Sheets.ARMOR_TRIMS_SHEET); + + lBreast = new BreastModelBox( + 64, + 64, + 16, + 17, + -4F, + 0.0F, + 0F, + 4, + 5, + 4, + 0.0F, + false + ); + rBreast = new BreastModelBox( + 64, + 64, + 20, + 17, + 0, + 0.0F, + 0F, + 4, + 5, + 4, + 0.0F, + false + ); + + lBreastWear = new OverlayModelBox( + true, + 64, + 64, + 17, + 34, + -4F, + 0.0F, + 0F, + 4, + 5, + 3, + 0.0F, + false + ); + rBreastWear = new OverlayModelBox( + false, + 64, + 64, + 21, + 34, + 0, + 0.0F, + 0F, + 4, + 5, + 3, + 0.0F, + false + ); + + lBoobArmor = new BreastModelBox( + 64, + 32, + 16, + 17, + -4F, + 0.0F, + 0F, + 4, + 5, + 3, + 0.0F, + false + ); + rBoobArmor = new BreastModelBox( + 64, + 32, + 20, + 17, + 0, + 0.0F, + 0F, + 4, + 5, + 3, + 0.0F, + false + ); + } + + // Helper to get armor resource + public ResourceLocation getArmorResource( + T entity, + ItemStack stack, + EquipmentSlot slot, + @Nullable String type + ) { + ArmorItem item = (ArmorItem) stack.getItem(); + String texture = item.getMaterial().getName(); + String domain = "minecraft"; + int idx = texture.indexOf(':'); + if (idx != -1) { + domain = texture.substring(0, idx); + texture = texture.substring(idx + 1); + } + String s1 = String.format( + Locale.ROOT, + "%s:textures/models/armor/%s_layer_%d%s.png", + domain, + texture, + (slot == EquipmentSlot.LEGS ? 2 : 1), + type == null ? "" : String.format(Locale.ROOT, "_%s", type) + ); + + s1 = ForgeHooksClient.getArmorTexture(entity, stack, s1, slot, type); + return ResourceLocation.tryParse(s1); + } + + private static final ResourceLocation DEFAULT_TEXTURE = + ResourceLocation.withDefaultNamespace( + "textures/entity/player/wide/steve.png" + ); + + private ResourceLocation getEntityTexture(T entity) { + // Use ISkinnedEntity interface if available (EntityDamsel/EntityKidnapper) + if ( + entity instanceof com.tiedup.remake.entities.ISkinnedEntity skinned + ) { + ResourceLocation tex = skinned.getSkinTexture(); + if (tex != null) return tex; + } + + // Fallback to renderer + if ( + this.renderer instanceof + net.minecraft.client.renderer.entity.EntityRenderer + ) { + ResourceLocation rendererTex = ( + (net.minecraft.client.renderer.entity.EntityRenderer< + T + >) this.renderer + ).getTextureLocation(entity); + if (rendererTex != null) return rendererTex; + } + + return DEFAULT_TEXTURE; + } + + @Override + public void render( + @Nonnull PoseStack matrixStack, + @Nonnull MultiBufferSource bufferSource, + int packedLightIn, + @Nonnull T ent, + float limbAngle, + float limbDistance, + float partialTicks, + float animationProgress, + float headYaw, + float headPitch + ) { + // FIX: Check if Wildfire config is initialized before accessing it + if (GeneralClientConfig.INSTANCE == null) { + return; // Config not loaded yet, skip rendering + } + + if ( + GeneralClientConfig.INSTANCE.disableRendering.get() || + ent.isSpectator() + ) { + return; + } + + try { + GenderPlayer plr = WildfireCompat.getGenderInfo(ent); + if (plr == null) { + return; + } + + ItemStack armorStack = ent.getItemBySlot(EquipmentSlot.CHEST); + IGenderArmor genderArmor = WildfireHelper.getArmorConfig( + armorStack + ); + boolean isChestplateOccupied = genderArmor.coversBreasts(); + + if ( + genderArmor.alwaysHidesBreasts() || + (!plr.showBreastsInArmor() && isChestplateOccupied) + ) { + return; + } + + HumanoidModel model = this.getParentModel(); + + if (!plr.getGender().canHaveBreasts()) { + return; + } + + Breasts breasts = plr.getBreasts(); + float breastOffsetX = + Math.round( + (Math.round(breasts.getXOffset() * 100f) / 100f) * 10 + ) / + 10f; + float breastOffsetY = + -Math.round( + (Math.round(breasts.getYOffset() * 100f) / 100f) * 10 + ) / + 10f; + float breastOffsetZ = + -Math.round( + (Math.round(breasts.getZOffset() * 100f) / 100f) * 10 + ) / + 10f; + + BreastPhysics leftBreastPhysics = plr.getLeftBreastPhysics(); + + // Physics Handling + float bSize; + float lTotal = 0, + lTotalX = 0, + leftBounceRotation = 0; + float rTotal = 0, + rTotalX = 0, + rightBounceRotation = 0; + + if (ent instanceof net.minecraft.world.entity.player.Player) { + // For Players, use original physics + bSize = leftBreastPhysics.getBreastSize(partialTicks); + + lTotal = Mth.lerp( + partialTicks, + leftBreastPhysics.getPreBounceY(), + leftBreastPhysics.getBounceY() + ); + lTotalX = Mth.lerp( + partialTicks, + leftBreastPhysics.getPreBounceX(), + leftBreastPhysics.getBounceX() + ); + leftBounceRotation = Mth.lerp( + partialTicks, + leftBreastPhysics.getPreBounceRotation(), + leftBreastPhysics.getBounceRotation() + ); + + if (breasts.isUniboob()) { + rTotal = lTotal; + rTotalX = lTotalX; + rightBounceRotation = leftBounceRotation; + } else { + BreastPhysics rightBreastPhysics = + plr.getRightBreastPhysics(); + rTotal = Mth.lerp( + partialTicks, + rightBreastPhysics.getPreBounceY(), + rightBreastPhysics.getBounceY() + ); + rTotalX = Mth.lerp( + partialTicks, + rightBreastPhysics.getPreBounceX(), + rightBreastPhysics.getBounceX() + ); + rightBounceRotation = Mth.lerp( + partialTicks, + rightBreastPhysics.getPreBounceRotation(), + rightBreastPhysics.getBounceRotation() + ); + } + } else { + // For NPCs, use local physics simulation + Pair physics = + WildfireCompat.getNpcPhysics(ent, plr); + if (physics != null) { + NpcBreastPhysics left = physics.getLeft(); + NpcBreastPhysics right = physics.getRight(); + + // Update physics (ideally should be done in tick, but render update is acceptable for visual bounce) + // Note: This makes physics frame-rate dependent, but avoids complex tick handler setup + left.update(ent, genderArmor); + if (!breasts.isUniboob()) { + right.update(ent, genderArmor); + } + + bSize = left.getBreastSize(partialTicks); + + lTotal = Mth.lerp( + partialTicks, + left.getPreBounceY(), + left.getBounceY() + ); + lTotalX = Mth.lerp( + partialTicks, + left.getPreBounceX(), + left.getBounceX() + ); + leftBounceRotation = Mth.lerp( + partialTicks, + left.getPreBounceRotation(), + left.getBounceRotation() + ); + + if (breasts.isUniboob()) { + rTotal = lTotal; + rTotalX = lTotalX; + rightBounceRotation = leftBounceRotation; + } else { + rTotal = Mth.lerp( + partialTicks, + right.getPreBounceY(), + right.getBounceY() + ); + rTotalX = Mth.lerp( + partialTicks, + right.getPreBounceX(), + right.getBounceX() + ); + rightBounceRotation = Mth.lerp( + partialTicks, + right.getPreBounceRotation(), + right.getBounceRotation() + ); + } + } else { + bSize = plr.getBustSize(); // Fallback if physics creation fails + } + } + + float outwardAngle = + (Math.round(breasts.getCleavage() * 100f) / 100f) * 100f; + outwardAngle = Math.min(outwardAngle, 10); + + float reducer = 0; + if (bSize < 0.84f) reducer++; + if (bSize < 0.72f) reducer++; + + if (preBreastSize != bSize) { + lBreast = new BreastModelBox( + 64, + 64, + 16, + 17, + -4F, + 0.0F, + 0F, + 4, + 5, + (int) (4 - breastOffsetZ - reducer), + 0.0F, + false + ); + rBreast = new BreastModelBox( + 64, + 64, + 20, + 17, + 0, + 0.0F, + 0F, + 4, + 5, + (int) (4 - breastOffsetZ - reducer), + 0.0F, + false + ); + preBreastSize = bSize; + } + + float overlayAlpha = ent.isInvisible() ? 0.15F : 1; + RenderSystem.setShaderColor(1f, 1f, 1f, 1f); + + // Physics calculation handled above + float breastSize = bSize * 1.5f; + if (breastSize > 0.7f) breastSize = 0.7f; + if (bSize > 0.7f) { + breastSize = bSize; + } + + if (breastSize < 0.02f) return; + + float zOff = 0.0625f - (bSize * 0.0625f); + breastSize = bSize + 0.5f * Math.abs(bSize - 0.7f) * 2f; + + float resistance = plr.getArmorPhysicsOverride() + ? 0 + : Mth.clamp(genderArmor.physicsResistance(), 0, 1); + + boolean breathingAnimation = + resistance <= 0.5F && + (!ent.isUnderWater() || + MobEffectUtil.hasWaterBreathing(ent) || + ent + .level() + .getBlockState( + BlockPos.containing( + ent.getX(), + ent.getEyeY(), + ent.getZ() + ) + ) + .is(Blocks.BUBBLE_COLUMN)); + boolean bounceEnabled = + plr.hasBreastPhysics() && + (!isChestplateOccupied || resistance < 1); + + int combineTex = LivingEntityRenderer.getOverlayCoords(ent, 0); + ResourceLocation entityTexture = getEntityTexture(ent); + + RenderType type; + boolean bodyVisible = !ent.isInvisible(); + if (bodyVisible) { + type = RenderType.entityTranslucent(entityTexture); + } else { + if (!isChestplateOccupied) return; + type = null; + } + + renderBreastWithTransforms( + ent, + model.body, + armorStack, + matrixStack, + bufferSource, + type, + packedLightIn, + combineTex, + overlayAlpha, + bounceEnabled, + lTotalX, + lTotal, + leftBounceRotation, + breastSize, + breastOffsetX, + breastOffsetY, + breastOffsetZ, + zOff, + outwardAngle, + breasts.isUniboob(), + isChestplateOccupied, + breathingAnimation, + true + ); + renderBreastWithTransforms( + ent, + model.body, + armorStack, + matrixStack, + bufferSource, + type, + packedLightIn, + combineTex, + overlayAlpha, + bounceEnabled, + rTotalX, + rTotal, + rightBounceRotation, + breastSize, + -breastOffsetX, + breastOffsetY, + breastOffsetZ, + zOff, + -outwardAngle, + breasts.isUniboob(), + isChestplateOccupied, + breathingAnimation, + false + ); + + RenderSystem.setShaderColor(1f, 1f, 1f, 1f); + } catch (Exception e) { + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[WildfireDamselLayer] Failed to render gender layer", + e + ); + } + } + + private void renderBreastWithTransforms( + T entity, + ModelPart body, + ItemStack armorStack, + PoseStack matrixStack, + MultiBufferSource bufferSource, + @Nullable RenderType breastRenderType, + int packedLightIn, + int combineTex, + float alpha, + boolean bounceEnabled, + float totalX, + float total, + float bounceRotation, + float breastSize, + float breastOffsetX, + float breastOffsetY, + float breastOffsetZ, + float zOff, + float outwardAngle, + boolean uniboob, + boolean isChestplateOccupied, + boolean breathingAnimation, + boolean left + ) { + matrixStack.pushPose(); + try { + // Transform to Body + matrixStack.translate( + body.x * 0.0625f, + body.y * 0.0625f, + body.z * 0.0625f + ); + if (body.zRot != 0.0F) { + matrixStack.mulPose( + new Quaternionf().rotationXYZ(0f, 0f, body.zRot) + ); + } + if (body.yRot != 0.0F) { + matrixStack.mulPose( + new Quaternionf().rotationXYZ(0f, body.yRot, 0f) + ); + } + if (body.xRot != 0.0F) { + matrixStack.mulPose( + new Quaternionf().rotationXYZ(body.xRot, 0f, 0f) + ); + } + + // --- Bouncing & Positioning logic same as original --- + if (bounceEnabled) { + matrixStack.translate(totalX / 32f, 0, 0); + matrixStack.translate(0, total / 32f, 0); + } + + matrixStack.translate( + breastOffsetX * 0.0625f, + 0.05625f + (breastOffsetY * 0.0625f), + zOff - 0.0625f * 2f + (breastOffsetZ * 0.0625f) + ); + + if (!uniboob) { + matrixStack.translate(-0.0625f * 2 * (left ? 1 : -1), 0, 0); + } + if (bounceEnabled) { + matrixStack.mulPose( + new Quaternionf().rotationXYZ( + 0, + (float) (bounceRotation * (Math.PI / 180f)), + 0 + ) + ); + } + if (!uniboob) { + matrixStack.translate(0.0625f * 2 * (left ? 1 : -1), 0, 0); + } + + float rotationMultiplier = 0; + if (bounceEnabled) { + matrixStack.translate(0, -0.035f * breastSize, 0); + rotationMultiplier = -total / 12f; + } + float totalRotation = breastSize + rotationMultiplier; + if (!bounceEnabled) { + totalRotation = breastSize; + } + if (totalRotation > breastSize + 0.2F) { + totalRotation = breastSize + 0.2F; + } + totalRotation = Math.min(totalRotation, 1); + + if (isChestplateOccupied) { + matrixStack.translate(0, 0, 0.01f); + } + + matrixStack.mulPose( + new Quaternionf().rotationXYZ( + 0, + (float) (outwardAngle * (Math.PI / 180f)), + 0 + ) + ); + matrixStack.mulPose( + new Quaternionf().rotationXYZ( + (float) (-35f * totalRotation * (Math.PI / 180f)), + 0, + 0 + ) + ); + + if (breathingAnimation) { + float f5 = -Mth.cos(entity.tickCount * 0.09F) * 0.45F + 0.45F; + matrixStack.mulPose( + new Quaternionf().rotationXYZ( + (float) (f5 * (Math.PI / 180f)), + 0, + 0 + ) + ); + } + + matrixStack.scale(0.9995f, 1f, 1f); + + renderBreast( + entity, + armorStack, + matrixStack, + bufferSource, + breastRenderType, + packedLightIn, + combineTex, + alpha, + left + ); + } catch (Exception e) { + com.tiedup.remake.core.TiedUpMod.LOGGER.error( + "[WildfireDamselLayer] Render error", + e + ); + } + matrixStack.popPose(); + } + + private void renderBreast( + T entity, + ItemStack armorStack, + PoseStack matrixStack, + MultiBufferSource bufferSource, + @Nullable RenderType breastRenderType, + int packedLightIn, + int packedOverlayIn, + float alpha, + boolean left + ) { + if (breastRenderType != null) { + VertexConsumer vertexConsumer = bufferSource.getBuffer( + breastRenderType + ); + renderBox( + left ? lBreast : rBreast, + matrixStack, + vertexConsumer, + packedLightIn, + packedOverlayIn, + 1F, + 1F, + 1F, + alpha + ); + + // Render Wear (jacket overlay layer) - NPC skins are 64x64 and include this area + matrixStack.translate(0, 0, -0.015f); + matrixStack.scale(1.05f, 1.05f, 1.05f); + renderBox( + left ? lBreastWear : rBreastWear, + matrixStack, + vertexConsumer, + packedLightIn, + packedOverlayIn, + 1F, + 1F, + 1F, + alpha + ); + } + + // Armor Rendering + if ( + !armorStack.isEmpty() && + armorStack.getItem() instanceof ArmorItem armorItem + ) { + ResourceLocation armorTexture = getArmorResource( + entity, + armorStack, + EquipmentSlot.CHEST, + null + ); + ResourceLocation overlayTexture = null; + float armorR = 1f; + float armorG = 1f; + float armorB = 1f; + if (armorItem instanceof DyeableLeatherItem dyeableItem) { + overlayTexture = getArmorResource( + entity, + armorStack, + EquipmentSlot.CHEST, + "overlay" + ); + int color = dyeableItem.getColor(armorStack); + armorR = (float) ((color >> 16) & 255) / 255.0F; + armorG = (float) ((color >> 8) & 255) / 255.0F; + armorB = (float) (color & 255) / 255.0F; + } + matrixStack.pushPose(); + matrixStack.translate(left ? 0.001f : -0.001f, 0.015f, -0.015f); + matrixStack.scale(1.05f, 1, 1); + WildfireModelRenderer.BreastModelBox armor = left + ? lBoobArmor + : rBoobArmor; + RenderType armorType = RenderType.armorCutoutNoCull(armorTexture); + VertexConsumer armorVertexConsumer = bufferSource.getBuffer( + armorType + ); + renderBox( + armor, + matrixStack, + armorVertexConsumer, + packedLightIn, + OverlayTexture.NO_OVERLAY, + armorR, + armorG, + armorB, + 1 + ); + if (overlayTexture != null) { + RenderType overlayType = RenderType.armorCutoutNoCull( + overlayTexture + ); + VertexConsumer overlayVertexConsumer = bufferSource.getBuffer( + overlayType + ); + renderBox( + armor, + matrixStack, + overlayVertexConsumer, + packedLightIn, + OverlayTexture.NO_OVERLAY, + 1, + 1, + 1, + 1 + ); + } + + ArmorTrim.getTrim( + entity.level().registryAccess(), + armorStack + ).ifPresent(trim -> { + ArmorMaterial armorMaterial = armorItem.getMaterial(); + TextureAtlasSprite sprite = this.armorTrimAtlas.getSprite( + trim.outerTexture(armorMaterial) + ); + VertexConsumer trimVertexConsumer = sprite.wrap( + bufferSource.getBuffer(Sheets.armorTrimsSheet()) + ); + renderBox( + armor, + matrixStack, + trimVertexConsumer, + packedLightIn, + OverlayTexture.NO_OVERLAY, + 1, + 1, + 1, + 1 + ); + }); + + if (armorStack.hasFoil()) { + renderBox( + armor, + matrixStack, + bufferSource.getBuffer(RenderType.armorEntityGlint()), + packedLightIn, + OverlayTexture.NO_OVERLAY, + 1, + 1, + 1, + 1 + ); + } + + matrixStack.popPose(); + } + + // Bondage Item Rendering - render bind texture on breasts + ItemStack bindStack = getBondageBindItem(entity); + if (!bindStack.isEmpty()) { + ResourceLocation bindTexture = getBindTextureForItem(bindStack); + if (bindTexture != null) { + matrixStack.pushPose(); + matrixStack.translate(left ? 0.001f : -0.001f, 0.015f, -0.025f); + matrixStack.scale(1.08f, 1.02f, 1.02f); + WildfireModelRenderer.BreastModelBox bondageBox = left + ? lBoobArmor + : rBoobArmor; + RenderType bindType = RenderType.entityCutoutNoCull( + bindTexture + ); + VertexConsumer bindVertexConsumer = bufferSource.getBuffer( + bindType + ); + renderBox( + bondageBox, + matrixStack, + bindVertexConsumer, + packedLightIn, + OverlayTexture.NO_OVERLAY, + 1, + 1, + 1, + 1 + ); + matrixStack.popPose(); + } + } + } + + /** + * Get the bondage bind item from an entity. + * Works for both NPCs (EntityDamsel) and Players (via IBondageState). + */ + private ItemStack getBondageBindItem(T entity) { + // For NPCs (AbstractTiedUpNpc and subclasses) + if (entity instanceof com.tiedup.remake.entities.AbstractTiedUpNpc npc) { + return npc.getEquipment(BodyRegionV2.ARMS); + } + + // For Players (via capability) + if (entity instanceof net.minecraft.world.entity.player.Player player) { + IBondageState state = KidnappedHelper.getKidnappedState(player); + if (state != null) { + return state.getEquipment(BodyRegionV2.ARMS); + } + } + + return ItemStack.EMPTY; + } + + /** + * Get the texture for a bondage bind item. + * Uses same path logic as DamselBondageLayer. + */ + @Nullable + private ResourceLocation getBindTextureForItem(ItemStack stack) { + if (stack.isEmpty()) { + return null; + } + + ResourceLocation itemId = ForgeRegistries.ITEMS.getKey(stack.getItem()); + if (itemId == null) { + return null; + } + + String itemName = itemId.getPath(); + + // V2 items use GLB models, not texture subfolders — fallback to "binds" + String subfolder = "binds"; + + // Get color suffix from NBT (e.g., "_red", "_blue", or "" if no color) + String colorSuffix = KidnapperItemSelector.getColorSuffix(stack); + + return ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/models/bondage/" + + subfolder + + "/" + + itemName + + colorSuffix + + ".png" + ); + } + + private static void renderBox( + WildfireModelRenderer.ModelBox model, + PoseStack matrixStack, + VertexConsumer bufferIn, + int packedLightIn, + int packedOverlayIn, + float red, + float green, + float blue, + float alpha + ) { + Matrix4f matrix4f = matrixStack.last().pose(); + Matrix3f matrix3f = matrixStack.last().normal(); + for (WildfireModelRenderer.TexturedQuad quad : model.quads) { + Vector3f vector3f = new Vector3f( + quad.normal.getX(), + quad.normal.getY(), + quad.normal.getZ() + ); + vector3f.mul(matrix3f); + for (PositionTextureVertex vertex : quad.vertexPositions) { + bufferIn.vertex( + matrix4f, + vertex.x() / 16.0F, + vertex.y() / 16.0F, + vertex.z() / 16.0F + ); + bufferIn.color(red, green, blue, alpha); + bufferIn.uv( + vertex.texturePositionX(), + vertex.texturePositionY() + ); + bufferIn.overlayCoords(packedOverlayIn); + bufferIn.uv2(packedLightIn); + bufferIn.normal(vector3f.x(), vector3f.y(), vector3f.z()); + bufferIn.endVertex(); + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/core/ChokeEffect.java b/src/main/java/com/tiedup/remake/core/ChokeEffect.java new file mode 100644 index 0000000..c86f36b --- /dev/null +++ b/src/main/java/com/tiedup/remake/core/ChokeEffect.java @@ -0,0 +1,84 @@ +package com.tiedup.remake.core; + +import com.tiedup.remake.entities.EntityMaster; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.effect.MobEffect; +import net.minecraft.world.effect.MobEffectCategory; +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; + +public class ChokeEffect extends MobEffect { + + private static final int AIR_DRAIN_PER_TICK = 8; // net -4 after vanilla +4 restoration + + public ChokeEffect() { + super(MobEffectCategory.HARMFUL, 0x5C3317); // dark brown (leather) + } + + @Override + public void applyEffectTick(LivingEntity entity, int amplifier) { + // Drain air (runs AFTER baseTick which restores +4) + int air = entity.getAirSupply(); + int drain = AIR_DRAIN_PER_TICK + amplifier * 4; + entity.setAirSupply(Math.max(-20, air - drain)); + + // Darkness when air gets low + if (air < 200 && entity.tickCount % 40 == 0) { + entity.addEffect( + new MobEffectInstance(MobEffects.DARKNESS, 60, 0, false, false) + ); + } + + // Slowness in advanced phase + if (air < 100 && entity.tickCount % 40 == 0) { + entity.addEffect( + new MobEffectInstance( + MobEffects.MOVEMENT_SLOWDOWN, + 60, + 0, + false, + false + ) + ); + } + + // Damage when air exhausted (every 20 ticks) + if (air <= 0 && entity.tickCount % 20 == 0) { + // Non-lethal when used by Master (safety: pet cannot die from master's choke) + boolean masterOwned = + entity instanceof Player p && + EntityMaster.getMasterUUID(p) != null; + if (masterOwned && entity.getHealth() <= 2.0f) { + if (entity.getHealth() > 1.0f) { + entity.setHealth(1.0f); + } + } else { + entity.hurt(entity.damageSources().generic(), 2.0f); + } + } + + // Sound every 20 ticks + if (entity.tickCount % 20 == 0) { + entity + .level() + .playSound( + null, + entity.getX(), + entity.getY(), + entity.getZ(), + SoundEvents.PLAYER_HURT, + SoundSource.PLAYERS, + 0.6f, + 0.5f + entity.getRandom().nextFloat() * 0.2f + ); + } + } + + @Override + public boolean isDurationEffectTick(int duration, int amplifier) { + return true; // tick every tick + } +} diff --git a/src/main/java/com/tiedup/remake/core/ModConfig.java b/src/main/java/com/tiedup/remake/core/ModConfig.java new file mode 100644 index 0000000..1f68afc --- /dev/null +++ b/src/main/java/com/tiedup/remake/core/ModConfig.java @@ -0,0 +1,734 @@ +package com.tiedup.remake.core; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.minecraftforge.common.ForgeConfigSpec; +import org.apache.commons.lang3.tuple.Pair; + +/** + * Centralized configuration for TiedUp! Remake. + * Handles both Server (COMMON) and Client configurations. + */ +public class ModConfig { + + public static final ServerConfig SERVER; + public static final ForgeConfigSpec SERVER_SPEC; + + public static final ClientConfig CLIENT; + public static final ForgeConfigSpec CLIENT_SPEC; + + static { + final Pair serverSpecPair = + new ForgeConfigSpec.Builder().configure(ServerConfig::new); + SERVER = serverSpecPair.getLeft(); + SERVER_SPEC = serverSpecPair.getRight(); + + final Pair clientSpecPair = + new ForgeConfigSpec.Builder().configure(ClientConfig::new); + CLIENT = clientSpecPair.getLeft(); + CLIENT_SPEC = clientSpecPair.getRight(); + } + + public static class ServerConfig { + + // === General Mechanics === + public final ForgeConfigSpec.IntValue tyingTime; + public final ForgeConfigSpec.IntValue untyingTime; + public final ForgeConfigSpec.BooleanValue struggleEnabled; + public final ForgeConfigSpec.IntValue struggleProbability; + public final ForgeConfigSpec.IntValue struggleTimer; + public final ForgeConfigSpec.IntValue struggleMinDecrease; + public final ForgeConfigSpec.IntValue struggleMaxDecrease; + public final ForgeConfigSpec.IntValue struggleCollarRandomShock; + + // === Phase 2: Mini-Game Settings === + public final ForgeConfigSpec.BooleanValue struggleMiniGameEnabled; + public final ForgeConfigSpec.BooleanValue lockpickMiniGameEnabled; + public final ForgeConfigSpec.IntValue kidnapperDetectionRadius; + public final ForgeConfigSpec.IntValue exhaustionCooldownSeconds; + + // === Restrictions === + public final ForgeConfigSpec.DoubleValue tiedSwimSpeedMultiplier; + public final ForgeConfigSpec.IntValue messageCooldown; + + // === Kidnapping & Bounties === + public final ForgeConfigSpec.BooleanValue enslavementEnabled; + public final ForgeConfigSpec.BooleanValue forcedSeatingEnabled; + public final ForgeConfigSpec.IntValue maxBountiesPerPlayer; + public final ForgeConfigSpec.IntValue bountyDuration; + public final ForgeConfigSpec.IntValue bountyDeliveryRadius; + public final ForgeConfigSpec.IntValue kidnapBombRadius; + public final ForgeConfigSpec.IntValue kidnapBombFuse; + + // === NPCs === + public final ForgeConfigSpec.BooleanValue damselsSpawn; + public final ForgeConfigSpec.BooleanValue kidnappersSpawn; + public final ForgeConfigSpec.DoubleValue kidnapperMaxHealth; + public final ForgeConfigSpec.DoubleValue kidnapperSpeed; + public final ForgeConfigSpec.DoubleValue kidnapperDamage; + public final ForgeConfigSpec.DoubleValue kidnapperFollowRange; + public final ForgeConfigSpec.IntValue merchantHostileDuration; + public final ForgeConfigSpec.EnumValue spawnGenderMode; + + // === Spawning Configuration === + public final ForgeConfigSpec.IntValue damselSpawnRate; + public final ForgeConfigSpec.IntValue kidnapperSpawnRate; + public final ForgeConfigSpec.IntValue kidnapperArcherSpawnRate; + public final ForgeConfigSpec.IntValue kidnapperEliteSpawnRate; + public final ForgeConfigSpec.IntValue kidnapperMerchantSpawnRate; + public final ForgeConfigSpec.IntValue masterSpawnRate; + + public enum SpawnGenderMode { + BOTH, + FEMALE_ONLY, + MALE_ONLY, + } + + // === Economy === + public final ForgeConfigSpec.IntValue merchantMinTrades; + public final ForgeConfigSpec.IntValue merchantMaxTrades; + // Tier prices (in nuggets) + public final ForgeConfigSpec.IntValue tier1PriceMin; + public final ForgeConfigSpec.IntValue tier1PriceMax; + public final ForgeConfigSpec.IntValue tier2PriceMin; + public final ForgeConfigSpec.IntValue tier2PriceMax; + public final ForgeConfigSpec.IntValue tier3PriceMin; + public final ForgeConfigSpec.IntValue tier3PriceMax; + public final ForgeConfigSpec.IntValue tier4PriceMin; + public final ForgeConfigSpec.IntValue tier4PriceMax; + + // === Combat & Tools === + public final ForgeConfigSpec.DoubleValue whipDamage; + public final ForgeConfigSpec.IntValue whipResistanceDecrease; + + public final ForgeConfigSpec.IntValue chloroformDurability; + public final ForgeConfigSpec.IntValue ragWetTime; + public final ForgeConfigSpec.IntValue chloroformEffectDuration; + + public final ForgeConfigSpec.IntValue ropeArrowBindChance; + public final ForgeConfigSpec.IntValue archerArrowBindChanceBase; + public final ForgeConfigSpec.IntValue archerArrowBindChancePerHit; + + public final ForgeConfigSpec.IntValue shockerControllerRadius; + + public final ForgeConfigSpec.DoubleValue taserDamage; + public final ForgeConfigSpec.IntValue taserStunDuration; + + public final ForgeConfigSpec.IntValue lockpickSuccessChance; + public final ForgeConfigSpec.IntValue lockpickBreakChance; + public final ForgeConfigSpec.DoubleValue lockpickJamChance; + + // === Resistances === + public final Map bindResistances = + new HashMap<>(); + public final ForgeConfigSpec.IntValue padlockResistance; + + // === Gags === + public final ForgeConfigSpec.BooleanValue gagTalkProximity; + public final Map gagComprehension = + new HashMap<>(); + public final Map gagRange = + new HashMap<>(); + + // === Dialogue === + public final ForgeConfigSpec.IntValue dialogueRadius; + public final ForgeConfigSpec.IntValue dialogueCooldown; + + // === Kidnapper Lairs (Phase 4) === + public final ForgeConfigSpec.BooleanValue enableLairs; + public final ForgeConfigSpec.DoubleValue lairDensity; + + // === Kidnapper Capture (Phase 4) === + public final ForgeConfigSpec.BooleanValue transportToLair; + public final ForgeConfigSpec.BooleanValue confiscateInventory; + + // === Kidnapper Ransom (Phase 4) === + public final ForgeConfigSpec.BooleanValue enableRansom; + public final ForgeConfigSpec.IntValue ransomAmount; + public final ForgeConfigSpec.ConfigValue ransomItem; + public final ForgeConfigSpec.IntValue ransomTimeoutMinutes; + + // === Kidnapper Timeout (Phase 4) === + public final ForgeConfigSpec.IntValue maxCaptivityMinutes; + public final ForgeConfigSpec.BooleanValue releaseWithInventory; + public final ForgeConfigSpec.IntValue laborRestSeconds; + + // === Kidnapper Struggle (Phase 4) === + public final ForgeConfigSpec.BooleanValue struggleMakesNoise; + + // === Kidnapper AI (Phase 4) === + public final ForgeConfigSpec.BooleanValue enablePunishment; + + // === Kidnapper Abandon (Phase 4) === + public final ForgeConfigSpec.IntValue abandonTeleportRadius; + public final ForgeConfigSpec.BooleanValue abandonKeepsBlindfold; + public final ForgeConfigSpec.BooleanValue abandonKeepsBinds; + + // === Kidnapper Solo Mode (Phase 4) === + public final ForgeConfigSpec.BooleanValue enableSoloFallback; + public final ForgeConfigSpec.DoubleValue soloKeepChance; + public final ForgeConfigSpec.DoubleValue soloAbandonChance; + public final ForgeConfigSpec.IntValue soloTimeoutSeconds; + + // === Master NPC (Pet Play) === + public final ForgeConfigSpec.BooleanValue enableMasterSpawn; + public final ForgeConfigSpec.IntValue masterPetCollarResistance; + public final ForgeConfigSpec.DoubleValue masterSlaveDamageMultiplier; + public final ForgeConfigSpec.IntValue masterMinDistractionInterval; + public final ForgeConfigSpec.IntValue masterMaxDistractionInterval; + public final ForgeConfigSpec.IntValue masterMinDistractionDuration; + public final ForgeConfigSpec.IntValue masterMaxDistractionDuration; + + // === Cells (Phase 4) === + public final ForgeConfigSpec.IntValue cellMaxWallDistance; + public final ForgeConfigSpec.IntValue maxPrisonersPerCell; + public final ForgeConfigSpec.BooleanValue deleteMarkerOnCellDelete; + public final ForgeConfigSpec.BooleanValue autoRemoveDestroyedWalls; + + public ServerConfig(ForgeConfigSpec.Builder builder) { + builder.push("General"); + tyingTime = builder + .comment("Time in seconds to tie up a player") + .defineInRange("tyingTime", 5, 1, 60); + untyingTime = builder + .comment("Time in seconds to untie a player") + .defineInRange("untyingTime", 10, 1, 60); + struggleEnabled = builder + .comment("Enable struggle mechanic") + .define("struggleEnabled", true); + struggleProbability = builder + .comment("Chance (0-100) to succeed in a struggle attempt") + .defineInRange("struggleProbability", 40, 0, 100); + struggleTimer = builder + .comment("Cooldown between struggle attempts in ticks") + .defineInRange("struggleTimer", 80, 1, 200); + struggleMinDecrease = builder + .comment("Minimum resistance removed per successful struggle") + .defineInRange("struggleMinDecrease", 1, 1, 100); + struggleMaxDecrease = builder + .comment("Maximum resistance removed per successful struggle") + .defineInRange("struggleMaxDecrease", 10, 1, 100); + struggleCollarRandomShock = builder + .comment("Probability % of random shock during collar struggle") + .defineInRange("struggleCollarRandomShock", 20, 0, 100); + + builder.push("MiniGames"); + struggleMiniGameEnabled = builder + .comment( + "Enable the struggle mini-game (QTE sequence) instead of RNG-based struggle" + ) + .define("struggleMiniGameEnabled", true); + lockpickMiniGameEnabled = builder + .comment( + "Enable the lockpick mini-game (sweet spot) instead of RNG-based lockpicking" + ) + .define("lockpickMiniGameEnabled", true); + kidnapperDetectionRadius = builder + .comment( + "Radius in blocks where kidnappers penalize struggle mini-game" + ) + .defineInRange("kidnapperDetectionRadius", 10, 0, 50); + exhaustionCooldownSeconds = builder + .comment("Cooldown in seconds after mini-game exhaustion") + .defineInRange("exhaustionCooldownSeconds", 30, 5, 120); + builder.pop(); + builder.pop(); + + builder.push("Restrictions"); + tiedSwimSpeedMultiplier = builder + .comment( + "Swim speed multiplier when legs are bound (0.0 - 1.0)" + ) + .defineInRange("tiedSwimSpeedMultiplier", 0.5, 0.0, 1.0); + messageCooldown = builder + .comment("Cooldown in ms for restriction chat messages") + .defineInRange("messageCooldown", 2000, 0, 10000); + builder.pop(); + + builder.push("Kidnapping"); + enslavementEnabled = builder + .comment("Enable Master/Slave system") + .define("enslavementEnabled", true); + forcedSeatingEnabled = builder + .comment("Enable Forced Seating mechanics (ALT+Click)") + .define("forcedSeatingEnabled", true); + maxBountiesPerPlayer = builder + .comment("Max active bounties per player") + .defineInRange("maxBountiesPerPlayer", 5, 1, 50); + bountyDuration = builder + .comment("Duration of bounties in seconds") + .defineInRange("bountyDuration", 14400, 60, 86400); // 4 hours default + bountyDeliveryRadius = builder + .comment("Radius in blocks to deliver captive for bounty") + .defineInRange("bountyDeliveryRadius", 5, 1, 20); + kidnapBombRadius = builder + .comment("Explosion radius for kidnap bombs") + .defineInRange("kidnapBombRadius", 5, 1, 20); + kidnapBombFuse = builder + .comment("Fuse time for kidnap bombs in ticks") + .defineInRange("kidnapBombFuse", 80, 20, 600); + builder.pop(); + + builder.push("NPCs"); + damselsSpawn = builder + .comment("Enable natural spawning of Damsels") + .define("damselsSpawn", true); + kidnappersSpawn = builder + .comment( + "Enable natural spawning of Kidnappers (includes Elite, Archer, Merchant)" + ) + .define("kidnappersSpawn", true); + kidnapperMaxHealth = builder + .comment("Max Health for Kidnappers") + .defineInRange("kidnapperMaxHealth", 20.0, 1.0, 100.0); + kidnapperSpeed = builder + .comment("Movement Speed for Kidnappers") + .defineInRange("kidnapperSpeed", 0.27, 0.1, 1.0); + kidnapperDamage = builder + .comment("Attack Damage for Kidnappers") + .defineInRange("kidnapperDamage", 6.0, 1.0, 20.0); + kidnapperFollowRange = builder + .comment("Follow Range for Kidnappers") + .defineInRange("kidnapperFollowRange", 60.0, 16.0, 128.0); + merchantHostileDuration = builder + .comment("Ticks a Merchant stays hostile after being attacked") + .defineInRange("merchantHostileDuration", 6000, 0, 72000); // 5 mins + + builder.push("Spawning"); + spawnGenderMode = builder + .comment( + "Gender spawn mode for NPCs: BOTH, FEMALE_ONLY, MALE_ONLY" + ) + .defineEnum("spawnGenderMode", SpawnGenderMode.BOTH); + + // NPC spawn rates (0-100 percentage multiplier) + damselSpawnRate = builder + .comment( + "Spawn rate for Damsels (0-100, percentage of base spawn weight)" + ) + .defineInRange("damselSpawnRate", 60, 0, 100); + kidnapperSpawnRate = builder + .comment( + "Spawn rate for base Kidnappers (0-100, percentage of base spawn weight)" + ) + .defineInRange("kidnapperSpawnRate", 70, 0, 100); + kidnapperArcherSpawnRate = builder + .comment( + "Spawn rate for Kidnapper Archers (0-100, percentage of base spawn weight)" + ) + .defineInRange("kidnapperArcherSpawnRate", 70, 0, 100); + kidnapperEliteSpawnRate = builder + .comment( + "Spawn rate for Kidnapper Elites (0-100, percentage of base spawn weight)" + ) + .defineInRange("kidnapperEliteSpawnRate", 70, 0, 100); + kidnapperMerchantSpawnRate = builder + .comment( + "Spawn rate for Kidnapper Merchants (0-100, percentage of base spawn weight)" + ) + .defineInRange("kidnapperMerchantSpawnRate", 70, 0, 100); + masterSpawnRate = builder + .comment( + "Spawn rate for Masters (0-100, percentage of base spawn weight)" + ) + .defineInRange("masterSpawnRate", 100, 0, 100); + + builder.pop(); + builder.pop(); + + builder.push("Economy"); + merchantMinTrades = builder + .comment("Min number of trades offered by Merchant") + .defineInRange("merchantMinTrades", 8, 1, 20); + merchantMaxTrades = builder + .comment("Max number of trades offered by Merchant") + .defineInRange("merchantMaxTrades", 12, 1, 20); + + builder.push("TierPrices"); // In nuggets + tier1PriceMin = builder.defineInRange("tier1Min", 9, 1, 640); + tier1PriceMax = builder.defineInRange("tier1Max", 18, 1, 640); + tier2PriceMin = builder.defineInRange("tier2Min", 27, 1, 640); + tier2PriceMax = builder.defineInRange("tier2Max", 45, 1, 640); + tier3PriceMin = builder.defineInRange("tier3Min", 54, 1, 640); + tier3PriceMax = builder.defineInRange("tier3Max", 90, 1, 640); + tier4PriceMin = builder.defineInRange("tier4Min", 90, 1, 640); + tier4PriceMax = builder.defineInRange("tier4Max", 180, 1, 640); + builder.pop(); + builder.pop(); + + builder.push("CombatTools"); + whipDamage = builder + .comment("Damage dealt by Whip") + .defineInRange("whipDamage", 2.0, 0.0, 20.0); + whipResistanceDecrease = builder + .comment("Resistance removed per Whip hit") + .defineInRange("whipResistanceDecrease", 15, 0, 100); + + chloroformDurability = builder + .comment("Number of uses for Chloroform Bottle") + .defineInRange("chloroformDurability", 9, 1, 64); + ragWetTime = builder + .comment("Ticks before wet rag dries") + .defineInRange("ragWetTime", 6000, 100, 72000); + chloroformEffectDuration = builder + .comment("Ticks knockout effect lasts") + .defineInRange("chloroformEffectDuration", 200, 20, 6000); + + ropeArrowBindChance = builder + .comment("Chance % to bind target with Rope Arrow") + .defineInRange("ropeArrowBindChance", 50, 0, 100); + archerArrowBindChanceBase = builder + .comment("Base bind chance % for Archer NPCs") + .defineInRange("archerArrowBindChanceBase", 10, 0, 100); + archerArrowBindChancePerHit = builder + .comment( + "Cumulative bind chance % increase per hit for Archer NPCs" + ) + .defineInRange("archerArrowBindChancePerHit", 10, 0, 100); + + shockerControllerRadius = builder + .comment("Radius for Shocker Controller") + .defineInRange("shockerControllerRadius", 50, 1, 200); + + taserDamage = builder + .comment("Damage dealt by Taser") + .defineInRange("taserDamage", 5.0, 0.0, 20.0); + taserStunDuration = builder + .comment("Ticks Taser stun lasts") + .defineInRange("taserStunDuration", 100, 0, 600); + + lockpickSuccessChance = builder + .comment("Chance % to successfully pick a lock") + .defineInRange("lockpickSuccessChance", 25, 0, 100); + lockpickBreakChance = builder + .comment("Chance % to break lockpick on fail") + .defineInRange("lockpickBreakChance", 15, 0, 100); + lockpickJamChance = builder + .comment("Chance % to jam lock on fail") + .defineInRange("lockpickJamChance", 2.5, 0.0, 100.0); + builder.pop(); + + builder.push("Resistances"); + addResistance(builder, "rope", 100); + addResistance(builder, "chain", 150); + addResistance(builder, "armbinder", 180); + addResistance(builder, "wrap", 200); + addResistance(builder, "straitjacket", 250); + addResistance(builder, "latex_sack", 300); + addResistance(builder, "ribbon", 50); + addResistance(builder, "vine", 60); + addResistance(builder, "web", 70); + addResistance(builder, "slime", 80); + addResistance(builder, "tape", 90); + + addResistance(builder, "gag", 100); + addResistance(builder, "blindfold", 100); + addResistance(builder, "collar", 100); + + padlockResistance = builder + .comment("Resistance added by a padlock/lock") + .defineInRange("padlockResistance", 250, 0, 1000); + builder.pop(); + + builder.push("Gags"); + gagTalkProximity = builder + .comment("Enable proximity chat for gagged players") + .define("gagTalkProximity", true); + + builder.push("Properties"); + addGagProps(builder, "cloth", 0.4, 15.0); + addGagProps(builder, "ball", 0.2, 10.0); + addGagProps(builder, "tape", 0.05, 5.0); + addGagProps(builder, "stuffed", 0.0, 3.0); + addGagProps(builder, "panel", 0.05, 4.0); + addGagProps(builder, "latex", 0.1, 6.0); + addGagProps(builder, "ring", 0.5, 12.0); + addGagProps(builder, "bite", 0.3, 10.0); + addGagProps(builder, "sponge", 0.0, 2.0); + addGagProps(builder, "baguette", 0.25, 8.0); + builder.pop(); + builder.pop(); + + builder.push("Dialogue"); + dialogueRadius = builder + .comment("Radius for NPC dialogue broadcast") + .defineInRange("dialogueRadius", 20, 1, 100); + dialogueCooldown = builder + .comment("Cooldown in ticks between same-type dialogues") + .defineInRange("dialogueCooldown", 100, 20, 1200); + builder.pop(); + + // === KIDNAPPER CONFIGURATION (Phase 4) === + builder.push("KidnapperAdvanced"); + + builder.push("Lairs"); + enableLairs = builder + .comment( + "Enable kidnapper lair structures (camps, outposts, fortresses)" + ) + .define("enableLairs", true); + lairDensity = builder + .comment( + "Density multiplier for lair spawning (1.0 = default, 0.5 = half as common)" + ) + .defineInRange("lairDensity", 1.0, 0.1, 5.0); + builder.pop(); + + builder.push("Capture"); + transportToLair = builder + .comment( + "Kidnappers transport captives to their lair instead of fleeing" + ) + .define("transportToLair", true); + confiscateInventory = builder + .comment( + "Kidnappers confiscate captive's inventory when imprisoned" + ) + .define("confiscateInventory", true); + builder.pop(); + + builder.push("Ransom"); + enableRansom = builder + .comment("Enable ransom demands for captured players") + .define("enableRansom", true); + ransomAmount = builder + .comment("Default ransom amount") + .defineInRange("ransomAmount", 16, 1, 64); + ransomItem = builder + .comment("Item ID used for ransom payment") + .define("ransomItem", "minecraft:iron_ingot"); + ransomTimeoutMinutes = builder + .comment("Time in minutes before ransom demand expires") + .defineInRange("ransomTimeoutMinutes", 30, 5, 120); + builder.pop(); + + builder.push("Timeout"); + maxCaptivityMinutes = builder + .comment( + "Maximum time in minutes a player can be held captive (0 = unlimited)" + ) + .defineInRange("maxCaptivityMinutes", 45, 0, 240); + releaseWithInventory = builder + .comment( + "Return player's inventory when released after timeout" + ) + .define("releaseWithInventory", true); + laborRestSeconds = builder + .comment( + "Rest time in seconds between labor tasks for imprisoned players" + ) + .defineInRange("laborRestSeconds", 120, 10, 600); + builder.pop(); + + builder.push("Struggle"); + struggleMakesNoise = builder + .comment("Struggling makes noise that alerts nearby kidnappers") + .define("struggleMakesNoise", true); + builder.pop(); + + builder.push("AI"); + enablePunishment = builder + .comment( + "Kidnappers punish recaptured escapees (tighter binds, add blindfold)" + ) + .define("enablePunishment", true); + builder.pop(); + + builder.push("Abandon"); + abandonTeleportRadius = builder + .comment( + "Radius in blocks for random teleport when abandoning captive" + ) + .defineInRange("abandonTeleportRadius", 1000, 100, 5000); + abandonKeepsBlindfold = builder + .comment("Captive keeps blindfold when abandoned") + .define("abandonKeepsBlindfold", true); + abandonKeepsBinds = builder + .comment("Captive keeps binds when abandoned") + .define("abandonKeepsBinds", true); + builder.pop(); + + builder.push("SoloMode"); + enableSoloFallback = builder + .comment( + "Enable fallback behavior when no buyers are available in singleplayer" + ) + .define("enableSoloFallback", true); + soloKeepChance = builder + .comment( + "Chance (0.0-1.0) that kidnapper keeps the captive in solo mode" + ) + .defineInRange("soloKeepChance", 0.6, 0.0, 1.0); + soloAbandonChance = builder + .comment( + "Chance (0.0-1.0) that kidnapper abandons the captive in solo mode (uses remaining if keep fails)" + ) + .defineInRange("soloAbandonChance", 0.4, 0.0, 1.0); + soloTimeoutSeconds = builder + .comment("Time in seconds before solo mode fallback triggers") + .defineInRange("soloTimeoutSeconds", 120, 30, 600); + builder.pop(); + + builder.push("MasterNPC"); + enableMasterSpawn = builder + .comment( + "Enable Master NPC spawning in solo mode (pet play system)" + ) + .define("enableMasterSpawn", true); + masterPetCollarResistance = builder + .comment( + "Resistance value for pet collar (higher = harder to escape)" + ) + .defineInRange("petCollarResistance", 250, 50, 1000); + masterSlaveDamageMultiplier = builder + .comment( + "Damage multiplier for slave attacking master (0.25 = 75% reduction)" + ) + .defineInRange("slaveDamageMultiplier", 0.25, 0.0, 1.0); + masterMinDistractionInterval = builder + .comment( + "Minimum time in ticks between distractions (2400 = 2 minutes)" + ) + .defineInRange("minDistractionInterval", 2400, 600, 12000); + masterMaxDistractionInterval = builder + .comment( + "Maximum time in ticks between distractions (6000 = 5 minutes)" + ) + .defineInRange("maxDistractionInterval", 6000, 1200, 24000); + masterMinDistractionDuration = builder + .comment( + "Minimum distraction duration in ticks (600 = 30 seconds)" + ) + .defineInRange("minDistractionDuration", 600, 200, 2400); + masterMaxDistractionDuration = builder + .comment( + "Maximum distraction duration in ticks (1200 = 60 seconds)" + ) + .defineInRange("maxDistractionDuration", 1200, 400, 4800); + builder.pop(); + + builder.pop(); // KidnapperAdvanced + + builder.push("Cells"); + cellMaxWallDistance = builder + .comment( + "Maximum distance in blocks from cell center to wall markers" + ) + .defineInRange("maxWallDistance", 32, 8, 64); + maxPrisonersPerCell = builder + .comment("Maximum number of prisoners per cell") + .defineInRange("maxPrisonersPerCell", 1, 1, 4); + deleteMarkerOnCellDelete = builder + .comment("Delete marker blocks when a cell is deleted") + .define("deleteMarkerOnCellDelete", true); + autoRemoveDestroyedWalls = builder + .comment( + "Automatically remove wall references when wall blocks are destroyed" + ) + .define("autoRemoveDestroyedWalls", true); + builder.pop(); + } + + private void addResistance( + ForgeConfigSpec.Builder builder, + String name, + int defaultVal + ) { + bindResistances.put( + name, + builder.defineInRange(name, defaultVal, 1, 1000) + ); + } + + private void addGagProps( + ForgeConfigSpec.Builder builder, + String name, + double comp, + double range + ) { + builder.push(name); + gagComprehension.put( + name, + builder.defineInRange("comprehension", comp, 0.0, 1.0) + ); + gagRange.put( + name, + builder.defineInRange("range", range, 0.0, 100.0) + ); + builder.pop(); + } + } + + public static class ClientConfig { + + public final ForgeConfigSpec.DoubleValue earplugVolumeMultiplier; + public final ForgeConfigSpec.DoubleValue blindfoldOverlayOpacity; + public final ForgeConfigSpec.BooleanValue hardcoreBlindfold; + + // Dynamic Textures (Clothes System) + public final ForgeConfigSpec.BooleanValue enableDynamicTextures; + public final ForgeConfigSpec.BooleanValue useTextureHostWhitelist; + public final ForgeConfigSpec.ConfigValue< + List + > textureHostWhitelist; + + // Cell Rendering (Phase 4) + public final ForgeConfigSpec.BooleanValue showCellHighlights; + public final ForgeConfigSpec.IntValue highlightRenderDistance; + + public ClientConfig(ForgeConfigSpec.Builder builder) { + builder.push("Audio"); + earplugVolumeMultiplier = builder + .comment( + "Volume multiplier when wearing earplugs (0.0 = silent)" + ) + .defineInRange("earplugVolumeMultiplier", 0.15, 0.0, 1.0); + builder.pop(); + + builder.push("Visuals"); + blindfoldOverlayOpacity = builder + .comment( + "Opacity of the blindfold overlay (currently unused, texture is fixed)" + ) + .defineInRange("blindfoldOverlayOpacity", 1.0, 0.0, 1.0); + hardcoreBlindfold = builder + .comment( + "Hardcore blindfold mode: when ON, blindfold covers everything including menus (100% opacity). When OFF, blindfold is hidden when a GUI screen is open." + ) + .define("hardcoreBlindfold", true); + builder.pop(); + + builder.push("DynamicTextures"); + enableDynamicTextures = builder + .comment( + "Enable loading dynamic textures from URLs for clothes items" + ) + .define("enableDynamicTextures", true); + useTextureHostWhitelist = builder + .comment( + "Only allow texture URLs from whitelisted hosts (security feature)" + ) + .define("useTextureHostWhitelist", true); + textureHostWhitelist = builder + .comment( + "Additional allowed hosts for dynamic textures (besides defaults: imgur, discord CDN, github raw)" + ) + .defineList("additionalWhitelistedHosts", List.of(), obj -> + obj instanceof String + ); + builder.pop(); + + builder.push("CellRendering"); + showCellHighlights = builder + .comment("Show cell boundary highlights when holding cell wand") + .define("showCellHighlights", true); + highlightRenderDistance = builder + .comment( + "Maximum render distance for cell highlights in blocks" + ) + .defineInRange("highlightRenderDistance", 32, 8, 64); + builder.pop(); + } + } +} diff --git a/src/main/java/com/tiedup/remake/core/ModEffects.java b/src/main/java/com/tiedup/remake/core/ModEffects.java new file mode 100644 index 0000000..21db627 --- /dev/null +++ b/src/main/java/com/tiedup/remake/core/ModEffects.java @@ -0,0 +1,17 @@ +package com.tiedup.remake.core; + +import net.minecraft.world.effect.MobEffect; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegistryObject; + +public class ModEffects { + + public static final DeferredRegister MOB_EFFECTS = + DeferredRegister.create(ForgeRegistries.MOB_EFFECTS, TiedUpMod.MOD_ID); + + public static final RegistryObject CHOKE = MOB_EFFECTS.register( + "choke", + ChokeEffect::new + ); +} diff --git a/src/main/java/com/tiedup/remake/core/ModMenuTypes.java b/src/main/java/com/tiedup/remake/core/ModMenuTypes.java new file mode 100644 index 0000000..532485a --- /dev/null +++ b/src/main/java/com/tiedup/remake/core/ModMenuTypes.java @@ -0,0 +1,28 @@ +package com.tiedup.remake.core; + +import com.tiedup.remake.entities.NpcInventoryMenu; +import net.minecraft.world.inventory.MenuType; +import net.minecraftforge.common.extensions.IForgeMenuType; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegistryObject; + +/** + * ModMenuTypes - Registry for container menu types. + * + *

Registers vanilla-style container menus for GUIs.

+ */ +public class ModMenuTypes { + + public static final DeferredRegister> MENUS = + DeferredRegister.create(ForgeRegistries.MENU_TYPES, TiedUpMod.MOD_ID); + + /** + * NPC Inventory menu - vanilla container for NPC inventory management. + */ + public static final RegistryObject< + MenuType + > NPC_INVENTORY = MENUS.register("npc_inventory", () -> + IForgeMenuType.create(NpcInventoryMenu::createClientMenu) + ); +} diff --git a/src/main/java/com/tiedup/remake/core/ModSounds.java b/src/main/java/com/tiedup/remake/core/ModSounds.java new file mode 100644 index 0000000..c617dec --- /dev/null +++ b/src/main/java/com/tiedup/remake/core/ModSounds.java @@ -0,0 +1,52 @@ +package com.tiedup.remake.core; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundEvent; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegistryObject; + +/** + * ModSounds - Registry for custom sound effects. + * + *

Contains all original 1.12.2 sound effects ported to 1.20.1. + * Sounds are defined in assets/tiedup/sounds.json.

+ */ +public class ModSounds { + + public static final DeferredRegister SOUND_EVENTS = + DeferredRegister.create(ForgeRegistries.SOUND_EVENTS, TiedUpMod.MOD_ID); + + /** Plays when a shock collar is triggered. */ + public static final RegistryObject ELECTRIC_SHOCK = + registerSound("electric_shock"); + /** Plays when a remote controller or locator is used. */ + public static final RegistryObject SHOCKER_ACTIVATED = + registerSound("shocker_activated"); + /** Plays when a collar is successfully put on a player. */ + public static final RegistryObject COLLAR_PUT = registerSound( + "collar_put" + ); + /** Plays when a collar key unlocks a padlock. */ + public static final RegistryObject COLLAR_KEY_OPEN = + registerSound("collar_key_open"); + /** Plays when a collar key locks a padlock. */ + public static final RegistryObject COLLAR_KEY_CLOSE = + registerSound("collar_key_close"); + /** Generic heavy chain sound. */ + public static final RegistryObject CHAIN = registerSound( + "chain" + ); + /** Slap sound for the paddle tool. */ + public static final RegistryObject SLAP = registerSound("slap"); + /** Whip sound for the whip tool. */ + public static final RegistryObject WHIP = registerSound("whip"); + + private static RegistryObject registerSound(String name) { + return SOUND_EVENTS.register(name, () -> + SoundEvent.createVariableRangeEvent( + ResourceLocation.fromNamespaceAndPath(TiedUpMod.MOD_ID, name) + ) + ); + } +} diff --git a/src/main/java/com/tiedup/remake/core/SettingsAccessor.java b/src/main/java/com/tiedup/remake/core/SettingsAccessor.java new file mode 100644 index 0000000..8395f51 --- /dev/null +++ b/src/main/java/com/tiedup/remake/core/SettingsAccessor.java @@ -0,0 +1,496 @@ +package com.tiedup.remake.core; + +import com.tiedup.remake.entities.skins.Gender; +import com.tiedup.remake.util.ModGameRules; +import java.util.Map; +import java.util.function.Supplier; +import org.jetbrains.annotations.Nullable; +import net.minecraft.world.level.GameRules; +import net.minecraftforge.common.ForgeConfigSpec; + +/** + * Centralized accessor for mod settings that resolves the GameRules vs ModConfig priority. + * + * Priority order: + * 1. GameRules (if a world is loaded and rules are available) - these are per-world overrides + * 2. ModConfig (if config is loaded) - these are the user's intended defaults + * 3. Hardcoded fallback - safe defaults if neither system is available yet + * + * This class exists because GameRules defaults (set at registration time before config loads) + * can diverge from ModConfig defaults. Without this accessor, ModConfig spawn rate values + * were completely ignored (BUG-001). + */ +public class SettingsAccessor { + + // ==================== Spawn Toggles ==================== + + /** + * Check if damsel spawning is enabled. + * + * @param rules GameRules from the current world, or null if unavailable + * @return true if damsels should spawn + */ + public static boolean doDamselsSpawn(@Nullable GameRules rules) { + if (rules != null) return rules.getBoolean(ModGameRules.DAMSELS_SPAWN); + return safeGet(() -> ModConfig.SERVER.damselsSpawn.get(), true); + } + + /** + * Check if kidnapper spawning is enabled. + * + * @param rules GameRules from the current world, or null if unavailable + * @return true if kidnappers should spawn + */ + public static boolean doKidnappersSpawn(@Nullable GameRules rules) { + if (rules != null) return rules.getBoolean(ModGameRules.KIDNAPPERS_SPAWN); + return safeGet(() -> ModConfig.SERVER.kidnappersSpawn.get(), true); + } + + // ==================== Spawn Rates ==================== + + /** + * Get damsel spawn rate (0-100). + * ModConfig default: 60, GameRule registration default was 100 (now fixed to 60). + * + * @param rules GameRules from the current world, or null if unavailable + * @return spawn rate percentage + */ + public static int getDamselSpawnRate(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.DAMSEL_SPAWN_RATE); + return safeGet(() -> ModConfig.SERVER.damselSpawnRate.get(), 60); + } + + /** + * Get base kidnapper spawn rate (0-100). + * ModConfig default: 70, GameRule registration default was 100 (now fixed to 70). + * + * @param rules GameRules from the current world, or null if unavailable + * @return spawn rate percentage + */ + public static int getKidnapperSpawnRate(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.KIDNAPPER_SPAWN_RATE); + return safeGet(() -> ModConfig.SERVER.kidnapperSpawnRate.get(), 70); + } + + /** + * Get kidnapper archer spawn rate (0-100). + * ModConfig default: 70, GameRule registration default was 100 (now fixed to 70). + * + * @param rules GameRules from the current world, or null if unavailable + * @return spawn rate percentage + */ + public static int getKidnapperArcherSpawnRate(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.KIDNAPPER_ARCHER_SPAWN_RATE); + return safeGet(() -> ModConfig.SERVER.kidnapperArcherSpawnRate.get(), 70); + } + + /** + * Get kidnapper elite spawn rate (0-100). + * ModConfig default: 70, GameRule registration default was 100 (now fixed to 70). + * + * @param rules GameRules from the current world, or null if unavailable + * @return spawn rate percentage + */ + public static int getKidnapperEliteSpawnRate(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.KIDNAPPER_ELITE_SPAWN_RATE); + return safeGet(() -> ModConfig.SERVER.kidnapperEliteSpawnRate.get(), 70); + } + + /** + * Get kidnapper merchant spawn rate (0-100). + * ModConfig default: 70, GameRule registration default was 100 (now fixed to 70). + * + * @param rules GameRules from the current world, or null if unavailable + * @return spawn rate percentage + */ + public static int getKidnapperMerchantSpawnRate(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.KIDNAPPER_MERCHANT_SPAWN_RATE); + return safeGet(() -> ModConfig.SERVER.kidnapperMerchantSpawnRate.get(), 70); + } + + /** + * Get master spawn rate (0-100). + * ModConfig default: 100, GameRule default: 100 (these already matched). + * + * @param rules GameRules from the current world, or null if unavailable + * @return spawn rate percentage + */ + public static int getMasterSpawnRate(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.MASTER_SPAWN_RATE); + return safeGet(() -> ModConfig.SERVER.masterSpawnRate.get(), 100); + } + + // ==================== Bind Resistance ==================== + + /** + * Get the configured base resistance for a bind type. + * + *

Normalizes the bind key so callers can pass raw item names + * (e.g., "ropes", "vine_seed") or config keys (e.g., "rope", "vine"). + * + *

The normalization mapping: + *

    + *
  • "ropes" / "shibari" -> "rope"
  • + *
  • "vine_seed" -> "vine"
  • + *
  • "web_bind" -> "web"
  • + *
  • "duct_tape" -> "tape"
  • + *
  • "leather_straps" / "medical_straps" / "dogbinder" -> "armbinder"
  • + *
  • "beam_cuffs" -> "chain"
  • + *
+ * + *

BUG-003 fix: Previously, {@code IHasResistance.getBaseResistance()} + * called {@code ModGameRules.getResistance()} which only knew 4 types (rope, gag, + * blindfold, collar) and returned hardcoded 100 for the other 10 types. Meanwhile + * {@code BindVariant.getResistance()} read from ModConfig which had all 14 types. + * This caused a display-vs-struggle desync (display: 250, struggle: 100). + * Now both paths use this method. + * + * @param bindType The raw item name or config key + * @return Resistance value from config, or 100 as fallback + */ + public static int getBindResistance(String bindType) { + String key = normalizeBindKey(bindType); + return safeGet(() -> { + if (ModConfig.SERVER == null) { + return 100; + } + Map resistances = + ModConfig.SERVER.bindResistances; + if (resistances != null && resistances.containsKey(key)) { + return resistances.get(key).get(); + } + return 100; + }, 100); + } + + /** + * Get the configured resistance added by a padlock. + * + * @param rules GameRules from the current world, or null if unavailable + * @return Padlock resistance, or 250 as fallback + */ + public static int getPadlockResistance(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.PADLOCK_RESISTANCE); + return safeGet(() -> { + if (ModConfig.SERVER == null) { + return 250; + } + return ModConfig.SERVER.padlockResistance.get(); + }, 250); + } + + /** + * Normalize a raw bind item name to its config key. + * + *

Replicates the mapping from + * {@link com.tiedup.remake.items.base.BindVariant#getResistance()} so that + * every call site resolves to the same config entry. + * + * @param bindType Raw item name (e.g., "ropes", "vine_seed", "collar") + * @return Normalized config key (e.g., "rope", "vine", "collar") + */ + static String normalizeBindKey(String bindType) { + if (bindType == null) { + return "rope"; + } + return switch (bindType.toLowerCase()) { + // Plural -> singular + case "ropes" -> "rope"; + + // Aliases that share another type's resistance + case "shibari" -> "rope"; + case "leather_straps", "medical_straps", "dogbinder" -> "armbinder"; + case "beam_cuffs" -> "chain"; + case "choke_collar" -> "collar"; + + // Registry name -> config key rename + case "vine_seed" -> "vine"; + case "web_bind" -> "web"; + case "duct_tape" -> "tape"; + + // Already a valid config key (rope, chain, armbinder, wrap, + // straitjacket, latex_sack, ribbon, vine, web, slime, tape, + // gag, blindfold, collar) + default -> bindType.toLowerCase(); + }; + } + + // ==================== Struggle System ==================== + + /** + * Check if struggle system is enabled. + * + * @param rules GameRules from the current world, or null if unavailable + * @return true if struggle is enabled + */ + public static boolean isStruggleEnabled(@Nullable GameRules rules) { + if (rules != null) return rules.getBoolean(ModGameRules.STRUGGLE); + return safeGet(() -> ModConfig.SERVER.struggleEnabled.get(), true); + } + + /** + * Get the struggle success probability (0-100). + * + * @param rules GameRules from the current world, or null if unavailable + * @return Probability percentage + */ + public static int getProbabilityStruggle(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.PROBABILITY_STRUGGLE); + return safeGet(() -> ModConfig.SERVER.struggleProbability.get(), 40); + } + + /** + * Get the minimum resistance decrease on successful struggle. + * + * @param rules GameRules from the current world, or null if unavailable + * @return Minimum decrease amount (default 1) + */ + public static int getStruggleMinDecrease(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.STRUGGLE_MIN_DECREASE); + return safeGet(() -> ModConfig.SERVER.struggleMinDecrease.get(), 1); + } + + /** + * Get the maximum resistance decrease on successful struggle. + * + * @param rules GameRules from the current world, or null if unavailable + * @return Maximum decrease amount (default 10) + */ + public static int getStruggleMaxDecrease(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.STRUGGLE_MAX_DECREASE); + return safeGet(() -> ModConfig.SERVER.struggleMaxDecrease.get(), 10); + } + + /** + * Get the struggle cooldown timer in ticks. + * + * @param rules GameRules from the current world, or null if unavailable + * @return Cooldown in ticks (default 80 = 4 seconds) + */ + public static int getStruggleTimer(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.STRUGGLE_TIMER); + return safeGet(() -> ModConfig.SERVER.struggleTimer.get(), 80); + } + + /** + * Get the probability of random shock during collar struggle (0-100). + * + * @param rules GameRules from the current world, or null if unavailable + * @return Shock probability percentage (default 20) + */ + public static int getStruggleCollarRandomShock(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.STRUGGLE_COLLAR_RANDOM_SHOCK); + return safeGet(() -> ModConfig.SERVER.struggleCollarRandomShock.get(), 20); + } + + /** + * Get ticks per 1 resistance point for continuous struggle. + * GameRule-only — no ModConfig equivalent exists. + * + * @param rules GameRules from the current world, or null if unavailable + * @return Ticks per resistance (default 20 = 1 per second) + */ + public static int getStruggleContinuousRate(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.STRUGGLE_CONTINUOUS_RATE); + return 20; + } + + // ==================== Tying/Untying ==================== + + /** + * Get the tying duration in seconds. + * + * @param rules GameRules from the current world, or null if unavailable + * @return Duration in seconds (default 5) + */ + public static int getTyingPlayerTime(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.TYING_PLAYER_TIME); + return safeGet(() -> ModConfig.SERVER.tyingTime.get(), 5); + } + + /** + * Get the untying duration in seconds. + * + * @param rules GameRules from the current world, or null if unavailable + * @return Duration in seconds (default 10) + */ + public static int getUntyingPlayerTime(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.UNTYING_PLAYER_TIME); + return safeGet(() -> ModConfig.SERVER.untyingTime.get(), 10); + } + + // ==================== Enslavement ==================== + + /** + * Check if the enslavement system is enabled. + * + * @param rules GameRules from the current world, or null if unavailable + * @return true if enslavement is enabled + */ + public static boolean isEnslavementEnabled(@Nullable GameRules rules) { + if (rules != null) return rules.getBoolean(ModGameRules.ENSLAVEMENT_ENABLED); + return safeGet(() -> ModConfig.SERVER.enslavementEnabled.get(), true); + } + + // ==================== Bounty System ==================== + + /** + * Get maximum bounties per player. + * + * @param rules GameRules from the current world, or null if unavailable + * @return Max bounties (default 5) + */ + public static int getMaxBounties(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.MAX_BOUNTIES); + return safeGet(() -> ModConfig.SERVER.maxBountiesPerPlayer.get(), 5); + } + + /** + * Get bounty duration in seconds. + * + * @param rules GameRules from the current world, or null if unavailable + * @return Duration in seconds (default 14400 = 4 hours) + */ + public static int getBountyDuration(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.BOUNTY_DURATION); + return safeGet(() -> ModConfig.SERVER.bountyDuration.get(), 14400); + } + + /** + * Get bounty delivery detection radius. + * + * @param rules GameRules from the current world, or null if unavailable + * @return Radius in blocks (default 5) + */ + public static int getBountyDeliveryRadius(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.BOUNTY_DELIVERY_RADIUS); + return safeGet(() -> ModConfig.SERVER.bountyDeliveryRadius.get(), 5); + } + + // ==================== Kidnap Bomb ==================== + + /** + * Get the kidnap bomb explosion radius. + * + * @param rules GameRules from the current world, or null if unavailable + * @return Radius in blocks (default 5) + */ + public static int getKidnapBombRadius(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.KIDNAP_BOMB_RADIUS); + return safeGet(() -> ModConfig.SERVER.kidnapBombRadius.get(), 5); + } + + // ==================== NPC Struggle ==================== + + /** + * Check if NPC struggle system is enabled. + * GameRule-only — no ModConfig equivalent exists. + * + * @param rules GameRules from the current world, or null if unavailable + * @return true if NPCs can struggle against restraints + */ + public static boolean isNpcStruggleEnabled(@Nullable GameRules rules) { + if (rules != null) return rules.getBoolean(ModGameRules.NPC_STRUGGLE_ENABLED); + return true; + } + + /** + * Get the base interval in ticks between NPC struggle attempts. + * GameRule-only — no ModConfig equivalent exists. + * Modified by personality type (FIERCE x0.3, SUBMISSIVE x3.0, etc.). + * + * @param rules GameRules from the current world, or null if unavailable + * @return Base interval in ticks (default 6000 = 5 minutes) + */ + public static int getNpcStruggleInterval(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.NPC_STRUGGLE_INTERVAL); + return 6000; + } + + // ==================== Equipment Settings ==================== + + /** + * Get the shocker controller detection radius. + * + * @param rules GameRules from the current world, or null if unavailable + * @return Radius in blocks (default 50, range 1-200) + */ + public static int getShockerControllerRadius(@Nullable GameRules rules) { + if (rules != null) return rules.getInt(ModGameRules.SHOCKER_CONTROLLER_BASE_RADIUS); + return safeGet(() -> ModConfig.SERVER.shockerControllerRadius.get(), 50); + } + + // ==================== Gameplay Settings ==================== + + /** + * Check if gag talk proximity chat is enabled. + * When enabled, gagged players' muffled speech is only heard by nearby players. + * + * @param rules GameRules from the current world, or null if unavailable + * @return true if proximity chat is enabled for gagged players + */ + public static boolean isGagTalkProximityEnabled(@Nullable GameRules rules) { + if (rules != null) return rules.getBoolean(ModGameRules.GAG_TALK_PROXIMITY); + return safeGet(() -> ModConfig.SERVER.gagTalkProximity.get(), true); + } + + // ==================== Gender Mode ==================== + + /** + * Resolve preferred spawn gender. GameRule overrides ModConfig. + * + *

GameRule values: 0 = fall through to ModConfig, 1 = Female only, 2 = Male only. + * ModConfig values: BOTH, FEMALE_ONLY, MALE_ONLY. + * + * @param rules GameRules from the current world, or null if unavailable + * @return null for both genders, Gender.FEMALE, or Gender.MALE + */ + public static @Nullable Gender getPreferredSpawnGender(@Nullable GameRules rules) { + if (rules != null) { + int mode = rules.getInt(ModGameRules.SPAWN_GENDER_MODE); + if (mode == 1) return Gender.FEMALE; + if (mode == 2) return Gender.MALE; + // mode == 0 or invalid: fall through to ModConfig + } + ModConfig.ServerConfig.SpawnGenderMode configMode = + safeGet(() -> ModConfig.SERVER.spawnGenderMode.get(), + ModConfig.ServerConfig.SpawnGenderMode.BOTH); + return switch (configMode) { + case FEMALE_ONLY -> Gender.FEMALE; + case MALE_ONLY -> Gender.MALE; + default -> null; // both + }; + } + + // ==================== Chloroform ==================== + + /** + * Get the chloroform effect duration in ticks. + * Config-only — no real GameRule exists (the old ModGameRules getter just read ModConfig). + * + * @return Duration in ticks (default 200 = 10 seconds) + */ + public static int getChloroformDuration() { + return safeGet(() -> ModConfig.SERVER.chloroformEffectDuration.get(), 200); + } + + // ==================== Helpers ==================== + + /** + * Safely get a config value, returning fallback if config isn't loaded yet. + * ModConfig values throw IllegalStateException when accessed before the config + * spec is loaded (e.g., during mod construction or very early startup). + * + * @param supplier the config value supplier + * @param fallback value to return if config isn't available + * @param value type + * @return the config value or fallback + */ + private static T safeGet(Supplier supplier, T fallback) { + try { + return supplier.get(); + } catch (IllegalStateException e) { + return fallback; + } + } +} diff --git a/src/main/java/com/tiedup/remake/core/SystemMessageManager.java b/src/main/java/com/tiedup/remake/core/SystemMessageManager.java new file mode 100644 index 0000000..775607f --- /dev/null +++ b/src/main/java/com/tiedup/remake/core/SystemMessageManager.java @@ -0,0 +1,739 @@ +package com.tiedup.remake.core; + +import java.util.List; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; + +/** + * System message manager for TiedUp! mod. + * + * Handles action bar messages (displayed on screen above hotbar) + * as opposed to chat messages. + * + * Use cases: + * - Action feedback: "X is tying you up!" + * - System info: "You can't do that while tied" + * - Progress: "Resistance: 50" + * - State changes: "You have been enslaved" + * + * This is different from EntityDialogueManager which handles + * chat messages (what entities SAY to players). + */ +public class SystemMessageManager { + + // ======================================== + // MESSAGE CATEGORIES + // ======================================== + + /** + * Categories for system messages. + */ + public enum MessageCategory { + // === RESTRAINT ACTIONS (done TO you) === + BEING_TIED, // "X is tying you up!" + TIED_UP, // "X tied you up, you can't move!" + BEING_GAGGED, // "X is gagging you!" + GAGGED, // "X gagged you, you can't speak!" + BEING_BLINDFOLDED, // "X is blindfolding you!" + BLINDFOLDED, // "X blindfolded you, you can't see!" + BEING_COLLARED, // "X is putting a collar on you!" + COLLARED, // "X collared you!" + EARPLUGS_ON, // "X put earplugs on you!" + MITTENS_ON, // "X put mittens on you!" + ENSLAVED, // "You have been enslaved by X!" + + // === RESTRAINT ACTIONS (done BY you - kidnapper's perspective) === + TYING_TARGET, // "You are tying X..." + TIED_TARGET, // "You tied X!" + GAGGING_TARGET, // "You are gagging X..." + GAGGED_TARGET, // "You gagged X!" + BLINDFOLDING_TARGET, // "You are blindfolding X..." + BLINDFOLDED_TARGET, // "You blindfolded X!" + COLLARING_TARGET, // "You are collaring X..." + COLLARED_TARGET, // "You collared X!" + + // === RELEASE ACTIONS === + UNTIED, // "X untied you!" + UNGAGGED, // "X removed your gag!" + UNBLINDFOLDED, // "X removed your blindfold!" + UNCOLLARED, // "X removed your collar!" + FREED, // "You have been freed!" + + // === STRUGGLE SYSTEM === + STRUGGLE_SUCCESS, // "You loosened the binds!" + STRUGGLE_FAIL, // "You couldn't loosen the binds..." + STRUGGLE_BROKE_FREE, // "You broke free!" + STRUGGLE_SHOCKED, // "You were shocked for struggling!" + STRUGGLE_COLLAR_SUCCESS, // "You manage to damage the lock!" + STRUGGLE_COLLAR_FAIL, // "You try to reach the lock, but can't get a good grip." + + // === RESTRICTIONS (TIED) === + CANT_MOVE, // "You can't move while tied!" + CANT_ATTACK_TIED, // "You can't attack while tied!" + CANT_USE_ITEM_TIED, // "You can't use items while tied!" + CANT_OPEN_INVENTORY, // "You can't open inventory while tied!" + CANT_INTERACT_TIED, // "You can't interact while tied!" + CANT_SPEAK, // "You can't speak while gagged!" + CANT_SEE, // "You can't see while blindfolded!" + CANT_BREAK_TIED, // "You can't break blocks while tied!" + CANT_PLACE_TIED, // "You can't place blocks while tied!" + NO_ELYTRA, // "You can't fly with elytra while tied!" + + // === RESTRICTIONS (MITTENS) === + CANT_ATTACK_MITTENS, // "You can't attack with mittens on!" + CANT_USE_ITEM_MITTENS, // "You can't use items with mittens on!" + CANT_INTERACT_MITTENS, // "You can't interact with mittens on!" + CANT_BREAK_MITTENS, // "You can't break blocks with mittens on!" + CANT_PLACE_MITTENS, // "You can't place blocks with mittens on!" + + // === SLAVE SYSTEM === + SLAVE_COMMAND, // "Your master commands you to..." + SLAVE_SHOCK, // "You've been shocked!" + GPS_ZONE_VIOLATION, // "You've been shocked! Return back to your allowed area!" + GPS_OWNER_ALERT, // "ALERT: %s is outside the safe zone!" + SLAVE_JOB_ASSIGNED, // "Job assigned: bring X" + SLAVE_JOB_COMPLETE, // "Job complete! You are free." + SLAVE_JOB_FAILED, // "Job failed!" + SLAVE_JOB_LAST_CHANCE, // "Last chance! Next failure = death" + SLAVE_JOB_KILLED, // "You were killed for failing" + + // === TIGHTEN === + BINDS_TIGHTENED, // "X tightened your binds!" + + // === TOOLS & ITEMS === + KEY_CLAIMED, // "Key claimed and linked to %s!" + KEY_NOT_OWNER, // "You don't own this key!" + KEY_WRONG_TARGET, // "This key doesn't fit this collar!" + LOCATOR_CLAIMED, // "Locator claimed!" + LOCATOR_NOT_OWNER, // "You don't own this locator!" + LOCATOR_DETECTED, // "Target detected: %s" + SHOCKER_CLAIMED, // "Shocker claimed!" + SHOCKER_NOT_OWNER, // "You don't own this shocker!" + SHOCKER_MODE_SET, // "Shocker mode: %s" + SHOCKER_TRIGGERED, // "Shocked %s!" + RAG_DRY, // "The rag is dry - soak it first" + RAG_SOAKED, // "You soaked the rag with chloroform" + RAG_EVAPORATED, // "The chloroform has evaporated" + + // === BOUNTY === + BOUNTY_CREATED, // "Bounty created on %s!" + BOUNTY_CLAIMED, // "You claimed the bounty on %s!" + BOUNTY_EXPIRED, // "Bounty on %s expired" + + // === CELL SYSTEM === + PRISONER_ARRIVED, // "X has been placed in your cell" + PRISONER_ESCAPED, // "X has escaped from your cell!" + PRISONER_RELEASED, // "X has been released from your cell" + CELL_BREACH, // "Your cell wall has been breached!" + CELL_ASSIGNED, // "You have been assigned to X's cell" + CELL_CREATED, // "Cell created successfully" + CELL_DELETED, // "Cell deleted" + CELL_RENAMED, // "Cell renamed to X" + + // === GENERIC === + INFO, // Generic info message + WARNING, // Generic warning + ERROR, // Generic error + } + + // ======================================== + // MESSAGE TEMPLATES + // ======================================== + + /** + * Get the raw message template for a category. + * Use this when you need to customize the message. + * + * @param category The message category + * @return The template string (may contain %s placeholders) + */ + public static String getTemplate(MessageCategory category) { + return getMessageTemplate(category); + } + + /** + * Get message template for a category. + * Use %s for entity name placeholder. + */ + private static String getMessageTemplate(MessageCategory category) { + return switch (category) { + // Restraint actions + case BEING_TIED -> "%s is tying you up!"; + case TIED_UP -> "%s tied you up, you can't move!"; + case BEING_GAGGED -> "%s is gagging you!"; + case GAGGED -> "%s gagged you, you can't speak!"; + case BEING_BLINDFOLDED -> "%s is blindfolding you!"; + case BLINDFOLDED -> "%s blindfolded you, you can't see!"; + case BEING_COLLARED -> "%s is putting a collar on you!"; + case COLLARED -> "%s collared you!"; + case EARPLUGS_ON -> "%s put earplugs on you!"; + case MITTENS_ON -> "%s put mittens on you!"; + case ENSLAVED -> "You have been enslaved by %s!"; + // Restraint actions (kidnapper's perspective) + case TYING_TARGET -> "You are tying %s..."; + case TIED_TARGET -> "You tied %s!"; + case GAGGING_TARGET -> "You are gagging %s..."; + case GAGGED_TARGET -> "You gagged %s!"; + case BLINDFOLDING_TARGET -> "You are blindfolding %s..."; + case BLINDFOLDED_TARGET -> "You blindfolded %s!"; + case COLLARING_TARGET -> "You are collaring %s..."; + case COLLARED_TARGET -> "You collared %s!"; + // Release actions + case UNTIED -> "%s untied you!"; + case UNGAGGED -> "%s removed your gag!"; + case UNBLINDFOLDED -> "%s removed your blindfold!"; + case UNCOLLARED -> "%s removed your collar!"; + case FREED -> "You have been freed!"; + // Struggle + case STRUGGLE_SUCCESS -> "You feel the ropes loosening..."; + case STRUGGLE_FAIL -> "You struggle against the ropes, but they hold tight."; + case STRUGGLE_BROKE_FREE -> "You broke free!"; + case STRUGGLE_SHOCKED -> "You were shocked for struggling!"; + case STRUGGLE_COLLAR_SUCCESS -> "You manage to damage the lock!"; + case STRUGGLE_COLLAR_FAIL -> "You try to reach the lock, but can't get a good grip."; + // Restrictions (Tied) + case CANT_MOVE -> "You can't move while tied!"; + case CANT_ATTACK_TIED -> "You can't attack while tied!"; + case CANT_USE_ITEM_TIED -> "You can't use items while tied!"; + case CANT_OPEN_INVENTORY -> "You can't open inventory while tied!"; + case CANT_INTERACT_TIED -> "You can't interact while tied!"; + case CANT_SPEAK -> "You can't speak while gagged!"; + case CANT_SEE -> "You can't see while blindfolded!"; + case CANT_BREAK_TIED -> "You can't break blocks while tied!"; + case CANT_PLACE_TIED -> "You can't place blocks while tied!"; + case NO_ELYTRA -> "You can't fly with elytra while tied!"; + // Restrictions (Mittens) + case CANT_ATTACK_MITTENS -> "You can't attack with mittens on!"; + case CANT_USE_ITEM_MITTENS -> "You can't use items with mittens on!"; + case CANT_INTERACT_MITTENS -> "You can't interact with mittens on!"; + case CANT_BREAK_MITTENS -> "You can't break blocks with mittens on!"; + case CANT_PLACE_MITTENS -> "You can't place blocks with mittens on!"; + // Slave system + case SLAVE_COMMAND -> "Your master commands: %s"; + case SLAVE_SHOCK -> "You've been shocked!"; + case GPS_ZONE_VIOLATION -> "You've been shocked! Return back to your allowed area!"; + case GPS_OWNER_ALERT -> "ALERT: %s is outside the safe zone!"; + case SLAVE_JOB_ASSIGNED -> "Job assigned: bring %s"; + case SLAVE_JOB_COMPLETE -> "Job complete! You are free."; + case SLAVE_JOB_FAILED -> "Job failed!"; + case SLAVE_JOB_LAST_CHANCE -> "LAST CHANCE! Next failure means death!"; + case SLAVE_JOB_KILLED -> "You were executed for failing your task."; + // Tighten + case BINDS_TIGHTENED -> "%s tightened your binds!"; + // Tools & Items + case KEY_CLAIMED -> "Key claimed and linked to %s!"; + case KEY_NOT_OWNER -> "You don't own this key!"; + case KEY_WRONG_TARGET -> "This key doesn't fit this collar!"; + case LOCATOR_CLAIMED -> "Locator claimed!"; + case LOCATOR_NOT_OWNER -> "You don't own this locator!"; + case LOCATOR_DETECTED -> "Target detected: %s"; + case SHOCKER_CLAIMED -> "Shocker claimed!"; + case SHOCKER_NOT_OWNER -> "You don't own this shocker!"; + case SHOCKER_MODE_SET -> "Shocker mode: %s"; + case SHOCKER_TRIGGERED -> "Shocked %s!"; + case RAG_DRY -> "The rag is dry - soak it first"; + case RAG_SOAKED -> "You soaked the rag with chloroform"; + case RAG_EVAPORATED -> "The chloroform has evaporated"; + // Bounty + case BOUNTY_CREATED -> "Bounty created on %s!"; + case BOUNTY_CLAIMED -> "You claimed the bounty on %s!"; + case BOUNTY_EXPIRED -> "Bounty on %s expired"; + // Cell System + case PRISONER_ARRIVED -> "%s has been placed in your cell"; + case PRISONER_ESCAPED -> "%s has escaped from your cell!"; + case PRISONER_RELEASED -> "%s has been released from your cell"; + case CELL_BREACH -> "Your cell wall has been breached!"; + case CELL_ASSIGNED -> "You have been assigned to %s's cell"; + case CELL_CREATED -> "Cell created successfully"; + case CELL_DELETED -> "Cell deleted"; + case CELL_RENAMED -> "Cell renamed to: %s"; + // Generic + case INFO -> "%s"; + case WARNING -> "%s"; + case ERROR -> "%s"; + }; + } + + /** + * Get formatting color for a category. + */ + private static ChatFormatting getCategoryColor(MessageCategory category) { + return switch (category) { + // Actions done to you - yellow/gold (attention) + case + BEING_TIED, + BEING_GAGGED, + BEING_BLINDFOLDED, + BEING_COLLARED -> ChatFormatting.YELLOW; + // Completed restraints - red (danger/restriction) + case + TIED_UP, + GAGGED, + BLINDFOLDED, + COLLARED, + EARPLUGS_ON, + MITTENS_ON, + ENSLAVED -> ChatFormatting.RED; + // Kidnapper's perspective - progress (yellow) and complete (green) + case + TYING_TARGET, + GAGGING_TARGET, + BLINDFOLDING_TARGET, + COLLARING_TARGET -> ChatFormatting.YELLOW; + case + TIED_TARGET, + GAGGED_TARGET, + BLINDFOLDED_TARGET, + COLLARED_TARGET -> ChatFormatting.GREEN; + // Release - green (positive) + case + UNTIED, + UNGAGGED, + UNBLINDFOLDED, + UNCOLLARED, + FREED -> ChatFormatting.GREEN; + // Struggle success - green + case + STRUGGLE_SUCCESS, + STRUGGLE_BROKE_FREE, + STRUGGLE_COLLAR_SUCCESS -> ChatFormatting.GREEN; + // Struggle fail - gray + case STRUGGLE_FAIL, STRUGGLE_COLLAR_FAIL -> ChatFormatting.GRAY; + // Shock - red + case + STRUGGLE_SHOCKED, + SLAVE_SHOCK, + GPS_ZONE_VIOLATION, + GPS_OWNER_ALERT -> ChatFormatting.RED; + // Restrictions - dark red + case + CANT_MOVE, + CANT_ATTACK_TIED, + CANT_USE_ITEM_TIED, + CANT_OPEN_INVENTORY, + CANT_INTERACT_TIED, + CANT_SPEAK, + CANT_SEE, + CANT_BREAK_TIED, + CANT_PLACE_TIED, + NO_ELYTRA, + CANT_ATTACK_MITTENS, + CANT_USE_ITEM_MITTENS, + CANT_INTERACT_MITTENS, + CANT_BREAK_MITTENS, + CANT_PLACE_MITTENS -> ChatFormatting.DARK_RED; + // Slave commands - gold + case SLAVE_COMMAND, SLAVE_JOB_ASSIGNED -> ChatFormatting.GOLD; + // Slave job complete - green + case SLAVE_JOB_COMPLETE -> ChatFormatting.GREEN; + // Slave job failed - red + case SLAVE_JOB_FAILED -> ChatFormatting.RED; + // Slave job last chance/killed - dark red (danger) + case + SLAVE_JOB_LAST_CHANCE, + SLAVE_JOB_KILLED -> ChatFormatting.DARK_RED; + // Tighten - yellow + case BINDS_TIGHTENED -> ChatFormatting.YELLOW; + // Tools & Items - various + case + KEY_CLAIMED, + LOCATOR_CLAIMED, + SHOCKER_CLAIMED, + RAG_SOAKED -> ChatFormatting.GREEN; + case + KEY_NOT_OWNER, + KEY_WRONG_TARGET, + LOCATOR_NOT_OWNER, + SHOCKER_NOT_OWNER, + RAG_DRY -> ChatFormatting.RED; + case + LOCATOR_DETECTED, + SHOCKER_MODE_SET, + SHOCKER_TRIGGERED, + RAG_EVAPORATED -> ChatFormatting.YELLOW; + // Bounty + case BOUNTY_CREATED, BOUNTY_CLAIMED -> ChatFormatting.GREEN; + case BOUNTY_EXPIRED -> ChatFormatting.YELLOW; + // Cell System + case + PRISONER_ARRIVED, + CELL_CREATED, + CELL_RENAMED -> ChatFormatting.GREEN; + case PRISONER_ESCAPED, CELL_BREACH -> ChatFormatting.RED; + case PRISONER_RELEASED, CELL_DELETED -> ChatFormatting.YELLOW; + case CELL_ASSIGNED -> ChatFormatting.GOLD; + // Generic + case INFO -> ChatFormatting.WHITE; + case WARNING -> ChatFormatting.YELLOW; + case ERROR -> ChatFormatting.RED; + }; + } + + // ======================================== + // SEND METHODS - TO SINGLE PLAYER + // ======================================== + + /** + * Send a system message to a player's action bar. + * Uses category template with entity name. + * + * @param player The player to send to + * @param category The message category + * @param actor The entity performing the action (for %s replacement) + */ + public static void sendToPlayer( + Player player, + MessageCategory category, + Entity actor + ) { + if (player == null) return; + + String actorName = actor != null ? getEntityName(actor) : "Someone"; + String message = String.format(getMessageTemplate(category), actorName); + + sendToPlayer(player, message, getCategoryColor(category)); + } + + /** + * Send a system message to a player's action bar. + * Uses category template without entity (for messages that don't need one). + * + * @param player The player to send to + * @param category The message category + */ + public static void sendToPlayer(Player player, MessageCategory category) { + if (player == null) return; + + String message = getMessageTemplate(category); + sendToPlayer(player, message, getCategoryColor(category)); + } + + /** + * Send a custom system message to a player's action bar. + * + * @param player The player to send to + * @param message The message to send + * @param color The color to use + */ + public static void sendToPlayer( + Player player, + String message, + ChatFormatting color + ) { + if (player == null || message == null) return; + + // Works on both client and server + MutableComponent component = Component.literal(message).withStyle( + style -> style.withColor(color) + ); + + // true = action bar (above hotbar), false = chat + player.displayClientMessage(component, true); + + TiedUpMod.LOGGER.debug( + "[SystemMessage] -> {}: {}", + player.getName().getString(), + message + ); + } + + /** + * Send a custom system message to a player's CHAT. + * + * @param player The player to send to + * @param message The message to send + * @param color The color to use + */ + public static void sendChatToPlayer( + Player player, + String message, + ChatFormatting color + ) { + if (player == null || message == null) return; + + MutableComponent component = Component.literal(message).withStyle( + style -> style.withColor(color) + ); + + // false = chat + player.displayClientMessage(component, false); + + TiedUpMod.LOGGER.debug( + "[SystemMessage-Chat] -> {}: {}", + player.getName().getString(), + message + ); + } + + /** + * Send a system message to a player's CHAT using a category. + */ + public static void sendChatToPlayer( + Player player, + MessageCategory category + ) { + if (player == null) return; + String message = getMessageTemplate(category); + sendChatToPlayer(player, message, getCategoryColor(category)); + } + + /** + * Send a system message to a player's CHAT using a category and actor. + */ + public static void sendChatToPlayer( + Player player, + MessageCategory category, + Entity actor + ) { + if (player == null) return; + String actorName = actor != null ? getEntityName(actor) : "Someone"; + String message = String.format(getMessageTemplate(category), actorName); + sendChatToPlayer(player, message, getCategoryColor(category)); + } + + /** + * Send a custom message with category formatting. + * + * @param player The player to send to + * @param category The category (for color) + * @param customMessage The custom message text + */ + public static void sendToPlayer( + Player player, + MessageCategory category, + String customMessage + ) { + sendToPlayer(player, customMessage, getCategoryColor(category)); + } + + /** + * Send a message with resistance info appended. + * + * @param player The player to send to + * @param category The message category + * @param resistance The current resistance value + */ + public static void sendWithResistance( + Player player, + MessageCategory category, + int resistance + ) { + if (player == null) return; + + String message = + getMessageTemplate(category) + " (Resistance: " + resistance + ")"; + sendToPlayer(player, message, getCategoryColor(category)); + } + + // ======================================== + // SEND METHODS - TO NEARBY PLAYERS + // ======================================== + + /** + * Send a system message to all players within radius. + * The message is from the perspective of the actor. + * + * @param actor The entity performing the action + * @param target The target of the action (if player, gets special message) + * @param category The message category + * @param radius The radius in blocks + */ + public static void sendToNearby( + Entity actor, + LivingEntity target, + MessageCategory category, + int radius + ) { + if (actor == null) return; + + List players = actor + .level() + .getEntitiesOfClass( + Player.class, + actor.getBoundingBox().inflate(radius) + ); + + for (Player player : players) { + if (player == target) { + // Target gets the "done to you" message + sendToPlayer(player, category, actor); + } + // Other nearby players could get a different perspective + // For now, only the target gets the message + } + } + + /** + * Send a system message to the target if they're a player. + * + * @param actor The entity performing the action + * @param target The target (only receives if Player) + * @param category The message category + */ + public static void sendToTarget( + Entity actor, + LivingEntity target, + MessageCategory category + ) { + if (target instanceof Player player) { + sendToPlayer(player, category, actor); + } + } + + /** + * Send a custom message to the target if they're a player. + * + * @param actor The entity performing the action (for logging) + * @param target The target (only receives if Player) + * @param message The message to send + * @param color The color to use + */ + public static void sendToTarget( + Entity actor, + LivingEntity target, + String message, + ChatFormatting color + ) { + if (target instanceof Player player) { + sendToPlayer(player, message, color); + } + } + + // ======================================== + // CONVENIENCE METHODS + // ======================================== + + /** + * Send "X is tying you up!" to target. + */ + public static void sendBeingTied(Entity actor, LivingEntity target) { + sendToTarget(actor, target, MessageCategory.BEING_TIED); + } + + /** + * Send "You are tying X..." to the kidnapper. + */ + public static void sendTyingTarget(Entity kidnapper, LivingEntity target) { + if (kidnapper instanceof Player player) { + TiedUpMod.LOGGER.info( + "[SystemMessage] Sending TYING_TARGET to {} about {}", + player.getName().getString(), + target.getName().getString() + ); + sendToPlayer(player, MessageCategory.TYING_TARGET, target); + } + } + + /** + * Send "X tied you up!" to target. + */ + public static void sendTiedUp(Entity actor, LivingEntity target) { + sendToTarget(actor, target, MessageCategory.TIED_UP); + } + + /** + * Send "You tied X!" to the kidnapper. + */ + public static void sendTiedTarget(Entity kidnapper, LivingEntity target) { + if (kidnapper instanceof Player player) { + sendToPlayer(player, MessageCategory.TIED_TARGET, target); + } + } + + /** + * Send "X is gagging you!" to target. + */ + public static void sendBeingGagged(Entity actor, LivingEntity target) { + sendToTarget(actor, target, MessageCategory.BEING_GAGGED); + } + + /** + * Send "X gagged you!" to target. + */ + public static void sendGagged(Entity actor, LivingEntity target) { + sendToTarget(actor, target, MessageCategory.GAGGED); + } + + /** + * Send "You have been enslaved by X!" to target. + */ + public static void sendEnslaved(Entity actor, LivingEntity target) { + sendToTarget(actor, target, MessageCategory.ENSLAVED); + } + + /** + * Send "You have been freed!" to target. + */ + public static void sendFreed(LivingEntity target) { + if (target instanceof Player player) { + sendToPlayer(player, MessageCategory.FREED); + } + } + + /** + * Send restriction message (can't do X while tied). + */ + public static void sendRestriction( + Player player, + MessageCategory restrictionCategory + ) { + sendToPlayer(player, restrictionCategory); + } + + /** + * Send a shock message to target. + */ + public static void sendShocked(LivingEntity target) { + if (target instanceof Player player) { + sendToPlayer(player, MessageCategory.SLAVE_SHOCK); + } + } + + /** + * Send binds tightened message. + */ + public static void sendBindsTightened(Entity actor, LivingEntity target) { + sendToTarget(actor, target, MessageCategory.BINDS_TIGHTENED); + } + + /** + * Send job assigned message with item name. + */ + public static void sendJobAssigned(Player player, String itemName) { + if (player == null) return; + String message = String.format( + getMessageTemplate(MessageCategory.SLAVE_JOB_ASSIGNED), + itemName + ); + sendToPlayer( + player, + message, + getCategoryColor(MessageCategory.SLAVE_JOB_ASSIGNED) + ); + } + + // ======================================== + // UTILITY + // ======================================== + + /** + * Get display name for an entity. + */ + private static String getEntityName(Entity entity) { + if (entity == null) return "Someone"; + + // Check for custom damsel name + if (entity instanceof com.tiedup.remake.entities.EntityDamsel damsel) { + String damselName = damsel.getNpcName(); + if (damselName != null && !damselName.isEmpty()) { + return damselName; + } + } + + return entity.getName().getString(); + } +} diff --git a/src/main/java/com/tiedup/remake/core/TiedUpMod.java b/src/main/java/com/tiedup/remake/core/TiedUpMod.java new file mode 100644 index 0000000..ccdc41f --- /dev/null +++ b/src/main/java/com/tiedup/remake/core/TiedUpMod.java @@ -0,0 +1,612 @@ +package com.tiedup.remake.core; + +import com.mojang.logging.LogUtils; +import com.tiedup.remake.commands.TiedUpCommand; +import com.tiedup.remake.compat.mca.MCACompat; +import com.tiedup.remake.dispenser.DispenserBehaviors; +import com.tiedup.remake.entities.ModEntities; +import com.tiedup.remake.items.ModCreativeTabs; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.util.ModGameRules; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.server.ServerStartingEvent; +import net.minecraftforge.eventbus.api.IEventBus; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent; +import net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import org.slf4j.Logger; + +/** + * TiedUp! - Main Mod Class + * + * Community remake of the TiedUp! mod for Minecraft 1.20.1 + * Original mod by Yuti & Marl Velius (1.12.2) + * + * This is an independent community remake, not affiliated with the original developers. + * For educational and preservation purposes. + * + * @author Unknown + * @version 0.1.0-ALPHA + */ +@Mod(TiedUpMod.MOD_ID) +public class TiedUpMod { + + // Mod ID - Must match gradle.properties + public static final String MOD_ID = "tiedup"; + + // Logger + public static final Logger LOGGER = LogUtils.getLogger(); + + @SuppressWarnings("removal") // FMLJavaModLoadingContext.get() is correct for 1.20.1, deprecated in 1.21.1+ + public TiedUpMod() { + IEventBus modEventBus = FMLJavaModLoadingContext.get().getModEventBus(); + + // Register items + ModItems.ITEMS.register(modEventBus); + + // Phase 16: Register blocks and block entities + com.tiedup.remake.blocks.ModBlocks.BLOCKS.register(modEventBus); + com.tiedup.remake.blocks.ModBlocks.BLOCK_ITEMS.register(modEventBus); + com.tiedup.remake.blocks.entity.ModBlockEntities.BLOCK_ENTITIES.register( + modEventBus + ); + + // V2 System: Register V2 blocks and block entities (OBJ models) + com.tiedup.remake.v2.V2Blocks.BLOCKS.register(modEventBus); + com.tiedup.remake.v2.V2Items.ITEMS.register(modEventBus); + com.tiedup.remake.v2.V2BlockEntities.BLOCK_ENTITIES.register( + modEventBus + ); + + // V2 bondage items + com.tiedup.remake.v2.bondage.V2BondageItems.ITEMS.register(modEventBus); + + // Register mob effects + ModEffects.MOB_EFFECTS.register(modEventBus); + + // Register sounds + ModSounds.SOUND_EVENTS.register(modEventBus); + + // Register creative tabs + ModCreativeTabs.CREATIVE_MODE_TABS.register(modEventBus); + + // Phase 8: Register entities + ModEntities.ENTITIES.register(modEventBus); + + // Register menu types (vanilla containers) + ModMenuTypes.MENUS.register(modEventBus); + + // Register structure processors + com.tiedup.remake.worldgen.ModProcessors.PROCESSORS.register( + modEventBus + ); + + // Register structures + com.tiedup.remake.worldgen.ModStructures.STRUCTURE_TYPES.register( + modEventBus + ); + com.tiedup.remake.worldgen.ModStructures.STRUCTURE_PIECE_TYPES.register( + modEventBus + ); + + // Register network packets + ModNetwork.register(); + + // Register Config + net.minecraftforge.fml.ModLoadingContext.get().registerConfig( + net.minecraftforge.fml.config.ModConfig.Type.SERVER, + ModConfig.SERVER_SPEC + ); + net.minecraftforge.fml.ModLoadingContext.get().registerConfig( + net.minecraftforge.fml.config.ModConfig.Type.CLIENT, + ModConfig.CLIENT_SPEC + ); + + // Phase 6: Register custom GameRules + ModGameRules.register(); + + // Register the commonSetup method for modloading + modEventBus.addListener(this::commonSetup); + + // Register ourselves for server and other game events we are interested in + MinecraftForge.EVENT_BUS.register(this); + + LOGGER.info("TiedUp! Mod initializing..."); + LOGGER.info( + "This is a community remake - Original mod by Yuti & Marl Velius" + ); + } + + /** + * Common setup - runs on both client and server + */ + private void commonSetup(final FMLCommonSetupEvent event) { + LOGGER.info("TiedUp! Common setup"); + LOGGER.info("Registered {} items", ModItems.ITEMS.getEntries().size()); + + // Phase 1: Data-Driven Skin System + // Skin loading moved to SkinReloadListener (registered via AddReloadListenerEvent) + // This allows skins to be loaded from datapacks and reloaded with /reload + + // Phase 14.3.5: Initialize SaleLoader and JobLoader + com.tiedup.remake.util.tasks.SaleLoader.init(); + com.tiedup.remake.util.tasks.JobLoader.init(); + + // MCA Compatibility: Initialize if MCA is present + MCACompat.init(); + com.tiedup.remake.compat.wildfire.WildfireCompat.init(); + + // Register dispenser behaviors (must be on main thread) + event.enqueueWork(DispenserBehaviors::register); + } + + /** + * Server starting event + */ + @SubscribeEvent + public void onServerStarting(ServerStartingEvent event) { + LOGGER.info("TiedUp! Server starting"); + + // Register all commands under /tiedup + TiedUpCommand.register(event.getServer().getCommands().getDispatcher()); + LOGGER.info( + "Registered all TiedUp commands (all commands now under /tiedup)" + ); + + // Initialize PrisonerManager + try { + net.minecraft.server.level.ServerLevel overworld = event + .getServer() + .overworld(); + // Force initialization of PrisonerManager + com.tiedup.remake.prison.PrisonerManager.get(overworld); + LOGGER.info("PrisonerManager initialized"); + } catch (Exception e) { + LOGGER.error( + "Error during PrisonerManager initialization: {}", + e.getMessage(), + e + ); + } + } + + /** + * Client-side setup (MOD bus events) + */ + @Mod.EventBusSubscriber( + modid = MOD_ID, + bus = Mod.EventBusSubscriber.Bus.MOD, + value = Dist.CLIENT + ) + public static class ClientModEvents { + + @SubscribeEvent + public static void onClientSetup(FMLClientSetupEvent event) { + LOGGER.info("TiedUp! Client setup"); + + // Initialize animation system + event.enqueueWork(() -> { + // Initialize unified BondageAnimationManager + com.tiedup.remake.client.animation.BondageAnimationManager.init(); + LOGGER.info("BondageAnimationManager initialized"); + + // Initialize OBJ model registry for 3D bondage items + com.tiedup.remake.client.renderer.obj.ObjModelRegistry.init(); + + // Register menu screens (vanilla container system) + net.minecraft.client.gui.screens.MenuScreens.register( + ModMenuTypes.NPC_INVENTORY.get(), + com.tiedup.remake.client.gui.screens.NpcInventoryScreen::new + ); + LOGGER.info("Menu screens registered"); + + // Note: SkinManagers are loaded server-side via SkinReloadListener + // Properties (variant ID, hasSlimArms) are synced to clients via entityData + // Clients compute texture paths from the synced variant ID + }); + } + + @SubscribeEvent + public static void onRegisterKeybindings( + net.minecraftforge.client.event.RegisterKeyMappingsEvent event + ) { + com.tiedup.remake.client.ModKeybindings.register(event); + } + + /** + * Register entity renderers. + * Phase 14.2.3: EntityDamsel needs a renderer. + * Phase 14.3: EntityKidnapper uses DamselRenderer. + */ + @SubscribeEvent + public static void onRegisterRenderers( + net.minecraftforge.client.event.EntityRenderersEvent.RegisterRenderers event + ) { + event.registerEntityRenderer( + ModEntities.DAMSEL.get(), + com.tiedup.remake.client.renderer.DamselRenderer::new + ); + LOGGER.info("Registered entity renderer for EntityDamsel"); + + // Phase 1: EntityDamselShiny renderer (reuses DamselRenderer) + event.registerEntityRenderer( + ModEntities.DAMSEL_SHINY.get(), + com.tiedup.remake.client.renderer.DamselRenderer::new + ); + LOGGER.info("Registered entity renderer for EntityDamselShiny"); + + // Phase 14.3: EntityKidnapper renderer (reuses DamselRenderer) + event.registerEntityRenderer( + ModEntities.KIDNAPPER.get(), + com.tiedup.remake.client.renderer.DamselRenderer::new + ); + LOGGER.info("Registered entity renderer for EntityKidnapper"); + + // Phase 14.3.6: EntityKidnapperElite renderer (reuses DamselRenderer) + event.registerEntityRenderer( + ModEntities.KIDNAPPER_ELITE.get(), + com.tiedup.remake.client.renderer.DamselRenderer::new + ); + LOGGER.info("Registered entity renderer for EntityKidnapperElite"); + + // EntityKidnapperMerchant renderer (reuses DamselRenderer) + event.registerEntityRenderer( + ModEntities.KIDNAPPER_MERCHANT.get(), + com.tiedup.remake.client.renderer.DamselRenderer::new + ); + LOGGER.info( + "Registered entity renderer for EntityKidnapperMerchant" + ); + + // Phase 18: EntityKidnapperArcher renderer (reuses DamselRenderer) + event.registerEntityRenderer( + ModEntities.KIDNAPPER_ARCHER.get(), + com.tiedup.remake.client.renderer.DamselRenderer::new + ); + LOGGER.info("Registered entity renderer for EntityKidnapperArcher"); + + // EntitySlaveTrader renderer (reuses DamselRenderer) + event.registerEntityRenderer( + ModEntities.SLAVE_TRADER.get(), + com.tiedup.remake.client.renderer.DamselRenderer::new + ); + LOGGER.info("Registered entity renderer for EntitySlaveTrader"); + + // EntityMaid renderer (reuses DamselRenderer) + event.registerEntityRenderer( + ModEntities.MAID.get(), + com.tiedup.remake.client.renderer.DamselRenderer::new + ); + LOGGER.info("Registered entity renderer for EntityMaid"); + + // EntityMaster renderer (reuses DamselRenderer) + event.registerEntityRenderer( + ModEntities.MASTER.get(), + com.tiedup.remake.client.renderer.DamselRenderer::new + ); + LOGGER.info("Registered entity renderer for EntityMaster"); + + // EntityLaborGuard renderer (reuses DamselRenderer) + event.registerEntityRenderer( + ModEntities.LABOR_GUARD.get(), + com.tiedup.remake.client.renderer.DamselRenderer::new + ); + LOGGER.info("Registered entity renderer for EntityLaborGuard"); + + // Phase 15: EntityRopeArrow renderer + event.registerEntityRenderer( + ModEntities.ROPE_ARROW.get(), + com.tiedup.remake.client.renderer.RopeArrowRenderer::new + ); + LOGGER.info("Registered entity renderer for EntityRopeArrow"); + + // Phase 16: EntityKidnapBomb renderer (custom renderer with our texture) + event.registerEntityRenderer( + ModEntities.KIDNAP_BOMB_ENTITY.get(), + com.tiedup.remake.client.renderer.KidnapBombRenderer::new + ); + LOGGER.info("Registered entity renderer for EntityKidnapBomb"); + + // NPC Fishing Bobber renderer (textured quad) + event.registerEntityRenderer( + ModEntities.NPC_FISHING_BOBBER.get(), + com.tiedup.remake.client.renderer.NpcFishingBobberRenderer::new + ); + LOGGER.info("Registered entity renderer for NpcFishingBobber"); + + // Furniture entity renderer (data-driven GLB mesh rendering) + event.registerEntityRenderer( + ModEntities.FURNITURE.get(), + com.tiedup.remake.v2.furniture.client.FurnitureEntityRenderer::new + ); + LOGGER.info("Registered entity renderer for EntityFurniture"); + + // Phase 16: TrappedChest uses vanilla ChestRenderer + event.registerBlockEntityRenderer( + com.tiedup.remake.blocks.entity.ModBlockEntities.TRAPPED_CHEST.get(), + net.minecraft.client.renderer.blockentity.ChestRenderer::new + ); + LOGGER.info("Registered block entity renderer for TrappedChest"); + } + + /** + * Register layer definitions. + * Phase 11: Register the HD bondage model layer. + * Phase 14.2.3: Register EntityDamsel model layers. + */ + @SubscribeEvent + public static void onRegisterLayerDefinitions( + net.minecraftforge.client.event.EntityRenderersEvent.RegisterLayerDefinitions event + ) { + event.registerLayerDefinition( + com.tiedup.remake.client.renderer.models.BondageLayerDefinitions.BONDAGE_LAYER, + com.tiedup.remake.client.renderer.models + .BondageLayerDefinitions::createBodyLayer + ); + event.registerLayerDefinition( + com.tiedup.remake.client.renderer.models.BondageLayerDefinitions.BONDAGE_LAYER_SLIM, + com.tiedup.remake.client.renderer.models + .BondageLayerDefinitions::createSlimBodyLayer + ); + LOGGER.info("Registered BondageLayerDefinitions (normal + slim)"); + + // Phase 19: EntityDamsel now uses vanilla ModelLayers.PLAYER / PLAYER_SLIM + // No custom layer registration needed - DamselModel extends PlayerModel + LOGGER.info( + "EntityDamsel uses vanilla PLAYER model layers (Phase 19)" + ); + } + + /** + * Add render layers to entity renderers. + * Phase 11: Add BondageItemRenderLayer to PlayerRenderer. + * + * Uses LOW priority to ensure bondage layers are added AFTER Wildfire's + * breast layers (which use default NORMAL priority). This ensures bondage + * items render ON TOP of breasts for players. + */ + @SubscribeEvent( + priority = net.minecraftforge.eventbus.api.EventPriority.LOW + ) + public static void onAddLayers( + net.minecraftforge.client.event.EntityRenderersEvent.AddLayers event + ) { + // Initialize ClothesModelCache for clothes rendering + com.tiedup.remake.client.renderer.layers.ClothesModelCache.init( + event.getEntityModels() + ); + + // V1 render layers (BondageItemRenderLayer, ArmorStandBondageLayer) removed. + // V2 render layer handles all equipment rendering. + + // MCA Compatibility: Add bondage render layers to MCA villager renderers + com.tiedup.remake.compat.mca.MCACompat.registerRenderLayers(event); + + // Wildfire Compatibility: Add gender layers + com.tiedup.remake.compat.wildfire.WildfireCompat.registerRenderLayers( + event + ); + } + } + + /** + * Common MOD bus events (runs on both client and server) + */ + @Mod.EventBusSubscriber( + modid = MOD_ID, + bus = Mod.EventBusSubscriber.Bus.MOD + ) + public static class CommonModEvents { + + /** + * Register spawn placements for natural spawning. + * All NPCs spawn on land like other creatures. + */ + @SubscribeEvent + public static void onRegisterSpawnPlacements( + net.minecraftforge.event.entity.SpawnPlacementRegisterEvent event + ) { + // Use Mob::checkMobSpawnRules for PathfinderMob entities + event.register( + ModEntities.DAMSEL.get(), + net.minecraft.world.entity.SpawnPlacements.Type.ON_GROUND, + net.minecraft.world.level.levelgen.Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, + net.minecraft.world.entity.Mob::checkMobSpawnRules, + net.minecraftforge.event.entity.SpawnPlacementRegisterEvent.Operation.REPLACE + ); + LOGGER.info("Registered spawn placement for EntityDamsel"); + + event.register( + ModEntities.KIDNAPPER.get(), + net.minecraft.world.entity.SpawnPlacements.Type.ON_GROUND, + net.minecraft.world.level.levelgen.Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, + net.minecraft.world.entity.Mob::checkMobSpawnRules, + net.minecraftforge.event.entity.SpawnPlacementRegisterEvent.Operation.REPLACE + ); + LOGGER.info("Registered spawn placement for EntityKidnapper"); + + event.register( + ModEntities.KIDNAPPER_ELITE.get(), + net.minecraft.world.entity.SpawnPlacements.Type.ON_GROUND, + net.minecraft.world.level.levelgen.Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, + net.minecraft.world.entity.Mob::checkMobSpawnRules, + net.minecraftforge.event.entity.SpawnPlacementRegisterEvent.Operation.REPLACE + ); + LOGGER.info("Registered spawn placement for EntityKidnapperElite"); + + event.register( + ModEntities.KIDNAPPER_ARCHER.get(), + net.minecraft.world.entity.SpawnPlacements.Type.ON_GROUND, + net.minecraft.world.level.levelgen.Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, + net.minecraft.world.entity.Mob::checkMobSpawnRules, + net.minecraftforge.event.entity.SpawnPlacementRegisterEvent.Operation.REPLACE + ); + LOGGER.info("Registered spawn placement for EntityKidnapperArcher"); + + event.register( + ModEntities.KIDNAPPER_MERCHANT.get(), + net.minecraft.world.entity.SpawnPlacements.Type.ON_GROUND, + net.minecraft.world.level.levelgen.Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, + net.minecraft.world.entity.Mob::checkMobSpawnRules, + net.minecraftforge.event.entity.SpawnPlacementRegisterEvent.Operation.REPLACE + ); + LOGGER.info( + "Registered spawn placement for EntityKidnapperMerchant" + ); + + event.register( + ModEntities.LABOR_GUARD.get(), + net.minecraft.world.entity.SpawnPlacements.Type.ON_GROUND, + net.minecraft.world.level.levelgen.Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, + net.minecraft.world.entity.Mob::checkMobSpawnRules, + net.minecraftforge.event.entity.SpawnPlacementRegisterEvent.Operation.REPLACE + ); + LOGGER.info("Registered spawn placement for EntityLaborGuard"); + } + + /** + * Register entity attributes. + * Phase 14.2: EntityDamsel needs attributes. + * Phase 14.3: EntityKidnapper and Elite variants. + */ + @SubscribeEvent + public static void onEntityAttributeCreation( + net.minecraftforge.event.entity.EntityAttributeCreationEvent event + ) { + event.put( + ModEntities.DAMSEL.get(), + com.tiedup.remake.entities.EntityDamsel.createAttributes().build() + ); + LOGGER.info("Registered entity attributes for EntityDamsel"); + + // Phase 1: EntityDamselShiny attributes + event.put( + ModEntities.DAMSEL_SHINY.get(), + com.tiedup.remake.entities.EntityDamselShiny.createAttributes().build() + ); + LOGGER.info("Registered entity attributes for EntityDamselShiny"); + + // Phase 14.3: EntityKidnapper attributes + event.put( + ModEntities.KIDNAPPER.get(), + com.tiedup.remake.entities.EntityKidnapper.createAttributes().build() + ); + LOGGER.info("Registered entity attributes for EntityKidnapper"); + + // Phase 14.3.6: EntityKidnapperElite attributes + event.put( + ModEntities.KIDNAPPER_ELITE.get(), + com.tiedup.remake.entities.EntityKidnapperElite.createAttributes().build() + ); + LOGGER.info( + "Registered entity attributes for EntityKidnapperElite" + ); + + // EntityKidnapperMerchant attributes + event.put( + ModEntities.KIDNAPPER_MERCHANT.get(), + com.tiedup.remake.entities.EntityKidnapperMerchant.createAttributes().build() + ); + LOGGER.info( + "Registered entity attributes for EntityKidnapperMerchant" + ); + + // Phase 18: EntityKidnapperArcher attributes + event.put( + ModEntities.KIDNAPPER_ARCHER.get(), + com.tiedup.remake.entities.EntityKidnapperArcher.createAttributes().build() + ); + LOGGER.info( + "Registered entity attributes for EntityKidnapperArcher" + ); + + // Slave Trader & Maid System: EntitySlaveTrader attributes + event.put( + ModEntities.SLAVE_TRADER.get(), + com.tiedup.remake.entities.EntitySlaveTrader.createAttributes().build() + ); + LOGGER.info("Registered entity attributes for EntitySlaveTrader"); + + // Slave Trader & Maid System: EntityMaid attributes + event.put( + ModEntities.MAID.get(), + com.tiedup.remake.entities.EntityMaid.createAttributes().build() + ); + LOGGER.info("Registered entity attributes for EntityMaid"); + + // Master NPC: EntityMaster attributes (pet play system) + event.put( + ModEntities.MASTER.get(), + com.tiedup.remake.entities.EntityMaster.createAttributes().build() + ); + LOGGER.info("Registered entity attributes for EntityMaster"); + + // Labor Guard: EntityLaborGuard attributes + event.put( + ModEntities.LABOR_GUARD.get(), + com.tiedup.remake.entities.EntityLaborGuard.createAttributes().build() + ); + LOGGER.info("Registered entity attributes for EntityLaborGuard"); + } + } + + /** + * Common events registered on the FORGE event bus. + * These events are triggered by Forge/Minecraft, not during mod initialization. + */ + @Mod.EventBusSubscriber( + modid = MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE + ) + public static class ForgeEvents { + + /** + * Register reload listeners for data-driven systems. + * + * Phase 1: Data-Driven Skin System + * - SkinReloadListener loads skin definitions from JSON files + * - Triggers on server start and /reload command + * + * Personality System: Data-driven dialogue + * - DialogueReloadListener loads dialogue JSON files from assets + */ + @SubscribeEvent + public static void onAddReloadListeners( + net.minecraftforge.event.AddReloadListenerEvent event + ) { + event.addListener( + new com.tiedup.remake.entities.skins.SkinReloadListener() + ); + LOGGER.info( + "Registered SkinReloadListener for data-driven skin loading" + ); + + event.addListener( + new com.tiedup.remake.dialogue.DialogueReloadListener() + ); + LOGGER.info( + "Registered DialogueReloadListener for data-driven dialogue loading" + ); + + // Data-driven bondage item definitions (server-side, from data//tiedup_items/) + event.addListener( + new com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemServerReloadListener() + ); + LOGGER.info( + "Registered DataDrivenItemServerReloadListener for server-side item definitions" + ); + + // Data-driven furniture definitions (server-side, from data//tiedup_furniture/) + event.addListener( + new com.tiedup.remake.v2.furniture.FurnitureServerReloadListener() + ); + LOGGER.info("Registered FurnitureServerReloadListener for data-driven furniture definitions"); + } + } + + // NOTE: Blindfold rendering moved to BlindfoldRenderEventHandler.java (Phase 5) +} diff --git a/src/main/java/com/tiedup/remake/dialogue/DialogueBridge.java b/src/main/java/com/tiedup/remake/dialogue/DialogueBridge.java new file mode 100644 index 0000000..6a5e410 --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/DialogueBridge.java @@ -0,0 +1,465 @@ +package com.tiedup.remake.dialogue; + +import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.EntityKidnapperMerchant; +import com.tiedup.remake.entities.KidnapperTheme; +import com.tiedup.remake.personality.PersonalityState; +import com.tiedup.remake.personality.PersonalityType; +import com.tiedup.remake.util.MessageDispatcher; +import org.jetbrains.annotations.Nullable; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; + +/** + * Bridge between the legacy EntityDialogueManager and the new DialogueManager. + * + * Provides methods to: + * - Build DialogueContext from EntityDamsel state + * - Build DialogueContext from IDialogueSpeaker (universal) + * - Map DialogueCategory to dialogue IDs + * - Try data-driven dialogues first, fallback to hardcoded + * + * Personality System: Data-driven dialogue + * Universal NPC Support: Extended for all speaker types + */ +public class DialogueBridge { + + /** + * Build a DialogueContext from an EntityDamsel. + * + * @param entity The entity to build context from + * @param player The player interacting (can be null) + * @return DialogueContext with all available state + */ + public static DialogueContext buildContext( + EntityDamsel entity, + @Nullable Player player + ) { + DialogueContext.Builder builder = DialogueContext.builder() + .npcName(entity.getNpcName()) + .bound(entity.isTiedUp()) + .gagged(entity.isGagged()) + .blindfold(entity.isBlindfolded()) + .hasCollar(entity.hasCollar()); + + // Add player info if available + if (player != null) { + builder.playerName(player.getName().getString()); + } + + // Add personality state if available + PersonalityState state = entity.getPersonalityState(); + if (state != null) { + builder + .personality(state.getPersonality()) + .mood((int) state.getMood()) + .hunger(state.getNeeds().getHunger()); + + // Get master name if collared (commanding player is the master) + if ( + entity.hasCollar() && + state.getCommandingPlayer() != null && + player != null + ) { + // If the interacting player is the commanding player (master) + if (player.getUUID().equals(state.getCommandingPlayer())) { + builder.masterName(player.getName().getString()); + } + } + } else { + // Default values if no personality state + builder.personality(PersonalityType.CALM).mood(50); + } + + return builder.build(); + } + + /** + * Map a DialogueCategory to a dialogue ID. + * + * @param category The legacy category + * @return Dialogue ID for the new system + */ + public static String categoryToDialogueId(DialogueCategory category) { + return switch (category) { + // Capture sequence + case CAPTURE_START -> "capture.start"; + case CAPTURE_APPROACHING -> "capture.approaching"; + case CAPTURE_CHASE -> "capture.chase"; + case CAPTURE_TYING -> "capture.tying"; + case CAPTURE_TIED -> "capture.tied"; + case CAPTURE_GAGGING -> "capture.gagging"; + case CAPTURE_GAGGED -> "capture.gagged"; + case CAPTURE_ENSLAVED -> "capture.enslaved"; + case CAPTURE_ESCAPE -> "capture.escape"; + // Damsel specific + case DAMSEL_PANIC -> "capture.panic"; + case DAMSEL_FLEE -> "capture.flee"; + case DAMSEL_CAPTURED -> "capture.captured"; + case DAMSEL_FREED -> "capture.freed"; + case DAMSEL_GREETING -> "idle.greeting"; + case DAMSEL_IDLE -> "idle.free"; + case DAMSEL_CALL_FOR_HELP -> "capture.call_for_help"; + // Slave management + case SLAVE_TALK_RESPONSE -> "idle.slave_talk"; + case SLAVE_STRUGGLE -> "struggle.warned"; + case SLAVE_TRANSPORT -> "idle.transport"; + case SLAVE_ARRIVE_PRISON -> "idle.arrive_prison"; + case SLAVE_TIED_TO_POLE -> "idle.tied_to_pole"; + case PUNISH -> "idle.punish"; + // Sale system + case SALE_WAITING -> "idle.sale_waiting"; + case SALE_ANNOUNCE -> "idle.sale_announce"; + case SALE_OFFER -> "idle.sale_offer"; + case SALE_COMPLETE -> "idle.sale_complete"; + case SALE_ABANDONED -> "idle.sale_abandoned"; + case SALE_KEPT -> "idle.sale_kept"; + // Job system + case JOB_ASSIGNED -> "jobs.assigned"; + case JOB_HURRY -> "jobs.hurry"; + case JOB_COMPLETE -> "jobs.complete"; + case JOB_FAILED -> "jobs.failed"; + case JOB_LAST_CHANCE -> "jobs.last_chance"; + case JOB_KILL -> "jobs.kill"; + // Combat + case ATTACKED_RESPONSE -> "combat.attacked_response"; + case ATTACK_SLAVE -> "combat.attack_slave"; + case GENERIC_THREAT -> "combat.threat"; + case GENERIC_TAUNT -> "combat.taunt"; + // General + case FREED -> "idle.freed_captor"; + case GOODBYE -> "idle.goodbye"; + case GET_OUT -> "idle.get_out"; + // Personality system commands + case COMMAND_ACCEPT -> "command.generic.accept"; + case COMMAND_REFUSE -> "command.generic.refuse"; + case COMMAND_HESITATE -> "command.generic.hesitate"; + // Needs + case NEED_HUNGRY -> "needs.hungry"; + case NEED_TIRED -> "needs.tired"; + case NEED_UNCOMFORTABLE -> "needs.uncomfortable"; + case NEED_DIGNITY_LOW -> "needs.dignity_low"; + // Personality hints + case PERSONALITY_HINT_TIMID -> "personality.hint"; + case PERSONALITY_HINT_GENTLE -> "personality.hint"; + case PERSONALITY_HINT_SUBMISSIVE -> "personality.hint"; + case PERSONALITY_HINT_CALM -> "personality.hint"; + case PERSONALITY_HINT_CURIOUS -> "personality.hint"; + case PERSONALITY_HINT_PROUD -> "personality.hint"; + case PERSONALITY_HINT_FIERCE -> "personality.hint"; + case PERSONALITY_HINT_DEFIANT -> "personality.hint"; + case PERSONALITY_HINT_PLAYFUL -> "personality.hint"; + case PERSONALITY_HINT_MASOCHIST -> "personality.hint"; + case PERSONALITY_HINT_SADIST -> "personality.hint"; + // Discipline responses (Training V2) + case PRAISE_RESPONSE -> "discipline.praise"; + case SCOLD_RESPONSE -> "discipline.scold"; + case THREATEN_RESPONSE -> "discipline.threaten"; + }; + } + + /** + * Get dialogue from the data-driven system. + * + * @param entity The entity speaking + * @param player The player (can be null) + * @param category The dialogue category + * @return Dialogue text, or null if not found + */ + @Nullable + public static String getDataDrivenDialogue( + EntityDamsel entity, + @Nullable Player player, + DialogueCategory category + ) { + if (!DialogueManager.isInitialized()) { + return null; + } + + DialogueContext context = buildContext(entity, player); + String dialogueId = categoryToDialogueId(category); + + return DialogueManager.getDialogue(dialogueId, context); + } + + /** + * Get command dialogue from the data-driven system. + * + * @param entity The entity + * @param player The player + * @param commandName The command name (e.g., "follow", "stay") + * @param accepted Whether the command was accepted + * @param hesitated Whether the NPC hesitated + * @return Dialogue text, or null if not found + */ + @Nullable + public static String getCommandDialogue( + EntityDamsel entity, + @Nullable Player player, + String commandName, + boolean accepted, + boolean hesitated + ) { + if (!DialogueManager.isInitialized()) { + return null; + } + + DialogueContext context = buildContext(entity, player); + + String suffix; + if (!accepted) { + suffix = ".refuse"; + } else if (hesitated) { + suffix = ".hesitate"; + } else { + suffix = ".accept"; + } + + String dialogueId = "command." + commandName.toLowerCase() + suffix; + return DialogueManager.getDialogue(dialogueId, context); + } + + // =========================================== + // UNIVERSAL SPEAKER METHODS (IDialogueSpeaker) + // =========================================== + + /** + * Build a DialogueContext from any IDialogueSpeaker. + * Automatically detects speaker type and builds appropriate context. + * + * @param speaker The dialogue speaker + * @param player The player interacting (can be null) + * @return DialogueContext with all available state + */ + public static DialogueContext buildContext( + IDialogueSpeaker speaker, + @Nullable Player player + ) { + DialogueContext.Builder builder = DialogueContext.builder().fromSpeaker( + speaker, + player + ); + + // Add speaker-type specific context + LivingEntity entity = speaker.asEntity(); + + // Kidnapper-specific: add theme + if (entity instanceof EntityKidnapper kidnapper) { + KidnapperTheme theme = kidnapper.getTheme(); + if (theme != null) { + builder.kidnapperTheme(theme.name()); + } + } + + // Merchant-specific: add mode + if (entity instanceof EntityKidnapperMerchant merchant) { + builder.merchantMode(merchant.isHostile() ? "HOSTILE" : "MERCHANT"); + } + + // Damsel-specific: add bondage state and personality state + if (entity instanceof EntityDamsel damsel) { + builder + .bound(damsel.isTiedUp()) + .gagged(damsel.isGagged()) + .blindfold(damsel.isBlindfolded()) + .hasCollar(damsel.hasCollar()); + + // Add needs from personality state + PersonalityState state = damsel.getPersonalityState(); + if (state != null) { + builder.hunger(state.getNeeds().getHunger()); + + // Get master name if collared + if ( + damsel.hasCollar() && + state.getCommandingPlayer() != null && + player != null && + player.getUUID().equals(state.getCommandingPlayer()) + ) { + builder.masterName(player.getName().getString()); + } + } + } + + return builder.build(); + } + + /** + * Get dialogue for a speaker and dialogue ID. + * + * @param speaker The dialogue speaker + * @param player The player (can be null) + * @param dialogueId The dialogue ID (e.g., "guard.watching") + * @return Dialogue text, or null if not found + */ + @Nullable + public static String getDialogue( + IDialogueSpeaker speaker, + @Nullable Player player, + String dialogueId + ) { + if (!DialogueManager.isInitialized()) { + return null; + } + + DialogueContext context = buildContext(speaker, player); + return DialogueManager.getDialogue(dialogueId, context); + } + + /** + * Send dialogue from a speaker to a player. + * Handles dialogue lookup, variable substitution, and message dispatch. + * + * @param speaker The dialogue speaker + * @param player The player to send to + * @param dialogueId The dialogue ID + * @return true if dialogue was sent, false if not found or on cooldown + */ + public static boolean talkTo( + IDialogueSpeaker speaker, + Player player, + String dialogueId + ) { + // Check cooldown + if (speaker.getDialogueCooldown() > 0) { + return false; + } + + String dialogue = getDialogue(speaker, player, dialogueId); + if (dialogue == null || dialogue.isEmpty()) { + return false; + } + + // Format and send message + broadcastDialogue(speaker.asEntity(), player, dialogue, false); + + // Set default cooldown (600 ticks = 30 seconds) + speaker.setDialogueCooldown(600); + + return true; + } + + /** + * Send an action from a speaker to a player. + * Actions are formatted differently: "* Name action *" + * + * @param speaker The dialogue speaker + * @param player The player to send to + * @param dialogueId The dialogue ID + * @return true if action was sent, false if not found or on cooldown + */ + public static boolean actionTo( + IDialogueSpeaker speaker, + Player player, + String dialogueId + ) { + // Check cooldown + if (speaker.getDialogueCooldown() > 0) { + return false; + } + + String dialogue = getDialogue(speaker, player, dialogueId); + if (dialogue == null || dialogue.isEmpty()) { + return false; + } + + // Format and send as action + broadcastDialogue(speaker.asEntity(), player, dialogue, true); + + // Set default cooldown + speaker.setDialogueCooldown(600); + + return true; + } + + /** + * Broadcast dialogue from an entity to a player. + * + * @param entity The speaking entity + * @param player The target player + * @param text The dialogue text + * @param isAction Whether to format as action + */ + private static void broadcastDialogue( + LivingEntity entity, + Player player, + String text, + boolean isAction + ) { + String name = entity.getName().getString(); + + if (isAction) { + MessageDispatcher.actionTo(entity, player, text); + } else { + MessageDispatcher.talkTo(entity, player, text); + } + } + + /** + * Send dialogue to all players within a radius. + * + * @param speaker The dialogue speaker + * @param dialogueId The dialogue ID + * @param radius The broadcast radius in blocks + * @return true if dialogue was sent to at least one player + */ + public static boolean talkToNearby( + IDialogueSpeaker speaker, + String dialogueId, + double radius + ) { + if (speaker.getDialogueCooldown() > 0) { + return false; + } + + LivingEntity entity = speaker.asEntity(); + var nearbyPlayers = entity + .level() + .getEntitiesOfClass( + Player.class, + entity.getBoundingBox().inflate(radius) + ); + + if (nearbyPlayers.isEmpty()) { + return false; + } + + boolean sentAny = false; + for (Player player : nearbyPlayers) { + String dialogue = getDialogue(speaker, player, dialogueId); + if (dialogue != null && !dialogue.isEmpty()) { + broadcastDialogue(entity, player, dialogue, false); + sentAny = true; + } + } + + if (sentAny) { + speaker.setDialogueCooldown(600); + } + + return sentAny; + } + + /** + * Map theme to a personality-like folder for kidnapper dialogues. + * This allows theme-based dialogue variation. + * + * @param theme The kidnapper theme + * @return Personality folder name to use + */ + public static String mapThemeToPersonalityFolder(KidnapperTheme theme) { + if (theme == null) { + return "default"; + } + + return switch (theme) { + case ROPE, SHIBARI -> "traditional"; + case TAPE, LEATHER, CHAIN -> "rough"; + case MEDICAL, ASYLUM -> "clinical"; + case LATEX, RIBBON -> "playful"; + case BEAM, WRAP -> "default"; + }; + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/DialogueCondition.java b/src/main/java/com/tiedup/remake/dialogue/DialogueCondition.java new file mode 100644 index 0000000..d10e44c --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/DialogueCondition.java @@ -0,0 +1,155 @@ +package com.tiedup.remake.dialogue; + +import com.tiedup.remake.personality.PersonalityType; +import java.util.EnumSet; +import java.util.Set; +import org.jetbrains.annotations.Nullable; + +/** + * Conditions for when a dialogue entry can be used. + * + * Personality System: Data-driven dialogue + */ +public class DialogueCondition { + + /** Personality types this dialogue applies to (null = all) */ + @Nullable + private final Set personalities; + + /** Minimum mood value (-100 to 100) */ + private final int moodMin; + + /** Maximum mood value (-100 to 100) */ + private final int moodMax; + + private DialogueCondition(Builder builder) { + this.personalities = builder.personalities; + this.moodMin = builder.moodMin; + this.moodMax = builder.moodMax; + } + + /** + * Check if this condition matches the given context. + */ + public boolean matches(DialogueContext context) { + // Check personality + if (personalities != null && !personalities.isEmpty()) { + if (!personalities.contains(context.getPersonality())) { + return false; + } + } + + // Check mood + if (context.getMood() < moodMin || context.getMood() > moodMax) { + return false; + } + + return true; + } + + /** + * Create a condition that matches all contexts (no restrictions). + */ + public static DialogueCondition any() { + return new Builder().build(); + } + + /** + * Create a condition for specific personalities. + */ + public static DialogueCondition forPersonalities(PersonalityType... types) { + return new Builder().personalities(types).build(); + } + + public static Builder builder() { + return new Builder(); + } + + // Getters + @Nullable + public Set getPersonalities() { + return personalities; + } + + public int getMoodMin() { + return moodMin; + } + + public int getMoodMax() { + return moodMax; + } + + /** + * Builder for DialogueCondition. + */ + public static class Builder { + + private Set personalities = null; + private int moodMin = -100; + private int moodMax = 100; + + public Builder personalities(PersonalityType... types) { + this.personalities = EnumSet.noneOf(PersonalityType.class); + for (PersonalityType type : types) { + this.personalities.add(type); + } + return this; + } + + public Builder personalities(Set types) { + this.personalities = types; + return this; + } + + /** + * No-op: training conditions are ignored (training system removed). + * Kept for backward compatibility with JSON files that still reference training_min. + */ + public Builder trainingMin(Object level) { + return this; + } + + /** + * No-op: training conditions are ignored (training system removed). + * Kept for backward compatibility with JSON files that still reference training_max. + */ + public Builder trainingMax(Object level) { + return this; + } + + public Builder moodMin(int min) { + this.moodMin = min; + return this; + } + + public Builder moodMax(int max) { + this.moodMax = max; + return this; + } + + public Builder moodRange(int min, int max) { + this.moodMin = min; + this.moodMax = max; + return this; + } + + /** + * No-op: resentment conditions are ignored (training system removed). + */ + public Builder resentmentMin(int min) { + return this; + } + + public Builder resentmentMax(int max) { + return this; + } + + public Builder resentmentRange(int min, int max) { + return this; + } + + public DialogueCondition build() { + return new DialogueCondition(this); + } + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/DialogueContext.java b/src/main/java/com/tiedup/remake/dialogue/DialogueContext.java new file mode 100644 index 0000000..e6edf98 --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/DialogueContext.java @@ -0,0 +1,339 @@ +package com.tiedup.remake.dialogue; + +import com.tiedup.remake.personality.PersonalityType; +import org.jetbrains.annotations.Nullable; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; + +/** + * Context information for dialogue selection. + * Holds all relevant state needed to match dialogue conditions. + * + * Personality System: Data-driven dialogue + * Universal NPC Support: Extended for all speaker types + */ +public class DialogueContext { + + // Speaker type for dialogue routing + private final SpeakerType speakerType; + + private final PersonalityType personality; + private final int mood; + + // Kidnapper-specific context + @Nullable + private final String kidnapperTheme; + + // Merchant-specific context + @Nullable + private final String merchantMode; + + // Variable substitution data + @Nullable + private final String npcName; + + @Nullable + private final String playerName; + + @Nullable + private final String masterName; + + @Nullable + private final String targetName; + + // Additional state + private final boolean isBound; + private final boolean isGagged; + private final boolean isBlindfold; + private final boolean hasCollar; + private final float hunger; + + private DialogueContext(Builder builder) { + this.speakerType = builder.speakerType; + this.personality = builder.personality; + this.mood = builder.mood; + this.kidnapperTheme = builder.kidnapperTheme; + this.merchantMode = builder.merchantMode; + this.npcName = builder.npcName; + this.playerName = builder.playerName; + this.masterName = builder.masterName; + this.targetName = builder.targetName; + this.isBound = builder.isBound; + this.isGagged = builder.isGagged; + this.isBlindfold = builder.isBlindfold; + this.hasCollar = builder.hasCollar; + this.hunger = builder.hunger; + } + + // --- Getters for condition matching --- + + /** + * Get the speaker type for dialogue routing. + */ + public SpeakerType getSpeakerType() { + return speakerType; + } + + public PersonalityType getPersonality() { + return personality; + } + + /** + * Get the kidnapper theme (for theme-based personalities). + * Only relevant for kidnapper-type speakers. + */ + @Nullable + public String getKidnapperTheme() { + return kidnapperTheme; + } + + /** + * Get the merchant mode (MERCHANT or HOSTILE). + * Only relevant for merchant speakers. + */ + @Nullable + public String getMerchantMode() { + return merchantMode; + } + + public int getMood() { + return mood; + } + + // --- Getters for variable substitution --- + + @Nullable + public String getNpcName() { + return npcName; + } + + @Nullable + public String getPlayerName() { + return playerName; + } + + @Nullable + public String getMasterName() { + return masterName; + } + + @Nullable + public String getTargetName() { + return targetName; + } + + public boolean isBound() { + return isBound; + } + + public boolean isGagged() { + return isGagged; + } + + public boolean isBlindfold() { + return isBlindfold; + } + + public boolean hasCollar() { + return hasCollar; + } + + public float getHunger() { + return hunger; + } + + /** + * Get mood as a string descriptor. + */ + public String getMoodString() { + if (mood >= 70) return "happy"; + if (mood >= 40) return "neutral"; + if (mood >= 10) return "sad"; + return "miserable"; + } + + /** + * Get hunger as a string descriptor. + */ + public String getHungerString() { + if (hunger >= 70) return "full"; + if (hunger >= 30) return "hungry"; + return "starving"; + } + + /** + * Substitute variables in dialogue text. + * + * @param text Text with placeholders like {player}, {npc}, etc. + * @return Text with placeholders replaced + */ + public String substituteVariables(String text) { + String result = text; + + // Entity names + if (npcName != null) { + result = result.replace("{npc}", npcName); + } + if (playerName != null) { + result = result.replace("{player}", playerName); + } + if (masterName != null) { + result = result.replace("{master}", masterName); + } + if (targetName != null) { + result = result.replace("{target}", targetName); + } + + // State variables + result = result.replace("{mood}", getMoodString()); + result = result.replace("{hunger}", getHungerString()); + result = result.replace( + "{personality}", + personality.name().toLowerCase() + ); + + return result; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for DialogueContext. + */ + public static class Builder { + + private SpeakerType speakerType = SpeakerType.DAMSEL; + private PersonalityType personality = PersonalityType.CALM; + private int mood = 50; + private String kidnapperTheme = null; + private String merchantMode = null; + private String npcName = null; + private String playerName = null; + private String masterName = null; + private String targetName = null; + private boolean isBound = false; + private boolean isGagged = false; + private boolean isBlindfold = false; + private boolean hasCollar = false; + private float hunger = 100f; + + public Builder speakerType(SpeakerType speakerType) { + this.speakerType = speakerType; + return this; + } + + public Builder personality(PersonalityType personality) { + this.personality = personality; + return this; + } + + public Builder kidnapperTheme(String theme) { + this.kidnapperTheme = theme; + return this; + } + + public Builder merchantMode(String mode) { + this.merchantMode = mode; + return this; + } + + public Builder mood(int mood) { + this.mood = mood; + return this; + } + + public Builder npcName(String name) { + this.npcName = name; + return this; + } + + public Builder playerName(String name) { + this.playerName = name; + return this; + } + + public Builder masterName(String name) { + this.masterName = name; + return this; + } + + public Builder targetName(String name) { + this.targetName = name; + return this; + } + + public Builder bound(boolean bound) { + this.isBound = bound; + return this; + } + + public Builder gagged(boolean gagged) { + this.isGagged = gagged; + return this; + } + + public Builder blindfold(boolean blindfold) { + this.isBlindfold = blindfold; + return this; + } + + public Builder hasCollar(boolean collar) { + this.hasCollar = collar; + return this; + } + + public Builder hunger(float hunger) { + this.hunger = hunger; + return this; + } + + /** + * Set player info from a Player entity. + */ + public Builder fromPlayer(Player player) { + this.playerName = player.getName().getString(); + return this; + } + + /** + * Set target info from a LivingEntity. + */ + public Builder fromTarget(LivingEntity target) { + this.targetName = target.getName().getString(); + return this; + } + + public DialogueContext build() { + return new DialogueContext(this); + } + + /** + * Build from an IDialogueSpeaker. + * Automatically sets speakerType, personality, mood, and npcName. + * + * @param speaker The dialogue speaker + * @param player The player interacting (can be null) + * @return Builder with speaker data applied + */ + public Builder fromSpeaker( + IDialogueSpeaker speaker, + @Nullable Player player + ) { + this.speakerType = speaker.getSpeakerType(); + this.npcName = speaker.getDialogueName(); + this.mood = speaker.getSpeakerMood(); + + if (speaker.getSpeakerPersonality() != null) { + this.personality = speaker.getSpeakerPersonality(); + } + + if (player != null) { + this.playerName = player.getName().getString(); + } + + return this; + } + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/DialogueEntry.java b/src/main/java/com/tiedup/remake/dialogue/DialogueEntry.java new file mode 100644 index 0000000..a03a713 --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/DialogueEntry.java @@ -0,0 +1,181 @@ +package com.tiedup.remake.dialogue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/** + * A dialogue entry containing an ID, conditions, and weighted variants. + * Represents a single dialogue "slot" that can be filled with different text + * based on personality and context. + * + * Personality System: Data-driven dialogue + */ +public class DialogueEntry { + + private static final Random RANDOM = new Random(); + + private final String id; + private final DialogueCondition condition; + private final List variants; + + public DialogueEntry( + String id, + DialogueCondition condition, + List variants + ) { + this.id = id; + this.condition = condition; + this.variants = new ArrayList<>(variants); + } + + public DialogueEntry(String id, DialogueCondition condition) { + this(id, condition, new ArrayList<>()); + } + + public String getId() { + return id; + } + + public DialogueCondition getCondition() { + return condition; + } + + public List getVariants() { + return variants; + } + + /** + * Add a variant to this entry. + */ + public DialogueEntry addVariant(DialogueVariant variant) { + this.variants.add(variant); + return this; + } + + /** + * Add a simple text variant with default weight. + */ + public DialogueEntry addVariant(String text) { + this.variants.add(new DialogueVariant(text)); + return this; + } + + /** + * Add a text variant with specified weight. + */ + public DialogueEntry addVariant(String text, int weight) { + this.variants.add(new DialogueVariant(text, weight)); + return this; + } + + /** + * Check if this entry matches the given context. + */ + public boolean matches(DialogueContext context) { + return condition.matches(context); + } + + /** + * Select a random variant based on weights. + * + * @return Selected variant, or null if no variants available + */ + public DialogueVariant selectVariant() { + if (variants.isEmpty()) { + return null; + } + + // Calculate total weight + int totalWeight = 0; + for (DialogueVariant variant : variants) { + totalWeight += variant.getWeight(); + } + + if (totalWeight <= 0) { + return variants.get(RANDOM.nextInt(variants.size())); + } + + // Weighted random selection + int roll = RANDOM.nextInt(totalWeight); + int cumulative = 0; + + for (DialogueVariant variant : variants) { + cumulative += variant.getWeight(); + if (roll < cumulative) { + return variant; + } + } + + return variants.get(0); // Fallback + } + + /** + * Select a random variant and format with context variables. + * + * @param context Context for variable substitution + * @return Formatted dialogue text, or null if no variants + */ + public String getFormattedDialogue(DialogueContext context) { + DialogueVariant variant = selectVariant(); + if (variant == null) { + return null; + } + + String text = context.substituteVariables(variant.getText()); + + // Format action text with asterisks + if (variant.isAction()) { + text = "*" + text + "*"; + } + + // Apply gag filter if speaker is gagged + if (context.isGagged()) { + // Don't muffle action text (already in asterisks) + if (!text.startsWith("*") || !text.endsWith("*")) { + text = GagTalkManager.transformToGaggedSpeech(text); + } + } + + return text; + } + + /** + * Check if this entry has any variants. + */ + public boolean hasVariants() { + return !variants.isEmpty(); + } + + // --- Static builder methods --- + + /** + * Create an entry that matches any context. + */ + public static DialogueEntry any(String id) { + return new DialogueEntry(id, DialogueCondition.any()); + } + + /** + * Create an entry with a condition builder. + */ + public static DialogueEntry withCondition( + String id, + DialogueCondition condition + ) { + return new DialogueEntry(id, condition); + } + + @Override + public String toString() { + return ( + "DialogueEntry{" + + "id='" + + id + + '\'' + + ", variants=" + + variants.size() + + '}' + ); + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/DialogueLoader.java b/src/main/java/com/tiedup/remake/dialogue/DialogueLoader.java new file mode 100644 index 0000000..1311f78 --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/DialogueLoader.java @@ -0,0 +1,471 @@ +package com.tiedup.remake.dialogue; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.personality.PersonalityType; +import java.io.Reader; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.jetbrains.annotations.Nullable; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; + +/** + * Loads dialogue entries from JSON files. + * + * New structure (per-personality dialogues): + * dialogue/{lang}/default/ - Base dialogues (fallback) + * dialogue/{lang}/{personality}/ - Personality-specific dialogues + * + * Each personality folder contains the same categories: + * - idle.json, struggle.json, capture.json, commands.json, + * needs.json, mood.json, jobs.json, combat.json + * + * Personality System: Data-driven dialogue + */ +public class DialogueLoader { + + private static final Gson GSON = new GsonBuilder() + .setPrettyPrinting() + .create(); + + /** + * Dialogue file categories to load. + */ + private static final String[] DIALOGUE_CATEGORIES = { + "commands", + "capture", + "struggle", + "jobs", + "mood", + "combat", + "needs", + "idle", + "actions", + "fear", + "reaction", + "environment", + // Phase 12: New dialogue categories + "discipline", + "home", + "leash", + "resentment", + // Phase 14: Personality hints and conversations + "personality", + "conversation", + // Master NPC dialogues + "punishment", + "purchase", + "inspection", + "petplay", + // Camp NPC dialogues (guard, maid) + "guard_labor", + "maid_labor", + // Kidnapper-specific dialogues + "guard", + "dogwalk", + "patrol", + "punish", + }; + + /** + * Speaker-type specific dialogue folders to load. + * These are loaded as additional dialogues for all personalities. + */ + private static final String[] SPEAKER_TYPE_FOLDERS = { + "master", + "kidnapper", + "maid", + "trader", + "guard", + "kidnapper_archer", + "kidnapper_elite", + "merchant", + }; + + /** + * Load all dialogues organized by personality. + * + * @param resourceManager Resource manager + * @param lang Language code (e.g., "en_us") + * @return Map of personality -> dialogue ID -> entries + */ + public static Map< + PersonalityType, + Map> + > loadDialogues(ResourceManager resourceManager, String lang) { + Map>> allDialogues = + new EnumMap<>(PersonalityType.class); + + TiedUpMod.LOGGER.info( + "[DialogueLoader] Loading dialogues for lang: {}", + lang + ); + + // Load default dialogues (stored with null key in a temporary holder) + Map> defaultDialogues = new HashMap<>(); + loadDialoguesForPersonality( + resourceManager, + lang, + "default", + null, + defaultDialogues + ); + + int totalEntries = defaultDialogues + .values() + .stream() + .mapToInt(List::size) + .sum(); + TiedUpMod.LOGGER.info( + "[DialogueLoader] Loaded {} default dialogue IDs ({} entries)", + defaultDialogues.size(), + totalEntries + ); + + // Load personality-specific dialogues + for (PersonalityType personality : PersonalityType.values()) { + // Start with a copy of default dialogues + Map> personalityDialogues = + new HashMap<>(); + + // Copy default entries (deep copy the lists) + for (Map.Entry< + String, + List + > entry : defaultDialogues.entrySet()) { + personalityDialogues.put( + entry.getKey(), + new ArrayList<>(entry.getValue()) + ); + } + + // Load and merge personality-specific dialogues (these take priority) + String folderName = personality.name().toLowerCase(); + loadDialoguesForPersonality( + resourceManager, + lang, + folderName, + personality, + personalityDialogues + ); + + allDialogues.put(personality, personalityDialogues); + } + + // Load speaker-type specific dialogues (e.g., master/) + // These are added to ALL personality maps so they're always available + for (String speakerFolder : SPEAKER_TYPE_FOLDERS) { + loadSpeakerTypeDialogues( + resourceManager, + lang, + speakerFolder, + allDialogues + ); + } + + TiedUpMod.LOGGER.info( + "[DialogueLoader] Loaded dialogues for {} personalities", + allDialogues.size() + ); + + return allDialogues; + } + + /** + * Load speaker-type specific dialogues and add them to all personality maps. + * This allows speaker-specific dialogues (e.g., master/) to be available + * regardless of personality context. + * + * @param resourceManager Resource manager + * @param lang Language code + * @param speakerFolder Speaker type folder name (e.g., "master") + * @param allDialogues Map to populate + */ + private static void loadSpeakerTypeDialogues( + ResourceManager resourceManager, + String lang, + String speakerFolder, + Map>> allDialogues + ) { + // Load from speaker/default folder + Map> speakerDialogues = new HashMap<>(); + String folderPath = speakerFolder + "/default"; + + loadDialoguesForPersonality( + resourceManager, + lang, + folderPath, + null, // No personality condition - available to all + speakerDialogues + ); + + if (speakerDialogues.isEmpty()) { + return; + } + + int entriesAdded = speakerDialogues + .values() + .stream() + .mapToInt(List::size) + .sum(); + TiedUpMod.LOGGER.info( + "[DialogueLoader] Loaded {} speaker-type dialogues from '{}'", + entriesAdded, + folderPath + ); + + // Add to all personality maps + for (PersonalityType personality : PersonalityType.values()) { + Map> personalityDialogues = + allDialogues.get(personality); + if (personalityDialogues != null) { + for (Map.Entry< + String, + List + > entry : speakerDialogues.entrySet()) { + personalityDialogues + .computeIfAbsent(entry.getKey(), k -> new ArrayList<>()) + .addAll(entry.getValue()); + } + } + } + } + + /** + * Load dialogues for a specific personality folder. + * + * @param resourceManager Resource manager + * @param lang Language code + * @param folderName Folder name (e.g., "default", "timid", "fierce") + * @param personality Personality type (null for default) + * @param dialogues Map to populate (personality-specific entries are added at front for priority) + */ + private static void loadDialoguesForPersonality( + ResourceManager resourceManager, + String lang, + String folderName, + @Nullable PersonalityType personality, + Map> dialogues + ) { + int loadedFiles = 0; + int loadedEntries = 0; + + for (String category : DIALOGUE_CATEGORIES) { + String path = + "dialogue/" + + lang + + "/" + + folderName + + "/" + + category + + ".json"; + ResourceLocation location = ResourceLocation.fromNamespaceAndPath( + TiedUpMod.MOD_ID, + path + ); + + try { + Resource resource = resourceManager + .getResource(location) + .orElse(null); + if (resource == null) continue; + + try (Reader reader = resource.openAsReader()) { + JsonObject json = GSON.fromJson(reader, JsonObject.class); + int count = parseDialogueFile(json, personality, dialogues); + loadedFiles++; + loadedEntries += count; + } + } catch (Exception e) { + TiedUpMod.LOGGER.warn( + "[DialogueLoader] Failed to load {}: {}", + location, + e.getMessage() + ); + } + } + + if (loadedFiles > 0) { + TiedUpMod.LOGGER.debug( + "[DialogueLoader] Loaded {} files ({} entries) for '{}'", + loadedFiles, + loadedEntries, + folderName + ); + } + } + + /** + * Parse a dialogue JSON file. + * + * @param json JSON object + * @param personality Personality context (null for default, used to set conditions) + * @param dialogues Map to populate + * @return Number of entries loaded + */ + private static int parseDialogueFile( + JsonObject json, + @Nullable PersonalityType personality, + Map> dialogues + ) { + if (!json.has("entries")) return 0; + + JsonArray entries = json.getAsJsonArray("entries"); + int count = 0; + + for (JsonElement element : entries) { + DialogueEntry entry = parseEntry( + element.getAsJsonObject(), + personality + ); + if (entry != null && entry.hasVariants()) { + // For personality-specific dialogues, add at front (higher priority) + List list = dialogues.computeIfAbsent( + entry.getId(), + k -> new ArrayList<>() + ); + + if (personality != null) { + // Personality-specific: add at front for priority + list.add(0, entry); + } else { + // Default: add at end + list.add(entry); + } + count++; + } + } + + return count; + } + + /** + * Parse a single dialogue entry from JSON. + * + * @param json JSON object for entry + * @param personality Personality context (for automatic condition) + * @return Parsed entry or null + */ + @Nullable + private static DialogueEntry parseEntry( + JsonObject json, + @Nullable PersonalityType personality + ) { + if (!json.has("id")) return null; + + String id = json.get("id").getAsString(); + + // Build condition + DialogueCondition.Builder conditionBuilder = + DialogueCondition.builder(); + + // If loading for a specific personality, add that as a condition + if (personality != null) { + conditionBuilder.personalities(personality); + } + + // Apply additional conditions from JSON + if (json.has("conditions")) { + applyConditions( + conditionBuilder, + json.getAsJsonObject("conditions"), + personality + ); + } + + List variants = parseVariants( + json.getAsJsonArray("variants") + ); + + if (variants.isEmpty()) { + return null; + } + + return new DialogueEntry(id, conditionBuilder.build(), variants); + } + + /** + * Apply conditions from JSON to builder. + */ + private static void applyConditions( + DialogueCondition.Builder builder, + JsonObject json, + @Nullable PersonalityType forcedPersonality + ) { + // Personality filter (only if not already forced) + if (forcedPersonality == null && json.has("personality")) { + Set personalities = EnumSet.noneOf( + PersonalityType.class + ); + JsonArray arr = json.getAsJsonArray("personality"); + for (JsonElement e : arr) { + try { + personalities.add( + PersonalityType.valueOf(e.getAsString().toUpperCase()) + ); + } catch (IllegalArgumentException ex) { + TiedUpMod.LOGGER.warn( + "[DialogueLoader] Unknown personality: {}", + e.getAsString() + ); + } + } + if (!personalities.isEmpty()) { + builder.personalities(personalities); + } + } + + // Training level range - gracefully ignored (training system removed) + // JSON files may still contain training_min/training_max but they have no effect + + // Mood range + if (json.has("mood_min")) { + builder.moodMin(json.get("mood_min").getAsInt()); + } + if (json.has("mood_max")) { + builder.moodMax(json.get("mood_max").getAsInt()); + } + + // Relationship type - gracefully ignored (relationship system removed) + // JSON files may still contain "relationship" but it has no effect + } + + /** + * Parse variants array from JSON. + */ + private static List parseVariants( + @Nullable JsonArray json + ) { + List variants = new ArrayList<>(); + if (json == null) return variants; + + for (JsonElement element : json) { + JsonObject variantJson = element.getAsJsonObject(); + + if (!variantJson.has("text")) continue; + + String text = variantJson.get("text").getAsString(); + int weight = variantJson.has("weight") + ? variantJson.get("weight").getAsInt() + : 10; + boolean isAction = + variantJson.has("is_action") && + variantJson.get("is_action").getAsBoolean(); + + variants.add(new DialogueVariant(text, weight, isAction)); + } + + return variants; + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/DialogueManager.java b/src/main/java/com/tiedup/remake/dialogue/DialogueManager.java new file mode 100644 index 0000000..f4d258d --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/DialogueManager.java @@ -0,0 +1,428 @@ +package com.tiedup.remake.dialogue; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.personality.PersonalityType; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import org.jetbrains.annotations.Nullable; +import net.minecraft.server.packs.resources.ResourceManager; + +/** + * Central manager for the data-driven dialogue system. + * Loads dialogues from JSON and provides selection based on context. + * + * New structure: dialogues are organized by personality type. + * Each personality has its own complete set of dialogues, + * with default dialogues as fallback. + * + * Usage: + * 1. Call reload() on server/resource reload to load JSON files + * 2. Call getDialogue(dialogueId, context) to get appropriate dialogue text + * + * Personality System: Data-driven dialogue + */ +public class DialogueManager { + + private static final Random RANDOM = new Random(); + + /** + * Loaded dialogues organized by personality. + * Map: PersonalityType -> DialogueId -> List of matching entries + */ + private static Map< + PersonalityType, + Map> + > dialogues = new EnumMap<>(PersonalityType.class); + + /** Current language code */ + private static String currentLang = "en_us"; + + /** Whether dialogues have been loaded */ + private static boolean initialized = false; + + /** + * Reload dialogues from resource manager. + * Should be called on server start and on /reload. + * + * @param resourceManager Minecraft's resource manager + */ + public static void reload(ResourceManager resourceManager) { + reload(resourceManager, "en_us"); + } + + /** + * Reload dialogues with specified language. + * + * @param resourceManager Minecraft's resource manager + * @param lang Language code + */ + public static void reload(ResourceManager resourceManager, String lang) { + TiedUpMod.LOGGER.debug( + "[DialogueManager] Reloading dialogues for lang: {}", + lang + ); + + currentLang = lang; + dialogues = DialogueLoader.loadDialogues(resourceManager, lang); + initialized = true; + + int totalEntries = dialogues + .values() + .stream() + .flatMap(m -> m.values().stream()) + .mapToInt(List::size) + .sum(); + + TiedUpMod.LOGGER.debug( + "[DialogueManager] Loaded {} personalities, {} total entries", + dialogues.size(), + totalEntries + ); + } + + /** + * Get a dialogue string for the given ID and context. + * Uses the personality from context to select the appropriate dialogue set. + * + * @param dialogueId The dialogue identifier (e.g., "command.follow.accept") + * @param context Current dialogue context (contains personality) + * @return Formatted dialogue text, or null if no match + */ + @Nullable + public static String getDialogue( + String dialogueId, + DialogueContext context + ) { + if (!initialized) { + TiedUpMod.LOGGER.warn( + "[DialogueManager] Attempted to get dialogue before initialization" + ); + return null; + } + + PersonalityType personality = context.getPersonality(); + + // Get dialogues for this personality + Map> personalityDialogues = dialogues.get( + personality + ); + if (personalityDialogues == null) { + TiedUpMod.LOGGER.warn( + "[DialogueManager] No dialogues loaded for personality: {}", + personality + ); + return null; + } + + List entries = personalityDialogues.get(dialogueId); + if (entries == null || entries.isEmpty()) { + TiedUpMod.LOGGER.warn( + "[DialogueManager] No entries for dialogue ID: '{}' (personality: {}). Available IDs: {}", + dialogueId, + personality, + personalityDialogues + .keySet() + .stream() + .filter(k -> k.startsWith("action.")) + .toList() + ); + return null; + } + + // Find all matching entries + List matching = new ArrayList<>(); + for (DialogueEntry entry : entries) { + if (entry.matches(context)) { + matching.add(entry); + } + } + + if (matching.isEmpty()) { + TiedUpMod.LOGGER.debug( + "[DialogueManager] No matching entries for: {} (conditions didn't match)", + dialogueId + ); + return null; + } + + // Select from matching entries (first match = highest priority) + DialogueEntry selected = matching.get(0); + + return selected.getFormattedDialogue(context); + } + + /** + * Get a dialogue with fallback to a default message. + * + * @param dialogueId The dialogue identifier + * @param context Current dialogue context + * @param fallback Fallback message if no match found + * @return Dialogue text or fallback + */ + public static String getDialogueOrDefault( + String dialogueId, + DialogueContext context, + String fallback + ) { + String result = getDialogue(dialogueId, context); + return result != null ? result : context.substituteVariables(fallback); + } + + /** + * Get a random dialogue from a category pattern. + * Useful for getting any dialogue matching "command.*.accept" pattern. + * + * @param pattern Dialogue ID pattern (use * as wildcard) + * @param context Current dialogue context + * @return Matching dialogue or null + */ + @Nullable + public static String getRandomDialogue( + String pattern, + DialogueContext context + ) { + if (!initialized) return null; + + PersonalityType personality = context.getPersonality(); + Map> personalityDialogues = dialogues.get( + personality + ); + if (personalityDialogues == null) return null; + + String regexPattern = pattern.replace(".", "\\.").replace("*", ".*"); + List allMatching = new ArrayList<>(); + + for (Map.Entry< + String, + List + > entry : personalityDialogues.entrySet()) { + if (entry.getKey().matches(regexPattern)) { + for (DialogueEntry dialogueEntry : entry.getValue()) { + if (dialogueEntry.matches(context)) { + allMatching.add(dialogueEntry); + } + } + } + } + + if (allMatching.isEmpty()) return null; + + DialogueEntry selected = allMatching.get( + RANDOM.nextInt(allMatching.size()) + ); + return selected.getFormattedDialogue(context); + } + + /** + * Check if a dialogue ID exists for the given personality. + * + * @param dialogueId The dialogue identifier + * @param personality Personality type + * @return true if at least one entry exists for this ID + */ + public static boolean hasDialogue( + String dialogueId, + PersonalityType personality + ) { + Map> personalityDialogues = dialogues.get( + personality + ); + return ( + personalityDialogues != null && + personalityDialogues.containsKey(dialogueId) && + !personalityDialogues.get(dialogueId).isEmpty() + ); + } + + /** + * Check if a dialogue ID exists (for any personality). + */ + public static boolean hasDialogue(String dialogueId) { + for (Map< + String, + List + > personalityDialogues : dialogues.values()) { + if ( + personalityDialogues.containsKey(dialogueId) && + !personalityDialogues.get(dialogueId).isEmpty() + ) { + return true; + } + } + return false; + } + + /** + * Get all dialogue IDs matching a prefix for a personality. + * + * @param prefix Prefix to match (e.g., "command.") + * @param personality Personality type + * @return List of matching dialogue IDs + */ + public static List getDialogueIds( + String prefix, + PersonalityType personality + ) { + List result = new ArrayList<>(); + Map> personalityDialogues = dialogues.get( + personality + ); + if (personalityDialogues != null) { + for (String id : personalityDialogues.keySet()) { + if (id.startsWith(prefix)) { + result.add(id); + } + } + } + return result; + } + + /** + * Get count of loaded personalities. + */ + public static int getPersonalityCount() { + return dialogues.size(); + } + + /** + * Get total count of dialogue entries across all personalities. + */ + public static int getEntryCount() { + return dialogues + .values() + .stream() + .flatMap(m -> m.values().stream()) + .mapToInt(List::size) + .sum(); + } + + /** + * Get count of unique dialogue IDs (across all personalities). + */ + public static int getDialogueCount() { + return (int) dialogues + .values() + .stream() + .flatMap(m -> m.keySet().stream()) + .distinct() + .count(); + } + + /** + * Check if dialogues have been loaded. + */ + public static boolean isInitialized() { + return initialized; + } + + /** + * Clear all loaded dialogues. + */ + public static void clear() { + dialogues.clear(); + initialized = false; + } + + /** + * Get current language code. + */ + public static String getCurrentLang() { + return currentLang; + } + + // --- Convenience methods for common dialogue categories --- + + /** + * Get command accept dialogue. + */ + @Nullable + public static String getCommandAccept( + String commandName, + DialogueContext context + ) { + return getDialogue( + "command." + commandName.toLowerCase() + ".accept", + context + ); + } + + /** + * Get command refuse dialogue. + */ + @Nullable + public static String getCommandRefuse( + String commandName, + DialogueContext context + ) { + return getDialogue( + "command." + commandName.toLowerCase() + ".refuse", + context + ); + } + + /** + * Get command hesitate dialogue. + */ + @Nullable + public static String getCommandHesitate( + String commandName, + DialogueContext context + ) { + return getDialogue( + "command." + commandName.toLowerCase() + ".hesitate", + context + ); + } + + /** + * Get capture dialogue. + */ + @Nullable + public static String getCaptureDialogue( + String phase, + DialogueContext context + ) { + return getDialogue("capture." + phase, context); + } + + /** + * Get struggle dialogue. + */ + @Nullable + public static String getStruggleDialogue( + String type, + DialogueContext context + ) { + return getDialogue("struggle." + type, context); + } + + /** + * Get mood expression dialogue. + */ + @Nullable + public static String getMoodDialogue(String mood, DialogueContext context) { + return getDialogue("mood." + mood, context); + } + + /** + * Get needs dialogue (hungry, tired, etc.). + */ + @Nullable + public static String getNeedsDialogue( + String need, + DialogueContext context + ) { + return getDialogue("needs." + need, context); + } + + /** + * Get idle/random dialogue. + */ + @Nullable + public static String getIdleDialogue(DialogueContext context) { + return getRandomDialogue("idle.*", context); + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/DialogueReloadListener.java b/src/main/java/com/tiedup/remake/dialogue/DialogueReloadListener.java new file mode 100644 index 0000000..78505e9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/DialogueReloadListener.java @@ -0,0 +1,63 @@ +package com.tiedup.remake.dialogue; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import net.minecraft.server.packs.resources.PreparableReloadListener; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.util.profiling.ProfilerFiller; + +/** + * Forge reload listener for dialogue JSON files. + * + * Integrates with Minecraft's resource reload system to: + * 1. Load dialogue definitions from assets on server start + * 2. Reload dialogues when /reload command is executed + * + * Registered via AddReloadListenerEvent in TiedUpMod. + * + * Personality System: Data-driven dialogue + */ +public class DialogueReloadListener implements PreparableReloadListener { + + @Override + public CompletableFuture reload( + PreparationBarrier barrier, + ResourceManager resourceManager, + ProfilerFiller preparationsProfiler, + ProfilerFiller reloadProfiler, + Executor backgroundExecutor, + Executor gameExecutor + ) { + // Load dialogues in background thread (non-blocking) + return CompletableFuture.runAsync( + () -> { + preparationsProfiler.startTick(); + preparationsProfiler.push("tiedup_dialogue_loading"); + + try { + TiedUpMod.LOGGER.info( + "[DialogueReloadListener] Starting dialogue reload..." + ); + + // Load dialogues from assets + DialogueManager.reload(resourceManager, "en_us"); + + TiedUpMod.LOGGER.info( + "[DialogueReloadListener] Dialogue reload complete - {} IDs loaded", + DialogueManager.getDialogueCount() + ); + } catch (Exception e) { + TiedUpMod.LOGGER.error( + "[DialogueReloadListener] Failed to reload dialogues", + e + ); + } + + preparationsProfiler.pop(); + preparationsProfiler.endTick(); + }, + backgroundExecutor + ).thenCompose(barrier::wait); + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/DialogueTriggerSystem.java b/src/main/java/com/tiedup/remake/dialogue/DialogueTriggerSystem.java new file mode 100644 index 0000000..166200c --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/DialogueTriggerSystem.java @@ -0,0 +1,154 @@ +package com.tiedup.remake.dialogue; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.NpcCommand; +import com.tiedup.remake.personality.NpcNeeds; +import com.tiedup.remake.personality.PersonalityState; +import org.jetbrains.annotations.Nullable; +import net.minecraft.world.entity.player.Player; + +/** + * System for selecting proactive dialogues based on NPC state. + * Makes NPCs feel alive by having them speak about their needs, mood, and environment. + * + * Personality System Phase 4: Living NPCs + */ +public class DialogueTriggerSystem { + + /** + * Select a proactive dialogue ID based on NPC's current state. + * Returns null if no dialogue should be triggered. + * + * @param npc The damsel entity + * @return Dialogue ID or null + */ + @Nullable + public static String selectProactiveDialogue(EntityDamsel npc) { + PersonalityState state = npc.getPersonalityState(); + if (state == null) { + return null; + } + + NpcNeeds needs = state.getNeeds(); + + // Priority 1: Critical needs (starving) + if (needs.isStarving()) { + return "needs.starving"; + } + + // Priority 2: Very low mood + if (state.getMood() < 20) { + return "mood.miserable"; + } + + // Priority 4: Non-critical needs + if (needs.isHungry()) { + return "needs.hungry"; + } + if (needs.isTired()) { + return "needs.dignity_low"; + } + + // Priority 5: Low mood + if (state.getMood() < 40) { + return "mood.sad"; + } + + // Priority 6: Job-specific idle (if doing a job) + if (state.getActiveCommand().type == NpcCommand.CommandType.JOB) { + return selectJobIdleDialogue(npc, state); + } + + // Priority 7: Generic idle + return selectIdleDialogue(npc, state); + } + + /** + * Select dialogue for approaching player. + * No fear/relationship system — returns generic approach dialogue. + * + * @param npc The damsel entity + * @param player The approaching player + * @return Dialogue ID + */ + public static String selectApproachDialogue( + EntityDamsel npc, + Player player + ) { + return "reaction.approach.stranger"; + } + + /** + * Select job-specific idle dialogue. + */ + @Nullable + private static String selectJobIdleDialogue( + EntityDamsel npc, + PersonalityState state + ) { + NpcCommand job = state.getActiveCommand(); + if (job.type != NpcCommand.CommandType.JOB) { + return null; + } + + // Check mood first + if (state.getMood() < 30) { + return "mood.working_unhappy"; + } + + // Job-specific idle + return "jobs.idle." + job.name().toLowerCase(); + } + + /** + * Select generic idle dialogue. + */ + @Nullable + private static String selectIdleDialogue( + EntityDamsel npc, + PersonalityState state + ) { + // High mood = positive idle + if (state.getMood() > 70) { + return "idle.content"; + } + + // Normal idle + return "idle.neutral"; + } + + /** + * Select environmental dialogue based on weather/time. + * + * @param npc The damsel entity + * @return Dialogue ID or null + */ + @Nullable + public static String selectEnvironmentDialogue(EntityDamsel npc) { + // Check if outdoors (can see sky) + if (!npc.level().canSeeSky(npc.blockPosition())) { + return null; + } + + // Thunder takes priority + if (npc.level().isThundering()) { + return "environment.thunder"; + } + + // Rain + if ( + npc.level().isRaining() && + npc.level().isRainingAt(npc.blockPosition()) + ) { + return "environment.rain"; + } + + // Night (only if dark enough) + long dayTime = npc.level().getDayTime() % 24000; + if (dayTime >= 13000 && dayTime <= 23000) { + return "environment.night"; + } + + return null; + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/DialogueVariant.java b/src/main/java/com/tiedup/remake/dialogue/DialogueVariant.java new file mode 100644 index 0000000..15dd064 --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/DialogueVariant.java @@ -0,0 +1,42 @@ +package com.tiedup.remake.dialogue; + +/** + * A single dialogue variant with text, weight, and type. + * + * Personality System: Data-driven dialogue + */ +public class DialogueVariant { + + private final String text; + private final int weight; + private final boolean isAction; + + public DialogueVariant(String text, int weight, boolean isAction) { + this.text = text; + this.weight = weight; + this.isAction = isAction; + } + + public DialogueVariant(String text, int weight) { + this(text, weight, false); + } + + public DialogueVariant(String text) { + this(text, 10, false); + } + + public String getText() { + return text; + } + + public int getWeight() { + return weight; + } + + /** + * Whether this is an action (displayed as *action*) or speech. + */ + public boolean isAction() { + return isAction; + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/EmotionalContext.java b/src/main/java/com/tiedup/remake/dialogue/EmotionalContext.java new file mode 100644 index 0000000..2ff4bc3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/EmotionalContext.java @@ -0,0 +1,270 @@ +package com.tiedup.remake.dialogue; + +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Detects and applies emotional context to gagged speech. + */ +public class EmotionalContext { + + /** + * Types of emotional expression that affect speech transformation. + */ + public enum EmotionType { + NORMAL, + SHOUTING, + WHISPERING, + QUESTIONING, + PLEADING, + DISTRESSED, + LAUGHING, + CRYING, + } + + // Pattern for extended vowels (nooooo, heeelp, pleeease) + private static final Pattern EXTENDED_VOWELS = Pattern.compile( + "([aeiou])\\1{2,}", + Pattern.CASE_INSENSITIVE + ); + + // Pattern for laughing (haha, hehe, lol) + private static final Pattern LAUGHING_PATTERN = Pattern.compile( + "(ha){2,}|(he){2,}|(hi){2,}|lol|lmao|xd", + Pattern.CASE_INSENSITIVE + ); + + // Pattern for crying indicators + private static final Pattern CRYING_PATTERN = Pattern.compile( + "\\*(?:sob|cry|sniff|whimper)s?\\*|;-?;|T[-_]T|:'+\\(", + Pattern.CASE_INSENSITIVE + ); + + // Pleading keywords + private static final Set PLEADING_WORDS = Set.of( + "please", + "help", + "stop", + "no", + "don't", + "dont", + "wait", + "s'il vous plait", + "svp", + "aide", + "aidez", + "arrete", + "arreter", + "non", + "mercy", + "spare", + "let me go", + "release" + ); + + // Whispering indicators + private static final Set WHISPERING_INDICATORS = Set.of( + "*whisper*", + "*whispers*", + "*quietly*", + "*softly*", + "(whisper)", + "(quietly)" + ); + + /** + * Detect the emotional context of a message or word. + * + * @param text The text to analyze + * @return The detected emotion type + */ + public static EmotionType detectEmotion(String text) { + if (text == null || text.isEmpty()) { + return EmotionType.NORMAL; + } + + String lower = text.toLowerCase(); + + // Check for explicit emotional markers first + if (CRYING_PATTERN.matcher(text).find()) { + return EmotionType.CRYING; + } + + if (LAUGHING_PATTERN.matcher(text).find()) { + return EmotionType.LAUGHING; + } + + // Check for whispering indicators + for (String indicator : WHISPERING_INDICATORS) { + if (lower.contains(indicator)) { + return EmotionType.WHISPERING; + } + } + + // Check for shouting (ALL CAPS with 3+ letters, or multiple !) + String letters = text.replaceAll("[^a-zA-Z]", ""); + if (letters.length() >= 3 && letters.equals(letters.toUpperCase())) { + return EmotionType.SHOUTING; + } + // Require at least 2 exclamation marks for shouting + if (text.contains("!!") || text.contains("!?") || text.contains("?!")) { + return EmotionType.SHOUTING; + } + + // Check for questioning + if (text.endsWith("?") || text.endsWith("??")) { + return EmotionType.QUESTIONING; + } + + // Check for distress (extended vowels: nooooo, heeelp) + if (EXTENDED_VOWELS.matcher(text).find()) { + return EmotionType.DISTRESSED; + } + + // Check for pleading keywords + for (String word : PLEADING_WORDS) { + if (lower.contains(word)) { + return EmotionType.PLEADING; + } + } + + return EmotionType.NORMAL; + } + + /** + * Apply emotional modifiers to a muffled message. + * + * @param muffled The muffled text + * @param emotion The detected emotion type + * @return Modified text with emotional expression + */ + public static String applyEmotionalModifiers( + String muffled, + EmotionType emotion + ) { + if (muffled == null || muffled.isEmpty()) { + return muffled; + } + + switch (emotion) { + case SHOUTING: + // Convert to uppercase and emphasize + return ( + muffled.toUpperCase() + (muffled.endsWith("!") ? "!" : "!!") + ); + case WHISPERING: + // Softer, shorter sounds + return "*" + muffled.toLowerCase() + "*"; + case QUESTIONING: + // Rising intonation + String question = muffled.endsWith("?") + ? muffled + : muffled + "?"; + return question.replace("mm", "mn").replace("nn", "nh"); + case PLEADING: + // Add whimpering + return muffled + "-mm.."; + case DISTRESSED: + // Extend the dominant sound + return extendDominantSound(muffled); + case LAUGHING: + // Muffled laughter + return muffled.replace("mm", "hm").replace("nn", "hn") + "-hm"; + case CRYING: + // Add sobbing sounds + return "*hic* " + muffled + " *mm*"; + case NORMAL: + default: + return muffled; + } + } + + /** + * Extend the dominant/repeated sound in a muffled word for distress effect. + */ + private static String extendDominantSound(String text) { + if (text == null || text.length() < 2) { + return text; + } + + StringBuilder result = new StringBuilder(); + char prev = 0; + int repeatCount = 0; + + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + + if (c == prev && Character.isLetter(c)) { + repeatCount++; + // Extend repeated sounds + if (repeatCount <= 3) { + result.append(c); + } + } else { + result.append(c); + repeatCount = 1; + } + prev = c; + } + + // If no extension happened, extend the last vowel-like sound + String str = result.toString(); + if (str.equals(text)) { + // Find last 'm' or 'n' or vowel and extend it + int lastExtendable = -1; + for (int i = str.length() - 1; i >= 0; i--) { + char c = Character.toLowerCase(str.charAt(i)); + if (c == 'm' || c == 'n' || c == 'a' || c == 'o' || c == 'u') { + lastExtendable = i; + break; + } + } + if (lastExtendable >= 0) { + char ext = str.charAt(lastExtendable); + return ( + str.substring(0, lastExtendable + 1) + + ext + + ext + + str.substring(lastExtendable + 1) + ); + } + } + + return result.toString(); + } + + /** + * Get the intensity multiplier for an emotion. + * Higher intensity = more muffling effect. + * + * @param emotion The emotion type + * @return Intensity multiplier (1.0 = normal) + */ + public static float getIntensityMultiplier(EmotionType emotion) { + switch (emotion) { + case SHOUTING: + return 1.3f; // Harder to understand when shouting + case WHISPERING: + return 0.7f; // Easier (softer, clearer) + case DISTRESSED: + return 1.2f; + case CRYING: + return 1.4f; + case LAUGHING: + return 1.1f; + case PLEADING: + case QUESTIONING: + case NORMAL: + default: + return 1.0f; + } + } + + /** + * Check if the emotion should preserve more of the original text. + * Some emotions (like whispering) are clearer. + */ + public static boolean shouldPreserveMore(EmotionType emotion) { + return emotion == EmotionType.WHISPERING; + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/EntityDialogueManager.java b/src/main/java/com/tiedup/remake/dialogue/EntityDialogueManager.java new file mode 100644 index 0000000..acc5e40 --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/EntityDialogueManager.java @@ -0,0 +1,635 @@ +package com.tiedup.remake.dialogue; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.util.MessageDispatcher; +import java.util.List; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; + +/** + * Complete dialogue system for EntityDamsel and EntityKidnapper. + * + * Phase 14.3: Centralized NPC dialogue management + * + * Features: + * - Multiple dialogue variants per action category + * - Integration with GagTalkManager for gagged NPCs + * - Formatted messages with entity name + * - Action messages vs speech messages + * - Radius-based broadcasting + */ +public class EntityDialogueManager { + + // ======================================== + // DIALOGUE CATEGORIES + // ======================================== + + /** + * Dialogue categories for different NPC actions. + */ + public enum DialogueCategory { + // === KIDNAPPER CAPTURE SEQUENCE === + CAPTURE_START, // When kidnapper starts chasing + CAPTURE_APPROACHING, // While approaching target + CAPTURE_CHASE, // When pursuing an escaped captive + CAPTURE_TYING, // While tying up target + CAPTURE_TIED, // After target is tied + CAPTURE_GAGGING, // While gagging target + CAPTURE_GAGGED, // After target is gagged + CAPTURE_ENSLAVED, // After enslaving target + CAPTURE_ESCAPE, // When captive escapes + + // === SLAVE MANAGEMENT === + SLAVE_TALK_RESPONSE, // When slave tries to talk + SLAVE_STRUGGLE, // When slave struggles + SLAVE_TRANSPORT, // When transporting slave + SLAVE_ARRIVE_PRISON, // When arriving at prison + SLAVE_TIED_TO_POLE, // When tying slave to pole + PUNISH, // When punishing a recaptured or misbehaving captive + + // === SALE SYSTEM === + SALE_WAITING, // Waiting for buyer + SALE_ANNOUNCE, // Announcing sale + SALE_OFFER, // Offering to sell to a passing player (needs %s for player name and %s for price) + SALE_COMPLETE, // Sale completed + SALE_ABANDONED, // When kidnapper abandons captive (solo mode) + SALE_KEPT, // When kidnapper decides to keep captive (solo mode) + + // === JOB SYSTEM === + JOB_ASSIGNED, // Giving job to slave + JOB_HURRY, // Urging slave to hurry + JOB_COMPLETE, // Job completed successfully + JOB_FAILED, // Job failed + JOB_LAST_CHANCE, // Warning before killing + JOB_KILL, // Killing the slave for failure + + // === COMBAT/ATTACK === + ATTACKED_RESPONSE, // When attacked by someone + ATTACK_SLAVE, // When slave attacks + + // === DAMSEL SPECIFIC === + DAMSEL_PANIC, // When damsel is scared + DAMSEL_FLEE, // When damsel flees + DAMSEL_CAPTURED, // When damsel is captured + DAMSEL_FREED, // When damsel is freed + DAMSEL_GREETING, // Greeting nearby players + DAMSEL_IDLE, // Random idle chatter + DAMSEL_CALL_FOR_HELP, // When tied damsel sees a player nearby (needs %s for player name) + + // === GENERAL === + FREED, // When freeing someone + GOODBYE, // Saying goodbye + GET_OUT, // Telling someone to leave + GENERIC_THREAT, // Generic threatening line + GENERIC_TAUNT, // Generic taunting line + + // === PERSONALITY SYSTEM (Phase E) === + COMMAND_ACCEPT, // When NPC accepts a command + COMMAND_REFUSE, // When NPC refuses a command + COMMAND_HESITATE, // When NPC hesitates before command + + // === DISCIPLINE SYSTEM (Training V2) === + PRAISE_RESPONSE, // When NPC is praised + SCOLD_RESPONSE, // When NPC is scolded + THREATEN_RESPONSE, // When NPC is threatened + NEED_HUNGRY, // When NPC is hungry + NEED_TIRED, // When NPC is tired + NEED_UNCOMFORTABLE, // When NPC is uncomfortable + NEED_DIGNITY_LOW, // When NPC has low dignity + PERSONALITY_HINT_TIMID, // Hint for TIMID personality + PERSONALITY_HINT_GENTLE, // Hint for GENTLE personality + PERSONALITY_HINT_SUBMISSIVE, // Hint for SUBMISSIVE personality + PERSONALITY_HINT_CALM, // Hint for CALM personality + PERSONALITY_HINT_CURIOUS, // Hint for CURIOUS personality + PERSONALITY_HINT_PROUD, // Hint for PROUD personality + PERSONALITY_HINT_FIERCE, // Hint for FIERCE personality + PERSONALITY_HINT_DEFIANT, // Hint for DEFIANT personality + PERSONALITY_HINT_PLAYFUL, // Hint for PLAYFUL personality + PERSONALITY_HINT_MASOCHIST, // Hint for MASOCHIST personality + PERSONALITY_HINT_SADIST, // Hint for SADIST personality (kidnappers) + } + + // ======================================== + // DIALOGUE RETRIEVAL (Data-Driven Only) + // ======================================== + + /** + * Get random dialogue for a category using the data-driven system. + * All dialogues are now loaded from JSON files. + * + * @param category The dialogue category + * @return Dialogue text, or a fallback message if not found + */ + public static String getDialogue(DialogueCategory category) { + // Use data-driven system with default context + if (com.tiedup.remake.dialogue.DialogueManager.isInitialized()) { + String dialogueId = + com.tiedup.remake.dialogue.DialogueBridge.categoryToDialogueId( + category + ); + com.tiedup.remake.dialogue.DialogueContext defaultContext = + com.tiedup.remake.dialogue.DialogueContext.builder() + .personality( + com.tiedup.remake.personality.PersonalityType.CALM + ) + .mood(50) + .build(); + + String text = + com.tiedup.remake.dialogue.DialogueManager.getDialogue( + dialogueId, + defaultContext + ); + if (text != null) { + return text; + } + } + + // Fallback for uninitialized system + com.tiedup.remake.core.TiedUpMod.LOGGER.warn( + "[EntityDialogueManager] Data-driven dialogue not found for category: {}", + category.name() + ); + return "[" + category.name() + "]"; + } + + /** + * Get dialogue for a category using the data-driven system. + * Uses entity context for personality-aware dialogue selection. + * + * @param entity The entity speaking + * @param player The player (can be null) + * @param category The dialogue category + * @return Dialogue text + */ + public static String getDialogue( + EntityDamsel entity, + Player player, + DialogueCategory category + ) { + // Always use data-driven system + String dataDriven = + com.tiedup.remake.dialogue.DialogueBridge.getDataDrivenDialogue( + entity, + player, + category + ); + if (dataDriven != null) { + return dataDriven; + } + + // Fallback to category-only lookup (no entity context) + return getDialogue(category); + } + + // ======================================== + // MESSAGE SENDING METHODS + // ======================================== + + /** + * Send a dialogue message to a specific player. + * Format: *EntityName* : message + * Uses data-driven dialogue system if available. + * + * @param entity The entity speaking + * @param player The player receiving the message + * @param category The dialogue category + */ + public static void talkTo( + EntityDamsel entity, + Player player, + DialogueCategory category + ) { + // Use data-driven dialogue when available + talkTo(entity, player, getDialogue(entity, player, category)); + } + + /** + * Send a custom message to a specific player. + * Delegates formatting, gag talk, and earplug handling to MessageDispatcher. + * + * @param entity The entity speaking + * @param player The player receiving the message + * @param message The message to send + */ + public static void talkTo( + EntityDamsel entity, + Player player, + String message + ) { + if (entity == null || player == null || message == null) return; + if (entity.level().isClientSide()) return; + if (!(player instanceof ServerPlayer)) return; + MessageDispatcher.talkTo(entity, player, message); + } + + /** + * Send a dialogue message using a dialogue ID. + * Resolves the ID to actual text via DialogueManager. + * + * @param entity The entity speaking + * @param player The player receiving the message + * @param dialogueId The dialogue ID (e.g., "action.whip") + */ + public static void talkByDialogueId( + EntityDamsel entity, + Player player, + String dialogueId + ) { + if (entity == null || player == null || dialogueId == null) return; + if (entity.level().isClientSide()) return; + + if (!com.tiedup.remake.dialogue.DialogueManager.isInitialized()) { + talkTo(entity, player, "[Dialogue system not ready]"); + return; + } + + // Build context and resolve dialogue text + com.tiedup.remake.dialogue.DialogueContext context = + com.tiedup.remake.dialogue.DialogueBridge.buildContext( + entity, + player + ); + + String text = com.tiedup.remake.dialogue.DialogueManager.getDialogue( + dialogueId, + context + ); + + if (text == null) { + text = "[Missing: " + dialogueId + "]"; + com.tiedup.remake.core.TiedUpMod.LOGGER.warn( + "[EntityDialogueManager] Missing dialogue for ID: {} with personality: {}", + dialogueId, + context.getPersonality() + ); + } + + talkTo(entity, player, text); + } + + /** + * Send an action message to a specific player. + * Format: EntityName action + * Uses data-driven dialogue system if available. + * + * @param entity The entity performing the action + * @param player The player receiving the message + * @param category The dialogue category + */ + public static void actionTo( + EntityDamsel entity, + Player player, + DialogueCategory category + ) { + // Use data-driven dialogue when available + actionTo(entity, player, getDialogue(entity, player, category)); + } + + /** + * Send a custom action message to a specific player. + * Delegates formatting and earplug handling to MessageDispatcher. + * + * @param entity The entity performing the action + * @param player The player receiving the message + * @param action The action description + */ + public static void actionTo( + EntityDamsel entity, + Player player, + String action + ) { + if (entity == null || player == null || action == null) return; + if (entity.level().isClientSide()) return; + if (!(player instanceof ServerPlayer)) return; + MessageDispatcher.actionTo(entity, player, action); + } + + /** + * Talk to all players within a radius. + * Builds proper context per player for variable substitution. + * + * @param entity The entity speaking + * @param category The dialogue category + * @param radius The radius in blocks + */ + public static void talkToNearby( + EntityDamsel entity, + DialogueCategory category, + int radius + ) { + if (entity == null) return; + + List players = entity + .level() + .getEntitiesOfClass( + Player.class, + entity.getBoundingBox().inflate(radius) + ); + + for (Player player : players) { + // Use the context-aware dialogue lookup for proper {player} substitution + String dialogue = getDialogue(entity, player, category); + talkTo(entity, player, dialogue); + } + } + + /** + * Talk to all players within a radius with a custom message. + * + * @param entity The entity speaking + * @param message The message to send + * @param radius The radius in blocks + */ + public static void talkToNearby( + EntityDamsel entity, + String message, + int radius + ) { + if (entity == null || message == null) return; + + List players = entity + .level() + .getEntitiesOfClass( + Player.class, + entity.getBoundingBox().inflate(radius) + ); + + for (Player player : players) { + talkTo(entity, player, message); + } + } + + /** + * Send an action message to all players within a radius. + * Builds proper context per player for variable substitution. + * + * @param entity The entity performing the action + * @param category The dialogue category + * @param radius The radius in blocks + */ + public static void actionToNearby( + EntityDamsel entity, + DialogueCategory category, + int radius + ) { + if (entity == null) return; + + List players = entity + .level() + .getEntitiesOfClass( + Player.class, + entity.getBoundingBox().inflate(radius) + ); + + for (Player player : players) { + // Use the context-aware dialogue lookup for proper {player} substitution + String action = getDialogue(entity, player, category); + actionTo(entity, player, action); + } + } + + /** + * Send a custom action message to all players within a radius. + * + * @param entity The entity performing the action + * @param action The action description + * @param radius The radius in blocks + */ + public static void actionToNearby( + EntityDamsel entity, + String action, + int radius + ) { + if (entity == null || action == null) return; + + List players = entity + .level() + .getEntitiesOfClass( + Player.class, + entity.getBoundingBox().inflate(radius) + ); + + for (Player player : players) { + actionTo(entity, player, action); + } + } + + // ======================================== + // CONVENIENCE METHODS + // ======================================== + + /** + * Make entity say something to their target (if player). + */ + public static void talkToTarget( + EntityDamsel entity, + LivingEntity target, + DialogueCategory category + ) { + if (target instanceof Player player) { + talkTo(entity, player, category); + } + } + + /** + * Make entity say a custom message to their target (if player). + */ + public static void talkToTarget( + EntityDamsel entity, + LivingEntity target, + String message + ) { + if (target instanceof Player player) { + talkTo(entity, player, message); + } + } + + /** + * Make entity perform action to their target (if player). + */ + public static void actionToTarget( + EntityDamsel entity, + LivingEntity target, + DialogueCategory category + ) { + if (target instanceof Player player) { + actionTo(entity, player, category); + } + } + + /** + * Make entity perform custom action to their target (if player). + */ + public static void actionToTarget( + EntityDamsel entity, + LivingEntity target, + String action + ) { + if (target instanceof Player player) { + actionTo(entity, player, action); + } + } + + // ======================================== + // COMPOUND MESSAGES + // ======================================== + + /** + * Get a dialogue for job assignment with item name. + */ + public static String getJobAssignmentDialogue(String itemName) { + return getDialogue(DialogueCategory.JOB_ASSIGNED) + itemName; + } + + /** + * Get a call for help dialogue with player name. + * + * @deprecated Use {@link #callForHelp(EntityDamsel, Player)} which builds proper context. + */ + @Deprecated + public static String getCallForHelpDialogue(String playerName) { + // Legacy fallback - manually substitute {player} + String text = getDialogue(DialogueCategory.DAMSEL_CALL_FOR_HELP); + return text.replace("{player}", playerName); + } + + /** + * Get a sale offer dialogue with player name and price. + * The {player} placeholder is the buyer, {target} is the price. + * + * @param playerName The buyer's name + * @param price The price as a display string (e.g., "50 gold") + * @return Formatted dialogue text + */ + public static String getSaleOfferDialogue(String playerName, String price) { + // Substitute placeholders manually since we don't have full context + String text = getDialogue(DialogueCategory.SALE_OFFER); + text = text.replace("{player}", playerName); + text = text.replace("{target}", price); // {target} is used for price in sale dialogues + return text; + } + + /** + * Make a tied damsel call for help to a nearby player. + * Pre-conditions (tied, slave, not gagged) should be checked by caller. + * + * @param damsel The damsel calling for help + * @param targetPlayer The player to call for help + */ + public static void callForHelp(EntityDamsel damsel, Player targetPlayer) { + if (damsel == null || targetPlayer == null) return; + + // Use context-aware dialogue for proper {player} substitution + String message = getDialogue( + damsel, + targetPlayer, + DialogueCategory.DAMSEL_CALL_FOR_HELP + ); + talkTo(damsel, targetPlayer, message); + } + + /** + * Get a sale announcement with coordinates. + */ + public static String getSaleAnnouncement( + EntityDamsel entity, + String slaveName + ) { + return String.format( + "%s is selling %s at coordinates: %d, %d, %d", + entity.getNpcName(), + slaveName, + (int) entity.getX(), + (int) entity.getY(), + (int) entity.getZ() + ); + } + + // ======================================== + // PERSONALITY SYSTEM HELPERS + // ======================================== + + /** + * Get personality hint dialogue based on personality type name. + * + * @param personalityTypeName The name of the personality type (from PersonalityType enum) + * @return The hint dialogue category, or null if unknown + */ + public static DialogueCategory getPersonalityHintCategory( + String personalityTypeName + ) { + return switch (personalityTypeName.toUpperCase()) { + case "TIMID" -> DialogueCategory.PERSONALITY_HINT_TIMID; + case "GENTLE" -> DialogueCategory.PERSONALITY_HINT_GENTLE; + case "SUBMISSIVE" -> DialogueCategory.PERSONALITY_HINT_SUBMISSIVE; + case "CALM" -> DialogueCategory.PERSONALITY_HINT_CALM; + case "CURIOUS" -> DialogueCategory.PERSONALITY_HINT_CURIOUS; + case "PROUD" -> DialogueCategory.PERSONALITY_HINT_PROUD; + case "FIERCE" -> DialogueCategory.PERSONALITY_HINT_FIERCE; + case "DEFIANT" -> DialogueCategory.PERSONALITY_HINT_DEFIANT; + case "PLAYFUL" -> DialogueCategory.PERSONALITY_HINT_PLAYFUL; + case "MASOCHIST" -> DialogueCategory.PERSONALITY_HINT_MASOCHIST; + case "SADIST" -> DialogueCategory.PERSONALITY_HINT_SADIST; + default -> null; + }; + } + + /** + * Show a personality hint action to a player. + * Used when discovery level is GLIMPSE to give behavioral cues. + * + * @param entity The entity with the personality + * @param player The player to show the hint to + * @param personalityTypeName The personality type name + */ + public static void showPersonalityHint( + EntityDamsel entity, + Player player, + String personalityTypeName + ) { + DialogueCategory hintCategory = getPersonalityHintCategory( + personalityTypeName + ); + if (hintCategory != null) { + actionTo(entity, player, hintCategory); + } + } + + /** + * Get dialogue for command response based on acceptance. + * + * @param accepted true if command was accepted, false if refused + * @param hesitated true if the NPC hesitated before responding + * @return The appropriate dialogue + */ + public static String getCommandResponseDialogue( + boolean accepted, + boolean hesitated + ) { + if (!accepted) { + return getDialogue(DialogueCategory.COMMAND_REFUSE); + } + if (hesitated) { + return getDialogue(DialogueCategory.COMMAND_HESITATE); + } + return getDialogue(DialogueCategory.COMMAND_ACCEPT); + } + + /** + * Get dialogue for a specific need when it's critically low. + * + * @param needType The type of need ("hunger", "comfort", "rest", "dignity") + * @return The appropriate dialogue category, or null if unknown + */ + public static DialogueCategory getNeedDialogueCategory(String needType) { + return switch (needType.toLowerCase()) { + case "hunger" -> DialogueCategory.NEED_HUNGRY; + case "rest" -> DialogueCategory.NEED_TIRED; + case "comfort" -> DialogueCategory.NEED_UNCOMFORTABLE; + case "dignity" -> DialogueCategory.NEED_DIGNITY_LOW; + default -> null; + }; + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/GagTalkManager.java b/src/main/java/com/tiedup/remake/dialogue/GagTalkManager.java new file mode 100644 index 0000000..0d732ce --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/GagTalkManager.java @@ -0,0 +1,562 @@ +package com.tiedup.remake.dialogue; + +import static com.tiedup.remake.util.GameConstants.*; + +import com.tiedup.remake.dialogue.EmotionalContext.EmotionType; +import com.tiedup.remake.items.base.ItemGag; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.GagMaterial; +import com.tiedup.remake.util.PhoneticMapper; +import com.tiedup.remake.util.SyllableAnalyzer; +import java.util.List; +import java.util.Random; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; + +/** + * Phase 16: GagTalk System V4 - Realistic phonetic transformation + * + *

Features: + *

    + *
  • Phonetic-based transformation preserving word structure
  • + *
  • Syllable-aware processing for natural rhythm
  • + *
  • Emotional context detection and expression
  • + *
  • Material-specific bleed-through rates
  • + *
  • Progressive comprehension (partial understanding)
  • + *
+ */ +public class GagTalkManager { + + private static final Random RANDOM = new Random(); + + private static final String[] CRIT_FAIL_SOUNDS = { + "Mmph!!", + "Mmmph...", + "Hmpf!", + "Mmm...", + "Mph?!", + "Nnnnh!", + "Hffff!", + "P-pph!", + }; + + /** + * Process a gagged message for any IBondageState entity. + * + * @param kidnapped The kidnapped entity (Player, EntityDamsel, etc.) + * @param gagStack The gag item stack + * @param originalMessage The original message before gagging + * @return The muffled message component + */ + public static Component processGagMessage( + IBondageState kidnapped, + ItemStack gagStack, + String originalMessage + ) { + LivingEntity entity = kidnapped.asLivingEntity(); + GagMaterial material = GagMaterial.CLOTH; + if (gagStack.getItem() instanceof ItemGag gag) { + material = gag.getGagMaterial(); + } + + // 1. EFFET DE SUFFOCATION (Si message trop long) + applySuffocationEffects(entity, originalMessage.length(), material); + + // 2. CHANCE D'ECHEC CRITIQUE + Component critFailResult = checkCriticalFailure( + kidnapped, + gagStack, + originalMessage.length(), + material + ); + if (critFailResult != null) { + return critFailResult; + } + + // 3. DETECT OVERALL MESSAGE EMOTION + EmotionType messageEmotion = EmotionalContext.detectEmotion( + originalMessage + ); + + // 4. CONSTRUCTION DU MESSAGE V4 + StringBuilder muffled = new StringBuilder(); + String[] words = originalMessage.split("\\s+"); + + for (int i = 0; i < words.length; i++) { + String word = words[i]; + + // Progressive comprehension: longer messages get harder to understand + float positionPenalty = (i > 5) ? 0.05f * (i - 5) : 0.0f; + float baseComp = material.getComprehension(); + float effectiveComprehension = Math.max( + 0, + baseComp - positionPenalty + ); + + // Apply emotional intensity modifier + effectiveComprehension /= EmotionalContext.getIntensityMultiplier( + messageEmotion + ); + + // Whispering is clearer + if (EmotionalContext.shouldPreserveMore(messageEmotion)) { + effectiveComprehension *= 1.5f; + } + + // Material-specific interjections (rare, natural placement) + if (RANDOM.nextFloat() < 0.03f && i > 0 && i < words.length - 1) { + muffled.append(getMaterialInterjection(material)).append(" "); + } + + // Movement affects clarity + if (entity.isSprinting() || !entity.onGround()) { + effectiveComprehension *= 0.7f; + } + + // Three-tier comprehension: + // - Full pass: word understood completely + // - Partial: first letter(s) + muffled rest + // - None: fully muffled + float roll = RANDOM.nextFloat(); + if (roll < effectiveComprehension * 0.4f) { + // Full word passes through + muffled.append(word); + } else if (roll < effectiveComprehension) { + // Partial: first letter(s) visible + muffled rest + muffled.append(generatePartiallyMuffledWord(word, material)); + } else { + // Fully muffled + muffled.append(generateMuffledWord(word, material)); + } + + // Word separator + if (i < words.length - 1) { + if (RANDOM.nextFloat() < 0.1f) { + muffled.append("-"); + } else { + muffled.append(" "); + } + } + } + + // 5. PENSEE INTERNE (Visible seulement pour l'entite) + kidnapped.sendMessage( + Component.literal("(") + .append( + Component.literal(originalMessage).withStyle( + ChatFormatting.ITALIC + ) + ) + .append(")") + .withStyle(ChatFormatting.GRAY), + false + ); + + return Component.literal(muffled.toString()); + } + + /** + * Generate a partially muffled word - first part recognizable, rest muffled. + */ + private static String generatePartiallyMuffledWord( + String original, + GagMaterial material + ) { + if (original.isEmpty()) return ""; + if (original.length() <= 2) return original; + + // Find a good split point (after first consonant cluster + vowel) + int splitPoint = findNaturalSplitPoint(original); + + String visible = original.substring(0, splitPoint); + String toMuffle = original.substring(splitPoint); + + if (toMuffle.isEmpty()) { + return visible; + } + + return visible + generateMuffledWord(toMuffle, material); + } + + /** + * Find a natural split point in a word for partial muffling. + */ + private static int findNaturalSplitPoint(String word) { + if (word.length() <= 2) return word.length(); + + // Try to split after first syllable-ish structure + boolean foundVowel = false; + for (int i = 0; i < word.length() && i < 4; i++) { + char c = Character.toLowerCase(word.charAt(i)); + boolean isVowel = "aeiouy".indexOf(c) >= 0; + + if (isVowel) { + foundVowel = true; + } else if (foundVowel) { + // Found consonant after vowel - good split point + return i; + } + } + + // Default: first 1-2 characters + return Math.min(2, word.length()); + } + + /** + * Generate a fully muffled word using phonetic transformation. + */ + private static String generateMuffledWord( + String original, + GagMaterial material + ) { + if (original == null || original.isEmpty()) return ""; + + // Extract and preserve punctuation + String punctuation = extractTrailingPunctuation(original); + String cleanWord = original.replaceAll("[^a-zA-Z]", ""); + + if (cleanWord.isEmpty()) { + return original; // Pure punctuation, keep as-is + } + + // Preserve original case pattern + boolean wasAllCaps = + cleanWord.length() >= 2 && + cleanWord.equals(cleanWord.toUpperCase()); + + // Split into syllables for rhythm preservation + List syllables = SyllableAnalyzer.splitIntoSyllables(cleanWord); + + StringBuilder result = new StringBuilder(); + float baseBleed = material.getComprehension(); + + for ( + int syllableIdx = 0; + syllableIdx < syllables.size(); + syllableIdx++ + ) { + String syllable = syllables.get(syllableIdx); + + // First syllable gets bonus bleed (more recognizable) + float syllableBleed = (syllableIdx == 0) + ? baseBleed * 1.3f + : baseBleed; + + // Stressed syllables are clearer + if ( + SyllableAnalyzer.isStressedSyllable( + syllable, + syllableIdx, + syllables.size() + ) + ) { + syllableBleed *= 1.2f; + } + + StringBuilder muffledSyllable = new StringBuilder(); + char prevOutput = 0; + + for (int i = 0; i < syllable.length(); i++) { + char c = syllable.charAt(i); + + // Calculate bleed for this specific phoneme + float phonemeBleed = + syllableBleed * material.getBleedRateFor(c); + + // Apply phonetic mapping + String mapped = PhoneticMapper.mapPhoneme( + c, + material, + phonemeBleed + ); + + // Avoid excessive repetition (mmmmm -> mm) + if (shouldSkipRepetition(mapped, prevOutput, muffledSyllable)) { + continue; + } + + muffledSyllable.append(mapped); + + if (!mapped.isEmpty()) { + prevOutput = mapped.charAt(mapped.length() - 1); + } + } + + result.append(muffledSyllable); + + // Occasional syllable separator for multi-syllable words + if ( + syllableIdx < syllables.size() - 1 && RANDOM.nextFloat() < 0.2f + ) { + result.append("-"); + } + } + + String muffled = result.toString(); + + // Collapse excessive repetitions (max 2 of same letter) + muffled = collapseRepetitions(muffled, 2); + + // Ensure we have something + if (muffled.isEmpty()) { + muffled = + material.getDominantConsonant() + material.getDominantVowel(); + } + + // Preserve ALL CAPS if original was all caps + if (wasAllCaps) { + muffled = muffled.toUpperCase(); + } + + return muffled + punctuation; + } + + /** + * Check if we should skip adding a mapped sound to avoid excessive repetition. + */ + private static boolean shouldSkipRepetition( + String mapped, + char prevOutput, + StringBuilder current + ) { + if (mapped.isEmpty()) return true; + + char firstMapped = mapped.charAt(0); + + // Skip if same character repeated more than twice + if (firstMapped == prevOutput) { + int repeatCount = 0; + for ( + int i = current.length() - 1; + i >= 0 && i >= current.length() - 3; + i-- + ) { + if (current.charAt(i) == firstMapped) { + repeatCount++; + } else { + break; + } + } + if (repeatCount >= 2) { + return true; + } + } + + return false; + } + + /** + * Collapse runs of repeated characters to a maximum count. + * "uuuuu" with maxRepeat=2 becomes "uu" + */ + private static String collapseRepetitions(String text, int maxRepeat) { + if (text == null || text.length() <= maxRepeat) { + return text; + } + + StringBuilder result = new StringBuilder(); + char prev = 0; + int count = 0; + + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + char lower = Character.toLowerCase(c); + char prevLower = Character.toLowerCase(prev); + + if (lower == prevLower && Character.isLetter(c)) { + count++; + if (count <= maxRepeat) { + result.append(c); + } + } else { + result.append(c); + count = 1; + } + prev = c; + } + + return result.toString(); + } + + /** + * Extract trailing punctuation from a word. + */ + private static String extractTrailingPunctuation(String word) { + StringBuilder punct = new StringBuilder(); + for (int i = word.length() - 1; i >= 0; i--) { + char c = word.charAt(i); + if (!Character.isLetter(c)) { + punct.insert(0, c); + } else { + break; + } + } + return punct.toString(); + } + + /** + * Get a material-specific interjection sound. + */ + private static String getMaterialInterjection(GagMaterial material) { + return switch (material) { + case BALL -> "*hhuup*"; + case TAPE -> "*mmn*"; + case STUFFED, SPONGE -> "*mm*"; + case RING -> "*aah*"; + case BITE -> "*ngh*"; + case LATEX -> "*uuh*"; + case BAGUETTE -> "*nom*"; + case PANEL -> "*mmph*"; + default -> "*mph*"; + }; + } + + // ======================================== + // SUFFOCATION & CRITICAL FAILURE HELPERS + // ======================================== + + /** + * Apply suffocation effects if message is too long. + * Long messages through restrictive gags cause slowness and potential blindness. + * + * @param entity The entity speaking + * @param messageLength The length of the message + * @param material The gag material type + */ + private static void applySuffocationEffects( + LivingEntity entity, + int messageLength, + GagMaterial material + ) { + if ( + messageLength > GAG_MAX_MESSAGE_LENGTH_BEFORE_SUFFOCATION && + material != GagMaterial.CLOTH + ) { + entity.addEffect( + new MobEffectInstance( + MobEffects.MOVEMENT_SLOWDOWN, + GAG_SUFFOCATION_SLOWNESS_DURATION, + 1 + ) + ); + if (RANDOM.nextFloat() < GAG_SUFFOCATION_BLINDNESS_CHANCE) { + entity.addEffect( + new MobEffectInstance( + MobEffects.BLINDNESS, + GAG_SUFFOCATION_BLINDNESS_DURATION, + 0 + ) + ); + } + } + } + + /** + * Check for critical failure when speaking through gag. + * Longer messages have higher chance of complete muffling. + * + * @param kidnapped The kidnapped entity + * @param gagStack The gag item stack + * @param messageLength The length of the message + * @param material The gag material type + * @return Critical fail Component if failed, null otherwise + */ + private static Component checkCriticalFailure( + IBondageState kidnapped, + ItemStack gagStack, + int messageLength, + GagMaterial material + ) { + float critChance = + GAG_BASE_CRITICAL_FAIL_CHANCE + + (messageLength * GAG_LENGTH_CRITICAL_FACTOR); + + if (RANDOM.nextFloat() < critChance && material != GagMaterial.CLOTH) { + kidnapped.sendMessage( + Component.translatable( + "chat.tiedup.gag.crit_fail", + gagStack.getHoverName() + ).withStyle(ChatFormatting.RED), + true + ); + return Component.literal( + CRIT_FAIL_SOUNDS[RANDOM.nextInt(CRIT_FAIL_SOUNDS.length)] + ); + } + return null; + } + + // ======================================== + // MCA INTEGRATION METHODS + // ======================================== + + /** + * Transform a message to gagged speech without side effects. + * For use with MCA villagers and AI chat. + * + *

Unlike {@link #processGagMessage}, this method: + *

    + *
  • Does not apply suffocation effects
  • + *
  • Does not show internal thoughts
  • + *
  • Does not have critical fail chance
  • + *
  • Simply returns the muffled text
  • + *
+ * + * @param originalMessage The original message to transform + * @param gagStack The gag item stack (determines material) + * @return The muffled message string + */ + public static String transformToGaggedSpeech( + String originalMessage, + ItemStack gagStack + ) { + if (originalMessage == null || originalMessage.isEmpty()) { + return ""; + } + + GagMaterial material = GagMaterial.CLOTH; + if (gagStack != null && gagStack.getItem() instanceof ItemGag gag) { + material = gag.getGagMaterial(); + } + + StringBuilder muffled = new StringBuilder(); + String[] words = originalMessage.split("\\s+"); + + for (int i = 0; i < words.length; i++) { + String word = words[i]; + + // Three-tier comprehension for MCA as well + float comp = material.getComprehension(); + float roll = RANDOM.nextFloat(); + + if (roll < comp * 0.4f) { + muffled.append(word); + } else if (roll < comp) { + muffled.append(generatePartiallyMuffledWord(word, material)); + } else { + muffled.append(generateMuffledWord(word, material)); + } + + if (i < words.length - 1) { + muffled.append(RANDOM.nextFloat() < 0.1f ? "-" : " "); + } + } + + return muffled.toString(); + } + + /** + * Transform a message to gagged speech using default cloth gag. + * Convenience method for when gag item is not available. + * + * @param originalMessage The original message to transform + * @return The muffled message string + */ + public static String transformToGaggedSpeech(String originalMessage) { + return transformToGaggedSpeech(originalMessage, ItemStack.EMPTY); + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/IDialogueSpeaker.java b/src/main/java/com/tiedup/remake/dialogue/IDialogueSpeaker.java new file mode 100644 index 0000000..59125db --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/IDialogueSpeaker.java @@ -0,0 +1,100 @@ +package com.tiedup.remake.dialogue; + +import com.tiedup.remake.personality.PersonalityType; +import org.jetbrains.annotations.Nullable; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; + +/** + * Interface for entities that can speak dialogue. + * Implemented by all NPCs that use the data-driven dialogue system. + * + * Dialogue System: Universal NPC dialogue support + */ +public interface IDialogueSpeaker { + /** + * Get the name displayed in dialogue chat messages. + * + * @return The speaker's display name + */ + String getDialogueName(); + + /** + * Get the speaker type for dialogue routing. + * Determines which dialogue folder is used. + * + * @return The speaker type + */ + SpeakerType getSpeakerType(); + + /** + * Get the speaker's personality for dialogue selection. + * May return null for NPCs without a personality system. + * + * @return PersonalityType or null + */ + @Nullable + PersonalityType getSpeakerPersonality(); + + /** + * Get the speaker's current mood (0-100). + * 50 = neutral, higher = happier, lower = angrier. + * + * @return Mood value between 0 and 100 + */ + int getSpeakerMood(); + + /** + * Get the relation type between this speaker and a player. + * Used for dialogue condition matching. + * + * Examples: + * - "master" (player owns this NPC) + * - "captor" (kidnapper holding player) + * - "prisoner" (player is imprisoned by this NPC's camp) + * - "customer" (player is trading with this NPC) + * - null (no special relation) + * + * @param player The player to check relation with + * @return Relation type string or null + */ + @Nullable + String getTargetRelation(Player player); + + /** + * Get this speaker as a LivingEntity. + * Used for getting position, level, and other entity data. + * + * @return The entity implementing this interface + */ + LivingEntity asEntity(); + + /** + * Get a cooldown timer to prevent dialogue spam. + * Returns 0 if dialogue is allowed now. + * + * @return Remaining cooldown ticks (0 = can speak) + */ + default int getDialogueCooldown() { + return 0; + } + + /** + * Set the dialogue cooldown timer. + * + * @param ticks Cooldown duration in ticks + */ + default void setDialogueCooldown(int ticks) { + // Default: no-op, override in implementations that track cooldown + } + + /** + * Check if dialogue should be processed through gag filter. + * For gagged entities, dialogue text is muffled. + * + * @return true if speaker is gagged + */ + default boolean isDialogueGagged() { + return false; + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/KidnapperDialogueTriggerSystem.java b/src/main/java/com/tiedup/remake/dialogue/KidnapperDialogueTriggerSystem.java new file mode 100644 index 0000000..e727831 --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/KidnapperDialogueTriggerSystem.java @@ -0,0 +1,121 @@ +package com.tiedup.remake.dialogue; + +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.ai.kidnapper.KidnapperState; +import org.jetbrains.annotations.Nullable; +import net.minecraft.world.entity.player.Player; + +/** + * Proactive dialogue trigger system for Kidnappers. + * + * This system enables kidnappers to speak unprompted based on their current state. + * Should be called periodically from the kidnapper's tick method. + * + * Universal NPC Dialogue Support + */ +public class KidnapperDialogueTriggerSystem { + + /** Minimum ticks between proactive dialogues (60 seconds) */ + private static final int PROACTIVE_COOLDOWN = 1200; + + /** Chance to trigger proactive dialogue (1/400 per check = ~0.25% per check) */ + private static final int TRIGGER_CHANCE = 400; + + /** Check interval (every 20 ticks = 1 second) */ + private static final int CHECK_INTERVAL = 20; + + /** + * Tick the dialogue trigger system for a kidnapper. + * Should be called every tick from the kidnapper. + * + * @param kidnapper The kidnapper to check + * @param tickCount Current tick counter + */ + public static void tick(EntityKidnapper kidnapper, int tickCount) { + // Only check periodically + if (tickCount % CHECK_INTERVAL != 0) { + return; + } + + // Check cooldown + if (kidnapper.getDialogueCooldown() > 0) { + return; + } + + // Random chance check + if (kidnapper.getRandom().nextInt(TRIGGER_CHANCE) != 0) { + return; + } + + // Get dialogue ID based on current state + String dialogueId = selectProactiveDialogue(kidnapper); + if (dialogueId == null) { + return; + } + + // Find nearest player to speak to + Player nearestPlayer = findNearestPlayer(kidnapper, 10.0); + if (nearestPlayer == null) { + return; + } + + // Speak + DialogueBridge.talkTo(kidnapper, nearestPlayer, dialogueId); + } + + /** + * Select a proactive dialogue ID based on the kidnapper's current state. + * + * @param kidnapper The kidnapper + * @return Dialogue ID or null if no dialogue for this state + */ + @Nullable + private static String selectProactiveDialogue(EntityKidnapper kidnapper) { + KidnapperState state = kidnapper.getCurrentState(); + if (state == null) { + return null; + } + + return switch (state) { + case GUARD -> "guard.idle"; + case PATROL -> "patrol.idle"; + case ALERT -> "patrol.alert"; + case SELLING -> "idle.sale_waiting"; + case IDLE -> null; // No proactive dialogue when idle + default -> null; + }; + } + + /** + * Find the nearest player within a radius. + * + * @param kidnapper The kidnapper + * @param radius Search radius + * @return Nearest player or null + */ + @Nullable + private static Player findNearestPlayer( + EntityKidnapper kidnapper, + double radius + ) { + var nearbyPlayers = kidnapper + .level() + .getEntitiesOfClass( + Player.class, + kidnapper.getBoundingBox().inflate(radius) + ); + + Player nearest = null; + double nearestDistSq = Double.MAX_VALUE; + + for (Player player : nearbyPlayers) { + double distSq = kidnapper.distanceToSqr(player); + if (distSq < nearestDistSq) { + nearestDistSq = distSq; + nearest = player; + } + } + + return nearest; + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/SpeakerType.java b/src/main/java/com/tiedup/remake/dialogue/SpeakerType.java new file mode 100644 index 0000000..1e04caa --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/SpeakerType.java @@ -0,0 +1,105 @@ +package com.tiedup.remake.dialogue; + +/** + * Defines the type of NPC speaker for dialogue routing. + * Each speaker type maps to a dialogue folder structure. + * + * Dialogue System: Universal NPC dialogue support + */ +public enum SpeakerType { + /** + * EntityDamsel and EntityDamselShiny. + * Uses personality-based dialogue folders (timid/, fierce/, etc.) + */ + DAMSEL("damsel"), + + /** + * EntityKidnapper (base kidnapper). + * Uses theme-based personality mapping. + */ + KIDNAPPER("kidnapper"), + + /** + * EntityKidnapperElite. + * More arrogant/confident dialogue tone. + */ + KIDNAPPER_ELITE("kidnapper_elite"), + + /** + * EntityKidnapperArcher. + * Ranged combat specific dialogue. + */ + KIDNAPPER_ARCHER("kidnapper_archer"), + + /** + * EntityKidnapperMerchant. + * Dual-mode: MERCHANT (greedy) vs HOSTILE (vengeful). + */ + MERCHANT("merchant"), + + /** + * EntityMaid. + * Obedient servant dialogue. + */ + MAID("maid"), + + /** + * EntityLaborGuard. + * Prison guard monitoring prisoners during labor. + */ + GUARD("guard"), + + /** + * EntitySlaveTrader. + * Camp boss, business-focused dialogue. + */ + TRADER("trader"), + + /** + * EntityMaster. + * Pet play master - dominant, commanding dialogue. + * Buys solo players from Kidnappers. + */ + MASTER("master"); + + private final String folderName; + + SpeakerType(String folderName) { + this.folderName = folderName; + } + + /** + * Get the dialogue folder name for this speaker type. + * Used for loading dialogue JSON files. + * + * @return Folder name (e.g., "kidnapper", "maid") + */ + public String getFolderName() { + return folderName; + } + + /** + * Check if this is a kidnapper-type speaker. + * Kidnapper types use theme-based personality mapping. + * + * @return true if kidnapper, elite, archer, or merchant + */ + public boolean isKidnapperType() { + return ( + this == KIDNAPPER || + this == KIDNAPPER_ELITE || + this == KIDNAPPER_ARCHER || + this == MERCHANT + ); + } + + /** + * Check if this is a camp management speaker. + * Camp speakers have special dialogue for prisoner management. + * + * @return true if maid or trader + */ + public boolean isCampManagement() { + return this == MAID || this == TRADER || this == GUARD; + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/conversation/ConversationManager.java b/src/main/java/com/tiedup/remake/dialogue/conversation/ConversationManager.java new file mode 100644 index 0000000..df2b27a --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/conversation/ConversationManager.java @@ -0,0 +1,562 @@ +package com.tiedup.remake.dialogue.conversation; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.DialogueBridge; +import com.tiedup.remake.dialogue.DialogueContext; +import com.tiedup.remake.dialogue.DialogueManager; +import com.tiedup.remake.dialogue.EntityDialogueManager; +import com.tiedup.remake.dialogue.IDialogueSpeaker; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.entities.NpcTypeHelper; +import com.tiedup.remake.personality.PersonalityState; +import com.tiedup.remake.personality.PersonalityType; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.jetbrains.annotations.Nullable; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; + +/** + * Manages interactive conversations between players and NPCs. + * Handles topic availability, dialogue retrieval, conversation state, + * cooldowns, refusals, and topic effects. + * + * Phase 5: Enhanced Conversation System + */ +public class ConversationManager { + + /** + * Active conversations: player UUID -> conversation state + */ + private static final Map activeConversations = + new HashMap<>(); + + /** + * Maximum distance for conversation + */ + private static final double MAX_CONVERSATION_DISTANCE = 5.0; + + /** + * Conversation state holder. + */ + public static class ConversationState { + + private final UUID speakerEntityId; + private final ResourceKey speakerDimension; + private final long startTime; + private ConversationTopic lastTopic; + private int messageCount; + + public ConversationState( + UUID speakerEntityId, + ResourceKey speakerDimension + ) { + this.speakerEntityId = speakerEntityId; + this.speakerDimension = speakerDimension; + this.startTime = System.currentTimeMillis(); + this.messageCount = 0; + } + + public UUID getSpeakerEntityId() { + return speakerEntityId; + } + + public ResourceKey getSpeakerDimension() { + return speakerDimension; + } + + public long getStartTime() { + return startTime; + } + + public ConversationTopic getLastTopic() { + return lastTopic; + } + + public void setLastTopic(ConversationTopic topic) { + this.lastTopic = topic; + this.messageCount++; + } + + public int getMessageCount() { + return messageCount; + } + } + + /** + * Check if a player can start a conversation with a speaker. + * Does not check refusal reasons - use checkRefusal for that. + * + * @param speaker The NPC to converse with + * @param player The player + * @return true if conversation can start (basic checks only) + */ + public static boolean canConverse(IDialogueSpeaker speaker, Player player) { + if (speaker == null || player == null) { + return false; + } + + // Check distance + if (speaker.asEntity().distanceTo(player) > MAX_CONVERSATION_DISTANCE) { + return false; + } + + // Check if speaker is alive + if (!speaker.asEntity().isAlive()) { + return false; + } + + // Check if player is already in a conversation + ConversationState current = activeConversations.get(player.getUUID()); + if (current != null) { + // Allow if it's with the same speaker + if ( + !current + .getSpeakerEntityId() + .equals(speaker.asEntity().getUUID()) + ) { + return false; + } + } + + return true; + } + + /** + * Check if the NPC will refuse to talk and why. + * + * @param speaker The NPC + * @param player The player + * @return Refusal reason (NONE if willing to talk) + */ + public static ConversationRefusalReason checkRefusal( + IDialogueSpeaker speaker, + Player player + ) { + // No refusal system — NPCs always talk + return ConversationRefusalReason.NONE; + } + + /** + * Start a conversation between a player and a speaker. + * + * @param speaker The NPC to converse with + * @param player The player + * @return true if conversation started successfully + */ + public static boolean startConversation( + IDialogueSpeaker speaker, + Player player + ) { + if (!canConverse(speaker, player)) { + return false; + } + + UUID playerId = player.getUUID(); + UUID speakerId = speaker.asEntity().getUUID(); + ResourceKey dimension = speaker.asEntity().level().dimension(); + + // Create new conversation state + ConversationState state = new ConversationState(speakerId, dimension); + activeConversations.put(playerId, state); + + TiedUpMod.LOGGER.info( + "[ConversationManager] Started conversation: player={}, speaker={}", + player.getName().getString(), + speaker.getDialogueName() + ); + + return true; + } + + /** + * End the current conversation for a player. + * Starts cooldown on the NPC's conversation memory. + * + * @param player The player + * @param damsel The damsel (can be null) + */ + public static void endConversation( + Player player, + @Nullable EntityDamsel damsel + ) { + ConversationState state = activeConversations.remove(player.getUUID()); + + if (state != null) { + TiedUpMod.LOGGER.info( + "[ConversationManager] Ended conversation: player={}, messages={}", + player.getName().getString(), + state.getMessageCount() + ); + } + + // No conversation memory/cooldown system + } + + /** + * End the current conversation for a player (legacy overload). + * + * @param player The player + */ + public static void endConversation(Player player) { + endConversation(player, null); + } + + /** + * Check if a player is in an active conversation. + * + * @param player The player + * @return true if in conversation + */ + public static boolean isInConversation(Player player) { + return activeConversations.containsKey(player.getUUID()); + } + + /** + * Get the current conversation state for a player. + * + * @param player The player + * @return Conversation state, or null if not in conversation + */ + @Nullable + public static ConversationState getConversationState(Player player) { + return activeConversations.get(player.getUUID()); + } + + /** + * Get all available conversation topics for a speaker. + * + * @param speaker The NPC + * @param player The player + * @return List of available topics + */ + public static List getAvailableTopics( + IDialogueSpeaker speaker, + Player player + ) { + DialogueContext context = DialogueBridge.buildContext(speaker, player); + + Set topics = ConversationTopic.getAvailableTopics( + context + ); + + // Sort by category, then by name within category + return topics + .stream() + .sorted((a, b) -> { + int catCompare = a.getCategory().compareTo(b.getCategory()); + if (catCompare != 0) return catCompare; + return a.getDisplayText().compareTo(b.getDisplayText()); + }) + .collect(Collectors.toList()); + } + + /** + * Get available topics by category. + * + * @param speaker The NPC + * @param player The player + * @return Map of category -> topics + */ + public static Map< + ConversationTopic.Category, + List + > getTopicsByCategory(IDialogueSpeaker speaker, Player player) { + DialogueContext context = DialogueBridge.buildContext(speaker, player); + + return ConversationTopic.getAvailableTopics(context) + .stream() + .collect(Collectors.groupingBy(ConversationTopic::getCategory)); + } + + /** + * Get topic effectiveness for UI display. + * + * @param speaker The NPC + * @param player The player + * @param topic The topic + * @return Effectiveness multiplier (0.2 to 1.0) + */ + public static float getTopicEffectiveness( + IDialogueSpeaker speaker, + Player player, + ConversationTopic topic + ) { + return 1.0f; + } + + /** + * Send a conversation topic to a speaker and get the response. + * + * @param speaker The NPC + * @param player The player + * @param topic The selected topic + * @return The response text, or null if failed + */ + @Nullable + public static String sendTopic( + IDialogueSpeaker speaker, + Player player, + ConversationTopic topic + ) { + if (!canConverse(speaker, player)) { + TiedUpMod.LOGGER.warn( + "[ConversationManager] Cannot send topic: conversation not valid" + ); + return null; + } + + DialogueContext context = DialogueBridge.buildContext(speaker, player); + + // Check if topic is available + if (!topic.isAvailableFor(context)) { + TiedUpMod.LOGGER.warn( + "[ConversationManager] Topic {} not available for context", + topic.name() + ); + return null; + } + + // Get response from DialogueManager + String response = DialogueManager.getDialogue( + topic.getDialogueId(), + context + ); + + if (response == null) { + TiedUpMod.LOGGER.warn( + "[ConversationManager] No dialogue found for topic: {}", + topic.getDialogueId() + ); + return null; + } + + // Update conversation state + ConversationState state = activeConversations.get(player.getUUID()); + if (state != null) { + state.setLastTopic(topic); + } + + // Apply effects for action topics + if (speaker.asEntity() instanceof EntityDamsel damsel && NpcTypeHelper.isDamselOnly(speaker.asEntity())) { + applyTopicEffects(damsel, player, topic); + } + + return response; + } + + /** + * Apply comprehensive topic effects based on the topic selected. + * Uses TopicEffect system with personality modifiers and fatigue. + * + * @param damsel The damsel entity + * @param player The player + * @param topic The topic that was selected + */ + private static void applyTopicEffects( + EntityDamsel damsel, + Player player, + ConversationTopic topic + ) { + PersonalityState state = damsel.getPersonalityState(); + if (state == null) { + return; + } + + // Get base effect for topic + TopicEffect baseEffect = TopicEffect.forTopic(topic); + + // Apply personality modifier + PersonalityType personality = state.getPersonality(); + TopicEffect finalEffect = baseEffect.withPersonalityModifier( + personality, + topic + ); + + // Apply mood effect only (no relationship/resentment/memory) + if (finalEffect.hasEffect() && finalEffect.moodChange() != 0) { + state.modifyMood(finalEffect.moodChange()); + } + } + + /** + * Send a topic and display the response to the player. + * This is the main method to use from network packets. + * + * @param speaker The NPC + * @param player The server player + * @param topic The selected topic + */ + public static void handleTopicSelection( + IDialogueSpeaker speaker, + ServerPlayer player, + ConversationTopic topic + ) { + String response = sendTopic(speaker, player, topic); + + if ( + response != null && + speaker.asEntity() instanceof EntityDamsel damsel && + NpcTypeHelper.isDamselOnly(speaker.asEntity()) + ) { + // Send the response as a dialogue message + EntityDialogueManager.talkTo(damsel, player, response); + } + } + + /** + * Clean up conversation state for a disconnecting player. + * Called from {@link com.tiedup.remake.events.lifecycle.PlayerDisconnectHandler}. + * + * @param playerId UUID of the disconnecting player + */ + public static void cleanupPlayer(java.util.UUID playerId) { + ConversationState removed = activeConversations.remove(playerId); + if (removed != null) { + TiedUpMod.LOGGER.debug( + "[ConversationManager] Cleaned up conversation for disconnecting player {}", + playerId + ); + } + } + + /** + * Clean up stale conversations (called periodically). + */ + public static void cleanupStaleConversations() { + long now = System.currentTimeMillis(); + long maxAge = 5 * 60 * 1000; // 5 minutes + + activeConversations + .entrySet() + .removeIf(entry -> { + if (now - entry.getValue().getStartTime() > maxAge) { + TiedUpMod.LOGGER.debug( + "[ConversationManager] Cleaned up stale conversation for player {}", + entry.getKey() + ); + return true; + } + return false; + }); + } + + /** + * Open a conversation GUI for a player with an NPC. + * Called from server side - sends packet to open client GUI. + * Checks for refusal reasons before opening. + * + * @param speaker The NPC to converse with + * @param player The server player + * @return true if conversation was opened + */ + public static boolean openConversation( + IDialogueSpeaker speaker, + ServerPlayer player + ) { + if (!canConverse(speaker, player)) { + TiedUpMod.LOGGER.warn( + "[ConversationManager] Cannot open conversation: canConverse returned false" + ); + return false; + } + + // Check for refusal + ConversationRefusalReason refusal = checkRefusal(speaker, player); + if (refusal != ConversationRefusalReason.NONE) { + // Send refusal dialogue to player + if ( + refusal.hasDialogue() && + speaker.asEntity() instanceof EntityDamsel damsel && + NpcTypeHelper.isDamselOnly(speaker.asEntity()) + ) { + DialogueContext context = DialogueBridge.buildContext( + speaker, + player + ); + String refusalMsg = DialogueManager.getDialogue( + refusal.getDialogueId(), + context + ); + if (refusalMsg != null) { + EntityDialogueManager.talkTo(damsel, player, refusalMsg); + } else { + // Fallback message + EntityDialogueManager.talkTo( + damsel, + player, + getDefaultRefusalMessage(refusal) + ); + } + } + + TiedUpMod.LOGGER.info( + "[ConversationManager] Conversation refused: player={}, reason={}", + player.getName().getString(), + refusal.name() + ); + return false; + } + + // Start the conversation state + if (!startConversation(speaker, player)) { + return false; + } + + // Get available topics + List topics = getAvailableTopics(speaker, player); + + if (topics.isEmpty()) { + TiedUpMod.LOGGER.warn( + "[ConversationManager] No available topics for conversation" + ); + endConversation(player); + return false; + } + + // Send packet to open conversation GUI + com.tiedup.remake.network.ModNetwork.sendToPlayer( + new com.tiedup.remake.network.conversation.PacketOpenConversation( + speaker.asEntity().getId(), + speaker.getDialogueName(), + topics + ), + player + ); + + TiedUpMod.LOGGER.info( + "[ConversationManager] Opened conversation GUI for {} with {} ({} topics)", + player.getName().getString(), + speaker.getDialogueName(), + topics.size() + ); + + return true; + } + + /** + * Get default refusal message when dialogue is not available. + * + * @param reason The refusal reason + * @return Default message + */ + private static String getDefaultRefusalMessage( + ConversationRefusalReason reason + ) { + return switch (reason) { + case COOLDOWN -> "*looks away* We just talked..."; + case LOW_MOOD -> "*stares at the ground* I don't feel like talking..."; + case HIGH_RESENTMENT -> "*glares silently*"; + case FEAR_OF_PLAYER -> "*looks away nervously*"; + case EXHAUSTED -> "*yawns* Too tired..."; + case TOPIC_LIMIT -> "*sighs* I'm tired of talking..."; + default -> "..."; + }; + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/conversation/ConversationRefusalReason.java b/src/main/java/com/tiedup/remake/dialogue/conversation/ConversationRefusalReason.java new file mode 100644 index 0000000..96e996d --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/conversation/ConversationRefusalReason.java @@ -0,0 +1,100 @@ +package com.tiedup.remake.dialogue.conversation; + +/** + * Reasons why an NPC might refuse to engage in conversation. + * Each reason has a corresponding dialogue ID for personality-specific responses. + * + * Phase 5: Enhanced Conversation System + */ +public enum ConversationRefusalReason { + /** No refusal - NPC will talk */ + NONE("", false), + + /** Cooldown active - recently finished talking */ + COOLDOWN("conversation.refusal.cooldown", false), + + /** Mood too low (< 20) - not feeling talkative */ + LOW_MOOD("conversation.refusal.low_mood", true), + + /** Resentment too high (> 70) - silent treatment */ + HIGH_RESENTMENT("conversation.refusal.resentment", true), + + /** Fear too high (> 60) - too scared to talk */ + FEAR_OF_PLAYER("conversation.refusal.fear", true), + + /** Rest too low (< 20) - too exhausted to talk */ + EXHAUSTED("conversation.refusal.exhausted", true), + + /** Topic limit reached - tired of talking this session */ + TOPIC_LIMIT("conversation.refusal.tired", true); + + /** Dialogue ID for the refusal message (personality-specific) */ + private final String dialogueId; + + /** Whether this refusal can be shown to the player as a message */ + private final boolean hasDialogue; + + ConversationRefusalReason(String dialogueId, boolean hasDialogue) { + this.dialogueId = dialogueId; + this.hasDialogue = hasDialogue; + } + + /** + * Get the dialogue ID for this refusal reason. + * Used to fetch personality-specific refusal messages. + * + * @return Dialogue ID string + */ + public String getDialogueId() { + return dialogueId; + } + + /** + * Whether this refusal has associated dialogue. + * + * @return true if there's a dialogue response + */ + public boolean hasDialogue() { + return hasDialogue; + } + + /** + * Get the translation key for the reason (for UI display). + * + * @return Translation key + */ + public String getTranslationKey() { + return "conversation.refusal." + this.name().toLowerCase(); + } + + /** + * Get how long (in ticks) before the player can try again. + * Returns 0 for reasons that aren't time-based. + * + * @return Retry delay in ticks, or 0 + */ + public int getRetryDelay() { + return switch (this) { + case COOLDOWN -> 1200; // 1 minute cooldown in ticks + case TOPIC_LIMIT -> 200; // 10 seconds + default -> 0; // State-based, no fixed delay + }; + } + + /** + * Get a suggestion for the player on how to resolve this refusal. + * + * @return Translation key for the suggestion + */ + public String getSuggestionKey() { + return switch (this) { + case COOLDOWN -> "conversation.suggestion.wait"; + case LOW_MOOD -> "conversation.suggestion.improve_mood"; + case HIGH_RESENTMENT -> "conversation.suggestion.reduce_resentment"; + case FEAR_OF_PLAYER -> "conversation.suggestion.be_gentle"; + case EXHAUSTED -> "conversation.suggestion.let_rest"; + case TOPIC_LIMIT -> "conversation.suggestion.end_conversation"; + default -> ""; + }; + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/conversation/ConversationTopic.java b/src/main/java/com/tiedup/remake/dialogue/conversation/ConversationTopic.java new file mode 100644 index 0000000..1d2f630 --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/conversation/ConversationTopic.java @@ -0,0 +1,292 @@ +package com.tiedup.remake.dialogue.conversation; + +import com.tiedup.remake.dialogue.DialogueContext; +import com.tiedup.remake.dialogue.SpeakerType; +import java.util.EnumSet; +import java.util.Set; + +/** + * Simplified conversation topics for interactive dialogue. + * 8 core topics with significant effects, organized in 2 categories. + * + * Phase 5: Enhanced Conversation System + */ +public enum ConversationTopic { + // === ACTIONS (Always visible) === + COMPLIMENT( + "conversation.compliment", + "Compliment", + Category.ACTION, + null, + null, + "You look nice today.", + "Give a compliment" + ), + + COMFORT( + "conversation.comfort", + "Comfort", + Category.ACTION, + null, + 50, + "It's going to be okay.", + "Comfort them" + ), + + PRAISE( + "conversation.praise", + "Praise", + Category.ACTION, + null, + null, + "You did well.", + "Praise their behavior" + ), + + SCOLD( + "conversation.scold", + "Scold", + Category.ACTION, + null, + null, + "That was wrong.", + "Scold them" + ), + + THREATEN( + "conversation.threaten", + "Threaten", + Category.ACTION, + null, + null, + "Don't make me...", + "Make a threat" + ), + + TEASE( + "conversation.tease", + "Tease", + Category.ACTION, + null, + null, + "Having fun?", + "Tease them playfully" + ), + + // === QUESTIONS (Basic inquiries) === + HOW_ARE_YOU( + "conversation.how_are_you", + "How are you?", + Category.QUESTION, + null, + null, + "How are you feeling?", + "Ask about their state" + ), + + WHATS_WRONG( + "conversation.whats_wrong", + "What's wrong?", + Category.QUESTION, + null, + 40, + "Something seems off...", + "Ask what's bothering them" + ); + + /** + * Topic categories for UI organization. + */ + public enum Category { + ACTION("Actions"), + QUESTION("Questions"); + + private final String displayName; + + Category(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + } + + private final String dialogueId; + private final String displayText; + private final Category category; + private final Integer minMood; + private final Integer maxMood; + private final String playerText; + private final String description; + + ConversationTopic( + String dialogueId, + String displayText, + Category category, + Integer minMood, + Integer maxMood, + String playerText, + String description + ) { + this.dialogueId = dialogueId; + this.displayText = displayText; + this.category = category; + this.minMood = minMood; + this.maxMood = maxMood; + this.playerText = playerText; + this.description = description; + } + + /** + * Get the dialogue ID for this topic (used to fetch NPC response). + */ + public String getDialogueId() { + return dialogueId; + } + + /** + * Get the display text shown in the UI button. + */ + public String getDisplayText() { + return displayText; + } + + /** + * Get the topic category. + */ + public Category getCategory() { + return category; + } + + /** + * Get the text the player says when selecting this topic. + */ + public String getPlayerText() { + return playerText; + } + + /** + * Get a description of what this topic does. + */ + public String getDescription() { + return description; + } + + /** + * Check if this topic is available for the given context. + * + * @param context The dialogue context + * @return true if the topic can be used + */ + public boolean isAvailableFor(DialogueContext context) { + // Check speaker type + if (!isValidForSpeaker(context.getSpeakerType())) { + return false; + } + + // Check mood bounds + int mood = context.getMood(); + if (minMood != null && mood < minMood) { + return false; + } + if (maxMood != null && mood > maxMood) { + return false; + } + + return true; + } + + /** + * Check if this topic is valid for a speaker type. + * + * @param speakerType The speaker type + * @return true if valid + */ + public boolean isValidForSpeaker(SpeakerType speakerType) { + // All 8 topics work with DAMSEL + return speakerType == SpeakerType.DAMSEL; + } + + /** + * Check if this is an action topic (as opposed to a question). + * + * @return true if this is an action + */ + public boolean isAction() { + return category == Category.ACTION; + } + + /** + * Check if this is a positive action (compliment, comfort, praise). + * + * @return true if positive + */ + public boolean isPositive() { + return this == COMPLIMENT || this == COMFORT || this == PRAISE; + } + + /** + * Check if this is a negative action (scold, threaten). + * + * @return true if negative + */ + public boolean isNegative() { + return this == SCOLD || this == THREATEN; + } + + /** + * Get all topics available for a given context. + * + * @param context The dialogue context + * @return Set of available topics + */ + public static Set getAvailableTopics( + DialogueContext context + ) { + Set available = EnumSet.noneOf( + ConversationTopic.class + ); + + for (ConversationTopic topic : values()) { + if (topic.isAvailableFor(context)) { + available.add(topic); + } + } + + return available; + } + + /** + * Get topics by category that are available for a context. + * + * @param context The dialogue context + * @param category The category to filter by + * @return Set of available topics in that category + */ + public static Set getAvailableByCategory( + DialogueContext context, + Category category + ) { + Set available = EnumSet.noneOf( + ConversationTopic.class + ); + + for (ConversationTopic topic : values()) { + if (topic.category == category && topic.isAvailableFor(context)) { + available.add(topic); + } + } + + return available; + } + + /** + * Get the translation key for this topic's display text. + * + * @return Translation key + */ + public String getTranslationKey() { + return "conversation.topic." + this.name().toLowerCase(); + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/conversation/PetRequest.java b/src/main/java/com/tiedup/remake/dialogue/conversation/PetRequest.java new file mode 100644 index 0000000..77e107f --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/conversation/PetRequest.java @@ -0,0 +1,144 @@ +package com.tiedup.remake.dialogue.conversation; + +/** + * Enum defining requests that a pet (player) can make to their Master NPC. + * This is the inverse of ConversationTopic - these are actions initiated by the player. + * + * Each request has: + * - An ID for network serialization + * - Display text shown in the menu + * - Player speech text (what the player "says" when selecting) + * - Response dialogue ID for the Master's response + */ +public enum PetRequest { + /** + * Ask Master for food. Triggers feeding sequence with bowl placement. + */ + REQUEST_FOOD( + "request.food", + "Ask for food", + "Please, may I have something to eat?", + "petplay.feeding" + ), + + /** + * Ask Master to rest. Triggers resting sequence with pet bed placement. + */ + REQUEST_SLEEP( + "request.sleep", + "Ask to rest", + "I'm tired... may I rest?", + "petplay.resting" + ), + + /** + * Request a walk where the pet leads (Master follows). + */ + REQUEST_WALK_PASSIVE( + "request.walk_passive", + "Request a walk (you lead)", + "Can we go for a walk? I'll lead the way.", + "petplay.walk_passive" + ), + + /** + * Request a walk where the Master leads (Master walks, pulls pet). + */ + REQUEST_WALK_ACTIVE( + "request.walk_active", + "Request a walk (Master leads)", + "Can we go for a walk? You lead.", + "petplay.walk_active" + ), + + /** + * Ask Master to tie you up. + */ + REQUEST_TIE( + "request.tie", + "Ask to be tied", + "Would you tie me up, please?", + "petplay.tie_request" + ), + + /** + * Ask Master to untie you. + */ + REQUEST_UNTIE( + "request.untie", + "Ask to be untied", + "May I be untied, please?", + "petplay.untie_request" + ), + + /** + * End the conversation gracefully. + */ + END_CONVERSATION( + "request.end", + "End conversation", + "Thank you, Master.", + "petplay.dismiss" + ); + + private final String id; + private final String displayText; + private final String playerText; + private final String responseDialogueId; + + PetRequest( + String id, + String displayText, + String playerText, + String responseDialogueId + ) { + this.id = id; + this.displayText = displayText; + this.playerText = playerText; + this.responseDialogueId = responseDialogueId; + } + + /** + * Get the unique ID for this request (used in network packets). + */ + public String getId() { + return id; + } + + /** + * Get the display text shown in the request menu button. + */ + public String getDisplayText() { + return displayText; + } + + /** + * Get the text that represents what the player "says" when making this request. + * This is displayed as player speech before the Master responds. + */ + public String getPlayerText() { + return playerText; + } + + /** + * Get the dialogue ID that the Master should respond with. + */ + public String getResponseDialogueId() { + return responseDialogueId; + } + + /** + * Find a PetRequest by its ID. + * + * @param id The request ID + * @return The matching PetRequest, or null if not found + */ + public static PetRequest fromId(String id) { + for (PetRequest request : values()) { + if (request.id.equals(id)) { + return request; + } + } + return null; + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/conversation/PetRequestManager.java b/src/main/java/com/tiedup/remake/dialogue/conversation/PetRequestManager.java new file mode 100644 index 0000000..1d88830 --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/conversation/PetRequestManager.java @@ -0,0 +1,290 @@ +package com.tiedup.remake.dialogue.conversation; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.dialogue.DialogueBridge; +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.entities.ai.master.MasterPlaceBlockGoal; +import com.tiedup.remake.entities.ai.master.MasterState; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.BindVariant; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.master.PacketOpenPetRequestMenu; +import com.tiedup.remake.state.PlayerBindState; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; + +/** + * Manager for handling pet requests to their Master NPC. + * + * This is the server-side handler for player-initiated requests in pet play mode. + * Utilizes existing systems: + * - PlayerEquipment.equip(BodyRegionV2.ARMS, bind) / takeBindOff() for tie/untie + * - MasterState.DOGWALK for walk mode + * - MasterPlaceBlockGoal for feeding/resting + * - Leash physics via mixin for pulling the player + */ +public class PetRequestManager { + + /** Maximum distance for pet request interaction */ + private static final double MAX_DISTANCE = 6.0; + + /** + * Open the pet request menu for a pet player. + * Sends a packet to the client to display the GUI. + * + * @param master The master entity + * @param pet The pet player + */ + public static void openRequestMenu(EntityMaster master, ServerPlayer pet) { + if (!master.isPetPlayer(pet)) { + TiedUpMod.LOGGER.warn( + "[PetRequestManager] {} is not a pet of {}", + pet.getName().getString(), + master.getNpcName() + ); + return; + } + + // Check distance + double dist = master.distanceTo(pet); + if (dist > MAX_DISTANCE) { + pet.sendSystemMessage( + Component.literal("You are too far from your Master to talk.") + ); + return; + } + + // Send packet to open GUI on client + ModNetwork.sendToPlayer( + new PacketOpenPetRequestMenu( + master.getId(), + master.getNpcName() + ), + pet + ); + + TiedUpMod.LOGGER.debug( + "[PetRequestManager] Opened request menu for {} with {}", + pet.getName().getString(), + master.getNpcName() + ); + } + + /** + * Handle a request from the pet player. + * Called when the player selects an option from the pet request menu. + * + * @param master The master entity + * @param pet The pet player making the request + * @param request The request type + */ + public static void handleRequest( + EntityMaster master, + ServerPlayer pet, + PetRequest request + ) { + if (!master.isPetPlayer(pet)) { + TiedUpMod.LOGGER.warn( + "[PetRequestManager] Rejected request from non-pet {} to {}", + pet.getName().getString(), + master.getNpcName() + ); + return; + } + + // Check distance + double dist = master.distanceTo(pet); + if (dist > MAX_DISTANCE) { + pet.sendSystemMessage( + Component.literal("You are too far from your Master.") + ); + return; + } + + TiedUpMod.LOGGER.info( + "[PetRequestManager] {} requested {} from {}", + pet.getName().getString(), + request.name(), + master.getNpcName() + ); + + // Display what the player "says" + pet.sendSystemMessage( + Component.literal("You: " + request.getPlayerText()) + ); + + // Handle specific request + switch (request) { + case REQUEST_FOOD -> triggerFeeding(master, pet); + case REQUEST_SLEEP -> triggerResting(master, pet); + case REQUEST_WALK_PASSIVE -> triggerDogwalk(master, pet, false); + case REQUEST_WALK_ACTIVE -> triggerDogwalk(master, pet, true); + case REQUEST_TIE -> triggerTie(master, pet); + case REQUEST_UNTIE -> triggerUntie(master, pet); + case END_CONVERSATION -> endConversation(master, pet); + } + } + + /** + * Trigger feeding action - Master places bowl for pet. + */ + private static void triggerFeeding(EntityMaster master, ServerPlayer pet) { + DialogueBridge.talkTo(master, pet, "petplay.feeding"); + + MasterPlaceBlockGoal goal = master.getPlaceBlockGoal(); + if (goal != null) { + goal.triggerFeeding(); + } + } + + /** + * Trigger resting action - Master places pet bed for pet. + */ + private static void triggerResting(EntityMaster master, ServerPlayer pet) { + DialogueBridge.talkTo(master, pet, "petplay.resting"); + + MasterPlaceBlockGoal goal = master.getPlaceBlockGoal(); + if (goal != null) { + goal.triggerResting(); + } + } + + /** + * Trigger dogwalk mode. + * Puts dogbind on player and attaches leash. + * + * @param masterLeads If true, Master walks and pulls pet. If false, Master follows pet. + */ + private static void triggerDogwalk( + EntityMaster master, + ServerPlayer pet, + boolean masterLeads + ) { + // Get player bind state + PlayerBindState state = PlayerBindState.getInstance(pet); + if (state == null) { + TiedUpMod.LOGGER.warn( + "[PetRequestManager] Could not get PlayerBindState for {}", + pet.getName().getString() + ); + return; + } + + // Put dogbind on player (if not already tied) + if (!state.isTiedUp()) { + ItemStack dogbind = new ItemStack( + ModItems.getBind(BindVariant.DOGBINDER) + ); + state.equip(BodyRegionV2.ARMS, dogbind); + TiedUpMod.LOGGER.debug( + "[PetRequestManager] Equipped dogbind on {} for walk", + pet.getName().getString() + ); + } + + // Attach leash + master.attachLeashToPet(); + + // Set dogwalk mode + master.setDogwalkMode(masterLeads); + master.setMasterState(MasterState.DOGWALK); + + String dialogueId = masterLeads + ? "petplay.walk_active" + : "petplay.walk_passive"; + DialogueBridge.talkTo(master, pet, dialogueId); + + TiedUpMod.LOGGER.info( + "[PetRequestManager] {} entered DOGWALK mode with {} (masterLeads={})", + master.getNpcName(), + pet.getName().getString(), + masterLeads + ); + } + + /** + * Trigger tie request - Master ties up the pet. + */ + private static void triggerTie(EntityMaster master, ServerPlayer pet) { + // Don't allow tie requests during dogwalk + if (master.getStateManager().getCurrentState() == MasterState.DOGWALK) { + DialogueBridge.talkTo(master, pet, "petplay.busy"); + return; + } + + // Get player bind state + PlayerBindState state = PlayerBindState.getInstance(pet); + if (state == null) { + TiedUpMod.LOGGER.warn( + "[PetRequestManager] Could not get PlayerBindState for {}", + pet.getName().getString() + ); + return; + } + + // Check if already tied + if (state.isTiedUp()) { + DialogueBridge.talkTo(master, pet, "petplay.already_tied"); + return; + } + + // Master equips armbinder on pet (classic pet play restraint) + ItemStack bind = new ItemStack(ModItems.getBind(BindVariant.ARMBINDER)); + state.equip(BodyRegionV2.ARMS, bind); + + DialogueBridge.talkTo(master, pet, "petplay.tie_accept"); + + TiedUpMod.LOGGER.info( + "[PetRequestManager] {} tied up {} with armbinder", + master.getNpcName(), + pet.getName().getString() + ); + } + + /** + * Trigger untie request - Master unties the pet. + */ + private static void triggerUntie(EntityMaster master, ServerPlayer pet) { + // Don't allow untie requests during dogwalk + if (master.getStateManager().getCurrentState() == MasterState.DOGWALK) { + DialogueBridge.talkTo(master, pet, "petplay.busy"); + return; + } + + // Get player bind state + PlayerBindState state = PlayerBindState.getInstance(pet); + if (state == null) { + TiedUpMod.LOGGER.warn( + "[PetRequestManager] Could not get PlayerBindState for {}", + pet.getName().getString() + ); + return; + } + + // Check if actually tied + if (!state.isTiedUp()) { + DialogueBridge.talkTo(master, pet, "petplay.not_tied"); + return; + } + + // Master removes bind from pet + state.unequip(BodyRegionV2.ARMS); + + DialogueBridge.talkTo(master, pet, "petplay.untie_accept"); + + TiedUpMod.LOGGER.info( + "[PetRequestManager] {} untied {}", + master.getNpcName(), + pet.getName().getString() + ); + } + + /** + * End the conversation gracefully. + */ + private static void endConversation(EntityMaster master, ServerPlayer pet) { + DialogueBridge.talkTo(master, pet, "petplay.dismiss"); + } +} diff --git a/src/main/java/com/tiedup/remake/dialogue/conversation/TopicEffect.java b/src/main/java/com/tiedup/remake/dialogue/conversation/TopicEffect.java new file mode 100644 index 0000000..7a46002 --- /dev/null +++ b/src/main/java/com/tiedup/remake/dialogue/conversation/TopicEffect.java @@ -0,0 +1,167 @@ +package com.tiedup.remake.dialogue.conversation; + +import com.tiedup.remake.personality.PersonalityType; + +/** + * Represents the effects of a conversation topic on an NPC's state. + * Effects are applied with personality modifiers. + * + * Phase 5: Enhanced Conversation System + */ +public record TopicEffect( + /** Mood change (-20 to +20) */ + float moodChange +) { + // --- Static Factory Methods for Standard Topics --- + + public static TopicEffect compliment() { + return new TopicEffect(5f); + } + + public static TopicEffect comfort() { + return new TopicEffect(10f); + } + + public static TopicEffect praise() { + return new TopicEffect(3f); + } + + public static TopicEffect scold() { + return new TopicEffect(-5f); + } + + public static TopicEffect threaten() { + return new TopicEffect(-8f); + } + + public static TopicEffect tease() { + return new TopicEffect(0f); + } + + public static TopicEffect howAreYou() { + return new TopicEffect(1f); + } + + public static TopicEffect whatsWrong() { + return new TopicEffect(2f); + } + + // --- Effect Application Methods --- + + /** + * Apply personality modifier to this effect. + * Different personalities react differently to topics. + * + * @param personality The NPC's personality + * @param topic The topic being used + * @return Modified TopicEffect + */ + public TopicEffect withPersonalityModifier( + PersonalityType personality, + ConversationTopic topic + ) { + float moodMult = 1.0f; + + switch (personality) { + case MASOCHIST -> { + if ( + topic == ConversationTopic.SCOLD || + topic == ConversationTopic.THREATEN + ) { + moodMult = -0.5f; + } + } + case SUBMISSIVE -> { + if (topic == ConversationTopic.PRAISE) { + moodMult = 1.5f; + } + } + case PLAYFUL -> { + if (topic == ConversationTopic.TEASE) { + moodMult = 3.0f; + } + } + case TIMID -> { + if (topic == ConversationTopic.COMFORT) { + moodMult = 1.5f; + } + } + case GENTLE -> { + if ( + topic == ConversationTopic.COMPLIMENT || + topic == ConversationTopic.COMFORT + ) { + moodMult = 1.3f; + } + } + case PROUD -> { + if (topic == ConversationTopic.COMPLIMENT) { + moodMult = 0.5f; + } + } + default -> { + // No special handling + } + } + + return new TopicEffect(this.moodChange * moodMult); + } + + /** + * Apply effectiveness multiplier (from topic fatigue). + * + * @param effectiveness Multiplier (0.2 to 1.0) + * @return Scaled TopicEffect + */ + public TopicEffect withEffectiveness(float effectiveness) { + return new TopicEffect(this.moodChange * effectiveness); + } + + /** + * Get the base effect for a conversation topic. + * + * @param topic The topic + * @return Base TopicEffect + */ + public static TopicEffect forTopic(ConversationTopic topic) { + return switch (topic) { + case COMPLIMENT -> compliment(); + case COMFORT -> comfort(); + case PRAISE -> praise(); + case SCOLD -> scold(); + case THREATEN -> threaten(); + case TEASE -> tease(); + case HOW_ARE_YOU -> howAreYou(); + case WHATS_WRONG -> whatsWrong(); + default -> neutral(); + }; + } + + /** + * Create a neutral effect (no changes). + */ + public static TopicEffect neutral() { + return new TopicEffect(0f); + } + + /** + * Check if this effect has any significant changes. + * + * @return true if any value is non-zero + */ + public boolean hasEffect() { + return moodChange != 0; + } + + /** + * Get a descriptive string of the effect for debugging. + * + * @return Human-readable effect description + */ + public String toDebugString() { + if (moodChange != 0) { + return "Mood:" + (moodChange > 0 ? "+" : "") + moodChange; + } + return ""; + } +} diff --git a/src/main/java/com/tiedup/remake/dispenser/ClothesDispenseBehavior.java b/src/main/java/com/tiedup/remake/dispenser/ClothesDispenseBehavior.java new file mode 100644 index 0000000..4e1a1d5 --- /dev/null +++ b/src/main/java/com/tiedup/remake/dispenser/ClothesDispenseBehavior.java @@ -0,0 +1,31 @@ +package com.tiedup.remake.dispenser; + +import com.tiedup.remake.items.clothes.GenericClothes; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.state.IBondageState; +import net.minecraft.world.item.ItemStack; + +/** + * Dispenser behavior for dressing entities with clothes. + * + * Based on original BehaviorDispenserClothes from 1.12.2 + */ +public class ClothesDispenseBehavior extends EquipBondageDispenseBehavior { + + @Override + protected boolean isValidItem(ItemStack stack) { + return !stack.isEmpty() && stack.getItem() instanceof GenericClothes; + } + + @Override + protected boolean canEquip(IBondageState state) { + return state != null && !state.hasClothes(); + } + + @Override + protected void equip(IBondageState state, ItemStack stack) { + if (state != null) { + state.equip(BodyRegionV2.TORSO, stack); + } + } +} diff --git a/src/main/java/com/tiedup/remake/dispenser/DispenserBehaviors.java b/src/main/java/com/tiedup/remake/dispenser/DispenserBehaviors.java new file mode 100644 index 0000000..bb8389a --- /dev/null +++ b/src/main/java/com/tiedup/remake/dispenser/DispenserBehaviors.java @@ -0,0 +1,107 @@ +package com.tiedup.remake.dispenser; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.*; +import net.minecraft.world.level.block.DispenserBlock; + +/** + * Registration class for all TiedUp dispenser behaviors. + * + * Allows dispensers to: + * - Equip bondage items (binds, gags, blindfolds, collars, earplugs, clothes) on entities + * - Shoot rope arrows + * + * Based on original behaviors package from 1.12.2 + */ +public class DispenserBehaviors { + + /** + * Register all dispenser behaviors. + * Should be called from TiedUpMod.commonSetup() using enqueueWork(). + */ + public static void register() { + TiedUpMod.LOGGER.info( + "[DispenserBehaviors] Registering dispenser behaviors..." + ); + + registerBindBehaviors(); + registerGagBehaviors(); + registerBlindfoldBehaviors(); + registerCollarBehaviors(); + registerEarplugsBehaviors(); + registerClothesBehaviors(); + registerRopeArrowBehavior(); + + TiedUpMod.LOGGER.info( + "[DispenserBehaviors] Dispenser behaviors registered!" + ); + } + + private static void registerBindBehaviors() { + var behavior = GenericBondageDispenseBehavior.forBind(); + for (BindVariant variant : BindVariant.values()) { + DispenserBlock.registerBehavior( + ModItems.getBind(variant), + behavior + ); + } + } + + private static void registerGagBehaviors() { + var behavior = GenericBondageDispenseBehavior.forGag(); + for (GagVariant variant : GagVariant.values()) { + DispenserBlock.registerBehavior(ModItems.getGag(variant), behavior); + } + DispenserBlock.registerBehavior(ModItems.MEDICAL_GAG.get(), behavior); + DispenserBlock.registerBehavior(ModItems.HOOD.get(), behavior); + } + + private static void registerBlindfoldBehaviors() { + var behavior = GenericBondageDispenseBehavior.forBlindfold(); + for (BlindfoldVariant variant : BlindfoldVariant.values()) { + DispenserBlock.registerBehavior( + ModItems.getBlindfold(variant), + behavior + ); + } + } + + private static void registerCollarBehaviors() { + var behavior = GenericBondageDispenseBehavior.forCollar(); + DispenserBlock.registerBehavior( + ModItems.CLASSIC_COLLAR.get(), + behavior + ); + DispenserBlock.registerBehavior(ModItems.SHOCK_COLLAR.get(), behavior); + DispenserBlock.registerBehavior( + ModItems.SHOCK_COLLAR_AUTO.get(), + behavior + ); + DispenserBlock.registerBehavior(ModItems.GPS_COLLAR.get(), behavior); + } + + private static void registerEarplugsBehaviors() { + var behavior = GenericBondageDispenseBehavior.forEarplugs(); + for (EarplugsVariant variant : EarplugsVariant.values()) { + DispenserBlock.registerBehavior( + ModItems.getEarplugs(variant), + behavior + ); + } + } + + private static void registerClothesBehaviors() { + DispenserBlock.registerBehavior( + ModItems.CLOTHES.get(), + new ClothesDispenseBehavior() + ); + } + + private static void registerRopeArrowBehavior() { + DispenserBlock.registerBehavior( + ModItems.ROPE_ARROW.get(), + new RopeArrowDispenseBehavior() + ); + } +} diff --git a/src/main/java/com/tiedup/remake/dispenser/EquipBondageDispenseBehavior.java b/src/main/java/com/tiedup/remake/dispenser/EquipBondageDispenseBehavior.java new file mode 100644 index 0000000..b0e3da2 --- /dev/null +++ b/src/main/java/com/tiedup/remake/dispenser/EquipBondageDispenseBehavior.java @@ -0,0 +1,84 @@ +package com.tiedup.remake.dispenser; + +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.List; +import net.minecraft.core.BlockPos; +import net.minecraft.core.BlockSource; +import net.minecraft.core.Direction; +import net.minecraft.core.dispenser.DefaultDispenseItemBehavior; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.DispenserBlock; +import net.minecraft.world.phys.AABB; + +/** + * Base class for dispenser behaviors that equip bondage items on entities. + * + * When a dispenser containing a bondage item fires, it will attempt to + * equip the item on any valid entity (Player or NPC) standing in front of it. + * + * Based on original BehaviorDispenserEquipBondage from 1.12.2 + */ +public abstract class EquipBondageDispenseBehavior + extends DefaultDispenseItemBehavior +{ + + @Override + protected ItemStack execute(BlockSource source, ItemStack stack) { + Direction facing = source + .getBlockState() + .getValue(DispenserBlock.FACING); + BlockPos dispenserPos = new BlockPos( + (int) source.x(), + (int) source.y(), + (int) source.z() + ); + BlockPos targetPos = dispenserPos.relative(facing); + + // Create expanded AABB to catch entities (players are 1.8 blocks tall) + AABB searchArea = new AABB(targetPos).inflate(0.5, 1.0, 0.5); + + // Find all living entities in front of the dispenser + List entities = source + .getLevel() + .getEntitiesOfClass(LivingEntity.class, searchArea); + + // Try to equip on the first valid entity + for (LivingEntity entity : entities) { + IBondageState state = KidnappedHelper.getKidnappedState(entity); + if (state != null && isValidItem(stack) && canEquip(state)) { + ItemStack toEquip = stack.split(1); + equip(state, toEquip); + return stack; + } + } + + // No valid target - use default dispenser behavior (drop item) + return super.execute(source, stack); + } + + /** + * Check if the item stack is valid for this behavior. + * + * @param stack The item stack to check + * @return true if the item can be equipped by this behavior + */ + protected abstract boolean isValidItem(ItemStack stack); + + /** + * Check if the entity state allows equipping this item type. + * + * @param state The target entity's kidnapped state + * @return true if the item can be equipped on this entity + */ + protected abstract boolean canEquip(IBondageState state); + + /** + * Equip the item on the entity. + * + * @param state The target entity's kidnapped state + * @param stack The item stack to equip + */ + protected abstract void equip(IBondageState state, ItemStack stack); +} diff --git a/src/main/java/com/tiedup/remake/dispenser/GenericBondageDispenseBehavior.java b/src/main/java/com/tiedup/remake/dispenser/GenericBondageDispenseBehavior.java new file mode 100644 index 0000000..1df9ef6 --- /dev/null +++ b/src/main/java/com/tiedup/remake/dispenser/GenericBondageDispenseBehavior.java @@ -0,0 +1,95 @@ +package com.tiedup.remake.dispenser; + +import com.tiedup.remake.items.base.*; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.v2.BodyRegionV2; +import java.util.function.BiConsumer; +import java.util.function.Predicate; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +/** + * Generic dispenser behavior for equipping bondage items. + * Replaces individual BindDispenseBehavior, GagDispenseBehavior, etc. + * + * Use factory methods to create instances for each bondage type. + */ +public class GenericBondageDispenseBehavior + extends EquipBondageDispenseBehavior +{ + + private final Class itemClass; + private final Predicate canEquipCheck; + private final BiConsumer equipAction; + + private GenericBondageDispenseBehavior( + Class itemClass, + Predicate canEquipCheck, + BiConsumer equipAction + ) { + this.itemClass = itemClass; + this.canEquipCheck = canEquipCheck; + this.equipAction = equipAction; + } + + @Override + protected boolean isValidItem(ItemStack stack) { + return !stack.isEmpty() && itemClass.isInstance(stack.getItem()); + } + + @Override + protected boolean canEquip(IBondageState state) { + return state != null && canEquipCheck.test(state); + } + + @Override + protected void equip(IBondageState state, ItemStack stack) { + if (state != null) { + equipAction.accept(state, stack); + } + } + + // ======================================== + // Factory Methods + // ======================================== + + public static GenericBondageDispenseBehavior forBind() { + return new GenericBondageDispenseBehavior( + ItemBind.class, + state -> !state.isTiedUp(), + (s, i) -> s.equip(BodyRegionV2.ARMS, i) + ); + } + + public static GenericBondageDispenseBehavior forGag() { + return new GenericBondageDispenseBehavior( + ItemGag.class, + state -> !state.isGagged(), + (s, i) -> s.equip(BodyRegionV2.MOUTH, i) + ); + } + + public static GenericBondageDispenseBehavior forBlindfold() { + return new GenericBondageDispenseBehavior( + ItemBlindfold.class, + state -> !state.isBlindfolded(), + (s, i) -> s.equip(BodyRegionV2.EYES, i) + ); + } + + public static GenericBondageDispenseBehavior forCollar() { + return new GenericBondageDispenseBehavior( + ItemCollar.class, + state -> !state.hasCollar(), + (s, i) -> s.equip(BodyRegionV2.NECK, i) + ); + } + + public static GenericBondageDispenseBehavior forEarplugs() { + return new GenericBondageDispenseBehavior( + ItemEarplugs.class, + state -> !state.hasEarplugs(), + (s, i) -> s.equip(BodyRegionV2.EARS, i) + ); + } +} diff --git a/src/main/java/com/tiedup/remake/dispenser/RopeArrowDispenseBehavior.java b/src/main/java/com/tiedup/remake/dispenser/RopeArrowDispenseBehavior.java new file mode 100644 index 0000000..8259961 --- /dev/null +++ b/src/main/java/com/tiedup/remake/dispenser/RopeArrowDispenseBehavior.java @@ -0,0 +1,32 @@ +package com.tiedup.remake.dispenser; + +import com.tiedup.remake.entities.EntityRopeArrow; +import net.minecraft.core.Position; +import net.minecraft.core.dispenser.AbstractProjectileDispenseBehavior; +import net.minecraft.world.entity.projectile.Projectile; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * Dispenser behavior for shooting rope arrows. + * + * Based on original BehaviorDispenserRopeArrow from 1.12.2 + */ +public class RopeArrowDispenseBehavior + extends AbstractProjectileDispenseBehavior +{ + + @Override + protected Projectile getProjectile( + Level level, + Position position, + ItemStack stack + ) { + return new EntityRopeArrow( + level, + position.x(), + position.y(), + position.z() + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/AbstractTiedUpNpc.java b/src/main/java/com/tiedup/remake/entities/AbstractTiedUpNpc.java new file mode 100644 index 0000000..c6da331 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/AbstractTiedUpNpc.java @@ -0,0 +1,1322 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.damsel.components.*; +import com.tiedup.remake.entities.skins.Gender; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.state.IRestrainableEntity; +import com.tiedup.remake.state.ICaptor; +import com.tiedup.remake.util.tasks.ItemTask; +import com.tiedup.remake.util.teleport.Position; +import com.tiedup.remake.util.teleport.TeleportHelper; +import com.tiedup.remake.v2.bondage.IV2BondageEquipment; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2EquipmentHolder; +import dev.kosmx.playerAnim.api.layered.AnimationStack; +import dev.kosmx.playerAnim.api.layered.IAnimation; +import dev.kosmx.playerAnim.impl.IAnimatedPlayer; +import dev.kosmx.playerAnim.impl.animation.AnimationApplier; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.*; +import net.minecraft.world.entity.ai.attributes.AttributeSupplier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * AbstractTiedUpNpc - Base class for all TiedUp! humanoid NPCs. + * + * Phase 3 of anti-spaghetti audit: Extracts shared NPC functionality from EntityDamsel + * so that EntityKidnapper can extend this directly (instead of incorrectly extending EntityDamsel). + * + *

Shared functionality:

+ *
    + *
  • IRestrainable bondage delegation (via DamselBondageManager)
  • + *
  • V2 equipment system (IV2EquipmentHolder)
  • + *
  • Animation system (IAnimatedPlayer via DamselAnimationController)
  • + *
  • Inventory/equipment slots (via DamselInventoryManager)
  • + *
  • Pose state (sitting, kneeling, dog, struggling)
  • + *
  • Name system (getNpcName/setNpcName)
  • + *
  • Gender/slim arms
  • + *
  • Despawn protection
  • + *
  • Leash offset
  • + *
+ * + *

Subclass-specific (NOT in this class):

+ *
    + *
  • Personality system (EntityDamsel)
  • + *
  • Variant/appearance (each subclass has its own)
  • + *
  • AI goals (each subclass registers its own)
  • + *
  • Dialogue handler (EntityDamsel)
  • + *
  • MenuProvider (EntityDamsel)
  • + *
+ * + * @see EntityDamsel + * @see EntityKidnapper + */ +public abstract class AbstractTiedUpNpc + extends PathfinderMob + implements + IRestrainable, + ISkinnedEntity, + IAnimatedPlayer, + com.tiedup.remake.dialogue.IDialogueSpeaker, + IV2EquipmentHolder +{ + + // ======================================== + // DATA SYNC (Client-Server) - Shared across all NPC types + // ======================================== + + /** + * NPC's custom name. + * Synced to client for display. + */ + public static final EntityDataAccessor DATA_DAMSEL_NAME = + SynchedEntityData.defineId( + AbstractTiedUpNpc.class, + EntityDataSerializers.STRING + ); + + /** + * Variant ID (e.g., "classic_1", "guest_fuya_kitty"). + * Synced to client for texture selection. + */ + public static final EntityDataAccessor DATA_VARIANT_ID = + SynchedEntityData.defineId( + AbstractTiedUpNpc.class, + EntityDataSerializers.STRING + ); + + /** + * V2 bondage equipment -- single CompoundTag replacing 7 individual ItemStack accessors. + * Epic 4B: Serialized from internal V2BondageEquipment via copy-on-write pattern. + * Synced to client for rendering (client deserializes in onSyncedDataUpdated). + */ + public static final EntityDataAccessor DATA_V2_EQUIPMENT = + SynchedEntityData.defineId( + AbstractTiedUpNpc.class, + EntityDataSerializers.COMPOUND_TAG + ); + + /** + * Whether this entity uses slim arm model. + * Synced to client for correct model rendering. + */ + public static final EntityDataAccessor DATA_SLIM_ARMS = + SynchedEntityData.defineId( + AbstractTiedUpNpc.class, + EntityDataSerializers.BOOLEAN + ); + + /** + * Gender of the entity (MALE/FEMALE). + * Synced to client for voice, behavior, and model rendering. + */ + public static final EntityDataAccessor DATA_GENDER = + SynchedEntityData.defineId( + AbstractTiedUpNpc.class, + EntityDataSerializers.STRING + ); + + /** + * Is NPC currently in sitting pose? + * Synced to client for animation/rendering. + */ + public static final EntityDataAccessor DATA_SITTING = + SynchedEntityData.defineId( + AbstractTiedUpNpc.class, + EntityDataSerializers.BOOLEAN + ); + + /** + * Is NPC currently in kneeling pose? + * Synced to client for animation/rendering. + */ + public static final EntityDataAccessor DATA_KNEELING = + SynchedEntityData.defineId( + AbstractTiedUpNpc.class, + EntityDataSerializers.BOOLEAN + ); + + /** + * Is NPC currently struggling against restraints? + * Synced to client for struggle animation. + */ + public static final EntityDataAccessor DATA_STRUGGLING = + SynchedEntityData.defineId( + AbstractTiedUpNpc.class, + EntityDataSerializers.BOOLEAN + ); + + /** + * Main hand item. + * Synced to client for rendering and behavior determination. + */ + public static final EntityDataAccessor DATA_MAIN_HAND = + SynchedEntityData.defineId( + AbstractTiedUpNpc.class, + EntityDataSerializers.ITEM_STACK + ); + + // ======================================== + // COMPONENTS (shared across all NPC types) + // ======================================== + + /** + * Manages inventory, equipment, and feeding. + */ + private final DamselInventoryManager inventoryManager; + + /** + * Manages all bondage and captivity mechanics. + */ + private final DamselBondageManager bondageManager; + + /** + * V2 bondage equipment storage (internal, not a Forge capability for entities). + * Epic 4B: Replaces 7 EntityDataAccessor with single CompoundTag sync. + */ + private final com.tiedup.remake.v2.bondage.capability.V2BondageEquipment v2Equipment = + new com.tiedup.remake.v2.bondage.capability.V2BondageEquipment(); + + /** + * Manages all animation-related systems: + * - IAnimatedPlayer implementation + * - Animation stack management + * - Pose management (sitting, kneeling, dog, struggling, trembling) + * - Dog pose rotation smoothing + */ + private DamselAnimationController animationController; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public AbstractTiedUpNpc(EntityType type, Level level) { + super(type, level); + this.setCanPickUpLoot(false); + + // Initialize inventory component + this.inventoryManager = new DamselInventoryManager(this); + + // Initialize bondage component + this.bondageManager = new DamselBondageManager( + this, + createBondageHost() + ); + + // Initialize animation controller component + this.animationController = new DamselAnimationController( + createAnimationHost() + ); + + // Allow NPC to pathfind through doors + if ( + this.getNavigation() instanceof + net.minecraft.world.entity.ai.navigation.GroundPathNavigation groundNav + ) { + groundNav.setCanOpenDoors(true); + groundNav.setCanPassDoors(true); + } + + // Allow pathfinding through decorative blocks (snow, leaves, etc.) + this.setPathfindingMalus( + net.minecraft.world.level.pathfinder.BlockPathTypes.POWDER_SNOW, + 0.0f + ); + this.setPathfindingMalus( + net.minecraft.world.level.pathfinder.BlockPathTypes.LEAVES, + 0.0f + ); + this.setPathfindingMalus( + net.minecraft.world.level.pathfinder.BlockPathTypes.DANGER_POWDER_SNOW, + 0.0f + ); + } + + /** + * Create the bondage host for this NPC type. + * Subclasses MUST override this because the default BondageHost + * requires EntityDamsel-specific methods (personality, dialogue). + * + * NOTE: Called during super() constructor -- subclass fields are NOT initialized yet. + * The host implementation must only store the reference, not access fields. + */ + protected abstract IBondageHost createBondageHost(); + + /** + * Create the animation host for this NPC type. + * Subclasses can override if they need different host behavior. + * Default creates an AnimationHost that delegates to this entity. + */ + protected IAnimationHost createAnimationHost() { + return new com.tiedup.remake.entities.damsel.hosts.AnimationHost(this); + } + + // ======================================== + // ABSTRACT METHODS - Subclasses must implement + // ======================================== + + /** + * Get the skin texture for this entity. + * Each NPC type has its own variant/skin system. + */ + @Override + public abstract ResourceLocation getSkinTexture(); + + /** + * Register AI goals specific to this NPC type. + * Called after all components are initialized. + * Avoids name clash with Mob.registerGoals(). + */ + protected abstract void registerNpcGoals(); + + /** + * Get the speaker type for dialogue routing. + * Each NPC type returns its own SpeakerType. + */ + @Override + public abstract com.tiedup.remake.dialogue.SpeakerType getSpeakerType(); + + // ======================================== + // ENTITY INITIALIZATION + // ======================================== + + @Override + protected void defineSynchedData() { + super.defineSynchedData(); + this.entityData.define(DATA_DAMSEL_NAME, ""); + this.entityData.define(DATA_VARIANT_ID, ""); + this.entityData.define(DATA_V2_EQUIPMENT, new CompoundTag()); + this.entityData.define(DATA_SLIM_ARMS, false); + this.entityData.define(DATA_GENDER, Gender.FEMALE.getSerializedName()); + // Pose states + this.entityData.define(DATA_SITTING, false); + this.entityData.define(DATA_KNEELING, false); + this.entityData.define(DATA_STRUGGLING, false); + // Equipment + this.entityData.define(DATA_MAIN_HAND, ItemStack.EMPTY); + } + + @Override + public void onSyncedDataUpdated(EntityDataAccessor key) { + super.onSyncedDataUpdated(key); + // Epic 4B: Deserialize V2 equipment on client when synced data changes + if (DATA_V2_EQUIPMENT.equals(key)) { + if (this.v2Equipment != null && this.level() != null && this.level().isClientSide) { + CompoundTag tag = this.entityData.get(DATA_V2_EQUIPMENT); + this.v2Equipment.deserializeNBT(tag); + } + } + } + + // ======================================== + // DESPAWN PROTECTION + // ======================================== + + /** + * Prevent NPCs from despawning. + * These are important NPCs that should persist in the world. + * + * @param distanceToClosestPlayer Distance to nearest player + * @return false to never despawn + */ + @Override + public boolean removeWhenFarAway(double distanceToClosestPlayer) { + return false; // Never despawn + } + + /** + * Check if this entity should be saved to disk. + * Always true for NPCs - they are persistent. + * + * @return true to always save + */ + @Override + public boolean isPersistenceRequired() { + return true; // Always save to disk + } + + // ======================================== + // TICK - PERIODIC UPDATES + // ======================================== + + @Override + public void tick() { + // DOG pose transition detection (before super.tick) + animationController.tickAnimationBeforeSuperTick(); + + super.tick(); + + // DOG pose rotation smoothing AFTER super.tick (runs on both client/server) + // Must be before tickAnimationStack() which returns early on client + animationController.tickDogPoseRotationSmoothing(); + + // Animation stack tick (client-side only, returns early if client) + if (animationController.tickAnimationStack()) { + return; // Client-side, animation stack handled + } + + // Server-side only below + + // Restore pending captor reference + bondageManager.restoreCaptorFromUUID(); + + // Subclass-specific server tick behavior + tickSubclass(); + } + + /** + * Override point for subclass-specific tick logic. + * Called every server tick after animation and bondage restoration. + * Default is empty -- subclasses override as needed. + */ + protected void tickSubclass() { + // Default: no-op + } + + // ======================================== + // BEHAVIORAL FLAGS (overridable by subclasses) + // ======================================== + + /** + * Check if this entity can call for help when captured. + * Damsels call for help, kidnappers don't. + * + * @return true if can call for help (default: true) + */ + public boolean canCallForHelp() { + return true; + } + + /** + * Check if this entity can panic. + * Damsels panic, kidnappers don't. + * + * @return true if can panic (default: true) + */ + public boolean canPanic() { + return true; + } + + // ======================================== + // POSE STATE + // ======================================== + + /** + * Check if NPC is currently sitting. + * @return true if in sitting pose + */ + public boolean isSitting() { + return this.entityData.get(DATA_SITTING); + } + + /** + * Set sitting pose state. + * @param sitting true to enter sitting pose + */ + public void setSitting(boolean sitting) { + this.entityData.set(DATA_SITTING, sitting); + // Clear kneeling if sitting + if (sitting) { + this.entityData.set(DATA_KNEELING, false); + } + } + + /** + * Check if NPC is currently kneeling. + * @return true if in kneeling pose + */ + public boolean isKneeling() { + return this.entityData.get(DATA_KNEELING); + } + + /** + * Set kneeling pose state. + * @param kneeling true to enter kneeling pose + */ + public void setKneeling(boolean kneeling) { + this.entityData.set(DATA_KNEELING, kneeling); + // Clear sitting if kneeling + if (kneeling) { + this.entityData.set(DATA_SITTING, false); + } + } + + /** + * Check if NPC is in any pose (sitting or kneeling). + * @return true if in a pose + */ + public boolean isInPose() { + return this.isSitting() || this.isKneeling() || this.isDogPose(); + } + + /** + * Check if NPC is in DOG pose (based on equipped bind). + * @return true if equipped bind has DOG pose type + */ + public boolean isDogPose() { + ItemStack bind = this.getEquipment(BodyRegionV2.ARMS); + if ( + bind.getItem() instanceof + com.tiedup.remake.items.base.ItemBind itemBind + ) { + return ( + itemBind.getPoseType() == + com.tiedup.remake.items.base.PoseType.DOG + ); + } + return false; + } + + /** + * Check if NPC is currently struggling against restraints. + * Used for animation sync. + * @return true if struggling + */ + public boolean isStruggling() { + return this.entityData.get(DATA_STRUGGLING); + } + + /** + * Set struggling state for animation. + * @param struggling true if starting struggle animation + */ + public void setStruggling(boolean struggling) { + this.entityData.set(DATA_STRUGGLING, struggling); + } + + // ======================================== + // NAME SYSTEM + // ======================================== + + /** + * Get NPC's custom name. + */ + public String getNpcName() { + return this.entityData.get(DATA_DAMSEL_NAME); + } + + /** + * Set NPC's custom name. + * Also updates Minecraft's custom name display. + */ + public void setNpcName(String name) { + this.entityData.set(DATA_DAMSEL_NAME, name); + // Make the name visible in-game + this.setCustomName(net.minecraft.network.chat.Component.literal(name)); + this.setCustomNameVisible(true); + } + + /** + * @deprecated Use {@link #getNpcName()} instead. Will be removed in a future version. + */ + @Deprecated(forRemoval = true) + public String getDamselName() { + return getNpcName(); + } + + /** + * @deprecated Use {@link #setNpcName(String)} instead. Will be removed in a future version. + */ + @Deprecated(forRemoval = true) + public void setDamselName(String name) { + setNpcName(name); + } + + // ======================================== + // GENDER / SLIM ARMS + // ======================================== + + /** + * Check if this NPC uses slim arms model. + */ + public boolean hasSlimArms() { + return this.entityData.get(DATA_SLIM_ARMS); + } + + /** + * Set slim arms flag directly. + * Used by subclasses that have their own variant systems. + */ + public void setSlimArms(boolean slimArms) { + this.entityData.set(DATA_SLIM_ARMS, slimArms); + } + + /** + * Set gender. + */ + public void setGender(Gender gender) { + this.entityData.set(DATA_GENDER, gender.getSerializedName()); + } + + /** + * Get gender. + */ + public Gender getGender() { + return Gender.fromName(this.entityData.get(DATA_GENDER)); + } + + // ======================================== + // EQUIPMENT SLOTS (Delegated to DamselInventoryManager) + // ======================================== + + @Override + public ItemStack getItemBySlot(EquipmentSlot slot) { + return this.inventoryManager.getItemBySlot(slot); + } + + @Override + public void setItemSlot(EquipmentSlot slot, ItemStack stack) { + this.verifyEquippedItem(stack); + this.inventoryManager.setItemSlot(slot, stack); + } + + @Override + public Iterable getArmorSlots() { + return this.inventoryManager.getArmorSlots(); + } + + @Override + public ItemStack getMainHandItem() { + return this.inventoryManager.getMainHandItem(); + } + + /** + * Set the main hand item. + * + * @param stack Item to hold + */ + public void setMainHandItem(ItemStack stack) { + this.inventoryManager.setMainHandItem(stack); + } + + // ======================================== + // IRestrainable - BONDAGE STATE QUERIES (Delegated to DamselBondageManager) + // ======================================== + + @Override + public boolean isTiedUp() { + return bondageManager.isTiedUp(); + } + + @Override + public boolean isGagged() { + return bondageManager.isGagged(); + } + + @Override + public boolean isBlindfolded() { + return bondageManager.isBlindfolded(); + } + + @Override + public boolean hasEarplugs() { + return bondageManager.hasEarplugs(); + } + + @Override + public boolean hasCollar() { + return bondageManager.hasCollar(); + } + + /** + * Check if a player is an owner of this NPC's collar. + */ + public boolean isCollarOwner(Player player) { + return bondageManager.isCollarOwner(player); + } + + @Override + public boolean hasClothes() { + return bondageManager.hasClothes(); + } + + @Override + public boolean hasMittens() { + return bondageManager.hasMittens(); + } + + @Override + public boolean isBoundAndGagged() { + return bondageManager.isBoundAndGagged(); + } + + // ======================================== + // V2 Region-Based Equipment Access (Delegated to DamselBondageManager) + // ======================================== + + @Override + public ItemStack getEquipment(BodyRegionV2 region) { + return bondageManager.getEquipment(region); + } + + @Override + public void equip(BodyRegionV2 region, ItemStack stack) { + bondageManager.equip(region, stack); + } + + // ======================================== + // IRestrainable - EQUIPMENT UNEQUIP (Delegated to DamselBondageManager) + // ======================================== + + @Override + public ItemStack unequip(BodyRegionV2 region) { + return bondageManager.unequip(region); + } + + @Override + public ItemStack forceUnequip(BodyRegionV2 region) { + return bondageManager.forceUnequip(region); + } + + // ======================================== + // IRestrainable - ENSLAVEMENT + // ======================================== + + @Override + public boolean isCaptive() { + return this.isLeashed(); + } + + @Override + public boolean isEnslavable() { + if (this.isLeashed()) return false; + return this.isTiedUp(); + } + + @Override + public boolean canBeLeashed(Player player) { + if (this.isLeashed()) return false; + + // Standard check: must be tied up + if (this.isTiedUp()) return true; + + // Exception: collar owner can leash even if not tied + if (this.hasCollar()) { + ItemStack collar = this.getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemCollar collarItem) { + if (collarItem.getOwners(collar).contains(player.getUUID())) { + return true; + } + } + } + + return false; + } + + @Override + public ICaptor getCaptor() { + return bondageManager.getCaptor(); + } + + @Override + public boolean getCapturedBy(ICaptor newCaptor) { + return bondageManager.getCapturedBy(newCaptor); + } + + /** + * Force capture for managed camp operations (dogwalk, extract). + * Bypasses canCapture() PrisonerManager state check. + */ + public boolean forceCapturedBy(ICaptor newCaptor) { + return bondageManager.forceCapturedBy(newCaptor); + } + + @Override + public void free() { + bondageManager.free(); + } + + @Override + public void free(boolean transportState) { + bondageManager.free(transportState); + } + + // ======================================== + // IRestrainable - UTILITY (Delegated to DamselBondageManager) + // ======================================== + + @Override + public UUID getKidnappedUniqueId() { + return bondageManager.getKidnappedUniqueId(); + } + + @Override + public String getKidnappedName() { + return bondageManager.getKidnappedName(); + } + + @Override + public LivingEntity asLivingEntity() { + return bondageManager.asLivingEntity(); + } + + @Override + public void kidnappedDropItem(ItemStack stack) { + bondageManager.kidnappedDropItem(stack); + } + + // ======================================== + // IRestrainable - STATE QUERIES (Advanced) (Delegated to DamselBondageManager) + // ======================================== + + @Override + public boolean canBeTiedUp() { + return bondageManager.canBeTiedUp(); + } + + @Override + public boolean isTiedToPole() { + return bondageManager.isTiedToPole(); + } + + @Override + public boolean tieToClosestPole(int searchRadius) { + return bondageManager.tieToClosestPole(searchRadius); + } + + @Override + public boolean isForSell() { + return bondageManager.isForSell(); + } + + @Override + @Nullable + public ItemTask getSalePrice() { + return bondageManager.getSalePrice(); + } + + @Override + public void putForSale(ItemTask price) { + bondageManager.putForSale(price); + } + + @Override + public void cancelSale() { + bondageManager.cancelSale(); + } + + @Override + public boolean canBeKidnappedByEvents() { + return bondageManager.canBeKidnappedByEvents(); + } + + @Override + public boolean hasLockedCollar() { + return bondageManager.hasLockedCollar(); + } + + @Override + public boolean hasNamedCollar() { + return bondageManager.hasNamedCollar(); + } + + @Override + public boolean hasClothesWithSmallArms() { + return bondageManager.hasClothesWithSmallArms(); + } + + @Override + public boolean hasGaggingEffect() { + ItemStack gag = this.getEquipment(BodyRegionV2.MOUTH); + if (gag.isEmpty()) return false; + return ( + gag.getItem() instanceof + com.tiedup.remake.items.base.IHasGaggingEffect + ); + } + + @Override + public boolean hasBlindingEffect() { + ItemStack blindfold = this.getEquipment(BodyRegionV2.EYES); + if (blindfold.isEmpty()) return false; + return ( + blindfold.getItem() instanceof + com.tiedup.remake.items.base.IHasBlindingEffect + ); + } + + @Override + public boolean hasKnives() { + // NPCs don't have inventories for knives + return false; + } + + // ======================================== + // IRestrainable - REPLACE (Delegated to DamselBondageManager) + // ======================================== + + @Override + public ItemStack replaceEquipment(BodyRegionV2 region, ItemStack newStack, boolean force) { + return bondageManager.replaceEquipment(region, newStack, force); + } + + // ======================================== + // IRestrainable - BULK OPERATIONS (Delegated to DamselBondageManager) + // ======================================== + + @Override + public void applyBondage( + ItemStack bind, + ItemStack gag, + ItemStack blindfold, + ItemStack earplugs, + ItemStack collar, + ItemStack clothes + ) { + bondageManager.applyBondage(bind, gag, blindfold, earplugs, collar, clothes); + } + + @Override + public void untie(boolean drop) { + bondageManager.untie(drop); + } + + @Override + public void dropBondageItems(boolean drop) { + bondageManager.dropBondageItems(drop); + } + + @Override + public void dropBondageItems(boolean drop, boolean dropBind) { + bondageManager.dropBondageItems(drop, dropBind); + } + + @Override + public void dropBondageItems( + boolean drop, + boolean dropBind, + boolean dropGag, + boolean dropBlindfold, + boolean dropEarplugs, + boolean dropCollar, + boolean dropClothes + ) { + bondageManager.dropBondageItems(drop, dropBind, dropGag, dropBlindfold, + dropEarplugs, dropCollar, dropClothes); + } + + @Override + public void dropClothes() { + bondageManager.dropClothes(); + } + + @Override + public int getBondageItemsWhichCanBeRemovedCount() { + return bondageManager.getBondageItemsWhichCanBeRemovedCount(); + } + + // ======================================== + // IRestrainable - PERMISSIONS (Delegated to DamselBondageManager) + // ======================================== + + @Override + public boolean canTakeOffClothes(Player player) { + return bondageManager.canTakeOffClothes(player); + } + + @Override + public boolean canChangeClothes(Player player) { + return bondageManager.canChangeClothes(player); + } + + @Override + public boolean canChangeClothes() { + return bondageManager.canChangeClothes(); + } + + // ======================================== + // IRestrainable - SPECIAL INTERACTIONS (Delegated to DamselBondageManager) + // ======================================== + + @Override + public void tighten(Player tightener) { + bondageManager.tighten(tightener); + } + + @Override + public void applyChloroform(int duration) { + bondageManager.applyChloroform(duration); + } + + @Override + public void shockKidnapped() { + bondageManager.shockKidnapped(); + } + + @Override + public void shockKidnapped(String messageAddon, float damage) { + bondageManager.shockKidnapped(messageAddon, damage); + } + + @Override + public void takeBondageItemBy(IRestrainableEntity taker, int slotIndex) { + bondageManager.takeBondageItemBy(taker, slotIndex); + } + + // ======================================== + // IRestrainable - CAPTIVITY TRANSFER (Delegated to DamselBondageManager) + // ======================================== + + @Override + public void transferCaptivityTo(ICaptor newCaptor) { + bondageManager.transferCaptivityTo(newCaptor); + } + + // ======================================== + // IRestrainable - METADATA + // ======================================== + + @Override + public String getNameFromCollar() { + ItemStack collar = this.getEquipment(BodyRegionV2.NECK); + if (collar.isEmpty()) { + return this.getNpcName(); + } + + if (collar.hasCustomHoverName()) { + return collar.getHoverName().getString(); + } + + return this.getNpcName(); + } + + @Override + public net.minecraft.world.entity.Entity getTransport() { + // NPCs use vanilla leash directly, not a transport entity + return null; + } + + // ======================================== + // IRestrainable - TELEPORT / DEATH + // ======================================== + + @Override + public void teleportToPosition(Position position) { + if (position == null || this.level().isClientSide) return; + TeleportHelper.teleportEntity(this, position); + } + + @Override + public boolean onDeathKidnapped(Level world) { + if (world.isClientSide) return false; + + // Check if we have a collar with cell configured + ItemStack collar = this.getEquipment(BodyRegionV2.NECK); + if (collar.isEmpty()) return false; + + if (!(collar.getItem() instanceof ItemCollar itemCollar)) return false; + + java.util.UUID cellId = itemCollar.getCellId(collar); + if (cellId == null) return false; + + // Get cell position from registry + if ( + !(this.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel) + ) return false; + com.tiedup.remake.cells.CellDataV2 cell = + com.tiedup.remake.cells.CellRegistryV2.get(serverLevel).getCell( + cellId + ); + if (cell == null) return false; + + Position cellPosition = new Position( + cell.getSpawnPoint().above(), + serverLevel.dimension() + ); + + // We have a cell - respawn there instead of dying + + // 1. Free from captivity if captured + if (this.isCaptive()) { + this.free(false); + } + + // 2. Unlock all locked items + unlockAllItems(); + + // 3. Heal to full + this.setHealth(this.getMaxHealth()); + + // 4. Teleport to cell + this.teleportToPosition(cellPosition); + + TiedUpMod.LOGGER.info( + "[AbstractTiedUpNpc] {} respawned at cell instead of dying", + this.getName().getString() + ); + + return true; // Cancel death + } + + /** + * Unlock all locked bondage items on this entity. + * Used before respawning at prison. + * Epic 4B: Reads from V2 regions, syncs once at end after mutations. + */ + private void unlockAllItems() { + com.tiedup.remake.v2.BodyRegionV2[] regions = { + com.tiedup.remake.v2.BodyRegionV2.ARMS, + com.tiedup.remake.v2.BodyRegionV2.MOUTH, + com.tiedup.remake.v2.BodyRegionV2.EYES, + com.tiedup.remake.v2.BodyRegionV2.EARS, + com.tiedup.remake.v2.BodyRegionV2.NECK, + }; + boolean changed = false; + for (com.tiedup.remake.v2.BodyRegionV2 region : regions) { + ItemStack stack = v2Equipment.getInRegion(region); + if ( + !stack.isEmpty() && + stack.getItem() instanceof ILockable lockable && + lockable.isLocked(stack) + ) { + lockable.setLocked(stack, false); + changed = true; + } + } + if (changed) { + syncV2Equipment(); + } + } + + // ======================================== + // IV2EquipmentHolder IMPLEMENTATION (Epic 4B) + // ======================================== + + @Override + public IV2BondageEquipment getV2Equipment() { + return v2Equipment; + } + + /** + * Serialize V2 equipment to the synched entity data. + * Called by DamselBondageManager after every mutation (copy-on-write pattern). + */ + public void syncV2Equipment() { + this.entityData.set(DATA_V2_EQUIPMENT, this.v2Equipment.serializeNBT()); + } + + @Override + public void syncEquipmentToData() { + syncV2Equipment(); + } + + // ======================================== + // SHOCK COLLAR CHECK + // ======================================== + + /** + * Check if this NPC has a shock collar equipped. + * Shock collars activate automatically when fear reaches flee threshold. + * + * @return true if wearing a shock collar + */ + public boolean hasShockCollar() { + ItemStack collar = this.getEquipment(BodyRegionV2.NECK); + if (collar.isEmpty()) return false; + return ( + collar.getItem() instanceof com.tiedup.remake.items.ItemShockCollar + ); + } + + // ======================================== + // BONDAGE SERVICE (delegated to BondageManager) + // ======================================== + + /** + * Check if bondage service is enabled for this NPC. + */ + public boolean isBondageServiceEnabled() { + return bondageManager.isBondageServiceEnabled(); + } + + /** + * Get the custom bondage service message from the collar. + */ + public String getBondageServiceMessage() { + return bondageManager.getBondageServiceMessage(); + } + + // ======================================== + // COMPONENT ACCESSORS + // ======================================== + + /** + * Get inventory manager component. + */ + public DamselInventoryManager getInventoryManager() { + return inventoryManager; + } + + /** + * Get bondage manager component. + */ + public DamselBondageManager getBondageManager() { + return bondageManager; + } + + /** + * Get animation controller component. + */ + public DamselAnimationController getAnimationController() { + return animationController; + } + + // ======================================== + // LEASH ATTACHMENT POINT + // ======================================== + + /** + * Override leash offset to attach at neck level instead of eye level. + * Vanilla default is (0, eyeHeight, bbWidth*0.4) which is too high for humanoids. + */ + @Override + protected net.minecraft.world.phys.Vec3 getLeashOffset() { + // Neck height (~1.3 blocks from feet) + // Slight forward offset to prevent clipping with body + return new net.minecraft.world.phys.Vec3( + 0.0, + 1.3, + this.getBbWidth() * 0.2 + ); + } + + // ======================================== + // IANIMATEDPLAYER INTERFACE (PlayerAnimator) + // ======================================== + + /** + * Get the animation stack for this entity. + * Required by IAnimatedPlayer interface. + * + * @return AnimationStack containing all animation layers + */ + @Override + public AnimationStack getAnimationStack() { + return animationController.getAnimationStack(); + } + + /** + * Get the animation applier for model rendering. + * Required by IAnimatedPlayer interface. + * + * @return AnimationApplier for this entity + */ + @Override + public AnimationApplier playerAnimator_getAnimation() { + return animationController.playerAnimator_getAnimation(); + } + + /** + * Get a stored animation by ID. + * Required by IAnimatedPlayer interface. + * + * @param id Animation identifier + * @return The stored animation, or null if not found + */ + @Override + @Nullable + public IAnimation playerAnimator_getAnimation(ResourceLocation id) { + return animationController.playerAnimator_getAnimation(id); + } + + /** + * Store an animation by ID. + * Required by IAnimatedPlayer interface. + * + * @param id Animation identifier + * @param animation Animation to store, or null to remove + * @return The previously stored animation, or null + */ + @Override + @Nullable + public IAnimation playerAnimator_setAnimation( + ResourceLocation id, + @Nullable IAnimation animation + ) { + return animationController.playerAnimator_setAnimation(id, animation); + } + + // ======================================== + // IDIALOGUESPEAKER PARTIAL IMPLEMENTATION + // ======================================== + + /** + * Get the name displayed in dialogue chat messages. + */ + @Override + public String getDialogueName() { + return getNpcName(); + } + + /** + * Get this speaker as a LivingEntity. + */ + @Override + public LivingEntity asEntity() { + return this; + } + + /** + * Check if dialogue should be processed through gag filter. + */ + @Override + public boolean isDialogueGagged() { + return isGagged(); + } + + // ======================================== + // NBT PERSISTENCE (shared portion) + // ======================================== + + /** + * Save shared NPC data to NBT. + * Subclasses MUST call super.addAdditionalSaveData(tag) then add their own data. + * + * Bondage and inventory are saved here. Damsel-specific data (appearance, personality, + * rewards) is saved by DamselDataSerializer via EntityDamsel.addAdditionalSaveData(). + */ + @Override + public void addAdditionalSaveData(CompoundTag tag) { + super.addAdditionalSaveData(tag); + // Bondage (restraints, captor, sale state) + bondageManager.saveToTag(tag); + // Inventory (NPC inventory, equipment) + inventoryManager.saveToTag(tag); + } + + /** + * Load shared NPC data from NBT. + * Subclasses MUST call super.readAdditionalSaveData(tag) then load their own data. + */ + @Override + public void readAdditionalSaveData(CompoundTag tag) { + super.readAdditionalSaveData(tag); + // Bondage (restraints, captor, sale state) + bondageManager.loadFromTag(tag); + // Inventory (NPC inventory, equipment) + inventoryManager.loadFromTag(tag); + } + + /** + * Check if this NPC is currently in a training session. + * Always returns false -- training sessions have been removed. + * + * @return false + */ + public boolean isInTrainingSession() { + return false; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/BondageServiceHandler.java b/src/main/java/com/tiedup/remake/entities/BondageServiceHandler.java new file mode 100644 index 0000000..fb63f3c --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/BondageServiceHandler.java @@ -0,0 +1,199 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.BindVariant; +import com.tiedup.remake.items.base.GagVariant; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.util.MessageDispatcher; +import com.tiedup.remake.util.teleport.Position; +import com.tiedup.remake.util.time.Timer; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * Handles the bondage service system for TiedUp NPCs. + * When enabled via collar settings, players who attack the NPC + * will be captured and teleported to the configured prison. + * + *

Flow: + *

    + *
  • First hit: Warning message + 10 second timer
  • + *
  • Second hit within timer: Capture and teleport to prison
  • + *
+ * + *

This system is transient - serviceTargets are not persisted. + */ +public class BondageServiceHandler { + + private static final String DEFAULT_MESSAGE = + "Hello! Would you like to be tied up in my owner's prison?"; + private static final int WARNING_TIMER_SECONDS = 10; + + private final AbstractTiedUpNpc npc; + private final Map serviceTargets = new HashMap<>(); + + public BondageServiceHandler(AbstractTiedUpNpc npc) { + this.npc = npc; + } + + /** + * Check if bondage service is enabled for this damsel. + * Requires: collar with bondage service flag ON and cell assigned. + * + * @return true if bondage service is active + */ + public boolean isEnabled() { + if (!npc.hasCollar()) return false; + + ItemStack collar = npc.getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemCollar itemCollar) { + return ( + itemCollar.hasCellAssigned(collar) && + itemCollar.isBondageServiceEnabled(collar) + ); + } + return false; + } + + /** + * Get the custom bondage service message from the collar. + * + * @return Custom message or default message + */ + public String getMessage() { + if (npc.hasCollar()) { + ItemStack collar = npc.getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemCollar itemCollar) { + String message = itemCollar.getServiceSentence(collar); + if (message != null && !message.isEmpty()) { + return message; + } + } + } + return DEFAULT_MESSAGE; + } + + /** + * Handle a player attack on the damsel. + * If bondage service is enabled, intercepts the attack. + * + * @param player The attacking player + * @return true if the attack was intercepted (damage should be cancelled) + */ + public boolean handlePlayerAttack(Player player) { + if (player == null || npc.isTiedUp() || !isEnabled()) { + return false; + } + + UUID playerID = player.getUUID(); + Timer timer = serviceTargets.get(playerID); + + if (timer != null && !timer.isExpired()) { + // SECOND HIT - Capture! + capturePlayer(player); + serviceTargets.remove(playerID); + } else { + // FIRST HIT - Warning + String message = getMessage() + " Hit me again!"; + MessageDispatcher.talkTo(npc, player, message); + serviceTargets.put( + playerID, + new Timer(WARNING_TIMER_SECONDS, npc.level()) + ); + } + + return true; // Attack intercepted + } + + /** + * Capture a player and teleport them to the configured cell. + * + * @param player The player to capture + */ + private void capturePlayer(Player player) { + ItemStack collar = npc.getEquipment(BodyRegionV2.NECK); + if (!(collar.getItem() instanceof ItemCollar itemCollar)) return; + + java.util.UUID cellId = itemCollar.getCellId(collar); + if (cellId == null) return; + + // Get cell position from registry + if ( + !(npc.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel) + ) return; + com.tiedup.remake.cells.CellDataV2 cell = + com.tiedup.remake.cells.CellRegistryV2.get(serverLevel).getCell( + cellId + ); + if (cell == null) return; + + Position cellPosition = new Position( + cell.getSpawnPoint().above(), + serverLevel.dimension() + ); + + // Warn masters if configured + warnOwners(player, itemCollar, collar); + + // Get player's kidnapped state + PlayerBindState state = PlayerBindState.getInstance(player); + if (state != null) { + // Apply bondage + state.equip(BodyRegionV2.ARMS, new ItemStack(ModItems.getBind(BindVariant.ROPES))); + state.equip(BodyRegionV2.MOUTH, new ItemStack(ModItems.getGag(GagVariant.BALL_GAG))); + + // Teleport to cell + state.teleportToPosition(cellPosition); + + // Tie to pole if configured on collar + if (itemCollar.shouldTieToPole(collar)) { + state.tieToClosestPole(3); + } + } + + // Announce capture + MessageDispatcher.talkTo(npc, player, "Welcome to my owner's cell!"); + } + + /** + * Warn collar owners about a capture via bondage service. + * + * @param capturedPlayer The player who was captured + * @param itemCollar The collar item + * @param collarStack The collar ItemStack + */ + private void warnOwners( + Player capturedPlayer, + ItemCollar itemCollar, + ItemStack collarStack + ) { + if (!itemCollar.shouldWarnMasters(collarStack)) { + return; + } + + String message = + npc.getNpcName() + + " captured " + + capturedPlayer.getName().getString() + + " via bondage service!"; + + for (UUID ownerUUID : itemCollar.getOwners(collarStack)) { + Player owner = npc.level().getPlayerByUUID(ownerUUID); + if (owner != null) { + SystemMessageManager.sendChatToPlayer( + owner, + message, + ChatFormatting.GOLD + ); + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/DamselRewardTracker.java b/src/main/java/com/tiedup/remake/entities/DamselRewardTracker.java new file mode 100644 index 0000000..1957333 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/DamselRewardTracker.java @@ -0,0 +1,230 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.EntityDialogueManager; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +/** + * Tracks savior and reward state for EntityDamsel. + * Handles the emerald reward system when players rescue damsels. + * + *

Anti-abuse features: + *

    + *
  • No reward if already rewarded (prevents infinite reward exploit)
  • + *
  • No reward if savior is the one who tied (prevents self-rescue exploit)
  • + *
+ * + *

This system IS persisted to NBT. + */ +public class DamselRewardTracker { + + private final EntityDamsel damsel; + + /** Player who rescued this damsel (won't flee from them) */ + @Nullable + private UUID saviorUUID; + + /** Player who tied this damsel (for anti-abuse checks) */ + @Nullable + private UUID tiedByUUID; + + /** Whether a reward has been given (prevents re-exploitation) */ + private boolean hasGivenReward = false; + + public DamselRewardTracker(EntityDamsel damsel) { + this.damsel = damsel; + } + + // ======================================== + // SAVIOR GETTERS/SETTERS + // ======================================== + + /** + * Set the player who rescued this damsel. + * @param player The savior (or null to clear) + */ + public void setSavior(@Nullable Player player) { + this.saviorUUID = player != null ? player.getUUID() : null; + } + + /** + * Check if the given entity is this damsel's savior. + * @param entity The entity to check + * @return true if this is the savior + */ + public boolean isSavior(Entity entity) { + if (this.saviorUUID == null || entity == null) { + return false; + } + return this.saviorUUID.equals(entity.getUUID()); + } + + /** + * Get the savior's UUID. + * @return The savior UUID or null if no savior + */ + @Nullable + public UUID getSaviorUUID() { + return this.saviorUUID; + } + + // ======================================== + // ANTI-ABUSE TRACKING + // ======================================== + + /** + * Set the player who tied this damsel (for reward anti-abuse). + * @param player The player who tied this damsel (or null to clear) + */ + public void setTiedBy(@Nullable Player player) { + this.tiedByUUID = player != null ? player.getUUID() : null; + } + + /** + * Get the UUID of the player who tied this damsel. + * @return The tiedBy UUID or null + */ + @Nullable + public UUID getTiedByUUID() { + return this.tiedByUUID; + } + + /** + * Check if this damsel has already given a reward. + * @return true if reward already given + */ + public boolean hasGivenReward() { + return this.hasGivenReward; + } + + /** + * Reset the reward state (for respawn/new capture cycle). + */ + public void reset() { + this.hasGivenReward = false; + this.tiedByUUID = null; + } + + // ======================================== + // REWARD LOGIC + // ======================================== + + /** + * Reward the player who rescued this damsel. + * Gives emeralds and marks the player as savior (won't flee from them). + * + * @param savior The player who freed this damsel + */ + public void rewardSavior(Player savior) { + // Anti-abuse: Don't reward if already rewarded + if (this.hasGivenReward) { + TiedUpMod.LOGGER.debug( + "[DamselRewardTracker] {} already gave reward, skipping", + damsel.getName().getString() + ); + // Still set as savior (won't flee from them) + this.setSavior(savior); + return; + } + + // Anti-abuse: Don't reward if savior is the one who tied + if ( + this.tiedByUUID != null && this.tiedByUUID.equals(savior.getUUID()) + ) { + TiedUpMod.LOGGER.debug( + "[DamselRewardTracker] {} was tied by {}, no reward for self-rescue", + damsel.getName().getString(), + savior.getName().getString() + ); + // Still set as savior (won't flee) but no reward + this.setSavior(savior); + this.hasGivenReward = true; // Prevent future reward attempts too + return; + } + + // Mark as rewarded BEFORE giving reward (prevents race conditions) + this.hasGivenReward = true; + + // Mark as savior (damsel won't flee from this player anymore) + this.setSavior(savior); + + // Create reward (1-3 emeralds) + int emeraldCount = 1 + damsel.getRandom().nextInt(3); + ItemStack reward = new ItemStack(Items.EMERALD, emeraldCount); + + // Drop at damsel's location (player can pick it up) + ItemEntity itemEntity = new ItemEntity( + damsel.level(), + damsel.getX(), + damsel.getY() + 0.5, + damsel.getZ(), + reward + ); + + // Give slight velocity toward savior + double dx = savior.getX() - damsel.getX(); + double dz = savior.getZ() - damsel.getZ(); + double dist = Math.sqrt(dx * dx + dz * dz); + if (dist > 0.1) { + itemEntity.setDeltaMovement( + (dx / dist) * 0.2, + 0.2, + (dz / dist) * 0.2 + ); + } + damsel.level().addFreshEntity(itemEntity); + + // Thank the savior via dialogue + damsel.talkToPlayersInRadius( + EntityDialogueManager.DialogueCategory.DAMSEL_FREED, + ModConfig.SERVER.dialogueRadius.get() + ); + + TiedUpMod.LOGGER.info( + "[DamselRewardTracker] {} rewarded {} with {} emeralds", + damsel.getName().getString(), + savior.getName().getString(), + emeraldCount + ); + } + + // ======================================== + // NBT PERSISTENCE + // ======================================== + + /** + * Save reward tracker state to NBT. + * @param tag The tag to save to + */ + public void save(CompoundTag tag) { + if (saviorUUID != null) { + tag.putUUID("SaviorUUID", saviorUUID); + } + if (tiedByUUID != null) { + tag.putUUID("TiedByUUID", tiedByUUID); + } + tag.putBoolean("HasGivenReward", hasGivenReward); + } + + /** + * Load reward tracker state from NBT. + * @param tag The tag to load from + */ + public void load(CompoundTag tag) { + if (tag.contains("SaviorUUID")) { + this.saviorUUID = tag.getUUID("SaviorUUID"); + } + if (tag.contains("TiedByUUID")) { + this.tiedByUUID = tag.getUUID("TiedByUUID"); + } + this.hasGivenReward = tag.getBoolean("HasGivenReward"); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/DamselVariant.java b/src/main/java/com/tiedup/remake/entities/DamselVariant.java new file mode 100644 index 0000000..554831e --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/DamselVariant.java @@ -0,0 +1,79 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.entities.skins.Gender; +import com.tiedup.remake.entities.skins.SkinVariant; +import net.minecraft.resources.ResourceLocation; + +/** + * Represents a damsel skin variant with metadata. + * + * Phase 14.2: Modernized skin system for EntityDamsel + * Simplified: All skins from damsel folder, no guest distinction. + * + * @param id Unique identifier (e.g., "dam_mob_1", "anastasia") + * @param texture Skin texture location + * @param hasSlimArms True for Alex/slim model (3px arms), false for Steve/normal (4px arms) + * @param defaultName Default display name for this variant + * @param gender Gender of the skin (default FEMALE) + */ +public record DamselVariant( + String id, + ResourceLocation texture, + boolean hasSlimArms, + String defaultName, + Gender gender +) implements SkinVariant { + /** + * Create a damsel variant from texture name. + * + * @param textureName Texture file name without extension (e.g., "dam_mob_1", "anastasia") + * @param hasSlimArms True if this variant uses slim arms + * @return Damsel variant + */ + public static DamselVariant create( + String textureName, + boolean hasSlimArms + ) { + return create(textureName, hasSlimArms, "Damsel"); + } + + /** + * Create a damsel variant from texture name with custom display name. + * + * @param textureName Texture file name without extension + * @param hasSlimArms True if this variant uses slim arms + * @param displayName Display name for this variant + * @return Damsel variant + */ + public static DamselVariant create( + String textureName, + boolean hasSlimArms, + String displayName + ) { + return new DamselVariant( + textureName, + ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/damsel/" + textureName + ".png" + ), + hasSlimArms, + displayName, + Gender.FEMALE + ); + } + + @Override + public String toString() { + return ( + "DamselVariant{id='" + + id + + "', slim=" + + hasSlimArms + + ", name='" + + defaultName + + "', gender=" + + gender + + "}" + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/EntityDamsel.java b/src/main/java/com/tiedup/remake/entities/EntityDamsel.java new file mode 100644 index 0000000..e46f2e9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/EntityDamsel.java @@ -0,0 +1,852 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.core.SettingsAccessor; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.damsel.components.*; +import com.tiedup.remake.entities.skins.DamselSkinManager; +import com.tiedup.remake.entities.skins.Gender; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.state.ICaptor; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.ChatFormatting; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.*; +import net.minecraft.world.entity.ai.attributes.AttributeSupplier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * EntityDamsel - Capturable female NPC with full bondage support. + * + * Phase 3 audit: Now extends AbstractTiedUpNpc instead of PathfinderMob. + * All shared NPC functionality (bondage, equipment, animation, pose, name, gender) + * has been extracted to AbstractTiedUpNpc. + * + *

Damsel-specific features (remain here):

+ *
    + *
  • DamselAppearance (variant/skin system)
  • + *
  • DamselPersonalitySystem (personality, mood, training, commands)
  • + *
  • DamselAIController (damsel-specific AI goals)
  • + *
  • DamselDialogueHandler (dialogue cooldowns)
  • + *
  • DamselRewardTracker (savior/reward system)
  • + *
  • MenuProvider (inventory GUI)
  • + *
  • Personality-related IDialogueSpeaker methods
  • + *
+ */ +public class EntityDamsel + extends AbstractTiedUpNpc + implements + net.minecraft.world.MenuProvider +{ + + // ======================================== + // DAMSEL-SPECIFIC DATA SYNC + // ======================================== + + /** + * Personality type name (e.g., "TIMID", "FIERCE"). + * Synced to client for UI display. + */ + public static final EntityDataAccessor DATA_PERSONALITY_TYPE = + SynchedEntityData.defineId( + EntityDamsel.class, + EntityDataSerializers.STRING + ); + + /** + * Current active command (e.g., "FOLLOW", "STAY", "NONE"). + * Synced to client for UI display. + */ + public static final EntityDataAccessor DATA_ACTIVE_COMMAND = + SynchedEntityData.defineId( + EntityDamsel.class, + EntityDataSerializers.STRING + ); + + // ======================================== + // DAMSEL-SPECIFIC COMPONENTS + // ======================================== + + /** + * Manages visual appearance (variant, skin, gender, name, slim arms). + * Phase 1 component extraction. + */ + private final DamselAppearance appearance; + + /** + * Manages all personality-related systems. + * Phase 6: Extracted from EntityDamsel (~700 lines) + */ + private DamselPersonalitySystem personalitySystem; + + /** + * Manages all AI-related systems. + * Phase 5: Extracted from EntityDamsel (~450 lines) + */ + private DamselAIController aiController; + + /** + * Manages all dialogue-related systems. + * Phase 4: Extracted from EntityDamsel (~180 lines) + */ + private DamselDialogueHandler dialogueHandler; + + /** + * Orchestrates NBT serialization for all components. + * Phase 8: Final refactoring phase. + */ + private DamselDataSerializer serializer; + + /** Tracks savior and reward state */ + private final DamselRewardTracker rewardTracker = new DamselRewardTracker(this); + + // ======================================== + // CONSTRUCTOR + // ======================================== + + @Override + protected IBondageHost createBondageHost() { + return new com.tiedup.remake.entities.damsel.hosts.BondageHost(this); + } + + public EntityDamsel(EntityType type, Level level) { + super(type, level); + + // Phase 1: Initialize appearance component + this.appearance = new DamselAppearance(this); + + // Phase 6: Initialize personality component + this.personalitySystem = new DamselPersonalitySystem( + this, + new com.tiedup.remake.entities.damsel.hosts.PersonalityTickContextHost(this) + ); + + // Phase 5: Initialize AI controller component + this.aiController = new DamselAIController( + this, + new com.tiedup.remake.entities.damsel.hosts.AIHost(this) + ); + + // Phase 3: Initialize animation controller component + // (already done in AbstractTiedUpNpc constructor) + + // Phase 4: Initialize dialogue handler component + this.dialogueHandler = new DamselDialogueHandler( + new com.tiedup.remake.entities.damsel.hosts.DialogueHost(this) + ); + + // Phase 8: Initialize data serializer + this.serializer = new DamselDataSerializer(this); + + // CRITICAL: Register AI goals now that aiController is initialized + // (registerGoals() was called by Mob constructor but skipped due to null check) + this.registerGoals(); + } + + // ======================================== + // ENTITY INITIALIZATION + // ======================================== + + @Override + protected void defineSynchedData() { + super.defineSynchedData(); + // Damsel-specific personality data + this.entityData.define(DATA_PERSONALITY_TYPE, "UNKNOWN"); + this.entityData.define(DATA_ACTIVE_COMMAND, "NONE"); + } + + @Override + public void onSyncedDataUpdated(EntityDataAccessor key) { + super.onSyncedDataUpdated(key); + // Phase 1: Invalidate variant cache when variant ID changes (from server sync) + if (DATA_VARIANT_ID.equals(key)) { + this.appearance.invalidateVariantCache(); + } + } + + @Override + public void onAddedToWorld() { + super.onAddedToWorld(); + + // Phase 1: Server-side only: Select deterministic variant based on UUID on spawn + if ( + !this.level().isClientSide && + this.appearance.getVariantId().isEmpty() + ) { + Gender preferredGender = SettingsAccessor.getPreferredSpawnGender( + this.level().getGameRules()); + + DamselVariant variant = DamselSkinManager.CORE.getVariantForEntity( + this.getUUID(), + preferredGender + ); + this.setVariant(variant); + + TiedUpMod.LOGGER.debug( + "[EntityDamsel] Spawned with variant: {}", + variant.id() + ); + } + + // Server-side only: Initialize personality if not already set (new spawn) + if ( + !this.level().isClientSide && + this.personalitySystem.getPersonalityState() == null + ) { + this.personalitySystem.initializePersonality(); + } + } + + /** + * Register all AI goals for this entity. + * Phase 5: Delegated to DamselAIController component. + * + * CRITICAL: This method is called by Mob constructor BEFORE our constructor finishes. + * We must null-check aiController and defer goal registration until after initialization. + */ + @Override + protected void registerGoals() { + if (aiController == null) { + // Called during super() construction, before aiController is initialized + // Goals will be registered manually after component initialization + return; + } + aiController.registerGoals(this.goalSelector, this.targetSelector); + } + + /** + * Not used directly -- registerGoals() delegates to aiController. + * Required by AbstractTiedUpNpc contract. + */ + @Override + protected void registerNpcGoals() { + // Handled by registerGoals() override above + } + + /** + * Create attribute modifiers for EntityDamsel. + * Called during entity type registration. + */ + public static AttributeSupplier.Builder createAttributes() { + return Mob.createMobAttributes() + .add(Attributes.MAX_HEALTH, 20.0) + .add(Attributes.MOVEMENT_SPEED, 0.25) + .add(Attributes.KNOCKBACK_RESISTANCE, 0.7) + .add(Attributes.FOLLOW_RANGE, 60.0); + } + + // ======================================== + // TICK (damsel-specific extensions) + // ======================================== + + /** + * Damsel-specific tick logic. + * Called by AbstractTiedUpNpc.tick() after animation and bondage restoration. + */ + @Override + protected void tickSubclass() { + // Phase 5: Call for help when being led as captive + aiController.tickCallForHelp(); + + // Phase 5: Leash traction system (teleport if stuck) + aiController.tickLeashTraction(); + + // Phase 6: Personality system tick (needs decay, mood updates) + personalitySystem.tickPersonality(); + + // Phase 6: Idle dialogue system + personalitySystem.tickIdleDialogue(); + + // Phase 6: Approach detection (player enters radius) + personalitySystem.tickApproachDetection(); + + // Phase 6: Environmental dialogue (weather, time) + personalitySystem.tickEnvironmentDialogue(); + } + + // ======================================== + // VARIANT SYSTEM (Delegated to DamselAppearance) + // ======================================== + + @Nullable + public DamselVariant getVariant() { + return this.appearance.getVariant(); + } + + public void setVariant(DamselVariant variant) { + this.appearance.setVariant(variant); + } + + public String getVariantId() { + return this.appearance.getVariantId(); + } + + // ======================================== + // SKIN TEXTURE (ISkinnedEntity implementation) + // ======================================== + + @Override + public ResourceLocation getSkinTexture() { + return this.appearance.getSkinTexture(); + } + + // ======================================== + // NAME SYSTEM OVERRIDE + // (DamselAppearance also sets custom name visible, delegate to it) + // ======================================== + + @Override + public String getNpcName() { + return this.appearance.getNpcName(); + } + + @Override + public void setNpcName(String name) { + this.appearance.setNpcName(name); + } + + // ======================================== + // GENDER/SLIMARMS OVERRIDE + // (DamselAppearance manages these for damsels) + // ======================================== + + @Override + public boolean hasSlimArms() { + return this.appearance.hasSlimArms(); + } + + @Override + public void setGender(Gender gender) { + this.appearance.setGender(gender); + } + + @Override + public Gender getGender() { + return this.appearance.getGender(); + } + + // ======================================== + // COMPONENT ACCESSORS + // ======================================== + + public DamselAppearance getAppearance() { + return appearance; + } + + public DamselPersonalitySystem getPersonalitySystem() { + return personalitySystem; + } + + public DamselRewardTracker getRewardTracker() { + return rewardTracker; + } + + // ======================================== + // NBT PERSISTENCE (damsel-specific) + // ======================================== + + @Override + public void addAdditionalSaveData(CompoundTag tag) { + super.addAdditionalSaveData(tag); + serializer.save(tag); + } + + @Override + public void readAdditionalSaveData(CompoundTag tag) { + super.readAdditionalSaveData(tag); + serializer.load(tag); + } + + // ======================================== + // BONDAGE SERVICE OVERRIDES + // ======================================== + + /** + * Override hurt to intercept player attacks for bondage service. + */ + @Override + public boolean hurt(DamageSource source, float amount) { + if ( + !this.level().isClientSide && + getBondageManager().handleDamageWithService(source, amount) + ) { + return false; + } + return super.hurt(source, amount); + } + + /** + * Override display name to show violet color when bondage service is active. + */ + @Override + public Component getDisplayName() { + if (getBondageManager().isBondageServiceEnabled()) { + return Component.literal(this.getNpcName()).withStyle( + ChatFormatting.LIGHT_PURPLE + ); + } + return super.getDisplayName(); + } + + // ======================================== + // DIALOGUE SYSTEM + // ======================================== + + public void talkTo(Player player, String message) { + com.tiedup.remake.dialogue.EntityDialogueManager.talkTo( + this, player, message + ); + } + + public void talkTo( + Player player, + com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory category + ) { + com.tiedup.remake.dialogue.EntityDialogueManager.talkTo( + this, player, category + ); + } + + public void actionTo(Player player, String action) { + com.tiedup.remake.dialogue.EntityDialogueManager.actionTo( + this, player, action + ); + } + + public void actionTo( + Player player, + com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory category + ) { + com.tiedup.remake.dialogue.EntityDialogueManager.actionTo( + this, player, category + ); + } + + public void talkToPlayersInRadius(String message, int radius) { + com.tiedup.remake.dialogue.EntityDialogueManager.talkToNearby( + this, message, radius + ); + } + + public void talkToPlayersInRadius( + com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory category, + int radius + ) { + com.tiedup.remake.dialogue.EntityDialogueManager.talkToNearby( + this, category, radius + ); + } + + public boolean talkToPlayersInRadiusWithCooldown( + com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory category, + int radius + ) { + return dialogueHandler.talkToPlayersInRadiusWithCooldown(category, radius); + } + + public void actionToPlayersInRadius(String action, int radius) { + com.tiedup.remake.dialogue.EntityDialogueManager.actionToNearby( + this, action, radius + ); + } + + public void actionToPlayersInRadius( + com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory category, + int radius + ) { + com.tiedup.remake.dialogue.EntityDialogueManager.actionToNearby( + this, category, radius + ); + } + + // ======================================== + // SAVIOR & REWARD SYSTEM + // ======================================== + + public void setSavior(@Nullable Player player) { + rewardTracker.setSavior(player); + } + + public boolean isSavior(Entity entity) { + return rewardTracker.isSavior(entity); + } + + @Nullable + public UUID getSaviorUUID() { + return rewardTracker.getSaviorUUID(); + } + + public void setTiedBy(@Nullable Player player) { + rewardTracker.setTiedBy(player); + } + + @Nullable + public UUID getTiedByUUID() { + return rewardTracker.getTiedByUUID(); + } + + public boolean hasGivenReward() { + return rewardTracker.hasGivenReward(); + } + + public void resetRewardState() { + rewardTracker.reset(); + } + + public void rewardSavior(Player savior) { + rewardTracker.rewardSavior(savior); + } + + // ======================================== + // PERSONALITY SYSTEM ACCESS + // ======================================== + + @Nullable + public com.tiedup.remake.personality.PersonalityState getPersonalityState() { + if ( + this.personalitySystem.getPersonalityState() == null && + !this.level().isClientSide + ) { + personalitySystem.initializePersonality(); + } + return this.personalitySystem.getPersonalityState(); + } + + public com.tiedup.remake.personality.PersonalityType getPersonalityType() { + return personalitySystem.getPersonalityType(); + } + + public void setPersonalityType( + com.tiedup.remake.personality.PersonalityType newType + ) { + personalitySystem.setPersonalityType(newType); + } + + public com.tiedup.remake.personality.NpcCommand getActiveCommand() { + return personalitySystem.getActiveCommand(); + } + + // ======================================== + // ANTI-FLEE SYSTEM + // ======================================== + + public long getLastWhipTime() { + return personalitySystem.getLastWhipTime(); + } + + public void setLastWhipTime(long time) { + personalitySystem.setLastWhipTime(time); + } + + // ======================================== + // COMMAND SYSTEM + // ======================================== + + public boolean giveCommand( + Player commander, + com.tiedup.remake.personality.NpcCommand command, + @Nullable net.minecraft.core.BlockPos targetPos + ) { + if (!this.hasCollar()) return false; + + ItemStack collar = this.getEquipment(BodyRegionV2.NECK); + if (!(collar.getItem() instanceof ItemCollar collarItem)) return false; + if (!collarItem.getOwners(collar).contains(commander.getUUID())) { + if (!this.isGagged()) { + com.tiedup.remake.dialogue.EntityDialogueManager.talkByDialogueId( + this, commander, "command.reject.not_master" + ); + } + return false; + } + + if (this.personalitySystem.getPersonalityState() != null) { + if ( + !this.personalitySystem.getPersonalityState().willObeyCommand( + commander, command + ) + ) { + return false; + } + + this.personalitySystem.getPersonalityState().setActiveCommand( + command, commander.getUUID(), targetPos + ); + personalitySystem.syncPersonalityData(); + return true; + } + return false; + } + + public boolean giveCommandWithTwoTargets( + Player commander, + com.tiedup.remake.personality.NpcCommand command, + @Nullable net.minecraft.core.BlockPos targetPos, + @Nullable net.minecraft.core.BlockPos targetPos2 + ) { + boolean success = giveCommand(commander, command, targetPos); + if ( + success && + targetPos2 != null && + this.personalitySystem.getPersonalityState() != null + ) { + this.personalitySystem.getPersonalityState().setCommandTarget2(targetPos2); + personalitySystem.syncPersonalityData(); + } + return success; + } + + public void cancelCommand() { + if (this.personalitySystem.getPersonalityState() != null) { + this.personalitySystem.getPersonalityState().clearCommand(); + personalitySystem.syncPersonalityData(); + } + } + + // ======================================== + // NPC INVENTORY ACCESS + // ======================================== + + public net.minecraft.core.NonNullList getNpcInventory() { + return this.getInventoryManager().getNpcInventory(); + } + + public int getNpcInventorySize() { + return this.getInventoryManager().getNpcInventorySize(); + } + + public void setNpcInventorySize(int newSize) { + this.getInventoryManager().setNpcInventorySize(newSize); + } + + public boolean hasEdibleInInventory() { + return this.getInventoryManager().hasEdibleInInventory(); + } + + public boolean tryEatFromInventory() { + return this.getInventoryManager().tryEatFromInventory( + this.personalitySystem.getPersonalityState() + ); + } + + public boolean tryEatFromChest(net.minecraft.core.BlockPos chestPos) { + return this.getInventoryManager().tryEatFromChest( + chestPos, + this.personalitySystem.getPersonalityState() + ); + } + + // ======================================== + // DIRECT FEEDING SYSTEM + // ======================================== + + public boolean feedByPlayer(Player player, ItemStack foodStack) { + return this.getInventoryManager().feedByPlayer( + player, foodStack, + this.personalitySystem.getPersonalityState() + ); + } + + // ======================================== + // INTERACTION + // ======================================== + + @Override + protected net.minecraft.world.InteractionResult mobInteract( + Player player, + net.minecraft.world.InteractionHand hand + ) { + ItemStack heldItem = player.getItemInHand(hand); + + // Check if holding edible item + if (heldItem.getItem().isEdible()) { + if (!this.hasCollar()) { + if (player instanceof net.minecraft.server.level.ServerPlayer sp) { + sp.displayClientMessage( + Component.literal( + "This NPC needs a collar before you can feed them." + ).withStyle(ChatFormatting.RED), + true + ); + } + return net.minecraft.world.InteractionResult.FAIL; + } + ItemStack collar = this.getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemCollar collarItem) { + if (!collarItem.isOwner(collar, player)) { + if (player instanceof net.minecraft.server.level.ServerPlayer sp) { + sp.displayClientMessage( + Component.literal("You don't own this NPC's collar.") + .withStyle(ChatFormatting.RED), + true + ); + } + return net.minecraft.world.InteractionResult.FAIL; + } + if (this.feedByPlayer(player, heldItem)) { + return net.minecraft.world.InteractionResult.SUCCESS; + } + if (player instanceof net.minecraft.server.level.ServerPlayer sp) { + sp.displayClientMessage( + Component.literal("This NPC can't eat that right now.") + .withStyle(ChatFormatting.RED), + true + ); + } + return net.minecraft.world.InteractionResult.FAIL; + } + } + + // Shift + empty hand on collared NPC = open conversation + if ( + !this.level().isClientSide() && + player.isShiftKeyDown() && + heldItem.isEmpty() && + this.hasCollar() + ) { + ItemStack collar = this.getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemCollar collarItem) { + if ( + collarItem.isOwner(collar, player) && + player instanceof net.minecraft.server.level.ServerPlayer serverPlayer + ) { + if ( + com.tiedup.remake.dialogue.conversation.ConversationManager.openConversation( + this, serverPlayer + ) + ) { + return net.minecraft.world.InteractionResult.SUCCESS; + } + } + } + } + + // Leash detach: if player right-clicks their leashed damsel, detach leash + if ( + !this.level().isClientSide() && + this.isLeashed() && + this.getLeashHolder() == player + ) { + this.dropLeash(true, !player.getAbilities().instabuild); + + ICaptor currentCaptor = getBondageManager().getCaptor(); + if (currentCaptor != null) { + currentCaptor.removeCaptive(this, false); + getBondageManager().clearCaptor(); + } + + return net.minecraft.world.InteractionResult.SUCCESS; + } + + return super.mobInteract(player, hand); + } + + // ======================================== + // MENU PROVIDER + // ======================================== + + @Override + @javax.annotation.Nullable + public net.minecraft.world.inventory.AbstractContainerMenu createMenu( + int containerId, + net.minecraft.world.entity.player.Inventory playerInventory, + Player player + ) { + return this.getInventoryManager().createMenu( + containerId, playerInventory, player + ); + } + + // ======================================== + // LIFECYCLE - DEATH + // ======================================== + + @Override + public void die(DamageSource damageSource) { + if ( + !this.level().isClientSide && + this.level() instanceof net.minecraft.server.level.ServerLevel serverLevel + ) { + UUID uuid = this.getUUID(); + + com.tiedup.remake.cells.CellRegistryV2 cellRegistry = + com.tiedup.remake.cells.CellRegistryV2.get(serverLevel); + cellRegistry.releasePrisonerFromAllCells(uuid); + + com.tiedup.remake.prison.PrisonerManager manager = + com.tiedup.remake.prison.PrisonerManager.get(serverLevel); + com.tiedup.remake.prison.PrisonerState state = manager.getState(uuid); + + if ( + state == com.tiedup.remake.prison.PrisonerState.IMPRISONED || + state == com.tiedup.remake.prison.PrisonerState.WORKING + ) { + com.tiedup.remake.prison.service.PrisonerService.get().escape( + serverLevel, uuid, "player_death" + ); + } + + TiedUpMod.LOGGER.debug( + "[EntityDamsel] {} died, cleaned up registries", + getNpcName() + ); + } + super.die(damageSource); + } + + // ======================================== + // IDIALOGUESPEAKER - Personality-specific methods + // ======================================== + + @Override + public com.tiedup.remake.dialogue.SpeakerType getSpeakerType() { + return com.tiedup.remake.dialogue.SpeakerType.DAMSEL; + } + + @Override + @Nullable + public com.tiedup.remake.personality.PersonalityType getSpeakerPersonality() { + if ( + personalitySystem != null && + personalitySystem.getPersonalityState() != null + ) { + return personalitySystem.getPersonalityState().getPersonality(); + } + return null; + } + + @Override + public int getSpeakerMood() { + if ( + personalitySystem != null && + personalitySystem.getPersonalityState() != null + ) { + return (int) personalitySystem.getPersonalityState().getMood(); + } + return 50; + } + + @Override + @Nullable + public String getTargetRelation(Player player) { + if (hasCollar()) { + ItemStack collar = getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemCollar collarItem) { + if (collarItem.isOwner(collar, player)) { + return "master"; + } + } + } + return null; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/EntityDamselShiny.java b/src/main/java/com/tiedup/remake/entities/EntityDamselShiny.java new file mode 100644 index 0000000..1f579dd --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/EntityDamselShiny.java @@ -0,0 +1,314 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.core.SettingsAccessor; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.skins.Gender; +import com.tiedup.remake.entities.skins.ShinyDamselSkinManager; +import javax.annotation.Nullable; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.ai.attributes.AttributeSupplier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.level.Level; + +/** + * Shiny Damsel - Rare, faster damsel variant. + * + * Phase 1: Data-Driven Skin System - Shiny Damsels + * + * Differences from regular EntityDamsel: + * - Much faster movement speed (0.35 vs 0.25) + * - Rare spawns + * - Golden/shimmering name color + * - Dedicated shiny skins + * - Special sparkle particles + */ +public class EntityDamselShiny extends EntityDamsel { + + // ======================================== + // DATA SYNC + // ======================================== + + /** + * Shiny variant name (for skin selection). + */ + private static final EntityDataAccessor DATA_SHINY_NAME = + SynchedEntityData.defineId( + EntityDamselShiny.class, + EntityDataSerializers.STRING + ); + + // ======================================== + // STATE + // ======================================== + + @Nullable + private DamselVariant shinyVariant; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public EntityDamselShiny( + EntityType type, + Level level + ) { + super(type, level); + } + + // ======================================== + // ATTRIBUTES + // ======================================== + + /** + * Create shiny damsel attributes. + * Much faster than regular damsels. + */ + public static AttributeSupplier.Builder createAttributes() { + return Mob.createMobAttributes() + .add(Attributes.MAX_HEALTH, 20.0) // Same health + .add(Attributes.MOVEMENT_SPEED, 0.35) // Much faster (0.35 vs 0.25) + .add(Attributes.KNOCKBACK_RESISTANCE, 0.1); + } + + // ======================================== + // DATA SYNC + // ======================================== + + @Override + protected void defineSynchedData() { + super.defineSynchedData(); + this.entityData.define(DATA_SHINY_NAME, ""); + } + + @Override + public void onSyncedDataUpdated(EntityDataAccessor key) { + super.onSyncedDataUpdated(key); + // Invalidate shiny variant cache when synced from server + if (DATA_SHINY_NAME.equals(key)) { + this.shinyVariant = null; + } + } + + // ======================================== + // INITIALIZATION + // ======================================== + + @Override + public void onAddedToWorld() { + super.onAddedToWorld(); + + // Select deterministic shiny variant based on UUID if not set + if ( + !this.level().isClientSide && this.getShinyVariantName().isEmpty() + ) { + Gender preferredGender = SettingsAccessor.getPreferredSpawnGender( + this.level().getGameRules()); + + DamselVariant variant = + ShinyDamselSkinManager.CORE.getVariantForEntity( + this.getUUID(), + preferredGender + ); + this.setShinyVariant(variant); + + TiedUpMod.LOGGER.info( + "[EntityDamselShiny] Spawned shiny: {}", + variant.defaultName() + ); + } + } + + // ======================================== + // SHINY VARIANT + // ======================================== + + /** + * Get current shiny variant. + */ + @Nullable + public DamselVariant getShinyVariant() { + // Try to load from synced variant name first + if ( + this.shinyVariant == null && !this.getShinyVariantName().isEmpty() + ) { + this.shinyVariant = ShinyDamselSkinManager.CORE.getVariant( + this.getShinyVariantName() + ); + } + // Fallback: compute from UUID if not yet synced (client-side race condition fix) + if (this.shinyVariant == null) { + this.shinyVariant = ShinyDamselSkinManager.CORE.getVariantForEntity( + this.getUUID() + ); + } + return this.shinyVariant; + } + + /** + * Set shiny variant. + */ + public void setShinyVariant(DamselVariant variant) { + if (variant == null) return; + + this.shinyVariant = variant; + // Bug fix: Store variant.id() for skin lookup, not defaultName() + this.entityData.set(DATA_SHINY_NAME, variant.id()); + this.setNpcName(variant.defaultName()); + this.setSlimArms(variant.hasSlimArms()); + } + + /** + * Get shiny variant name (synced data). + */ + public String getShinyVariantName() { + return this.entityData.get(DATA_SHINY_NAME); + } + + /** + * Get texture for this shiny damsel. + * Overrides damsel skin system. + */ + public ResourceLocation getShinySkin() { + DamselVariant variant = this.getShinyVariant(); + if (variant != null) { + return variant.texture(); + } + // Fallback to hardcoded default (don't use getRandomVariant() - causes skin flashing!) + return ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/damsel/shiny/ellen.png" + ); + } + + /** + * Check if this shiny damsel has slim arms. + * Uses parent's synced entityData implementation. + */ + @Override + public boolean hasSlimArms() { + return super.hasSlimArms(); + } + + /** + * Get the skin texture for this shiny damsel. + * Computes texture path from variant ID - no local skin JSONs needed on client. + */ + @Override + public ResourceLocation getSkinTexture() { + String variantId = this.getShinyVariantName(); + if (!variantId.isEmpty()) { + return ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/damsel/shiny/" + variantId + ".png" + ); + } + // Fallback + return ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/damsel/shiny/ellen.png" + ); + } + + // ======================================== + // DISPLAY + // ======================================== + + @Override + public Component getDisplayName() { + // Rainbow name: each character gets a different hue + String name = this.getNpcName(); + net.minecraft.network.chat.MutableComponent result = Component.literal( + "" + ); + for (int i = 0; i < name.length(); i++) { + float hue = (float) i / name.length(); + int rgb = java.awt.Color.HSBtoRGB(hue, 0.9F, 1.0F) & 0xFFFFFF; + result.append( + Component.literal(String.valueOf(name.charAt(i))).withStyle( + Style.EMPTY.withColor(rgb) + ) + ); + } + return result; + } + + // ======================================== + // OVERRIDES + // ======================================== + + /** + * Shiny damsels don't use the standard variant system. + * They have their own shiny skin system. + */ + @Override + public void setVariant(DamselVariant variant) { + // Don't call super - shiny use their own skin system + // Name is set in setShinyVariant() + } + + // ======================================== + // NBT PERSISTENCE + // ======================================== + + @Override + public void addAdditionalSaveData(net.minecraft.nbt.CompoundTag tag) { + super.addAdditionalSaveData(tag); + + // Save shiny variant name + if (!this.getShinyVariantName().isEmpty()) { + tag.putString("ShinyVariantName", this.getShinyVariantName()); + } + } + + @Override + public void readAdditionalSaveData(net.minecraft.nbt.CompoundTag tag) { + super.readAdditionalSaveData(tag); + + // Restore shiny variant name + if (tag.contains("ShinyVariantName")) { + String variantName = tag.getString("ShinyVariantName"); + this.entityData.set(DATA_SHINY_NAME, variantName); + // Clear cached variant so it gets reloaded + this.shinyVariant = null; + } + } + + // ======================================== + // PARTICLE EFFECTS + // ======================================== + + @Override + public void tick() { + super.tick(); + + // Spawn rainbow sparkle particles (client-side only) + if (level().isClientSide && this.random.nextFloat() < 0.2F) { + double x = this.getX() + (this.random.nextDouble() - 0.5) * 0.6; + double y = this.getY() + this.random.nextDouble() * 1.8; + double z = this.getZ() + (this.random.nextDouble() - 0.5) * 0.6; + + // Rainbow cycling: hue rotates over time, each particle gets a unique color + float hue = + ((this.tickCount * 3 + this.random.nextInt(60)) % 360) / 360.0F; + int rgb = java.awt.Color.HSBtoRGB(hue, 0.9F, 1.0F); + float r = ((rgb >> 16) & 0xFF) / 255.0F; + float g = ((rgb >> 8) & 0xFF) / 255.0F; + float b = (rgb & 0xFF) / 255.0F; + + net.minecraft.core.particles.DustParticleOptions rainbowDust = + new net.minecraft.core.particles.DustParticleOptions( + new org.joml.Vector3f(r, g, b), + 0.8F + ); + + this.level().addParticle(rainbowDust, x, y, z, 0.0, 0.02, 0.0); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/EntityKidnapBomb.java b/src/main/java/com/tiedup/remake/entities/EntityKidnapBomb.java new file mode 100644 index 0000000..f77d86f --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/EntityKidnapBomb.java @@ -0,0 +1,209 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.blocks.entity.KidnapBombBlockEntity; +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.util.KidnapExplosion; +import com.tiedup.remake.core.SettingsAccessor; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.item.PrimedTnt; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * Kidnap Bomb Entity - Primed TNT that applies bondage on explosion. + * + * Phase 16: Blocks + * + * When the fuse runs out, instead of a destructive explosion, + * it applies stored bondage items to all entities in radius. + * + * Based on original EntityKidnapBomb from 1.12.2 + */ +public class EntityKidnapBomb extends PrimedTnt { + + // Stored bondage items + private ItemStack bind = ItemStack.EMPTY; + private ItemStack gag = ItemStack.EMPTY; + private ItemStack blindfold = ItemStack.EMPTY; + private ItemStack earplugs = ItemStack.EMPTY; + private ItemStack collar = ItemStack.EMPTY; + private ItemStack clothes = ItemStack.EMPTY; + + public EntityKidnapBomb( + EntityType type, + Level level + ) { + super(type, level); + } + + /** + * Create from a placed bomb block. + */ + public EntityKidnapBomb( + Level level, + double x, + double y, + double z, + @Nullable LivingEntity igniter, + @Nullable KidnapBombBlockEntity bombTile + ) { + super(ModEntities.KIDNAP_BOMB_ENTITY.get(), level); + this.setPos(x, y, z); + double d0 = level.random.nextDouble() * (Math.PI * 2); + this.setDeltaMovement(-Math.sin(d0) * 0.02, 0.2, -Math.cos(d0) * 0.02); + this.setFuse(ModConfig.SERVER.kidnapBombFuse.get()); + + this.xo = x; + this.yo = y; + this.zo = z; + + // Copy bondage items from tile entity + if (bombTile != null) { + this.bind = bombTile.getBind().copy(); + this.gag = bombTile.getGag().copy(); + this.blindfold = bombTile.getBlindfold().copy(); + this.earplugs = bombTile.getEarplugs().copy(); + this.collar = bombTile.getCollar().copy(); + this.clothes = bombTile.getClothes().copy(); + } + } + + @Override + public void tick() { + // Store previous position + this.xo = this.getX(); + this.yo = this.getY(); + this.zo = this.getZ(); + + // Apply gravity + if (!this.isNoGravity()) { + this.setDeltaMovement(this.getDeltaMovement().add(0, -0.04, 0)); + } + + // Move + this.move(MoverType.SELF, this.getDeltaMovement()); + + // Apply friction + this.setDeltaMovement(this.getDeltaMovement().scale(0.98)); + + if (this.onGround()) { + this.setDeltaMovement( + this.getDeltaMovement().multiply(0.7, -0.5, 0.7) + ); + } + + // Handle fuse + int fuse = this.getFuse() - 1; + this.setFuse(fuse); + + if (fuse <= 0) { + this.discard(); + if (!this.level().isClientSide) { + this.explode(); + } + } else { + // Spawn smoke particles (client-side only) + this.updateInWaterStateAndDoFluidPushing(); + if (this.level().isClientSide) { + this.level().addParticle( + ParticleTypes.SMOKE, + this.getX(), + this.getY() + 0.5, + this.getZ(), + 0, + 0, + 0 + ); + } + } + } + + /** + * Perform the kidnap explosion (no block damage). + */ + protected void explode() { + int radius = getExplosionRadius(); + BlockPos pos = this.blockPosition(); + + KidnapExplosion explosion = new KidnapExplosion( + this.level(), + pos, + radius, + bind, + gag, + blindfold, + earplugs, + collar, + clothes + ); + explosion.explode(); + } + + /** + * Get the explosion radius from game rules. + */ + private int getExplosionRadius() { + // Default radius of 5 blocks + return SettingsAccessor.getKidnapBombRadius( + this.level().getGameRules() + ); + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + @Override + protected void addAdditionalSaveData(CompoundTag tag) { + super.addAdditionalSaveData(tag); + + if (!bind.isEmpty()) { + tag.put("bind", bind.save(new CompoundTag())); + } + if (!gag.isEmpty()) { + tag.put("gag", gag.save(new CompoundTag())); + } + if (!blindfold.isEmpty()) { + tag.put("blindfold", blindfold.save(new CompoundTag())); + } + if (!earplugs.isEmpty()) { + tag.put("earplugs", earplugs.save(new CompoundTag())); + } + if (!collar.isEmpty()) { + tag.put("collar", collar.save(new CompoundTag())); + } + if (!clothes.isEmpty()) { + tag.put("clothes", clothes.save(new CompoundTag())); + } + } + + @Override + protected void readAdditionalSaveData(CompoundTag tag) { + super.readAdditionalSaveData(tag); + + if (tag.contains("bind")) { + bind = ItemStack.of(tag.getCompound("bind")); + } + if (tag.contains("gag")) { + gag = ItemStack.of(tag.getCompound("gag")); + } + if (tag.contains("blindfold")) { + blindfold = ItemStack.of(tag.getCompound("blindfold")); + } + if (tag.contains("earplugs")) { + earplugs = ItemStack.of(tag.getCompound("earplugs")); + } + if (tag.contains("collar")) { + collar = ItemStack.of(tag.getCompound("collar")); + } + if (tag.contains("clothes")) { + clothes = ItemStack.of(tag.getCompound("clothes")); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/EntityKidnapper.java b/src/main/java/com/tiedup/remake/entities/EntityKidnapper.java new file mode 100644 index 0000000..d4f2fc1 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/EntityKidnapper.java @@ -0,0 +1,2077 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.SettingsAccessor; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.IDialogueSpeaker; +import com.tiedup.remake.dialogue.KidnapperDialogueTriggerSystem; +import com.tiedup.remake.dialogue.SpeakerType; +import com.tiedup.remake.entities.ai.kidnapper.*; +import com.tiedup.remake.entities.kidnapper.components.KidnapperAggressionSystem; +import com.tiedup.remake.entities.skins.Gender; +import com.tiedup.remake.entities.skins.KidnapperSkinManager; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.personality.PersonalityType; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.state.ICaptor; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.util.tasks.ItemTask; +import com.tiedup.remake.util.tasks.SaleLoader; +import com.tiedup.remake.util.teleport.Position; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.network.chat.Component; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.*; +import net.minecraft.world.entity.ai.attributes.AttributeSupplier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.ai.goal.*; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; + +/** + * EntityKidnapper - Aggressive NPC that captures and enslaves players. + * + * Phase 14.3: Modern implementation for 1.20.1 + * Based on original EntityKidnapper.java (~700 lines) + * + *

Key Differences from EntityDamsel:

+ *
    + *
  • Implements ICaptor - can enslave players
  • + *
  • Aggressive AI - hunts and captures players
  • + *
  • Doesn't panic or flee
  • + *
  • Can hold bind/gag items
  • + *
+ * + *

Features:

+ *
    + *
  • Target detection and pursuit
  • + *
  • Capture mechanics (tie up + gag)
  • + *
  • Slave management via ICaptor
  • + *
  • Prison teleportation (via collar config)
  • + *
  • Job system (shock collar + tasks)
  • + *
  • Sale system (multiplayer)
  • + *
+ * + *

ARCHITECTURE NOTE - Inherited Components (from AbstractTiedUpNpc):

+ *
    + *
  • DamselBondageManager (via AbstractTiedUpNpc) - bondage/restraint mechanics
  • + *
  • DamselInventoryManager (via AbstractTiedUpNpc) - equipment slots
  • + *
  • DamselAnimationController (via AbstractTiedUpNpc) - animation/poses
  • + *
  • V2BondageEquipment (via AbstractTiedUpNpc) - V2 bondage equipment
  • + *
+ * Kidnapper-specific components (KidnapperAppearance, KidnapperCaptiveManager, etc.) + * are owned directly by EntityKidnapper. + * + * @see AbstractTiedUpNpc + * @see ICaptor + */ +public class EntityKidnapper + extends AbstractTiedUpNpc + implements ICaptor, IDialogueSpeaker +{ + + // ======================================== + // CAPTIVE PRIORITY (for prisoner replacement) + // ======================================== + + /** + * Priority levels for captives when replacing prisoners in cells. + * Higher priority captives will cause lower priority prisoners to be released. + */ + public enum CaptivePriority { + DAMSEL(1), + DAMSEL_SHINY(2), + PLAYER(3); + + private final int priority; + + CaptivePriority(int priority) { + this.priority = priority; + } + + public int getPriority() { + return priority; + } + + /** + * Get the priority for an entity. + * + * @param entity The entity to check + * @return The captive priority + */ + public static CaptivePriority fromEntity(LivingEntity entity) { + if (entity instanceof Player) return PLAYER; + if (entity instanceof EntityDamselShiny) return DAMSEL_SHINY; + if (entity instanceof EntityDamsel) return DAMSEL; + return DAMSEL; // Default for unknown entities + } + + /** + * Check if this priority is higher than another. + */ + public boolean isHigherThan(CaptivePriority other) { + return this.priority > other.priority; + } + } + + // ======================================== + // DATA SYNC (Client-Server) + // ======================================== + + /** + * Phase 17: Whether kidnapper currently has a captive. + * Synced to client for rendering (e.g., holding leash). + */ + private static final EntityDataAccessor DATA_HAS_CAPTIVE = + SynchedEntityData.defineId( + EntityKidnapper.class, + EntityDataSerializers.BOOLEAN + ); + + /** + * Whether kidnapping mode is active (via collar config). + * Synced to client for name color display. + */ + private static final EntityDataAccessor DATA_KIDNAPPING_MODE = + SynchedEntityData.defineId( + EntityKidnapper.class, + EntityDataSerializers.BOOLEAN + ); + + /** + * Kidnapper variant ID (skin selection). + * Synced to client for rendering. + */ + private static final EntityDataAccessor DATA_KIDNAPPER_VARIANT_ID = + SynchedEntityData.defineId( + EntityKidnapper.class, + EntityDataSerializers.STRING + ); + + /** + * Kidnapper theme (item set selection). + * Synced to client for potential visual effects. + */ + private static final EntityDataAccessor DATA_THEME = + SynchedEntityData.defineId( + EntityKidnapper.class, + EntityDataSerializers.STRING + ); + + /** + * Kidnapper theme color. + * Synced to client for rendering colored items. + */ + private static final EntityDataAccessor DATA_THEME_COLOR = + SynchedEntityData.defineId( + EntityKidnapper.class, + EntityDataSerializers.STRING + ); + + // ======================================== + // SERVER-SIDE STATE + // ======================================== + + /** Appearance manager for skin variants, themes, and item selection. */ + private final com.tiedup.remake.entities.kidnapper.components.KidnapperAppearance appearance; + + /** Captive manager for ICaptor implementation and captive lifecycle. */ + private final com.tiedup.remake.entities.kidnapper.components.KidnapperCaptiveManager captiveManager; + + /** Post-capture "get out" state (for escape behavior). */ + private boolean getOutState = false; + + /** Whether this kidnapper is currently dogwalking a prisoner. */ + private boolean dogwalking = false; + + /** Items stolen from players via KidnapperThiefGoal. Dropped at 100% on death. */ + private final List stolenItems = new ArrayList<>(); + + /** Collar keys generated when collaring captives. Dropped at 20% on death. */ + private final List collarKeys = new ArrayList<>(); + + /** Job manager handles job assignment and tracking. */ + private final KidnapperJobManager jobManager = new KidnapperJobManager( + this + ); + + /** Collar config helper for accessing collar settings. */ + private final KidnapperCollarConfig collarConfig = + new KidnapperCollarConfig(this); + + /** Capture equipment manager for themed bondage items. */ + private final KidnapperCaptureEquipment captureEquipment = + new KidnapperCaptureEquipment(this); + + /** State manager for behavioral state tracking. */ + private final com.tiedup.remake.entities.kidnapper.components.KidnapperStateManager stateManager; + + /** Aggression system for escape tracking, fight-back, and robbery immunity. */ + private final com.tiedup.remake.entities.kidnapper.components.KidnapperAggressionSystem aggressionSystem; + + /** Camp manager for structure association and hunter role. */ + private final com.tiedup.remake.entities.kidnapper.components.KidnapperCampManager campManager; + + /** Alert manager for broadcasting and receiving alerts between kidnappers. */ + private final com.tiedup.remake.entities.kidnapper.components.KidnapperAlertManager alertManager; + + /** Target selector for target validation and selection logic. */ + private final com.tiedup.remake.entities.kidnapper.components.KidnapperTargetSelector targetSelector; + + /** Cell manager for cell queries and breach responses. */ + private final com.tiedup.remake.entities.kidnapper.components.KidnapperCellManager cellManager; + + /** Sale manager for NPC sale transactions. */ + private final com.tiedup.remake.entities.kidnapper.components.KidnapperSaleManager saleManager; + + /** AI manager for goal registration (lazy initialized in registerGoals). */ + private com.tiedup.remake.entities.kidnapper.components.KidnapperAIManager aiManager; + + /** Data serializer for NBT persistence. */ + private final com.tiedup.remake.entities.kidnapper.components.KidnapperDataSerializer dataSerializer; + + // ======================================== + // AI STATE SYSTEM (Phase 3) + // ======================================== + + // ======================================== + // ABSTRACT METHOD IMPLEMENTATIONS (from AbstractTiedUpNpc) + // ======================================== + + @Override + protected com.tiedup.remake.entities.damsel.components.IBondageHost createBondageHost() { + return new com.tiedup.remake.entities.kidnapper.hosts.BondageHost(this); + } + + @Override + protected void registerNpcGoals() { + // Kidnapper goal registration is handled directly by registerGoals() override, + // which delegates to KidnapperAIManager. This abstract method exists for the + // contract but is not called for kidnappers. + } + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public EntityKidnapper( + EntityType type, + Level level + ) { + super(type, level); + // Initialize appearance manager + this.appearance = + new com.tiedup.remake.entities.kidnapper.components.KidnapperAppearance( + this, + new com.tiedup.remake.entities.kidnapper.hosts.AppearanceHost( + this + ), + DATA_KIDNAPPER_VARIANT_ID, + DATA_THEME, + DATA_THEME_COLOR + ); + + // Initialize state manager + this.stateManager = + new com.tiedup.remake.entities.kidnapper.components.KidnapperStateManager( + new com.tiedup.remake.entities.kidnapper.hosts.StateHost(this) + ); + + // Initialize aggression system + this.aggressionSystem = + new com.tiedup.remake.entities.kidnapper.components.KidnapperAggressionSystem( + new com.tiedup.remake.entities.kidnapper.hosts.AggressionHost( + this + ) + ); + + // Initialize camp manager + this.campManager = + new com.tiedup.remake.entities.kidnapper.components.KidnapperCampManager( + new com.tiedup.remake.entities.kidnapper.hosts.CampHost(this) + ); + + // Initialize alert manager + this.alertManager = + new com.tiedup.remake.entities.kidnapper.components.KidnapperAlertManager( + this, + new com.tiedup.remake.entities.kidnapper.hosts.AlertHost(this) + ); + + // Initialize target selector + this.targetSelector = + new com.tiedup.remake.entities.kidnapper.components.KidnapperTargetSelector( + this, + new com.tiedup.remake.entities.kidnapper.hosts.TargetHost(this) + ); + + // Initialize cell manager + this.cellManager = + new com.tiedup.remake.entities.kidnapper.components.KidnapperCellManager( + new com.tiedup.remake.entities.kidnapper.hosts.CellHost(this) + ); + + // Initialize sale manager + this.saleManager = + new com.tiedup.remake.entities.kidnapper.components.KidnapperSaleManager( + new com.tiedup.remake.entities.kidnapper.hosts.SaleHost(this) + ); + + // Initialize captive manager + this.captiveManager = + new com.tiedup.remake.entities.kidnapper.components.KidnapperCaptiveManager( + this, + new com.tiedup.remake.entities.kidnapper.hosts.CaptiveHost( + this + ), + DATA_HAS_CAPTIVE + ); + + // Note: aiManager is lazy-initialized in registerGoals() to avoid NPE + // (registerGoals is called from Mob constructor before this point) + + // Initialize data serializer + this.dataSerializer = + new com.tiedup.remake.entities.kidnapper.components.KidnapperDataSerializer( + new com.tiedup.remake.entities.kidnapper.hosts.DataSerializerHost( + this + ), + DATA_KIDNAPPING_MODE + ); + + // Additional navigation config beyond what AbstractTiedUpNpc sets + // (AbstractTiedUpNpc already sets canOpenDoors + canPassDoors) + if ( + this.getNavigation() instanceof + net.minecraft.world.entity.ai.navigation.GroundPathNavigation groundNav + ) { + groundNav.setCanFloat(true); // Allow crossing small ponds in forests + } + // Increase pathfinding search range for better navigation in complex terrain + this.getNavigation().setMaxVisitedNodesMultiplier(2.0f); + } + + // ======================================== + // ATTRIBUTES + // ======================================== + + /** + * Create kidnapper attributes. + * Faster than damsel to be able to catch them. + */ + public static AttributeSupplier.Builder createAttributes() { + // NOTE: Using default values (ModConfig not loaded yet during attribute registration) + return Mob.createMobAttributes() + .add(Attributes.MAX_HEALTH, 20.0) // Default: 20.0 + .add(Attributes.MOVEMENT_SPEED, 0.27) // Default: 0.27 + .add(Attributes.KNOCKBACK_RESISTANCE, 0.7D) + .add(Attributes.FOLLOW_RANGE, 60.0) // Default: 60.0 + .add(Attributes.ATTACK_DAMAGE, 6.0); // Default: 6.0 + } + + // ======================================== + // DATA SYNC + // ======================================== + + @Override + protected void defineSynchedData() { + super.defineSynchedData(); + this.entityData.define(DATA_HAS_CAPTIVE, false); // Phase 17: renamed + this.entityData.define(DATA_KIDNAPPING_MODE, false); + this.entityData.define(DATA_KIDNAPPER_VARIANT_ID, ""); + this.entityData.define(DATA_THEME, ""); + this.entityData.define(DATA_THEME_COLOR, ""); + } + + @Override + public void onSyncedDataUpdated(EntityDataAccessor key) { + super.onSyncedDataUpdated(key); + // Invalidate kidnapper variant cache when synced from server + if (DATA_KIDNAPPER_VARIANT_ID.equals(key)) { + appearance.invalidateVariantCache(); + } + } + + /** + * Set kidnapper variant and name. + */ + public void setKidnapperVariant(KidnapperVariant variant) { + appearance.setKidnapperVariant(variant); + } + + /** + * Get current kidnapper variant. + */ + @Nullable + public KidnapperVariant getKidnapperVariant() { + return appearance.getKidnapperVariant(); + } + + /** + * Get kidnapper variant ID (synced data). + */ + public String getKidnapperVariantId() { + return appearance.getKidnapperVariantId(); + } + + // ======================================== + // VARIANT SYSTEM - Virtual methods for subclasses + // ======================================== + + /** + * Lookup a variant by ID from the appropriate skin manager. + * Override in subclasses to use their specific skin manager. + * Delegates to appearance component by default. + */ + public KidnapperVariant lookupVariantById(String variantId) { + return KidnapperSkinManager.CORE.getVariant(variantId); + } + + /** + * Compute which variant to use based on UUID. + * Override in subclasses to use different skin managers. + * Delegates to appearance component by default. + */ + public KidnapperVariant computeVariantForEntity(UUID entityUUID) { + Gender preferredGender = SettingsAccessor.getPreferredSpawnGender( + this.level() != null ? this.level().getGameRules() : null); + return KidnapperSkinManager.CORE.getVariantForEntity( + entityUUID, + preferredGender + ); + } + + /** + * Get the texture folder for this kidnapper type. + * Override in subclasses for different texture paths. + */ + public String getVariantTextureFolder() { + return "textures/entity/kidnapper/"; + } + + /** + * Get the default variant ID for fallback. + * Override in subclasses for their default variant. + */ + public String getDefaultVariantId() { + return "knp_mob_1"; + } + + /** + * Apply the name from variant to this entity. + * Override in subclasses for different naming behavior. + */ + public void applyVariantName(KidnapperVariant variant) { + if (variant.id().startsWith("knp_mob_")) { + this.setNpcName( + com.tiedup.remake.util.NameGenerator.getRandomKidnapperName() + ); + } else { + this.setNpcName(variant.defaultName()); + } + } + + /** + * Get the NBT key for saving the variant. + * Override in subclasses if they need different keys for backward compatibility. + */ + public String getVariantNBTKey() { + return "KidnapperVariantId"; + } + + /** + * Select items for this kidnapper. + * Override in EntityKidnapperElite for higher probabilities. + */ + public KidnapperItemSelector.SelectionResult selectItemsForKidnapper() { + return KidnapperItemSelector.selectForKidnapper(); + } + + /** + * Default texture for kidnapper entities. + */ + private static final ResourceLocation DEFAULT_KIDNAPPER_TEXTURE = + ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/kidnapper/knp_mob_1.png" + ); + + /** + * Get the skin texture for this kidnapper. + * Computes texture path from variant ID - no local skin JSONs needed on client. + */ + @Override + public ResourceLocation getSkinTexture() { + return appearance.getSkinTexture(); + } + + @Override + public void onAddedToWorld() { + super.onAddedToWorld(); + + // Initialize appearance (variant and theme) on server side + if (!this.level().isClientSide) { + appearance.initializeAppearance(); + } + } + + /** + * Get current theme. + */ + @Nullable + public KidnapperTheme getTheme() { + return appearance.getTheme(); + } + + /** + * Get current theme color. + */ + @Nullable + public com.tiedup.remake.items.base.ItemColor getThemeColor() { + return appearance.getThemeColor(); + } + + /** + * Get the selected items for this kidnapper. + */ + @Nullable + public KidnapperItemSelector.SelectionResult getItemSelection() { + return appearance.getItemSelection(); + } + + /** + * Ensure appearance (variant and theme) is initialized. + * Used by KidnapperCaptureEquipment when setting up held items. + */ + public void ensureAppearanceInitialized() { + if (!this.level().isClientSide) { + appearance.initializeAppearance(); + } + } + + // ======================================== + // AI GOALS + // ======================================== + + @Override + protected void registerGoals() { + // Lazy initialize AI manager (must be done here because registerGoals is called + // from Mob constructor before EntityKidnapper fields are initialized) + if (this.aiManager == null) { + this.aiManager = + new com.tiedup.remake.entities.kidnapper.components.KidnapperAIManager( + this, + new com.tiedup.remake.entities.kidnapper.hosts.AIHost(this) + ); + } + + // Delegate to AI manager + // Note: Does NOT call super() - kidnappers have completely different AI than damsels + aiManager.registerGoals(); + } + + // ======================================== + // TICK + // ======================================== + + @Override + protected void tickSubclass() { + // Tick dialogue cooldown + this.tickDialogueCooldown(); + + // Proactive dialogue system + KidnapperDialogueTriggerSystem.tick(this, this.tickCount); + } + + // ======================================== + // I_KIDNAPPER IMPLEMENTATION (Phase 17: slave→captive) + // ======================================== + + // C6-V2: ICaptor now takes IBondageState; KidnapperCaptiveManager still uses + // IRestrainable internally (needed for ISaleable access in sale goals). + // All real IBondageState instances are IRestrainable, so the cast is safe. + // Log a warning if the invariant is ever broken (future-proofing). + + @Override + public void addCaptive(IBondageState captive) { + if (captive instanceof IRestrainable r) { captiveManager.addCaptive(r); } + else { logBridgeWarning("addCaptive", captive); } + } + + @Override + public void removeCaptive(IBondageState captive, boolean transportState) { + if (captive instanceof IRestrainable r) { captiveManager.removeCaptive(r, transportState); } + else { logBridgeWarning("removeCaptive", captive); } + } + + @Override + public boolean canCapture(IBondageState captive) { + if (captive instanceof IRestrainable r) return captiveManager.canCapture(r); + logBridgeWarning("canCapture", captive); + return false; + } + + @Override + public boolean canRelease(IBondageState captive) { + if (captive instanceof IRestrainable r) return captiveManager.canRelease(r); + logBridgeWarning("canRelease", captive); + return false; + } + + @Override + public boolean allowCaptiveTransfer() { + return captiveManager.allowCaptiveTransfer(); + } + + @Override + public boolean allowMultipleCaptives() { + return captiveManager.allowMultipleCaptives(); + } + + @Override + public void onCaptiveLogout(IBondageState captive) { + if (captive instanceof IRestrainable r) { captiveManager.onCaptiveLogout(r); } + else { logBridgeWarning("onCaptiveLogout", captive); } + } + + @Override + public void onCaptiveReleased(IBondageState captive) { + if (captive instanceof IRestrainable r) { captiveManager.onCaptiveReleased(r); } + else { logBridgeWarning("onCaptiveReleased", captive); } + } + + @Override + public void onCaptiveStruggle(IBondageState captive) { + if (captive instanceof IRestrainable r) { captiveManager.onCaptiveStruggle(r); } + else { logBridgeWarning("onCaptiveStruggle", captive); } + } + + private void logBridgeWarning(String method, IBondageState captive) { + TiedUpMod.LOGGER.error("[EntityKidnapper] {} called with non-IRestrainable: {}", method, captive.getClass().getSimpleName()); + } + + @Override + public boolean hasCaptives() { + return captiveManager.hasCaptives(); + } + + @Override + public Entity getEntity() { + return captiveManager.getEntity(); + } + + // ======================================== + // TARGET MANAGEMENT + // ======================================== + + /** + * Get current hunt target. + */ + @Nullable + public LivingEntity getTarget() { + return targetSelector.getTarget(); + } + + /** + * Set current hunt target. + */ + public void setTarget(@Nullable LivingEntity target) { + targetSelector.setTarget(target); + } + + /** + * Check if an entity is a suitable target for capture. + * Supports both Players and EntityDamsel. + */ + public boolean isSuitableTarget(LivingEntity entity) { + return targetSelector.isSuitableTarget(entity); + } + + /** + * Check if target is still valid during an ACTIVE chase. + * + * Unlike isSuitableTarget(), this does NOT require line-of-sight. + * During a chase, the kidnapper should continue pursuing the target + * even if obstacles temporarily block vision (trees, hills, etc). + * The pathfinder will navigate around obstacles. + * + * Use this in canContinueToUse() for capture goals. + */ + public boolean isTargetStillValidForChase(LivingEntity entity) { + return targetSelector.isTargetStillValidForChase(entity); + } + + /** + * Check if kidnapper is close enough to capture target. + */ + public boolean isCloseToTarget() { + return targetSelector.isCloseToTarget(); + } + + /** + * Check if target is too far away. + */ + public boolean isTooFarFromTarget(int radius) { + return targetSelector.isTooFarFromTarget(radius); + } + + /** + * Find closest suitable target within radius. + * Searches for Players, EntityDamsel, and MCA villagers. + */ + @Nullable + public LivingEntity getClosestSuitableTarget(int radius) { + return targetSelector.getClosestSuitableTarget(radius); + } + + // ======================================== + // KIDNAPPING MODE (Collar-based) - Delegates to KidnapperCollarConfig + // ======================================== + + /** + * Check if kidnapping mode is enabled via collar. + */ + public boolean isKidnappingModeEnabled() { + return collarConfig.isKidnappingModeEnabled(); + } + + /** + * Check if kidnapping mode is fully ready (enabled + prison set). + */ + public boolean isKidnappingModeReady() { + return collarConfig.isKidnappingModeReady(); + } + + /** + * Get cell ID from collar. + */ + @Nullable + public java.util.UUID getCellIdFromCollar() { + return collarConfig.getCellId(); + } + + /** + * Check if collar has a cell assigned. + */ + public boolean hasCellAssignedFromCollar() { + return collarConfig.hasCellAssigned(); + } + + /** + * Check if should warn masters after capturing slave. + */ + public boolean shouldWarnMastersFromCollar() { + return collarConfig.shouldWarnMasters(); + } + + /** + * Check if should tie slave to pole in prison. + */ + public boolean shouldTieToPoleFromCollar() { + return collarConfig.shouldTieToPole(); + } + + /** + * Check if player has a token in their inventory. + * Players with tokens are not targeted by kidnappers. + */ + public static boolean hasTokenInInventory(Player player) { + return com.tiedup.remake.entities.kidnapper.components.KidnapperTargetSelector.hasTokenInInventory( + player + ); + } + + // ======================================== + // CAPTURE MECHANICS - Delegates to KidnapperCaptureEquipment + // ======================================== + + /** + * Get bind item to use for capture. + */ + public ItemStack getBindItem() { + return captureEquipment.getBindItem(); + } + + /** + * Get gag item to use for capture. + */ + public ItemStack getGagItem() { + return captureEquipment.getGagItem(); + } + + /** + * Equip themed bind and gag items before capture. + */ + public void setUpHeldItems() { + captureEquipment.setUpHeldItems(); + } + + /** + * Get mittens item to apply after capture. + */ + @Nullable + public ItemStack getMittensItem() { + return captureEquipment.getMittensItem(); + } + + /** + * Get earplugs item to apply after capture. + */ + @Nullable + public ItemStack getEarplugsItem() { + return captureEquipment.getEarplugsItem(); + } + + /** + * Get blindfold item to apply after capture. + */ + @Nullable + public ItemStack getBlindfoldItem() { + return captureEquipment.getBlindfoldItem(); + } + + /** + * Get collar item to apply during capture. + */ + @Nullable + public ItemStack getCollarItem() { + return captureEquipment.getCollarItem(); + } + + /** + * Clear held items (hide them). + * Note: Overridden in subclasses (Archer restores bow, Merchant may differ). + */ + public void clearHeldItems() { + captureEquipment.clearHeldItems(); + } + + // ======================================== + // CAPTIVE STATE (Phase 17: slave→captive) + // ======================================== + + /** + * Phase 17: Get current captive. + */ + @Nullable + public IRestrainable getCaptive() { + return captiveManager.getCaptive(); + } + + /** + * Phase 17: Transfer all captives to another captor. + */ + public void transferAllCaptivesTo(ICaptor newCaptor) { + captiveManager.transferAllCaptivesTo(newCaptor); + } + + // ======================================== + // OVERRIDES + // ======================================== + + @Override + public void equip(BodyRegionV2 region, ItemStack stack) { + if (region == BodyRegionV2.ARMS) { + // When kidnapper gets tied up, free their captive + if (!this.level().isClientSide) { + this.getOutState = false; + captiveManager.onPutBindOn(); + + // Drop held items + this.setItemSlot(EquipmentSlot.MAINHAND, ItemStack.EMPTY); + this.setItemSlot(EquipmentSlot.OFFHAND, ItemStack.EMPTY); + } + } + super.equip(region, stack); + } + + @Override + public boolean canBeTiedUp() { + // Can't be tied up if: + // - Waiting for job completion + // - Selling captive + return ( + super.canBeTiedUp() && + !this.isWaitingForJobToBeCompleted() && + !this.isSellingCaptive() + ); + } + + /** + * Kidnappers don't panic. + */ + @Override + public boolean canPanic() { + return false; + } + + /** + * Kidnappers don't call for help when captured. + * They're tough criminals, not helpless damsels! + */ + @Override + public boolean canCallForHelp() { + return false; + } + + /** + * Phase 17: Override die() to free captives when kidnapper is killed. + * This ensures players attached via leash are properly released. + */ + @Override + public void die( + net.minecraft.world.damagesource.DamageSource damageSource + ) { + // Free current captive before dying + if (!this.level().isClientSide) { + captiveManager.onDie(); + } + + // Clear job tracking + this.clearCurrentJob(); + + super.die(damageSource); + } + + /** Token drop chance (5%) */ + private static final float TOKEN_DROP_CHANCE = 0.05f; + + /** + * Prevent taser from dropping when kidnapper dies. + * Taser is unique to kidnappers and should not be obtainable by players. + * Also handles token drop (5% chance). + */ + @Override + protected void dropEquipment() { + // Check main hand for taser - don't drop it + ItemStack mainHand = this.getItemBySlot( + net.minecraft.world.entity.EquipmentSlot.MAINHAND + ); + if ( + !mainHand.isEmpty() && + mainHand.getItem() instanceof com.tiedup.remake.items.ItemTaser + ) { + this.setItemSlot( + net.minecraft.world.entity.EquipmentSlot.MAINHAND, + ItemStack.EMPTY + ); + } + + // Check off hand too + ItemStack offHand = this.getItemBySlot( + net.minecraft.world.entity.EquipmentSlot.OFFHAND + ); + if ( + !offHand.isEmpty() && + offHand.getItem() instanceof com.tiedup.remake.items.ItemTaser + ) { + this.setItemSlot( + net.minecraft.world.entity.EquipmentSlot.OFFHAND, + ItemStack.EMPTY + ); + } + + super.dropEquipment(); + + // Token drop: 5% chance when killed + if ( + !this.level().isClientSide && + this.getRandom().nextFloat() < TOKEN_DROP_CHANCE + ) { + ItemStack token = new ItemStack( + com.tiedup.remake.items.ModItems.TOKEN.get() + ); + this.spawnAtLocation(token); + TiedUpMod.LOGGER.info( + "[EntityKidnapper] {} dropped a token on death!", + this.getNpcName() + ); + } + + // Bind item drops from kidnapper inventory + if (!this.level().isClientSide) { + KidnapperItemSelector.SelectionResult selection = + getItemSelection(); + if (selection != null) { + float dropChance = 0.15f; + if ( + !selection.bind.isEmpty() && + this.getRandom().nextFloat() < dropChance + ) { + this.spawnAtLocation(selection.bind.copy()); + } + if ( + selection.hasGag() && + this.getRandom().nextFloat() < dropChance + ) { + this.spawnAtLocation(selection.gag.copy()); + } + if ( + selection.hasMittens() && + this.getRandom().nextFloat() < dropChance + ) { + this.spawnAtLocation(selection.mittens.copy()); + } + if ( + selection.hasEarplugs() && + this.getRandom().nextFloat() < dropChance + ) { + this.spawnAtLocation(selection.earplugs.copy()); + } + if ( + selection.hasBlindfold() && + this.getRandom().nextFloat() < dropChance + ) { + this.spawnAtLocation(selection.blindfold.copy()); + } + } + } + + // Drop stolen items at 100% rate (player's property) + if (!this.level().isClientSide) { + for (ItemStack stolen : this.stolenItems) { + if (!stolen.isEmpty()) { + this.spawnAtLocation(stolen); + } + } + if (!this.stolenItems.isEmpty()) { + TiedUpMod.LOGGER.info( + "[EntityKidnapper] {} dropped {} stolen item(s) on death", + this.getNpcName(), + this.stolenItems.size() + ); + } + this.stolenItems.clear(); + } + + // Drop collar keys at 20% rate + if (!this.level().isClientSide) { + for (ItemStack key : this.collarKeys) { + if (!key.isEmpty() && this.getRandom().nextFloat() < 0.20f) { + this.spawnAtLocation(key); + TiedUpMod.LOGGER.info( + "[EntityKidnapper] {} dropped a collar key on death", + this.getNpcName() + ); + } + } + this.collarKeys.clear(); + } + } + + /** + * Phase 17: Override remove() to handle chunk unload gracefully. + * Free captives when the NPC is removed from the world. + */ + @Override + public void remove(RemovalReason reason) { + // Free captive on any removal (death, chunk unload, dimension change, etc.) + if (!this.level().isClientSide) { + captiveManager.onRemove(reason); + } + + super.remove(reason); + } + + // ======================================== + // CAPTURE TIMING (Overridable for Elite) + // ======================================== + + /** + * Get the time in ticks to tie up a target. + * Elite kidnappers override this to be faster. + * + * @return Ticks to complete binding (default: 20 = 1 second) + */ + public int getCaptureBindTime() { + return 20; + } + + /** + * Get the time in ticks to gag a target. + * Elite kidnappers override this to be faster. + * + * @return Ticks to complete gagging (default: 20 = 1 second) + */ + public int getCaptureGagTime() { + return 20; + } + + // ======================================== + // JOB SYSTEM - Delegates to KidnapperJobManager + // ======================================== + + /** + * Check if waiting for worker to complete job. + */ + public boolean isWaitingForJobToBeCompleted() { + return jobManager.isWaitingForJobToBeCompleted(); + } + + /** + * Get the current job assigned to worker. + */ + @Nullable + public ItemTask getCurrentJob() { + return jobManager.getCurrentJob(); + } + + /** + * Get the UUID of the worker doing the current job. + */ + @Nullable + public UUID getJobWorkerUUID() { + return jobManager.getJobWorkerUUID(); + } + + /** + * Set the UUID of the worker doing the current job. + */ + public void setJobWorkerUUID(@Nullable UUID workerUUID) { + jobManager.setJobWorkerUUID(workerUUID); + } + + /** + * Find the job worker entity by UUID. + */ + @Nullable + public Player getJobWorker() { + return jobManager.getJobWorker(); + } + + /** + * Assign a job to the current captive. + */ + public boolean assignJob(ItemTask job) { + return jobManager.assignJob(job); + } + + /** + * Assign a random job using JobLoader. + */ + public boolean assignRandomJob() { + return jobManager.assignRandomJob(); + } + + /** + * Clear the current job and worker tracking. + */ + public void clearCurrentJob() { + jobManager.clearCurrentJob(); + } + + // ======================================== + // SALE SYSTEM (Phase 14.3.5, Phase 17: slave→captive) + // ======================================== + + /** + * Phase 17: Check if currently selling captive. + */ + public boolean isSellingCaptive() { + return saleManager.isSellingCaptive(); + } + + /** + * Phase 17: Put current captive for sale at a random price. + * Uses SaleLoader to get a random price. + * Price is multiplied for valuable captives: + * - Players: 2x base price + * - Shiny damsels: 3x base price + * + * @return true if sale started successfully + */ + public boolean startSale() { + return saleManager.startSale(); + } + + /** + * Phase 17: Put current captive for sale at a specific price. + * + * @param price The price to sell at + * @return true if sale started successfully + */ + public boolean startSale(ItemTask price) { + return saleManager.startSale(price); + } + + /** + * Cancel current sale. + */ + public void cancelSale() { + saleManager.cancelSale(); + } + + // ======================================== + // SOLO MODE FALLBACK (Phase 4) + // ======================================== + + /** + * Keep the captive when no buyers are available (solo mode). + * The kidnapper decides to keep the captive for themselves. + * This will trigger DecideNextAction to assign a job or bring to cell. + */ + public void keepCaptive() { + captiveManager.keepCaptive(); + } + + /** + * Abandon the captive when no buyers are available (solo mode). + * Applies blindfold, frees the captive (keeps restraints), and teleports them far away. + */ + public void abandonCaptive() { + captiveManager.abandonCaptive(); + } + + /** + * Phase 17: Complete sale and transfer captive to buyer. + * + * @param buyer The ICaptor buying the captive + * @return true if sale completed successfully + */ + public boolean completeSale(ICaptor buyer) { + return saleManager.completeSale(buyer); + } + + // ======================================== + // GET OUT STATE + // ======================================== + + public boolean isGetOutState() { + return this.getOutState; + } + + public void setGetOutState(boolean state) { + this.getOutState = state; + } + + /** + * Check if this kidnapper is currently walking a prisoner (dogwalk). + */ + public boolean isDogwalking() { + return this.dogwalking; + } + + /** + * Set the dogwalking state. + */ + public void setDogwalking(boolean dogwalking) { + this.dogwalking = dogwalking; + } + + // ======================================== + // NBT PERSISTENCE + // ======================================== + + @Override + public void addAdditionalSaveData(CompoundTag tag) { + super.addAdditionalSaveData(tag); + + // Delegate to data serializer + dataSerializer.saveToNBT(tag); + + // Save stolen items + if (!this.stolenItems.isEmpty()) { + ListTag stolenTag = new ListTag(); + for (ItemStack stack : this.stolenItems) { + if (!stack.isEmpty()) { + stolenTag.add(stack.save(new CompoundTag())); + } + } + tag.put("StolenItems", stolenTag); + } + + // Save collar keys + if (!this.collarKeys.isEmpty()) { + ListTag keysTag = new ListTag(); + for (ItemStack key : this.collarKeys) { + if (!key.isEmpty()) { + keysTag.add(key.save(new CompoundTag())); + } + } + tag.put("CollarKeys", keysTag); + } + } + + @Override + public void readAdditionalSaveData(CompoundTag tag) { + super.readAdditionalSaveData(tag); + + // Delegate to data serializer + dataSerializer.loadFromNBT(tag); + + // Load stolen items + this.stolenItems.clear(); + if (tag.contains("StolenItems", Tag.TAG_LIST)) { + ListTag stolenTag = tag.getList("StolenItems", Tag.TAG_COMPOUND); + for (int i = 0; i < stolenTag.size(); i++) { + ItemStack stack = ItemStack.of(stolenTag.getCompound(i)); + if (!stack.isEmpty()) { + this.stolenItems.add(stack); + } + } + } + + // Load collar keys + this.collarKeys.clear(); + if (tag.contains("CollarKeys", Tag.TAG_LIST)) { + ListTag keysTag = tag.getList("CollarKeys", Tag.TAG_COMPOUND); + for (int i = 0; i < keysTag.size(); i++) { + ItemStack key = ItemStack.of(keysTag.getCompound(i)); + if (!key.isEmpty()) { + this.collarKeys.add(key); + } + } + } + } + + // ======================================== + // ESCAPE TRACKING METHODS + // ======================================== + + /** + * Called when a captive escapes from this kidnapper. + * Records the escaped entity for potential recapture. + * + * @param escaped The entity that escaped (IRestrainable or LivingEntity) + */ + public void onCaptiveEscaped(@Nullable Object escaped) { + aggressionSystem.onCaptiveEscaped(escaped); + } + + /** + * Get the escaped target if still within memory time. + * + * @return The escaped entity, or null if expired or none + */ + @Nullable + public LivingEntity getEscapedTarget() { + return aggressionSystem.getEscapedTarget(); + } + + /** + * Clear the escaped target memory. + * Called after successfully recapturing or giving up. + */ + public void clearEscapedTarget() { + aggressionSystem.clearEscapedTarget(); + } + + // ======================================== + // FIGHT BACK SYSTEM METHODS + // ======================================== + + /** + * Get the last entity that attacked this kidnapper. + * Returns null if no recent attack (>5 seconds). + */ + @Nullable + public LivingEntity getLastAttacker() { + return aggressionSystem.getLastAttacker(); + } + + /** + * Set the last attacker manually. + * Used to trigger fight back when someone tries to free a captive. + */ + public void setLastAttacker(LivingEntity attacker) { + aggressionSystem.setLastAttacker(attacker); + } + + /** + * Check if player is the master/owner of this kidnapper. + * Master = owner of the kidnapper's collar. + */ + public boolean isMaster(Player player) { + if (!this.hasCollar()) return false; + + ItemStack collar = this.getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemCollar collarItem) { + return collarItem.isOwner(collar, player); + } + return false; + } + + /** Damage reduction multiplier against monsters (50% damage taken) */ + private static final float MONSTER_DAMAGE_REDUCTION = 0.5f; + + @Override + public boolean hurt( + net.minecraft.world.damagesource.DamageSource source, + float amount + ) { + float finalAmount = amount; + + // Track the attacker for fight back system + if (source.getEntity() instanceof LivingEntity attacker) { + aggressionSystem.setLastAttacker(attacker); + + // Punish prisoners who dare to attack us + if ( + !this.level().isClientSide && + attacker instanceof ServerPlayer player + ) { + punishAttackingPrisoner(player); + + // Expire PROTECTED status if player attacks a kidnapper + // Attacking a kidnapper voids your safe-exit window + if (this.level() instanceof ServerLevel serverLevel) { + com.tiedup.remake.prison.PrisonerManager pm = + com.tiedup.remake.prison.PrisonerManager.get( + serverLevel + ); + if ( + pm.getState(player.getUUID()) == + com.tiedup.remake.prison.PrisonerState.PROTECTED + ) { + pm.expireProtection( + player.getUUID(), + serverLevel.getGameTime() + ); + TiedUpMod.LOGGER.debug( + "[EntityKidnapper] Expired PROTECTED status for {} (attacked kidnapper)", + player.getName().getString() + ); + } + } + } + + // Camp kidnappers take reduced damage from monsters (trained fighters) + if ( + this.getAssociatedStructure() != null && + attacker instanceof net.minecraft.world.entity.monster.Monster + ) { + finalAmount = amount * MONSTER_DAMAGE_REDUCTION; + } + } + return super.hurt(source, finalAmount); + } + + /** + * Punish a prisoner who attacks this kidnapper. + * Checks if the player is a tied-up prisoner and applies shock + tightens binds. + * + * @param player The player who attacked + * @return true if punishment was applied + */ + protected boolean punishAttackingPrisoner(ServerPlayer player) { + return captiveManager.punishAttackingPrisoner(player); + } + + @Override + protected void customServerAiStep() { + super.customServerAiStep(); + // Required for swing animation to work + this.updateSwingTime(); + + // Update alert system + alertManager.tick(); + + // Update captive manager (restores captive from UUID after chunk/server reload) + captiveManager.tick(); + } + + // ======================================== + // AI STATE SYSTEM (Phase 3) + // ======================================== + + /** + * Get the current behavioral state. + */ + public KidnapperState getCurrentState() { + return stateManager.getCurrentState(); + } + + /** + * Set the current behavioral state. + * + * @param state The new state + */ + public void setCurrentState(KidnapperState state) { + stateManager.setCurrentState(state); + } + + /** + * Get the alert target (escapee being searched for). + */ + @Nullable + public LivingEntity getAlertTarget() { + return alertManager.getAlertTarget(); + } + + /** + * Set the alert target. + * + * @param target The escapee to search for + */ + public void setAlertTarget(@Nullable LivingEntity target) { + alertManager.setAlertTarget(target); + } + + /** + * Get the associated structure UUID for this kidnapper. + */ + @Nullable + public UUID getAssociatedStructure() { + return campManager.getAssociatedStructure(); + } + + /** + * Set the associated structure UUID. + * When first associated with a camp, randomly decides if this kidnapper + * becomes a "hunter" (50% chance) that patrols far from camp. + * + * @param structureId The structure this kidnapper belongs to + */ + public void setAssociatedStructure(@Nullable UUID structureId) { + campManager.setAssociatedStructure(structureId); + } + + /** + * Check if this kidnapper is a "hunter" that patrols far from camp. + * + * @return true if this kidnapper hunts in the wilderness + */ + public boolean isHunter() { + return campManager.isHunter(); + } + + /** + * Set whether this kidnapper is a hunter. + * + * @param hunter true to make this kidnapper a hunter + */ + public void setHunter(boolean hunter) { + campManager.setHunter(hunter); + } + + // ======================================== + // ROBBED IMMUNITY METHODS + // ======================================== + + /** + * Grant robbed immunity to a player. + * This kidnapper will not target this player for 2 minutes. + * + * @param playerUUID UUID of the player who was robbed + */ + public void grantRobbedImmunity(UUID playerUUID) { + aggressionSystem.grantRobbedImmunity(playerUUID); + } + + /** + * Check if a player has robbed immunity from this kidnapper. + * + * @param playerUUID UUID of the player to check + * @return true if the player has immunity + */ + public boolean hasRobbedImmunity(UUID playerUUID) { + return aggressionSystem.hasRobbedImmunity(playerUUID); + } + + /** + * Clear robbed immunity for a player (e.g., when they break their bindings). + * + * @param playerUUID UUID of the player + */ + public void clearRobbedImmunity(UUID playerUUID) { + aggressionSystem.clearRobbedImmunity(playerUUID); + } + + // ======================================== + // COMPONENT ACCESSORS + // ======================================== + + /** + * Get the aggression system component (for TargetHost). + * + * @return The aggression system + */ + public KidnapperAggressionSystem getAggressionSystem() { + return this.aggressionSystem; + } + + /** + * Get the alert manager component (for CaptiveHost). + * + * @return The alert manager + */ + public com.tiedup.remake.entities.kidnapper.components.KidnapperAlertManager getAlertManager() { + return this.alertManager; + } + + /** + * Get the job manager component (for TargetHost). + * + * @return The job manager + */ + public KidnapperJobManager getJobManager() { + return this.jobManager; + } + + /** + * Get the collar config component (for TargetHost). + * + * @return The collar config + */ + public KidnapperCollarConfig getCollarConfig() { + return this.collarConfig; + } + + /** + * Get the appearance component (for DataSerializerHost). + * + * @return The appearance component + */ + public com.tiedup.remake.entities.kidnapper.components.KidnapperAppearance getAppearanceComponent() { + return this.appearance; + } + + /** + * Get the captive manager component (for DataSerializerHost). + * + * @return The captive manager + */ + public com.tiedup.remake.entities.kidnapper.components.KidnapperCaptiveManager getCaptiveManagerComponent() { + return this.captiveManager; + } + + /** + * Get the state manager component (for DataSerializerHost). + * + * @return The state manager + */ + public com.tiedup.remake.entities.kidnapper.components.KidnapperStateManager getStateManagerComponent() { + return this.stateManager; + } + + /** + * Get the camp manager component (for DataSerializerHost). + * + * @return The camp manager + */ + public com.tiedup.remake.entities.kidnapper.components.KidnapperCampManager getCampManagerComponent() { + return this.campManager; + } + + /** + * Get the captive transfer flag (for SaleHost). + * + * @return true if captive transfer is allowed + */ + public boolean getAllowCaptiveTransferFlag() { + return captiveManager.getAllowCaptiveTransferFlag(); + } + + /** + * Set the captive transfer flag (for SaleHost). + * + * @param flag true to allow captive transfer + */ + public void setAllowCaptiveTransferFlag(boolean flag) { + captiveManager.setAllowCaptiveTransferFlag(flag); + } + + /** + * Get the active KidnapperWaitForBuyerGoal for this kidnapper. + * Used by MasterBuyPlayerGoal to notify when purchase is complete. + * + * @return The WaitForBuyerGoal if active, or null if not found + */ + @Nullable + public KidnapperWaitForBuyerGoal getWaitForBuyerGoal() { + // Search through running goals to find the WaitForBuyerGoal + for (var wrappedGoal : this.goalSelector.getAvailableGoals()) { + if ( + wrappedGoal.getGoal() instanceof + KidnapperWaitForBuyerGoal waitGoal + ) { + return waitGoal; + } + } + return null; + } + + // ======================================== + // TARGET PURSUIT CHECK + // ======================================== + + /** + * Check if a target is already being pursued by another kidnapper. + * Elite kidnappers ignore this check and can always pursue. + * + * @param target The potential target + * @return true if another kidnapper is already pursuing this target + */ + public boolean isTargetBeingPursuedByOther(LivingEntity target) { + return targetSelector.isTargetBeingPursuedByOther(target); + } + + /** + * Broadcast an alert to nearby kidnappers about an escapee. + * Other kidnappers within 50 blocks will enter ALERT state. + * + * @param escapee The entity that escaped + */ + public void broadcastAlert(LivingEntity escapee) { + alertManager.broadcastAlert(escapee); + } + + /** + * Receive an alert broadcast from another kidnapper. + * + * @param escapee The entity that escaped + * @param source The kidnapper that sent the alert + */ + public void receiveAlertBroadcast( + LivingEntity escapee, + EntityKidnapper source + ) { + alertManager.receiveAlertBroadcast(escapee, source); + } + + /** + * Called when a prisoner is detected struggling nearby. + * Uses LINE OF SIGHT - will only react if the kidnapper can SEE the prisoner. + * + * If the prisoner is seen struggling: + * 1. Shock the prisoner (punishment) + * 2. Set target to approach and tighten their binds + * + * @param prisoner The player who is struggling + */ + public void onStruggleDetected(ServerPlayer prisoner) { + captiveManager.onStruggleDetected(prisoner, prisoner.blockPosition()); + } + + /** + * Set the target prisoner to approach after catching them struggling. + * @param target The prisoner to approach + */ + public void setStrugglePunishmentTarget(@Nullable LivingEntity target) { + captiveManager.setStrugglePunishmentTarget(target); + } + + /** + * Get the current struggle punishment target. + * @return The prisoner to approach, or null if none + */ + @Nullable + public LivingEntity getStrugglePunishmentTarget() { + return captiveManager.getStrugglePunishmentTarget(); + } + + /** + * Clear the struggle punishment target (after approaching or timeout). + */ + public void clearStrugglePunishmentTarget() { + captiveManager.clearStrugglePunishmentTarget(); + } + + /** + * Called when a WALL marker is destroyed near this kidnapper. + * Kidnapper will investigate the breach and enter ALERT state if they have a prisoner in that cell. + * + * @param breachPos The position where the wall was destroyed + * @param cellId The UUID of the affected cell + */ + public void onCellBreach(BlockPos breachPos, UUID cellId) { + cellManager.onCellBreach(breachPos, cellId); + } + + /** + * Find a cell that contains a specific prisoner. + * + * @param prisonerId The prisoner UUID + * @return The CellDataV2, or null if not found + */ + @Nullable + public CellDataV2 findCellContainingPrisoner(UUID prisonerId) { + return cellManager.findCellContainingPrisoner(prisonerId); + } + + /** + * Get all nearby cells that have prisoners in them. + * Used by KidnapperGuardGoal to find cells to watch. + * + * @return List of occupied cells within 32 blocks + */ + public List getNearbyCellsWithPrisoners() { + return cellManager.getNearbyCellsWithPrisoners(); + } + + /** + * Check if a position is inside a specific cell (using WALL markers). + * + * @param pos The position to check + * @param cell The cell to check against + * @return true if the position is inside the cell boundaries + */ + public boolean isInsideCell(BlockPos pos, CellDataV2 cell) { + return cellManager.isInsideCell(pos, cell); + } + + /** + * Get the PATROL markers near this kidnapper. + * Used for patrol path following. + * + * @param radius Search radius + * @return List of PATROL marker positions + */ + public List getNearbyPatrolMarkers(int radius) { + return cellManager.getNearbyPatrolMarkers(radius); + } + + // ======================================== + // DISPLAY + // ======================================== + + @Override + public Component getDisplayName() { + // Red name for kidnappers + return Component.literal(this.getNpcName()).withStyle(style -> + style.withColor(0xFF5555) + ); // Red + } + + // ======================================== + // LEASH HOLDER POSITION + // ======================================== + + /** + * Override to position leash at right hand instead of chest. + * Vanilla Entity.getRopeHoldPosition returns position + (0, eyeHeight*0.7, 0) which is chest level. + * We want it to come from the hand like Player.getRopeHoldPosition does. + * + * @param partialTicks Partial tick for interpolation + * @return Position where the leash rope should attach (right hand) + */ + @Override + public net.minecraft.world.phys.Vec3 getRopeHoldPosition( + float partialTicks + ) { + return captiveManager.getRopeHoldPosition(partialTicks); + } + + // ======================================== + // DIALOGUE SPEAKER IMPLEMENTATION + // ======================================== + + // ======================================== + // DIALOGUE CONVENIENCE METHODS + // ======================================== + + /** + * Talk to a specific player using a message string. + * Kidnapper equivalent of EntityDamsel.talkTo(Player, String). + */ + public void talkTo(Player player, String message) { + if (player == null || message == null) return; + if (this.level().isClientSide()) return; + com.tiedup.remake.util.MessageDispatcher.talkTo(this, player, message); + } + + /** + * Talk to a specific player using a dialogue category. + * Kidnapper equivalent of EntityDamsel.talkTo(Player, DialogueCategory). + */ + public void talkTo( + Player player, + com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory category + ) { + if (player == null || category == null) return; + if (this.level().isClientSide()) return; + String message = com.tiedup.remake.dialogue.EntityDialogueManager.getDialogue(category); + com.tiedup.remake.util.MessageDispatcher.talkTo(this, player, message); + } + + /** + * Broadcast a message to all players in radius. + * Kidnapper equivalent of EntityDamsel.talkToPlayersInRadius(String, int). + */ + public void talkToPlayersInRadius(String message, int radius) { + if (message == null) return; + if (this.level().isClientSide()) return; + com.tiedup.remake.util.MessageDispatcher.talkToNearby(this, message, (double) radius); + } + + /** + * Broadcast a dialogue category to all players in radius. + * Kidnapper equivalent of EntityDamsel.talkToPlayersInRadius(DialogueCategory, int). + */ + public void talkToPlayersInRadius( + com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory category, + int radius + ) { + if (category == null) return; + if (this.level().isClientSide()) return; + String message = com.tiedup.remake.dialogue.EntityDialogueManager.getDialogue(category); + com.tiedup.remake.util.MessageDispatcher.talkToNearby(this, message, (double) radius); + } + + /** + * Perform an action message to a specific player. + * Kidnapper equivalent of EntityDamsel.actionTo(Player, String). + */ + public void actionTo(Player player, String action) { + if (player == null || action == null) return; + if (this.level().isClientSide()) return; + com.tiedup.remake.util.MessageDispatcher.actionTo(this, player, action); + } + + /** + * Perform an action message to a specific player using a category. + * Kidnapper equivalent of EntityDamsel.actionTo(Player, DialogueCategory). + */ + public void actionTo( + Player player, + com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory category + ) { + if (player == null || category == null) return; + if (this.level().isClientSide()) return; + String action = com.tiedup.remake.dialogue.EntityDialogueManager.getDialogue(category); + com.tiedup.remake.util.MessageDispatcher.actionTo(this, player, action); + } + + /** Dialogue cooldown timer (ticks remaining before next dialogue) */ + private int dialogueCooldown = 0; + + @Override + public String getDialogueName() { + return this.getNpcName(); + } + + @Override + public SpeakerType getSpeakerType() { + return SpeakerType.KIDNAPPER; + } + + @Override + @Nullable + public PersonalityType getSpeakerPersonality() { + // Map kidnapper theme to a personality-like behavior + KidnapperTheme theme = this.getTheme(); + if (theme == null) { + return PersonalityType.CALM; + } + + return switch (theme) { + case ROPE, SHIBARI -> PersonalityType.CALM; // Traditional, methodical + case TAPE, LEATHER, CHAIN -> PersonalityType.FIERCE; // Rough, aggressive + case MEDICAL, ASYLUM -> PersonalityType.PROUD; // Clinical, professional + case LATEX, RIBBON -> PersonalityType.PLAYFUL; // Playful, teasing + case BEAM, WRAP -> PersonalityType.CURIOUS; // Experimental, modern + }; + } + + @Override + public int getSpeakerMood() { + // Kidnappers mood is based on: + // - Having a captive (+20) + // - Current state (varies) + int mood = 50; + + if (this.hasCaptives()) { + mood += 20; + } + + // State-based adjustment + KidnapperState state = this.getCurrentState(); + if (state != null) { + mood += switch (state) { + case SELLING -> 10; // Excited about sale + case JOB_WATCH -> 5; + case GUARD -> 0; + case CAPTURE -> 15; // Hunting excitement + case PUNISH -> -10; // Stern + case PATROL, IDLE, HUNT -> 0; // Neutral for patrolling/hunting + case ALERT -> -5; // Concerned + case TRANSPORT -> 5; + }; + } + + return Math.max(0, Math.min(100, mood)); + } + + @Override + @Nullable + public String getTargetRelation(Player player) { + // Check if this kidnapper is holding the player captive + IRestrainable captive = this.getCaptive(); + if (captive != null && captive.asLivingEntity() == player) { + return "captor"; + } + + return null; + } + + @Override + public LivingEntity asEntity() { + return this; + } + + @Override + public int getDialogueCooldown() { + return this.dialogueCooldown; + } + + @Override + public void setDialogueCooldown(int ticks) { + this.dialogueCooldown = ticks; + } + + /** + * Tick the dialogue cooldown. + * Called from the main tick method. + */ + protected void tickDialogueCooldown() { + if (this.dialogueCooldown > 0) { + this.dialogueCooldown--; + } + } + + // ======================================== + // STOLEN ITEMS (Thief Goal) + // ======================================== + + /** + * Add an item to the stolen items list. + * Called by KidnapperThiefGoal when stealing from a player. + */ + public void addStolenItem(ItemStack stack) { + if (!stack.isEmpty()) { + this.stolenItems.add(stack.copy()); + } + } + + // ======================================== + // COLLAR KEYS (Capture Goal) + // ======================================== + + /** + * Add a collar key to be stored on this kidnapper. + * Called by KidnapperCaptureGoal when collaring a captive. + */ + public void addCollarKey(ItemStack keyStack) { + if (!keyStack.isEmpty()) { + this.collarKeys.add(keyStack.copy()); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/EntityKidnapperArcher.java b/src/main/java/com/tiedup/remake/entities/EntityKidnapperArcher.java new file mode 100644 index 0000000..07ea02a --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/EntityKidnapperArcher.java @@ -0,0 +1,491 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.core.SettingsAccessor; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.SpeakerType; +import com.tiedup.remake.entities.ai.kidnapper.KidnapperArcherRangedGoal; +import com.tiedup.remake.entities.skins.ArcherKidnapperSkinManager; +import com.tiedup.remake.entities.skins.Gender; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.ai.attributes.AttributeSupplier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.monster.RangedAttackMob; +import net.minecraft.world.item.BowItem; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.UseAnim; +import net.minecraft.world.level.Level; + +/** + * Archer Kidnapper - Ranged kidnapper that attacks with rope arrows. + * + * Phase 18: Archer Kidnappers + * + * Differences from regular EntityKidnapper: + * - Attacks from range with rope arrows + * - Lower health (15 vs 20) + * - Faster movement for repositioning (0.30 vs 0.27) + * - Only approaches to capture once target is tied + * - Red name color (same as normal kidnapper) + * - Dedicated archer skins + * - Implements RangedAttackMob for bow animation + * + * Refactored: Uses parent's variant system via virtual methods. + */ +public class EntityKidnapperArcher + extends EntityKidnapper + implements RangedAttackMob +{ + + // ======================================== + // CONSTANTS + // ======================================== + + public static final double ARCHER_MAX_HEALTH = 15.0D; + public static final double ARCHER_MOVEMENT_SPEED = 0.30D; + public static final double ARCHER_KNOCKBACK_RESISTANCE = 0.3D; + public static final double ARCHER_FOLLOW_RANGE = 40.0D; + public static final double ARCHER_ATTACK_DAMAGE = 4.0D; + public static final int ARCHER_CAPTURE_TIME_TICKS = 25; + public static final int ARCHER_NAME_COLOR = 0xFF5555; + public static final float ARROW_VELOCITY = 1.6F; + public static final float ARROW_INACCURACY = 2.0F; + + // ======================================== + // DATA SYNC (Archer-specific) + // ======================================== + + /** + * Whether the archer is currently aiming (for bow draw animation). + */ + private static final EntityDataAccessor DATA_AIMING = + SynchedEntityData.defineId( + EntityKidnapperArcher.class, + EntityDataSerializers.BOOLEAN + ); + + /** + * Whether the archer is in ranged attack mode (has active shooting target). + * Used for bow visibility and ready pose. + */ + private static final EntityDataAccessor DATA_IN_RANGED_MODE = + SynchedEntityData.defineId( + EntityKidnapperArcher.class, + EntityDataSerializers.BOOLEAN + ); + + // ======================================== + // STATE + // ======================================== + + /** Ticks the archer has been charging the bow */ + private int aimingTicks; + + // ======================================== + // HIT TRACKING (for cumulative bind chance) + // ======================================== + + /** Maximum bind chance (100%) */ + public static final int ARCHER_MAX_BIND_CHANCE = 100; + + /** Track hit counts per target UUID */ + private final Map targetHitCounts = new HashMap<>(); + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public EntityKidnapperArcher( + EntityType type, + Level level + ) { + super(type, level); + // Force right-handed so bow is always in main hand (right) + this.setLeftHanded(false); + } + + // ======================================== + // ATTRIBUTES + // ======================================== + + /** + * Create archer kidnapper attributes. + * Fast but fragile - relies on range. + */ + public static AttributeSupplier.Builder createAttributes() { + return Mob.createMobAttributes() + .add(Attributes.MAX_HEALTH, ARCHER_MAX_HEALTH) + .add(Attributes.MOVEMENT_SPEED, ARCHER_MOVEMENT_SPEED) + .add(Attributes.KNOCKBACK_RESISTANCE, ARCHER_KNOCKBACK_RESISTANCE) + .add(Attributes.FOLLOW_RANGE, ARCHER_FOLLOW_RANGE) + .add(Attributes.ATTACK_DAMAGE, ARCHER_ATTACK_DAMAGE); + } + + // ======================================== + // DATA SYNC + // ======================================== + + @Override + protected void defineSynchedData() { + super.defineSynchedData(); + this.entityData.define(DATA_AIMING, false); + this.entityData.define(DATA_IN_RANGED_MODE, false); + } + + // ======================================== + // VARIANT SYSTEM - Override virtual methods + // ======================================== + + @Override + public KidnapperVariant lookupVariantById(String variantId) { + return ArcherKidnapperSkinManager.CORE.getVariant(variantId); + } + + @Override + public KidnapperVariant computeVariantForEntity(UUID entityUUID) { + Gender preferredGender = SettingsAccessor.getPreferredSpawnGender( + this.level() != null ? this.level().getGameRules() : null); + return ArcherKidnapperSkinManager.CORE.getVariantForEntity( + entityUUID, + preferredGender + ); + } + + @Override + public String getVariantTextureFolder() { + return "textures/entity/kidnapper/archer/"; + } + + @Override + public String getDefaultVariantId() { + return "bowy"; + } + + @Override + public void applyVariantName(KidnapperVariant variant) { + // Archer variants always use their default name + this.setNpcName(variant.defaultName()); + } + + @Override + public String getVariantNBTKey() { + // Use different key for backward compatibility with existing saves + return "ArcherVariantName"; + } + + // ======================================== + // AI GOALS + // ======================================== + + @Override + protected void registerGoals() { + // Call parent to register all standard kidnapper goals + super.registerGoals(); + + // Add ranged attack goal with priority 1 (same as FindTarget) + // This makes the archer shoot first, then approach to capture + this.goalSelector.addGoal(1, new KidnapperArcherRangedGoal(this)); + + TiedUpMod.LOGGER.debug( + "[EntityKidnapperArcher] Registered ranged attack goal" + ); + } + + // ======================================== + // INITIALIZATION + // ======================================== + + @Override + public void onAddedToWorld() { + super.onAddedToWorld(); + + // Server-side: Give archer a bow if not holding one + if (!this.level().isClientSide && this.getMainHandItem().isEmpty()) { + this.setItemInHand( + InteractionHand.MAIN_HAND, + new ItemStack(Items.BOW) + ); + TiedUpMod.LOGGER.debug( + "[EntityKidnapperArcher] Gave bow to {}", + this.getNpcName() + ); + } + } + + // ======================================== + // RANGED ATTACK MOB INTERFACE + // ======================================== + + /** + * Called by the ranged goal to perform the actual attack. + * Required by RangedAttackMob interface. + */ + @Override + public void performRangedAttack(LivingEntity target, float velocity) { + // Create and shoot rope arrow + EntityRopeArrow arrow = new EntityRopeArrow(this.level(), this); + + double dx = target.getX() - this.getX(); + double dy = target.getY(0.33) - arrow.getY(); + double dz = target.getZ() - this.getZ(); + double horizontalDist = Math.sqrt(dx * dx + dz * dz); + + arrow.shoot( + dx, + dy + horizontalDist * 0.2, + dz, + ARROW_VELOCITY, + ARROW_INACCURACY + ); + + // Play bow sound + this.level().playSound( + null, + this.getX(), + this.getY(), + this.getZ(), + net.minecraft.sounds.SoundEvents.ARROW_SHOOT, + this.getSoundSource(), + 1.0F, + 1.0F / (this.getRandom().nextFloat() * 0.4F + 0.8F) + ); + + this.level().addFreshEntity(arrow); + + TiedUpMod.LOGGER.debug( + "[EntityKidnapperArcher] {} shot arrow at {}", + this.getNpcName(), + target.getName().getString() + ); + } + + // ======================================== + // AIMING STATE (for animation) + // ======================================== + + /** + * Set whether the archer is currently aiming. + */ + public void setAiming(boolean aiming) { + if (aiming) { + this.aimingTicks = 0; + this.startUsingItem(InteractionHand.MAIN_HAND); + } else { + this.aimingTicks = 0; + this.stopUsingItem(); + } + this.entityData.set(DATA_AIMING, aiming); + } + + /** + * Check if the archer is currently aiming. + */ + public boolean isAiming() { + return this.entityData.get(DATA_AIMING); + } + + /** + * Set whether the archer is in ranged attack mode. + */ + public void setInRangedMode(boolean inRangedMode) { + this.entityData.set(DATA_IN_RANGED_MODE, inRangedMode); + } + + /** + * Check if the archer is in ranged attack mode. + */ + public boolean isInRangedMode() { + return this.entityData.get(DATA_IN_RANGED_MODE); + } + + /** + * Get how long the archer has been aiming (for bow pull animation). + */ + public int getAimingTicks() { + return this.aimingTicks; + } + + @Override + public void tick() { + super.tick(); + if (this.isAiming()) { + this.aimingTicks++; + } + } + + @Override + public boolean isUsingItem() { + return this.isAiming() || super.isUsingItem(); + } + + @Override + public InteractionHand getUsedItemHand() { + if (this.isAiming()) { + return InteractionHand.MAIN_HAND; + } + return super.getUsedItemHand(); + } + + /** + * Get the use animation type for the item. + */ + public UseAnim getItemUseAnimation() { + if ( + this.isAiming() && + this.getMainHandItem().getItem() instanceof BowItem + ) { + return UseAnim.BOW; + } + return UseAnim.NONE; + } + + // ======================================== + // HIT TRACKING METHODS + // ======================================== + + /** + * Get the current bind chance for a target. + * Base 10% + 10% per previous hit. + */ + public int getBindChanceForTarget(UUID targetUUID) { + int hitCount = targetHitCounts.getOrDefault(targetUUID, 0); + int baseChance = + com.tiedup.remake.core.ModConfig.SERVER.archerArrowBindChanceBase.get(); + int perHitChance = + com.tiedup.remake.core.ModConfig.SERVER.archerArrowBindChancePerHit.get(); + + int chance = baseChance + (hitCount * perHitChance); + return Math.min(chance, ARCHER_MAX_BIND_CHANCE); + } + + /** + * Record a hit on a target (increases future bind chance). + */ + public void recordHitOnTarget(UUID targetUUID) { + int currentHits = targetHitCounts.getOrDefault(targetUUID, 0); + targetHitCounts.put(targetUUID, currentHits + 1); + TiedUpMod.LOGGER.debug( + "[EntityKidnapperArcher] {} hit target, new hit count: {}", + this.getNpcName(), + currentHits + 1 + ); + } + + /** + * Clear hit counts for a specific target (called when target is captured). + */ + public void clearHitsForTarget(UUID targetUUID) { + targetHitCounts.remove(targetUUID); + } + + /** + * Clear all hit counts (called when archer changes target). + */ + public void clearAllHitCounts() { + targetHitCounts.clear(); + } + + // ======================================== + // DISPLAY + // ======================================== + + @Override + public Component getDisplayName() { + return Component.literal(this.getNpcName()).withStyle( + Style.EMPTY.withColor(ARCHER_NAME_COLOR) + ); + } + + // ======================================== + // OVERRIDES + // ======================================== + + /** + * Archers take slightly longer to capture up close. + */ + @Override + public int getCaptureBindTime() { + return ARCHER_CAPTURE_TIME_TICKS; + } + + /** + * Archers take slightly longer to gag. + */ + @Override + public int getCaptureGagTime() { + return ARCHER_CAPTURE_TIME_TICKS; + } + + /** + * Archers use lower item probabilities - they rely on arrows. + */ + @Override + public KidnapperItemSelector.SelectionResult selectItemsForKidnapper() { + return KidnapperItemSelector.selectForArcherKidnapper(); + } + + /** + * Check if archer can use a bow (for animation layer). + */ + public boolean canUseBow() { + ItemStack mainHand = this.getMainHandItem(); + return mainHand.getItem() instanceof BowItem; + } + + // ======================================== + // BOW RESTORATION + // ======================================== + + /** + * Override clearHeldItems to restore the bow. + * Archer should always have bow in hand when not actively capturing. + */ + @Override + public void clearHeldItems() { + // Restore bow in main hand instead of clearing + this.setItemSlot( + net.minecraft.world.entity.EquipmentSlot.MAINHAND, + new ItemStack(Items.BOW) + ); + + // Clear off hand + this.setItemSlot( + net.minecraft.world.entity.EquipmentSlot.OFFHAND, + ItemStack.EMPTY + ); + + TiedUpMod.LOGGER.debug( + "[EntityKidnapperArcher] {} restored bow after capture phase", + this.getNpcName() + ); + } + + // ======================================== + // DIALOGUE SPEAKER (Archer-specific) + // ======================================== + + @Override + public SpeakerType getSpeakerType() { + return SpeakerType.KIDNAPPER_ARCHER; + } + + @Override + public int getSpeakerMood() { + // Archers are calm and precise + if (this.isAiming()) { + return 60; // Focused + } + return 55; // Calm + } +} diff --git a/src/main/java/com/tiedup/remake/entities/EntityKidnapperElite.java b/src/main/java/com/tiedup/remake/entities/EntityKidnapperElite.java new file mode 100644 index 0000000..b78d1be --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/EntityKidnapperElite.java @@ -0,0 +1,177 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.core.SettingsAccessor; +import com.tiedup.remake.dialogue.SpeakerType; +import com.tiedup.remake.entities.skins.EliteKidnapperSkinManager; +import com.tiedup.remake.entities.skins.Gender; +import java.util.UUID; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.ai.attributes.AttributeSupplier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.level.Level; + +/** + * Elite Kidnapper - Rare, faster, more dangerous kidnapper variant. + * + * Phase 14.3.6: Elite Kidnappers + * + * Differences from regular EntityKidnapper: + * - Faster movement speed (0.35 vs 0.27) + * - Higher health (40 vs 20) + * - Faster capture time (10 ticks vs 20 ticks) + * - Dark red name color + * - Dedicated elite skins (Suki, Carol, Athena, Evelyn) + * - Rarer spawns + * - Higher item probabilities + * + * Refactored: Uses parent's variant system via virtual methods. + */ +public class EntityKidnapperElite extends EntityKidnapper { + + // ======================================== + // CONSTANTS + // ======================================== + + public static final double ELITE_MAX_HEALTH = 40.0D; + public static final double ELITE_MOVEMENT_SPEED = 0.35D; + public static final double ELITE_KNOCKBACK_RESISTANCE = 0.9D; + public static final double ELITE_FOLLOW_RANGE = 60.0D; + public static final double ELITE_ATTACK_DAMAGE = 8.0D; + public static final int ELITE_CAPTURE_TIME_TICKS = 10; + public static final int ELITE_NAME_COLOR = 0xAA0000; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public EntityKidnapperElite( + EntityType type, + Level level + ) { + super(type, level); + } + + // ======================================== + // ATTRIBUTES + // ======================================== + + /** + * Create elite kidnapper attributes. + * Faster and tougher than regular kidnappers. + */ + public static AttributeSupplier.Builder createAttributes() { + return Mob.createMobAttributes() + .add(Attributes.MAX_HEALTH, ELITE_MAX_HEALTH) + .add(Attributes.MOVEMENT_SPEED, ELITE_MOVEMENT_SPEED) + .add(Attributes.KNOCKBACK_RESISTANCE, ELITE_KNOCKBACK_RESISTANCE) + .add(Attributes.FOLLOW_RANGE, ELITE_FOLLOW_RANGE) + .add(Attributes.ATTACK_DAMAGE, ELITE_ATTACK_DAMAGE); + } + + // ======================================== + // VARIANT SYSTEM - Override virtual methods + // ======================================== + + @Override + public KidnapperVariant lookupVariantById(String variantId) { + return EliteKidnapperSkinManager.CORE.getVariant(variantId); + } + + @Override + public KidnapperVariant computeVariantForEntity(UUID entityUUID) { + Gender preferredGender = SettingsAccessor.getPreferredSpawnGender( + this.level() != null ? this.level().getGameRules() : null); + return EliteKidnapperSkinManager.CORE.getVariantForEntity( + entityUUID, + preferredGender + ); + } + + @Override + public String getVariantTextureFolder() { + return "textures/entity/kidnapper/elite/"; + } + + @Override + public String getDefaultVariantId() { + return "suki"; + } + + @Override + public void applyVariantName(KidnapperVariant variant) { + // Elite variants always use their default name + this.setNpcName(variant.defaultName()); + } + + @Override + public String getVariantNBTKey() { + // Use different key for backward compatibility with existing saves + return "EliteVariantName"; + } + + // ======================================== + // CAPTURE TIMING (Faster for Elite) + // ======================================== + + /** + * Elite kidnappers tie up targets in half the time. + */ + @Override + public int getCaptureBindTime() { + return ELITE_CAPTURE_TIME_TICKS; + } + + /** + * Elite kidnappers gag targets in half the time. + */ + @Override + public int getCaptureGagTime() { + return ELITE_CAPTURE_TIME_TICKS; + } + + // ======================================== + // DISPLAY + // ======================================== + + @Override + public Component getDisplayName() { + return Component.literal(this.getNpcName()).withStyle( + Style.EMPTY.withColor(ELITE_NAME_COLOR) + ); + } + + // ======================================== + // OVERRIDES + // ======================================== + + /** + * Elite kidnappers use higher item probabilities. + * - 100% bind (same as regular) + * - 100% gag (instead of 50%) + * - 80% mittens (instead of 40%) + * - 60% earplugs (instead of 30%) + * - 40% blindfold (instead of 20%) + */ + @Override + public KidnapperItemSelector.SelectionResult selectItemsForKidnapper() { + return KidnapperItemSelector.selectForEliteKidnapper(); + } + + // ======================================== + // DIALOGUE SPEAKER (Elite-specific) + // ======================================== + + @Override + public SpeakerType getSpeakerType() { + return SpeakerType.KIDNAPPER_ELITE; + } + + @Override + public int getSpeakerMood() { + // Elite kidnappers are always confident + return 70; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/EntityKidnapperMerchant.java b/src/main/java/com/tiedup/remake/entities/EntityKidnapperMerchant.java new file mode 100644 index 0000000..6aefd80 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/EntityKidnapperMerchant.java @@ -0,0 +1,1006 @@ +package com.tiedup.remake.entities; + +import static com.tiedup.remake.util.GameConstants.*; +import com.tiedup.remake.v2.BodyRegionV2; + +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.core.SettingsAccessor; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.SpeakerType; +import com.tiedup.remake.entities.ai.ConditionalGoal; +import com.tiedup.remake.entities.ai.kidnapper.*; +import com.tiedup.remake.entities.skins.Gender; +import com.tiedup.remake.entities.skins.MerchantKidnapperSkinManager; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.*; +import com.tiedup.remake.items.clothes.GenericClothes; +import com.tiedup.remake.personality.PersonalityType; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.ICaptor; +import com.tiedup.remake.util.MessageDispatcher; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import javax.annotation.Nullable; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.ai.attributes.AttributeSupplier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.ai.goal.*; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * Kidnapper Merchant - Elite kidnapper who trades mod items for gold. + * + * Behavior: + * - MERCHANT mode (default): Neutral, wanders peacefully, accepts trades + * - HOSTILE mode (when attacked): Full elite kidnapper AI, faster capture + * - Reverts to MERCHANT after 5 minutes OR when attacker is captured/sold + * + * Trading: + * - Sells 8-12 random mod items for gold ingots/nuggets + * - Prices are tier-based (1-2 gold for basic binds, 10-20 for GPS collar) + * - Trades are fixed once generated (persist in NBT) + */ +public class EntityKidnapperMerchant extends EntityKidnapperElite { + + // ======================================== + // MERCHANT STATE + // ======================================== + + public enum MerchantState { + MERCHANT(0), + HOSTILE(1); + + private final int id; + + MerchantState(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public static MerchantState fromId(int id) { + return id == 1 ? HOSTILE : MERCHANT; + } + } + + // ======================================== + // DATA SYNC + // ======================================== + + private static final EntityDataAccessor DATA_MERCHANT_STATE = + SynchedEntityData.defineId( + EntityKidnapperMerchant.class, + EntityDataSerializers.INT + ); + + // ======================================== + // STATE (Server-side only) + // ======================================== + + private MerchantState currentState = MerchantState.MERCHANT; + private List trades = new ArrayList<>(); + private int hostileCooldownTicks = 0; + + @Nullable + private UUID attackerUUID = null; + + @Nullable + private UUID lastSoldCaptiveUUID = null; + + // Track players currently trading with this merchant + private final Set tradingPlayers = new HashSet<>(); + + // Static reverse-lookup: player UUID -> merchant entity UUID + // Used by PlayerDisconnectHandler for O(1) cleanup instead of scanning all entities + private static final ConcurrentHashMap playerToMerchant = + new ConcurrentHashMap<>(); + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public EntityKidnapperMerchant( + EntityType type, + Level level + ) { + super(type, level); + } + + // ======================================== + // ATTRIBUTES (Same as Elite) + // ======================================== + + public static AttributeSupplier.Builder createAttributes() { + return Mob.createMobAttributes() + .add(Attributes.MAX_HEALTH, 40.0D) + .add(Attributes.MOVEMENT_SPEED, 0.35D) + .add(Attributes.KNOCKBACK_RESISTANCE, 0.9D) + .add(Attributes.FOLLOW_RANGE, 60.0D) + .add(Attributes.ATTACK_DAMAGE, 8.0D); // Same as Elite + } + + // ======================================== + // DATA SYNC + // ======================================== + + @Override + protected void defineSynchedData() { + super.defineSynchedData(); + this.entityData.define( + DATA_MERCHANT_STATE, + MerchantState.MERCHANT.getId() + ); + } + + // ======================================== + // INITIALIZATION + // ======================================== + + @Override + public void onAddedToWorld() { + super.onAddedToWorld(); + + // Server-side initialization + if (!this.level().isClientSide) { + // Generate trades on first spawn + if (trades.isEmpty()) { + generateRandomTrades(); + TiedUpMod.LOGGER.info( + "[EntityKidnapperMerchant] Generated {} trades for merchant at {}", + trades.size(), + this.blockPosition() + ); + } + } + + // Set custom name with gold color when in merchant mode + updateCustomName(); + } + + /** + * Update the custom name to show merchant status. + */ + private void updateCustomName() { + if (currentState == MerchantState.MERCHANT) { + // Gold-colored name with symbols using ChatFormatting.GOLD + this.setCustomName( + Component.literal("⚜ " + this.getNpcName() + " ⚜") + .withStyle(net.minecraft.ChatFormatting.GOLD) + .withStyle(net.minecraft.ChatFormatting.BOLD) + ); + this.setCustomNameVisible(true); + } else { + // In hostile mode, show name in dark red (same as elite) + this.setCustomName( + Component.literal(this.getNpcName()).withStyle( + net.minecraft.network.chat.Style.EMPTY.withColor(0xAA0000) + ) + ); + this.setCustomNameVisible(true); + } + } + + // ======================================== + // AI GOALS (Conditional) + // ======================================== + + @Override + protected void registerGoals() { + // DON'T call super - we need full control over goals + + // Priority 0: Always swim + this.goalSelector.addGoal(0, new FloatGoal(this)); + + // Priority 1-10: Aggressive goals (ONLY when hostile) + this.goalSelector.addGoal( + 1, + new ConditionalGoal( + new KidnapperFightBackGoal(this), + this::isHostile + ) + ); + + this.goalSelector.addGoal( + 2, + new ConditionalGoal( + new KidnapperFindTargetGoal(this, 25), + this::isHostile + ) + ); + + this.goalSelector.addGoal( + 3, + new ConditionalGoal(new KidnapperCaptureGoal(this), this::isHostile) + ); + + this.goalSelector.addGoal( + 4, + new ConditionalGoal( + new KidnapperBringToCellGoal(this), + this::isHostile + ) + ); + + // Priority 5: DecideNextAction - Active in BOTH modes + // This goal decides if the merchant should sell or do a job with the captive + // Must be active when reverting to MERCHANT mode after capturing attacker + this.goalSelector.addGoal(5, new KidnapperDecideNextActionGoal(this)); + + this.goalSelector.addGoal( + 6, + new ConditionalGoal(new KidnapperPatrolGoal(this), this::isHostile) + ); + + this.goalSelector.addGoal( + 7, + new ConditionalGoal( + new KidnapperFleeWithCaptiveGoal(this), + this::isHostile + ) + ); + + this.goalSelector.addGoal( + 8, + new ConditionalGoal( + new KidnapperFleeSafeGoal(this), + this::isHostile + ) + ); + + // Priority 9-10: Sale and Job goals (active in MERCHANT mode) + // These should work when merchant has a captive, regardless of hostile state + this.goalSelector.addGoal( + 9, + new ConditionalGoal(new KidnapperWaitForBuyerGoal(this), () -> + !isHostile() + ) + ); + + this.goalSelector.addGoal( + 10, + new ConditionalGoal(new KidnapperWaitForJobGoal(this), () -> + !isHostile() + ) + ); + + // Priority 11-14: Peaceful goals (only when not trading) + this.goalSelector.addGoal( + 11, + new ConditionalGoal( + new WaterAvoidingRandomStrollGoal(this, 1.0D), + () -> !isTrading() + ) + ); + this.goalSelector.addGoal( + 12, + new LookAtPlayerGoal(this, Player.class, 8.0F) + ); + this.goalSelector.addGoal( + 13, + new ConditionalGoal(new RandomLookAroundGoal(this), () -> + !isTrading() + ) + ); + this.goalSelector.addGoal(14, new OpenDoorGoal(this, false)); + } + + // ======================================== + // STATE TRANSITIONS + // ======================================== + + @Override + public boolean hurt(DamageSource source, float amount) { + boolean result = super.hurt(source, amount); + + // Transition to hostile when attacked + if ( + !level().isClientSide && + source.getEntity() instanceof LivingEntity attacker + ) { + if (currentState == MerchantState.MERCHANT) { + transitionToHostile(attacker); + } + } + + return result; + } + + /** + * Override equip to detect restraint attempts and become hostile. + * Consolidates former putBindOn/putGagOn/putBlindfoldOn overrides. + */ + @Override + public void equip(BodyRegionV2 region, ItemStack stack) { + if (region == BodyRegionV2.ARMS || region == BodyRegionV2.MOUTH || region == BodyRegionV2.EYES) { + if (!level().isClientSide && currentState == MerchantState.MERCHANT && !stack.isEmpty()) { + LivingEntity attacker = findNearbyAttacker(); + if (attacker != null) { + transitionToHostile(attacker); + } + } + } + super.equip(region, stack); + } + + /** + * Find nearby player who might be the one trying to restrain us. + */ + private LivingEntity findNearbyAttacker() { + // Look for players within 5 blocks + List nearbyPlayers = this.level().getEntitiesOfClass( + Player.class, + this.getBoundingBox().inflate(5.0), + player -> !player.isSpectator() + ); + + // Return closest player + if (!nearbyPlayers.isEmpty()) { + return nearbyPlayers.get(0); + } + + return null; + } + + private void transitionToHostile(LivingEntity attacker) { + currentState = MerchantState.HOSTILE; + attackerUUID = attacker.getUUID(); + hostileCooldownTicks = ModConfig.SERVER.merchantHostileDuration.get(); + entityData.set(DATA_MERCHANT_STATE, MerchantState.HOSTILE.getId()); + + // Equip kidnapper items + setUpHeldItems(); + + // Update name (hide merchant title) + updateCustomName(); + + // Talk to nearby players + talkToPlayersInRadius("You'll regret that!", 20); + + TiedUpMod.LOGGER.info( + "[EntityKidnapperMerchant] {} transitioned to HOSTILE (attacked by {})", + this.getName().getString(), + attacker.getName().getString() + ); + } + + @Override + public void tick() { + super.tick(); + + // Handle hostile cooldown + if (!level().isClientSide && currentState == MerchantState.HOSTILE) { + hostileCooldownTicks--; + + if (hostileCooldownTicks <= 0 || wasAttackerCaptured()) { + revertToMerchant(); + } + } + + // Spawn golden particles when in merchant mode (client-side) + if ( + level().isClientSide && + currentState == MerchantState.MERCHANT && + this.random.nextFloat() < MERCHANT_SPARKLE_PARTICLE_CHANCE + ) { + // Golden sparkle particles around the merchant using DustParticleOptions + double x = + this.getX() + + (this.random.nextDouble() - 0.5) * MERCHANT_PARTICLE_SPREAD_XZ; + double y = + this.getY() + + this.random.nextDouble() * MERCHANT_PARTICLE_SPREAD_Y; + double z = + this.getZ() + + (this.random.nextDouble() - 0.5) * MERCHANT_PARTICLE_SPREAD_XZ; + + // Gold color (R=1.0, G=0.843, B=0.0) with size 1.0 + net.minecraft.core.particles.DustParticleOptions goldDust = + new net.minecraft.core.particles.DustParticleOptions( + new org.joml.Vector3f(1.0F, 0.843F, 0.0F), + 1.0F + ); + + this.level().addParticle(goldDust, x, y, z, 0.0, 0.02, 0.0); + } + } + + private boolean wasAttackerCaptured() { + if (attackerUUID == null) return false; + + // Check if current captive is the attacker + IBondageState captive = getCaptive(); + if ( + captive != null && + captive.getKidnappedUniqueId().equals(attackerUUID) + ) { + return true; + } + + // Check if we sold the attacker + if ( + lastSoldCaptiveUUID != null && + lastSoldCaptiveUUID.equals(attackerUUID) + ) { + return true; + } + + return false; + } + + private void revertToMerchant() { + currentState = MerchantState.MERCHANT; + attackerUUID = null; + lastSoldCaptiveUUID = null; + hostileCooldownTicks = 0; + entityData.set(DATA_MERCHANT_STATE, MerchantState.MERCHANT.getId()); + + // Clear aggressive behavior + setTarget(null); + clearHeldItems(); + + // Update name (show merchant title) + updateCustomName(); + + // Talk to nearby players + talkToPlayersInRadius("Alright... business as usual.", 20); + + TiedUpMod.LOGGER.info( + "[EntityKidnapperMerchant] {} reverted to MERCHANT", + this.getName().getString() + ); + } + + // ======================================== + // VARIANT SYSTEM - Override virtual methods + // ======================================== + + @Override + public KidnapperVariant lookupVariantById(String variantId) { + return MerchantKidnapperSkinManager.CORE.getVariant(variantId); + } + + @Override + public KidnapperVariant computeVariantForEntity(UUID entityUUID) { + Gender preferredGender = SettingsAccessor.getPreferredSpawnGender( + this.level() != null ? this.level().getGameRules() : null); + return MerchantKidnapperSkinManager.CORE.getVariantForEntity( + entityUUID, + preferredGender + ); + } + + @Override + public String getVariantTextureFolder() { + return "textures/entity/kidnapper/merchant/"; + } + + @Override + public String getDefaultVariantId() { + return "goldy"; + } + + @Override + public void applyVariantName(KidnapperVariant variant) { + // Merchant variants always use their default name + this.setNpcName(variant.defaultName()); + } + + @Override + public String getVariantNBTKey() { + // Use different key for backward compatibility with existing saves + return "MerchantVariantName"; + } + + // ======================================== + // DISPLAY + // ======================================== + + /** + * Override getDisplayName to use customName when in merchant mode. + * This ensures the gold-colored "⚜ Merchant ⚜" is displayed. + */ + @Override + public Component getDisplayName() { + // In merchant mode, use the custom name (which has gold color) + if (currentState == MerchantState.MERCHANT && this.hasCustomName()) { + return this.getCustomName(); + } + // In hostile mode, use parent's display name logic + return super.getDisplayName(); + } + + // ======================================== + // SALE OVERRIDE (Track sold attacker) + // ======================================== + + @Override + public boolean completeSale(ICaptor buyer) { + IBondageState captive = getCaptive(); + if (captive != null) { + lastSoldCaptiveUUID = captive.getKidnappedUniqueId(); + } + return super.completeSale(buyer); + } + + // ======================================== + // TRADE GENERATION + // ======================================== + + private void generateRandomTrades() { + // ======================================== + // GUARANTEED UTILITIES (always available) + // ======================================== + addGuaranteedUtilities(); + + // ======================================== + // RANDOM TRADES + // ======================================== + int min = ModConfig.SERVER.merchantMinTrades.get(); + int max = ModConfig.SERVER.merchantMaxTrades.get(); + int count = min + this.random.nextInt(Math.max(1, max - min + 1)); + + // Collect all mod items + List items = collectAllModItems(); + + // Shuffle manually (Collections.shuffle doesn't accept RandomSource) + for (int i = items.size() - 1; i > 0; i--) { + int j = this.random.nextInt(i + 1); + ItemStack temp = items.get(i); + items.set(i, items.get(j)); + items.set(j, temp); + } + + // Generate trades + for (int i = 0; i < Math.min(count, items.size()); i++) { + MerchantTrade trade = generateRandomPriceForItem(items.get(i)); + trades.add(trade); + } + + TiedUpMod.LOGGER.debug( + "[EntityKidnapperMerchant] Generated {} trades ({} utilities + {} random)", + trades.size(), + getUtilityCount(), + trades.size() - getUtilityCount() + ); + } + + /** + * Add guaranteed utility items that are ALWAYS available. + * These are essential tools that players need access to. + */ + private void addGuaranteedUtilities() { + // Collar Key - needed to link to collared entities (Tier 2 pricing: 3-6 gold) + trades.add( + new MerchantTrade( + new ItemStack(ModItems.COLLAR_KEY.get()), + 4, + 0 // 4 gold ingots + ) + ); + + // Command Wand - needed to give commands to NPCs (Tier 3 pricing: 5-10 gold) + trades.add( + new MerchantTrade( + new ItemStack(ModItems.COMMAND_WAND.get()), + 6, + 0 // 6 gold ingots + ) + ); + + // Lockpick - useful utility (Tier 2 pricing) + trades.add( + new MerchantTrade( + new ItemStack(ModItems.LOCKPICK.get()), + 3, + 0 // 3 gold ingots + ) + ); + + // Cell Core - essential for building cells (expensive, Tier 4 pricing) + trades.add( + new MerchantTrade( + new ItemStack( + com.tiedup.remake.blocks.ModBlocks.CELL_CORE.get().asItem() + ), + 12, + 0 // 12 gold ingots + ) + ); + } + + private int getUtilityCount() { + return 4; // Number of guaranteed utilities + } + + private List collectAllModItems() { + List items = new ArrayList<>(); + + // All binds (15) + // Items with colors get multiple variants (one per color) + for (BindVariant variant : BindVariant.values()) { + if (variant.supportsColor()) { + // Add one item per color (16 standard colors) + for (ItemColor color : ItemColor.values()) { + if ( + color != ItemColor.CAUTION && color != ItemColor.CLEAR + ) { + ItemStack stack = new ItemStack( + ModItems.getBind(variant) + ); + KidnapperItemSelector.applyColor(stack, color); + items.add(stack); + } + } + } else { + // No color variants + items.add(new ItemStack(ModItems.getBind(variant))); + } + } + + // All gags (19) + for (GagVariant variant : GagVariant.values()) { + if (variant.supportsColor()) { + // Add one item per color + for (ItemColor color : ItemColor.values()) { + // TAPE_GAG can use caution/clear, others only standard colors + if ( + variant == GagVariant.TAPE_GAG || + (color != ItemColor.CAUTION && color != ItemColor.CLEAR) + ) { + ItemStack stack = new ItemStack( + ModItems.getGag(variant) + ); + KidnapperItemSelector.applyColor(stack, color); + items.add(stack); + } + } + } else { + items.add(new ItemStack(ModItems.getGag(variant))); + } + } + + // All blindfolds (2) - BOTH support colors + for (BlindfoldVariant variant : BlindfoldVariant.values()) { + if (variant.supportsColor()) { + // Add one item per color (16 standard colors) + for (ItemColor color : ItemColor.values()) { + if ( + color != ItemColor.CAUTION && color != ItemColor.CLEAR + ) { + ItemStack stack = new ItemStack( + ModItems.getBlindfold(variant) + ); + KidnapperItemSelector.applyColor(stack, color); + items.add(stack); + } + } + } else { + items.add(new ItemStack(ModItems.getBlindfold(variant))); + } + } + + // Earplugs - no color support + for (EarplugsVariant variant : EarplugsVariant.values()) { + items.add(new ItemStack(ModItems.getEarplugs(variant))); + } + + // Mittens - no color support + for (MittensVariant variant : MittensVariant.values()) { + items.add(new ItemStack(ModItems.getMittens(variant))); + } + + // Knives - no color support + for (KnifeVariant variant : KnifeVariant.values()) { + items.add(new ItemStack(ModItems.getKnife(variant))); + } + + // Complex items + items.add(new ItemStack(ModItems.CLASSIC_COLLAR.get())); + items.add(new ItemStack(ModItems.SHOCK_COLLAR.get())); + items.add(new ItemStack(ModItems.GPS_COLLAR.get())); + items.add(new ItemStack(ModItems.WHIP.get())); + // BLACKLIST: TASER (too powerful) + // BLACKLIST: LOCKPICK (now in guaranteed utilities) + // BLACKLIST: MASTER_KEY (too OP - unlocks everything) + items.add(new ItemStack(ModItems.MEDICAL_GAG.get())); + items.add(new ItemStack(ModItems.HOOD.get())); + items.add(new ItemStack(ModItems.CLOTHES.get())); + + return items; + } + + private MerchantTrade generateRandomPriceForItem(ItemStack item) { + int tier = getItemTier(item); + + // Calculate base price in nuggets + int minPrice, maxPrice; + switch (tier) { + case 4: + minPrice = ModConfig.SERVER.tier4PriceMin.get(); + maxPrice = ModConfig.SERVER.tier4PriceMax.get(); + break; + case 3: + minPrice = ModConfig.SERVER.tier3PriceMin.get(); + maxPrice = ModConfig.SERVER.tier3PriceMax.get(); + break; + case 2: + minPrice = ModConfig.SERVER.tier2PriceMin.get(); + maxPrice = ModConfig.SERVER.tier2PriceMax.get(); + break; + case 1: + default: + minPrice = ModConfig.SERVER.tier1PriceMin.get(); + maxPrice = ModConfig.SERVER.tier1PriceMax.get(); + break; + } + + int baseNuggets = + minPrice + + this.random.nextInt(Math.max(1, maxPrice - minPrice + 1)); + + // Randomly split into ingots + nuggets + int ingots = this.random.nextBoolean() ? baseNuggets / 9 : 0; + int nuggets = baseNuggets - (ingots * 9); + + return new MerchantTrade(item.copy(), ingots, nuggets); + } + + private int getItemTier(ItemStack item) { + Item i = item.getItem(); + + // Tier 4: GPS collar + if (i == ModItems.GPS_COLLAR.get()) { + return 4; + } + + // Tier 3: Shock collar, taser, master key + if ( + i == ModItems.SHOCK_COLLAR.get() || + i == ModItems.TASER.get() || + i == ModItems.MASTER_KEY.get() + ) { + return 3; + } + + // Tier 2: Collars, whip, tools, complex items, clothes + if ( + i == ModItems.CLASSIC_COLLAR.get() || + i == ModItems.WHIP.get() || + i == ModItems.LOCKPICK.get() || + i == ModItems.MEDICAL_GAG.get() || + i == ModItems.HOOD.get() || + i instanceof GenericClothes + ) { + return 2; + } + + // Tier 1: All other items (binds, gags, blindfolds, knives, etc.) + return 1; + } + + // ======================================== + // NBT PERSISTENCE + // ======================================== + + @Override + public void addAdditionalSaveData(CompoundTag tag) { + super.addAdditionalSaveData(tag); + + tag.putInt("MerchantState", currentState.getId()); + tag.putInt("HostileCooldown", hostileCooldownTicks); + + if (attackerUUID != null) { + tag.putUUID("AttackerUUID", attackerUUID); + } + + if (lastSoldCaptiveUUID != null) { + tag.putUUID("LastSoldCaptiveUUID", lastSoldCaptiveUUID); + } + + // Variant is saved by parent via getVariantNBTKey() + + // Save trades + ListTag tradesTag = new ListTag(); + for (MerchantTrade trade : trades) { + tradesTag.add(trade.save()); + } + tag.put("Trades", tradesTag); + } + + @Override + public void readAdditionalSaveData(CompoundTag tag) { + super.readAdditionalSaveData(tag); + + if (tag.contains("MerchantState")) { + currentState = MerchantState.fromId(tag.getInt("MerchantState")); + entityData.set(DATA_MERCHANT_STATE, currentState.getId()); + } + + hostileCooldownTicks = tag.getInt("HostileCooldown"); + + if (tag.contains("AttackerUUID")) { + attackerUUID = tag.getUUID("AttackerUUID"); + } + + if (tag.contains("LastSoldCaptiveUUID")) { + lastSoldCaptiveUUID = tag.getUUID("LastSoldCaptiveUUID"); + } + + // Variant is restored by parent via getVariantNBTKey() and lookupVariantById() + + // Restore trades + trades.clear(); + if (tag.contains("Trades")) { + ListTag tradesTag = tag.getList("Trades", 10); // 10 = CompoundTag + for (int i = 0; i < tradesTag.size(); i++) { + trades.add(MerchantTrade.load(tradesTag.getCompound(i))); + } + } + } + + // ======================================== + // PUBLIC ACCESSORS + // ======================================== + + public boolean isHostile() { + return currentState == MerchantState.HOSTILE; + } + + public boolean isMerchant() { + return currentState == MerchantState.MERCHANT; + } + + public List getTrades() { + return new ArrayList<>(trades); + } + + /** + * Check if the merchant is currently trading with a player. + */ + public boolean isTrading() { + return !tradingPlayers.isEmpty(); + } + + /** + * Mark that a player has opened the trading screen. + */ + public void startTrading(UUID playerUUID) { + tradingPlayers.add(playerUUID); + playerToMerchant.put(playerUUID, this.getUUID()); + TiedUpMod.LOGGER.debug( + "[EntityKidnapperMerchant] Player {} started trading", + playerUUID + ); + } + + /** + * Mark that a player has closed the trading screen. + */ + public void stopTrading(UUID playerUUID) { + tradingPlayers.remove(playerUUID); + playerToMerchant.remove(playerUUID); + TiedUpMod.LOGGER.debug( + "[EntityKidnapperMerchant] Player {} stopped trading", + playerUUID + ); + } + + /** + * BUG FIX: Clean up trading player on disconnect to prevent memory leak. + * Called from PlayerDisconnectHandler. + */ + public void cleanupTradingPlayer(UUID playerUUID) { + tradingPlayers.remove(playerUUID); + playerToMerchant.remove(playerUUID); + } + + /** + * Get the merchant entity UUID for a given trading player. + * Used for O(1) lookup on disconnect instead of scanning all entities. + */ + @Nullable + public static UUID getMerchantForPlayer(UUID playerUUID) { + return playerToMerchant.get(playerUUID); + } + + // ======================================== + // UTILITY + // ======================================== + + @Override + public void clearHeldItems() { + this.setItemInHand( + net.minecraft.world.InteractionHand.MAIN_HAND, + ItemStack.EMPTY + ); + this.setItemInHand( + net.minecraft.world.InteractionHand.OFF_HAND, + ItemStack.EMPTY + ); + } + + public void talkToPlayersInRadius(String message, double radius) { + MessageDispatcher.talkToNearby(this, message, radius); + } + + // ======================================== + // DIALOGUE SPEAKER (Merchant-specific) + // ======================================== + + @Override + public SpeakerType getSpeakerType() { + return SpeakerType.MERCHANT; + } + + @Override + public PersonalityType getSpeakerPersonality() { + // Personality changes based on mode + if (this.isHostile()) { + return PersonalityType.FIERCE; // Vengeful when attacked + } + return PersonalityType.CURIOUS; // Greedy/business-oriented in merchant mode + } + + @Override + public int getSpeakerMood() { + if (this.isHostile()) { + return 30; // Angry + } + // In merchant mode, mood depends on having trades + if (!trades.isEmpty()) { + return 75; // Good business + } + return 60; // Waiting for customers + } + + @Override + public String getTargetRelation(Player player) { + // If trading with player + if (tradingPlayers.contains(player.getUUID())) { + return "customer"; + } + // Fall back to parent's implementation + return super.getTargetRelation(player); + } + + // No die() override needed: vanilla die() calls remove(KILLED), so this + // remove() override already handles death cleanup. Parent EntityKidnapper.die() + // handles captive freeing and loot drops. + @Override + public void remove(RemovalReason reason) { + // Clear trading players to prevent dangling references + if (!this.level().isClientSide) { + int count = tradingPlayers.size(); + this.tradingPlayers.clear(); + if (count > 0) { + TiedUpMod.LOGGER.debug( + "[EntityKidnapperMerchant] {} clearing {} trading players on removal", + getNpcName(), + count + ); + } + } + super.remove(reason); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/EntityLaborGuard.java b/src/main/java/com/tiedup/remake/entities/EntityLaborGuard.java new file mode 100644 index 0000000..3cbffaf --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/EntityLaborGuard.java @@ -0,0 +1,661 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.SpeakerType; +import com.tiedup.remake.entities.ai.guard.GuardFightBackGoal; +import com.tiedup.remake.entities.ai.guard.GuardFollowPrisonerGoal; +import com.tiedup.remake.entities.ai.guard.GuardHuntMonstersGoal; +import com.tiedup.remake.entities.ai.guard.GuardMonitorGoal; +import com.tiedup.remake.entities.skins.LaborGuardSkinManager; +import com.tiedup.remake.labor.LaborTask; +import com.tiedup.remake.prison.LaborRecord; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.service.PrisonerService; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.util.MessageDispatcher; +import com.tiedup.remake.util.RestraintApplicator; +import java.util.List; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.ai.attributes.AttributeSupplier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.ai.goal.FloatGoal; +import net.minecraft.world.entity.ai.goal.LookAtPlayerGoal; +import net.minecraft.world.entity.ai.goal.MeleeAttackGoal; +import net.minecraft.world.entity.ai.goal.RandomLookAroundGoal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; + +/** + * Labor Guard entity - Physical guard that follows and monitors a prisoner during labor. + * + * Extends EntityDamsel for the player model, skin system, and IRestrainable interface + * (the guard itself can be captured/tied up as an escape mechanic). + * + * Spawned by MaidExtractGoal after extraction, despawned by MaidReturnGoal when + * the prisoner returns to cell. + */ +public class EntityLaborGuard extends EntityDamsel { + + // ==================== CONSTANTS ==================== + + /** Distance at which prisoner gets warning + escape countdown starts */ + public static final double WARNING_RADIUS = 20.0; + + /** Ticks before escape after leaving warning radius (15 seconds) */ + public static final int ESCAPE_COUNTDOWN_TICKS = 300; + + /** Distance at which guard teleports to prisoner */ + public static final double TELEPORT_DISTANCE = 32.0; + + /** Guard name color — steel blue */ + public static final int GUARD_NAME_COLOR = 0x4682B4; + + // ==================== SERVER FIELDS ==================== + + @Nullable + private UUID prisonerUUID; + + @Nullable + private UUID campId; + + @Nullable + private UUID spawnerMaidId; + + /** Prevents registerGoals from being called twice by parent constructor */ + private boolean goalsRegistered = false; + + /** Prevents triggerEscapeOnIncapacitated from firing multiple times */ + private boolean escapeTriggered = false; + + /** Cooldown to prevent double-speak from Forge's dual interaction packets */ + private long lastInteractTick = -100; + + /** Set by GuardMonitorGoal when prisoner needs physical punishment */ + private boolean needsWhip = false; + + // ==================== CONSTRUCTOR ==================== + + public EntityLaborGuard( + EntityType type, + Level level + ) { + super(type, level); + } + + // ==================== ATTRIBUTES ==================== + + public static AttributeSupplier.Builder createAttributes() { + return Mob.createMobAttributes() + .add(Attributes.MAX_HEALTH, 30.0) + .add(Attributes.MOVEMENT_SPEED, 0.30) + .add(Attributes.ATTACK_DAMAGE, 6.0) + .add(Attributes.FOLLOW_RANGE, 40.0) + .add(Attributes.KNOCKBACK_RESISTANCE, 0.5); + } + + // ==================== AI GOALS ==================== + + /** + * Override registerGoals to set up guard-specific AI. + * Complete override - does not use DamselAIController goals. + * + * IMPORTANT: This is called twice during construction: + * 1. By Mob() constructor (via super chain) + * 2. By EntityDamsel() constructor at line 455 + * We use a flag to ensure goals are only registered once. + */ + @Override + protected void registerGoals() { + if (this.goalsRegistered) { + return; + } + // Also skip if called during Mob's constructor before goalSelector is set + // (shouldn't happen, but safety check) + if (this.goalSelector == null) { + return; + } + + this.goalSelector.addGoal(0, new FloatGoal(this)); + this.goalSelector.addGoal(1, new GuardHuntMonstersGoal(this)); + this.goalSelector.addGoal(2, new GuardFightBackGoal(this)); + this.goalSelector.addGoal(3, new MeleeAttackGoal(this, 1.2, false)); + this.goalSelector.addGoal(4, new GuardFollowPrisonerGoal(this)); + this.goalSelector.addGoal( + 5, + new com.tiedup.remake.entities.ai.guard.GuardWhipGoal(this) + ); + this.goalSelector.addGoal(6, new GuardMonitorGoal(this)); + this.goalSelector.addGoal( + 8, + new LookAtPlayerGoal(this, Player.class, 8.0F) + ); + this.goalSelector.addGoal(9, new RandomLookAroundGoal(this)); + + // Register command goals so the guard responds to player commands + com.tiedup.remake.entities.damsel.components.DamselAIController.registerCommandGoals( + this.goalSelector, + this, + 7 + ); + + this.goalsRegistered = true; + } + + // ==================== SKIN TEXTURE ==================== + + /** + * Override to use guard/ texture folder instead of damsel/. + * DamselAppearance.getSkinTexture() hardcodes "textures/entity/damsel/", + * so we must override to point to "textures/entity/guard/". + */ + @Override + public ResourceLocation getSkinTexture() { + String variantId = this.getVariantId(); + if (!variantId.isEmpty()) { + return ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/guard/" + variantId + ".png" + ); + } + // Fallback to first registered guard skin + DamselVariant fallback = LaborGuardSkinManager.CORE.getVariantForEntity( + this.getUUID() + ); + if (fallback != null) { + return fallback.texture(); + } + return ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/guard/feifei.png" + ); + } + + // ==================== VARIANT SYSTEM ==================== + + /** + * Override to use LaborGuardSkinManager instead of DamselSkinManager. + */ + @Override + public void onAddedToWorld() { + super.onAddedToWorld(); + + // Override variant with guard skin (server-side only) + if ( + !this.level().isClientSide && + (this.getVariantId().isEmpty() || + LaborGuardSkinManager.CORE.getVariant(this.getVariantId()) == + null) + ) { + DamselVariant variant = + LaborGuardSkinManager.CORE.getVariantForEntity(this.getUUID()); + if (variant != null) { + this.setVariant(variant); + } + } + + // Safety: ensure guard always has a name (belt-and-suspenders for variant loading failures) + if (!this.level().isClientSide && this.getNpcName().isEmpty()) { + this.setNpcName("Guard"); + } + } + + // ==================== DISPLAY ==================== + + @Override + public Component getDisplayName() { + return Component.literal(this.getNpcName()).withStyle( + Style.EMPTY.withColor(GUARD_NAME_COLOR) + ); + } + + // ==================== DIALOGUE (IDialogueSpeaker overrides) ==================== + + @Override + public SpeakerType getSpeakerType() { + return SpeakerType.GUARD; + } + + @Override + @Nullable + public com.tiedup.remake.personality.PersonalityType getSpeakerPersonality() { + return com.tiedup.remake.personality.PersonalityType.FIERCE; + } + + @Override + public int getSpeakerMood() { + return 50; // Neutral + } + + @Override + @Nullable + public String getTargetRelation(Player player) { + if (prisonerUUID != null && player.getUUID().equals(prisonerUUID)) { + return "prisoner"; + } + return null; + } + + // ==================== TICK ==================== + + @Override + public void tick() { + super.tick(); + + if ( + !this.level().isClientSide && + this.level() instanceof ServerLevel level + ) { + // Auto-cleanup: if tied up, trigger escape (guard is incapacitated) + if (this.isTiedUp() && !escapeTriggered) { + triggerEscapeOnIncapacitated(level); + return; + } + + // Orphan/duplication check every 5 seconds + if (prisonerUUID != null && tickCount % 100 == 0) { + PrisonerManager manager = PrisonerManager.get(level); + LaborRecord labor = manager.getLaborRecord(prisonerUUID); + UUID assignedGuard = labor.getGuardId(); + + if (assignedGuard == null) { + // Guard reference was cleared (escape, reset, etc.) - orphan, discard self + TiedUpMod.LOGGER.info( + "[EntityLaborGuard] Orphan guard detected (guardId=null), discarding self {}", + this.getUUID().toString().substring(0, 8) + ); + this.discard(); + return; + } + + if (!assignedGuard.equals(this.getUUID())) { + // Different guard is assigned - duplicate, discard self + TiedUpMod.LOGGER.warn( + "[EntityLaborGuard] Duplicate guard detected, discarding self (assigned={}, self={})", + assignedGuard.toString().substring(0, 8), + this.getUUID().toString().substring(0, 8) + ); + this.discard(); + return; + } + } + } + } + + // ==================== DAMAGE HANDLING ==================== + + /** + * Override hurt to: + * 1. Reduce monster damage by 50% + * 2. Punish prisoner if they attack the guard + */ + @Override + public boolean hurt(DamageSource source, float amount) { + if (!this.level().isClientSide) { + // 50% damage reduction from monsters + if ( + source.getEntity() instanceof + net.minecraft.world.entity.monster.Monster + ) { + amount *= 0.5f; + } + + // If attacked by the prisoner, punish them (don't fight back) + if ( + source.getEntity() instanceof ServerPlayer player && + prisonerUUID != null && + player.getUUID().equals(prisonerUUID) + ) { + punishPrisonerAttack(player); + return false; // Guard ignores prisoner damage + } + } + + return super.hurt(source, amount); + } + + /** + * Punish prisoner for attacking the guard. + */ + private void punishPrisonerAttack(ServerPlayer prisoner) { + IRestrainable cap = KidnappedHelper.getKidnappedState(prisoner); + if (cap != null) { + cap.shockKidnapped("Don't touch your guard!", 2.0f); + RestraintApplicator.tightenBind(cap, prisoner); + } + + prisoner.sendSystemMessage( + Component.literal("Attacking your guard is punished!").withStyle( + ChatFormatting.DARK_RED + ) + ); + } + + // ==================== INTERACTION ==================== + + @Override + protected InteractionResult mobInteract( + Player player, + InteractionHand hand + ) { + if (hand != InteractionHand.MAIN_HAND) { + return super.mobInteract(player, hand); + } + + if ( + !this.level().isClientSide && + player instanceof ServerPlayer serverPlayer && + prisonerUUID != null && + player.getUUID().equals(prisonerUUID) && + this.level() instanceof ServerLevel serverLevel + ) { + // Cooldown: Forge sends INTERACT_AT + INTERACT per right-click + long currentTick = this.tickCount; + if (currentTick - lastInteractTick < 5) { + return InteractionResult.SUCCESS; + } + lastInteractTick = currentTick; + + PrisonerManager manager = PrisonerManager.get(serverLevel); + LaborRecord labor = manager.getLaborRecord(prisonerUUID); + LaborTask task = labor.getTask(); + LaborRecord.WorkPhase phase = labor.getPhase(); + + if (phase == LaborRecord.WorkPhase.WORKING) { + if (task != null) { + // Refresh progress before showing + task.checkProgress(serverPlayer, serverLevel); + + int progress = task.getProgress(); + int quota = task.getQuota(); + int percent = task.getProgressPercent(); + guardSay( + serverPlayer, + "Task: " + + task.getDescription() + + " — " + + progress + + "/" + + quota + + " (" + + percent + + "%)" + ); + } else { + guardSay(serverPlayer, "Get to work!"); + } + + showCampDirection(serverPlayer, serverLevel); + return InteractionResult.SUCCESS; + } + + if (phase == LaborRecord.WorkPhase.PENDING_RETURN) { + guardSay( + serverPlayer, + "guard.labor.pending_return", + "Walk back to camp. Follow me." + ); + showCampDirection(serverPlayer, serverLevel); + return InteractionResult.SUCCESS; + } + + // Catch-all for other phases (EXTRACTING, RETURNING, etc.) + guardSay(serverPlayer, "Follow me."); + return InteractionResult.SUCCESS; + } + + return super.mobInteract(player, hand); + } + + // ==================== HELPERS ==================== + + /** + * Send a formatted guard speech message to a player. + * Tries JSON dialogue first, falls back to provided message. + */ + public void guardSay( + ServerPlayer player, + String dialogueId, + String fallback + ) { + String text = com.tiedup.remake.dialogue.DialogueBridge.getDialogue( + this, + player, + dialogueId + ); + if (text == null) { + text = fallback; + } + MessageDispatcher.talkTo(this, player, text); + } + + /** + * Send a formatted guard speech message (no dialogue ID, direct message). + */ + public void guardSay(ServerPlayer player, String message) { + MessageDispatcher.talkTo(this, player, message); + } + + /** + * Show camp direction to a player. + */ + private void showCampDirection(ServerPlayer player, ServerLevel level) { + if (campId == null) return; + List campCells = CellRegistryV2.get(level).getCellsByCamp( + campId + ); + if (campCells.isEmpty()) return; + BlockPos campCenter = campCells.get(0).getCorePos(); + String direction = getCardinalDirection( + player.blockPosition(), + campCenter + ); + int distance = (int) Math.sqrt( + player.blockPosition().distSqr(campCenter) + ); + guardSay( + player, + "Camp is " + distance + " blocks to the " + direction + "." + ); + } + + /** + * Get cardinal direction from one position to another. + */ + private static String getCardinalDirection(BlockPos from, BlockPos to) { + int dx = to.getX() - from.getX(); + int dz = to.getZ() - from.getZ(); + + // In Minecraft: -Z = north, +Z = south, +X = east, -X = west + String ns = ""; + String ew = ""; + if (Math.abs(dz) > 2) ns = dz < 0 ? "north" : "south"; + if (Math.abs(dx) > 2) ew = dx > 0 ? "east" : "west"; + + if (!ns.isEmpty() && !ew.isEmpty()) return ns + "-" + ew; + if (!ns.isEmpty()) return ns; + if (!ew.isEmpty()) return ew; + return "nearby"; + } + + // ==================== DEATH / REMOVAL ==================== + + /** + * Shared cleanup: clear guard reference, trigger prisoner escape, notify. + * Guarded by escapeTriggered to prevent double-fire from die() + remove(). + */ + private void performPrisonerCleanup(String reason) { + if (escapeTriggered) return; + if (this.level().isClientSide) return; + if (!(this.level() instanceof ServerLevel level)) return; + if (prisonerUUID == null) return; + + escapeTriggered = true; + + TiedUpMod.LOGGER.info( + "[EntityLaborGuard] Guard {} - triggering escape for prisoner {}", + reason, + prisonerUUID.toString().substring(0, 8) + ); + + // Clear guard reference + PrisonerManager manager = PrisonerManager.get(level); + LaborRecord labor = manager.getLaborRecord(prisonerUUID); + labor.setGuardId(null); + + // Trigger escape + PrisonerService.get().escape(level, prisonerUUID, reason); + + // Notify prisoner + ServerPlayer prisoner = level + .getServer() + .getPlayerList() + .getPlayer(prisonerUUID); + if (prisoner != null) { + prisoner.sendSystemMessage( + Component.literal( + "Your guard has been eliminated! You are free!" + ).withStyle(ChatFormatting.GREEN, ChatFormatting.BOLD) + ); + } + } + + /** + * When the guard dies, trigger IMMEDIATE escape for the prisoner. + */ + @Override + public void die(DamageSource source) { + performPrisonerCleanup("guard eliminated"); + super.die(source); + } + + /** + * Handle non-death removal (e.g. /kill, chunk unload, dimension change). + * DISCARDED is intentional (orphan self-cleanup) — already handled by tick check. + */ + @Override + public void remove(RemovalReason reason) { + if (reason != RemovalReason.DISCARDED) { + performPrisonerCleanup("guard removed (" + reason.name() + ")"); + } + super.remove(reason); + } + + /** + * Trigger escape when the guard is incapacitated (tied up / captured). + */ + private void triggerEscapeOnIncapacitated(ServerLevel level) { + performPrisonerCleanup("guard captured"); + // Discard the guard after triggering escape + this.discard(); + } + + // ==================== GETTERS/SETTERS ==================== + + @Nullable + public UUID getPrisonerUUID() { + return prisonerUUID; + } + + public void setPrisonerUUID(@Nullable UUID prisonerUUID) { + this.prisonerUUID = prisonerUUID; + } + + @Nullable + public UUID getCampId() { + return campId; + } + + public void setCampId(@Nullable UUID campId) { + this.campId = campId; + } + + @Nullable + public UUID getSpawnerMaidId() { + return spawnerMaidId; + } + + public void setSpawnerMaidId(@Nullable UUID spawnerMaidId) { + this.spawnerMaidId = spawnerMaidId; + } + + public boolean needsWhip() { + return needsWhip; + } + + public void setNeedsWhip(boolean needsWhip) { + this.needsWhip = needsWhip; + } + + /** + * Get the prisoner player entity (server-side). + * + * @return The prisoner player, or null if offline or no prisoner assigned + */ + @Nullable + public ServerPlayer getPrisoner() { + if (prisonerUUID == null) return null; + if (!(this.level() instanceof ServerLevel level)) return null; + return level.getServer().getPlayerList().getPlayer(prisonerUUID); + } + + // ==================== NBT PERSISTENCE ==================== + + @Override + public void addAdditionalSaveData(CompoundTag tag) { + super.addAdditionalSaveData(tag); + + if (prisonerUUID != null) { + tag.putUUID("PrisonerUUID", prisonerUUID); + } + if (campId != null) { + tag.putUUID("CampId", campId); + } + if (spawnerMaidId != null) { + tag.putUUID("SpawnerMaidId", spawnerMaidId); + } + } + + @Override + public void readAdditionalSaveData(CompoundTag tag) { + super.readAdditionalSaveData(tag); + + if (tag.contains("PrisonerUUID")) { + this.prisonerUUID = tag.getUUID("PrisonerUUID"); + } + if (tag.contains("CampId")) { + this.campId = tag.getUUID("CampId"); + } + if (tag.contains("SpawnerMaidId")) { + this.spawnerMaidId = tag.getUUID("SpawnerMaidId"); + } + } + + // ==================== DESPAWN PROTECTION ==================== + + @Override + public boolean removeWhenFarAway(double distanceToClosestPlayer) { + return false; + } + + @Override + public boolean isPersistenceRequired() { + return true; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/EntityMaid.java b/src/main/java/com/tiedup/remake/entities/EntityMaid.java new file mode 100644 index 0000000..e59a27b --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/EntityMaid.java @@ -0,0 +1,1040 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.cells.CampMaidManager; +import com.tiedup.remake.cells.CampOwnership; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.SpeakerType; +import com.tiedup.remake.entities.maid.components.MaidPrisonInteraction; +import com.tiedup.remake.entities.maid.components.MaidTraderLink; +import com.tiedup.remake.entities.ai.maid.MaidCollectRansomGoal; +import com.tiedup.remake.entities.ai.maid.MaidDefendTraderGoal; +import com.tiedup.remake.entities.ai.maid.MaidDeliverCaptiveGoal; +import com.tiedup.remake.entities.ai.maid.MaidFetchCaptiveGoal; +import com.tiedup.remake.entities.ai.maid.MaidFollowTraderGoal; +import com.tiedup.remake.entities.ai.maid.MaidState; +import com.tiedup.remake.entities.ai.maid.goals.MaidAssignTaskGoal; +import com.tiedup.remake.entities.ai.maid.goals.MaidExtractGoal; +import com.tiedup.remake.entities.ai.maid.goals.MaidIdleGoal; +// Prison system v2 goals +import com.tiedup.remake.entities.ai.maid.goals.MaidInitPrisonerGoal; +import com.tiedup.remake.entities.ai.maid.goals.MaidReturnGoal; +import com.tiedup.remake.entities.skins.Gender; +import com.tiedup.remake.entities.skins.MaidSkinManager; +import com.tiedup.remake.personality.PersonalityType; +import com.tiedup.remake.prison.LaborRecord; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerRecord; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.util.teleport.Position; +import com.tiedup.remake.util.teleport.TeleportHelper; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.ai.attributes.AttributeSupplier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.ai.goal.*; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +/** + * EntityMaid - Servant of the SlaveTrader who executes orders. + * + * Slave Trader & Maid System + * + * Characteristics: + * - Highest stats in the mod (ultra fast capture) + * - Bound to a SlaveTrader (masterUUID) + * - Executes trader's orders (deliver, collect, fetch) + * - Passive unless trader is threatened + * - When trader dies, becomes neutral and capturable + * + * Skin: Maid outfit + */ +public class EntityMaid extends EntityKidnapperElite { + + // ======================================== + // CONSTANTS + // ======================================== + + public static final double MAID_MAX_HEALTH = 60.0D; + public static final double MAID_MOVEMENT_SPEED = 0.28D; // Reduced from 0.40 - more natural pace + public static final double MAID_KNOCKBACK_RESISTANCE = 0.8D; + public static final double MAID_FOLLOW_RANGE = 50.0D; + public static final double MAID_ATTACK_DAMAGE = 12.0D; + public static final int MAID_CAPTURE_TIME_TICKS = 5; // Ultra fast + public static final int MAID_NAME_COLOR = 0xFF69B4; // Hot Pink + + // ======================================== + // DATA SYNC + // ======================================== + + private static final EntityDataAccessor DATA_MAID_STATE = + SynchedEntityData.defineId( + EntityMaid.class, + EntityDataSerializers.STRING + ); + + // ======================================== + // STATE + // ======================================== + + /** Trader link component — manages master trader lookup, caching, and linking */ + private final MaidTraderLink traderLink; + + /** Prison interaction component — prisoner check-in, extraction, struggle detection */ + private final MaidPrisonInteraction prisonInteraction; + + /** Current behavioral state */ + private MaidState currentState = MaidState.IDLE; + + /** UUID of captive to deliver/fetch */ + @Nullable + private UUID targetCaptiveUUID; + + /** UUID of buyer destination */ + @Nullable + private UUID targetBuyerUUID; + + /** Position to collect items from */ + @Nullable + private BlockPos collectTarget; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public EntityMaid(EntityType type, Level level) { + super(type, level); + this.traderLink = new MaidTraderLink(this); + this.prisonInteraction = new MaidPrisonInteraction(this); + // Configure navigation for better pathfinding in structures + if ( + this.getNavigation() instanceof + net.minecraft.world.entity.ai.navigation.GroundPathNavigation groundNav + ) { + groundNav.setCanOpenDoors(true); + groundNav.setCanPassDoors(true); + } + } + + // ======================================== + // ATTRIBUTES + // ======================================== + + public static AttributeSupplier.Builder createAttributes() { + return Mob.createMobAttributes() + .add(Attributes.MAX_HEALTH, MAID_MAX_HEALTH) + .add(Attributes.MOVEMENT_SPEED, MAID_MOVEMENT_SPEED) + .add(Attributes.KNOCKBACK_RESISTANCE, MAID_KNOCKBACK_RESISTANCE) + .add(Attributes.FOLLOW_RANGE, MAID_FOLLOW_RANGE) + .add(Attributes.ATTACK_DAMAGE, MAID_ATTACK_DAMAGE); + } + + // ======================================== + // DATA SYNC + // ======================================== + + @Override + protected void defineSynchedData() { + super.defineSynchedData(); + this.entityData.define(DATA_MAID_STATE, MaidState.IDLE.name()); + } + + // ======================================== + // AI GOALS + // ======================================== + + @Override + protected void registerGoals() { + // Priority 0: Always swim + this.goalSelector.addGoal(0, new FloatGoal(this)); + + // Priority 1: Defend trader (highest combat priority) + this.goalSelector.addGoal(1, new MaidDefendTraderGoal(this)); + + // Priority 2: Deliver captive to buyer + this.goalSelector.addGoal(2, new MaidDeliverCaptiveGoal(this)); + + // Priority 2: Extract prisoner from cell for labor + this.goalSelector.addGoal(2, new MaidExtractGoal(this)); + + // Priority 2: Return prisoner to cell after labor + this.goalSelector.addGoal(2, new MaidReturnGoal(this)); + + // Priority 3: Collect ransom/items + this.goalSelector.addGoal(3, new MaidCollectRansomGoal(this)); + + // Priority 3: Initialize new prisoners + this.goalSelector.addGoal(3, new MaidInitPrisonerGoal(this)); + + // Priority 3: Assign tasks to idle prisoners + this.goalSelector.addGoal(3, new MaidAssignTaskGoal(this)); + + // Priority 4: Fetch captive from cell (for sale) + this.goalSelector.addGoal(4, new MaidFetchCaptiveGoal(this)); + + // Priority 5: Follow trader + this.goalSelector.addGoal(5, new MaidFollowTraderGoal(this)); + + // Priority 6: Fight back if attacked + this.goalSelector.addGoal( + 6, + new com.tiedup.remake.entities.ai.kidnapper.KidnapperFightBackGoal( + this + ) + ); + + // Priority 7: Melee attack + this.goalSelector.addGoal(7, new MeleeAttackGoal(this, 1.2D, false)); + + // Priority 8: Idle behavior (monitor workers, patrol) + this.goalSelector.addGoal(8, new MaidIdleGoal(this)); + + // FUTURE: Maid command goals require personality system (EntityDamsel-only). + // These were non-functional before (Maids have no PersonalityState). + // Proper fix: Create MaidCommandGoals that use collar owner instead. + // DamselAIController.registerCommandGoals(this.goalSelector, this, 9); + + // Priority 10: Look at players + this.goalSelector.addGoal( + 10, + new LookAtPlayerGoal(this, Player.class, 8.0F) + ); + + // Priority 11: Random look around + this.goalSelector.addGoal(11, new RandomLookAroundGoal(this)); + + // Priority 12: Wander when idle + this.goalSelector.addGoal( + 12, + new WaterAvoidingRandomStrollGoal(this, 0.8D) + ); + } + + // ======================================== + // STATE MANAGEMENT + // ======================================== + + public MaidState getMaidState() { + return currentState; + } + + public void setMaidState(MaidState state) { + if (this.currentState != state) { + TiedUpMod.LOGGER.debug( + "[EntityMaid] {} state change: {} -> {}", + this.getNpcName(), + this.currentState, + state + ); + this.currentState = state; + this.entityData.set(DATA_MAID_STATE, state.name()); + } + } + + /** + * Check if the maid is in freed state (trader dead). + */ + public boolean isFreed() { + return currentState == MaidState.FREE; + } + + // ======================================== + // MASTER TRADER (facades to MaidTraderLink) + // ======================================== + + @Nullable + public UUID getMasterTraderUUID() { + return traderLink.getMasterTraderUUID(); + } + + public void setMasterTraderUUID(@Nullable UUID masterTraderUUID) { + traderLink.setMasterTraderUUID(masterTraderUUID); + } + + /** + * Get the master trader entity. + * Delegates to MaidTraderLink for multi-level lookup. + */ + @Nullable + public EntitySlaveTrader getMasterTrader() { + return traderLink.getMasterTrader(); + } + + /** + * Get the camp UUID this maid belongs to. + * Delegates to MaidTraderLink. + */ + @Nullable + public UUID getCampUUID() { + return traderLink.getCampUUID(); + } + + /** + * Get the trader link component. + * Exposed for direct component access when needed (e.g., testing). + */ + public MaidTraderLink getTraderLink() { + return traderLink; + } + + // ======================================== + // STUCK DETECTION + // ======================================== + + /** Last recorded position for stuck detection */ + private transient Vec3 stuckCheckPos = Vec3.ZERO; + + /** Tick counter for stuck detection */ + private transient int stuckTimer = 0; + + /** Interval for stuck position checks (1 second) */ + private static final int STUCK_CHECK_INTERVAL = 20; + + /** Threshold for stuck detection (30 seconds) */ + private static final int STUCK_THRESHOLD = 600; + + /** Minimum distance to consider moved (blocks) */ + private static final double STUCK_MOVE_THRESHOLD = 2.0; + + // ======================================== + // TICK + // ======================================== + + @Override + public void tick() { + super.tick(); + + // Server-side only: periodic link establishment if no master trader + if ( + !this.level().isClientSide && + this.level() instanceof ServerLevel serverLevel + ) { + traderLink.tickLinkEstablishment(serverLevel); + tickStuckDetection(serverLevel); + + // Check if captured and removed from camp (every second) + if (this.tickCount % 20 == 0) { + checkIfCapturedAndRemoved(serverLevel); + } + } + } + + /** + * Check if the maid is stuck and teleport home if needed. + */ + private void tickStuckDetection(ServerLevel serverLevel) { + // Only check every second + if (tickCount % STUCK_CHECK_INTERVAL != 0) { + return; + } + + // Only check if actively navigating and not tied up + if (!isNavigating() || isTiedUp() || isFreed()) { + stuckTimer = 0; + return; + } + + Vec3 currentPos = position(); + double distMoved = currentPos.distanceTo(stuckCheckPos); + + if (distMoved < STUCK_MOVE_THRESHOLD) { + stuckTimer += STUCK_CHECK_INTERVAL; + + if (stuckTimer >= STUCK_THRESHOLD) { + teleportToHome(serverLevel); + stuckTimer = 0; + } + } else { + stuckTimer = 0; + } + stuckCheckPos = currentPos; + } + + /** + * Check if the maid is actively navigating. + */ + private boolean isNavigating() { + return !getNavigation().isDone(); + } + + /** + * Teleport the maid back to her home camp. + */ + private void teleportToHome(ServerLevel serverLevel) { + EntitySlaveTrader trader = getMasterTrader(); + if (trader != null) { + BlockPos homePos = trader.blockPosition(); + TeleportHelper.teleportEntity( + this, + new Position(homePos, serverLevel.dimension()) + ); + getNavigation().stop(); + setMaidState(MaidState.IDLE); + TiedUpMod.LOGGER.info( + "[EntityMaid] {} was stuck, teleported home", + getNpcName() + ); + } + } + + /** + * Called when the master trader dies. + * Maid becomes neutral and capturable. + * Prisoners are reassigned in CampOwnership (maidId cleared but campId preserved). + */ + public void onTraderDeath() { + TiedUpMod.LOGGER.info( + "[EntityMaid] {} trader died, becoming free", + this.getNpcName() + ); + + // Reassign prisoners (clear maid reference) + if (this.level() instanceof ServerLevel serverLevel) { + CampMaidManager.reassignPrisonersFromMaid( + this.getUUID(), + null, + serverLevel + ); + } + + traderLink.clearTraderUUID(); + this.setMaidState(MaidState.FREE); + this.setTarget(null); + + // Clear any current tasks + this.targetCaptiveUUID = null; + this.targetBuyerUUID = null; + this.collectTarget = null; + } + + /** + * Handle maid death. + * Registers death time in CampOwnership for respawn timer. + * Prisoners are paused but not freed (camp is still alive). + */ + @Override + public void die( + net.minecraft.world.damagesource.DamageSource damageSource + ) { + if ( + !this.level().isClientSide && + this.level() instanceof ServerLevel serverLevel + ) { + // Get camp UUID before death + UUID campId = getCampUUID(); + + if (campId != null && !isFreed()) { + // Mark maid as dead - prisoners are paused, respawn in 5 minutes + CampMaidManager.markMaidDead( + campId, + serverLevel.getGameTime(), + serverLevel + ); + + // Release any currently captured prisoner (leashed) + if (this.hasCaptives()) { + IRestrainable captive = this.getCaptive(); + if (captive != null) { + captive.free(false); + } + } + + // Notify nearby prisoners using PrisonerManager + PrisonerManager manager = PrisonerManager.get(serverLevel); + for (UUID prisonerId : manager.getPrisonersInCamp(campId)) { + ServerPlayer player = serverLevel + .getServer() + .getPlayerList() + .getPlayer(prisonerId); + if ( + player != null && + player.position().distanceTo(this.position()) <= 50 + ) { + player.sendSystemMessage( + net.minecraft.network.chat.Component.literal( + "The maid has died. Work is paused. A replacement will arrive in 5 minutes." + ).withStyle(net.minecraft.ChatFormatting.GOLD) + ); + } + } + + TiedUpMod.LOGGER.info( + "[EntityMaid] {} died, camp {} awaiting maid respawn", + this.getNpcName(), + campId.toString().substring(0, 8) + ); + } + } + + super.die(damageSource); + } + + @Override + public void remove(RemovalReason reason) { + // MEDIUM FIX: Handle cleanup for camp system if removed without dying + if ( + !this.level().isClientSide && + reason == RemovalReason.DISCARDED && + !this.isDeadOrDying() + ) { + if ( + this.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel + ) { + UUID campId = getCampUUID(); + if (campId != null) { + // Mark as dead so a replacement can be scheduled + CampMaidManager.markMaidDead( + campId, + serverLevel.getGameTime(), + serverLevel + ); + TiedUpMod.LOGGER.info( + "[EntityMaid] {} removed without dying, notifying camp {}", + getNpcName(), + campId.toString().substring(0, 8) + ); + } + } + } + + // Standard kidnapper cleanup (captives) + super.remove(reason); + } + + // ======================================== + // CAPTURE DETECTION + // ======================================== + + /** + * Check if maid has been captured and removed from camp. + * If captured and taken far from camp (>100 blocks), the maid is marked as "dead" + * and a replacement will spawn after 5 minutes. + */ + private void checkIfCapturedAndRemoved(ServerLevel level) { + // Must be tied up + if (!isTiedUp()) return; + + // Must have master trader (not freed) + if (isFreed() || getMasterTraderUUID() == null) return; + + // Must have a camp + UUID campId = getCampUUID(); + if (campId == null) return; + + CampOwnership ownership = CampOwnership.get(level); + CampOwnership.CampData camp = ownership.getCamp(campId); + if (camp == null || camp.getCenter() == null) return; + + // Check distance from camp center + BlockPos campCenter = camp.getCenter(); + double distanceSq = this.distanceToSqr( + campCenter.getX(), + campCenter.getY(), + campCenter.getZ() + ); + + // If > 100 blocks away → maid "dead" for camp + double CAPTURE_DISTANCE_THRESHOLD = 100.0; + if ( + distanceSq > CAPTURE_DISTANCE_THRESHOLD * CAPTURE_DISTANCE_THRESHOLD + ) { + TiedUpMod.LOGGER.warn( + "[EntityMaid] {} captured and removed {} blocks from camp {} - registering death", + this.getNpcName(), + (int) Math.sqrt(distanceSq), + campId.toString().substring(0, 8) + ); + + // Register death (triggers 5-minute replacement timer) + CampMaidManager.markMaidDead(campId, level.getGameTime(), level); + + // Detach from camp (become neutral) + onCapturedAndRemoved(); + } + } + + /** + * Called when maid is captured and removed from camp. + * Maid becomes neutral/freed and replacement timer starts. + */ + private void onCapturedAndRemoved() { + TiedUpMod.LOGGER.info( + "[EntityMaid] {} captured - becoming neutral, replacement will spawn in 5 minutes", + this.getNpcName() + ); + + // Clear trader link + traderLink.clearTraderUUID(); + + // Become free/neutral + this.setMaidState(MaidState.FREE); + + // Clear tasks + this.targetCaptiveUUID = null; + this.targetBuyerUUID = null; + this.collectTarget = null; + + // Clear target + this.setTarget(null); + } + + // ======================================== + // ORDER HANDLING + // ======================================== + + /** + * Start delivering a captive to a buyer. + */ + public void startDeliverCaptive(IRestrainable captive, Player buyer) { + if (currentState.isBusy()) { + TiedUpMod.LOGGER.warn( + "[EntityMaid] {} cannot deliver - busy with {}", + this.getNpcName(), + currentState + ); + return; + } + + this.targetCaptiveUUID = captive.getKidnappedUniqueId(); + this.targetBuyerUUID = buyer.getUUID(); + setMaidState(MaidState.DELIVERING); + + TiedUpMod.LOGGER.info( + "[EntityMaid] {} starting delivery of {} to {}", + this.getNpcName(), + captive.getKidnappedName(), + buyer.getName().getString() + ); + } + + /** + * Start collecting items from a location. + */ + public void startCollectItems(BlockPos target) { + if (currentState.isBusy()) { + TiedUpMod.LOGGER.warn( + "[EntityMaid] {} cannot collect - busy with {}", + this.getNpcName(), + currentState + ); + return; + } + + this.collectTarget = target; + setMaidState(MaidState.COLLECTING); + + TiedUpMod.LOGGER.info( + "[EntityMaid] {} starting collection at {}", + this.getNpcName(), + target.toShortString() + ); + } + + /** + * Start fetching a captive from their cell. + */ + public void startFetchCaptive(UUID captiveUUID) { + if (currentState.isBusy()) { + TiedUpMod.LOGGER.warn( + "[EntityMaid] {} cannot fetch - busy with {}", + this.getNpcName(), + currentState + ); + return; + } + + this.targetCaptiveUUID = captiveUUID; + setMaidState(MaidState.FETCHING); + + TiedUpMod.LOGGER.info( + "[EntityMaid] {} starting fetch of captive {}", + this.getNpcName(), + captiveUUID.toString().substring(0, 8) + ); + } + + /** + * Start fetching a captive and then delivering to a buyer. + * This chains FETCHING → DELIVERING automatically. + * + * @param captiveUUID The captive to fetch + * @param buyerUUID The buyer to deliver to + */ + public void startFetchAndDeliver(UUID captiveUUID, UUID buyerUUID) { + if (currentState.isBusy()) { + TiedUpMod.LOGGER.warn( + "[EntityMaid] {} cannot fetch and deliver - busy with {}", + this.getNpcName(), + currentState + ); + return; + } + + this.targetCaptiveUUID = captiveUUID; + this.targetBuyerUUID = buyerUUID; + setMaidState(MaidState.FETCHING); + + TiedUpMod.LOGGER.info( + "[EntityMaid] {} starting fetch of captive {} for delivery to buyer {}", + this.getNpcName(), + captiveUUID.toString().substring(0, 8), + buyerUUID.toString().substring(0, 8) + ); + } + + /** + * Transition from FETCHING to DELIVERING after capture. + * Called by MaidFetchCaptiveGoal when it has the captive. + */ + public void transitionToDelivering() { + if (targetBuyerUUID == null) { + // No buyer set - just complete the fetch + completeTask(); + return; + } + + // We have a buyer - transition to delivering + setMaidState(MaidState.DELIVERING); + + TiedUpMod.LOGGER.info( + "[EntityMaid] {} transitioning to delivery for buyer {}", + this.getNpcName(), + targetBuyerUUID.toString().substring(0, 8) + ); + } + + /** + * Complete current task and return to idle. + */ + public void completeTask() { + TiedUpMod.LOGGER.info( + "[EntityMaid] {} completed task {}", + this.getNpcName(), + currentState + ); + + this.targetCaptiveUUID = null; + this.targetBuyerUUID = null; + this.collectTarget = null; + setMaidState(MaidState.IDLE); + } + + /** + * Start patrolling cells to check for escapes and damage. + */ + public void startPatrol() { + // Patrol is handled internally by MaidIdleGoal — no state change needed. + // Setting MaidState.PATROL caused a permanent softlock because no goal + // ever reset it back to IDLE. + } + + // ======================================== + // PLAYER INTERACTION - MANUAL CHECK-IN + // ======================================== + + @Override + protected InteractionResult mobInteract( + Player player, + InteractionHand hand + ) { + // Only process main hand to avoid double-triggering + if (hand != InteractionHand.MAIN_HAND) { + return super.mobInteract(player, hand); + } + + if ( + player instanceof ServerPlayer serverPlayer && + !isFreed() && + level() instanceof ServerLevel serverLevel + ) { + // Check if this player is a prisoner of this camp using PrisonerManager + PrisonerManager manager = PrisonerManager.get(serverLevel); + PrisonerRecord record = manager.getPrisoner(player.getUUID()); + + if ( + record != null && + this.getCampUUID() != null && + this.getCampUUID().equals(record.getCampId()) + ) { + // Player is a prisoner of this camp — delegate to prison interaction component + LaborRecord laborRecord = manager.getLaborRecord( + player.getUUID() + ); + InteractionResult result = prisonInteraction.handlePrisonerInteract( + serverPlayer, manager, record, laborRecord + ); + if (result != null) { + return result; + } + } + } + return super.mobInteract(player, hand); + } + + // ======================================== + // TARGETING + // ======================================== + + @Override + public boolean isSuitableTarget( + net.minecraft.world.entity.LivingEntity entity + ) { + // If freed, don't target anyone + if (isFreed()) { + return false; + } + + // If trader is under attack, target the attacker + EntitySlaveTrader trader = getMasterTrader(); + if (trader != null && trader.getLastAttacker() == entity) { + return true; + } + + // Otherwise, only target if they attacked us + return entity == this.getLastAttacker(); + } + + // ======================================== + // CAPTURE TIMING (Ultra fast) + // ======================================== + + @Override + public int getCaptureBindTime() { + return MAID_CAPTURE_TIME_TICKS; + } + + @Override + public int getCaptureGagTime() { + return MAID_CAPTURE_TIME_TICKS; + } + + // ======================================== + // VARIANT SYSTEM + // ======================================== + + @Override + public KidnapperVariant lookupVariantById(String variantId) { + return MaidSkinManager.CORE.getVariant(variantId); + } + + @Override + public KidnapperVariant computeVariantForEntity(UUID entityUUID) { + return MaidSkinManager.CORE.getVariantForEntity( + entityUUID, + Gender.FEMALE + ); + } + + @Override + public String getVariantTextureFolder() { + return "textures/entity/kidnapper/maid/"; + } + + @Override + public String getDefaultVariantId() { + return "maid_default"; + } + + @Override + public String getVariantNBTKey() { + return "MaidVariantId"; + } + + @Override + public void applyVariantName(KidnapperVariant variant) { + // Numbered variants (maid_mob_1, maid_mob_2, etc.) get random names + // Named variants (maid_default, maid_classic, etc.) use their default name + if (variant.id().startsWith("maid_mob_")) { + this.setNpcName( + com.tiedup.remake.util.NameGenerator.getRandomMaidName() + ); + } else { + this.setNpcName(variant.defaultName()); + } + } + + // ======================================== + // DISPLAY + // ======================================== + + @Override + public Component getDisplayName() { + String suffix = isFreed() ? " (Free)" : ""; + return Component.literal(this.getNpcName() + suffix).withStyle( + Style.EMPTY.withColor(isFreed() ? 0xAAAAAA : MAID_NAME_COLOR) + ); + } + + // ======================================== + // NBT PERSISTENCE + // ======================================== + + @Override + public void addAdditionalSaveData(CompoundTag tag) { + super.addAdditionalSaveData(tag); + + traderLink.save(tag); + tag.putString("MaidState", currentState.name()); + + if (targetCaptiveUUID != null) { + tag.putUUID("TargetCaptiveUUID", targetCaptiveUUID); + } + if (targetBuyerUUID != null) { + tag.putUUID("TargetBuyerUUID", targetBuyerUUID); + } + if (collectTarget != null) { + tag.putInt("CollectTargetX", collectTarget.getX()); + tag.putInt("CollectTargetY", collectTarget.getY()); + tag.putInt("CollectTargetZ", collectTarget.getZ()); + } + // NOTE: Labor states are now persisted in CampOwnership, not here + } + + @Override + public void readAdditionalSaveData(CompoundTag tag) { + super.readAdditionalSaveData(tag); + + traderLink.load(tag); + if (tag.contains("MaidState")) { + try { + currentState = MaidState.valueOf(tag.getString("MaidState")); + } catch (IllegalArgumentException e) { + currentState = MaidState.IDLE; + } + } + + if (tag.contains("TargetCaptiveUUID")) { + targetCaptiveUUID = tag.getUUID("TargetCaptiveUUID"); + } + if (tag.contains("TargetBuyerUUID")) { + targetBuyerUUID = tag.getUUID("TargetBuyerUUID"); + } + if (tag.contains("CollectTargetX")) { + collectTarget = new BlockPos( + tag.getInt("CollectTargetX"), + tag.getInt("CollectTargetY"), + tag.getInt("CollectTargetZ") + ); + } + // NOTE: Labor states are now loaded from CampOwnership, not here + } + + // ======================================== + // GETTERS FOR AI GOALS + // ======================================== + + @Nullable + public UUID getTargetCaptiveUUID() { + return targetCaptiveUUID; + } + + @Nullable + public UUID getTargetBuyerUUID() { + return targetBuyerUUID; + } + + @Nullable + public BlockPos getCollectTarget() { + return collectTarget; + } + + // ======================================== + // PRISONER STATE ACCESS (facades to MaidPrisonInteraction) + // ======================================== + + /** + * Get the prison interaction component. + * Exposed for direct component access when needed. + */ + public MaidPrisonInteraction getPrisonInteraction() { + return prisonInteraction; + } + + public Map getPrisonerRecords() { + return prisonInteraction.getPrisonerRecords(); + } + + public Set getProcessedPrisoners() { + return prisonInteraction.getProcessedPrisoners(); + } + + @Nullable + public CampOwnership getCampOwnership() { + return prisonInteraction.getCampOwnership(); + } + + @Nullable + public PrisonerManager getPrisonerManager() { + return prisonInteraction.getPrisonerManager(); + } + + // ======================================== + // STRUGGLE DETECTION (facades to MaidPrisonInteraction) + // ======================================== + + public void onStruggleDetected(ServerPlayer prisoner) { + prisonInteraction.onStruggleDetected(prisoner); + } + + @Nullable + public ServerPlayer getStrugglePunishmentTarget() { + return prisonInteraction.getStrugglePunishmentTarget(); + } + + public void clearStrugglePunishmentTarget() { + prisonInteraction.clearStrugglePunishmentTarget(); + } + + // ======================================== + // DIALOGUE SPEAKER (Maid-specific) + // ======================================== + + @Override + public SpeakerType getSpeakerType() { + return SpeakerType.MAID; + } + + @Override + public PersonalityType getSpeakerPersonality() { + // Maids are always obedient + return PersonalityType.SUBMISSIVE; + } + + @Override + public int getSpeakerMood() { + // Mood based on current state + MaidState maidState = this.getMaidState(); + if (maidState == null) { + return 50; + } + + return switch (maidState) { + case IDLE -> 60; // Calm, waiting for orders + case FETCHING, DELIVERING -> 50; // Focused on task + case DEFENDING -> 30; // Serious, protective + case RETURNING_PRISONER, EXTRACTING_PRISONER -> 55; // Working + case PATROL -> 55; // Alert + case COLLECTING, FREE -> 50; + }; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/EntityMaster.java b/src/main/java/com/tiedup/remake/entities/EntityMaster.java new file mode 100644 index 0000000..c723905 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/EntityMaster.java @@ -0,0 +1,1195 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.core.SettingsAccessor; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.SpeakerType; +import com.tiedup.remake.entities.ai.master.*; +import com.tiedup.remake.entities.master.components.IMasterStateHost; +import com.tiedup.remake.entities.master.components.MasterPetManager; +import com.tiedup.remake.entities.master.components.MasterStateManager; +import com.tiedup.remake.entities.master.components.MasterTaskManager; +import com.tiedup.remake.entities.skins.Gender; +import com.tiedup.remake.entities.skins.MasterSkinManager; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import com.tiedup.remake.minigame.ContinuousStruggleMiniGameState; +import com.tiedup.remake.minigame.StruggleSessionManager; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.master.PacketMasterStateSync; +import com.tiedup.remake.state.PlayerBindState; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.ai.attributes.AttributeSupplier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.ai.goal.FloatGoal; +import net.minecraft.world.entity.ai.goal.LookAtPlayerGoal; +import net.minecraft.world.entity.ai.goal.RandomLookAroundGoal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * EntityMaster - Rare NPC that buys solo players from Kidnappers. + * + * Master NPCs implement the pet play system: + * - Buys solo players from Kidnappers (100% chance when no buyers) + * - Follows the player (inverted follower mechanic) + * - Restricts eating/sleeping to special blocks (Bowl/Pet Bed) + * - Monitors player for escape attempts + * - Punishes detected struggle attempts + * - Has distraction windows where escape is possible + * + * Key Differences from Kidnapper: + * - Does not actively hunt players + * - Follows the player instead of other way around + * - Much higher combat stats (100+ HP, damage reduction from slave) + * - Cannot be easily defeated by the enslaved player + */ +public class EntityMaster extends EntityKidnapperElite { + + // ======================================== + // CONSTANTS + // ======================================== + + public static final double MASTER_MAX_HEALTH = 150.0D; + public static final double MASTER_MOVEMENT_SPEED = 0.30D; + public static final double MASTER_KNOCKBACK_RESISTANCE = 0.95D; + public static final double MASTER_FOLLOW_RANGE = 60.0D; + public static final double MASTER_ATTACK_DAMAGE = 10.0D; + public static final double MASTER_ARMOR = 10.0D; + + /** Damage reduction from slave attacks (75% reduction) */ + public static final float SLAVE_DAMAGE_MULTIPLIER = 0.25f; + + /** Regeneration rate (HP per tick, 0.5 HP/sec = 0.025 HP/tick) */ + public static final float REGEN_PER_TICK = 0.025f; + + /** Name color (dark purple) */ + public static final int MASTER_NAME_COLOR = 0x8B008B; + + /** NBT keys */ + private static final String NBT_MASTER_STATE = "MasterState"; + private static final String NBT_PET_UUID = "PetPlayerUUID"; + private static final String NBT_MASTER_VARIANT = "MasterVariantId"; + private static final String NBT_PLACED_BLOCK_POS = "PlacedBlockPos"; + private static final String NBT_DOGWALK_MASTER_LEADS = "DogwalkMasterLeads"; + + // ======================================== + // DATA SYNC + // ======================================== + + /** Current state synced to client */ + private static final EntityDataAccessor DATA_MASTER_STATE = + SynchedEntityData.defineId( + EntityMaster.class, + EntityDataSerializers.STRING + ); + + /** Whether master is distracted */ + private static final EntityDataAccessor DATA_DISTRACTED = + SynchedEntityData.defineId( + EntityMaster.class, + EntityDataSerializers.BOOLEAN + ); + + // ======================================== + // COMPONENTS + // ======================================== + + /** State manager for master behavioral states */ + private final MasterStateManager stateManager; + + /** Pet manager for pet player lifecycle (collar, leash, freedom) */ + private final MasterPetManager petManager; + + /** Task manager for pet tasks, engagement cadence, and cold shoulder */ + private final MasterTaskManager taskManager; + + /** The kidnapper that is selling the player (for approach/buy logic) */ + @Nullable + private EntityKidnapper sellingKidnapper; + + /** Reference to the place block goal for triggering feeding/resting */ + @Nullable + private MasterPlaceBlockGoal placeBlockGoal; + + /** Reference to the dogwalk goal */ + @Nullable + private MasterDogwalkGoal dogwalkGoal; + + /** Whether master leads during dogwalk (true = master pulls, false = master follows) */ + private boolean dogwalkMasterLeads = false; + + /** Reference to task assign goal for force assignment */ + @Nullable + private MasterTaskAssignGoal taskAssignGoal; + + /** Reference to task watch goal */ + @Nullable + private MasterTaskWatchGoal taskWatchGoal; + + /** Forced punishment type for next PUNISH cycle (null = random selection) */ + @Nullable + private PunishmentType pendingForcedPunishment = null; + + /** Whether the next punishment is an attack punishment (dual: choke + physical restraint) */ + private boolean pendingAttackPunishment = false; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public EntityMaster(EntityType type, Level level) { + super(type, level); + // Initialize state manager + this.stateManager = new MasterStateManager(new MasterStateHost()); + // Initialize pet manager (depends on stateManager) + this.petManager = new MasterPetManager(this, this.stateManager); + // Initialize task manager + this.taskManager = new MasterTaskManager(this); + } + + // ======================================== + // ATTRIBUTES + // ======================================== + + /** + * Create master attributes. + * Very tanky - designed to be nearly impossible for slave to defeat. + */ + public static AttributeSupplier.Builder createAttributes() { + return Mob.createMobAttributes() + .add(Attributes.MAX_HEALTH, MASTER_MAX_HEALTH) + .add(Attributes.MOVEMENT_SPEED, MASTER_MOVEMENT_SPEED) + .add(Attributes.KNOCKBACK_RESISTANCE, MASTER_KNOCKBACK_RESISTANCE) + .add(Attributes.FOLLOW_RANGE, MASTER_FOLLOW_RANGE) + .add(Attributes.ATTACK_DAMAGE, MASTER_ATTACK_DAMAGE) + .add(Attributes.ARMOR, MASTER_ARMOR); + } + + // ======================================== + // DATA SYNC + // ======================================== + + @Override + protected void defineSynchedData() { + super.defineSynchedData(); + this.entityData.define(DATA_MASTER_STATE, MasterState.IDLE.name()); + this.entityData.define(DATA_DISTRACTED, false); + } + + /** + * Sync master state to all tracking clients. + */ + private void syncState() { + this.entityData.set( + DATA_MASTER_STATE, + stateManager.getCurrentState().name() + ); + this.entityData.set( + DATA_DISTRACTED, + stateManager.getCurrentState() == MasterState.DISTRACTED + ); + + // Send network packet for detailed sync + if ( + !this.level().isClientSide && + this.level() instanceof ServerLevel serverLevel + ) { + ModNetwork.sendToTracking( + new PacketMasterStateSync( + this.getId(), + stateManager.getCurrentState().ordinal(), + stateManager.getPetPlayerUUID(), + stateManager.getRemainingDistractionTicks() + ), + this + ); + } + } + + /** + * Get master state from synced data (client-side). + */ + public MasterState getMasterState() { + try { + return MasterState.valueOf(this.entityData.get(DATA_MASTER_STATE)); + } catch (IllegalArgumentException e) { + return MasterState.IDLE; + } + } + + /** + * Check if master is distracted (client-safe). + */ + public boolean isDistracted() { + return this.entityData.get(DATA_DISTRACTED); + } + + // ======================================== + // SELLING KIDNAPPER (for approach/purchase) + // ======================================== + + /** + * Set the kidnapper that is selling a player to this Master. + * Called when Master spawns to approach a sale. + */ + public void setSellingKidnapper(@Nullable EntityKidnapper kidnapper) { + this.sellingKidnapper = kidnapper; + if (kidnapper != null) { + // Set state to purchasing - Master will approach + this.stateManager.setCurrentState(MasterState.PURCHASING); + TiedUpMod.LOGGER.info( + "[EntityMaster] {} set to purchase from {}", + this.getNpcName(), + kidnapper.getNpcName() + ); + } + } + + /** + * Get the kidnapper that is selling a player. + */ + @Nullable + public EntityKidnapper getSellingKidnapper() { + return sellingKidnapper; + } + + /** + * Check if this Master has a valid selling kidnapper. + */ + public boolean hasSellingKidnapper() { + return sellingKidnapper != null && sellingKidnapper.isAlive(); + } + + /** + * Clear the selling kidnapper reference (after purchase complete). + */ + public void clearSellingKidnapper() { + this.sellingKidnapper = null; + } + + // ======================================== + // AI GOALS + // ======================================== + + @Override + protected void registerGoals() { + // Float goal always highest priority + this.goalSelector.addGoal(0, new FloatGoal(this)); + + // Priority 1: Hunt monsters near pet (protect the pet!) + this.goalSelector.addGoal(1, new MasterHuntMonstersGoal(this)); + + // Priority 1: Buy player from kidnapper (initial state) + this.goalSelector.addGoal(1, new MasterBuyPlayerGoal(this)); + + // Priority 2: Punish + Place block (urgent reactions) + this.goalSelector.addGoal(2, new MasterPunishGoal(this)); + this.placeBlockGoal = new MasterPlaceBlockGoal(this); + this.goalSelector.addGoal(2, this.placeBlockGoal); + + // Priority 3: Dogwalk + Human Chair (exclusive movement modes) + this.dogwalkGoal = new MasterDogwalkGoal(this); + this.goalSelector.addGoal(3, this.dogwalkGoal); + this.goalSelector.addGoal(3, new MasterHumanChairGoal(this)); + + // Priority 4: Inventory inspection (short burst, higher than observe) + this.goalSelector.addGoal(4, new MasterInventoryInspectGoal(this)); + + // Priority 5: Observe player (watching state) + this.goalSelector.addGoal(5, new MasterObservePlayerGoal(this)); + + // Priority 6: Task assign, task watch, random events (engagement goals) + this.taskAssignGoal = new MasterTaskAssignGoal(this); + this.goalSelector.addGoal(6, this.taskAssignGoal); + this.taskWatchGoal = new MasterTaskWatchGoal(this); + this.goalSelector.addGoal(6, this.taskWatchGoal); + this.goalSelector.addGoal(6, new MasterRandomEventGoal(this)); + + // Priority 7: Idle behaviors (micro-actions between engagements) + this.goalSelector.addGoal(7, new MasterIdleBehaviorGoal(this)); + + // Priority 8: Follow player - DEFAULT behavior, lowest master priority + // Must be below all engagement goals so they can interrupt following. + // FollowPlayer (MOVE+LOOK) at prio 3 was blocking all prio 4-7 goals! + this.goalSelector.addGoal(8, new MasterFollowPlayerGoal(this)); + + // FUTURE: Master command goals require personality system (EntityDamsel-only). + // These were non-functional before (Masters have no PersonalityState). + // Proper fix: Create MasterCommandGoals that use collar owner instead. + // DamselAIController.registerCommandGoals(this.goalSelector, this, 9); + + // Look goals - lowest priority + this.goalSelector.addGoal( + 10, + new LookAtPlayerGoal(this, Player.class, 8.0F) + ); + this.goalSelector.addGoal(11, new RandomLookAroundGoal(this)); + + // Note: No target goals - Master doesn't hunt, only follows their pet + } + + // ======================================== + // VARIANT SYSTEM OVERRIDES + // ======================================== + + @Override + public KidnapperVariant lookupVariantById(String variantId) { + return MasterSkinManager.CORE.getVariant(variantId); + } + + @Override + public KidnapperVariant computeVariantForEntity(UUID entityUUID) { + Gender preferredGender = SettingsAccessor.getPreferredSpawnGender( + this.level() != null ? this.level().getGameRules() : null); + return MasterSkinManager.CORE.getVariantForEntity( + entityUUID, + preferredGender + ); + } + + @Override + public String getVariantTextureFolder() { + return "textures/entity/master/"; + } + + @Override + public String getDefaultVariantId() { + return "amy"; + } + + @Override + public String getVariantNBTKey() { + return NBT_MASTER_VARIANT; + } + + // ======================================== + // PLAYER INTERACTION + // ======================================== + + /** + * Handle player right-click interaction. + * - Pet + Shift + empty hand = open pet request menu + * - Pet + empty hand = pet greeting + * - Non-pet + Shift + empty hand = open conversation + * - Non-pet + empty hand = stranger greeting + */ + @Override + protected net.minecraft.world.InteractionResult mobInteract( + Player player, + net.minecraft.world.InteractionHand hand + ) { + // Enslaved: use base NPC behavior (feeding, conversation via EntityDamsel) + if (this.isTiedUp()) { + return super.mobInteract(player, hand); + } + + ItemStack heldItem = player.getItemInHand(hand); + if (this.level().isClientSide()) { + return net.minecraft.world.InteractionResult.SUCCESS; + } + + if (!(player instanceof ServerPlayer serverPlayer)) { + return net.minecraft.world.InteractionResult.PASS; + } + + boolean isPet = isPetPlayer(player); + + // Pet interactions + if (isPet) { + // Cold shoulder - ignore pet interactions + if (isGivingColdShoulder()) { + return net.minecraft.world.InteractionResult.PASS; + } + // If holding an item and FETCH_ITEM or DEMAND task is active, try to give it + if ( + !heldItem.isEmpty() && + (taskManager.getCurrentTask() == PetTask.FETCH_ITEM || + taskManager.getCurrentTask() == PetTask.DEMAND) + ) { + if (taskManager.handleFetchItemGive(heldItem.getItem())) { + // Take the item from player + heldItem.shrink(1); + return net.minecraft.world.InteractionResult.SUCCESS; + } + // Item wasn't the right one - don't consume the interaction + } + + // SPEAK task: pet right-clicks on master to complete + if (taskManager.getCurrentTask() == PetTask.SPEAK) { + com.tiedup.remake.dialogue.DialogueBridge.talkTo( + this, + player, + "petplay.task_complete" + ); + taskManager.clearActiveTask(); + setMasterState(MasterState.FOLLOWING); + return net.minecraft.world.InteractionResult.SUCCESS; + } + + // Empty hand interactions + if (heldItem.isEmpty()) { + if (player.isShiftKeyDown()) { + // Shift + empty hand = open pet request menu + com.tiedup.remake.dialogue.conversation.PetRequestManager.openRequestMenu( + this, + serverPlayer + ); + return net.minecraft.world.InteractionResult.SUCCESS; + } else { + // Simple greeting + com.tiedup.remake.dialogue.DialogueBridge.talkTo( + this, + player, + "petplay.pet_greeting" + ); + return net.minecraft.world.InteractionResult.SUCCESS; + } + } + } + + // Non-pet interactions + if (!isPet && heldItem.isEmpty()) { + if (player.isShiftKeyDown()) { + // Shift + empty hand = open conversation GUI (for non-pets) + if ( + com.tiedup.remake.dialogue.conversation.ConversationManager.openConversation( + this, + serverPlayer + ) + ) { + return net.minecraft.world.InteractionResult.SUCCESS; + } + } else { + // Right click = greeting dialogue + com.tiedup.remake.dialogue.DialogueBridge.talkTo( + this, + player, + "idle.stranger_greeting" + ); + return net.minecraft.world.InteractionResult.SUCCESS; + } + } + + return super.mobInteract(player, hand); + } + + // ======================================== + // DISPLAY + // ======================================== + + @Override + public Component getDisplayName() { + return Component.literal(this.getNpcName()).withStyle( + Style.EMPTY.withColor(MASTER_NAME_COLOR) + ); + } + + @Override + public SpeakerType getSpeakerType() { + return SpeakerType.MASTER; + } + + /** + * Get relationship type for dialogue context. + * Returns "pet" for the owned player. + */ + @Override + public String getTargetRelation(Player player) { + if ( + stateManager.getPetPlayerUUID() != null && + stateManager.getPetPlayerUUID().equals(player.getUUID()) + ) { + return "pet"; + } + return null; + } + + /** + * Get mood for dialogue context. + * Based on current state and pet behavior. + */ + @Override + public int getSpeakerMood() { + int mood = 50; // Neutral + MasterState state = stateManager.getCurrentState(); + + switch (state) { + case DISTRACTED -> mood += 20; + case PUNISH -> mood -= 30; + case OBSERVING -> mood += 10; + case FOLLOWING -> mood += 15; + default -> { + } + } + + return Math.max(0, Math.min(100, mood)); + } + + // ======================================== + // TICK + // ======================================== + + @Override + public void tick() { + super.tick(); + + if (!this.level().isClientSide) { + // Tick distraction system + if (stateManager.tickDistraction()) { + syncState(); + } + + // Passive regeneration + if (this.getHealth() < this.getMaxHealth()) { + this.heal(REGEN_PER_TICK); + } + + // Check pet struggle sessions + tickStruggleDetection(); + + // Sync Master data to collar every second (for disconnect persistence) + if (this.tickCount % 20 == 0) { + ServerPlayer pet = getPetPlayer(); + if (pet != null && hasPetCollar(pet)) { + syncDataToCollar(pet); + } + } + + // FIX: Check for expired temporary event items every 5 seconds + if (this.tickCount % 100 == 0) { + ServerPlayer pet = getPetPlayer(); + if (pet != null) { + MasterRandomEventGoal.cleanupExpiredTempItems( + pet, + this.level().getGameTime() + ); + } + } + } + } + + /** + * Check if pet is struggling and detect if master is watching. + */ + private void tickStruggleDetection() { + UUID petUUID = stateManager.getPetPlayerUUID(); + if (petUUID == null) return; + + // Get struggle session for pet + ContinuousStruggleMiniGameState session = + StruggleSessionManager.getInstance().getContinuousStruggleSession( + petUUID + ); + + if (session != null && session.getHeldDirection() >= 0) { + // Pet is actively struggling + ServerPlayer pet = getPetPlayer(); + if ( + pet != null && + stateManager.isWatching() && + this.hasLineOfSight(pet) + ) { + // Detected! Interrupt and punish + onStruggleDetected(session); + } + } + } + + /** + * Called when master detects pet struggling. + */ + private void onStruggleDetected(ContinuousStruggleMiniGameState session) { + ServerPlayer pet = getPetPlayer(); + if (pet == null) return; + + TiedUpMod.LOGGER.info( + "[EntityMaster] {} detected {} struggling!", + this.getNpcName(), + pet.getName().getString() + ); + + // Reset collar resistance (repair the lock) + resetCollarResistance(pet); + + // Transition to punish state + stateManager.onStruggleAttempt(); + syncState(); + + // Send warning message to pet + pet.sendSystemMessage( + Component.literal( + this.getNpcName() + " caught you trying to escape!" + ).withStyle(Style.EMPTY.withColor(MASTER_NAME_COLOR)) + ); + } + + /** @see MasterPetManager#resetCollarResistance(ServerPlayer) */ + private void resetCollarResistance(ServerPlayer pet) { petManager.resetCollarResistance(pet); } + + // ======================================== + // COMBAT - DAMAGE REDUCTION + // ======================================== + + @Override + public boolean hurt(DamageSource source, float amount) { + // Check if attacker is the pet + boolean attackedByPet = false; + ServerPlayer petAttacker = null; + + if (source.getEntity() instanceof Player player) { + if (player.getUUID().equals(stateManager.getPetPlayerUUID())) { + attackedByPet = true; + petAttacker = (player instanceof ServerPlayer sp) ? sp : null; + + // Reduce damage from slave attacks + amount *= SLAVE_DAMAGE_MULTIPLIER; + TiedUpMod.LOGGER.debug( + "[EntityMaster] Reduced slave damage: {} -> {}", + amount / SLAVE_DAMAGE_MULTIPLIER, + amount + ); + } + } + + // Interrupt interruptible states if attacked + if (amount > 0) { + stateManager.interruptDistraction(); + interruptIfVulnerable(); + syncState(); + } + + // PUNISHMENT: If attacked by pet, trigger choke collar and enter PUNISH state + if (attackedByPet && petAttacker != null && amount > 0) { + triggerPunishmentForAttack(petAttacker); + } + + return super.hurt(source, amount); + } + + /** + * Trigger punishment when pet attacks the Master. + * Applies dual punishment: choke collar + a physical restraint (bind, blindfold, gag, or leash tug). + */ + private void triggerPunishmentForAttack(ServerPlayer pet) { + // Don't overwrite an active punishment + if (stateManager.getCurrentState() == MasterState.PUNISH) { + return; + } + + TiedUpMod.LOGGER.info( + "[EntityMaster] {} attacked by pet {} - triggering attack punishment", + getNpcName(), + pet.getName().getString() + ); + + // Dialogue - Master is angry + com.tiedup.remake.dialogue.DialogueBridge.talkTo( + this, + pet, + "punishment.attacked" + ); + + // Flag for dual punishment (choke + physical restraint) + this.pendingAttackPunishment = true; + + // Enter PUNISH state + setMasterState(MasterState.PUNISH); + } + + /** + * Interrupt states where the Master is vulnerable (sitting, walking pet). + * Called when the Master takes damage from a non-pet source. + * Forces the Master to get up / stop walking and return to FOLLOWING. + */ + private void interruptIfVulnerable() { + MasterState current = stateManager.getCurrentState(); + if ( + current == MasterState.HUMAN_CHAIR || current == MasterState.DOGWALK + ) { + TiedUpMod.LOGGER.info( + "[EntityMaster] {} interrupted {} due to taking damage", + getNpcName(), + current + ); + // State change triggers goal stop() which handles cleanup + setMasterState(MasterState.FOLLOWING); + } + } + + /** + * Called when the pet player takes damage. + * If the Master is sitting on them (HUMAN_CHAIR), get up immediately. + */ + public void onPetHurt(DamageSource source, float amount) { + if (amount <= 0) return; + MasterState current = stateManager.getCurrentState(); + if (current == MasterState.HUMAN_CHAIR) { + TiedUpMod.LOGGER.info( + "[EntityMaster] {} getting up - pet was hurt", + getNpcName() + ); + ServerPlayer pet = getPetPlayer(); + if (pet != null) { + com.tiedup.remake.dialogue.DialogueBridge.talkTo( + this, + pet, + "petplay.human_chair_end" + ); + } + setMasterState(MasterState.FOLLOWING); + } + } + + @Override + public void die(DamageSource source) { + // Free the pet when master dies + ServerPlayer pet = getPetPlayer(); + if (pet != null) { + freePet(pet); + } + + super.die(source); + } + + // ======================================== + // PET MANAGEMENT (delegates to MasterPetManager) + // ======================================== + + /** Get the pet manager component. */ + public MasterPetManager getPetManager() { return petManager; } + + /** @see MasterPetManager#getPetPlayer() */ + @Nullable + public ServerPlayer getPetPlayer() { return petManager.getPetPlayer(); } + + /** @see MasterPetManager#setPetPlayer(ServerPlayer) */ + public void setPetPlayer(ServerPlayer player) { + petManager.setPetPlayer(player); + syncState(); + } + + /** @see MasterPetManager#hasPet() */ + public boolean hasPet() { return petManager.hasPet(); } + + /** @see MasterPetManager#isPetPlayer(Player) */ + public boolean isPetPlayer(Player player) { return petManager.isPetPlayer(player); } + + // ======================================== + // GOAL ACCESSORS + // ======================================== + + /** + * Get the place block goal for triggering feeding/resting. + */ + @Nullable + public MasterPlaceBlockGoal getPlaceBlockGoal() { + return placeBlockGoal; + } + + /** + * Get the dogwalk goal. + */ + @Nullable + public MasterDogwalkGoal getDogwalkGoal() { + return dogwalkGoal; + } + + // ======================================== + // DOGWALK MODE + // ======================================== + + /** + * Set dogwalk mode. + * + * @param masterLeads If true, master walks and pulls pet. If false, master follows pet. + */ + public void setDogwalkMode(boolean masterLeads) { + this.dogwalkMasterLeads = masterLeads; + if (dogwalkGoal != null) { + dogwalkGoal.setMasterLeads(masterLeads); + } + } + + /** + * Check if master leads during dogwalk. + */ + public boolean isDogwalkMasterLeads() { + return dogwalkMasterLeads; + } + + // ======================================== + // TASK MANAGEMENT (delegates to MasterTaskManager) + // ======================================== + + /** Get the task manager component. */ + public MasterTaskManager getTaskManager() { return taskManager; } + + /** @see MasterTaskManager#hasActiveTask() */ + public boolean hasActiveTask() { return taskManager.hasActiveTask(); } + + /** @see MasterTaskManager#getCurrentTask() */ + @Nullable + public PetTask getCurrentTask() { return taskManager.getCurrentTask(); } + + /** @see MasterTaskManager#setActiveTask(PetTask) */ + public void setActiveTask(PetTask task) { taskManager.setActiveTask(task); } + + /** @see MasterTaskManager#clearActiveTask() */ + public void clearActiveTask() { taskManager.clearActiveTask(); } + + /** @see MasterTaskManager#getTaskStartPosition() */ + @Nullable + public net.minecraft.world.phys.Vec3 getTaskStartPosition() { return taskManager.getTaskStartPosition(); } + + /** @see MasterTaskManager#setTaskStartPosition(net.minecraft.world.phys.Vec3) */ + public void setTaskStartPosition(net.minecraft.world.phys.Vec3 position) { taskManager.setTaskStartPosition(position); } + + /** @see MasterTaskManager#getRequestedItem() */ + @Nullable + public net.minecraft.world.item.Item getRequestedItem() { return taskManager.getRequestedItem(); } + + /** @see MasterTaskManager#setRequestedItem(net.minecraft.world.item.Item) */ + public void setRequestedItem(net.minecraft.world.item.Item item) { taskManager.setRequestedItem(item); } + + /** + * Get the task assign goal for force assignment. + */ + @Nullable + public MasterTaskAssignGoal getTaskAssignGoal() { + return taskAssignGoal; + } + + // ======================================== + // LEASH MANAGEMENT (delegates to MasterPetManager) + // ======================================== + + /** @see MasterPetManager#attachLeashToPet() */ + public void attachLeashToPet() { petManager.attachLeashToPet(); } + + /** @see MasterPetManager#detachLeashFromPet() */ + public void detachLeashFromPet() { petManager.detachLeashFromPet(); } + + /** @see MasterPetManager#isPetLeashed() */ + public boolean isPetLeashed() { return petManager.isPetLeashed(); } + + // ======================================== + // ENGAGEMENT CADENCE (delegates to MasterTaskManager) + // ======================================== + + /** @see MasterTaskManager#markEngagement() */ + public void markEngagement() { taskManager.markEngagement(); } + + /** @see MasterTaskManager#getEngagementMultiplier() */ + public float getEngagementMultiplier() { return taskManager.getEngagementMultiplier(); } + + // ======================================== + // COLD SHOULDER (delegates to MasterTaskManager) + // ======================================== + + /** @see MasterTaskManager#isGivingColdShoulder() */ + public boolean isGivingColdShoulder() { return taskManager.isGivingColdShoulder(); } + + /** @see MasterTaskManager#startColdShoulder(int) */ + public void startColdShoulder(int durationTicks) { taskManager.startColdShoulder(durationTicks); } + + /** @see MasterPetManager#putPetCollar(ServerPlayer) */ + public void putPetCollar(ServerPlayer player) { petManager.putPetCollar(player); } + + /** @see MasterPetManager#freePet(ServerPlayer) */ + public void freePet(ServerPlayer player) { + petManager.freePet(player); + syncState(); + } + + /** + * Check if player has pet play collar from this master. + */ + public static boolean hasPetCollar(Player player) { + ItemStack collarStack = V2EquipmentHelper.getInRegion( + player, + BodyRegionV2.NECK + ); + if (collarStack.isEmpty()) return false; + + CompoundTag tag = collarStack.getTag(); + return tag != null && tag.getBoolean("petPlayMode"); + } + + /** @see MasterPetManager#syncDataToCollar(ServerPlayer) */ + private void syncDataToCollar(ServerPlayer pet) { petManager.syncDataToCollar(pet); } + + /** + * Get master UUID from player's pet collar. + */ + @Nullable + public static UUID getMasterUUID(Player player) { + ItemStack collarStack = V2EquipmentHelper.getInRegion( + player, + BodyRegionV2.NECK + ); + if (collarStack.isEmpty()) return null; + + CompoundTag tag = collarStack.getTag(); + if (tag != null && tag.hasUUID("masterUUID")) { + return tag.getUUID("masterUUID"); + } + return null; + } + + // ======================================== + // STATE ACCESSORS + // ======================================== + + public MasterStateManager getStateManager() { + return stateManager; + } + + public void setMasterState(MasterState state) { + stateManager.setCurrentState(state); + syncState(); + } + + /** + * Get and consume the forced punishment type (returns null if none pending). + */ + @Nullable + public PunishmentType consumeForcedPunishment() { + PunishmentType type = this.pendingForcedPunishment; + this.pendingForcedPunishment = null; + return type; + } + + /** + * Get and consume the attack punishment flag. + * Returns true if this punishment was triggered by pet attacking the master. + */ + public boolean consumeAttackPunishment() { + boolean pending = this.pendingAttackPunishment; + this.pendingAttackPunishment = false; + return pending; + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + @Override + public void addAdditionalSaveData(CompoundTag tag) { + super.addAdditionalSaveData(tag); + + tag.putString(NBT_MASTER_STATE, stateManager.serializeState()); + String petUUID = stateManager.serializePetUUID(); + if (petUUID != null) { + tag.putString(NBT_PET_UUID, petUUID); + } + + // Save task, engagement, and cold shoulder data + taskManager.save(tag); + + // Save dogwalk mode preference + tag.putBoolean(NBT_DOGWALK_MASTER_LEADS, dogwalkMasterLeads); + + // FIX: Save placed block position for cleanup after restart + if (placeBlockGoal != null) { + net.minecraft.core.BlockPos placedPos = + placeBlockGoal.getPlacedBlockPos(); + if (placedPos != null) { + tag.putInt(NBT_PLACED_BLOCK_POS + "X", placedPos.getX()); + tag.putInt(NBT_PLACED_BLOCK_POS + "Y", placedPos.getY()); + tag.putInt(NBT_PLACED_BLOCK_POS + "Z", placedPos.getZ()); + } + } + } + + @Override + public void readAdditionalSaveData(CompoundTag tag) { + super.readAdditionalSaveData(tag); + + if (tag.contains(NBT_MASTER_STATE)) { + stateManager.deserializeState(tag.getString(NBT_MASTER_STATE)); + } + if (tag.contains(NBT_PET_UUID)) { + stateManager.deserializePetUUID(tag.getString(NBT_PET_UUID)); + } + + // Load task, engagement, and cold shoulder data + taskManager.load(tag); + + // Load dogwalk mode preference + if (tag.contains(NBT_DOGWALK_MASTER_LEADS)) { + dogwalkMasterLeads = tag.getBoolean(NBT_DOGWALK_MASTER_LEADS); + } + + // FIX: Load placed block position and schedule cleanup + if ( + tag.contains(NBT_PLACED_BLOCK_POS + "X") && placeBlockGoal != null + ) { + net.minecraft.core.BlockPos placedPos = + new net.minecraft.core.BlockPos( + tag.getInt(NBT_PLACED_BLOCK_POS + "X"), + tag.getInt(NBT_PLACED_BLOCK_POS + "Y"), + tag.getInt(NBT_PLACED_BLOCK_POS + "Z") + ); + placeBlockGoal.setPlacedBlockPos(placedPos); + // Schedule cleanup for next tick (level not fully loaded yet) + placeBlockGoal.cleanupOrphanedBlock(); + } + } + + @Override + public void remove(RemovalReason reason) { + // MEDIUM FIX: Master-specific cleanup (pet is NOT in captiveManager) + if (!this.level().isClientSide) { + // 0. If master was in HUMAN_CHAIR, clean up the pet pose. + // discard() triggers remove() but NOT goal.stop(), so without this + // the pet keeps the humanChairMode bind and freeze effects forever. + if (stateManager.getCurrentState() == MasterState.HUMAN_CHAIR) { + ServerPlayer pet = getPetPlayer(); + if (pet != null) { + PlayerBindState bindState = PlayerBindState.getInstance( + pet + ); + if (bindState != null && bindState.isTiedUp()) { + net.minecraft.world.item.ItemStack bind = + bindState.getEquipment(BodyRegionV2.ARMS); + if (!bind.isEmpty()) { + net.minecraft.nbt.CompoundTag tag = bind.getTag(); + if ( + tag != null && + tag.getBoolean( + com.tiedup.remake.state.HumanChairHelper.NBT_KEY + ) + ) { + bindState.unequip(BodyRegionV2.ARMS); + } + } + } + pet.removeEffect( + net.minecraft.world.effect.MobEffects.MOVEMENT_SLOWDOWN + ); + pet.removeEffect( + net.minecraft.world.effect.MobEffects.JUMP + ); + TiedUpMod.LOGGER.info( + "[EntityMaster] {} cleanup: removed human chair pose from pet", + getNpcName() + ); + } + } + + // 1. Detach leash from pet + // This prevents the player from being leashed to a non-existent entity + this.detachLeashFromPet(); + + // 2. End any active struggle session for the pet + // If master vanishes, the struggle session must end or it will bug out + UUID petUUID = stateManager.getPetPlayerUUID(); + if (petUUID != null) { + com.tiedup.remake.minigame.StruggleSessionManager.getInstance().endContinuousStruggleSession( + petUUID, + false + ); + TiedUpMod.LOGGER.info( + "[EntityMaster] {} cleanup: detached leash and ended struggle session for pet {}", + getNpcName(), + petUUID.toString().substring(0, 8) + ); + } + } + + // Call super to handle any standard kidnapper cleanup (if used) + super.remove(reason); + } + + // ======================================== + // STATE HOST IMPLEMENTATION + // ======================================== + + private class MasterStateHost implements IMasterStateHost { + + @Override + public String getNpcName() { + return EntityMaster.this.getNpcName(); + } + + @Override + public long getCurrentTick() { + return EntityMaster.this.level().getGameTime(); + } + + @Override + public void onDistracted() { + // Could play animation or particles + TiedUpMod.LOGGER.debug( + "[EntityMaster] {} became distracted", + getNpcName() + ); + } + + @Override + public void onDistractionEnd() { + TiedUpMod.LOGGER.debug( + "[EntityMaster] {} is no longer distracted", + getNpcName() + ); + } + + @Override + public void onStruggleDetected() { + // Already handled in onStruggleDetected method + } + } + + // ======================================== + // BODY ROTATION OVERRIDE + // ======================================== + + @Override + protected float tickHeadTurn(float yRot, float animStep) { + // While sitting on pet, skip BodyRotationControl and force body to match + // entity yRot (which IS network-synced, unlike yBodyRot). + // Server: positionOnPet() sets yRot = sideYaw → synced to client. + // Client: we read the synced yRot and apply it to yBodyRot. + if (isSitting()) { + this.yBodyRot = this.getYRot(); + this.yBodyRotO = this.yRotO; + return animStep; + } + return super.tickHeadTurn(yRot, animStep); + } + + // ======================================== + // COLLISION OVERRIDE + // ======================================== + + @Override + public boolean isPushable() { + // Disable entity pushing during human chair so master can sit on pet + if (getMasterState() == MasterState.HUMAN_CHAIR) { + return false; + } + return super.isPushable(); + } + + @Override + protected void pushEntities() { + // Don't push anyone while sitting on pet (blocks master→player collision direction) + if (getMasterState() == MasterState.HUMAN_CHAIR) { + return; + } + super.pushEntities(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/EntityRopeArrow.java b/src/main/java/com/tiedup/remake/entities/EntityRopeArrow.java new file mode 100644 index 0000000..00e37ad --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/EntityRopeArrow.java @@ -0,0 +1,137 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.projectile.AbstractArrow; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.EntityHitResult; + +/** + * Rope Arrow Entity - Arrow that binds targets on hit. + * + * Phase 15: Rope Arrow implementation + * + * Behavior: + * - When hitting an entity, has a chance to bind them + * - Default: 50% chance + * - From Archer Kidnapper: 10% base + 10% per previous hit on same target + * - Uses rope restraint on target + * - Target must be IBondageState (Player, Damsel, Kidnapper) + */ +public class EntityRopeArrow extends AbstractArrow { + + public EntityRopeArrow( + EntityType type, + Level level + ) { + super(type, level); + } + + public EntityRopeArrow(Level level, LivingEntity shooter) { + super(ModEntities.ROPE_ARROW.get(), shooter, level); + } + + public EntityRopeArrow(Level level, double x, double y, double z) { + super(ModEntities.ROPE_ARROW.get(), x, y, z, level); + } + + /** + * Called when the arrow hits an entity. + * Attempts to bind the target with a chance based on shooter type. + */ + @Override + protected void onHitEntity(EntityHitResult result) { + // Don't call super - we don't want normal arrow damage + if (this.level().isClientSide) { + return; + } + + if (result.getEntity() instanceof LivingEntity target) { + // Check if target can be kidnapped + IBondageState targetState = KidnappedHelper.getKidnappedState(target); + if (targetState == null) { + TiedUpMod.LOGGER.debug( + "[EntityRopeArrow] Target {} cannot be bound (not IBondageState)", + target.getName().getString() + ); + // Remove arrow + this.discard(); + return; + } + + // Already tied? + if (targetState.isTiedUp()) { + TiedUpMod.LOGGER.debug( + "[EntityRopeArrow] Target {} is already tied", + target.getName().getString() + ); + this.discard(); + return; + } + + // Determine bind chance based on shooter type + int bindChance; + EntityKidnapperArcher archerShooter = null; + + if (this.getOwner() instanceof EntityKidnapperArcher archer) { + // Archer kidnapper: cumulative chance system + archerShooter = archer; + bindChance = archer.getBindChanceForTarget(target.getUUID()); + } else { + // Other shooters: default 50% chance + bindChance = ModConfig.SERVER.ropeArrowBindChance.get(); + } + + // Roll for bind chance + int roll = this.random.nextInt(100) + 1; + if (roll <= bindChance) { + // Success! Bind the target + ItemStack ropeItem = new ItemStack( + ModItems.getBind( + com.tiedup.remake.items.base.BindVariant.ROPES + ) + ); + targetState.equip(BodyRegionV2.ARMS, ropeItem); + + TiedUpMod.LOGGER.info( + "[EntityRopeArrow] Successfully bound {} (roll: {} <= {}%)", + target.getName().getString(), + roll, + bindChance + ); + + // Clear hit counts for this target if shot by archer + if (archerShooter != null) { + archerShooter.clearHitsForTarget(target.getUUID()); + } + } else { + TiedUpMod.LOGGER.debug( + "[EntityRopeArrow] Failed to bind {} (roll: {} > {}%)", + target.getName().getString(), + roll, + bindChance + ); + + // Record hit for archer's cumulative chance + if (archerShooter != null) { + archerShooter.recordHitOnTarget(target.getUUID()); + } + } + } + + // Remove the arrow after hit + this.discard(); + } + + @Override + protected ItemStack getPickupItem() { + return new ItemStack(ModItems.ROPE_ARROW.get()); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/EntitySlaveTrader.java b/src/main/java/com/tiedup/remake/entities/EntitySlaveTrader.java new file mode 100644 index 0000000..6f26b37 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/EntitySlaveTrader.java @@ -0,0 +1,929 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.cells.CampLifecycleManager; +import com.tiedup.remake.cells.CampOwnership; +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.SpeakerType; +// Prison system v2 goals +import com.tiedup.remake.entities.ai.trader.goals.TraderIdleGoal; +import com.tiedup.remake.entities.ai.trader.goals.TraderSellGoal; +import com.tiedup.remake.entities.skins.Gender; +import com.tiedup.remake.entities.skins.TraderSkinManager; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.IHasResistance; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.trader.PacketOpenTraderScreen; +import com.tiedup.remake.network.trader.PacketOpenTraderScreen.CaptiveOfferData; +import com.tiedup.remake.personality.PersonalityType; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerRecord; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.util.tasks.ItemTask; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.ai.attributes.AttributeSupplier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.ai.goal.*; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * EntitySlaveTrader - Boss of a camp who sells captives. + * + * Slave Trader & Maid System + * + * Characteristics: + * - Stats like Elite (high HP, fast, resistant) + * - Does NOT capture (stays at camp) + * - Manages sale of captives in camp cells + * - Has a Maid who follows orders + * - Token required to interact peacefully + * + * When killed: + * - Camp becomes permanently dead + * - Maid becomes neutral + */ +public class EntitySlaveTrader extends EntityKidnapperElite { + + // ======================================== + // CONSTANTS + // ======================================== + + public static final double TRADER_MAX_HEALTH = 50.0D; + public static final double TRADER_MOVEMENT_SPEED = 0.30D; + public static final double TRADER_KNOCKBACK_RESISTANCE = 0.95D; + public static final double TRADER_FOLLOW_RANGE = 40.0D; + public static final double TRADER_ATTACK_DAMAGE = 10.0D; + public static final int TRADER_NAME_COLOR = 0xFFD700; // Gold + + // ======================================== + // STATE + // ======================================== + + /** UUID of the maid that serves this trader */ + @Nullable + private UUID maidUUID; + + /** UUID of the camp this trader manages */ + @Nullable + private UUID campUUID; + + /** Spawn position (marker location) */ + @Nullable + private net.minecraft.core.BlockPos spawnPos; + + /** Grace period: warned player UUID and tick */ + @Nullable + private UUID warnedPlayerUUID; + + private long warningTick; + private static final int GRACE_PERIOD_TICKS = 100; // 5 seconds before warning expires + + /** Chase limit: tick when target was set, and max chase duration */ + private long chaseStartTick; + private static final int MAX_CHASE_TICKS = 200; // 10 seconds max chase + private static final double MAX_CHASE_DISTANCE_FROM_SPAWN = 30.0; + + /** Hostility cooldown (ticks remaining before auto-reset) */ + private int hostileCooldownTicks = 0; + private static final int HOSTILE_DURATION_TICKS = 600; // 30 seconds max hostility + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public EntitySlaveTrader( + EntityType type, + Level level + ) { + super(type, level); + } + + // ======================================== + // ATTRIBUTES + // ======================================== + + public static AttributeSupplier.Builder createAttributes() { + return Mob.createMobAttributes() + .add(Attributes.MAX_HEALTH, TRADER_MAX_HEALTH) + .add(Attributes.MOVEMENT_SPEED, TRADER_MOVEMENT_SPEED) + .add(Attributes.KNOCKBACK_RESISTANCE, TRADER_KNOCKBACK_RESISTANCE) + .add(Attributes.FOLLOW_RANGE, TRADER_FOLLOW_RANGE) + .add(Attributes.ATTACK_DAMAGE, TRADER_ATTACK_DAMAGE); + } + + // ======================================== + // AI GOALS + // ======================================== + + @Override + protected void registerGoals() { + // Trader has different AI - doesn't hunt, stays at camp + + // Priority 0: Always swim + this.goalSelector.addGoal(0, new FloatGoal(this)); + + // Priority 1: Melee attack if hostile (trader fights but doesn't carry captives) + this.goalSelector.addGoal(1, new MeleeAttackGoal(this, 1.2D, false)); + + // Priority 4: Sell captives (interact with buyers) + this.goalSelector.addGoal(4, new TraderSellGoal(this)); + + // Priority 5: Idle/patrol behavior + this.goalSelector.addGoal(5, new TraderIdleGoal(this)); + + // FUTURE: Trader command goals require personality system (EntityDamsel-only). + // These were non-functional before (Traders have no PersonalityState). + // Proper fix: Create TraderCommandGoals that use collar owner instead. + // DamselAIController.registerCommandGoals(this.goalSelector, this, 6); + + // Priority 10: Look at players + this.goalSelector.addGoal( + 10, + new LookAtPlayerGoal(this, Player.class, 8.0F) + ); + + // Priority 11: Random look around + this.goalSelector.addGoal(11, new RandomLookAroundGoal(this)); + + // Priority 12: Wander occasionally + this.goalSelector.addGoal( + 12, + new WaterAvoidingRandomStrollGoal(this, 0.6D) + ); + } + + // ======================================== + // INTERACTION + // ======================================== + + @Override + public InteractionResult mobInteract(Player player, InteractionHand hand) { + // Enslaved: use base NPC behavior (feeding, conversation via EntityDamsel) + if (this.isTiedUp()) { + return super.mobInteract(player, hand); + } + + if (this.level().isClientSide) { + return InteractionResult.SUCCESS; + } + + // Only process main hand to avoid double-triggering + if (hand != InteractionHand.MAIN_HAND) { + return InteractionResult.PASS; + } + + // Check if player has token + if (hasTokenInInventory(player)) { + // Open trading screen + if (player instanceof ServerPlayer serverPlayer) { + openTradingScreen(serverPlayer); + } + return InteractionResult.SUCCESS; + } else { + // No token - grace period before becoming hostile + if (player instanceof ServerPlayer serverPlayer) { + if ( + warnedPlayerUUID == null || + !warnedPlayerUUID.equals(player.getUUID()) + ) { + // First warning — do NOT attack yet + warnedPlayerUUID = player.getUUID(); + warningTick = this.tickCount; + serverPlayer.sendSystemMessage( + Component.literal("[" + this.getNpcName() + "] ") + .withStyle(Style.EMPTY.withColor(TRADER_NAME_COLOR)) + .append( + Component.literal( + "You don't have a trader token. Leave now, or I'll make you leave." + ).withStyle(ChatFormatting.RED) + ) + ); + return InteractionResult.SUCCESS; + } + // Second click → attack directly + this.hostileCooldownTicks = HOSTILE_DURATION_TICKS; + this.setTarget(player); + } + return InteractionResult.SUCCESS; + } + } + + /** + * Open the trading screen for a player. + * Gathers available captives and sends packet to client. + */ + private void openTradingScreen(ServerPlayer player) { + if (!(this.level() instanceof ServerLevel serverLevel)) { + return; + } + + // Gather captive offers from camp cells + List offers = gatherCaptiveOffers(serverLevel); + + TiedUpMod.LOGGER.info( + "[EntitySlaveTrader] {} opening trading screen for {} with {} offers", + this.getNpcName(), + player.getName().getString(), + offers.size() + ); + + // Send packet to open screen + ModNetwork.sendToPlayer( + new PacketOpenTraderScreen( + this.getId(), + this.getNpcName(), + offers + ), + player + ); + } + + /** + * Gather captive offers from camp cells. + * Supports both Players and NPCs (Damsels) as captives. + */ + private List gatherCaptiveOffers(ServerLevel level) { + List offers = new ArrayList<>(); + + if (campUUID == null) { + return offers; + } + + CampOwnership ownership = CampOwnership.get(level); + CampOwnership.CampData campData = ownership.getCamp(campUUID); + + if (campData == null || campData.getCenter() == null) { + return offers; + } + + CellRegistryV2 cellRegistry = CellRegistryV2.get(level); + List cells = cellRegistry.findCellsNear( + campData.getCenter(), + 50.0 + ); + + for (CellDataV2 cell : cells) { + if (!cell.isOccupied()) { + continue; + } + + // Iterate over ALL prisoners in the cell (not just first) + for (UUID captiveId : cell.getPrisonerIds()) { + // Try to find the captive - first as Player, then as Entity + net.minecraft.world.entity.LivingEntity captiveEntity = null; + String captiveName = null; + + // Try player first + ServerPlayer captivePlayer = level + .getServer() + .getPlayerList() + .getPlayer(captiveId); + if (captivePlayer != null) { + captiveEntity = captivePlayer; + captiveName = captivePlayer.getName().getString(); + } else { + // Try as entity (e.g., Damsel) + net.minecraft.world.entity.Entity entity = level.getEntity( + captiveId + ); + if ( + entity instanceof + net.minecraft.world.entity.LivingEntity living + ) { + captiveEntity = living; + captiveName = living.getName().getString(); + } + } + + if (captiveEntity == null) { + continue; + } + + IRestrainable kidnappedState = KidnappedHelper.getKidnappedState( + captiveEntity + ); + if (kidnappedState == null || !kidnappedState.isForSell()) { + continue; + } + + ItemTask price = kidnappedState.getSalePrice(); + if (price == null) { + continue; + } + + String priceDescription = + price.getAmount() + + "x " + + (price.getItem() != null + ? price.getItem().getDescription().getString() + : "???"); + + offers.add( + new CaptiveOfferData( + captiveId, + captiveName, + priceDescription, + price.getAmount(), + price.getItemId() + ) + ); + } + } + + return offers; + } + + // Uses EntityKidnapper.hasTokenInInventory(player) - inherited from parent + + // ======================================== + // TARGETING - Trader doesn't capture + // ======================================== + + @Override + public void setTarget(@Nullable LivingEntity target) { + super.setTarget(target); + if (target != null) { + this.chaseStartTick = this.tickCount; + } + } + + @Override + public boolean isSuitableTarget( + net.minecraft.world.entity.LivingEntity entity + ) { + // Trader doesn't actively hunt - only attacks if: + // 1. Entity attacked us (getLastAttacker) + // 2. Entity has no token + + if (entity instanceof Player player) { + // Don't target players with tokens + if (hasTokenInInventory(player)) { + return false; + } + } + + // Only target if they attacked us + return entity == this.getLastAttacker(); + } + + // ======================================== + // MAID & CAMP MANAGEMENT + // ======================================== + + @Nullable + public UUID getMaidUUID() { + return maidUUID; + } + + public void setMaidUUID(@Nullable UUID maidUUID) { + this.maidUUID = maidUUID; + } + + @Nullable + public UUID getCampUUID() { + return campUUID; + } + + public void setCampUUID(@Nullable UUID campUUID) { + this.campUUID = campUUID; + } + + @Nullable + public net.minecraft.core.BlockPos getSpawnPos() { + return spawnPos; + } + + public void setSpawnPos(@Nullable net.minecraft.core.BlockPos spawnPos) { + this.spawnPos = spawnPos; + } + + /** + * Clear all hostility state and return to neutral. + */ + private void clearHostility() { + this.setTarget(null); + this.hostileCooldownTicks = 0; + this.warnedPlayerUUID = null; + } + + /** + * Get the maid entity if loaded. + */ + @Nullable + public EntityMaid getMaid() { + if ( + maidUUID == null || + !(this.level() instanceof ServerLevel serverLevel) + ) { + return null; + } + var entity = serverLevel.getEntity(maidUUID); + return entity instanceof EntityMaid maid ? maid : null; + } + + /** + * Order the maid to deliver a captive to a buyer. + */ + public void orderMaidDeliverCaptive(IRestrainable captive, Player buyer) { + EntityMaid maid = getMaid(); + if (maid != null) { + maid.startDeliverCaptive(captive, buyer); + } + } + + /** + * Order the maid to collect ransom/items. + */ + public void orderMaidCollectItems(BlockPos target) { + EntityMaid maid = getMaid(); + if (maid != null) { + maid.startCollectItems(target); + } + } + + // ======================================== + // DEATH HANDLING + // ======================================== + + /** Guard against double-cleanup (die() triggers remove(KILLED)) */ + private boolean cleanedUp = false; + + private void performCleanup() { + if (cleanedUp) return; + cleanedUp = true; + + if ( + !this.level().isClientSide && + this.level() instanceof ServerLevel serverLevel + ) { + // Mark camp as dead and perform full cleanup + // This will: cancel ransoms, free prisoners, unlock collars, clear labor states + if (campUUID != null) { + CampLifecycleManager.markCampDead(campUUID, serverLevel); + TiedUpMod.LOGGER.info( + "[EntitySlaveTrader] {} removed, camp {} marked as dead and prisoners freed", + this.getNpcName(), + campUUID.toString().substring(0, 8) + ); + } + + // Free the maid (becomes neutral/capturable) + EntityMaid maid = getMaid(); + if (maid != null) { + maid.onTraderDeath(); + } + } + } + + @Override + public void die( + net.minecraft.world.damagesource.DamageSource damageSource + ) { + performCleanup(); + super.die(damageSource); + } + + @Override + public void remove(Entity.RemovalReason reason) { + performCleanup(); + super.remove(reason); + } + + // ======================================== + // VARIANT SYSTEM + // ======================================== + + @Override + public KidnapperVariant lookupVariantById(String variantId) { + return TraderSkinManager.CORE.getVariant(variantId); + } + + @Override + public KidnapperVariant computeVariantForEntity(UUID entityUUID) { + return TraderSkinManager.CORE.getVariantForEntity( + entityUUID, + Gender.FEMALE + ); + } + + @Override + public String getVariantTextureFolder() { + return "textures/entity/kidnapper/trader/"; + } + + @Override + public String getDefaultVariantId() { + return "trader_default"; + } + + @Override + public String getVariantNBTKey() { + return "TraderVariantId"; + } + + @Override + public void applyVariantName(KidnapperVariant variant) { + // Numbered variants (trader_mob_1, trader_mob_2, etc.) get random names + // Named variants (trader_default, trader_noble, etc.) use their default name + if (variant.id().startsWith("trader_mob_")) { + this.setNpcName( + com.tiedup.remake.util.NameGenerator.getRandomTraderName() + ); + } else { + this.setNpcName(variant.defaultName()); + } + } + + // ======================================== + // DISPLAY + // ======================================== + + @Override + public Component getDisplayName() { + return Component.literal(this.getNpcName()).withStyle( + Style.EMPTY.withColor(TRADER_NAME_COLOR) + ); + } + + // ======================================== + // NBT PERSISTENCE + // ======================================== + + @Override + public void addAdditionalSaveData(CompoundTag tag) { + super.addAdditionalSaveData(tag); + + if (maidUUID != null) { + tag.putUUID("MaidUUID", maidUUID); + } + if (campUUID != null) { + tag.putUUID("CampUUID", campUUID); + } + if (spawnPos != null) { + tag.putInt("SpawnX", spawnPos.getX()); + tag.putInt("SpawnY", spawnPos.getY()); + tag.putInt("SpawnZ", spawnPos.getZ()); + } + if (hostileCooldownTicks > 0) { + tag.putInt("HostileCooldown", hostileCooldownTicks); + } + } + + @Override + public void readAdditionalSaveData(CompoundTag tag) { + super.readAdditionalSaveData(tag); + + if (tag.contains("MaidUUID")) { + maidUUID = tag.getUUID("MaidUUID"); + } + if (tag.contains("CampUUID")) { + campUUID = tag.getUUID("CampUUID"); + } + if ( + tag.contains("SpawnX") && + tag.contains("SpawnY") && + tag.contains("SpawnZ") + ) { + spawnPos = new net.minecraft.core.BlockPos( + tag.getInt("SpawnX"), + tag.getInt("SpawnY"), + tag.getInt("SpawnZ") + ); + } + if (tag.contains("HostileCooldown")) { + hostileCooldownTicks = tag.getInt("HostileCooldown"); + } + } + + // ======================================== + // CAPTURE DETECTION + // ======================================== + + /** + * Check if trader has been captured and removed from camp. + * If captured and taken far from camp (>100 blocks), the camp dies. + */ + @Override + public void tick() { + super.tick(); + + // Server-side only + if ( + !this.level().isClientSide && + this.level() instanceof ServerLevel serverLevel + ) { + // Grace period expiry — just reset warning (player gets a fresh warning next click) + if ( + warnedPlayerUUID != null && + this.tickCount - warningTick > GRACE_PERIOD_TICKS + ) { + warnedPlayerUUID = null; + } + + // Chase limit — give up if too far from spawn or chasing too long + if (this.getTarget() != null) { + boolean shouldClearTarget = false; + + // Timer-based limit (always works, even if spawnPos is null) + boolean tooLong = + this.tickCount - chaseStartTick > MAX_CHASE_TICKS; + if (tooLong) { + shouldClearTarget = true; + } + + // Distance-from-spawn limit (when spawnPos is available) + if (!shouldClearTarget && spawnPos != null) { + double distFromSpawn = this.distanceToSqr( + spawnPos.getX() + 0.5, + spawnPos.getY(), + spawnPos.getZ() + 0.5 + ); + if ( + distFromSpawn > + MAX_CHASE_DISTANCE_FROM_SPAWN * + MAX_CHASE_DISTANCE_FROM_SPAWN + ) { + shouldClearTarget = true; + } + } + + // Token forgiveness — stop chasing if target acquired a token + if ( + !shouldClearTarget && + this.getTarget() instanceof Player targetPlayer && + hasTokenInInventory(targetPlayer) + ) { + shouldClearTarget = true; + } + + if (shouldClearTarget) { + clearHostility(); + } + } + + // Hostility cooldown — auto-reset after duration expires + if (hostileCooldownTicks > 0) { + hostileCooldownTicks--; + if (hostileCooldownTicks <= 0 && this.getTarget() != null) { + clearHostility(); + } + } + + // Check every second (20 ticks) + if (this.tickCount % 20 == 0) { + checkIfCapturedAndRemoved(serverLevel); + } + } + } + + private void checkIfCapturedAndRemoved(ServerLevel level) { + // Must be tied up + if (!isTiedUp()) return; + + // Must have a camp + UUID campId = getCampUUID(); + if (campId == null) return; + + CampOwnership ownership = CampOwnership.get(level); + CampOwnership.CampData camp = ownership.getCamp(campId); + if (camp == null || camp.getCenter() == null) return; + + // Check distance from camp center + BlockPos campCenter = camp.getCenter(); + double distanceSq = this.distanceToSqr( + campCenter.getX(), + campCenter.getY(), + campCenter.getZ() + ); + + // If > 100 blocks away → camp destroyed + double CAPTURE_DISTANCE_THRESHOLD = 100.0; + if ( + distanceSq > CAPTURE_DISTANCE_THRESHOLD * CAPTURE_DISTANCE_THRESHOLD + ) { + TiedUpMod.LOGGER.warn( + "[EntitySlaveTrader] {} captured and removed {} blocks from camp {} - marking camp dead", + this.getNpcName(), + (int) Math.sqrt(distanceSq), + campId.toString().substring(0, 8) + ); + + // Mark camp as dead (frees all prisoners, etc.) + CampLifecycleManager.markCampDead(campId, level); + + // Clear our camp reference + this.setCampUUID(null); + + // Broadcast destruction message + broadcastCampDestruction(level, campCenter); + } + } + + private void broadcastCampDestruction( + ServerLevel level, + BlockPos campCenter + ) { + Component message = Component.literal( + "A slave trader camp has been destroyed!" + ).withStyle(ChatFormatting.GOLD, ChatFormatting.BOLD); + + // Send to all players within 200 blocks + for (ServerPlayer player : level.players()) { + double distSq = player.distanceToSqr( + campCenter.getX(), + campCenter.getY(), + campCenter.getZ() + ); + + if (distSq < 200 * 200) { + player.sendSystemMessage(message); + } + } + } + + // ======================================== + // STRUGGLE DETECTION + // ======================================== + + /** Target prisoner to approach after catching them struggling */ + @Nullable + private ServerPlayer strugglePunishmentTarget; + + /** Maximum distance to HEAR struggle (through bars, no line of sight needed) */ + private static final double STRUGGLE_HEARING_RANGE = 6.0; + + /** Maximum distance to SEE struggle (requires line of sight) */ + private static final double STRUGGLE_VISION_RANGE = 15.0; + + /** + * Called when a prisoner is detected struggling nearby. + * Uses HYBRID detection: HEARING (close range) + VISION (far range). + * + * Detection modes: + * - Within 6 blocks: Can HEAR through bars/fences (no line of sight needed) + * - Within 15 blocks: Can SEE if line of sight is clear + * + * If detected: + * 1. Shock the prisoner (punishment - harder than maid!) + * 2. Approach to tighten their binds (reset resistance) + * + * @param prisoner The player who is struggling + */ + public void onStruggleDetected(ServerPlayer prisoner) { + // HYBRID DETECTION: Hearing (close) + Vision (far) + double distance = this.distanceTo(prisoner); + + // HEARING: Close range - can hear through bars/fences (no LOS needed) + boolean canHear = distance <= STRUGGLE_HEARING_RANGE; + + // VISION: Longer range - requires clear line of sight + boolean canSee = + distance <= STRUGGLE_VISION_RANGE && + this.getSensing().hasLineOfSight(prisoner); + + if (!canHear && !canSee) { + return; // Can't detect the struggle + } + + // Check if this player is a prisoner of our camp + if (campUUID == null) { + return; + } + + if (!(this.level() instanceof ServerLevel serverLevel)) { + return; + } + + PrisonerManager manager = PrisonerManager.get(serverLevel); + PrisonerRecord record = manager.getPrisoner(prisoner.getUUID()); + if (record == null || !campUUID.equals(record.getCampId())) { + return; // Not our prisoner + } + + IRestrainable state = KidnappedHelper.getKidnappedState(prisoner); + if (state == null || !state.isTiedUp()) { + return; + } + + String detectionMethod = canHear ? "heard" : "saw"; + TiedUpMod.LOGGER.info( + "[EntitySlaveTrader] {} {} {} struggling! Punishing... (distance: {})", + this.getNpcName(), + detectionMethod, + prisoner.getName().getString(), + distance + ); + + // PUNISHMENT: Shock the prisoner + state.shockKidnapped(" Don't even think about it.", 3.0f); // Trader shocks harder + + // TIGHTEN BINDS: Reset resistance to maximum + tightenBinds(state, prisoner); + + // Look at the prisoner menacingly + this.getLookControl().setLookAt(prisoner, 30.0f, 30.0f); + + // Set as target to approach + this.strugglePunishmentTarget = prisoner; + } + + /** + * Tighten the prisoner's binds by resetting their resistance to maximum. + * This happens when a guard catches someone struggling. + * + * @param state The prisoner's kidnapped state + * @param prisoner The prisoner entity + */ + private void tightenBinds(IRestrainable state, LivingEntity prisoner) { + com.tiedup.remake.util.RestraintApplicator.tightenBind(state, prisoner); + } + + /** + * Get the current struggle punishment target. + * @return The prisoner to approach, or null if none + */ + @Nullable + public ServerPlayer getStrugglePunishmentTarget() { + return this.strugglePunishmentTarget; + } + + /** + * Clear the struggle punishment target (after approaching or timeout). + */ + public void clearStrugglePunishmentTarget() { + this.strugglePunishmentTarget = null; + } + + // ======================================== + // DIALOGUE SPEAKER (Trader-specific) + // ======================================== + + @Override + public SpeakerType getSpeakerType() { + return SpeakerType.TRADER; + } + + @Override + public PersonalityType getSpeakerPersonality() { + // Traders are greedy business-oriented + return PersonalityType.PROUD; + } + + @Override + public int getSpeakerMood() { + // Mood based on business (captives to sell) + // Count captives in camp cells + int captiveCount = 0; + if ( + campUUID != null && this.level() instanceof ServerLevel serverLevel + ) { + CampOwnership ownership = CampOwnership.get(serverLevel); + CampOwnership.CampData campData = ownership.getCamp(campUUID); + if (campData != null && campData.getCenter() != null) { + CellRegistryV2 cellRegistry = CellRegistryV2.get(serverLevel); + List cells = cellRegistry.findCellsNear( + campData.getCenter(), + 50.0 + ); + for (CellDataV2 cell : cells) { + if (cell.isOccupied()) { + captiveCount += cell.getPrisonerIds().size(); + } + } + } + } + if (captiveCount > 2) { + return 85; // Excellent business! + } else if (captiveCount > 0) { + return 70; // Good business + } + return 50; // Waiting for captives + } + + @Override + public String getTargetRelation(Player player) { + // Check if player has trader token (friendly) + // For now, just return customer if interacting + return "customer"; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ISkinnedEntity.java b/src/main/java/com/tiedup/remake/entities/ISkinnedEntity.java new file mode 100644 index 0000000..650808f --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ISkinnedEntity.java @@ -0,0 +1,27 @@ +package com.tiedup.remake.entities; + +import net.minecraft.resources.ResourceLocation; + +/** + * Interface for entities that provide their own skin texture. + * + *

This interface eliminates instanceof cascades in renderers by allowing + * each entity subclass to define its own texture logic polymorphically. + * + *

Issue #19: DamselRenderer had 6+ instanceof checks to determine + * which texture method to call. With this interface, the renderer simply calls + * {@link #getSkinTexture()} and each entity type returns the appropriate texture. + * + * @see DamselRenderer + */ +public interface ISkinnedEntity { + /** + * Get the skin texture for this entity. + * + *

Implementations should return the appropriate texture based on + * the entity's current state (variant, skin selection, etc.). + * + * @return The texture ResourceLocation, never null + */ + ResourceLocation getSkinTexture(); +} diff --git a/src/main/java/com/tiedup/remake/entities/KidnapperCaptureEquipment.java b/src/main/java/com/tiedup/remake/entities/KidnapperCaptureEquipment.java new file mode 100644 index 0000000..61a48a3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/KidnapperCaptureEquipment.java @@ -0,0 +1,158 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.BindVariant; +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import org.jetbrains.annotations.Nullable; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.item.ItemStack; + +/** + * Manages capture equipment for EntityKidnapper. + * + *

Handles the themed bondage items used during capture sequences: + *

    + *
  • Bind (always) - Main hand during capture
  • + *
  • Gag (probability) - Off hand during capture
  • + *
  • Mittens, Earplugs, Blindfold (probability) - Applied after capture
  • + *
+ * + *

The theme and items are selected at spawn via {@link KidnapperItemSelector} + * and stored in the kidnapper's {@code itemSelection} field. + * + *

Note: This class does NOT handle persistence - the theme is persisted + * by EntityKidnapper directly. + */ +public class KidnapperCaptureEquipment { + + protected final EntityKidnapper kidnapper; + + public KidnapperCaptureEquipment(EntityKidnapper kidnapper) { + this.kidnapper = kidnapper; + } + + // ======================================== + // ITEM GETTERS + // ======================================== + + /** + * Get bind item to use for capture. + * Uses held item if valid, otherwise default ropes. + * + * @return The bind ItemStack to use (never null/empty) + */ + public ItemStack getBindItem() { + ItemStack mainHand = kidnapper.getMainHandItem(); + if (!mainHand.isEmpty() && mainHand.getItem() instanceof IV2BondageItem) { + return mainHand; + } + return new ItemStack(ModItems.getBind(BindVariant.ROPES)); + } + + /** + * Get gag item to use for capture. + * Uses offhand item if valid, otherwise empty (no gag). + * + * @return The gag ItemStack, or empty if no gag selected + */ + public ItemStack getGagItem() { + ItemStack offHand = kidnapper.getOffhandItem(); + if (!offHand.isEmpty() && offHand.getItem() instanceof IV2BondageItem) { + return offHand; + } + return ItemStack.EMPTY; + } + + /** + * Get mittens item to apply after capture. + * + * @return The mittens ItemStack, or null if not selected + */ + @Nullable + public ItemStack getMittensItem() { + var selection = kidnapper.getItemSelection(); + if (selection != null && !selection.mittens.isEmpty()) { + return selection.mittens.copy(); + } + return null; + } + + /** + * Get earplugs item to apply after capture. + * + * @return The earplugs ItemStack, or null if not selected + */ + @Nullable + public ItemStack getEarplugsItem() { + var selection = kidnapper.getItemSelection(); + if (selection != null && !selection.earplugs.isEmpty()) { + return selection.earplugs.copy(); + } + return null; + } + + /** + * Get blindfold item to apply after capture. + * + * @return The blindfold ItemStack, or null if not selected + */ + @Nullable + public ItemStack getBlindfoldItem() { + var selection = kidnapper.getItemSelection(); + if (selection != null && !selection.blindfold.isEmpty()) { + return selection.blindfold.copy(); + } + return null; + } + + /** + * Get collar item to apply during capture. + * Returns a basic shock collar that kidnappers use. + * + * @return The collar ItemStack, or null if not available + */ + @Nullable + public ItemStack getCollarItem() { + // Kidnappers always have a shock collar to mark their captives + return new ItemStack(ModItems.SHOCK_COLLAR.get()); + } + + // ======================================== + // HELD ITEM MANAGEMENT + // ======================================== + + /** + * Equip themed bind and gag items before capture. + * Called when starting to chase a target. + * + *

This makes the kidnapper visually hold their capture tools. + */ + public void setUpHeldItems() { + // Initialize theme if not done yet + var selection = kidnapper.getItemSelection(); + if (selection == null) { + kidnapper.ensureAppearanceInitialized(); + selection = kidnapper.getItemSelection(); + } + + // Equip bind in main hand (always present) + kidnapper.setItemSlot(EquipmentSlot.MAINHAND, selection.bind.copy()); + + // Equip gag in off hand (if selected) + if (!selection.gag.isEmpty()) { + kidnapper.setItemSlot(EquipmentSlot.OFFHAND, selection.gag.copy()); + } + } + + /** + * Clear held items (hide capture tools). + * Called when not chasing anyone. + * + *

Note: Subclasses may override this to restore different items + * (e.g., Archer restores bow, Merchant may have different behavior). + */ + public void clearHeldItems() { + kidnapper.setItemSlot(EquipmentSlot.MAINHAND, ItemStack.EMPTY); + kidnapper.setItemSlot(EquipmentSlot.OFFHAND, ItemStack.EMPTY); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/KidnapperCollarConfig.java b/src/main/java/com/tiedup/remake/entities/KidnapperCollarConfig.java new file mode 100644 index 0000000..556e7ef --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/KidnapperCollarConfig.java @@ -0,0 +1,167 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.util.teleport.Position; +import java.util.List; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * Helper class to access collar configuration for EntityKidnapper. + * + *

Provides null-safe accessors for collar settings like: + *

    + *
  • Kidnapping mode state
  • + *
  • Prison/Home positions
  • + *
  • Blacklist/Whitelist filtering
  • + *
  • Post-capture behavior flags
  • + *
+ * + *

This is a transient helper - not persisted to NBT. + */ +public class KidnapperCollarConfig { + + private final EntityKidnapper kidnapper; + + public KidnapperCollarConfig(EntityKidnapper kidnapper) { + this.kidnapper = kidnapper; + } + + // ======================================== + // COLLAR ITEM ACCESS + // ======================================== + + /** + * Get the collar item if equipped. + * @return ItemCollar or null if no collar or not an ItemCollar + */ + @Nullable + public ItemCollar getCollarItem() { + ItemStack collar = kidnapper.getEquipment(BodyRegionV2.NECK); + if (collar.isEmpty()) return null; + if (collar.getItem() instanceof ItemCollar itemCollar) { + return itemCollar; + } + return null; + } + + /** + * Get the collar ItemStack. + * @return The collar stack (may be empty) + */ + public ItemStack getCollarStack() { + return kidnapper.getEquipment(BodyRegionV2.NECK); + } + + // ======================================== + // KIDNAPPING MODE + // ======================================== + + /** + * Check if kidnapping mode is enabled via collar. + */ + public boolean isKidnappingModeEnabled() { + if (!kidnapper.hasCollar()) return false; + + ItemCollar itemCollar = getCollarItem(); + if (itemCollar == null) return false; + + return itemCollar.isKidnappingModeEnabled(getCollarStack()); + } + + /** + * Check if kidnapping mode is fully ready (enabled + prison set). + */ + public boolean isKidnappingModeReady() { + if (!kidnapper.hasCollar()) return false; + + ItemCollar itemCollar = getCollarItem(); + if (itemCollar == null) return false; + + return itemCollar.isKidnappingModeReady(getCollarStack()); + } + + // ======================================== + // POSITION GETTERS + // ======================================== + + /** + * Get cell ID from collar. + */ + @Nullable + public java.util.UUID getCellId() { + ItemCollar itemCollar = getCollarItem(); + if (itemCollar == null) return null; + + return itemCollar.getCellId(getCollarStack()); + } + + /** + * Check if collar has a cell assigned. + */ + public boolean hasCellAssigned() { + ItemCollar itemCollar = getCollarItem(); + if (itemCollar == null) return false; + + return itemCollar.hasCellAssigned(getCollarStack()); + } + + // ======================================== + // BEHAVIOR FLAGS + // ======================================== + + /** + * Check if should warn masters after capturing slave. + */ + public boolean shouldWarnMasters() { + ItemCollar itemCollar = getCollarItem(); + if (itemCollar == null) return false; + + return itemCollar.shouldWarnMasters(getCollarStack()); + } + + /** + * Check if should tie slave to pole in prison. + */ + public boolean shouldTieToPole() { + ItemCollar itemCollar = getCollarItem(); + if (itemCollar == null) return false; + + return itemCollar.shouldTieToPole(getCollarStack()); + } + + // ======================================== + // BLACKLIST/WHITELIST + // ======================================== + + /** + * Check if player is valid target for kidnapping mode. + * Uses collar blacklist/whitelist. + * + *

Logic: + *

    + *
  • If whitelist is not empty, player MUST be on whitelist
  • + *
  • Otherwise, player must NOT be on blacklist
  • + *
+ */ + public boolean isValidKidnappingTarget(Player player) { + ItemCollar itemCollar = getCollarItem(); + if (itemCollar == null) return true; // No collar config = everyone is valid + + ItemStack collarStack = getCollarStack(); + UUID playerUUID = player.getUUID(); + + // If whitelist exists and is not empty, player MUST be on it + List whitelist = itemCollar.getWhitelist(collarStack); + if (!whitelist.isEmpty()) { + return whitelist.contains(playerUUID); + } + + // Otherwise, check blacklist (blacklisted = not a valid target) + return !itemCollar.isBlacklisted(collarStack, playerUUID); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/KidnapperItemSelector.java b/src/main/java/com/tiedup/remake/entities/KidnapperItemSelector.java new file mode 100644 index 0000000..65ef3bb --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/KidnapperItemSelector.java @@ -0,0 +1,486 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.*; +import java.util.Random; +import org.jetbrains.annotations.Nullable; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.item.ItemStack; + +/** + * Helper class for selecting themed items for kidnappers. + * Handles probability-based item selection and color application. + * + * Item selection order (most to least common): + * 1. Bind (arms) - 100% (always) + * 2. Gag - 50% + * 3. Mittens - 40% + * 4. Earplugs - 30% + * 5. Blindfold - 20% (last, most restrictive) + * + * Elite kidnappers get significant bonuses to all probabilities. + */ +public class KidnapperItemSelector { + + private static final Random RANDOM = new Random(); + + /** NBT key for item color */ + public static final String NBT_ITEM_COLOR = "ItemColor"; + + // === BASE PROBABILITIES (Regular Kidnapper) === + public static final double PROB_BIND = 1.0; // 100% + public static final double PROB_GAG = 0.5; // 50% + public static final double PROB_MITTENS = 0.4; // 40% + public static final double PROB_EARPLUGS = 0.3; // 30% + public static final double PROB_BLINDFOLD = 0.2; // 20% + + // === ELITE KIDNAPPER BONUSES === + public static final double ELITE_GAG_BONUS = 0.5; // +50% = 100% + public static final double ELITE_MITTENS_BONUS = 0.4; // +40% = 80% + public static final double ELITE_EARPLUGS_BONUS = 0.3; // +30% = 60% + public static final double ELITE_BLINDFOLD_BONUS = 0.2; // +20% = 40% + + // === ARCHER KIDNAPPER PENALTIES (relies on arrows) === + public static final double ARCHER_GAG_PENALTY = 0.2; // -20% = 30% + public static final double ARCHER_MITTENS_PENALTY = 0.2; // -20% = 20% + public static final double ARCHER_EARPLUGS_PENALTY = 0.15; // -15% = 15% + public static final double ARCHER_BLINDFOLD_PENALTY = 0.1; // -10% = 10% + + /** + * Result of item selection for a kidnapper. + * Contains theme, color, and all selected items. + */ + public static class SelectionResult { + + public final KidnapperTheme theme; + public final ItemColor color; // null if theme doesn't support colors + public final ItemStack bind; + public final ItemStack gag; + public final ItemStack mittens; + public final ItemStack earplugs; + public final ItemStack blindfold; + + public SelectionResult( + KidnapperTheme theme, + ItemColor color, + ItemStack bind, + ItemStack gag, + ItemStack mittens, + ItemStack earplugs, + ItemStack blindfold + ) { + this.theme = theme; + this.color = color; + this.bind = bind; + this.gag = gag; + this.mittens = mittens; + this.earplugs = earplugs; + this.blindfold = blindfold; + } + + /** + * Check if this selection has a gag. + */ + public boolean hasGag() { + return !gag.isEmpty(); + } + + /** + * Check if this selection has mittens. + */ + public boolean hasMittens() { + return !mittens.isEmpty(); + } + + /** + * Check if this selection has earplugs. + */ + public boolean hasEarplugs() { + return !earplugs.isEmpty(); + } + + /** + * Check if this selection has a blindfold. + */ + public boolean hasBlindfold() { + return !blindfold.isEmpty(); + } + } + + /** + * Select items for a regular kidnapper. + */ + public static SelectionResult selectForKidnapper() { + return selectItems(false, false); + } + + /** + * Select items for an elite kidnapper (higher probabilities). + */ + public static SelectionResult selectForEliteKidnapper() { + return selectItems(true, false); + } + + /** + * Select items for an archer kidnapper (lower probabilities - relies on arrows). + */ + public static SelectionResult selectForArcherKidnapper() { + return selectItems(false, true); + } + + /** + * Calculate adjusted probability based on kidnapper type. + * + * @param baseProb Base probability for the item + * @param eliteBonus Bonus probability for elite kidnappers + * @param archerPenalty Penalty probability for archer kidnappers + * @param isElite Whether the kidnapper is elite + * @param isArcher Whether the kidnapper is an archer + * @return Adjusted probability + */ + private static double getAdjustedProbability( + double baseProb, + double eliteBonus, + double archerPenalty, + boolean isElite, + boolean isArcher + ) { + double prob = baseProb; + if (isElite) prob += eliteBonus; + if (isArcher) prob -= archerPenalty; + return prob; + } + + /** + * Internal item selection logic. + */ + private static SelectionResult selectItems( + boolean isElite, + boolean isArcher + ) { + // 1. Select random theme + KidnapperTheme theme = KidnapperTheme.getRandomWeighted(); + + // 2. Select color (if theme supports it) + // Filter out colors that don't have textures for this theme's bind + ItemColor color = theme.supportsColor() + ? getValidColorForBind(theme.getBind()) + : null; + + // 3. Create bind (always) + ItemStack bind = createBind(theme.getBind(), color); + + // 4. Roll for gag (randomly selected from theme's compatible gags) + ItemStack gag = ItemStack.EMPTY; + double gagProb = getAdjustedProbability( + PROB_GAG, + ELITE_GAG_BONUS, + ARCHER_GAG_PENALTY, + isElite, + isArcher + ); + if (RANDOM.nextDouble() < gagProb) { + gag = createGag(theme.getRandomGag(), color); + } + + // 5. Roll for mittens (same for all themes) + ItemStack mittens = ItemStack.EMPTY; + double mittensProb = getAdjustedProbability( + PROB_MITTENS, + ELITE_MITTENS_BONUS, + ARCHER_MITTENS_PENALTY, + isElite, + isArcher + ); + if (RANDOM.nextDouble() < mittensProb) { + mittens = createMittens(); + } + + // 6. Roll for earplugs (same for all themes) + ItemStack earplugs = ItemStack.EMPTY; + double earplugsProb = getAdjustedProbability( + PROB_EARPLUGS, + ELITE_EARPLUGS_BONUS, + ARCHER_EARPLUGS_PENALTY, + isElite, + isArcher + ); + if (RANDOM.nextDouble() < earplugsProb) { + earplugs = createEarplugs(); + } + + // 7. Roll for blindfold (last, most restrictive - randomly selected) + ItemStack blindfold = ItemStack.EMPTY; + double blindfoldProb = getAdjustedProbability( + PROB_BLINDFOLD, + ELITE_BLINDFOLD_BONUS, + ARCHER_BLINDFOLD_PENALTY, + isElite, + isArcher + ); + if (theme.hasBlindfolds() && RANDOM.nextDouble() < blindfoldProb) { + blindfold = createBlindfold(theme.getRandomBlindfold(), color); + } + + return new SelectionResult( + theme, + color, + bind, + gag, + mittens, + earplugs, + blindfold + ); + } + + // ========================================= + // ITEM CREATION METHODS + // ========================================= + + /** + * Create a bind ItemStack with optional color. + */ + public static ItemStack createBind( + BindVariant variant, + @Nullable ItemColor color + ) { + ItemStack stack = new ItemStack(ModItems.getBind(variant)); + if (color != null && variant.supportsColor()) { + applyColor(stack, color); + } + return stack; + } + + /** + * Create a gag ItemStack with optional color. + * Validates that the color has a texture for this gag variant. + */ + public static ItemStack createGag( + GagVariant variant, + @Nullable ItemColor color + ) { + ItemStack stack = new ItemStack(ModItems.getGag(variant)); + if ( + color != null && + variant.supportsColor() && + isColorValidForGag(color, variant) + ) { + applyColor(stack, color); + } + return stack; + } + + /** + * Create a blindfold ItemStack with optional color. + * Validates that the color has a texture for this blindfold variant. + */ + public static ItemStack createBlindfold( + BlindfoldVariant variant, + @Nullable ItemColor color + ) { + ItemStack stack = new ItemStack(ModItems.getBlindfold(variant)); + if ( + color != null && + variant.supportsColor() && + isColorValidForBlindfold(color, variant) + ) { + applyColor(stack, color); + } + return stack; + } + + /** + * Create mittens ItemStack. + * Mittens don't have color variants. + */ + public static ItemStack createMittens() { + return new ItemStack(ModItems.getMittens(MittensVariant.LEATHER)); + } + + /** + * Create earplugs ItemStack. + * Earplugs don't have color variants. + */ + public static ItemStack createEarplugs() { + return new ItemStack(ModItems.getEarplugs(EarplugsVariant.CLASSIC)); + } + + // ========================================= + // COLOR METHODS + // ========================================= + + /** NBT key for CustomModelData (used for model overrides) */ + public static final String NBT_CUSTOM_MODEL_DATA = "CustomModelData"; + + /** + * Apply color NBT to an ItemStack. + * Sets both the ItemColor name and CustomModelData for model selection. + */ + public static void applyColor(ItemStack stack, ItemColor color) { + if (stack.isEmpty() || color == null) return; + CompoundTag tag = stack.getOrCreateTag(); + tag.putString(NBT_ITEM_COLOR, color.getName()); + tag.putInt(NBT_CUSTOM_MODEL_DATA, color.getModelId()); + } + + /** + * Get color from an ItemStack. + * @return The color, or null if no color is set + */ + @Nullable + public static ItemColor getColor(ItemStack stack) { + if (stack.isEmpty()) return null; + CompoundTag tag = stack.getTag(); + if (tag == null || !tag.contains(NBT_ITEM_COLOR)) return null; + return ItemColor.fromName(tag.getString(NBT_ITEM_COLOR)); + } + + /** + * Check if an ItemStack has a color applied. + */ + public static boolean hasColor(ItemStack stack) { + return getColor(stack) != null; + } + + /** + * Get the texture suffix for an item's color. + * Example: "ropes" + "_red" = "ropes_red" + * @return The color suffix (e.g., "_red"), or empty string if no color + */ + public static String getColorSuffix(ItemStack stack) { + ItemColor color = getColor(stack); + return color != null ? "_" + color.getName() : ""; + } + + // ========================================= + // COLOR VALIDATION + // ========================================= + + /** + * Get a random color that has a texture for the given bind variant. + * Excludes colors that don't have textures for specific variants. + */ + public static ItemColor getValidColorForBind(BindVariant variant) { + ItemColor color; + int attempts = 0; + do { + color = ItemColor.getRandomStandard(); + attempts++; + // Prevent infinite loop + if (attempts > 50) break; + } while (!isColorValidForBind(color, variant)); + return color; + } + + /** + * Check if a color has a texture for the given bind variant. + * Returns false for colors without textures. + */ + public static boolean isColorValidForBind( + ItemColor color, + BindVariant variant + ) { + if (color == null || variant == null) return true; + + // BROWN doesn't have textures for ROPES and SHIBARI + if ( + color == ItemColor.BROWN && + (variant == BindVariant.ROPES || variant == BindVariant.SHIBARI) + ) { + return false; + } + + // GRAY doesn't have texture for DUCT_TAPE + if (color == ItemColor.GRAY && variant == BindVariant.DUCT_TAPE) { + return false; + } + + return true; + } + + /** + * Check if a color has a texture for the given gag variant. + */ + public static boolean isColorValidForGag( + ItemColor color, + GagVariant variant + ) { + if (color == null || variant == null) return true; + + // GRAY doesn't have texture for TAPE_GAG + if (color == ItemColor.GRAY && variant == GagVariant.TAPE_GAG) { + return false; + } + + // WHITE doesn't have texture for CLOTH_GAG and CLEAVE_GAG + if ( + color == ItemColor.WHITE && + (variant == GagVariant.CLOTH_GAG || + variant == GagVariant.CLEAVE_GAG) + ) { + return false; + } + + // RED doesn't have texture for BALL_GAG and BALL_GAG_STRAP + if ( + color == ItemColor.RED && + (variant == GagVariant.BALL_GAG || + variant == GagVariant.BALL_GAG_STRAP) + ) { + return false; + } + + return true; + } + + /** + * Check if a color has a texture for the given blindfold variant. + */ + public static boolean isColorValidForBlindfold( + ItemColor color, + BlindfoldVariant variant + ) { + if (color == null || variant == null) return true; + + // BLACK doesn't have texture for CLASSIC or MASK blindfolds + if ( + color == ItemColor.BLACK && + (variant == BlindfoldVariant.CLASSIC || + variant == BlindfoldVariant.MASK) + ) { + return false; + } + + return true; + } + + /** + * Get a random color that has a texture for the given gag variant. + */ + public static ItemColor getValidColorForGag(GagVariant variant) { + ItemColor color; + int attempts = 0; + do { + color = ItemColor.getRandomStandard(); + attempts++; + if (attempts > 50) break; + } while (!isColorValidForGag(color, variant)); + return color; + } + + /** + * Get a random color that has a texture for the given blindfold variant. + */ + public static ItemColor getValidColorForBlindfold( + BlindfoldVariant variant + ) { + ItemColor color; + int attempts = 0; + do { + color = ItemColor.getRandomStandard(); + attempts++; + if (attempts > 50) break; + } while (!isColorValidForBlindfold(color, variant)); + return color; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/KidnapperJobManager.java b/src/main/java/com/tiedup/remake/entities/KidnapperJobManager.java new file mode 100644 index 0000000..e7b7ef7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/KidnapperJobManager.java @@ -0,0 +1,318 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.state.CollarRegistry; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.tasks.ItemTask; +import com.tiedup.remake.util.tasks.JobLoader; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * Manages the job system for EntityKidnapper. + * + *

The job system allows kidnappers to assign tasks to their captives. + * When a job is assigned: + *

    + *
  1. Worker UUID is stored for tracking
  2. + *
  3. Shock collar is put on the worker
  4. + *
  5. Worker is untied and freed to complete the job
  6. + *
  7. Kidnapper waits for job completion
  8. + *
+ * + *

This system IS persisted to NBT via {@link #save(CompoundTag)} and {@link #load(CompoundTag)}. + */ +public class KidnapperJobManager { + + private final EntityKidnapper kidnapper; + + /** Current job assigned to worker. */ + @Nullable + private ItemTask currentJob = null; + + /** UUID of worker doing the current job (freed but tracked). */ + @Nullable + private UUID jobWorkerUUID = null; + + public KidnapperJobManager(EntityKidnapper kidnapper) { + this.kidnapper = kidnapper; + } + + // ======================================== + // STATE QUERIES + // ======================================== + + /** + * Check if waiting for worker to complete job. + * Note: Worker is NOT a captive during job - they are tracked by UUID. + */ + public boolean isWaitingForJobToBeCompleted() { + return this.currentJob != null && this.jobWorkerUUID != null; + } + + /** + * Get the current job assigned to worker. + */ + @Nullable + public ItemTask getCurrentJob() { + return this.currentJob; + } + + /** + * Get the UUID of the worker doing the current job. + */ + @Nullable + public UUID getJobWorkerUUID() { + return this.jobWorkerUUID; + } + + /** + * Set the UUID of the worker doing the current job. + * Used by maids to register labor workers for attack protection. + */ + public void setJobWorkerUUID(@Nullable UUID workerUUID) { + this.jobWorkerUUID = workerUUID; + } + + /** + * Find the job worker entity by UUID. + * @return The worker as Player, or null if not found/offline + */ + @Nullable + public Player getJobWorker() { + if (this.jobWorkerUUID == null) { + return null; + } + return kidnapper.level().getPlayerByUUID(this.jobWorkerUUID); + } + + // ======================================== + // JOB ASSIGNMENT + // ======================================== + + /** + * Assign a job to the kidnapper's current captive. + * This will: + *

    + *
  1. Store the worker UUID
  2. + *
  3. Untie the captive (remove bind and gag)
  4. + *
  5. Free them from captivity
  6. + *
  7. Put a shock collar on them (AFTER untie)
  8. + *
+ * + * @param job The job to assign + * @return true if job was assigned + */ + public boolean assignJob(ItemTask job) { + if (!kidnapper.hasCaptives() || job == null) { + return false; + } + + if (this.currentJob != null) { + TiedUpMod.LOGGER.warn( + "[KidnapperJobManager] {} already has an active job", + kidnapper.getNpcName() + ); + return false; + } + + IBondageState captive = kidnapper.getCaptive(); + if (captive == null) { + return false; + } + + // Store job and worker info + this.currentJob = job; + this.jobWorkerUUID = captive.asLivingEntity().getUUID(); + + TiedUpMod.LOGGER.info( + "[KidnapperJobManager] {} assigned job to {}: {}", + kidnapper.getNpcName(), + captive.getKidnappedName(), + job.toDisplayString() + ); + + // IMPORTANT: Order matters here! + // 1. First untie (this clears ALL bondage slots including collar) + // 2. Then free from captivity + // 3. Finally put collar on (so it's not cleared by untie) + + // Untie the worker (remove bind and gag so they can work) + captive.untie(false); + + TiedUpMod.LOGGER.info( + "[KidnapperJobManager] {} untied {} for job", + kidnapper.getNpcName(), + captive.getKidnappedName() + ); + + // Free from captivity (they keep the collar, we track via UUID) + captive.free(false); + + TiedUpMod.LOGGER.info( + "[KidnapperJobManager] {} freed {} to complete job", + kidnapper.getNpcName(), + captive.getKidnappedName() + ); + + // Put a shock collar on the worker AFTER untie/free + ItemStack shockCollar = new ItemStack(ModItems.SHOCK_COLLAR_AUTO.get()); + if (shockCollar.getItem() instanceof ItemCollar collarItem) { + // Add kidnapper as owner so the collar is linked + collarItem.addOwner( + shockCollar, + kidnapper.getUUID(), + kidnapper.getNpcName() + ); + // Lock the collar so they can't remove it + shockCollar = collarItem.setLocked(shockCollar, true); + } + captive.equip(BodyRegionV2.NECK, shockCollar); + + // Register collar in CollarRegistry for tracking + registerJobCollar(captive.asLivingEntity(), shockCollar); + + TiedUpMod.LOGGER.info( + "[KidnapperJobManager] {} put shock collar (locked, owned) on {}", + kidnapper.getNpcName(), + captive.getKidnappedName() + ); + + // Hold shocker controller + kidnapper.setItemInHand( + InteractionHand.MAIN_HAND, + new ItemStack(ModItems.SHOCKER_CONTROLLER.get()) + ); + + return true; + } + + /** + * Assign a random job using JobLoader. + * + * @return true if job was assigned + */ + public boolean assignRandomJob() { + return assignJob(JobLoader.getRandomJob()); + } + + /** + * Clear the current job and worker tracking. + */ + public void clearCurrentJob() { + if (this.currentJob != null || this.jobWorkerUUID != null) { + TiedUpMod.LOGGER.info( + "[KidnapperJobManager] {} cleared job", + kidnapper.getNpcName() + ); + this.currentJob = null; + this.jobWorkerUUID = null; + + // Clear held shocker controller + kidnapper.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); + } + } + + // ======================================== + // COLLAR REGISTRY + // ======================================== + + /** + * Register a job collar in the CollarRegistry. + * This allows tracking the worker via the collar system. + * + * @param wearer The entity wearing the collar + * @param collarStack The collar ItemStack + */ + private void registerJobCollar(LivingEntity wearer, ItemStack collarStack) { + if (wearer == null || kidnapper.level().isClientSide()) { + return; + } + + if (!(kidnapper.level() instanceof ServerLevel serverLevel)) { + return; + } + + CollarRegistry registry = CollarRegistry.get(serverLevel); + if (registry == null) { + return; + } + + // Register kidnapper as owner of the wearer + registry.registerCollar(wearer.getUUID(), kidnapper.getUUID()); + + TiedUpMod.LOGGER.debug( + "[KidnapperJobManager] Registered job collar for {} owned by {}", + wearer.getName().getString(), + kidnapper.getNpcName() + ); + } + + /** + * Unregister a job collar from the CollarRegistry. + * Called when the job is completed and collar is removed. + * + * @param wearer The entity whose collar is being removed + */ + public void unregisterJobCollar(LivingEntity wearer) { + if (wearer == null || kidnapper.level().isClientSide()) { + return; + } + + if (!(kidnapper.level() instanceof ServerLevel serverLevel)) { + return; + } + + CollarRegistry registry = CollarRegistry.get(serverLevel); + if (registry == null) { + return; + } + + // Unregister the wearer + registry.unregisterWearer(wearer.getUUID()); + + TiedUpMod.LOGGER.debug( + "[KidnapperJobManager] Unregistered job collar for {}", + wearer.getName().getString() + ); + } + + // ======================================== + // NBT PERSISTENCE + // ======================================== + + /** + * Save job manager state to NBT. + * @param tag The tag to save to + */ + public void save(CompoundTag tag) { + if (this.currentJob != null) { + tag.put("CurrentJob", this.currentJob.save()); + } + if (this.jobWorkerUUID != null) { + tag.putUUID("JobWorkerUUID", this.jobWorkerUUID); + } + } + + /** + * Load job manager state from NBT. + * @param tag The tag to load from + */ + public void load(CompoundTag tag) { + if (tag.contains("CurrentJob")) { + this.currentJob = ItemTask.load(tag.getCompound("CurrentJob")); + } + if (tag.contains("JobWorkerUUID")) { + this.jobWorkerUUID = tag.getUUID("JobWorkerUUID"); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/KidnapperTheme.java b/src/main/java/com/tiedup/remake/entities/KidnapperTheme.java new file mode 100644 index 0000000..408005c --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/KidnapperTheme.java @@ -0,0 +1,290 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.items.base.BindVariant; +import com.tiedup.remake.items.base.BlindfoldVariant; +import com.tiedup.remake.items.base.GagVariant; +import java.util.Random; + +/** + * Defines themed item sets for kidnappers. + * Each theme groups compatible binds, gags, and blindfolds. + * + * Themes are selected randomly with weighted probabilities. + * Higher weight = more common theme. + * + * Note: Natural themes (SLIME, VINE, WEB) are reserved for monsters. + */ +public enum KidnapperTheme { + // === ROPE THEMES (most common) === + + ROPE( + BindVariant.ROPES, + new GagVariant[] { + GagVariant.ROPES_GAG, + GagVariant.CLOTH_GAG, + GagVariant.CLEAVE_GAG, + }, + new BlindfoldVariant[] { BlindfoldVariant.CLASSIC }, + true, // supportsColor + 30 // weight (spawn probability) + ), + + SHIBARI( + BindVariant.SHIBARI, + new GagVariant[] { + GagVariant.ROPES_GAG, + GagVariant.CLOTH_GAG, + GagVariant.RIBBON_GAG, + }, + new BlindfoldVariant[] { BlindfoldVariant.CLASSIC }, + true, + 15 + ), + + // === TAPE THEME === + + TAPE( + BindVariant.DUCT_TAPE, + new GagVariant[] { GagVariant.TAPE_GAG, GagVariant.WRAP_GAG }, + new BlindfoldVariant[] { BlindfoldVariant.MASK }, + true, + 20 + ), + + // === LEATHER/BDSM THEME === + + LEATHER( + BindVariant.LEATHER_STRAPS, + new GagVariant[] { + GagVariant.BALL_GAG, + GagVariant.BALL_GAG_STRAP, + GagVariant.PANEL_GAG, + }, + new BlindfoldVariant[] { BlindfoldVariant.MASK }, + false, + 15 + ), + + // === CHAIN THEME === + + CHAIN( + BindVariant.CHAIN, + new GagVariant[] { + GagVariant.CHAIN_PANEL_GAG, + GagVariant.BALL_GAG_STRAP, + }, + new BlindfoldVariant[] { BlindfoldVariant.MASK }, + false, + 10 + ), + + // === MEDICAL THEME === + + MEDICAL( + BindVariant.MEDICAL_STRAPS, + new GagVariant[] { + GagVariant.TUBE_GAG, + GagVariant.SPONGE_GAG, + GagVariant.BALL_GAG, + }, + new BlindfoldVariant[] { BlindfoldVariant.MASK }, + false, + 8 + ), + + // === SCI-FI/BEAM THEME === + + BEAM( + BindVariant.BEAM_CUFFS, + new GagVariant[] { GagVariant.BEAM_PANEL_GAG, GagVariant.LATEX_GAG }, + new BlindfoldVariant[] { BlindfoldVariant.MASK }, + false, + 5 + ), + + // === LATEX THEME (rare) === + + LATEX( + BindVariant.LATEX_SACK, + new GagVariant[] { GagVariant.LATEX_GAG, GagVariant.TUBE_GAG }, + new BlindfoldVariant[] { BlindfoldVariant.MASK }, + false, + 3 + ), + + // === ASYLUM THEME (rare) === + + ASYLUM( + BindVariant.STRAITJACKET, + new GagVariant[] { + GagVariant.BITE_GAG, + GagVariant.SPONGE_GAG, + GagVariant.BALL_GAG, + }, + new BlindfoldVariant[] { BlindfoldVariant.MASK }, + false, + 5 + ), + + // === RIBBON THEME (cute/playful) === + + RIBBON( + BindVariant.RIBBON, + new GagVariant[] { GagVariant.RIBBON_GAG, GagVariant.CLOTH_GAG }, + new BlindfoldVariant[] { BlindfoldVariant.CLASSIC }, + false, + 8 + ), + + // === WRAP THEME === + + WRAP( + BindVariant.WRAP, + new GagVariant[] { GagVariant.WRAP_GAG, GagVariant.TAPE_GAG }, + new BlindfoldVariant[] { BlindfoldVariant.MASK }, + false, + 5 + ); + + private static final Random RANDOM = new Random(); + + private final BindVariant bind; + private final GagVariant[] gags; + private final BlindfoldVariant[] blindfolds; + private final boolean supportsColor; + private final int weight; + + KidnapperTheme( + BindVariant bind, + GagVariant[] gags, + BlindfoldVariant[] blindfolds, + boolean supportsColor, + int weight + ) { + this.bind = bind; + this.gags = gags; + this.blindfolds = blindfolds; + this.supportsColor = supportsColor; + this.weight = weight; + } + + /** + * Get the primary bind for this theme. + */ + public BindVariant getBind() { + return bind; + } + + /** + * Get compatible gags for this theme (ordered by preference). + */ + public GagVariant[] getGags() { + return gags; + } + + /** + * Get compatible blindfolds for this theme. + */ + public BlindfoldVariant[] getBlindfolds() { + return blindfolds; + } + + /** + * Check if this theme supports color variations. + * Only themes with colorable items (rope, shibari, tape) support colors. + */ + public boolean supportsColor() { + return supportsColor; + } + + /** + * Get the spawn weight for this theme. + * Higher weight = more common. + */ + public int getWeight() { + return weight; + } + + /** + * Get primary gag (first in list). + * Used when only one gag is selected. + */ + public GagVariant getPrimaryGag() { + return gags.length > 0 ? gags[0] : GagVariant.BALL_GAG; + } + + /** + * Get a random gag from this theme's compatible list. + */ + public GagVariant getRandomGag() { + if (gags.length == 0) return GagVariant.BALL_GAG; + return gags[RANDOM.nextInt(gags.length)]; + } + + /** + * Get primary blindfold (first in list). + */ + public BlindfoldVariant getPrimaryBlindfold() { + return blindfolds.length > 0 ? blindfolds[0] : BlindfoldVariant.CLASSIC; + } + + /** + * Get a random blindfold from this theme's compatible list. + */ + public BlindfoldVariant getRandomBlindfold() { + if (blindfolds.length == 0) return BlindfoldVariant.CLASSIC; + return blindfolds[RANDOM.nextInt(blindfolds.length)]; + } + + /** + * Check if this theme has any blindfolds. + */ + public boolean hasBlindfolds() { + return blindfolds.length > 0; + } + + /** + * Get a random theme using weighted probability. + * Higher weight themes are more likely to be selected. + */ + public static KidnapperTheme getRandomWeighted() { + int totalWeight = 0; + for (KidnapperTheme theme : values()) { + totalWeight += theme.weight; + } + + int roll = RANDOM.nextInt(totalWeight); + int cumulative = 0; + + for (KidnapperTheme theme : values()) { + cumulative += theme.weight; + if (roll < cumulative) { + return theme; + } + } + + return ROPE; // Fallback + } + + /** + * Get a random theme using a specific Random instance. + */ + public static KidnapperTheme getRandomWeighted(Random random) { + int totalWeight = 0; + for (KidnapperTheme theme : values()) { + totalWeight += theme.weight; + } + + int roll = random.nextInt(totalWeight); + int cumulative = 0; + + for (KidnapperTheme theme : values()) { + cumulative += theme.weight; + if (roll < cumulative) { + return theme; + } + } + + return ROPE; // Fallback + } +} diff --git a/src/main/java/com/tiedup/remake/entities/KidnapperVariant.java b/src/main/java/com/tiedup/remake/entities/KidnapperVariant.java new file mode 100644 index 0000000..f00815f --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/KidnapperVariant.java @@ -0,0 +1,112 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.entities.skins.Gender; +import com.tiedup.remake.entities.skins.SkinVariant; +import net.minecraft.resources.ResourceLocation; + +/** + * Represents a kidnapper skin variant with metadata. + * + * All skins from kidnapper folder, random selection. + * + * @param id Unique identifier (e.g., "knp_mob_1", "blake") + * @param texture Skin texture location + * @param hasSlimArms True for Alex/slim model (3px arms), false for Steve/normal (4px arms) + * @param defaultName Default display name for this variant + * @param gender Gender of the skin (default FEMALE) + */ +public record KidnapperVariant( + String id, + ResourceLocation texture, + boolean hasSlimArms, + String defaultName, + Gender gender +) implements SkinVariant { + /** + * Create a kidnapper variant from texture name. + * + * @param textureName Texture file name without extension (e.g., "knp_mob_1", "blake") + * @param hasSlimArms True if this variant uses slim arms + * @return Kidnapper variant + */ + public static KidnapperVariant create( + String textureName, + boolean hasSlimArms + ) { + return create(textureName, hasSlimArms, "Kidnapper"); + } + + /** + * Create a kidnapper variant from texture name with custom display name. + * + * @param textureName Texture file name without extension + * @param hasSlimArms True if this variant uses slim arms + * @param displayName Display name for this variant + * @return Kidnapper variant + */ + public static KidnapperVariant create( + String textureName, + boolean hasSlimArms, + String displayName + ) { + return new KidnapperVariant( + textureName, + ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/kidnapper/" + textureName + ".png" + ), + hasSlimArms, + displayName, + Gender.FEMALE + ); + } + + /** + * Create a variant with a custom texture subfolder. + * Used by MaidSkinManager, TraderSkinManager, etc. + * + * @param textureName Texture file name without extension + * @param subfolder Subfolder under kidnapper/ (e.g., "maid", "trader") + * @param hasSlimArms True if this variant uses slim arms + * @param displayName Display name for this variant (required) + * @param gender Gender of the variant + * @return Kidnapper variant + */ + public static KidnapperVariant createWithSubfolder( + String textureName, + String subfolder, + boolean hasSlimArms, + String displayName, + Gender gender + ) { + return new KidnapperVariant( + textureName, + ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/kidnapper/" + + subfolder + + "/" + + textureName + + ".png" + ), + hasSlimArms, + displayName, + gender + ); + } + + @Override + public String toString() { + return ( + "KidnapperVariant{id='" + + id + + "', slim=" + + hasSlimArms + + ", name='" + + defaultName + + "', gender=" + + gender + + "}" + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/LeashProxyEntity.java b/src/main/java/com/tiedup/remake/entities/LeashProxyEntity.java new file mode 100644 index 0000000..91cc4ac --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/LeashProxyEntity.java @@ -0,0 +1,245 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.items.base.BindVariant; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.Objects; +import javax.annotation.ParametersAreNonnullByDefault; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.ServerScoreboard; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Pose; +import net.minecraft.world.entity.animal.Turtle; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.Vec3; +import net.minecraft.world.scores.PlayerTeam; + +/** + * Invisible proxy entity that follows a player and holds the leash. + * + * Based on PlayerCollars LeashProxyEntity implementation. + * Uses Turtle as base because it's a simple, leashable mob. + * + * Key features: + * - Baby turtle (small hitbox) + * - Invisible and invulnerable + * - No physics or collision + * - Follows target player's position (offset to neck height) + * - Leash renders from this entity to the holder + */ +@ParametersAreNonnullByDefault +public final class LeashProxyEntity extends Turtle { + + /** Team name for preventing collision display */ + public static final String TEAM_NAME = "tiedup_leash_proxy"; + + /** The player this proxy follows */ + private final LivingEntity target; + + /** + * Create a new leash proxy for a target player. + * + * @param target The player to follow + */ + public LeashProxyEntity(LivingEntity target) { + super(EntityType.TURTLE, target.level()); + this.target = target; + + // Make it invisible and invulnerable + setHealth(1.0F); + setInvulnerable(true); + setBaby(true); + setInvisible(true); + noPhysics = true; + + // Add to team to prevent collision nameplate display + MinecraftServer server = getServer(); + if (server != null) { + ServerScoreboard scoreboard = server.getScoreboard(); + + PlayerTeam team = scoreboard.getPlayerTeam(TEAM_NAME); + if (team == null) { + team = scoreboard.addPlayerTeam(TEAM_NAME); + } + if (team.getCollisionRule() != PlayerTeam.CollisionRule.NEVER) { + team.setCollisionRule(PlayerTeam.CollisionRule.NEVER); + } + + scoreboard.addPlayerToTeam(getScoreboardName(), team); + } + } + + // ==================== Position Sync ==================== + + /** + * Update proxy position to match target. + * + * @return true if proxy should be removed (target invalid) + */ + private boolean proxyUpdate() { + if (proxyIsRemoved()) return false; + + if (target == null) return true; + if (target.level() != level() || !target.isAlive()) return true; + + // If target is a player who disconnected, remove proxy + if ( + target instanceof ServerPlayer serverPlayer && + serverPlayer.hasDisconnected() + ) { + return true; + } + + Vec3 posActual = this.position(); + + // Dynamic Y offset based on bind type + // DOGBINDER = lower height for 4-legged pose (0.6) + // Default = neck height (1.3) + double yOffset = 1.3D; + if (target instanceof ServerPlayer player) { + IBondageState state = KidnappedHelper.getKidnappedState(player); + if (state != null && state.isTiedUp()) { + ItemStack bind = state.getEquipment(BodyRegionV2.ARMS); + if ( + !bind.isEmpty() && + bind.getItem() == ModItems.getBind(BindVariant.DOGBINDER) + ) { + yOffset = 0.35D; // Lower for 4-legged dogwalk pose (back/hip level) + } + } + } + + Vec3 posTarget = target.position().add(0.0D, yOffset, -0.15D); + + if (!Objects.equals(posActual, posTarget)) { + setRot(0.0F, 0.0F); + setPos(posTarget.x(), posTarget.y(), posTarget.z()); + setBoundingBox( + getDimensions(Pose.DYING).makeBoundingBox(posTarget) + ); + } + + // Update leash (handles vanilla leash physics) + tickLeash(); + + return false; + } + + @Override + public void tick() { + if (this.level().isClientSide) return; + + if (proxyUpdate() && !proxyIsRemoved()) { + proxyRemove(); + } + } + + // ==================== Lifecycle ==================== + + /** + * Check if this proxy has been removed. + */ + public boolean proxyIsRemoved() { + return this.isRemoved(); + } + + /** + * Remove this proxy entity. + */ + public void proxyRemove() { + super.remove(RemovalReason.DISCARDED); + } + + /** + * Override remove() to prevent accidental removal. + * Use proxyRemove() for intentional removal. + */ + @Override + public void remove(RemovalReason reason) { + // Only allow removal via proxyRemove() or if truly killed + if ( + reason == RemovalReason.KILLED || + reason == RemovalReason.CHANGED_DIMENSION + ) { + super.remove(reason); + } + // Ignore other removal attempts + } + + // ==================== Override to Prevent Behavior ==================== + + @Override + public float getHealth() { + return 1.0F; + } + + @Override + public void dropLeash(boolean sendPacket, boolean dropItem) { + // Don't drop leash - managed by mixin + } + + @Override + public boolean canBeLeashed(Player player) { + return false; + } + + @Override + protected void registerGoals() { + // No AI goals + } + + @Override + protected void doPush(Entity entity) { + // No pushing + } + + @Override + public void push(Entity entity) { + // No pushing + } + + @Override + public void playerTouch(Player player) { + // No interaction + } + + @Override + public boolean isPushable() { + return false; + } + + @Override + protected Vec3 getLeashOffset() { + // The proxy is already positioned at the player's neck (Y+1.3) + // No additional offset needed - leash attaches exactly at proxy position + return Vec3.ZERO; + } + + @Override + public boolean isInvulnerableTo( + net.minecraft.world.damagesource.DamageSource source + ) { + return true; + } + + @Override + public boolean hurt( + net.minecraft.world.damagesource.DamageSource source, + float amount + ) { + return false; + } + + /** + * Get the target entity this proxy follows. + */ + public LivingEntity getTarget() { + return target; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/MerchantTrade.java b/src/main/java/com/tiedup/remake/entities/MerchantTrade.java new file mode 100644 index 0000000..d8c93aa --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/MerchantTrade.java @@ -0,0 +1,137 @@ +package com.tiedup.remake.entities; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +/** + * Represents a single trade offer from a Kidnapper Merchant. + * + * Price is in gold ingots and/or gold nuggets. + * Example: 3x Gold Ingot + 5x Gold Nugget for 1x Ropes + */ +public class MerchantTrade { + + private final ItemStack item; + private final int ingotPrice; + private final int nuggetPrice; + + public MerchantTrade(ItemStack item, int ingotPrice, int nuggetPrice) { + this.item = item.copy(); + this.ingotPrice = Math.max(0, ingotPrice); + this.nuggetPrice = Math.max(0, nuggetPrice); + } + + /** + * Check if the player can afford this trade with the given stacks. + * + * @param ingots Stack of gold ingots (or empty) + * @param nuggets Stack of gold nuggets (or empty) + * @return true if player has enough gold + */ + public boolean canAfford(ItemStack ingots, ItemStack nuggets) { + int ingotCount = ingots.is(Items.GOLD_INGOT) ? ingots.getCount() : 0; + int nuggetCount = nuggets.is(Items.GOLD_NUGGET) + ? nuggets.getCount() + : 0; + + return ingotCount >= ingotPrice && nuggetCount >= nuggetPrice; + } + + /** + * Consume the payment from the given stacks. + * MUST call canAfford() before this! + * + * @param ingots Stack of gold ingots + * @param nuggets Stack of gold nuggets + */ + public void consumePayment(ItemStack ingots, ItemStack nuggets) { + if (ingots.is(Items.GOLD_INGOT)) { + ingots.shrink(ingotPrice); + } + if (nuggets.is(Items.GOLD_NUGGET)) { + nuggets.shrink(nuggetPrice); + } + } + + /** + * Get the item being sold. + * @return Copy of the item stack + */ + public ItemStack getItem() { + return item.copy(); + } + + /** + * Get display name of the item. + */ + public Component getItemName() { + return item.getHoverName(); + } + + /** + * Get ingot price. + */ + public int getIngotPrice() { + return ingotPrice; + } + + /** + * Get nugget price. + */ + public int getNuggetPrice() { + return nuggetPrice; + } + + /** + * Get formatted price display. + * Examples: + * - "5 gold + 12 nuggets" + * - "3 gold" + * - "8 nuggets" + */ + public Component getPriceDisplay() { + if (ingotPrice > 0 && nuggetPrice > 0) { + return Component.literal( + ingotPrice + " gold + " + nuggetPrice + " nuggets" + ); + } else if (ingotPrice > 0) { + return Component.literal(ingotPrice + " gold"); + } else if (nuggetPrice > 0) { + return Component.literal(nuggetPrice + " nuggets"); + } else { + return Component.literal("Free"); + } + } + + /** + * Save this trade to NBT. + */ + public CompoundTag save() { + CompoundTag tag = new CompoundTag(); + tag.put("Item", item.save(new CompoundTag())); + tag.putInt("IngotPrice", ingotPrice); + tag.putInt("NuggetPrice", nuggetPrice); + return tag; + } + + /** + * Load a trade from NBT. + */ + public static MerchantTrade load(CompoundTag tag) { + ItemStack item = ItemStack.of(tag.getCompound("Item")); + int ingotPrice = tag.getInt("IngotPrice"); + int nuggetPrice = tag.getInt("NuggetPrice"); + return new MerchantTrade(item, ingotPrice, nuggetPrice); + } + + @Override + public String toString() { + return ( + item.getHoverName().getString() + + " for " + + getPriceDisplay().getString() + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ModEntities.java b/src/main/java/com/tiedup/remake/entities/ModEntities.java new file mode 100644 index 0000000..0e85996 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ModEntities.java @@ -0,0 +1,473 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.furniture.EntityFurniture; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MobCategory; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegistryObject; + +/** + * Phase 8: Master-Slave Relationships + * Phase 14.2: EntityDamsel NPC + * Phase 14.3: EntityKidnapper NPC + * Phase 14.3.6: EntityKidnapperElite NPC + * + * Registry for custom entities added by the TiedUp mod. + * + * Current Entities: + * - EntityDamsel: Capturable female NPC with bondage support + * - EntityKidnapper: Aggressive NPC that captures and enslaves players + * - EntityKidnapperElite: Rare, faster kidnapper variant + * - LeashProxyEntity: Invisible turtle for player leashing (via mixin) + * + * Future Entities (Phase 14.4+): + * - EntityBondageFurniture: Furniture for bondage scenes + * + * @see EntityDamsel + * @see EntityKidnapper + * @see EntityKidnapperElite + * @see LeashProxyEntity + */ +@SuppressWarnings("null") // Minecraft API guarantees non-null returns +public class ModEntities { + + /** + * Deferred register for entity types. + */ + public static final DeferredRegister> ENTITIES = + DeferredRegister.create(ForgeRegistries.ENTITY_TYPES, TiedUpMod.MOD_ID); + + /** + * Damsel Entity + * + * Phase 14.2: Capturable NPC with full bondage support + * + * Purpose: + * - Capturable female NPCs that can be restrained and enslaved + * - Full IRestrainable implementation (binds, gags, collars, etc.) + * - 38 skin variants (18 classic + 20 guest) + * - Conditional AI (flees when free, wanders when not tied, etc.) + * + * Technical Details: + * - Type: PathfinderMob + * - Category: CREATURE (spawnable NPC) + * - Size: 0.6F x 1.8F (same as player) + * - Tracking Range: 80 blocks (high visibility) + * - Update Interval: 3 ticks (default) + * - Sends Velocity Updates: true + * + * Registry Name: "damsel" + */ + public static final RegistryObject> DAMSEL = + ENTITIES.register("damsel", () -> + EntityType.Builder.of(EntityDamsel::new, MobCategory.CREATURE) + .sized(0.6F, 1.8F) // Player size + .clientTrackingRange(80) // Track from far away + .updateInterval(3) + .setShouldReceiveVelocityUpdates(true) + .build("damsel") + ); + + /** + * Shiny Damsel Entity + * + * Phase 1: Data-Driven Skin System - Shiny Damsels + * + * Purpose: + * - Rare variant of regular Damsel + * - Much faster movement speed (0.35 vs 0.25) + * - Golden sparkle particles + * - Shiny-exclusive skins + * + * Technical Details: + * - Type: PathfinderMob (extends EntityDamsel) + * - Category: CREATURE (spawnable NPC) + * - Size: 0.6F x 1.8F (same as player) + * - Tracking Range: 80 blocks + * - Movement speed: 0.35 (vs 0.25 for regular) + * + * Registry Name: "damsel_shiny" + */ + public static final RegistryObject< + EntityType + > DAMSEL_SHINY = ENTITIES.register("damsel_shiny", () -> + EntityType.Builder.of(EntityDamselShiny::new, MobCategory.CREATURE) + .sized(0.6F, 1.8F) // Player size + .clientTrackingRange(80) + .updateInterval(3) + .setShouldReceiveVelocityUpdates(true) + .build("damsel_shiny") + ); + + /** + * Kidnapper Entity + * + * Phase 14.3: Aggressive NPC that captures and enslaves players + * + * Purpose: + * - Hunts and captures players + * - Implements ICaptor for slave management + * - Can tie up, gag, and enslave players + * - Brings slaves to prison location + * + * Technical Details: + * - Type: PathfinderMob (extends EntityDamsel) + * - Category: CREATURE (spawnable NPC) + * - Size: 0.6F x 1.8F (same as player) + * - Tracking Range: 80 blocks + * - Faster movement speed than damsel + * + * Registry Name: "kidnapper" + */ + public static final RegistryObject> KIDNAPPER = + ENTITIES.register("kidnapper", () -> + EntityType.Builder.of(EntityKidnapper::new, MobCategory.CREATURE) + .sized(0.6F, 1.8F) // Player size + .clientTrackingRange(80) + .updateInterval(3) + .setShouldReceiveVelocityUpdates(true) + .build("kidnapper") + ); + + /** + * Elite Kidnapper Entity + * + * Phase 14.3.6: Rare, faster, more dangerous kidnapper variant + * + * Purpose: + * - Rare spawn (elite version of kidnapper) + * - Faster movement and capture time + * - Higher health and knockback resistance + * - Unique skins (Suki, Carol, Athena, Evelyn) + * + * Technical Details: + * - Type: PathfinderMob (extends EntityKidnapper) + * - Category: CREATURE (spawnable NPC) + * - Size: 0.6F x 1.8F (same as player) + * - Tracking Range: 80 blocks + * - Movement speed: 0.30 (vs 0.27 for regular) + * - Capture time: 10 ticks (vs 20 for regular) + * + * Registry Name: "kidnapper_elite" + */ + public static final RegistryObject< + EntityType + > KIDNAPPER_ELITE = ENTITIES.register("kidnapper_elite", () -> + EntityType.Builder.of(EntityKidnapperElite::new, MobCategory.CREATURE) + .sized(0.6F, 1.8F) // Player size + .clientTrackingRange(80) + .updateInterval(3) + .setShouldReceiveVelocityUpdates(true) + .build("kidnapper_elite") + ); + + /** + * Merchant Kidnapper Entity + * + * Elite kidnapper who trades mod items for gold. + * + * Purpose: + * - Neutral merchant selling mod items for gold ingots/nuggets + * - Becomes hostile elite kidnapper when attacked + * - Reverts to merchant after 5 minutes or when attacker captured/sold + * + * Trading: + * - Sells 8-12 random mod items + * - Tier-based pricing (1-2 gold for basic, 10-20 for GPS collar) + * - Fixed trades (persist in NBT) + * + * Technical Details: + * - Type: PathfinderMob (extends EntityKidnapperElite) + * - Category: CREATURE (spawnable NPC) + * - Size: 0.6F x 1.8F (same as player) + * - Tracking Range: 80 blocks + * - Same stats as Elite (40 HP, 0.35 speed) + * + * Registry Name: "kidnapper_merchant" + */ + public static final RegistryObject< + EntityType + > KIDNAPPER_MERCHANT = ENTITIES.register("kidnapper_merchant", () -> + EntityType.Builder.of( + EntityKidnapperMerchant::new, + MobCategory.CREATURE + ) + .sized(0.6F, 1.8F) // Player size + .clientTrackingRange(80) + .updateInterval(3) + .setShouldReceiveVelocityUpdates(true) + .build("kidnapper_merchant") + ); + + /** + * Archer Kidnapper Entity + * + * Phase 18: Ranged kidnapper that attacks with rope arrows + * + * Purpose: + * - Attacks from range with rope arrows + * - Ties up targets before approaching + * - Lower health but faster movement + * - Unique archer skins + * + * Technical Details: + * - Type: PathfinderMob (extends EntityKidnapper) + * - Category: CREATURE (spawnable NPC) + * - Size: 0.6F x 1.8F (same as player) + * - Tracking Range: 80 blocks + * - Attack range: 10-25 blocks + * + * Registry Name: "kidnapper_archer" + */ + public static final RegistryObject< + EntityType + > KIDNAPPER_ARCHER = ENTITIES.register("kidnapper_archer", () -> + EntityType.Builder.of(EntityKidnapperArcher::new, MobCategory.CREATURE) + .sized(0.6F, 1.8F) // Player size + .clientTrackingRange(80) + .updateInterval(3) + .setShouldReceiveVelocityUpdates(true) + .build("kidnapper_archer") + ); + + /** + * Rope Arrow Entity + * + * Phase 15: Arrow projectile that binds targets on hit + * + * Purpose: + * - Ranged restraint tool + * - 75% chance to bind target on hit + * - Works on Players, Damsels, Kidnappers + * + * Technical Details: + * - Type: AbstractArrow + * - Category: MISC (projectile) + * - Size: 0.5F x 0.5F (same as arrow) + * - Tracking Range: 4 blocks + * + * Registry Name: "rope_arrow" + */ + public static final RegistryObject> ROPE_ARROW = + ENTITIES.register("rope_arrow", () -> + EntityType.Builder.of( + EntityRopeArrow::new, + MobCategory.MISC + ) + .sized(0.5F, 0.5F) + .clientTrackingRange(4) + .updateInterval(20) + .build("rope_arrow") + ); + + /** + * Kidnap Bomb Entity + * + * Phase 16: Primed TNT that applies bondage on explosion + * + * Purpose: + * - TNT that applies bondage instead of destruction + * - Stores bondage items to apply on explosion + * - Uses KidnapExplosion for area effect + * + * Technical Details: + * - Type: PrimedTnt (extends) + * - Category: MISC (projectile-like) + * - Size: 0.98F x 0.98F (same as TNT) + * - Tracking Range: 10 blocks + * + * Registry Name: "kidnap_bomb_entity" + */ + public static final RegistryObject< + EntityType + > KIDNAP_BOMB_ENTITY = ENTITIES.register("kidnap_bomb_entity", () -> + EntityType.Builder.of( + EntityKidnapBomb::new, + MobCategory.MISC + ) + .sized(0.98F, 0.98F) + .clientTrackingRange(10) + .updateInterval(10) + .fireImmune() + .build("kidnap_bomb_entity") + ); + + /** + * Slave Trader Entity + * + * Slave Trader & Maid System - Boss of a camp who sells captives. + * + * Purpose: + * - Manages sale of captives in camp cells + * - Does not capture (stays at camp) + * - Has a Maid servant + * - Token required for peaceful interaction + * + * Technical Details: + * - Type: PathfinderMob (extends EntityKidnapperElite) + * - Category: CREATURE (spawnable NPC) + * - Size: 0.6F x 1.8F (same as player) + * - Tracking Range: 80 blocks + * - High stats (50 HP, 0.30 speed) + * + * Registry Name: "slave_trader" + */ + public static final RegistryObject< + EntityType + > SLAVE_TRADER = ENTITIES.register("slave_trader", () -> + EntityType.Builder.of(EntitySlaveTrader::new, MobCategory.CREATURE) + .sized(0.6F, 1.8F) + .clientTrackingRange(80) + .updateInterval(3) + .setShouldReceiveVelocityUpdates(true) + .build("slave_trader") + ); + + /** + * Maid Entity + * + * Slave Trader & Maid System - Servant who executes trader's orders. + * + * Purpose: + * - Follows and protects the SlaveTrader + * - Delivers captives to buyers + * - Collects ransom/items + * - Highest stats in mod (ultra fast capture) + * - Becomes neutral when trader dies + * + * Technical Details: + * - Type: PathfinderMob (extends EntityKidnapperElite) + * - Category: CREATURE (spawnable NPC) + * - Size: 0.6F x 1.8F (same as player) + * - Tracking Range: 80 blocks + * - Highest stats (60 HP, 0.40 speed, 5 tick capture) + * + * Registry Name: "maid" + */ + public static final RegistryObject> MAID = + ENTITIES.register("maid", () -> + EntityType.Builder.of(EntityMaid::new, MobCategory.CREATURE) + .sized(0.6F, 1.8F) + .clientTrackingRange(80) + .updateInterval(3) + .setShouldReceiveVelocityUpdates(true) + .build("maid") + ); + + /** + * Master Entity + * + * Master NPC - Rare NPC that buys solo players from Kidnappers. + * + * Purpose: + * - Spawns when a Kidnapper sells a player in solo mode + * - Implements pet play mechanics (collar, bowl, pet bed) + * - Follows the player (inverted follower mechanic) + * - Restricts eating/sleeping to special blocks + * - Monitors and punishes escape attempts + * - Very high combat stats (hard to defeat) + * + * Technical Details: + * - Type: PathfinderMob (extends EntityKidnapperElite) + * - Category: CREATURE (spawnable NPC) + * - Size: 0.6F x 1.8F (same as player) + * - Tracking Range: 80 blocks + * - Ultra-tank stats (150 HP, armor, regen) + * + * Registry Name: "master" + */ + public static final RegistryObject> MASTER = + ENTITIES.register("master", () -> + EntityType.Builder.of(EntityMaster::new, MobCategory.CREATURE) + .sized(0.6F, 1.8F) + .clientTrackingRange(80) + .updateInterval(3) + .setShouldReceiveVelocityUpdates(true) + .build("master") + ); + + /** + * Labor Guard Entity + * + * Physical guard that follows and monitors a prisoner during labor. + * + * Purpose: + * - Follows prisoner during forced labor + * - Monitors activity and punishes inactivity + * - Protects prisoner from monsters + * - Triggers escape if prisoner leaves zone or guard dies + * + * Technical Details: + * - Type: PathfinderMob (extends EntityDamsel) + * - Category: CREATURE + * - Size: 0.6F x 1.8F (same as player) + * - Tracking Range: 80 blocks + * - Stats: 30 HP, 0.30 speed, 6.0 attack + * + * Registry Name: "labor_guard" + */ + public static final RegistryObject< + EntityType + > LABOR_GUARD = ENTITIES.register("labor_guard", () -> + EntityType.Builder.of(EntityLaborGuard::new, MobCategory.CREATURE) + .sized(0.6F, 1.8F) + .clientTrackingRange(80) + .updateInterval(3) + .setShouldReceiveVelocityUpdates(true) + .build("labor_guard") + ); + + /** + * NPC Fishing Bobber Entity + * + * Lightweight bobber spawned by NPC during fishing job. + * Floats on water, triggers bite after random delay. + * Ephemeral (no NBT persistence). + * + * Registry Name: "npc_fishing_bobber" + */ + public static final RegistryObject< + EntityType + > NPC_FISHING_BOBBER = ENTITIES.register("npc_fishing_bobber", () -> + EntityType.Builder.of( + NpcFishingBobber::new, + MobCategory.MISC + ) + .sized(0.25F, 0.25F) + .clientTrackingRange(64) + .updateInterval(5) + .build("npc_fishing_bobber") + ); + + /** + * Furniture Entity + * + * Data-driven furniture piece with configurable seats, locking, and models. + * + * Purpose: + * - Spawnable furniture whose behavior is defined by FurnitureRegistry JSON + * - Supports seating, locking, and breaking mechanics via FurnitureDefinition + * - Uses IEntityAdditionalSpawnData for client sync of furniture ID + * + * Technical Details: + * - Type: Entity (NOT LivingEntity — no attributes needed) + * - Category: MISC (placed object, not spawnable mob) + * - Size: 3.0F x 3.0F (generous default; actual hitbox comes from FurnitureDefinition) + * - Tracking Range: 64 blocks + * - Update Interval: 20 ticks (low frequency — furniture is stationary) + * - No velocity updates (stationary entity) + * + * Registry Name: "furniture" + */ + public static final RegistryObject> FURNITURE = + ENTITIES.register("furniture", () -> + EntityType.Builder.of(EntityFurniture::new, MobCategory.MISC) + .sized(3.0F, 3.0F) + .clientTrackingRange(64) + .updateInterval(20) + .setShouldReceiveVelocityUpdates(false) + .build("furniture") + ); +} diff --git a/src/main/java/com/tiedup/remake/entities/NpcFishingBobber.java b/src/main/java/com/tiedup/remake/entities/NpcFishingBobber.java new file mode 100644 index 0000000..d8e3811 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/NpcFishingBobber.java @@ -0,0 +1,189 @@ +package com.tiedup.remake.entities; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientGamePacketListener; +import net.minecraft.network.protocol.game.ClientboundAddEntityPacket; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; + +/** + * Lightweight fishing bobber entity spawned by NPCs during the FISH command. + * + *

Floats on the water surface with a sinusoidal bob animation. + * After a random delay, triggers a bite event (splash particles + sound). + * The owning NPC's goal checks {@link #isBiting()} each tick and reels in + * when a bite is detected.

+ * + *

Ephemeral entity: no NBT persistence. Auto-discards after {@value #MAX_LIFE} ticks.

+ */ +public class NpcFishingBobber extends Entity { + + private static final EntityDataAccessor DATA_OWNER_ID = + SynchedEntityData.defineId( + NpcFishingBobber.class, + EntityDataSerializers.INT + ); + + private static final EntityDataAccessor DATA_BITING = + SynchedEntityData.defineId( + NpcFishingBobber.class, + EntityDataSerializers.BOOLEAN + ); + + /** Safety removal after 30 seconds */ + private static final int MAX_LIFE = 600; + + /** Minimum ticks before a bite can occur */ + private static final int MIN_BITE_TIME = 100; + + /** Maximum ticks before a bite can occur */ + private static final int MAX_BITE_TIME = 300; + + private int biteTimer; + private int lifeTimer; + + /** + * Registration constructor. + */ + public NpcFishingBobber(EntityType type, Level level) { + super(type, level); + } + + /** + * Factory method: creates a bobber at the surface of the given water block. + * + * @param level The server level + * @param owner The NPC that owns this bobber + * @param waterPos The water block position (bobber sits on top) + * @return A new NpcFishingBobber positioned at the water surface + */ + public static NpcFishingBobber create( + Level level, + EntityDamsel owner, + BlockPos waterPos + ) { + NpcFishingBobber bobber = new NpcFishingBobber( + ModEntities.NPC_FISHING_BOBBER.get(), + level + ); + bobber.setPos( + waterPos.getX() + 0.5, + waterPos.getY() + 1.0, // surface of water + waterPos.getZ() + 0.5 + ); + bobber.entityData.set(DATA_OWNER_ID, owner.getId()); + bobber.biteTimer = + MIN_BITE_TIME + + owner.getRandom().nextInt(MAX_BITE_TIME - MIN_BITE_TIME); + return bobber; + } + + @Override + protected void defineSynchedData() { + this.entityData.define(DATA_OWNER_ID, 0); + this.entityData.define(DATA_BITING, false); + } + + @Override + protected void readAdditionalSaveData(CompoundTag tag) { + // Ephemeral entity - no persistence + } + + @Override + protected void addAdditionalSaveData(CompoundTag tag) { + // Ephemeral entity - no persistence + } + + @Override + public void tick() { + super.tick(); + + lifeTimer++; + if (lifeTimer >= MAX_LIFE) { + discard(); + return; + } + + // Sinusoidal bob on water surface + setDeltaMovement(0, Math.sin(tickCount * 0.15) * 0.01, 0); + + // Server-side bite logic + if (!level().isClientSide && !isBiting()) { + biteTimer--; + if (biteTimer <= 0) { + setBiting(true); + + // Splash particles + if (level() instanceof ServerLevel serverLevel) { + serverLevel.sendParticles( + ParticleTypes.FISHING, + getX(), + getY(), + getZ(), + 5, // count + 0.1, + 0.0, + 0.1, // spread + 0.02 // speed + ); + } + + // Splash sound + level().playSound( + null, + getX(), + getY(), + getZ(), + SoundEvents.FISHING_BOBBER_SPLASH, + SoundSource.NEUTRAL, + 1.0f, + 1.0f + ); + } + } + } + + /** + * Get the NPC that owns this bobber. + * + * @return The owner EntityDamsel, or null if not found + */ + public EntityDamsel getOwnerNpc() { + Entity entity = level().getEntity(entityData.get(DATA_OWNER_ID)); + return entity instanceof EntityDamsel damsel ? damsel : null; + } + + /** + * Whether a fish is biting this bobber. + */ + public boolean isBiting() { + return entityData.get(DATA_BITING); + } + + /** + * Set the biting state. + */ + public void setBiting(boolean biting) { + entityData.set(DATA_BITING, biting); + } + + @Override + public boolean isPickable() { + return false; + } + + @Override + public Packet getAddEntityPacket() { + return new ClientboundAddEntityPacket(this); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/NpcInventoryMenu.java b/src/main/java/com/tiedup/remake/entities/NpcInventoryMenu.java new file mode 100644 index 0000000..07aed42 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/NpcInventoryMenu.java @@ -0,0 +1,562 @@ +package com.tiedup.remake.entities; + +import com.tiedup.remake.core.ModMenuTypes; +import javax.annotation.Nullable; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ArmorItem; +import net.minecraft.world.item.ItemStack; + +/** + * Vanilla-style container menu for NPC inventory. + * + * Supports variable inventory sizes (9, 18, or 27 slots). + * Uses standard shift-click behavior for item transfer. + */ +public class NpcInventoryMenu extends AbstractContainerMenu { + + /** The NPC entity (null on client if entity not found) */ + @Nullable + private final EntityDamsel npc; + + /** Container wrapping the NPC's inventory */ + private final Container npcContainer; + + /** Number of NPC inventory slots */ + private final int npcSlotCount; + + /** NPC's display name (for screen title) */ + private final String npcName; + + // Slot layout constants + private static final int SLOT_SIZE = 18; + private static final int SLOTS_PER_ROW = 9; + + /** + * Server-side constructor - called when player opens NPC inventory. + * + * @param containerId Container window ID + * @param playerInventory Player's inventory + * @param npc The NPC entity + */ + public NpcInventoryMenu( + int containerId, + Inventory playerInventory, + EntityDamsel npc + ) { + super(ModMenuTypes.NPC_INVENTORY.get(), containerId); + this.npc = npc; + this.npcSlotCount = npc.getNpcInventorySize(); + this.npcName = npc.getNpcName(); + + // Create container wrapping NPC's inventory list + this.npcContainer = new NpcInventoryContainer(npc); + + // Add NPC inventory slots + addNpcSlots(); + + // Add equipment slots (armor + main hand) on the right side + addEquipmentSlots(); + + // Add player inventory slots (standard layout below NPC slots) + addPlayerInventorySlots(playerInventory); + } + + /** + * Client-side constructor - called when receiving menu open packet. + * Creates a dummy container since real NPC data comes from server. + * + * @param containerId Container window ID + * @param playerInventory Player's inventory + * @param buf Network buffer with extra data + * @return New menu instance + */ + public static NpcInventoryMenu createClientMenu( + int containerId, + Inventory playerInventory, + FriendlyByteBuf buf + ) { + int entityId = buf.readInt(); + int inventorySize = buf.readInt(); + String name = buf.readUtf(64); + + // Try to find entity on client + Entity entity = playerInventory.player.level().getEntity(entityId); + EntityDamsel npc = entity instanceof EntityDamsel d ? d : null; + + return new NpcInventoryMenu( + containerId, + playerInventory, + npc, + inventorySize, + name + ); + } + + /** + * Client-side constructor with explicit parameters. + */ + private NpcInventoryMenu( + int containerId, + Inventory playerInventory, + @Nullable EntityDamsel npc, + int inventorySize, + String npcName + ) { + super(ModMenuTypes.NPC_INVENTORY.get(), containerId); + this.npc = npc; + this.npcSlotCount = inventorySize; + this.npcName = npcName; + + // Use real NPC container if available, otherwise dummy + if (npc != null) { + this.npcContainer = new NpcInventoryContainer(npc); + } else { + this.npcContainer = new SimpleContainer(inventorySize); + } + + addNpcSlots(); + addEquipmentSlots(); + addPlayerInventorySlots(playerInventory); + } + + /** + * Add NPC inventory slots in chest-style grid. + */ + private void addNpcSlots() { + int rows = (npcSlotCount + SLOTS_PER_ROW - 1) / SLOTS_PER_ROW; + + // Calculate starting position (centered, at top of menu) + int startX = 8; + int startY = 18; + + for (int row = 0; row < rows; row++) { + for (int col = 0; col < SLOTS_PER_ROW; col++) { + int slotIndex = row * SLOTS_PER_ROW + col; + if (slotIndex >= npcSlotCount) break; + + int x = startX + col * SLOT_SIZE; + int y = startY + row * SLOT_SIZE; + this.addSlot(new Slot(npcContainer, slotIndex, x, y)); + } + } + } + + /** + * Add player inventory slots (standard vanilla layout). + * Uses vanilla chest formula: playerY = rows*18 + 31 + */ + private void addPlayerInventorySlots(Inventory playerInventory) { + int npcRows = (npcSlotCount + SLOTS_PER_ROW - 1) / SLOTS_PER_ROW; + // Vanilla formula: (rows - 4) * 18 + 103 = rows*18 + 31 + int playerInvY = npcRows * SLOT_SIZE + 31; + + // Main inventory (3 rows) + for (int row = 0; row < 3; row++) { + for (int col = 0; col < 9; col++) { + int x = 8 + col * SLOT_SIZE; + int y = playerInvY + row * SLOT_SIZE; + this.addSlot( + new Slot(playerInventory, col + row * 9 + 9, x, y) + ); + } + } + + // Hotbar (58 pixels below main inventory) + int hotbarY = playerInvY + 58; + for (int col = 0; col < 9; col++) { + int x = 8 + col * SLOT_SIZE; + this.addSlot(new Slot(playerInventory, col, x, hotbarY)); + } + } + + /** + * Add equipment slots (armor + main hand) on the right side of the GUI. + * Positioned to align with equipment panel in NpcInventoryScreen. + * Creates dummy slots if NPC is null (client-side fallback). + */ + private void addEquipmentSlots() { + // Equipment panel is at x = 176 (chest width) + 4 (gap) = 180 + // Slots within panel are at panelX + 5 = 185 + int equipX = 185; + // Panel starts at y = 17 (header), slots start at panelY + 12 = 29 + int equipY = 29; + + // Armor slots (HEAD, CHEST, LEGS, FEET) - top to bottom + EquipmentSlot[] armorSlots = { + EquipmentSlot.HEAD, + EquipmentSlot.CHEST, + EquipmentSlot.LEGS, + EquipmentSlot.FEET, + }; + + if (npc != null) { + // Real equipment slots connected to entity + for (int i = 0; i < 4; i++) { + this.addSlot( + new NpcArmorSlot( + npc, + armorSlots[i], + equipX, + equipY + i * SLOT_SIZE + ) + ); + } + this.addSlot( + new NpcMainHandSlot(npc, equipX, equipY + 4 * SLOT_SIZE + 4) + ); + } else { + // Dummy slots for client-side sync (when entity not found) + SimpleContainer dummyContainer = new SimpleContainer(5); + for (int i = 0; i < 4; i++) { + final EquipmentSlot slotType = armorSlots[i]; + this.addSlot( + new Slot( + dummyContainer, + i, + equipX, + equipY + i * SLOT_SIZE + ) { + @Override + public boolean mayPlace(ItemStack stack) { + if (stack.isEmpty()) return true; + if (stack.getItem() instanceof ArmorItem armor) { + return armor.getEquipmentSlot() == slotType; + } + return false; + } + + @Override + public int getMaxStackSize() { + return 1; + } + } + ); + } + this.addSlot( + new Slot(dummyContainer, 4, equipX, equipY + 4 * SLOT_SIZE + 4) + ); + } + } + + @Override + public ItemStack quickMoveStack(Player player, int slotIndex) { + ItemStack result = ItemStack.EMPTY; + Slot slot = this.slots.get(slotIndex); + + if (slot == null || !slot.hasItem()) { + return result; + } + + ItemStack slotStack = slot.getItem(); + result = slotStack.copy(); + + // Slot layout: + // 0 to npcSlotCount-1: NPC inventory + // npcSlotCount to npcSlotCount+4: Equipment (4 armor + 1 mainhand) + // npcSlotCount+5 to end: Player inventory + int equipmentStart = npcSlotCount; + int equipmentEnd = npcSlotCount + 5; // 4 armor + 1 mainhand + int playerInvStart = equipmentEnd; + + if (slotIndex < npcSlotCount) { + // From NPC inventory -> player inventory + if ( + !this.moveItemStackTo( + slotStack, + playerInvStart, + this.slots.size(), + true + ) + ) { + return ItemStack.EMPTY; + } + } else if (slotIndex < equipmentEnd) { + // From equipment slots -> player inventory + if ( + !this.moveItemStackTo( + slotStack, + playerInvStart, + this.slots.size(), + true + ) + ) { + return ItemStack.EMPTY; + } + } else { + // From player inventory -> try equipment first, then NPC inventory + boolean moved = false; + + // Try equipment slots first (armor items go to armor slots) + if (slotStack.getItem() instanceof ArmorItem) { + moved = this.moveItemStackTo( + slotStack, + equipmentStart, + equipmentStart + 4, + false + ); + } + + // Then try NPC inventory + if (!moved && !slotStack.isEmpty()) { + moved = this.moveItemStackTo(slotStack, 0, npcSlotCount, false); + } + + if (!moved) { + return ItemStack.EMPTY; + } + } + + if (slotStack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + + return result; + } + + @Override + public boolean stillValid(Player player) { + // Menu stays valid as long as NPC exists and is nearby + if (npc == null || !npc.isAlive()) { + return false; + } + return player.distanceToSqr(npc) <= 64.0; // 8 block range + } + + /** + * Get NPC's display name for screen title. + */ + public String getNpcName() { + return npcName; + } + + /** + * Get the NPC entity (may be null on client). + */ + @Nullable + public EntityDamsel getNpc() { + return npc; + } + + /** + * Get number of NPC inventory slots. + */ + public int getNpcSlotCount() { + return npcSlotCount; + } + + /** + * Container wrapper that delegates to EntityDamsel's inventory. + * Allows vanilla Container mechanics to work with NPC inventory. + */ + private static class NpcInventoryContainer implements Container { + + private final EntityDamsel npc; + + NpcInventoryContainer(EntityDamsel npc) { + this.npc = npc; + } + + @Override + public int getContainerSize() { + return npc.getNpcInventorySize(); + } + + @Override + public boolean isEmpty() { + for (int i = 0; i < getContainerSize(); i++) { + if (!getItem(i).isEmpty()) { + return false; + } + } + return true; + } + + @Override + public ItemStack getItem(int slot) { + return npc.getNpcInventory().get(slot); + } + + @Override + public ItemStack removeItem(int slot, int amount) { + ItemStack stack = getItem(slot); + if (stack.isEmpty()) { + return ItemStack.EMPTY; + } + if (amount >= stack.getCount()) { + ItemStack removed = stack.copy(); + npc.getNpcInventory().set(slot, ItemStack.EMPTY); + setChanged(); + return removed; + } else { + ItemStack split = stack.split(amount); + setChanged(); + return split; + } + } + + @Override + public ItemStack removeItemNoUpdate(int slot) { + ItemStack stack = getItem(slot).copy(); + npc.getNpcInventory().set(slot, ItemStack.EMPTY); + return stack; + } + + @Override + public void setItem(int slot, ItemStack stack) { + npc.getNpcInventory().set(slot, stack); + setChanged(); + } + + @Override + public void setChanged() { + // Mark entity as dirty for saving + // The NPC will save inventory in its NBT + } + + @Override + public boolean stillValid(Player player) { + return npc.isAlive() && player.distanceToSqr(npc) <= 64.0; + } + + @Override + public void clearContent() { + for (int i = 0; i < getContainerSize(); i++) { + npc.getNpcInventory().set(i, ItemStack.EMPTY); + } + setChanged(); + } + } + + /** + * Custom slot for NPC armor equipment. + * Reads/writes directly to entity's equipment slots. + */ + private static class NpcArmorSlot extends Slot { + + private final EntityDamsel npc; + private final EquipmentSlot equipmentSlot; + + NpcArmorSlot(EntityDamsel npc, EquipmentSlot slot, int x, int y) { + // Use a dummy container - we override all access methods + super(new SimpleContainer(1), 0, x, y); + this.npc = npc; + this.equipmentSlot = slot; + } + + @Override + public boolean mayPlace(ItemStack stack) { + if (stack.isEmpty()) return true; + // Only allow armor items that match this slot + if (stack.getItem() instanceof ArmorItem armor) { + return armor.getEquipmentSlot() == this.equipmentSlot; + } + return false; + } + + @Override + public ItemStack getItem() { + return npc.getItemBySlot(equipmentSlot); + } + + @Override + public void setByPlayer(ItemStack stack) { + npc.setItemSlot(equipmentSlot, stack); + } + + @Override + public void set(ItemStack stack) { + npc.setItemSlot(equipmentSlot, stack); + } + + @Override + public ItemStack remove(int amount) { + ItemStack current = getItem(); + if (current.isEmpty()) return ItemStack.EMPTY; + + if (amount >= current.getCount()) { + ItemStack removed = current.copy(); + npc.setItemSlot(equipmentSlot, ItemStack.EMPTY); + return removed; + } else { + ItemStack split = current.split(amount); + npc.setItemSlot(equipmentSlot, current); + return split; + } + } + + @Override + public boolean hasItem() { + return !getItem().isEmpty(); + } + + @Override + public int getMaxStackSize() { + return 1; + } + } + + /** + * Custom slot for NPC main hand equipment. + * Reads/writes directly to entity's main hand. + */ + private static class NpcMainHandSlot extends Slot { + + private final EntityDamsel npc; + + NpcMainHandSlot(EntityDamsel npc, int x, int y) { + super(new SimpleContainer(1), 0, x, y); + this.npc = npc; + } + + @Override + public ItemStack getItem() { + return npc.getMainHandItem(); + } + + @Override + public void setByPlayer(ItemStack stack) { + npc.setMainHandItem(stack); + } + + @Override + public void set(ItemStack stack) { + npc.setMainHandItem(stack); + } + + @Override + public ItemStack remove(int amount) { + ItemStack current = getItem(); + if (current.isEmpty()) return ItemStack.EMPTY; + + if (amount >= current.getCount()) { + ItemStack removed = current.copy(); + npc.setMainHandItem(ItemStack.EMPTY); + return removed; + } else { + ItemStack split = current.split(amount); + npc.setMainHandItem(current); + return split; + } + } + + @Override + public boolean hasItem() { + return !getItem().isEmpty(); + } + + @Override + public int getMaxStackSize() { + return 64; + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/NpcTypeHelper.java b/src/main/java/com/tiedup/remake/entities/NpcTypeHelper.java new file mode 100644 index 0000000..7d2d70d --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/NpcTypeHelper.java @@ -0,0 +1,32 @@ +package com.tiedup.remake.entities; + +import net.minecraft.world.entity.Entity; + +/** + * Centralized NPC type checks. Use these instead of scattered instanceof chains. + * + * Updated for C7-Phase3 (AbstractTiedUpNpc extraction): + * EntityKidnapper now extends AbstractTiedUpNpc directly, not EntityDamsel. + * The hierarchy is: + * AbstractTiedUpNpc + * ├── EntityDamsel (+ DamselShiny, LaborGuard) + * └── EntityKidnapper (+ Elite, Archer, Merchant, Maid, Master, SlaveTrader) + */ +public final class NpcTypeHelper { + private NpcTypeHelper() {} + + /** True for EntityDamsel and its non-kidnapper subtypes (DamselShiny, LaborGuard) */ + public static boolean isDamselOnly(Entity entity) { + return entity instanceof EntityDamsel; + } + + /** True for EntityKidnapper and all subtypes (Elite, Archer, Merchant, Maid, Master, SlaveTrader) */ + public static boolean isKidnapperFamily(Entity entity) { + return entity instanceof EntityKidnapper; + } + + /** True for any TiedUp NPC (EntityDamsel or EntityKidnapper families) */ + public static boolean isTiedUpNpc(Entity entity) { + return entity instanceof AbstractTiedUpNpc; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/ConditionalGoal.java b/src/main/java/com/tiedup/remake/entities/ai/ConditionalGoal.java new file mode 100644 index 0000000..3e62409 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/ConditionalGoal.java @@ -0,0 +1,69 @@ +package com.tiedup.remake.entities.ai; + +import java.util.EnumSet; +import java.util.function.BooleanSupplier; +import net.minecraft.world.entity.ai.goal.Goal; + +/** + * Wrapper goal that only executes when a condition is met. + * Used to conditionally enable/disable AI goals at runtime. + * + * Example: ConditionalGoal(new KidnapperFindTargetGoal(), () -> merchant.isHostile()) + */ +public class ConditionalGoal extends Goal { + + private final Goal wrappedGoal; + private final BooleanSupplier condition; + + public ConditionalGoal(Goal goal, BooleanSupplier condition) { + this.wrappedGoal = goal; + this.condition = condition; + this.setFlags(goal.getFlags()); + } + + @Override + public boolean canUse() { + return condition.getAsBoolean() && wrappedGoal.canUse(); + } + + @Override + public boolean canContinueToUse() { + return condition.getAsBoolean() && wrappedGoal.canContinueToUse(); + } + + @Override + public boolean isInterruptable() { + return wrappedGoal.isInterruptable(); + } + + @Override + public void start() { + wrappedGoal.start(); + } + + @Override + public void stop() { + wrappedGoal.stop(); + } + + @Override + public boolean requiresUpdateEveryTick() { + return wrappedGoal.requiresUpdateEveryTick(); + } + + @Override + public void tick() { + wrappedGoal.tick(); + } + + @Override + public void setFlags(EnumSet flags) { + super.setFlags(flags); + wrappedGoal.setFlags(flags); + } + + @Override + public EnumSet getFlags() { + return wrappedGoal.getFlags(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/StuckDetector.java b/src/main/java/com/tiedup/remake/entities/ai/StuckDetector.java new file mode 100644 index 0000000..2e96b9a --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/StuckDetector.java @@ -0,0 +1,213 @@ +package com.tiedup.remake.entities.ai; + +import com.tiedup.remake.util.KidnapperAIHelper; +import java.util.ArrayList; +import java.util.List; +import org.jetbrains.annotations.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Mob; + +/** + * Reusable stuck detection for kidnapper AI goals. + * + * When the entity stops making progress: + * 1. First tries random detours to navigate around obstacles + * 2. After max detour attempts, signals that a teleport is needed + * + * Maintains a blacklist of positions where the entity got stuck. + * Returning to a blacklisted position triggers immediate stuck detection. + */ +public class StuckDetector { + + private BlockPos lastProgressPos; + private int stuckTicks; + private int detourAttempts; + private boolean inDetour; + + @Nullable + private BlockPos detourTarget; + + /** Positions where the entity previously got stuck (FIFO, max MAX_AVOIDED) */ + private final List avoidedPositions = new ArrayList<>(); + private static final int MAX_AVOIDED = 5; + private static final int AVOID_RADIUS_SQ = 9; // 3 blocks squared + + private final int stuckThreshold; + private final int maxDetours; + private final int pathFailPenalty; + + /** + * Create a StuckDetector with default settings. + * Threshold: 60 ticks (3s), max detours: 3, path fail penalty: 20 ticks. + */ + public StuckDetector() { + this(60, 3); + } + + /** + * Create a StuckDetector with custom settings. + * + * @param stuckThreshold Ticks without progress before considering stuck + * @param maxDetours Number of detour attempts before signaling teleport + */ + public StuckDetector(int stuckThreshold, int maxDetours) { + this.stuckThreshold = stuckThreshold; + this.maxDetours = maxDetours; + this.pathFailPenalty = 20; + this.stuckTicks = 0; + this.detourAttempts = 0; + this.inDetour = false; + this.detourTarget = null; + } + + /** + * Full reset including blacklist. Call on goal start/stop. + */ + public void reset() { + this.lastProgressPos = null; + this.stuckTicks = 0; + this.detourAttempts = 0; + this.inDetour = false; + this.detourTarget = null; + this.avoidedPositions.clear(); + } + + /** + * Update stuck detection state. Call every tick. + * + * @param entity The mob being tracked + */ + public void update(Mob entity) { + BlockPos currentPos = entity.blockPosition(); + + // If in detour, check progress towards detour target first + if (this.inDetour) { + // Check if reached detour target (within 3 blocks) + if ( + this.detourTarget != null && + currentPos.distSqr(this.detourTarget) <= AVOID_RADIUS_SQ + ) { + this.inDetour = false; + this.detourTarget = null; + this.stuckTicks = 0; + this.lastProgressPos = currentPos; + return; + } + + // Check if made progress during detour (moved >= 2 blocks) + if ( + this.lastProgressPos == null || + currentPos.distManhattan(this.lastProgressPos) >= 2 + ) { + this.lastProgressPos = currentPos; + this.stuckTicks = 0; + // Don't clear detour yet — keep going until reaching target + } else { + this.stuckTicks++; + } + // Skip blacklist check while in detour — entity needs time to walk away + return; + } + + // NOT in detour: check if current position is near a blacklisted position — instant stuck + for (BlockPos avoided : avoidedPositions) { + if (currentPos.distSqr(avoided) <= AVOID_RADIUS_SQ) { + this.stuckTicks = this.stuckThreshold; + return; + } + } + + // Check if entity made progress (moved >= 2 blocks manhattan) + if ( + this.lastProgressPos == null || + currentPos.distManhattan(this.lastProgressPos) >= 2 + ) { + this.lastProgressPos = currentPos; + this.stuckTicks = 0; + } else { + this.stuckTicks++; + } + } + + /** + * Add penalty ticks when pathfinding fails. Call when moveTo() returns false. + */ + public void onPathFailed() { + this.stuckTicks += this.pathFailPenalty; + } + + /** + * @return true if the entity has been stuck long enough to need intervention + */ + public boolean isStuck() { + return this.stuckTicks >= this.stuckThreshold; + } + + /** + * @return true if detour attempts are exhausted and a teleport is needed + */ + public boolean shouldTeleport() { + return isStuck() && this.detourAttempts >= this.maxDetours; + } + + /** + * @return true if currently navigating a detour route + */ + public boolean isInDetour() { + return this.inDetour; + } + + /** + * Try to find a detour position to navigate around the obstacle. + * Blacklists the current position and finds a random ground position + * that is not near any blacklisted position. + * + * @param level The server level + * @param current The entity's current block position + * @param radius Search radius for detour points + * @return A detour position, or null if none found (should teleport) + */ + @Nullable + public BlockPos tryDetour(ServerLevel level, BlockPos current, int radius) { + // Blacklist current position (FIFO eviction) + if (avoidedPositions.size() >= MAX_AVOIDED) { + avoidedPositions.remove(0); + } + avoidedPositions.add(current.immutable()); + + // Try to find a detour point that is NOT near a blacklisted position + for (int attempt = 0; attempt < 15; attempt++) { + BlockPos candidate = KidnapperAIHelper.findRandomGroundPos( + level, + current, + radius, + level.getRandom(), + 1 + ); + if (candidate == null) continue; + + // Check candidate is not near any blacklisted position + boolean tooClose = false; + for (BlockPos avoided : avoidedPositions) { + if (candidate.distSqr(avoided) <= AVOID_RADIUS_SQ) { + tooClose = true; + break; + } + } + + if (!tooClose) { + this.detourTarget = candidate; + this.inDetour = true; + this.detourAttempts++; + this.stuckTicks = 0; + return candidate; + } + } + + // No valid detour found — caller should teleport + this.detourAttempts++; + this.stuckTicks = 0; + return null; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/WaypointNavigator.java b/src/main/java/com/tiedup/remake/entities/ai/WaypointNavigator.java new file mode 100644 index 0000000..ea68787 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/WaypointNavigator.java @@ -0,0 +1,228 @@ +package com.tiedup.remake.entities.ai; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.ArrayList; +import java.util.List; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.Mob; +import org.jetbrains.annotations.Nullable; + +/** + * Utility class for navigating through ordered waypoints. + * Used by Kidnapper and Maid AI to navigate complex structures like + * multi-story buildings, corridors, and staircases. + * + * Waypoints are processed in order (0, 1, 2...) until the final destination. + * The navigator tracks current position in the waypoint list and provides + * the current target position for pathfinding. + * + * Usage: + * 1. Create with entity and waypoint list + * 2. Call tick() each AI tick + * 3. Call navigateToCurrentWaypoint() to pathfind to current target + * 4. Check isComplete() to know when done + */ +public class WaypointNavigator { + + private final Mob entity; + private final List waypoints; + private int currentIndex = 0; + + /** Distance (squared) at which a waypoint is considered reached */ + private static final double WAYPOINT_REACH_DISTANCE_SQ = 2.5 * 2.5; + + /** Vertical tolerance for reaching a waypoint */ + private static final double WAYPOINT_Y_TOLERANCE = 2.0; + + /** Speed modifier for navigation */ + private double speedModifier = 1.0; + + /** + * Create a new waypoint navigator. + * + * @param entity The entity to navigate + * @param waypoints The ordered list of waypoints to follow + */ + public WaypointNavigator(Mob entity, List waypoints) { + this.entity = entity; + this.waypoints = new ArrayList<>(waypoints); + } + + /** + * Create a navigator with a custom speed modifier. + * + * @param entity The entity to navigate + * @param waypoints The ordered list of waypoints + * @param speedModifier Speed multiplier for navigation + */ + public WaypointNavigator( + Mob entity, + List waypoints, + double speedModifier + ) { + this(entity, waypoints); + this.speedModifier = speedModifier; + } + + /** + * Check if this navigator has any waypoints. + * + * @return true if there are waypoints to follow + */ + public boolean hasWaypoints() { + return !waypoints.isEmpty(); + } + + /** + * Check if all waypoints have been reached. + * + * @return true if navigation is complete + */ + public boolean isComplete() { + return currentIndex >= waypoints.size(); + } + + /** + * Get the current waypoint to navigate to. + * + * @return Current waypoint position, or null if complete + */ + @Nullable + public BlockPos getCurrentWaypoint() { + if (isComplete()) { + return null; + } + return waypoints.get(currentIndex); + } + + /** + * Get the current waypoint index (0-based). + * + * @return Current index in the waypoint list + */ + public int getCurrentIndex() { + return currentIndex; + } + + /** + * Get the total number of waypoints. + * + * @return Total waypoint count + */ + public int getTotalWaypoints() { + return waypoints.size(); + } + + /** + * Tick the navigator to check for waypoint advancement. + * Should be called each AI tick. + * Automatically advances to next waypoint when current is reached. + */ + public void tick() { + if (isComplete()) { + return; + } + + BlockPos target = getCurrentWaypoint(); + if (target == null) { + return; + } + + // Check XZ distance + double dx = entity.getX() - (target.getX() + 0.5); + double dz = entity.getZ() - (target.getZ() + 0.5); + double distXZSq = dx * dx + dz * dz; + + // Check Y distance + double distY = Math.abs(entity.getY() - target.getY()); + + // If close enough in both XZ and Y, advance to next waypoint + if ( + distXZSq < WAYPOINT_REACH_DISTANCE_SQ && + distY < WAYPOINT_Y_TOLERANCE + ) { + TiedUpMod.LOGGER.debug( + "[WaypointNavigator] {} reached waypoint {}/{} at {}", + entity.getName().getString(), + currentIndex + 1, + waypoints.size(), + target.toShortString() + ); + currentIndex++; + } + } + + /** + * Navigate to the current waypoint. + * Should be called periodically (e.g., every 10-20 ticks) to update pathfinding. + * + * @return true if a path was found, false if pathfinding failed + */ + public boolean navigateToCurrentWaypoint() { + BlockPos target = getCurrentWaypoint(); + if (target == null) { + return false; + } + + return entity + .getNavigation() + .moveTo( + target.getX() + 0.5, + target.getY(), + target.getZ() + 0.5, + speedModifier + ); + } + + /** + * Reset navigation to start from the beginning. + */ + public void reset() { + currentIndex = 0; + } + + /** + * Skip to a specific waypoint index. + * + * @param index The index to skip to + */ + public void skipTo(int index) { + currentIndex = Math.min(index, waypoints.size()); + } + + /** + * Get the speed modifier. + * + * @return Current speed modifier + */ + public double getSpeedModifier() { + return speedModifier; + } + + /** + * Set the speed modifier. + * + * @param speedModifier New speed modifier + */ + public void setSpeedModifier(double speedModifier) { + this.speedModifier = speedModifier; + } + + /** + * Get distance to current waypoint. + * + * @return Distance to current waypoint, or -1 if complete + */ + public double getDistanceToCurrentWaypoint() { + BlockPos target = getCurrentWaypoint(); + if (target == null) { + return -1; + } + + return entity.distanceToSqr( + target.getX() + 0.5, + target.getY(), + target.getZ() + 0.5 + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/damsel/DamselFleeFromPlayerGoal.java b/src/main/java/com/tiedup/remake/entities/ai/damsel/DamselFleeFromPlayerGoal.java new file mode 100644 index 0000000..05ca2fa --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/damsel/DamselFleeFromPlayerGoal.java @@ -0,0 +1,323 @@ +package com.tiedup.remake.entities.ai.damsel; + +import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.entities.EntityKidnapper; +import java.util.EnumSet; +import java.util.List; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.ai.util.DefaultRandomPos; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.pathfinder.Path; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; + +/** + * AI Goal: Damsel flees from nearby players and kidnappers. + * + * Phase 14.2: Based on original EntityAIAvoidKidnapper + * + *

Behavior:

+ *
    + *
  • Only active if damsel is NOT tied up
  • + *
  • Only active if damsel is NOT blindfolded (can't see player)
  • + *
  • Only active if damsel is NOT collared (enslaved shouldn't flee)
  • + *
  • Flees from players AND kidnappers within maxDistance
  • + *
  • Sprint speed when threat is very close
  • + *
+ */ +public class DamselFleeFromPlayerGoal extends Goal { + + private final EntityDamsel damsel; + private final float maxDistance; + private final double walkSpeedModifier; + private final double sprintSpeedModifier; + private LivingEntity fleeTarget; // Can be Player or EntityKidnapper + private Path path; + + /** Radius for dialogue broadcast */ + private static final int DIALOGUE_RADIUS = 20; + + /** Sprint cooldown in ticks (15-20 seconds between sprints) */ + private static final int SPRINT_COOLDOWN_MIN = 300; + private static final int SPRINT_COOLDOWN_MAX = 400; + + /** How long sprint lasts in ticks (1-2 seconds) */ + private static final int SPRINT_DURATION = 30; + + /** Current sprint cooldown timer */ + private int sprintCooldown = 0; + + /** Current sprint duration timer */ + private int sprintTimer = 0; + + /** Whether currently sprinting */ + private boolean isSprinting = false; + + /** Path recalculation delay */ + private int pathRecalcDelay = 0; + + /** How often to recalculate path (in ticks) */ + private static final int PATH_RECALC_INTERVAL = 10; + + /** Cooldown for canUse() threat scanning - prevents expensive search every tick */ + private int canUseCooldown = 0; + + /** + * Create flee from player goal. + * + * @param damsel Damsel entity + * @param maxDistance Max distance to detect and flee from players (blocks) + * @param walkSpeed Walking speed multiplier + * @param sprintSpeed Sprint speed multiplier (when player very close) + */ + public DamselFleeFromPlayerGoal( + EntityDamsel damsel, + float maxDistance, + double walkSpeed, + double sprintSpeed + ) { + this.damsel = damsel; + this.maxDistance = maxDistance; + this.walkSpeedModifier = walkSpeed; + this.sprintSpeedModifier = sprintSpeed; + this.setFlags(EnumSet.of(Goal.Flag.MOVE)); + } + + @Override + public boolean canUse() { + // Throttle threat scanning - only check every 10 ticks (0.5 sec) + if (--this.canUseCooldown > 0) { + return false; + } + this.canUseCooldown = 10; + + // Can't flee if tied up, blindfolded, or collared + if (damsel.isTiedUp() || damsel.isBlindfolded() || damsel.hasCollar()) { + return false; + } + + // Find nearest threat (player or kidnapper) + this.fleeTarget = findNearestThreat(); + if (fleeTarget == null) { + return false; + } + + // Calculate flee path (away from threat) + Vec3 fleePos = DefaultRandomPos.getPosAway( + damsel, + 16, // Range + 7, // Y variance + fleeTarget.position() + ); + + if (fleePos == null) { + return false; + } + + this.path = damsel + .getNavigation() + .createPath(fleePos.x, fleePos.y, fleePos.z, 0); + return this.path != null; + } + + /** + * Find the nearest threat to flee from. + * Prioritizes kidnappers over players. + * Ignores the savior (player who freed this damsel). + * + * @return Nearest threat (kidnapper or player), or null if none + */ + private LivingEntity findNearestThreat() { + AABB searchBox = damsel.getBoundingBox().inflate(maxDistance); + + // First check for kidnappers (higher threat) + List kidnappers = damsel + .level() + .getEntitiesOfClass( + EntityKidnapper.class, + searchBox, + k -> + k.isAlive() && + damsel.distanceTo(k) <= maxDistance + ); + + if (!kidnappers.isEmpty()) { + // Return closest kidnapper + EntityKidnapper closest = null; + double closestDist = Double.MAX_VALUE; + for (EntityKidnapper k : kidnappers) { + double dist = damsel.distanceToSqr(k); + if (dist < closestDist) { + closestDist = dist; + closest = k; + } + } + return closest; + } + + // Then check for players (excluding savior) + List players = damsel + .level() + .getEntitiesOfClass( + Player.class, + searchBox, + p -> + p.isAlive() && + damsel.distanceTo(p) <= maxDistance && + !damsel.isSavior(p) // Don't flee from savior + ); + + if (players.isEmpty()) { + return null; + } + + // Return closest player + Player closest = null; + double closestDist = Double.MAX_VALUE; + for (Player p : players) { + double dist = damsel.distanceToSqr(p); + if (dist < closestDist) { + closestDist = dist; + closest = p; + } + } + return closest; + } + + @Override + public boolean canContinueToUse() { + // Stop if got captured/restrained + if (damsel.isTiedUp() || damsel.isBlindfolded() || damsel.hasCollar()) { + return false; + } + + // Stop if target gone + if (fleeTarget == null || !fleeTarget.isAlive()) { + return false; + } + + // Continue as long as threat is within range (don't check isDone - we recalc in tick) + return fleeTarget.distanceToSqr(damsel) < (maxDistance * maxDistance); + } + + @Override + public void start() { + damsel.getNavigation().moveTo(this.path, this.walkSpeedModifier); + + // Reset sprint state when starting to flee + this.isSprinting = false; + this.sprintTimer = 0; + this.sprintCooldown = 0; // Allow immediate first sprint + this.pathRecalcDelay = PATH_RECALC_INTERVAL; // Don't recalc immediately + + // Broadcast flee dialogue to nearby players (with cooldown) + if (!damsel.level().isClientSide() && !damsel.isGagged()) { + damsel.talkToPlayersInRadiusWithCooldown( + DialogueCategory.DAMSEL_FLEE, + DIALOGUE_RADIUS + ); + } + } + + @Override + public void stop() { + this.fleeTarget = null; + this.path = null; + this.isSprinting = false; + this.sprintTimer = 0; + damsel.getNavigation().stop(); + } + + @Override + public void tick() { + if (fleeTarget == null) return; + + // Recalculate flee path periodically for smooth movement + // Only recalc if navigation is done or timer expired + boolean needsNewPath = + damsel.getNavigation().isDone() || --pathRecalcDelay <= 0; + + if (needsNewPath) { + pathRecalcDelay = PATH_RECALC_INTERVAL; + + // Update threat target (might have changed) + LivingEntity newThreat = findNearestThreat(); + if (newThreat != null) { + this.fleeTarget = newThreat; + } + + // Calculate new flee position + Vec3 fleePos = DefaultRandomPos.getPosAway( + damsel, + 16, + 7, + fleeTarget.position() + ); + + if (fleePos != null) { + // Move directly to flee position + damsel + .getNavigation() + .moveTo( + fleePos.x, + fleePos.y, + fleePos.z, + isSprinting ? sprintSpeedModifier : walkSpeedModifier + ); + } else { + // Fallback: move directly away from threat + Vec3 awayDir = damsel + .position() + .subtract(fleeTarget.position()) + .normalize(); + Vec3 fallbackPos = damsel.position().add(awayDir.scale(8)); + damsel + .getNavigation() + .moveTo( + fallbackPos.x, + damsel.getY(), + fallbackPos.z, + isSprinting ? sprintSpeedModifier : walkSpeedModifier + ); + } + } + + // Decrement sprint cooldown + if (sprintCooldown > 0) { + sprintCooldown--; + } + + // Handle sprint duration + if (isSprinting) { + sprintTimer--; + if (sprintTimer <= 0) { + // Sprint ended, start cooldown + isSprinting = false; + sprintCooldown = + SPRINT_COOLDOWN_MIN + + damsel + .getRandom() + .nextInt(SPRINT_COOLDOWN_MAX - SPRINT_COOLDOWN_MIN); + } + } + + // Check if should start sprinting (threat very close + not on cooldown) + double distSqr = damsel.distanceToSqr(fleeTarget); + double sprintThreshold = 4.0 * 4.0; + + if (!isSprinting && sprintCooldown <= 0 && distSqr < sprintThreshold) { + // Start sprint + isSprinting = true; + sprintTimer = SPRINT_DURATION; + } + + // Apply speed modifier + if (isSprinting) { + damsel.getNavigation().setSpeedModifier(sprintSpeedModifier); + } else { + damsel.getNavigation().setSpeedModifier(walkSpeedModifier); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/damsel/DamselPanicGoal.java b/src/main/java/com/tiedup/remake/entities/ai/damsel/DamselPanicGoal.java new file mode 100644 index 0000000..49e1d33 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/damsel/DamselPanicGoal.java @@ -0,0 +1,70 @@ +package com.tiedup.remake.entities.ai.damsel; + +import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory; +import com.tiedup.remake.entities.EntityDamsel; +import net.minecraft.world.entity.ai.goal.PanicGoal; + +/** + * AI Goal: Damsel panics and flees when hurt. + * + * Phase 14.2: Based on original EntityAIPanicTiedUp + * + *

Behavior:

+ *
    + *
  • Only active if NOT tied up (can't panic if bound)
  • + *
  • Only active if NOT collared (enslaved shouldn't panic)
  • + *
  • Triggers on taking damage
  • + *
  • Runs around frantically at high speed
  • + *
+ */ +public class DamselPanicGoal extends PanicGoal { + + private final EntityDamsel damsel; + + /** Radius for dialogue broadcast */ + private static final int DIALOGUE_RADIUS = 20; + + /** + * Create panic goal. + * + * @param damsel Damsel entity + * @param speedModifier Speed multiplier when panicking + */ + public DamselPanicGoal(EntityDamsel damsel, double speedModifier) { + super(damsel, speedModifier); + this.damsel = damsel; + } + + @Override + public boolean canUse() { + // Can't panic if tied up or collared + if (damsel.isTiedUp() || damsel.hasCollar()) { + return false; + } + + return super.canUse(); + } + + @Override + public boolean canContinueToUse() { + // Stop panicking if got tied or collared + if (damsel.isTiedUp() || damsel.hasCollar()) { + return false; + } + + return super.canContinueToUse(); + } + + @Override + public void start() { + super.start(); + + // Broadcast panic dialogue to nearby players (with cooldown) + if (!damsel.level().isClientSide() && !damsel.isGagged()) { + damsel.talkToPlayersInRadiusWithCooldown( + DialogueCategory.DAMSEL_PANIC, + DIALOGUE_RADIUS + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/damsel/DamselWanderGoal.java b/src/main/java/com/tiedup/remake/entities/ai/damsel/DamselWanderGoal.java new file mode 100644 index 0000000..2ddb9a7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/damsel/DamselWanderGoal.java @@ -0,0 +1,52 @@ +package com.tiedup.remake.entities.ai.damsel; + +import com.tiedup.remake.entities.EntityDamsel; +import net.minecraft.world.entity.ai.goal.WaterAvoidingRandomStrollGoal; + +/** + * AI Goal: Damsel wanders randomly when not restrained. + * + * Phase 14.2: Based on original EntityAIWanderExceptWhenTiedUp + * + *

Behavior:

+ *
    + *
  • Only active if NOT tied up
  • + *
  • Only active if NOT collared (enslaved damsels don't wander)
  • + *
  • Random walking within 10 block range
  • + *
+ */ +public class DamselWanderGoal extends WaterAvoidingRandomStrollGoal { + + private final EntityDamsel damsel; + + /** + * Create wander goal. + * + * @param damsel Damsel entity + * @param speedModifier Walking speed multiplier + */ + public DamselWanderGoal(EntityDamsel damsel, double speedModifier) { + super(damsel, speedModifier); + this.damsel = damsel; + } + + @Override + public boolean canUse() { + // Can't wander if tied up or collared + if (damsel.isTiedUp() || damsel.hasCollar()) { + return false; + } + + return super.canUse(); + } + + @Override + public boolean canContinueToUse() { + // Stop wandering if got tied or collared + if (damsel.isTiedUp() || damsel.hasCollar()) { + return false; + } + + return super.canContinueToUse(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/damsel/DamselWatchPlayerGoal.java b/src/main/java/com/tiedup/remake/entities/ai/damsel/DamselWatchPlayerGoal.java new file mode 100644 index 0000000..bfba6b6 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/damsel/DamselWatchPlayerGoal.java @@ -0,0 +1,62 @@ +package com.tiedup.remake.entities.ai.damsel; + +import com.tiedup.remake.entities.EntityDamsel; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.LookAtPlayerGoal; + +/** + * AI Goal: Damsel watches nearby players. + * + * Phase 14.2: Based on original EntityAIWatchClosestBlindfolded + * + *

Behavior:

+ *
    + *
  • Only active if NOT blindfolded (can't watch if can't see)
  • + *
  • Looks at players within 6 blocks
  • + *
  • Idle head tracking
  • + *
+ */ +public class DamselWatchPlayerGoal extends LookAtPlayerGoal { + + private final EntityDamsel damsel; + + /** + * Create watch player goal. + * + * @param damsel Damsel entity + * @param entityType Entity type to watch (usually Player.class) + * @param lookDistance Max distance to watch (blocks) + */ + public DamselWatchPlayerGoal( + EntityDamsel damsel, + Class entityType, + float lookDistance + ) { + super(damsel, entityType, lookDistance); + this.damsel = damsel; + } + + @Override + public boolean canUse() { + // Can't watch if blindfolded + if (damsel.isBlindfolded()) { + return false; + } + + // DOG pose: Can still watch players - body rotation is smoothed in tick() + // and head compensation is applied in the model + + return super.canUse(); + } + + @Override + public boolean canContinueToUse() { + // Stop watching if got blindfolded + if (damsel.isBlindfolded()) { + return false; + } + + return super.canContinueToUse(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/guard/GuardFightBackGoal.java b/src/main/java/com/tiedup/remake/entities/ai/guard/GuardFightBackGoal.java new file mode 100644 index 0000000..60fcf74 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/guard/GuardFightBackGoal.java @@ -0,0 +1,120 @@ +package com.tiedup.remake.entities.ai.guard; + +import com.tiedup.remake.entities.EntityLaborGuard; +import com.tiedup.remake.items.ItemTaser; +import com.tiedup.remake.items.ModItems; +import java.util.EnumSet; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * AI Goal: Guard fights back when attacked by a player. + * + * Adapted from KidnapperFightBackGoal for EntityDamsel-based guard. + * Differences from KidnapperFightBackGoal: + * - No hasCaptives()/isWaitingForJobToBeCompleted() check (always active) + * - No isMaster() check (guard fights all attackers) + * - Does NOT become hostile toward the monitored prisoner + * (prisoner attacks are handled separately in EntityLaborGuard.hurt()) + */ +public class GuardFightBackGoal extends Goal { + + private final EntityLaborGuard guard; + private LivingEntity attacker; + private int aggressionTimer; + + private static final int AGGRESSION_DURATION = 200; // 10 seconds + private int attackCooldown = 0; + + public GuardFightBackGoal(EntityLaborGuard guard) { + this.guard = guard; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (guard.isTiedUp()) { + return false; + } + + LivingEntity lastAttacker = guard.getLastAttacker(); + if (lastAttacker == null || !(lastAttacker instanceof Player player)) { + return false; + } + + // Don't fight the prisoner we're guarding (handled via shock in hurt()) + if ( + guard.getPrisonerUUID() != null && + player.getUUID().equals(guard.getPrisonerUUID()) + ) { + return false; + } + + this.attacker = lastAttacker; + return true; + } + + @Override + public void start() { + // Equip taser + guard.setItemInHand( + InteractionHand.MAIN_HAND, + new ItemStack(ModItems.TASER.get()) + ); + + this.aggressionTimer = AGGRESSION_DURATION; + this.attackCooldown = 0; + } + + @Override + public void tick() { + aggressionTimer--; + if (attackCooldown > 0) { + attackCooldown--; + } + + if (attacker != null && attacker.isAlive()) { + // Look at attacker + guard.getLookControl().setLookAt(attacker); + + // Pursue + guard.getNavigation().moveTo(attacker, 1.3); + + // Attack if close enough + if (guard.distanceTo(attacker) < 2.0 && attackCooldown <= 0) { + guard.swing(InteractionHand.MAIN_HAND); + + boolean damaged = guard.doHurtTarget(attacker); + + if (damaged) { + ItemStack heldItem = guard.getItemInHand( + InteractionHand.MAIN_HAND + ); + if (heldItem.getItem() instanceof ItemTaser taserItem) { + taserItem.hurtEnemy(heldItem, attacker, guard); + } + } + + attackCooldown = 20; + } + } + } + + @Override + public boolean canContinueToUse() { + if (aggressionTimer <= 0) return false; + if (attacker == null || !attacker.isAlive()) return false; + if (guard.distanceTo(attacker) > 30) return false; + return true; + } + + @Override + public void stop() { + guard.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); + this.attacker = null; + this.aggressionTimer = 0; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/guard/GuardFollowPrisonerGoal.java b/src/main/java/com/tiedup/remake/entities/ai/guard/GuardFollowPrisonerGoal.java new file mode 100644 index 0000000..eb63c48 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/guard/GuardFollowPrisonerGoal.java @@ -0,0 +1,220 @@ +package com.tiedup.remake.entities.ai.guard; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityLaborGuard; +import java.util.EnumSet; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.state.BlockState; + +/** + * AI Goal for EntityLaborGuard to follow their assigned prisoner. + * + * Adapted from MasterFollowPlayerGoal. + * Maintains distance of 3-12 blocks, teleports at 32 blocks. + */ +public class GuardFollowPrisonerGoal extends Goal { + + private final EntityLaborGuard guard; + + private static final double MIN_DISTANCE = 3.0; + private static final double IDEAL_DISTANCE = 6.0; + private static final double MAX_DISTANCE = 12.0; + private static final double TELEPORT_DISTANCE = 32.0; + private static final double FOLLOW_SPEED = 1.0; + private static final int PATH_RECALC_COOLDOWN = 10; + /** Ticks without making progress toward prisoner before force-teleporting */ + private static final int STUCK_TELEPORT_TICKS = 100; // 5 seconds + + private int pathRecalcCooldown = 0; + private int stuckTicks = 0; + private BlockPos lastProgressPos = null; + + public GuardFollowPrisonerGoal(EntityLaborGuard guard) { + this.guard = guard; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (guard.isTiedUp()) { + return false; + } + + ServerPlayer prisoner = guard.getPrisoner(); + if (prisoner == null || !prisoner.isAlive()) { + return false; + } + + return true; + } + + @Override + public boolean canContinueToUse() { + ServerPlayer prisoner = guard.getPrisoner(); + if (prisoner == null || !prisoner.isAlive()) { + return false; + } + + if (guard.isTiedUp()) { + return false; + } + + return true; + } + + @Override + public void start() { + this.pathRecalcCooldown = 0; + this.stuckTicks = 0; + this.lastProgressPos = null; + + TiedUpMod.LOGGER.debug( + "[GuardFollowPrisonerGoal] {} started following prisoner", + guard.getNpcName() + ); + } + + @Override + public void stop() { + guard.getNavigation().stop(); + + TiedUpMod.LOGGER.debug( + "[GuardFollowPrisonerGoal] {} stopped following prisoner", + guard.getNpcName() + ); + } + + @Override + public void tick() { + ServerPlayer prisoner = guard.getPrisoner(); + if (prisoner == null) return; + + // Check for dimension change + if (!guard.level().dimension().equals(prisoner.level().dimension())) { + return; + } + + // Look at prisoner + guard.getLookControl().setLookAt(prisoner, 30.0F, 30.0F); + + double distSq = guard.distanceToSqr(prisoner); + + // Teleport if too far + if (distSq > TELEPORT_DISTANCE * TELEPORT_DISTANCE) { + teleportNearPrisoner(prisoner); + this.stuckTicks = 0; + this.lastProgressPos = null; + return; + } + + // Stuck detection: if guard isn't making progress toward prisoner, teleport + // This catches cases where guard spawns in a cell or behind walls + if (distSq > IDEAL_DISTANCE * IDEAL_DISTANCE) { + BlockPos currentPos = guard.blockPosition(); + if ( + lastProgressPos == null || + currentPos.distManhattan(lastProgressPos) >= 2 + ) { + lastProgressPos = currentPos; + stuckTicks = 0; + } else { + stuckTicks++; + } + if (stuckTicks >= STUCK_TELEPORT_TICKS) { + TiedUpMod.LOGGER.info( + "[GuardFollowPrisonerGoal] {} stuck for {}s, teleporting to prisoner", + guard.getNpcName(), + STUCK_TELEPORT_TICKS / 20 + ); + teleportNearPrisoner(prisoner); + stuckTicks = 0; + lastProgressPos = null; + return; + } + } else { + stuckTicks = 0; + } + + // Update path periodically + this.pathRecalcCooldown--; + if (this.pathRecalcCooldown <= 0) { + this.pathRecalcCooldown = PATH_RECALC_COOLDOWN; + + if (distSq < MIN_DISTANCE * MIN_DISTANCE) { + // Too close - stop + guard.getNavigation().stop(); + } else if (distSq > IDEAL_DISTANCE * IDEAL_DISTANCE) { + // Beyond ideal distance - follow + guard.getNavigation().moveTo(prisoner, FOLLOW_SPEED); + } else if (!guard.getNavigation().isInProgress()) { + // At ideal distance and idle - wander near prisoner + wanderNearPrisoner(prisoner); + } + } + } + + private void wanderNearPrisoner(ServerPlayer prisoner) { + double angle = guard.getRandom().nextDouble() * Math.PI * 2; + double distance = + IDEAL_DISTANCE + (guard.getRandom().nextDouble() - 0.5) * 2; + + double x = prisoner.getX() + Math.cos(angle) * distance; + double z = prisoner.getZ() + Math.sin(angle) * distance; + + int groundY = findSafeGroundY( + prisoner.level(), + (int) x, + (int) z, + (int) prisoner.getY() + 10 + ); + double y = groundY; + + guard.getNavigation().moveTo(x, y, z, FOLLOW_SPEED * 0.6); + } + + private void teleportNearPrisoner(ServerPlayer prisoner) { + double angle = guard.getRandom().nextDouble() * Math.PI * 2; + double distance = MIN_DISTANCE + guard.getRandom().nextDouble() * 2; + + double x = prisoner.getX() + Math.cos(angle) * distance; + double z = prisoner.getZ() + Math.sin(angle) * distance; + + int groundY = findSafeGroundY( + prisoner.level(), + (int) x, + (int) z, + (int) prisoner.getY() + 10 + ); + double y = groundY; + + guard.teleportTo(x, y, z); + + TiedUpMod.LOGGER.debug( + "[GuardFollowPrisonerGoal] {} teleported near prisoner", + guard.getNpcName() + ); + } + + private int findSafeGroundY(Level level, int x, int z, int startY) { + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos( + x, + startY, + z + ); + + for (int y = startY; y > level.getMinBuildHeight(); y--) { + pos.setY(y); + BlockState below = level.getBlockState(pos.below()); + BlockState at = level.getBlockState(pos); + BlockState above = level.getBlockState(pos.above()); + + if (below.isSolid() && !at.isSolid() && !above.isSolid()) { + return y; + } + } + return startY; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/guard/GuardHuntMonstersGoal.java b/src/main/java/com/tiedup/remake/entities/ai/guard/GuardHuntMonstersGoal.java new file mode 100644 index 0000000..7fb6e18 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/guard/GuardHuntMonstersGoal.java @@ -0,0 +1,299 @@ +package com.tiedup.remake.entities.ai.guard; + +import com.tiedup.remake.entities.EntityLaborGuard; +import com.tiedup.remake.items.ItemTaser; +import com.tiedup.remake.items.ModItems; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.monster.*; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.phys.AABB; + +/** + * AI Goal for guard to hunt and kill monsters near the prisoner. + * + * Adapted from KidnapperHuntMonstersGoal for EntityDamsel-based guard. + * Scans 16 blocks around the PRISONER (not the guard), deals 2x damage, + * applies taser stun, and cancels creeper explosions. + */ +public class GuardHuntMonstersGoal extends Goal { + + private final EntityLaborGuard guard; + private LivingEntity targetMonster; + + private static final int SCAN_RADIUS = 12; + private static final int ATTACK_COOLDOWN = 15; + private static final float MONSTER_DAMAGE_MULTIPLIER = 2.0f; + + /** Maps combat task drop items to the mob classes that drop them. */ + private static final Map>> DROP_TO_MOB = + Map.of( + Items.ROTTEN_FLESH, + Set.of( + Zombie.class, + Husk.class, + Drowned.class, + ZombieVillager.class + ), + Items.BONE, + Set.of(Skeleton.class, WitherSkeleton.class, Stray.class), + Items.STRING, + Set.of(Spider.class, CaveSpider.class), + Items.GUNPOWDER, + Set.of(Creeper.class), + Items.SPIDER_EYE, + Set.of(Spider.class, CaveSpider.class, Witch.class), + Items.ARROW, + Set.of(Skeleton.class, Stray.class) + ); + + private int attackCooldown = 0; + + public GuardHuntMonstersGoal(EntityLaborGuard guard) { + this.guard = guard; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (guard.isTiedUp()) { + return false; + } + + // Don't hunt if fighting a player target + if (guard.getTarget() != null) { + return false; + } + + // Must have a prisoner to protect + ServerPlayer prisoner = guard.getPrisoner(); + if (prisoner == null || !prisoner.isAlive()) { + return false; + } + + this.targetMonster = findNearestMonster(prisoner); + return this.targetMonster != null; + } + + @Override + public boolean canContinueToUse() { + if (this.targetMonster == null || !this.targetMonster.isAlive()) { + return false; + } + + if (guard.getTarget() != null) { + return false; + } + + if (guard.isTiedUp()) { + return false; + } + + // Abort if guard is getting too far from prisoner + ServerPlayer prisoner = guard.getPrisoner(); + if ( + prisoner != null && + guard.distanceToSqr(prisoner) > SCAN_RADIUS * SCAN_RADIUS + ) { + return false; + } + + // Abort if monster is no longer threatening + if (this.targetMonster instanceof Monster monster && prisoner != null) { + if (!isThreateningTarget(monster, prisoner)) { + return false; + } + } + + return ( + guard.distanceToSqr(this.targetMonster) < + SCAN_RADIUS * SCAN_RADIUS * 4 + ); + } + + @Override + public void start() { + this.attackCooldown = 0; + + guard.setItemInHand( + InteractionHand.MAIN_HAND, + new ItemStack(ModItems.TASER.get()) + ); + } + + @Override + public void stop() { + this.targetMonster = null; + guard.getNavigation().stop(); + + guard.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); + } + + @Override + public void tick() { + if (this.targetMonster == null) { + return; + } + + guard.getLookControl().setLookAt(this.targetMonster); + + double distSq = guard.distanceToSqr(this.targetMonster); + + if (distSq > 4.0) { + guard.getNavigation().moveTo(this.targetMonster, 1.2); + } else { + guard.getNavigation().stop(); + + if (this.attackCooldown <= 0) { + attackMonsterWithBonus(this.targetMonster); + this.attackCooldown = ATTACK_COOLDOWN; + } + } + + if (this.attackCooldown > 0) { + this.attackCooldown--; + } + } + + private void attackMonsterWithBonus(LivingEntity target) { + guard.swing(InteractionHand.MAIN_HAND); + + float baseDamage = (float) guard.getAttributeValue( + Attributes.ATTACK_DAMAGE + ); + float bonusDamage = baseDamage * MONSTER_DAMAGE_MULTIPLIER; + + // Attribute kill to prisoner so mobs drop player-kill loot (skulls, etc.) + ServerPlayer prisoner = guard.getPrisoner(); + boolean damaged = target.hurt( + prisoner != null + ? guard.damageSources().playerAttack(prisoner) + : guard.damageSources().mobAttack(guard), + bonusDamage + ); + + if (damaged) { + ItemStack heldItem = guard.getItemInHand(InteractionHand.MAIN_HAND); + if (heldItem.getItem() instanceof ItemTaser taserItem) { + taserItem.hurtEnemy(heldItem, target, guard); + } + + if (target instanceof Creeper creeper) { + creeper.setSwellDir(-1); + } + } + } + + /** + * Find nearest threatening monster around the prisoner. + * Only targets monsters that are actively targeting the prisoner or the guard, + * that are reachable (not underwater when we're on land), + * and that are NOT task-relevant mobs during a combat task. + */ + private LivingEntity findNearestMonster(ServerPlayer prisoner) { + AABB searchBox = prisoner.getBoundingBox().inflate(SCAN_RADIUS); + Set> taskMobs = getTaskRelevantMobs(prisoner); + + List monsters = guard + .level() + .getEntitiesOfClass( + Monster.class, + searchBox, + m -> + m.isAlive() && + !m.isSpectator() && + !taskMobs.contains(m.getClass()) && + isThreateningTarget(m, prisoner) + ); + + if (monsters.isEmpty()) { + return null; + } + + Monster nearest = null; + double nearestDist = Double.MAX_VALUE; + + for (Monster m : monsters) { + double dist = guard.distanceToSqr(m); + if (dist < nearestDist) { + nearestDist = dist; + nearest = m; + } + } + + return nearest; + } + + /** + * Check if a monster is actually threatening the prisoner or the guard. + */ + private boolean isThreateningTarget( + Monster monster, + ServerPlayer prisoner + ) { + // Must be targeting us or the prisoner + LivingEntity monsterTarget = monster.getTarget(); + if (monsterTarget == null) { + return false; + } + boolean targetingPrisoner = monsterTarget + .getUUID() + .equals(prisoner.getUUID()); + boolean targetingGuard = monsterTarget + .getUUID() + .equals(guard.getUUID()); + if (!targetingPrisoner && !targetingGuard) { + return false; + } + + // Skip if monster is in water and we're not (unreachable chase) + if (monster.isInWater() && !guard.isInWater()) { + return false; + } + + return true; + } + + /** + * Get the set of mob classes that the prisoner's current task needs. + * Returns empty set if no combat task is active. + */ + private Set> getTaskRelevantMobs( + ServerPlayer prisoner + ) { + if ( + !(guard.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel) + ) { + return Set.of(); + } + com.tiedup.remake.prison.PrisonerManager manager = + com.tiedup.remake.prison.PrisonerManager.get(serverLevel); + com.tiedup.remake.prison.LaborRecord labor = manager.getLaborRecord( + prisoner.getUUID() + ); + if ( + labor.getPhase() != + com.tiedup.remake.prison.LaborRecord.WorkPhase.WORKING + ) { + return Set.of(); + } + com.tiedup.remake.labor.LaborTask task = labor.getTask(); + if (task == null || !task.isCombatTask()) { + return Set.of(); + } + Set> mobs = DROP_TO_MOB.get( + task.getTargetItem() + ); + return mobs != null ? mobs : Set.of(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/guard/GuardMonitorGoal.java b/src/main/java/com/tiedup/remake/entities/ai/guard/GuardMonitorGoal.java new file mode 100644 index 0000000..b70d948 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/guard/GuardMonitorGoal.java @@ -0,0 +1,570 @@ +package com.tiedup.remake.entities.ai.guard; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityLaborGuard; +import com.tiedup.remake.labor.LaborTask; +import com.tiedup.remake.prison.LaborRecord; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerRecord; +import com.tiedup.remake.prison.service.PrisonerService; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.util.MessageDispatcher; +import com.tiedup.remake.util.RestraintApplicator; +import java.util.EnumSet; +import java.util.List; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.goal.Goal; + +/** + * AI Goal for monitoring prisoner activity, distance, and task progress. + * + * Replaces the fragile MaidIdleGoal.monitorWorkers() with guard-based monitoring. + * Runs in parallel with other goals (Flag.NONE). + * + * Key improvements over MaidIdleGoal: + * - Shock only resets on TASK PROGRESS, not mere movement + * - Distance-based escape with 15-second countdown + * - Escalating punishment (4 levels) + */ +public class GuardMonitorGoal extends Goal { + + private final EntityLaborGuard guard; + + // Check intervals (ticks) + private static final int DISTANCE_CHECK_INTERVAL = 20; // 1 sec + private static final int ACTIVITY_CHECK_INTERVAL = 60; // 3 sec + private static final int TASK_CHECK_INTERVAL = 100; // 5 sec + + // Escape countdown + private static final int ESCAPE_COUNTDOWN_TICKS = 300; // 15 sec + private int escapeCountdown = -1; // -1 = not active + private boolean warningIssued = false; + + // Activity tracking + private int lastKnownProgress = -1; + + // Punishment cooldown + private static final int SHOCK_COOLDOWN_TICKS = 400; // 20 sec + private int shockCooldownTimer = 0; + + // Inactivity tracking + private long lastProgressTime = 0; + private static final long INACTIVITY_THRESHOLD_TICKS = 600; // 30 sec + private static final long COMBAT_INACTIVITY_THRESHOLD_TICKS = 2400; // 2 min (mobs are time-of-day dependent) + + // Counters + private int distanceCounter = 0; + private int activityCounter = 0; + private int taskCounter = 0; + + // Return-to-camp tracking (PENDING_RETURN phase) + private static final int RETURN_CHECK_INTERVAL = 300; // 15 sec + private int returnCheckCounter = 0; + private double closestDistanceToCamp = Double.MAX_VALUE; + + @javax.annotation.Nullable + private BlockPos campPos = null; + + public GuardMonitorGoal(EntityLaborGuard guard) { + this.guard = guard; + // No flags - runs in parallel with Follow and other goals + this.setFlags(EnumSet.noneOf(Goal.Flag.class)); + } + + @Override + public boolean canUse() { + if (guard.isTiedUp()) { + return false; + } + + ServerPlayer prisoner = guard.getPrisoner(); + return prisoner != null && prisoner.isAlive(); + } + + @Override + public boolean canContinueToUse() { + return canUse(); + } + + @Override + public void start() { + this.escapeCountdown = -1; + this.warningIssued = false; + this.lastKnownProgress = -1; + this.shockCooldownTimer = 0; + this.lastProgressTime = guard.level().getGameTime(); + this.returnCheckCounter = 0; + this.closestDistanceToCamp = Double.MAX_VALUE; + this.campPos = null; + } + + @Override + public void stop() { + this.escapeCountdown = -1; + this.warningIssued = false; + } + + @Override + public void tick() { + if (!(guard.level() instanceof ServerLevel level)) { + return; + } + + ServerPlayer prisoner = guard.getPrisoner(); + if (prisoner == null || !prisoner.isAlive()) return; + + // Decrement cooldowns + if (shockCooldownTimer > 0) { + shockCooldownTimer--; + } + + // Tick escape countdown + if (escapeCountdown > 0) { + escapeCountdown--; + + // Shock every 100 ticks (5 sec) during countdown + if (escapeCountdown % 100 == 0 && escapeCountdown > 0) { + guardSay( + prisoner, + "guard.labor.escape_warning", + "Get back here! NOW!" + ); + shockPrisoner(prisoner, "Return to the guard NOW!", 1.0f); + } + + if (escapeCountdown == 0) { + // Escape triggered + triggerEscape(level, prisoner, "fled from guard"); + return; + } + } + + // Distance check + distanceCounter++; + if (distanceCounter >= DISTANCE_CHECK_INTERVAL) { + distanceCounter = 0; + checkDistance(level, prisoner); + } + + // Activity check + activityCounter++; + if (activityCounter >= ACTIVITY_CHECK_INTERVAL) { + activityCounter = 0; + checkActivity(level, prisoner); + } + + // Task progress check + taskCounter++; + if (taskCounter >= TASK_CHECK_INTERVAL) { + taskCounter = 0; + checkTaskProgress(level, prisoner); + } + + // Return-to-camp check (PENDING_RETURN phase only) + returnCheckCounter++; + if (returnCheckCounter >= RETURN_CHECK_INTERVAL) { + returnCheckCounter = 0; + checkReturnProgress(level, prisoner); + } + } + + /** + * Check distance between guard and prisoner. + * Triggers escape countdown if prisoner is too far. + */ + private void checkDistance(ServerLevel level, ServerPlayer prisoner) { + double distance = guard.distanceTo(prisoner); + + if (distance > EntityLaborGuard.WARNING_RADIUS) { + // Prisoner is outside warning radius + if (escapeCountdown < 0) { + // Start escape countdown + escapeCountdown = ESCAPE_COUNTDOWN_TICKS; + warningIssued = true; + + guardSay( + prisoner, + "guard.labor.escape_warning", + "Where do you think you're going?!" + ); + shockPrisoner( + prisoner, + "Return to the guard immediately!", + 1.0f + ); + + prisoner.sendSystemMessage( + Component.literal( + "You are too far from your guard! Return within 15 seconds or escape will be triggered!" + ).withStyle(ChatFormatting.RED, ChatFormatting.BOLD) + ); + + TiedUpMod.LOGGER.info( + "[GuardMonitorGoal] Prisoner {} is {} blocks from guard - escape countdown started", + prisoner.getName().getString(), + distance + ); + } + } else { + // Prisoner is within warning radius + if (escapeCountdown >= 0) { + // Reset countdown - prisoner returned + escapeCountdown = -1; + warningIssued = false; + + guardSay( + prisoner, + "guard.labor.returned", + "Don't try that again." + ); + + prisoner.sendSystemMessage( + Component.literal( + "You returned to your guard. Stay close!" + ).withStyle(ChatFormatting.YELLOW) + ); + } + } + } + + /** + * Check prisoner activity (task progress based, NOT movement based). + */ + private void checkActivity(ServerLevel level, ServerPlayer prisoner) { + UUID prisonerId = prisoner.getUUID(); + PrisonerManager manager = PrisonerManager.get(level); + LaborRecord labor = manager.getLaborRecord(prisonerId); + + if (labor.getPhase() != LaborRecord.WorkPhase.WORKING) { + return; + } + + long currentTime = level.getGameTime(); + long timeSinceProgress = currentTime - lastProgressTime; + + // Combat tasks get a longer grace period since mobs are time-of-day dependent + LaborTask task = labor.getTask(); + long threshold = (task != null && task.isCombatTask()) + ? COMBAT_INACTIVITY_THRESHOLD_TICKS + : INACTIVITY_THRESHOLD_TICKS; + + if (timeSinceProgress > threshold && shockCooldownTimer <= 0) { + // Before escalating, check real-time progress (task.checkProgress runs on 5s interval, + // but activity check runs on 3s - progress may be stale) + if (task != null) { + task.checkProgress(prisoner, level); + int currentProgress = task.getProgress(); + if ( + lastKnownProgress >= 0 && + currentProgress > lastKnownProgress + ) { + // Player made progress - reset instead of punishing + lastProgressTime = currentTime; + labor.resetShockLevel(); + lastKnownProgress = currentProgress; + return; + } + } + + // Truly inactive - escalate punishment + int shockLevel = labor.getShockLevel(); + escalatePunishment(prisoner, labor, currentTime, shockLevel); + } + } + + /** + * Check task completion progress. + */ + private void checkTaskProgress(ServerLevel level, ServerPlayer prisoner) { + UUID prisonerId = prisoner.getUUID(); + PrisonerManager manager = PrisonerManager.get(level); + LaborRecord labor = manager.getLaborRecord(prisonerId); + + if (labor.getPhase() != LaborRecord.WorkPhase.WORKING) { + return; + } + + LaborTask task = labor.getTask(); + if (task == null) return; + + long currentTime = level.getGameTime(); + + // Check progress + task.checkProgress(prisoner, level); + + if (task.isComplete()) { + // Task complete + labor.completeTask(currentTime); + + // Tell prisoner to walk back to camp + UUID campId = guard.getCampId(); + if (campId != null) { + List campCells = CellRegistryV2.get( + level + ).getCellsByCamp(campId); + if (!campCells.isEmpty()) { + BlockPos campCenter = campCells.get(0).getCorePos(); + String direction = getCardinalDirection( + prisoner.blockPosition(), + campCenter + ); + int distance = (int) Math.sqrt( + prisoner.blockPosition().distSqr(campCenter) + ); + guardSay( + prisoner, + "guard.labor.task_complete", + "Good work. Now walk back to camp." + ); + guard.guardSay( + prisoner, + "Camp is " + + distance + + " blocks to the " + + direction + + ". Follow me." + ); + } else { + guardSay( + prisoner, + "guard.labor.task_complete", + "Good work. Now walk back to camp." + ); + } + } else { + guardSay( + prisoner, + "guard.labor.task_complete", + "Good work. Now walk back to camp." + ); + } + + TiedUpMod.LOGGER.info( + "[GuardMonitorGoal] {} completed task", + prisoner.getName().getString() + ); + return; + } + + // Check if progress increased + int currentProgress = task.getProgress(); + if (lastKnownProgress >= 0 && currentProgress > lastKnownProgress) { + // Progress made - reset inactivity tracking + lastProgressTime = currentTime; + labor.resetShockLevel(); + + labor.updateActivity( + currentTime, + prisoner.blockPosition().getX(), + prisoner.blockPosition().getY(), + prisoner.blockPosition().getZ() + ); + } + lastKnownProgress = currentProgress; + } + + /** + * Check if prisoner is walking back toward camp (PENDING_RETURN phase). + * If moving away from their closest recorded distance, shock them. + */ + private void checkReturnProgress(ServerLevel level, ServerPlayer prisoner) { + UUID prisonerId = prisoner.getUUID(); + PrisonerManager manager = PrisonerManager.get(level); + LaborRecord labor = manager.getLaborRecord(prisonerId); + + if (labor.getPhase() != LaborRecord.WorkPhase.PENDING_RETURN) { + // Reset tracking when not in return phase + closestDistanceToCamp = Double.MAX_VALUE; + campPos = null; + return; + } + + // Resolve camp position once + if (campPos == null) { + UUID campId = guard.getCampId(); + if (campId != null) { + List campCells = CellRegistryV2.get( + level + ).getCellsByCamp(campId); + if (!campCells.isEmpty()) { + campPos = campCells.get(0).getCorePos(); + } + } + if (campPos == null) return; + } + + double currentDist = Math.sqrt( + prisoner.blockPosition().distSqr(campPos) + ); + + if (currentDist < closestDistanceToCamp) { + // Getting closer — update closest + closestDistanceToCamp = currentDist; + } else if (currentDist > closestDistanceToCamp + 10) { + // Moving away (10 block tolerance) — shock + shockPrisoner(prisoner, "Walk back to camp!", 1.0f); + guardSay( + prisoner, + "guard.labor.return_warning", + "Wrong way! Get back to camp NOW!" + ); + + // Show direction + String direction = getCardinalDirection( + prisoner.blockPosition(), + campPos + ); + int dist = (int) currentDist; + guard.guardSay( + prisoner, + "Camp is " + dist + " blocks to the " + direction + "." + ); + } + } + + /** + * Escalate punishment based on shock level. + */ + private void escalatePunishment( + ServerPlayer prisoner, + LaborRecord labor, + long currentTime, + int shockLevel + ) { + switch (shockLevel) { + case 0 -> { + // Warning: randomly verbal or physical (whip) + if (guard.getRandom().nextBoolean()) { + // Verbal warning + guardSay( + prisoner, + "guard.labor.warning", + "I'm watching you. Get to work!" + ); + } else { + // Walk up and whip + guard.setNeedsWhip(true); + } + labor.incrementShockLevel(); + shockCooldownTimer = SHOCK_COOLDOWN_TICKS; + } + case 1 -> { + // First shock + shockPrisoner(prisoner, "Get back to work!", 1.0f); + guardSay( + prisoner, + "guard.labor.shock_1", + "Warning (1/3): Work or face punishment!" + ); + labor.incrementShockLevel(); + shockCooldownTimer = SHOCK_COOLDOWN_TICKS; + } + case 2 -> { + // Second shock + tighten binds + shockPrisoner(prisoner, "Last warning!", 2.0f); + + IRestrainable cap = KidnappedHelper.getKidnappedState(prisoner); + if (cap != null) { + RestraintApplicator.tightenBind(cap, prisoner); + } + + guardSay( + prisoner, + "guard.labor.shock_2", + "Warning (2/3): Your binds have been tightened!" + ); + labor.incrementShockLevel(); + shockCooldownTimer = SHOCK_COOLDOWN_TICKS; + } + default -> { + // Max punishment - fail task + shockPrisoner(prisoner, "Task failed!", 3.0f); + labor.failTask(currentTime); + + guardSay( + prisoner, + "guard.labor.task_failed", + "Task failed due to inactivity! You will be returned to your cell." + ); + + TiedUpMod.LOGGER.info( + "[GuardMonitorGoal] {} task failed due to inactivity (shock level 3)", + prisoner.getName().getString() + ); + } + } + } + + private void shockPrisoner( + ServerPlayer prisoner, + String message, + float intensity + ) { + if (!prisoner.isAlive()) return; + IRestrainable cap = KidnappedHelper.getKidnappedState(prisoner); + if (cap != null) { + cap.shockKidnapped(message, intensity); + } + } + + private void guardSay( + ServerPlayer prisoner, + String dialogueId, + String fallback + ) { + String text = com.tiedup.remake.dialogue.DialogueBridge.getDialogue( + guard, + prisoner, + dialogueId + ); + if (text == null) { + text = fallback; + } + MessageDispatcher.talkTo(guard, prisoner, text); + } + + private static String getCardinalDirection(BlockPos from, BlockPos to) { + int dx = to.getX() - from.getX(); + int dz = to.getZ() - from.getZ(); + + String ns = ""; + String ew = ""; + if (Math.abs(dz) > 2) ns = dz < 0 ? "north" : "south"; + if (Math.abs(dx) > 2) ew = dx > 0 ? "east" : "west"; + + if (!ns.isEmpty() && !ew.isEmpty()) return ns + "-" + ew; + if (!ns.isEmpty()) return ns; + if (!ew.isEmpty()) return ew; + return "nearby"; + } + + private void triggerEscape( + ServerLevel level, + ServerPlayer prisoner, + String reason + ) { + TiedUpMod.LOGGER.info( + "[GuardMonitorGoal] Escape triggered for {} - reason: {}", + prisoner.getName().getString(), + reason + ); + + // Clear guardId before escape so PrisonerService doesn't double-despawn + PrisonerManager manager = PrisonerManager.get(level); + LaborRecord labor = manager.getLaborRecord(prisoner.getUUID()); + labor.setGuardId(null); + + PrisonerService.get().escape(level, prisoner.getUUID(), reason); + + // Discard the guard entity itself + guard.discard(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/guard/GuardWhipGoal.java b/src/main/java/com/tiedup/remake/entities/ai/guard/GuardWhipGoal.java new file mode 100644 index 0000000..23e72de --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/guard/GuardWhipGoal.java @@ -0,0 +1,119 @@ +package com.tiedup.remake.entities.ai.guard; + +import com.tiedup.remake.entities.EntityLaborGuard; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.EnumSet; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.item.ItemStack; + +/** + * Guard walks up to prisoner and whips them when they're being lazy. + * + * Triggered by GuardMonitorGoal setting needsWhip flag on the guard. + * Uses MOVE+LOOK flags so it interrupts FollowPrisoner to walk TO the prisoner. + */ +public class GuardWhipGoal extends Goal { + + private static final double WHIP_REACH = 2.5; + private static final int MAX_DURATION = 100; // 5 sec to reach prisoner + + private final EntityLaborGuard guard; + private ServerPlayer target; + private int ticksActive; + + public GuardWhipGoal(EntityLaborGuard guard) { + this.guard = guard; + this.setFlags(EnumSet.of(Flag.MOVE, Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (!guard.needsWhip()) return false; + ServerPlayer prisoner = guard.getPrisoner(); + return prisoner != null && prisoner.isAlive(); + } + + @Override + public boolean canContinueToUse() { + return ( + guard.needsWhip() && + target != null && + target.isAlive() && + ticksActive < MAX_DURATION + ); + } + + @Override + public void start() { + this.target = guard.getPrisoner(); + this.ticksActive = 0; + + // Equip whip visually + guard.setItemInHand( + InteractionHand.MAIN_HAND, + new ItemStack(ModItems.WHIP.get()) + ); + } + + @Override + public void tick() { + ticksActive++; + + if (target == null) return; + + // Look at prisoner + guard.getLookControl().setLookAt(target, 30.0f, 30.0f); + + double distance = guard.distanceTo(target); + + if (distance <= WHIP_REACH) { + // Close enough — whip! + performWhip(); + guard.setNeedsWhip(false); + return; + } + + // Navigate to prisoner + if (ticksActive % 10 == 0) { + guard.getNavigation().moveTo(target, 1.2); + } + } + + @Override + public void stop() { + guard.setNeedsWhip(false); + guard.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); + guard.getNavigation().stop(); + target = null; + } + + private void performWhip() { + if (target == null) return; + + // Swing arm animation + guard.swing(InteractionHand.MAIN_HAND); + + // Shock the prisoner + IRestrainable cap = KidnappedHelper.getKidnappedState(target); + if (cap != null) { + cap.shockKidnapped("Get to work!", 1.5f); + } + + // Small knockback toward the guard + target.knockback( + 0.3, + guard.getX() - target.getX(), + guard.getZ() - target.getZ() + ); + + // Guard speaks + guard.guardSay(target, "guard.labor.whip", "Move it!"); + + // Remove whip from hand after use + guard.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/AbstractKidnapperFleeGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/AbstractKidnapperFleeGoal.java new file mode 100644 index 0000000..2a59afd --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/AbstractKidnapperFleeGoal.java @@ -0,0 +1,328 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.ai.StuckDetector; +import com.tiedup.remake.util.KidnapperAIHelper; +import java.util.EnumSet; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.level.Level; + +/** + * Abstract base class for kidnapper flee goals. + * + * Phase 3: Refactoring - Consolidates common flee behavior + * + * Subclasses: + * - KidnapperFleeSafeGoal: Flee without slave + * - KidnapperFleeWithCaptiveGoal: Flee with captive, then release + */ +public abstract class AbstractKidnapperFleeGoal extends Goal { + + protected final EntityKidnapper kidnapper; + + /** Current flee timer */ + protected int fleeTimer; + + /** Starting position for distance calculation */ + protected BlockPos startPos; + + /** Current flee target */ + protected BlockPos fleeTarget; + + /** Stuck detection — lower threshold (40 ticks = 2s) since being stuck while fleeing is very visible */ + protected final StuckDetector stuckDetector = new StuckDetector(40, 2); + + /** + * Create a new flee goal. + * + * @param kidnapper The kidnapper entity + */ + public AbstractKidnapperFleeGoal(EntityKidnapper kidnapper) { + this.kidnapper = kidnapper; + this.fleeTimer = 0; + this.startPos = null; + this.fleeTarget = null; + this.setFlags(EnumSet.of(Goal.Flag.MOVE)); + } + + // ==================== CONFIGURATION (Abstract) ==================== + + /** Get the flee speed. */ + protected abstract double getFleeSpeed(); + + /** Get the maximum flee duration in ticks. */ + protected abstract int getMaxFleeDuration(); + + /** Get the maximum flee distance in blocks. */ + protected abstract double getMaxFleeDistance(); + + /** Get the search range radius for flee targets. */ + protected abstract int getSearchRangeRadius(); + + // ==================== LOGIC HOOKS (Abstract) ==================== + + /** + * Check if the captive condition is met. + * @return true if the goal should activate based on captive state + */ + protected abstract boolean checkCaptiveCondition(); + + /** + * Called when the flee is complete (distance or time reached). + */ + protected abstract void onFleeComplete(); + + // ==================== OPTIONAL HOOKS (Overrideable) ==================== + + /** + * Called at the start of fleeing. + * Override for dialogue or logging. + */ + protected void onStart() { + // Default: do nothing + } + + // ==================== GOAL IMPLEMENTATION ==================== + + @Override + public boolean canUse() { + // Must be in "get out" state + if (!this.kidnapper.isGetOutState()) { + return false; + } + + // Check captive condition (subclass-specific) + if (!checkCaptiveCondition()) { + return false; + } + + // Must not be tied up + if (this.kidnapper.isTiedUp()) { + return false; + } + + return true; + } + + @Override + public boolean canContinueToUse() { + // Stop if no longer in get out state + if (!this.kidnapper.isGetOutState()) { + return false; + } + + // Check captive condition (subclass-specific) + if (!checkCaptiveCondition()) { + return false; + } + + // Stop if tied up + if (this.kidnapper.isTiedUp()) { + return false; + } + + return true; + } + + @Override + public void start() { + this.fleeTimer = 0; + this.startPos = this.kidnapper.blockPosition(); + this.fleeTarget = null; + this.stuckDetector.reset(); + + // Hook for subclasses + onStart(); + + selectFleeTarget(); + } + + @Override + public void stop() { + this.kidnapper.getNavigation().stop(); + this.fleeTimer = 0; + this.startPos = null; + this.fleeTarget = null; + this.stuckDetector.reset(); + } + + @Override + public void tick() { + this.fleeTimer++; + + // Check if reached safe distance or timeout + double distanceFromStart = this.kidnapper.blockPosition().distSqr( + this.startPos + ); + double maxDist = getMaxFleeDistance(); + double maxDistSq = maxDist * maxDist; + + if ( + this.fleeTimer >= getMaxFleeDuration() || + distanceFromStart >= maxDistSq + ) { + onFleeComplete(); + return; + } + + // Continue fleeing - recalculate path frequently + if (this.fleeTarget == null || reachedFleeTarget()) { + selectFleeTarget(); + this.stuckDetector.reset(); + } + + // Update stuck detection + this.stuckDetector.update(this.kidnapper); + + if (this.stuckDetector.isStuck()) { + if (this.stuckDetector.shouldTeleport()) { + // Teleport to a safe position away from current location + if (this.kidnapper.level() instanceof ServerLevel serverLevel) { + BlockPos safePos = KidnapperAIHelper.findSafePosition( + serverLevel, + this.kidnapper.blockPosition(), + getSearchRangeRadius(), + serverLevel.getRandom() + ); + if (safePos != null) { + this.kidnapper.teleportTo( + safePos.getX() + 0.5, + safePos.getY(), + safePos.getZ() + 0.5 + ); + // Also teleport captive if leashed + com.tiedup.remake.state.IBondageState captive = + this.kidnapper.getCaptive(); + if ( + captive != null && + captive.asLivingEntity().isAlive() + ) { + captive + .asLivingEntity() + .teleportTo( + safePos.getX() + 0.5, + safePos.getY(), + safePos.getZ() + 0.5 + ); + } + TiedUpMod.LOGGER.debug( + "[AbstractKidnapperFleeGoal] {} teleported to {} to unblock flee", + this.kidnapper.getNpcName(), + safePos.toShortString() + ); + } + } + this.stuckDetector.reset(); + } else if ( + this.kidnapper.level() instanceof ServerLevel serverLevel + ) { + BlockPos detour = this.stuckDetector.tryDetour( + serverLevel, + this.kidnapper.blockPosition(), + 5 + ); + if (detour != null) { + this.kidnapper.getNavigation().moveTo( + detour.getX() + 0.5, + detour.getY(), + detour.getZ() + 0.5, + getFleeSpeed() + ); + } + } + return; + } + + // Skip normal navigation if in detour + if (this.stuckDetector.isInDetour()) { + return; + } + + // Move to flee target - recalculate every few ticks for smooth movement + if ( + this.fleeTarget != null && + (this.fleeTimer % 3 == 0 || + !this.kidnapper.getNavigation().isInProgress()) + ) { + boolean pathFound = this.kidnapper.getNavigation().moveTo( + this.fleeTarget.getX() + 0.5, + this.fleeTarget.getY(), + this.fleeTarget.getZ() + 0.5, + getFleeSpeed() + ); + if (!pathFound) { + this.stuckDetector.onPathFailed(); + } + } + } + + // ==================== HELPER METHODS ==================== + + /** + * Select a random flee target away from start position. + */ + protected void selectFleeTarget() { + Level level = this.kidnapper.level(); + int range = getSearchRangeRadius(); + + // Try to find a valid flee point + for (int attempts = 0; attempts < 10; attempts++) { + // Random direction, biased away from start + int offsetX = this.kidnapper.getRandom().nextInt(range * 2) - range; + int offsetZ = this.kidnapper.getRandom().nextInt(range * 2) - range; + + // Bias away from start position + if (this.startPos != null) { + double dirX = this.kidnapper.getX() - this.startPos.getX(); + double dirZ = this.kidnapper.getZ() - this.startPos.getZ(); + double len = Math.sqrt(dirX * dirX + dirZ * dirZ); + if (len > 0.1) { + offsetX += (int) ((dirX / len) * range); + offsetZ += (int) ((dirZ / len) * range); + } + } + + int targetX = (int) this.kidnapper.getX() + offsetX; + int targetZ = (int) this.kidnapper.getZ() + offsetZ; + int targetY = (int) this.kidnapper.getY(); + + BlockPos testPos = new BlockPos(targetX, targetY, targetZ); + + // Find ground level + for (int yOffset = -5; yOffset <= 5; yOffset++) { + BlockPos checkPos = testPos.offset(0, yOffset, 0); + + if (isValidFleePoint(level, checkPos)) { + this.fleeTarget = checkPos; + return; + } + } + } + } + + /** + * Check if reached current flee target. + */ + protected boolean reachedFleeTarget() { + if (this.fleeTarget == null) return true; + + double distSq = this.kidnapper.distanceToSqr( + this.fleeTarget.getX() + 0.5, + this.fleeTarget.getY(), + this.fleeTarget.getZ() + 0.5 + ); + + return distSq < 4.0; + } + + /** + * Check if a position is valid for fleeing. + * Delegates to KidnapperAIHelper for consistent ground position checks. + */ + protected boolean isValidFleePoint(Level level, BlockPos pos) { + return KidnapperAIHelper.isValidGroundPosition(level, pos); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperAlertGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperAlertGoal.java new file mode 100644 index 0000000..74bb596 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperAlertGoal.java @@ -0,0 +1,264 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.EnumSet; +import java.util.UUID; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.Goal; + +/** + * AI Goal for EntityKidnapper to actively search for an escaped prisoner. + * + * Phase 3: Kidnapper Revamp - Advanced AI + * + * This goal: + * 1. Activates when kidnapper is in ALERT state with a valid alertTarget + * 2. Pursues the escapee at increased speed (1.2x) + * 3. Broadcasts position to other kidnappers on start + * 4. Times out after 60 seconds (returns to GUARD/PATROL) + * 5. On successful capture, transitions to PUNISH state + */ +public class KidnapperAlertGoal extends Goal { + + private final EntityKidnapper kidnapper; + + /** Speed multiplier when in ALERT state */ + private static final double ALERT_SPEED_MODIFIER = 1.2; + + /** Maximum distance to pursue before giving up */ + private static final double MAX_PURSUIT_DISTANCE = 50.0; + + /** Capture distance */ + private static final double CAPTURE_DISTANCE = 2.5; + + /** Alert timeout in ticks (60 seconds) */ + private static final int ALERT_TIMEOUT = 1200; + + /** Dialogue radius */ + private static final int DIALOGUE_RADIUS = 20; + + /** Current target being pursued */ + private LivingEntity target; + + /** Ticks spent in alert mode */ + private int alertTicks; + + /** Ticks until next path recalculation */ + private int pathRecalcTicks; + + public KidnapperAlertGoal(EntityKidnapper kidnapper) { + this.kidnapper = kidnapper; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Must be in ALERT state + if (this.kidnapper.getCurrentState() != KidnapperState.ALERT) { + return false; + } + + // Must have an alert target + LivingEntity alertTarget = this.kidnapper.getAlertTarget(); + if (alertTarget == null || !alertTarget.isAlive()) { + return false; + } + + // Must not be tied up + if (this.kidnapper.isTiedUp()) { + return false; + } + + // Must not already have a captive + if (this.kidnapper.hasCaptives()) { + return false; + } + + this.target = alertTarget; + return true; + } + + @Override + public boolean canContinueToUse() { + if (this.target == null || !this.target.isAlive()) { + return false; + } + + if (this.kidnapper.isTiedUp()) { + return false; + } + + // Stop if we captured someone + if (this.kidnapper.hasCaptives()) { + return false; + } + + // Stop if alert timed out + if (this.alertTicks >= ALERT_TIMEOUT) { + return false; + } + + // Stop if target is too far + if (this.kidnapper.distanceTo(this.target) > MAX_PURSUIT_DISTANCE) { + return false; + } + + // Stop if target was captured by someone else + IBondageState state = KidnappedHelper.getKidnappedState(this.target); + if (state != null && state.isCaptive()) { + return false; + } + + // Continue if still in ALERT state + return this.kidnapper.getCurrentState() == KidnapperState.ALERT; + } + + @Override + public void start() { + this.alertTicks = 0; + this.pathRecalcTicks = 0; + + // Announce pursuit + this.kidnapper.talkToPlayersInRadius( + DialogueCategory.CAPTURE_CHASE, + DIALOGUE_RADIUS + ); + + // Broadcast to other kidnappers + this.kidnapper.broadcastAlert(this.target); + + TiedUpMod.LOGGER.info( + "[KidnapperAlertGoal] {} started searching for {}", + this.kidnapper.getNpcName(), + this.target.getName().getString() + ); + } + + @Override + public void stop() { + // Clear alert target if we're giving up (timeout or target lost) + if (!this.kidnapper.hasCaptives()) { + this.kidnapper.setAlertTarget(null); + + // Return to IDLE state - will be picked up by GUARD or PATROL + if (this.kidnapper.getCurrentState() == KidnapperState.ALERT) { + this.kidnapper.setCurrentState(KidnapperState.IDLE); + } + + if (this.alertTicks >= ALERT_TIMEOUT) { + TiedUpMod.LOGGER.info( + "[KidnapperAlertGoal] {} gave up searching after timeout", + this.kidnapper.getNpcName() + ); + } + } + + this.target = null; + this.alertTicks = 0; + this.kidnapper.getNavigation().stop(); + } + + @Override + public void tick() { + if (this.target == null) { + return; + } + + this.alertTicks++; + this.pathRecalcTicks++; + + // Always look at target + this.kidnapper.getLookControl().setLookAt(this.target, 30.0F, 30.0F); + + double distance = this.kidnapper.distanceTo(this.target); + + // Recalculate path periodically (every 10 ticks for responsive pursuit) + if (this.pathRecalcTicks >= 10) { + this.pathRecalcTicks = 0; + this.kidnapper.getNavigation().moveTo( + this.target, + ALERT_SPEED_MODIFIER + ); + } + + // Attempt capture if close enough + if (distance <= CAPTURE_DISTANCE) { + attemptCapture(); + } + + // Periodic re-broadcast every 10 seconds to keep other kidnappers updated + if (this.alertTicks % 200 == 0 && this.alertTicks > 0) { + this.kidnapper.broadcastAlert(this.target); + } + } + + /** + * Attempt to capture the target. + */ + private void attemptCapture() { + // First check if target is still suitable (not protected by grace/labor) + if (!this.kidnapper.isSuitableTarget(this.target)) { + TiedUpMod.LOGGER.debug( + "[KidnapperAlertGoal] {} - target {} is protected, aborting capture", + this.kidnapper.getNpcName(), + this.target.getName().getString() + ); + this.kidnapper.setAlertTarget(null); + this.kidnapper.setCurrentState(KidnapperState.IDLE); + return; + } + + IBondageState state = KidnappedHelper.getKidnappedState(this.target); + if (state == null) { + return; + } + + // Check if target is still tied up + if (state.isTiedUp()) { + // Quick recapture via PrisonerService (atomic: PrisonerManager + leash) + boolean recaptured = false; + if ( + this.kidnapper.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel + ) { + recaptured = + com.tiedup.remake.prison.service.PrisonerService.get().capture( + serverLevel, + this.target, + this.kidnapper + ); + } + if (recaptured) { + this.kidnapper.talkToPlayersInRadius( + DialogueCategory.CAPTURE_ENSLAVED, + DIALOGUE_RADIUS + ); + + TiedUpMod.LOGGER.info( + "[KidnapperAlertGoal] {} recaptured {}!", + this.kidnapper.getNpcName(), + this.target.getName().getString() + ); + + // Transition to PUNISH state + this.kidnapper.setCurrentState(KidnapperState.PUNISH); + this.kidnapper.setAlertTarget(null); + } + } else { + // Target is no longer tied - set as hunt target for normal capture + this.kidnapper.setTarget(this.target); + this.kidnapper.setAlertTarget(null); + this.kidnapper.setCurrentState(KidnapperState.CAPTURE); + } + } + + @Override + public boolean requiresUpdateEveryTick() { + return false; // Every other tick is fine for pursuit + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperArcherRangedGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperArcherRangedGoal.java new file mode 100644 index 0000000..e037e28 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperArcherRangedGoal.java @@ -0,0 +1,481 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory; +import com.tiedup.remake.entities.EntityKidnapperArcher; +import com.tiedup.remake.entities.EntityRopeArrow; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.EnumSet; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.phys.Vec3; + +/** + * AI Goal for EntityKidnapperArcher to attack targets from range. + * + * Phase 18: Archer Kidnappers + * + * This goal: + * 1. Finds targets within bow range (10-25 blocks) + * 2. Maintains distance from target (strafes if too close) + * 3. Shoots rope arrows to tie up target + * 4. Stops when target is tied (KidnapperCaptureGoal takes over) + * + * Based on Minecraft's RangedBowAttackGoal but adapted for rope arrows. + */ +public class KidnapperArcherRangedGoal extends Goal { + + private final EntityKidnapperArcher archer; + + /** Minimum distance to target (too close = strafe away) */ + private static final double MIN_ATTACK_DISTANCE = 8.0D; + + /** Maximum distance to target (too far = move closer) */ + private static final double MAX_ATTACK_DISTANCE = 25.0D; + + /** Ideal distance to maintain */ + private static final double IDEAL_DISTANCE = 15.0D; + + /** Ticks between shots */ + private static final int ATTACK_COOLDOWN = 20; // 1 second (reduced from 2s) + + /** Time to aim before shooting */ + private static final int AIM_TIME = 20; // 1 second + + /** Current attack timer */ + private int attackTimer; + + /** Combat duration tracker for timeout */ + private int combatTicks; + + /** Strafing direction (1 = right, -1 = left) */ + private int strafeDirection; + + /** Time until strafe direction changes */ + private int strafeTimer; + + /** Whether we're currently backing away */ + private boolean backingAway; + + /** Speed multiplier when strafing */ + private static final double STRAFE_SPEED = 0.8D; + + /** Speed multiplier when repositioning */ + private static final double REPOSITION_SPEED = 1.0D; + + /** Current target */ + private LivingEntity target; + + /** Dialogue cooldown */ + private int dialogueCooldown; + private static final int DIALOGUE_INTERVAL = 200; // 10 seconds + + /** Maximum combat duration before giving up (60 seconds) */ + private static final int MAX_COMBAT_TICKS = 1200; + + /** Whether we're currently aiming */ + private boolean isAiming; + + /** Ticks without line of sight (for canContinueToUse timeout) */ + private int noLosTimer; + + /** + * Create a new archer ranged attack goal. + * + * @param archer The archer kidnapper + */ + public KidnapperArcherRangedGoal(EntityKidnapperArcher archer) { + this.archer = archer; + this.attackTimer = 0; + this.strafeDirection = 1; + this.strafeTimer = 0; + this.backingAway = false; + this.dialogueCooldown = 0; + this.isAiming = false; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Must have a target + LivingEntity currentTarget = this.archer.getTarget(); + if (currentTarget == null || !currentTarget.isAlive()) { + return false; + } + + // Can't attack if tied up + if (this.archer.isTiedUp()) { + return false; + } + + // Can't attack if already have a captive + if (this.archer.hasCaptives()) { + return false; + } + + // Check if target is already tied (let capture goal handle it) + IBondageState targetState = KidnappedHelper.getKidnappedState( + currentTarget + ); + if (targetState != null && targetState.isTiedUp()) { + return false; + } + + // Must be in valid range + double distSq = this.archer.distanceToSqr(currentTarget); + if (distSq > MAX_ATTACK_DISTANCE * MAX_ATTACK_DISTANCE) { + return false; + } + + // Reject targets too far vertically (prevents targeting through floors) + double yDiff = Math.abs(this.archer.getY() - currentTarget.getY()); + if (yDiff > 10.0) { + return false; + } + + // Must have line of sight (prevents shooting through walls/ground) + if (!this.archer.getSensing().hasLineOfSight(currentTarget)) { + return false; + } + + this.target = currentTarget; + return true; + } + + @Override + public boolean canContinueToUse() { + // Stop if no target + if (this.target == null || !this.target.isAlive()) { + return false; + } + + // Stop if tied up + if (this.archer.isTiedUp()) { + return false; + } + + // Stop if got a captive + if (this.archer.hasCaptives()) { + return false; + } + + // Stop if target is now tied (success! capture goal takes over) + IBondageState targetState = KidnappedHelper.getKidnappedState( + this.target + ); + if (targetState != null && targetState.isTiedUp()) { + TiedUpMod.LOGGER.info( + "[KidnapperArcherRangedGoal] {} tied up {} with arrow, switching to capture", + this.archer.getNpcName(), + this.target.getName().getString() + ); + return false; + } + + // Stop if lost line of sight for too long (prevents shooting through terrain) + if (!this.archer.getSensing().hasLineOfSight(this.target)) { + this.noLosTimer++; + if (this.noLosTimer > 60) { + // 3 seconds without LOS + return false; + } + } else { + this.noLosTimer = 0; + } + + return true; + } + + @Override + public void start() { + this.attackTimer = 0; + this.combatTicks = 0; + this.noLosTimer = 0; + this.strafeTimer = 0; + this.strafeDirection = this.archer.getRandom().nextBoolean() ? 1 : -1; + this.backingAway = false; + + // Enter ranged mode - enables bow animation + this.archer.setInRangedMode(true); + + TiedUpMod.LOGGER.debug( + "[KidnapperArcherRangedGoal] {} starting ranged attack on {}", + this.archer.getNpcName(), + this.target.getName().getString() + ); + } + + @Override + public void stop() { + // Save target reference before anything else + LivingEntity savedTarget = this.target; + + TiedUpMod.LOGGER.debug( + "[KidnapperArcherRangedGoal] {} stopping ranged goal, target was: {}", + this.archer.getNpcName(), + savedTarget != null ? savedTarget.getName().getString() : "null" + ); + + // Check if target was successfully tied - if so, we need to restore the archer's target + // (it may have been cleared by another goal like FindTargetGoal) + if (savedTarget != null && savedTarget.isAlive()) { + IBondageState targetState = KidnappedHelper.getKidnappedState( + savedTarget + ); + if (targetState != null && targetState.isTiedUp()) { + // Target was successfully tied! Make sure archer has target for CaptureGoal + if (this.archer.getTarget() == null) { + TiedUpMod.LOGGER.debug( + "[KidnapperArcherRangedGoal] {} restoring target {} for CaptureGoal", + this.archer.getNpcName(), + savedTarget.getName().getString() + ); + this.archer.setTarget(savedTarget); + } + } + } + + this.target = null; + this.attackTimer = 0; + + // Always stop aiming animation and ranged mode when goal stops + this.isAiming = false; + this.archer.setAiming(false); + this.archer.setInRangedMode(false); + + TiedUpMod.LOGGER.debug( + "[KidnapperArcherRangedGoal] {} archer target after stop: {}", + this.archer.getNpcName(), + this.archer.getTarget() != null + ? this.archer.getTarget().getName().getString() + : "null" + ); + } + + @Override + public void tick() { + if (this.target == null) return; + + // TIMEOUT: Give up combat after MAX_COMBAT_TICKS to prevent shooting walls forever + this.combatTicks++; + if (this.combatTicks > MAX_COMBAT_TICKS) { + TiedUpMod.LOGGER.info( + "[KidnapperArcherRangedGoal] {} combat timeout ({} ticks), losing interest in {}", + this.archer.getNpcName(), + MAX_COMBAT_TICKS, + this.target.getName().getString() + ); + this.archer.setTarget(null); + return; + } + + // Always look at target with high rotation speeds for better tracking + // Parameters: maxYawSpeed (horizontal), maxPitchSpeed (vertical) + // Higher values = faster tracking, more responsive aiming + this.archer.getLookControl().setLookAt(this.target, 180.0F, 90.0F); + + double distSq = this.archer.distanceToSqr(this.target); + double dist = Math.sqrt(distSq); + + // Movement logic + handleMovement(dist); + + // Attack logic + handleAttack(dist); + + // Dialogue + if (this.dialogueCooldown > 0) { + this.dialogueCooldown--; + } + } + + /** + * Handle movement: maintain ideal distance, strafe to avoid attacks. + */ + private void handleMovement(double dist) { + // Too close - back away + if (dist < MIN_ATTACK_DISTANCE) { + this.backingAway = true; + Vec3 awayDir = this.archer.position() + .subtract(this.target.position()) + .normalize(); + double targetX = this.archer.getX() + awayDir.x * 5; + double targetZ = this.archer.getZ() + awayDir.z * 5; + this.archer.getNavigation().moveTo( + targetX, + this.archer.getY(), + targetZ, + REPOSITION_SPEED + ); + return; + } + + this.backingAway = false; + + // Too far - move closer + if (dist > MAX_ATTACK_DISTANCE) { + this.archer.getNavigation().moveTo(this.target, REPOSITION_SPEED); + return; + } + + // In range - strafe + this.strafeTimer--; + if (this.strafeTimer <= 0) { + // Change strafe direction randomly + this.strafeDirection = this.archer.getRandom().nextBoolean() + ? 1 + : -1; + this.strafeTimer = 40 + this.archer.getRandom().nextInt(40); + } + + // Strafe perpendicular to target + Vec3 toTarget = this.target.position() + .subtract(this.archer.position()) + .normalize(); + Vec3 strafeDir = new Vec3( + -toTarget.z * this.strafeDirection, + 0, + toTarget.x * this.strafeDirection + ); + + double strafeX = this.archer.getX() + strafeDir.x * 3; + double strafeZ = this.archer.getZ() + strafeDir.z * 3; + + // Also adjust distance slightly + if (dist < IDEAL_DISTANCE - 2) { + strafeX -= toTarget.x * 2; + strafeZ -= toTarget.z * 2; + } else if (dist > IDEAL_DISTANCE + 2) { + strafeX += toTarget.x * 2; + strafeZ += toTarget.z * 2; + } + + this.archer.getNavigation().moveTo( + strafeX, + this.archer.getY(), + strafeZ, + STRAFE_SPEED + ); + } + + /** + * Handle attack: aim and shoot rope arrows. + */ + private void handleAttack(double dist) { + // Don't attack while backing away + if (this.backingAway) { + this.attackTimer = 0; + // Stop aiming if backing away + if (this.isAiming) { + this.isAiming = false; + this.archer.setAiming(false); + } + return; + } + + // Must be in range + if (dist < MIN_ATTACK_DISTANCE || dist > MAX_ATTACK_DISTANCE) { + // Stop aiming if out of range + if (this.isAiming) { + this.isAiming = false; + this.archer.setAiming(false); + } + return; + } + + this.attackTimer++; + + // Start aiming at the beginning + if (this.attackTimer == 1 && !this.isAiming) { + this.isAiming = true; + this.archer.setAiming(true); + } + + // While aiming, face the target directly (body rotation) + if (this.isAiming && this.target != null) { + double dx = this.target.getX() - this.archer.getX(); + double dz = this.target.getZ() - this.archer.getZ(); + float targetYaw = + (float) (Math.atan2(dz, dx) * (180.0 / Math.PI)) - 90.0F; + this.archer.setYRot(targetYaw); + this.archer.yBodyRot = targetYaw; + } + + // Shoot when aim time is reached + if (this.attackTimer == AIM_TIME) { + shootArrow(); + + // Stop aiming after shooting + this.isAiming = false; + this.archer.setAiming(false); + + // Dialogue on first shot + if (this.dialogueCooldown <= 0) { + this.archer.talkToPlayersInRadius( + DialogueCategory.CAPTURE_START, + 20 + ); + this.dialogueCooldown = DIALOGUE_INTERVAL; + } + } + + // Reset timer after full cycle (aim + cooldown) + if (this.attackTimer >= AIM_TIME + ATTACK_COOLDOWN) { + this.attackTimer = 0; + } + } + + /** + * Shoot a rope arrow at the target. + * Only executes on server side (where AI goals run). + */ + private void shootArrow() { + if (this.target == null) return; + + // Safety check: AI goals should only run server-side, but verify + if (this.archer.level().isClientSide) { + return; + } + + // Create rope arrow + EntityRopeArrow arrow = new EntityRopeArrow( + this.archer.level(), + this.archer + ); + + // Calculate trajectory + double dx = this.target.getX() - this.archer.getX(); + double dy = this.target.getY(0.33) - arrow.getY(); + double dz = this.target.getZ() - this.archer.getZ(); + double horizontalDist = Math.sqrt(dx * dx + dz * dz); + + // Shoot with some arc for distance + float velocity = 1.6F; + float inaccuracy = 2.0F; // Some spread for difficulty + + arrow.shoot(dx, dy + horizontalDist * 0.2, dz, velocity, inaccuracy); + + // Play sound + this.archer.level().playSound( + null, + this.archer.getX(), + this.archer.getY(), + this.archer.getZ(), + SoundEvents.ARROW_SHOOT, + this.archer.getSoundSource(), + 1.0F, + 1.0F / (this.archer.getRandom().nextFloat() * 0.4F + 0.8F) + ); + + // Spawn arrow + this.archer.level().addFreshEntity(arrow); + + TiedUpMod.LOGGER.debug( + "[KidnapperArcherRangedGoal] {} shot arrow at {}", + this.archer.getNpcName(), + this.target.getName().getString() + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperBringToCellGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperBringToCellGoal.java new file mode 100644 index 0000000..0207368 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperBringToCellGoal.java @@ -0,0 +1,1175 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.cells.CampOwnership; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.cells.ConfiscatedInventoryRegistry; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory; +import com.tiedup.remake.entities.AbstractTiedUpNpc; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.EntityKidnapper.CaptivePriority; +import com.tiedup.remake.entities.ai.StuckDetector; +import com.tiedup.remake.entities.ai.WaypointNavigator; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerRecord; +import com.tiedup.remake.prison.PrisonerState; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.util.KidnapperAIHelper; +import com.tiedup.remake.util.teleport.TeleportHelper; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; + +/** + * AI Goal for EntityKidnapper to bring captured players to nearby cells. + * + * Phase: Kidnapper Revamp - Cell System + * + * This goal: + * 1. Finds the nearest cell (BlockMarker with cellId) within range + * 2. Walks the kidnapper towards the cell + * 3. When close enough, teleports the prisoner to the cell spawn point + * 4. Registers the prisoner in CellRegistry + */ +public class KidnapperBringToCellGoal extends Goal { + + private final EntityKidnapper kidnapper; + + /** Maximum distance to search for cells */ + private static final int SEARCH_RADIUS = 64; + + /** Distance at which to teleport prisoner to cell */ + private static final double TELEPORT_DISTANCE = 5.0; + + /** Dialogue radius */ + private static final int DIALOGUE_RADIUS = 20; + + /** Target cell data */ + @Nullable + private CellDataV2 targetCell; + + /** Target cell position */ + @Nullable + private BlockPos targetPos; + + /** Waypoint navigator for complex paths */ + @Nullable + private WaypointNavigator waypointNav; + + /** Whether we've announced intention */ + private boolean hasAnnounced; + + /** Ticks since we started moving */ + private int moveTicks; + + /** Max ticks before giving up on pathfinding */ + private static final int MAX_MOVE_TICKS = 600; // 30 seconds + + /** Max horizontal distance (XZ) to consider "at delivery point" */ + private static final double DELIVERY_XZ_DISTANCE = 4.0; // Allow kidnapper to be 3-4 blocks away + + /** Max vertical distance (Y) to consider "at delivery point" */ + private static final double DELIVERY_Y_DISTANCE = 5.0; // Allow up to 4 blocks vertical difference + + /** Consecutive path failures before forced teleport (waypoint mode) */ + private static final int PATH_FAIL_THRESHOLD = 5; + + /** Counter for consecutive path failures */ + private int pathFailCounter = 0; + + /** Stuck detection for vanilla navigation mode */ + private final StuckDetector stuckDetector = new StuckDetector(60, 3); + + public KidnapperBringToCellGoal(EntityKidnapper kidnapper) { + this.kidnapper = kidnapper; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + /** Tick counter for periodic diagnostic logging */ + private int diagCounter = 0; + + @Override + public boolean canUse() { + // Periodic diagnostic: log which check blocks every 10 seconds + boolean diag = (this.kidnapper.hasCaptives() && + ++diagCounter % 200 == 0); + + // Don't interrupt dogwalk - WalkPrisonerGoal uses captive mechanism + // and BringToCellGoal (P5) would preempt it (P7), causing instant release + if (this.kidnapper.isDogwalking()) { + if (diag) TiedUpMod.LOGGER.warn( + "[KidnapperBringToCellGoal] {} BLOCKED: isDogwalking=true", + this.kidnapper.getNpcName() + ); + return false; + } + + // Common transport preconditions + if (!KidnapperAIHelper.canKidnapperTransport(this.kidnapper)) { + if (diag) { + IRestrainable captive = this.kidnapper.getCaptive(); + TiedUpMod.LOGGER.warn( + "[KidnapperBringToCellGoal] {} BLOCKED: canKidnapperTransport=false " + + "(tiedUp={}, hasCaptives={}, getOut={}, forSell={}, waitingJob={})", + this.kidnapper.getNpcName(), + this.kidnapper.isTiedUp(), + this.kidnapper.hasCaptives(), + this.kidnapper.isGetOutState(), + captive != null ? captive.isForSell() : "noCaptive", + this.kidnapper.isWaitingForJobToBeCompleted() + ); + } + return false; + } + + // Check if associated camp is still alive before starting + UUID campId = this.kidnapper.getAssociatedStructure(); + if ( + campId != null && + this.kidnapper.level() instanceof ServerLevel serverLevel + ) { + CampOwnership ownership = CampOwnership.get(serverLevel); + if (!ownership.isCampAlive(campId)) { + CampOwnership.CampData campData = ownership.getCamp(campId); + if (campData != null) { + // Camp was registered but trader was killed — block normally + if (diag) TiedUpMod.LOGGER.warn( + "[KidnapperBringToCellGoal] {} BLOCKED: camp {} is dead (trader killed)", + this.kidnapper.getNpcName(), + campId.toString().substring(0, 8) + ); + return false; + } + // Camp was NEVER registered — orphaned grid ID from structure + // boundary split. Auto-relink to nearest alive camp. + CampOwnership.CampData nearestCamp = + ownership.findNearestAliveCamp( + this.kidnapper.blockPosition(), + 100 + ); + if (nearestCamp != null) { + UUID newCampId = nearestCamp.getCampId(); + this.kidnapper.setAssociatedStructure(newCampId); + ownership.linkKidnapperToCamp( + newCampId, + this.kidnapper.getUUID() + ); + TiedUpMod.LOGGER.info( + "[KidnapperBringToCellGoal] {} auto-relinked from orphan camp {} to alive camp {}", + this.kidnapper.getNpcName(), + campId.toString().substring(0, 8), + newCampId.toString().substring(0, 8) + ); + // Continue with the new camp — don't return false + } else { + if (diag) TiedUpMod.LOGGER.warn( + "[KidnapperBringToCellGoal] {} BLOCKED: orphan camp {}, no alive camp nearby", + this.kidnapper.getNpcName(), + campId.toString().substring(0, 8) + ); + return false; + } + } + } + + // Find nearest cell (prioritizes camp cells if kidnapper is linked to a camp) + this.targetCell = findNearestCell(); + if (this.targetCell == null) { + if (diag) TiedUpMod.LOGGER.warn( + "[KidnapperBringToCellGoal] {} BLOCKED: findNearestCell=null (campId={})", + this.kidnapper.getNpcName(), + campId != null ? campId.toString().substring(0, 8) : "wild" + ); + return false; + } + + // Use DELIVERY point if available, otherwise fall back to spawn point + this.targetPos = getDeliveryOrSpawnPoint(this.targetCell); + + if (diag) TiedUpMod.LOGGER.warn( + "[KidnapperBringToCellGoal] {} canUse=TRUE, cell={}, target={}", + this.kidnapper.getNpcName(), + this.targetCell.getId().toString().substring(0, 8), + this.targetPos.toShortString() + ); + return true; + } + + /** + * Get the delivery point for a cell, or fall back to spawn point. + * DELIVERY markers are used for prisoner drop-off/pickup navigation. + * + * FIX: Uses smart floor detection instead of blindly calling .above(). + * This handles both floor-placed markers and air-placed markers correctly. + */ + private BlockPos getDeliveryOrSpawnPoint(CellDataV2 cell) { + return KidnapperAIHelper.getDeliveryOrSpawnPoint( + cell, + this.kidnapper.level() + ); + } + + @Override + public boolean canContinueToUse() { + if (!this.kidnapper.hasCaptives()) { + return false; + } + + if (this.kidnapper.isTiedUp()) { + return false; + } + + if (this.targetCell == null || this.targetPos == null) { + return false; + } + + // Give up after max ticks + if (this.moveTicks > MAX_MOVE_TICKS) { + return false; + } + + // Check if associated camp is still alive + UUID campId = this.kidnapper.getAssociatedStructure(); + if ( + campId != null && + this.kidnapper.level() instanceof ServerLevel serverLevel + ) { + CampOwnership ownership = CampOwnership.get(serverLevel); + if (!ownership.isCampAlive(campId)) { + CampOwnership.CampData campData = ownership.getCamp(campId); + if (campData != null) { + // Camp was registered but trader killed — free captive + TiedUpMod.LOGGER.debug( + "[KidnapperBringToCellGoal] {} - camp {} is dead, freeing captive and aborting", + this.kidnapper.getNpcName(), + campId.toString().substring(0, 8) + ); + IRestrainable captive = this.kidnapper.getCaptive(); + if (captive != null) { + ItemCollar.runWithSuppressedAlert(() -> + captive.free(false) + ); + this.kidnapper.removeCaptive(captive, false); + } + return false; + } + // Camp never registered (orphan) — canUse() should have + // auto-relinked, but if not, don't free the captive + } + } + + // Check captive is still captive and leashed to this kidnapper + IRestrainable captive = this.kidnapper.getCaptive(); + if (captive == null) { + // Captive reference lost (leash broke) - onCaptiveReleased() handles alerting + return false; + } + if (!captive.isCaptive()) { + // Captive freed themselves while we still have reference - record escape + this.kidnapper.onCaptiveEscaped(captive); + this.kidnapper.removeCaptive(captive, false); // Clear reference to stop spam + return false; + } + + return true; + } + + @Override + public void start() { + this.hasAnnounced = false; + this.moveTicks = 0; + this.pathFailCounter = 0; + this.stuckDetector.reset(); + + // Build waypoint path if cell has waypoints + if (targetCell != null && !targetCell.getPathWaypoints().isEmpty()) { + List fullPath = new ArrayList<>( + targetCell.getPathWaypoints() + ); + fullPath.add(targetPos); // Add DELIVERY as final waypoint + this.waypointNav = new WaypointNavigator(kidnapper, fullPath, 1.0); + TiedUpMod.LOGGER.debug( + "[KidnapperBringToCellGoal] {} using {} waypoints for navigation to cell", + this.kidnapper.getNpcName(), + fullPath.size() + ); + } else { + this.waypointNav = null; // Fallback to vanilla pathfinding + } + + TiedUpMod.LOGGER.debug( + "[KidnapperBringToCellGoal] {} starting cell delivery to {}", + this.kidnapper.getNpcName(), + this.targetPos + ); + } + + @Override + public void stop() { + // Cancel cell reservation if we're giving up + if ( + this.targetCell != null && + this.kidnapper.level() instanceof ServerLevel serverLevel + ) { + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + registry.cancelReservation( + this.targetCell.getId(), + this.kidnapper.getUUID() + ); + } + + this.targetCell = null; + this.targetPos = null; + this.waypointNav = null; + this.hasAnnounced = false; + this.moveTicks = 0; + this.pathFailCounter = 0; + this.stuckDetector.reset(); + + // Stop navigation + this.kidnapper.getNavigation().stop(); + } + + @Override + public void tick() { + IRestrainable captive = this.kidnapper.getCaptive(); + if ( + captive == null || this.targetPos == null || this.targetCell == null + ) { + return; + } + + // Verify captive is still leashed to this kidnapper + if (!captive.isCaptive()) { + // Captive freed themselves - record escape and alert + this.kidnapper.onCaptiveEscaped(captive); + this.kidnapper.broadcastAlert(captive.asLivingEntity()); + this.kidnapper.removeCaptive(captive, false); // Clear reference to stop spam + return; + } + + this.moveTicks++; + + // Announce intention once + if (!this.hasAnnounced) { + announceIntention(captive); + this.hasAnnounced = true; + } + + // WAYPOINT NAVIGATION MODE + if (waypointNav != null && waypointNav.hasWaypoints()) { + waypointNav.tick(); + + if (waypointNav.isComplete()) { + // Reached final destination (delivery point) + executeCellDelivery(captive); + return; + } + + // Navigate to current waypoint + if (this.moveTicks % 20 == 0) { + boolean pathFound = waypointNav.navigateToCurrentWaypoint(); + if (!pathFound) { + this.pathFailCounter++; + this.stuckDetector.onPathFailed(); + + BlockPos wp = waypointNav.getCurrentWaypoint(); + if (this.pathFailCounter >= PATH_FAIL_THRESHOLD) { + // Teleport to current waypoint if stuck (waypoints are safe passage points) + if (wp != null) { + TiedUpMod.LOGGER.warn( + "[KidnapperBringToCellGoal] {} stuck on waypoint {}/{}, teleporting to unblock", + this.kidnapper.getNpcName(), + waypointNav.getCurrentIndex() + 1, + waypointNav.getTotalWaypoints() + ); + this.kidnapper.teleportTo( + wp.getX() + 0.5, + wp.getY(), + wp.getZ() + 0.5 + ); + } + this.pathFailCounter = 0; + } + } else { + this.pathFailCounter = 0; + } + } + + // Look at current waypoint + BlockPos currentWp = waypointNav.getCurrentWaypoint(); + if (currentWp != null && !this.kidnapper.getNavigation().isDone()) { + this.kidnapper.getLookControl().setLookAt( + currentWp.getX() + 0.5, + currentWp.getY() + 1, + currentWp.getZ() + 0.5 + ); + } + return; + } + + // VANILLA NAVIGATION MODE (no waypoints defined) + // Check if at delivery point using separate XZ/Y distance checks + if (isAtDeliveryPoint(this.targetPos)) { + // Close enough - teleport and imprison + executeCellDelivery(captive); + } else { + // Update stuck detection + this.stuckDetector.update(this.kidnapper); + + // Handle stuck state + if (this.stuckDetector.isStuck()) { + if (this.stuckDetector.shouldTeleport()) { + TiedUpMod.LOGGER.warn( + "[KidnapperBringToCellGoal] {} stuck after detour attempts, teleporting to cell and delivering", + this.kidnapper.getNpcName() + ); + teleportKidnapperToCell(); + executeCellDelivery(captive); + this.stuckDetector.reset(); + return; + } else if ( + this.kidnapper.level() instanceof ServerLevel serverLevel + ) { + BlockPos detour = this.stuckDetector.tryDetour( + serverLevel, + this.kidnapper.blockPosition(), + 5 + ); + if (detour != null) { + this.kidnapper.getNavigation().moveTo( + detour.getX() + 0.5, + detour.getY(), + detour.getZ() + 0.5, + 1.0 + ); + TiedUpMod.LOGGER.debug( + "[KidnapperBringToCellGoal] {} detouring to {} to bypass obstacle", + this.kidnapper.getNpcName(), + detour.toShortString() + ); + } + } + return; + } + + // Skip normal navigation if in detour + if (this.stuckDetector.isInDetour()) { + return; + } + + // Keep walking towards cell + if (this.moveTicks % 20 == 0) { + boolean pathFound = this.kidnapper.getNavigation().moveTo( + this.targetPos.getX() + 0.5, + this.targetPos.getY(), + this.targetPos.getZ() + 0.5, + 1.0 + ); + + if (!pathFound) { + this.stuckDetector.onPathFailed(); + } + } + + // Only look at target if we have an active path (not stuck) + if (!this.kidnapper.getNavigation().isDone()) { + this.kidnapper.getLookControl().setLookAt( + this.targetPos.getX() + 0.5, + this.targetPos.getY() + 1, + this.targetPos.getZ() + 0.5 + ); + } + } + } + + /** + * Check if kidnapper is at the delivery point using separate XZ and Y distance checks. + * This prevents premature teleport when kidnapper is on wrong floor (Y different). + * + * @param deliveryPos The target delivery position + * @return true if kidnapper is close enough in both XZ and Y dimensions + */ + private boolean isAtDeliveryPoint(BlockPos deliveryPos) { + double dx = this.kidnapper.getX() - (deliveryPos.getX() + 0.5); + double dz = this.kidnapper.getZ() - (deliveryPos.getZ() + 0.5); + double distXZ = Math.sqrt(dx * dx + dz * dz); + double distY = Math.abs(this.kidnapper.getY() - deliveryPos.getY()); + + return distXZ < DELIVERY_XZ_DISTANCE && distY < DELIVERY_Y_DISTANCE; + } + + /** + * Teleport the kidnapper (and leashed captive) near the target cell. + * Called when pathfinding fails repeatedly — the kidnapper can't walk there + * (e.g., large vertical difference, no accessible path). + */ + private void teleportKidnapperToCell() { + if (this.targetCell == null) return; + + BlockPos spawnPoint = + this.targetCell.getSpawnPoint() != null + ? this.targetCell.getSpawnPoint() + : this.targetCell.getCorePos().above(); + // Teleport 2 blocks away from spawn point (so kidnapper doesn't stand on captive) + this.kidnapper.teleportTo( + spawnPoint.getX() + 2.5, + spawnPoint.getY() + 1, + spawnPoint.getZ() + 0.5 + ); + + TiedUpMod.LOGGER.info( + "[KidnapperBringToCellGoal] Teleported {} to cell area at {}", + this.kidnapper.getNpcName(), + spawnPoint + ); + } + + /** + * Find the nearest cell with an available spot. + * Priority order: + * 1. Recaptured ESCAPED prisoner → return to their ORIGINAL camp (captive's campId) + * 2. Kidnapper's camp cells (if kidnapper is linked to a camp) + * 3. Any nearby cells (fallback) + * + * May trigger prisoner replacement if cells are full and captive is higher priority. + */ + @Nullable + private CellDataV2 findNearestCell() { + if (!(this.kidnapper.level() instanceof ServerLevel serverLevel)) { + return null; + } + + BlockPos kidnapperPos = this.kidnapper.blockPosition(); + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + CampOwnership campOwnership = CampOwnership.get(serverLevel); + + // Get captive priority + IRestrainable captive = this.kidnapper.getCaptive(); + CaptivePriority captivePriority = + captive != null + ? CaptivePriority.fromEntity(captive.asLivingEntity()) + : CaptivePriority.DAMSEL; + + // ESCAPED SYSTEM: Check if captive has a campId (recaptured escaped prisoner) + // If so, return them to their ORIGINAL camp, not the kidnapper's camp + UUID captiveCampId = null; + if (captive != null && captive.asLivingEntity() != null) { + PrisonerManager manager = PrisonerManager.get(serverLevel); + PrisonerRecord record = manager.getRecord( + captive.asLivingEntity().getUUID() + ); + captiveCampId = record.getCampId(); + + if (captiveCampId != null) { + // Check if captive's camp is alive + CampOwnership.CampData captiveCamp = campOwnership.getCamp( + captiveCampId + ); + if (captiveCamp != null && captiveCamp.isAlive()) { + // Try to find a cell in the captive's ORIGINAL camp + CellDataV2 campCell = findCampCellByIndex( + registry, + captiveCampId + ); + if (campCell != null) { + TiedUpMod.LOGGER.info( + "[KidnapperBringToCellGoal] {} returning recaptured prisoner to original camp {}", + this.kidnapper.getNpcName(), + captiveCampId.toString().substring(0, 8) + ); + return campCell; + } + + // All cells full - check for replacement + // Allow replacement for PLAYER (x3) and DAMSEL_SHINY (x2) + if (captivePriority.isHigherThan(CaptivePriority.DAMSEL)) { + CellDataV2 replacementCell = + tryReleaseLowerPriorityPrisonerFromCamp( + serverLevel, + registry, + captiveCampId, + captivePriority + ); + if (replacementCell != null) { + TiedUpMod.LOGGER.info( + "[KidnapperBringToCellGoal] {} found replacement cell in captive's camp {} via prisoner eviction (priority: {})", + this.kidnapper.getNpcName(), + captiveCampId.toString().substring(0, 8), + captivePriority + ); + return replacementCell; + } + } + } + // Captive's camp is dead or has no cells - fall through to normal search + } + } + + // If kidnapper is linked to a camp, try camp cells first using the camp index + UUID associatedCamp = this.kidnapper.getAssociatedStructure(); + if (associatedCamp != null) { + CampOwnership.CampData campData = campOwnership.getCamp( + associatedCamp + ); + if (campData != null && campData.isAlive()) { + // Use the new cellsByCamp index for efficient lookup + CellDataV2 campCell = findCampCellByIndex( + registry, + associatedCamp + ); + if (campCell != null) { + return campCell; + } + + // All camp cells are full - check if we can replace a lower-priority prisoner + // Allow replacement for PLAYER (x3) and DAMSEL_SHINY (x2) + if (captivePriority.isHigherThan(CaptivePriority.DAMSEL)) { + CellDataV2 replacementCell = + tryReleaseLowerPriorityPrisonerFromCamp( + serverLevel, + registry, + associatedCamp, + captivePriority + ); + if (replacementCell != null) { + TiedUpMod.LOGGER.info( + "[KidnapperBringToCellGoal] {} found replacement cell in camp {} via prisoner eviction (priority: {})", + this.kidnapper.getNpcName(), + associatedCamp.toString().substring(0, 8), + captivePriority + ); + return replacementCell; + } + } + } + } + + // For wild kidnappers (no camp): find nearest alive camp and use its cells + // This covers archers and other wild kidnappers who capture far from camps + if (associatedCamp == null) { + CampOwnership.CampData nearestCamp = + campOwnership.findNearestAliveCamp( + kidnapperPos, + 200 // 200-block radius covers archers who shoot from distance + ); + if (nearestCamp != null) { + CellDataV2 campCell = findCampCellByIndex( + registry, + nearestCamp.getCampId() + ); + if (campCell != null) { + TiedUpMod.LOGGER.info( + "[KidnapperBringToCellGoal] {} (wild) found cell in nearest camp {}", + this.kidnapper.getNpcName(), + nearestCamp.getCampId().toString().substring(0, 8) + ); + return campCell; + } + + // Check for replacement if captive is high priority + if (captivePriority.isHigherThan(CaptivePriority.DAMSEL)) { + CellDataV2 replacementCell = + tryReleaseLowerPriorityPrisonerFromCamp( + serverLevel, + registry, + nearestCamp.getCampId(), + captivePriority + ); + if (replacementCell != null) { + TiedUpMod.LOGGER.info( + "[KidnapperBringToCellGoal] {} (wild) found replacement cell in nearest camp {} via prisoner eviction (priority: {})", + this.kidnapper.getNpcName(), + nearestCamp.getCampId().toString().substring(0, 8), + captivePriority + ); + return replacementCell; + } + } + } + } + + // Fall back to finding any nearby cell using CellRegistry (spatial lookup) + List nearbyCells = registry.findCellsNear( + kidnapperPos, + SEARCH_RADIUS + ); + + CellDataV2 nearestCell = null; + double nearestDistSq = Double.MAX_VALUE; + + for (CellDataV2 cell : nearbyCells) { + // Skip full cells + if (cell.isFull()) { + continue; + } + + // Skip cells reserved by other kidnappers + if ( + registry.isReservedByOther( + cell.getId(), + this.kidnapper.getUUID(), + this.kidnapper.level().getGameTime() + ) + ) { + continue; + } + + // Skip cells belonging to dead camps + if (cell.isCampOwned()) { + UUID cellCampId = cell.getCampId(); + if ( + cellCampId != null && !campOwnership.isCampAlive(cellCampId) + ) { + continue; + } + } + + double distSq = kidnapperPos.distSqr(cell.getCorePos()); + if (distSq < nearestDistSq) { + nearestDistSq = distSq; + nearestCell = cell; + } + } + + // Reserve the cell if found to prevent race condition + if (nearestCell != null) { + boolean reserved = registry.reserveCell( + nearestCell.getId(), + this.kidnapper.getUUID(), + this.kidnapper.level().getGameTime() + ); + if (!reserved) { + // Someone else reserved it in the meantime - retry + TiedUpMod.LOGGER.debug( + "[KidnapperBringToCellGoal] {} failed to reserve cell {}, will retry", + this.kidnapper.getNpcName(), + nearestCell.getId().toString().substring(0, 8) + ); + return null; + } + TiedUpMod.LOGGER.debug( + "[KidnapperBringToCellGoal] {} reserved cell {} (searched {} nearby cells)", + this.kidnapper.getNpcName(), + nearestCell.getId().toString().substring(0, 8), + nearbyCells.size() + ); + } + + return nearestCell; + } + + /** + * Find an available cell from the camp's registered cells using the camp index. + * Reserves the cell before returning to prevent race conditions. + * + * @param registry The cell registry + * @param campId The camp UUID + * @return An available cell (reserved), or null if all are full/reserved + */ + @Nullable + private CellDataV2 findCampCellByIndex( + CellRegistryV2 registry, + UUID campId + ) { + List campCells = registry.getCellsByCamp(campId); + + for (CellDataV2 cell : campCells) { + // Skip full cells + if (cell.isFull()) { + continue; + } + + // Skip cells reserved by other kidnappers + if ( + registry.isReservedByOther( + cell.getId(), + this.kidnapper.getUUID(), + this.kidnapper.level().getGameTime() + ) + ) { + continue; + } + + // Try to reserve this cell + if ( + registry.reserveCell( + cell.getId(), + this.kidnapper.getUUID(), + this.kidnapper.level().getGameTime() + ) + ) { + return cell; + } + } + + return null; + } + + /** + * Try to release a lower-priority prisoner to make room for a higher-priority captive. + * Only releases NPCs (Damsel/DamselShiny), never players. + * Uses the camp index to find cells belonging to the camp. + * + * @param serverLevel The server level + * @param registry The cell registry + * @param campId The camp UUID + * @param captivePriority The priority of the captive we want to place + * @return A cell that now has room, or null if no replacement possible + */ + @Nullable + private CellDataV2 tryReleaseLowerPriorityPrisonerFromCamp( + ServerLevel serverLevel, + CellRegistryV2 registry, + UUID campId, + CaptivePriority captivePriority + ) { + // Use camp index for efficient lookup + List campCells = registry.getCellsByCamp(campId); + + // Find a cell with a lower-priority NPC prisoner + for (CellDataV2 cell : campCells) { + if (!cell.isOccupied()) continue; + + // Check each prisoner in the cell + for (UUID prisonerId : cell.getPrisonerIds()) { + Entity entity = serverLevel.getEntity(prisonerId); + if (entity instanceof LivingEntity livingPrisoner) { + CaptivePriority prisonerPriority = + CaptivePriority.fromEntity(livingPrisoner); + + // Only replace NPCs with lower priority (never release players) + if ( + captivePriority.isHigherThan(prisonerPriority) && + !(livingPrisoner instanceof Player) + ) { + // Release this prisoner + releasePrisonerForReplacement( + serverLevel, + registry, + cell, + prisonerId, + livingPrisoner + ); + + TiedUpMod.LOGGER.debug( + "[KidnapperBringToCellGoal] Released {} (priority {}) to make room for priority {}", + livingPrisoner.getName().getString(), + prisonerPriority, + captivePriority + ); + + return cell; + } + } + } + } + + return null; + } + + /** + * Release a prisoner NPC from a cell to make room. + * The NPC is freed and allowed to wander away. + */ + private void releasePrisonerForReplacement( + ServerLevel serverLevel, + CellRegistryV2 registry, + CellDataV2 cell, + UUID prisonerId, + LivingEntity prisoner + ) { + // Remove from cell registry (HIGH FIX: pass server for state cleanup) + registry.releasePrisoner( + cell.getId(), + prisonerId, + serverLevel.getServer() + ); + + // Release from captivity state (clear imprisonment status) + PrisonerManager manager = PrisonerManager.get(serverLevel); + PrisonerRecord record = manager.getRecord(prisonerId); + if (record.isImprisoned()) { + // Release prisoner with grace period + manager.release(prisonerId, serverLevel.getGameTime()); + } + + // Free the NPC if it has kidnapped state + IRestrainable kidnappedState = KidnappedHelper.getKidnappedState( + prisoner + ); + if (kidnappedState != null) { + kidnappedState.free(false); // Free without dropping leash + } + + // If it's an NPC, despawn it (replaced by higher priority captive) + if (prisoner instanceof AbstractTiedUpNpc npc) { + TiedUpMod.LOGGER.info( + "[KidnapperBringToCellGoal] Despawning NPC {} (priority {}) - replaced by higher priority captive", + npc.getNpcName(), + CaptivePriority.fromEntity(npc) + ); + // Despawn the NPC (remove from world) + npc.discard(); + } + } + + /** + * Announce that we're taking the captive to a cell. + */ + private void announceIntention(IRestrainable captive) { + this.kidnapper.talkToPlayersInRadius( + DialogueCategory.SLAVE_TRANSPORT, + DIALOGUE_RADIUS + ); + + if (captive.asLivingEntity() instanceof Player player) { + this.kidnapper.talkTo(player, DialogueCategory.SLAVE_TRANSPORT); + } + + TiedUpMod.LOGGER.debug( + "[KidnapperBringToCellGoal] {} taking {} to cell at {}", + this.kidnapper.getNpcName(), + captive.getKidnappedName(), + this.targetPos + ); + } + + /** + * Execute the cell delivery - imprison and teleport. + * + * CRITICAL FIX: Teleport AFTER successful imprisonment to prevent state desync. + * Previous order: teleport → imprison → (fail) → player stuck in cell but logically free + * New order: assign → imprison → (success) → teleport + */ + private void executeCellDelivery(IRestrainable captive) { + if (this.targetCell == null || this.targetPos == null) { + return; + } + + if (!(this.kidnapper.level() instanceof ServerLevel serverLevel)) { + return; + } + + // Validation: must be captive by this kidnapper + if (!captive.isCaptive()) { + TiedUpMod.LOGGER.warn( + "[KidnapperBringToCellGoal] Cannot deliver - captive is not in captive state" + ); + return; + } + + // Validation: must have this kidnapper as holder + if ( + !this.kidnapper.hasCaptives() || + this.kidnapper.getCaptive() != captive + ) { + TiedUpMod.LOGGER.warn( + "[KidnapperBringToCellGoal] Cannot deliver - captive mismatch" + ); + return; + } + + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + UUID prisonerId = captive.asLivingEntity().getUUID(); + PrisonerManager manager = PrisonerManager.get(serverLevel); + + // Defense-in-depth: if prisoner is already in a cell (IMPRISONED state specifically), + // skip imprison and just release the leash. + PrisonerRecord existingRecord = manager.getRecord(prisonerId); + if (existingRecord != null && existingRecord.getState().isInCell()) { + TiedUpMod.LOGGER.warn( + "[KidnapperBringToCellGoal] {} is already in cell (state={}, cell={}) - " + + "releasing leash without destructive rollback", + captive.getKidnappedName(), + existingRecord.getState(), + existingRecord.getCellId() != null + ? existingRecord.getCellId().toString().substring(0, 8) + : "none" + ); + + // Cancel reservation + registry.cancelReservation( + this.targetCell.getId(), + this.kidnapper.getUUID() + ); + + // Free from kidnapper leash (they're already imprisoned in their cell) + captive.free(false); + this.kidnapper.removeCaptive(captive, false); + + this.targetCell = null; + this.targetPos = null; + return; + } + + // Get campId from cell (authoritative), fallback to kidnapper's camp + UUID campId = this.targetCell.getCampId(); + if (campId == null) { + campId = this.kidnapper.getAssociatedStructure(); + } + + // Imprison via PrisonerService (atomic: cell assign + PrisonerManager + unleash) + boolean imprisoned = + com.tiedup.remake.prison.service.PrisonerService.get().imprison( + serverLevel, + captive.asLivingEntity(), + this.kidnapper, + campId, + this.targetCell + ); + + if (!imprisoned) { + TiedUpMod.LOGGER.error( + "[KidnapperBringToCellGoal] PrisonerService.imprison() failed for {} - aborting", + captive.getKidnappedName() + ); + + // Cancel reservation since we're not using the cell + registry.cancelReservation( + this.targetCell.getId(), + this.kidnapper.getUUID() + ); + + // Clear targets + this.targetCell = null; + this.targetPos = null; + return; + } + + // Consume cell reservation + boolean hadReservation = registry.consumeReservation( + this.targetCell.getId(), + this.kidnapper.getUUID() + ); + if (!hadReservation) { + TiedUpMod.LOGGER.debug( + "[KidnapperBringToCellGoal] {} delivered without reservation (may have expired)", + this.kidnapper.getNpcName() + ); + } + + // Teleport after successful imprisonment (prevents state desync) + BlockPos spawnPoint = + this.targetCell.getSpawnPoint() != null + ? this.targetCell.getSpawnPoint() + : this.targetCell.getCorePos().above(); + com.tiedup.remake.util.teleport.Position position = + new com.tiedup.remake.util.teleport.Position( + spawnPoint, + serverLevel.dimension() + ); + TeleportHelper.teleportEntity(captive.asLivingEntity(), position); + + // Update the captive's collar with the cell ID + ItemStack collar = captive.getEquipment(BodyRegionV2.NECK); + if ( + !collar.isEmpty() && + collar.getItem() instanceof ItemCollar collarItem + ) { + collarItem.setCellId(collar, this.targetCell.getId()); + } + + TiedUpMod.LOGGER.info( + "[KidnapperBringToCellGoal] {} delivered {} to cell at {}", + this.kidnapper.getNpcName(), + captive.getKidnappedName(), + spawnPoint + ); + + // Confiscate inventory + if (captive.asLivingEntity() instanceof ServerPlayer serverPlayer) { + confiscateInventory(serverPlayer, serverLevel, this.targetCell); + } + + // Mark NPCs for sale immediately + if (captive.asLivingEntity() instanceof AbstractTiedUpNpc npc) { + com.tiedup.remake.util.tasks.ItemTask salePrice = + new com.tiedup.remake.util.tasks.ItemTask( + net.minecraft.world.item.Items.EMERALD, + 12 + ); + npc.putForSale(salePrice); + } + + // Enter "get out" state ONLY if not a camp guard (wild kidnapper) + if (this.kidnapper.getAssociatedStructure() == null) { + this.kidnapper.setGetOutState(true); + } + + // Clear target + this.targetCell = null; + this.targetPos = null; + } + + /** + * Phase 2: Confiscate the player's inventory and store in LOOT chest. + */ + private void confiscateInventory( + ServerPlayer player, + ServerLevel serverLevel, + CellDataV2 cell + ) { + // Find LOOT chest via CampData (fast path) or cell markers (fallback) + UUID campId = cell.getOwnerId(); + BlockPos lootPos = null; + if (campId != null) { + lootPos = + com.tiedup.remake.prison.service.ItemService.get().findCampChest( + serverLevel, + campId + ); + } + + // Last resort: offset from core position (unlikely to have a chest) + if (lootPos == null) { + lootPos = cell.getCorePos().offset(2, 0, 0); + TiedUpMod.LOGGER.warn( + "[KidnapperBringToCellGoal] No LOOT chest found for cell {} (campId={}) - confiscation will likely fail", + cell.getId().toString().substring(0, 8), + campId != null ? campId.toString().substring(0, 8) : "null" + ); + } + + // Check if player's inventory is empty (nothing to confiscate) + if (player.getInventory().isEmpty()) { + TiedUpMod.LOGGER.debug( + "[KidnapperBringToCellGoal] {} has empty inventory, skipping confiscation", + player.getName().getString() + ); + return; + } + + // Confiscate inventory + ConfiscatedInventoryRegistry registry = + ConfiscatedInventoryRegistry.get(serverLevel); + boolean success = registry.confiscate(player, lootPos, cell.getId()); + + if (success) { + TiedUpMod.LOGGER.debug( + "[KidnapperBringToCellGoal] Confiscated inventory from {} into chest at {}", + player.getName().getString(), + lootPos.toShortString() + ); + } else { + TiedUpMod.LOGGER.warn( + "[KidnapperBringToCellGoal] Failed to confiscate inventory from {}", + player.getName().getString() + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperCaptureGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperCaptureGoal.java new file mode 100644 index 0000000..9483226 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperCaptureGoal.java @@ -0,0 +1,1050 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.EntityDialogueManager; +import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.ai.StuckDetector; +import com.tiedup.remake.items.ItemKey; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.action.PacketTying; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerState; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.util.KidnapperAIHelper; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * AI Goal for EntityKidnapper to capture and enslave targets. + * + * Phase 14.3.3: Capture mechanics AI + * + * This goal: + * 1. Moves towards the target + * 2. When close enough, applies bind and gag + * 3. Enslaves the target once fully restrained + * + * Based on original EntityAIEnslavingPlayer from 1.12.2 + */ +public class KidnapperCaptureGoal extends Goal { + + private final EntityKidnapper kidnapper; + private final double speedModifier; + private LivingEntity target; + private int captureProgress; + private int pathRecalculateDelay; + private int chaseTicks; + + // Capture states - progressive application order + private static final int STATE_APPROACH = 0; + private static final int STATE_BIND = 1; + private static final int STATE_GAG = 2; + private static final int STATE_MITTENS = 3; + private static final int STATE_EARPLUGS = 4; + private static final int STATE_BLINDFOLD = 5; + private static final int STATE_COLLAR = 6; + private static final int STATE_ENSLAVE = 7; + private static final int STATE_DONE = 8; + + /** Radius for broadcasting dialogue to nearby players */ + private static final int DIALOGUE_RADIUS = 20; + + /** Cooldown between same dialogue category (in ticks) */ + private static final int DIALOGUE_COOLDOWN = 100; // 5 seconds + + // ========== Distance Constants ========== + /** Distance at which target is considered escaped */ + private static final double ESCAPE_DISTANCE = 30.0; + /** Distance needed to start/continue capture */ + private static final double CAPTURE_DISTANCE = 2.0; + /** Distance at which capture progress resets */ + private static final double DISTANCE_RESET = 3.0; + /** Look control speed (yaw/pitch max rotation per tick) */ + private static final float LOOK_SPEED = 30.0F; + + // ========== Timing Constants (in ticks) ========== + /** Path recalculation interval during approach (10 ticks = 0.5s) */ + private static final int PATH_RECALC_APPROACH = 10; + /** Path recalculation interval during capture (15 ticks = 0.75s) */ + private static final int PATH_RECALC_CAPTURE = 15; + /** Time to apply mittens (~1.5 seconds) */ + private static final int MITTENS_APPLY_TIME = 30; + /** Time to apply earplugs (~1 second) */ + private static final int EARPLUGS_APPLY_TIME = 20; + /** Time to apply blindfold (~2 seconds) */ + private static final int BLINDFOLD_APPLY_TIME = 40; + /** Time to apply collar (~2 seconds) */ + private static final int COLLAR_APPLY_TIME = 40; + /** Maximum chase duration before giving up (60 seconds) */ + private static final int MAX_CHASE_TICKS = 1200; + + private int captureState; + + /** Flag to signal the goal system to stop this goal cleanly (avoids double-stop) */ + private boolean forceStop; + + /** Stuck detection for approach phase */ + private final StuckDetector stuckDetector = new StuckDetector(60, 3); + + /** Track last time each dialogue category was used */ + private final Map dialogueCooldowns = + new HashMap<>(); + + /** + * Create a new capture goal. + * + * @param kidnapper The kidnapper entity + */ + public KidnapperCaptureGoal(EntityKidnapper kidnapper) { + this(kidnapper, 1.2); + } + + /** + * Create a new capture goal with custom speed. + * + * @param kidnapper The kidnapper entity + * @param speedModifier Speed multiplier when chasing + */ + public KidnapperCaptureGoal( + EntityKidnapper kidnapper, + double speedModifier + ) { + this.kidnapper = kidnapper; + this.speedModifier = speedModifier; + this.captureProgress = 0; + this.pathRecalculateDelay = 0; + this.captureState = STATE_APPROACH; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Need a target to capture + LivingEntity currentTarget = this.kidnapper.getTarget(); + if (currentTarget == null) { + // Only log occasionally to avoid spam + if (this.kidnapper.tickCount % 100 == 0) { + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} canUse=false: no target", + this.kidnapper.getNpcName() + ); + } + return false; + } + + // Can't capture if tied up + if (this.kidnapper.isTiedUp()) { + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} canUse=false: kidnapper is tied", + this.kidnapper.getNpcName() + ); + return false; + } + + // Can't capture if already have slave + if (this.kidnapper.hasCaptives()) { + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} canUse=false: already has captives", + this.kidnapper.getNpcName() + ); + return false; + } + + // Target must be alive + if (!currentTarget.isAlive()) { + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} canUse=false: target {} is dead", + this.kidnapper.getNpcName(), + currentTarget.getName().getString() + ); + return false; + } + + // Must pass all suitability checks (grace period, labor, etc.) + if (!this.kidnapper.isSuitableTarget(currentTarget)) { + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} canUse=false: target {} is not suitable (protected)", + this.kidnapper.getNpcName(), + currentTarget.getName().getString() + ); + // Clear the invalid target + this.kidnapper.setTarget(null); + return false; + } + + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} canUse=TRUE for target {}", + this.kidnapper.getNpcName(), + currentTarget.getName().getString() + ); + + this.target = currentTarget; + return true; + } + + @Override + public boolean canContinueToUse() { + // Stop if no target or target died + if (this.target == null || !this.target.isAlive()) { + return false; + } + + // Stop if kidnapper got tied up + if (this.kidnapper.isTiedUp()) { + return false; + } + + // Stop if capture is done or force-stopped (e.g. chase timeout) + if (this.captureState == STATE_DONE || this.forceStop) { + return false; + } + + // Stop if target escaped too far + if (this.kidnapper.distanceTo(this.target) > ESCAPE_DISTANCE) { + return false; + } + + // Stop if target became protected (entered labor, grace period, etc.) + // Use isTargetStillValidForChase() instead of isSuitableTarget() to avoid + // abandoning chase when trees/obstacles temporarily block line-of-sight + if (!this.kidnapper.isTargetStillValidForChase(this.target)) { + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} stopping - target {} became protected", + this.kidnapper.getNpcName(), + this.target.getName().getString() + ); + return false; + } + + // Phase 17: Stop if target was already captured by someone else + IBondageState state = KidnappedHelper.getKidnappedState(this.target); + if (state != null && state.isCaptive()) { + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} stopping - target {} already captured by another", + this.kidnapper.getNpcName(), + this.target.getName().getString() + ); + return false; + } + + return true; + } + + @Override + public void start() { + this.captureState = STATE_APPROACH; + this.captureProgress = 0; + this.pathRecalculateDelay = 0; + this.chaseTicks = 0; + this.forceStop = false; + this.stuckDetector.reset(); + + // Set AI state to CAPTURE - prevents receiving alerts during capture + this.kidnapper.setCurrentState(KidnapperState.CAPTURE); + + // Equip bind and gag items + this.kidnapper.setUpHeldItems(); + + // Broadcast to nearby players (includes target since they're close) + broadcastWithCooldown(DialogueCategory.CAPTURE_START); + + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} STARTED pursuing {} (state: CAPTURE)", + this.kidnapper.getNpcName(), + this.target.getName().getString() + ); + } + + @Override + public void stop() { + // Clear target if capture failed + if (this.captureState != STATE_DONE) { + this.kidnapper.setTarget(null); + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} stopped pursuing target", + this.kidnapper.getNpcName() + ); + } + + // Reset AI state to IDLE when capture ends + this.kidnapper.setCurrentState(KidnapperState.IDLE); + + // Hide held items when not chasing + this.kidnapper.clearHeldItems(); + + // Clear dialogue cooldowns to prevent memory leak + this.dialogueCooldowns.clear(); + + this.target = null; + this.captureState = STATE_APPROACH; + this.captureProgress = 0; + this.stuckDetector.reset(); + this.kidnapper.getNavigation().stop(); + } + + @Override + public void tick() { + // TIMEOUT: Give up chase after MAX_CHASE_TICKS to prevent infinite pursuit + this.chaseTicks++; + if (this.chaseTicks > MAX_CHASE_TICKS) { + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} chase timeout ({} ticks), giving up on {}", + this.kidnapper.getNpcName(), + MAX_CHASE_TICKS, + this.target != null + ? this.target.getName().getString() + : "unknown" + ); + // Set flag so canContinueToUse() returns false next tick, + // letting the goal system call stop() exactly once. + this.forceStop = true; + return; + } + + // Check target validity - could have died or been removed + if (this.target == null || !this.target.isAlive()) { + this.forceStop = true; + return; + } + + // Always look at target + this.kidnapper.getLookControl().setLookAt( + this.target, + LOOK_SPEED, + LOOK_SPEED + ); + + double distance = this.kidnapper.distanceTo(this.target); + + switch (this.captureState) { + case STATE_APPROACH: + tickApproach(distance); + break; + case STATE_BIND: + tickBind(); + break; + case STATE_GAG: + tickGag(); + break; + case STATE_MITTENS: + tickMittens(); + break; + case STATE_EARPLUGS: + tickEarplugs(); + break; + case STATE_BLINDFOLD: + tickBlindfold(); + break; + case STATE_COLLAR: + tickCollar(); + break; + case STATE_ENSLAVE: + tickEnslave(); + break; + } + } + + /** + * Approach the target. + */ + private void tickApproach(double distance) { + // Update stuck detection + this.stuckDetector.update(this.kidnapper); + + // Handle stuck state + if (this.stuckDetector.isStuck()) { + if (this.stuckDetector.shouldTeleport()) { + // Teleport near the target + if (this.kidnapper.level() instanceof ServerLevel serverLevel) { + BlockPos teleportPos = + KidnapperAIHelper.findRandomGroundPos( + serverLevel, + this.target.blockPosition(), + 3, + serverLevel.getRandom(), + 10 + ); + if (teleportPos != null) { + this.kidnapper.teleportTo( + teleportPos.getX() + 0.5, + teleportPos.getY(), + teleportPos.getZ() + 0.5 + ); + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} teleported near target to unblock", + this.kidnapper.getNpcName() + ); + } + } + this.stuckDetector.reset(); + } else if ( + this.kidnapper.level() instanceof ServerLevel serverLevel + ) { + BlockPos detour = this.stuckDetector.tryDetour( + serverLevel, + this.kidnapper.blockPosition(), + 5 + ); + if (detour != null) { + this.kidnapper.getNavigation().moveTo( + detour.getX() + 0.5, + detour.getY(), + detour.getZ() + 0.5, + this.speedModifier + ); + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} detouring to {} to bypass obstacle", + this.kidnapper.getNpcName(), + detour.toShortString() + ); + } + } + return; + } + + // Skip normal navigation if in detour + if (this.stuckDetector.isInDetour()) { + return; + } + + // Recalculate path frequently for smooth chasing + if (--this.pathRecalculateDelay <= 0) { + this.pathRecalculateDelay = PATH_RECALC_APPROACH; + boolean pathFound = this.kidnapper.getNavigation().moveTo( + this.target, + this.speedModifier + ); + if (!pathFound) { + this.stuckDetector.onPathFailed(); + } + } + + // Check if close enough to start capture + if (distance <= CAPTURE_DISTANCE) { + this.captureState = STATE_BIND; + this.captureProgress = 0; + this.stuckDetector.reset(); + + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} reached target, starting bind", + this.kidnapper.getNpcName() + ); + } + } + + /** + * Apply bind to target. + */ + private void tickBind() { + IBondageState state = KidnappedHelper.getKidnappedState(this.target); + if (state == null) { + this.captureState = STATE_DONE; + return; + } + + // If already tied, move to gag + if (state.isTiedUp()) { + this.captureState = STATE_GAG; + this.captureProgress = 0; + return; + } + + double distance = this.kidnapper.distanceTo(this.target); + + // Always keep following target while in capture mode + if (--this.pathRecalculateDelay <= 0) { + this.pathRecalculateDelay = PATH_RECALC_CAPTURE; + this.kidnapper.getNavigation().moveTo( + this.target, + this.speedModifier + ); + } + + // Check if target moved away too far - reset progress but keep following + if (distance > DISTANCE_RESET) { + this.captureProgress = 0; + return; // Will keep following via pathRecalculateDelay + } + + // Only progress if close enough, but keep following + if (distance > CAPTURE_DISTANCE) { + // Keep moving towards target + this.kidnapper.getNavigation().moveTo( + this.target, + this.speedModifier + ); + return; + } + + // Simulate tying progress + this.captureProgress++; + + // Taunt while tying (with cooldown) + if (this.captureProgress == 1) { + // Chat: broadcast dialogue to nearby players (includes target) + broadcastWithCooldown(DialogueCategory.CAPTURE_TYING); + } + + // Send progress packet to player target for progress bar display + // Use ticks directly - progress calculation is state/maxState so unit doesn't matter + if (this.target instanceof ServerPlayer serverPlayer) { + int maxTicks = this.kidnapper.getCaptureBindTime(); + String npcName = this.kidnapper.getNpcName(); + PacketTying packet = new PacketTying( + this.captureProgress, + maxTicks, + false, + npcName + ); + ModNetwork.sendToPlayer(packet, serverPlayer); + } + + if (this.captureProgress >= this.kidnapper.getCaptureBindTime()) { + ItemStack bind = this.kidnapper.getBindItem(); + state.equip(BodyRegionV2.ARMS, bind.copy()); + + // Send completion packet to clear progress bar + if (this.target instanceof ServerPlayer serverPlayer) { + int maxTicks = this.kidnapper.getCaptureBindTime(); + String npcName = this.kidnapper.getNpcName(); + PacketTying completionPacket = new PacketTying( + -1, + maxTicks, + false, + npcName + ); + ModNetwork.sendToPlayer(completionPacket, serverPlayer); + } + + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} tied up {}", + this.kidnapper.getNpcName(), + this.target.getName().getString() + ); + + this.captureState = STATE_GAG; + this.captureProgress = 0; + } + } + + /** + * Apply gag to target. + */ + private void tickGag() { + IBondageState state = KidnappedHelper.getKidnappedState(this.target); + if (state == null) { + this.captureState = STATE_DONE; + return; + } + + // If already gagged, move to mittens + if (state.isGagged()) { + this.captureState = STATE_MITTENS; + this.captureProgress = 0; + return; + } + + // Skip gag if kidnapper doesn't have one - move to mittens + if ( + this.kidnapper.getGagItem() == null || + this.kidnapper.getGagItem().isEmpty() + ) { + this.captureState = STATE_MITTENS; + this.captureProgress = 0; + return; + } + + // Must be tied first + if (!state.isTiedUp()) { + this.captureState = STATE_BIND; + return; + } + + double distance = this.kidnapper.distanceTo(this.target); + + // Always keep following target while in capture mode + if (--this.pathRecalculateDelay <= 0) { + this.pathRecalculateDelay = PATH_RECALC_CAPTURE; + this.kidnapper.getNavigation().moveTo( + this.target, + this.speedModifier + ); + } + + // Check if target moved away too far - reset progress but keep following + if (distance > DISTANCE_RESET) { + this.captureProgress = 0; + return; // Will keep following via pathRecalculateDelay + } + + // Only progress if close enough, but keep following + if (distance > CAPTURE_DISTANCE) { + // Keep moving towards target + this.kidnapper.getNavigation().moveTo( + this.target, + this.speedModifier + ); + return; + } + + // Apply gag progress + this.captureProgress++; + + // Taunt while gagging (with cooldown) + if (this.captureProgress == 1) { + // Chat: broadcast dialogue to nearby players (includes target) + broadcastWithCooldown(DialogueCategory.CAPTURE_GAGGING); + + // Screen: action bar message to target + SystemMessageManager.sendBeingGagged(this.kidnapper, this.target); + } + + if (this.captureProgress >= this.kidnapper.getCaptureGagTime()) { + ItemStack gag = this.kidnapper.getGagItem(); + state.equip(BodyRegionV2.MOUTH, gag.copy()); + + // Screen: action bar message to target + SystemMessageManager.sendGagged(this.kidnapper, this.target); + + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} gagged {}", + this.kidnapper.getNpcName(), + this.target.getName().getString() + ); + + this.captureState = STATE_MITTENS; + this.captureProgress = 0; + } + } + + /** + * Apply mittens to target. + */ + private void tickMittens() { + IBondageState state = KidnappedHelper.getKidnappedState(this.target); + if (state == null) { + this.captureState = STATE_DONE; + return; + } + + // Skip if kidnapper doesn't have mittens + ItemStack mittens = this.kidnapper.getMittensItem(); + if (mittens == null || mittens.isEmpty()) { + this.captureState = STATE_EARPLUGS; + this.captureProgress = 0; + return; + } + + // Skip if already has mittens + if (!state.getEquipment(BodyRegionV2.HANDS).isEmpty()) { + this.captureState = STATE_EARPLUGS; + this.captureProgress = 0; + return; + } + + double distance = this.kidnapper.distanceTo(this.target); + + // Keep following target + if (--this.pathRecalculateDelay <= 0) { + this.pathRecalculateDelay = PATH_RECALC_CAPTURE; + this.kidnapper.getNavigation().moveTo( + this.target, + this.speedModifier + ); + } + + if (distance > DISTANCE_RESET) { + this.captureProgress = 0; + return; + } + + if (distance > CAPTURE_DISTANCE) { + this.kidnapper.getNavigation().moveTo( + this.target, + this.speedModifier + ); + return; + } + + this.captureProgress++; + + // Apply mittens (faster than bind/gag) + if (this.captureProgress >= MITTENS_APPLY_TIME) { + state.equip(BodyRegionV2.HANDS, mittens.copy()); + + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} put mittens on {}", + this.kidnapper.getNpcName(), + this.target.getName().getString() + ); + + this.captureState = STATE_EARPLUGS; + this.captureProgress = 0; + } + } + + /** + * Apply earplugs to target. + */ + private void tickEarplugs() { + IBondageState state = KidnappedHelper.getKidnappedState(this.target); + if (state == null) { + this.captureState = STATE_DONE; + return; + } + + // Skip if kidnapper doesn't have earplugs + ItemStack earplugs = this.kidnapper.getEarplugsItem(); + if (earplugs == null || earplugs.isEmpty()) { + this.captureState = STATE_BLINDFOLD; + this.captureProgress = 0; + return; + } + + // Skip if already has earplugs + if (!state.getEquipment(BodyRegionV2.EARS).isEmpty()) { + this.captureState = STATE_BLINDFOLD; + this.captureProgress = 0; + return; + } + + double distance = this.kidnapper.distanceTo(this.target); + + // Keep following target + if (--this.pathRecalculateDelay <= 0) { + this.pathRecalculateDelay = PATH_RECALC_CAPTURE; + this.kidnapper.getNavigation().moveTo( + this.target, + this.speedModifier + ); + } + + if (distance > DISTANCE_RESET) { + this.captureProgress = 0; + return; + } + + if (distance > CAPTURE_DISTANCE) { + this.kidnapper.getNavigation().moveTo( + this.target, + this.speedModifier + ); + return; + } + + this.captureProgress++; + + // Apply earplugs (fast) + if (this.captureProgress >= EARPLUGS_APPLY_TIME) { + state.equip(BodyRegionV2.EARS, earplugs.copy()); + + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} put earplugs on {}", + this.kidnapper.getNpcName(), + this.target.getName().getString() + ); + + this.captureState = STATE_BLINDFOLD; + this.captureProgress = 0; + } + } + + /** + * Apply blindfold to target. + */ + private void tickBlindfold() { + IBondageState state = KidnappedHelper.getKidnappedState(this.target); + if (state == null) { + this.captureState = STATE_DONE; + return; + } + + // Skip if kidnapper doesn't have blindfold + ItemStack blindfold = this.kidnapper.getBlindfoldItem(); + if (blindfold == null || blindfold.isEmpty()) { + this.captureState = STATE_COLLAR; + this.captureProgress = 0; + return; + } + + // Skip if already blindfolded + if (!state.getEquipment(BodyRegionV2.EYES).isEmpty()) { + this.captureState = STATE_COLLAR; + this.captureProgress = 0; + return; + } + + double distance = this.kidnapper.distanceTo(this.target); + + // Keep following target + if (--this.pathRecalculateDelay <= 0) { + this.pathRecalculateDelay = PATH_RECALC_CAPTURE; + this.kidnapper.getNavigation().moveTo( + this.target, + this.speedModifier + ); + } + + if (distance > DISTANCE_RESET) { + this.captureProgress = 0; + return; + } + + if (distance > CAPTURE_DISTANCE) { + this.kidnapper.getNavigation().moveTo( + this.target, + this.speedModifier + ); + return; + } + + this.captureProgress++; + + // Apply blindfold (slower - most restrictive) + if (this.captureProgress >= BLINDFOLD_APPLY_TIME) { + state.equip(BodyRegionV2.EYES, blindfold.copy()); + + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} blindfolded {}", + this.kidnapper.getNpcName(), + this.target.getName().getString() + ); + + this.captureState = STATE_COLLAR; + this.captureProgress = 0; + } + } + + /** + * Apply collar to target. + */ + private void tickCollar() { + IBondageState state = KidnappedHelper.getKidnappedState(this.target); + if (state == null) { + this.captureState = STATE_DONE; + return; + } + + // Skip if kidnapper doesn't have collar + ItemStack collar = this.kidnapper.getCollarItem(); + if (collar == null || collar.isEmpty()) { + this.captureState = STATE_ENSLAVE; + this.captureProgress = 0; + return; + } + + // If already has collar, check ownership + if (state.hasCollar()) { + ItemStack existingCollar = state.getEquipment(BodyRegionV2.NECK); + if ( + existingCollar.getItem() instanceof + com.tiedup.remake.items.base.ItemCollar collarItem + ) { + java.util.List owners = collarItem.getOwners( + existingCollar + ); + if (!owners.contains(this.kidnapper.getUUID())) { + // Filter out self-collar (owner == target = exploit, treat as uncollared) + java.util.List realOwners = owners + .stream() + .filter(id -> !id.equals(this.target.getUUID())) + .toList(); + // Check if owned by a DIFFERENT player — abort (don't steal from players) + if ( + !realOwners.isEmpty() && + this.kidnapper.level() instanceof + net.minecraft.server.level.ServerLevel sl + ) { + for (java.util.UUID ownerId : realOwners) { + if ( + sl + .getServer() + .getPlayerList() + .getPlayer(ownerId) != + null + ) { + this.forceStop = true; + return; + } + } + } + // Self-collar or other kidnapper — transfer ownership + for (java.util.UUID oldOwner : new java.util.ArrayList<>( + owners + )) { + collarItem.removeOwner(existingCollar, oldOwner); + } + collarItem.addOwner( + existingCollar, + this.kidnapper.getUUID(), + this.kidnapper.getNpcName() + ); + } + } + this.captureState = STATE_ENSLAVE; + this.captureProgress = 0; + return; + } + + double distance = this.kidnapper.distanceTo(this.target); + + // Keep following target + if (--this.pathRecalculateDelay <= 0) { + this.pathRecalculateDelay = PATH_RECALC_CAPTURE; + this.kidnapper.getNavigation().moveTo( + this.target, + this.speedModifier + ); + } + + if (distance > DISTANCE_RESET) { + this.captureProgress = 0; + return; + } + + if (distance > CAPTURE_DISTANCE) { + this.kidnapper.getNavigation().moveTo( + this.target, + this.speedModifier + ); + return; + } + + this.captureProgress++; + + // Apply collar + if (this.captureProgress >= COLLAR_APPLY_TIME) { + // Create a copy of the collar and configure it + ItemStack collarCopy = collar.copy(); + if (collarCopy.getItem() instanceof ItemCollar collarItem) { + // Add kidnapper as owner + collarItem.addOwner( + collarCopy, + this.kidnapper.getUUID(), + this.kidnapper.getNpcName() + ); + + // Generate a key for this collar and link them via UUID + ItemStack keyStack = new ItemStack(ModItems.COLLAR_KEY.get()); + if (keyStack.getItem() instanceof ItemKey keyItem) { + UUID keyUUID = keyItem.getKeyUUID(keyStack); + collarItem.setLockedByKeyUUID(collarCopy, keyUUID); + // Store the key on the kidnapper for potential drop on death + this.kidnapper.addCollarKey(keyStack); + } else { + // Fallback: just lock without a key + collarItem.setLocked(collarCopy, true); + } + } + + state.equip(BodyRegionV2.NECK, collarCopy); + + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} collared and locked {}", + this.kidnapper.getNpcName(), + this.target.getName().getString() + ); + + this.captureState = STATE_ENSLAVE; + this.captureProgress = 0; + } + } + + /** + * Broadcast dialogue with cooldown check. + * Returns true if dialogue was sent. + */ + private boolean broadcastWithCooldown(DialogueCategory category) { + long currentTick = this.kidnapper.level().getGameTime(); + Long lastUsed = dialogueCooldowns.get(category); + + if (lastUsed != null && (currentTick - lastUsed) < DIALOGUE_COOLDOWN) { + return false; // Still on cooldown + } + + dialogueCooldowns.put(category, currentTick); + this.kidnapper.talkToPlayersInRadius(category, DIALOGUE_RADIUS); + return true; + } + + /** + * Enslave the target. + */ + private void tickEnslave() { + IBondageState state = KidnappedHelper.getKidnappedState(this.target); + if (state == null) { + this.captureState = STATE_DONE; + return; + } + + // Must be tied to enslave + if (!state.isTiedUp()) { + this.captureState = STATE_BIND; + return; + } + + // Only require gag if kidnapper had one to use + // This prevents infinite loop when kidnapper spawns without gag + boolean kidnapperHasGag = + this.kidnapper.getGagItem() != null && + !this.kidnapper.getGagItem().isEmpty(); + if (kidnapperHasGag && !state.isGagged()) { + this.captureState = STATE_GAG; + return; + } + + // Attempt capture via PrisonerService (atomic: PrisonerManager + leash) + boolean captured = false; + if ( + this.kidnapper.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel + ) { + captured = + com.tiedup.remake.prison.service.PrisonerService.get().capture( + serverLevel, + this.target, + this.kidnapper + ); + } + if (captured) { + // Chat: broadcast dialogue to nearby players (includes target since they're close) + broadcastWithCooldown(DialogueCategory.CAPTURE_ENSLAVED); + + // Screen: action bar message to target + SystemMessageManager.sendEnslaved(this.kidnapper, this.target); + + TiedUpMod.LOGGER.debug( + "[KidnapperCaptureGoal] {} successfully enslaved {}", + this.kidnapper.getNpcName(), + this.target.getName().getString() + ); + + this.captureState = STATE_DONE; + this.kidnapper.setTarget(null); + } else { + TiedUpMod.LOGGER.warn( + "[KidnapperCaptureGoal] {} failed to enslave {}", + this.kidnapper.getNpcName(), + this.target.getName().getString() + ); + + this.captureState = STATE_DONE; + this.kidnapper.setTarget(null); // Clear target so goal doesn't restart + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperDecideNextActionGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperDecideNextActionGoal.java new file mode 100644 index 0000000..48c9de7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperDecideNextActionGoal.java @@ -0,0 +1,670 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.cells.CampOwnership; +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.SystemMessageManager.MessageCategory; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.prison.service.PrisonerService; +import com.tiedup.remake.state.IBondageState; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * AI Goal for EntityKidnapper to decide what to do after capturing a slave. + * + * Phase 14.3.5: Decision system + * + * This goal activates when: + * - Kidnapper has a slave + * - Slave is NOT for sale + * - No job is assigned + * - Not in flee state + * - NOT a camp kidnapper with available cells (maid handles those) + * + * Behavior: + * 1. Wait a short delay (gives player time to react/escape) + * 2. Randomly decide: sell slave OR assign job + * 3. Transition to appropriate goal + * + * NOTE: For camp kidnappers with cells, this goal is DISABLED. + * After delivering a captive to a cell, the maid handles processing. + * This goal only activates for: + * - Wild kidnappers (no camp affiliation) + * - Camp kidnappers when all cells are full + * + * This bridges the gap between capture and sale/job systems. + */ +public class KidnapperDecideNextActionGoal extends Goal { + + private final EntityKidnapper kidnapper; + + /** Delay before deciding (ticks) - 30-60 seconds */ + private static final int MIN_DECIDE_DELAY = 600; + private static final int MAX_DECIDE_DELAY = 1200; + + /** + * Decision chances (0.0 - 1.0) + * + * BUG FIX: Integrated theft behavior into DecideNextAction. + * Problem: ThiefGoal (P7) never executed because DecideNextAction (P6) + * finished first and changed state, blocking ThiefGoal activation. + * Solution: Added theft as a third option here. + * + * Distribution: + * - 30% theft (steal items, release tied player, flee) + * - 30% sell (bring to merchant) + * - 40% job (assign labor task) + */ + private static final float THEFT_CHANCE = 0.3f; + private static final float SELL_CHANCE = 0.3f; + // Job = remaining probability (1.0 - THEFT_CHANCE - SELL_CHANCE = 0.4) + + /** Items to steal during theft (1-3) */ + private static final int MIN_ITEMS_TO_STEAL = 1; + private static final int MAX_ITEMS_TO_STEAL = 3; + + /** Current delay timer */ + private int decideTimer; + + /** Target delay for this decision */ + private int decideDelay; + + /** Whether decision has been announced */ + private boolean hasAnnounced; + + /** + * Create a new decide next action goal. + * + * @param kidnapper The kidnapper entity + */ + public KidnapperDecideNextActionGoal(EntityKidnapper kidnapper) { + this.kidnapper = kidnapper; + this.decideTimer = 0; + this.decideDelay = 0; + this.hasAnnounced = false; + this.setFlags(EnumSet.of(Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Don't interrupt dogwalk - WalkPrisonerGoal manages its own captive lifecycle + if (this.kidnapper.isDogwalking()) { + return false; + } + + // Must have a slave + if (!this.kidnapper.hasCaptives()) { + return false; + } + + // Must not already be selling + if (this.kidnapper.isSellingCaptive()) { + return false; + } + + // Must not already have a job assigned + if (this.kidnapper.isWaitingForJobToBeCompleted()) { + return false; + } + + // Must not be in flee state + if (this.kidnapper.isGetOutState()) { + return false; + } + + // Must not be tied up + if (this.kidnapper.isTiedUp()) { + return false; + } + + // Camp kidnappers with available cells should NOT use this goal + // They deliver to cells and let the maid handle everything + if (isCampKidnapperWithAvailableCells()) { + return false; + } + + return true; + } + + /** + * Check if BringToCellGoal would be able to find a cell. + * If so, this goal should not activate - let BringToCellGoal handle it. + * + * FIX: Must mirror the EXACT logic of KidnapperBringToCellGoal.findNearestCell(): + * 1. First try camp cells via getCellsByCamp() index + * 2. If no camp or camp cells full, fallback to findCellsNear() with 64 block radius + * + * Previous bug: Only checked camp cells, ignored the 64-block fallback, + * causing DecideNextAction to activate when BringToCellGoal would have found a cell. + */ + private boolean isCampKidnapperWithAvailableCells() { + if (!(kidnapper.level() instanceof ServerLevel serverLevel)) { + return false; + } + + CellRegistryV2 cellRegistry = CellRegistryV2.get(serverLevel); + CampOwnership campOwnership = CampOwnership.get(serverLevel); + + // Step 0: Check captive's campId (recaptured escaped prisoner) + // Must mirror BringToCellGoal.findNearestCell() which checks captive's camp first + IBondageState currentCaptive = this.kidnapper.getCaptive(); + if (currentCaptive != null && currentCaptive.asLivingEntity() != null) { + com.tiedup.remake.prison.PrisonerManager manager = + com.tiedup.remake.prison.PrisonerManager.get(serverLevel); + com.tiedup.remake.prison.PrisonerRecord record = manager.getRecord( + currentCaptive.asLivingEntity().getUUID() + ); + java.util.UUID captiveCampId = record.getCampId(); + if (captiveCampId != null) { + CampOwnership.CampData captiveCamp = campOwnership.getCamp( + captiveCampId + ); + if (captiveCamp != null && captiveCamp.isAlive()) { + List captiveCampCells = + cellRegistry.getCellsByCamp(captiveCampId); + for (CellDataV2 cell : captiveCampCells) { + if (!cell.isFull()) { + return true; // Captive's original camp has available cell + } + } + } + } + } + + // Step 1: Check camp cells (same as BringToCellGoal) + java.util.UUID campId = kidnapper.getAssociatedStructure(); + if (campId != null) { + CampOwnership.CampData campData = campOwnership.getCamp(campId); + if (campData != null && campData.isAlive()) { + // Check camp cells via index + List campCells = cellRegistry.getCellsByCamp( + campId + ); + + // DEBUG: Log if camp is alive but has no cells indexed + if (campCells.isEmpty()) { + TiedUpMod.LOGGER.warn( + "[DecideNextAction] {} has campId {} (alive) but NO cells in index! " + + "This is a bug - cells should be indexed when created.", + kidnapper.getNpcName(), + campId.toString().substring(0, 8) + ); + } + + for (CellDataV2 cell : campCells) { + if (!cell.isFull()) { + return true; // Camp has available cell + } + } + + // All camp cells full - can we replace a lower-priority prisoner? + // Only players can trigger replacement (they outrank NPCs) + IBondageState captive = kidnapper.getCaptive(); + if ( + captive != null && + captive.asLivingEntity() instanceof Player + ) { + // Check if there's actually a replaceable NPC prisoner in camp cells + boolean replacementPossible = false; + for (CellDataV2 cell : campCells) { + if (cell.isOccupied()) { + for (java.util.UUID prisonerId : cell.getPrisonerIds()) { + net.minecraft.world.entity.Entity prisonerEntity = + serverLevel.getEntity(prisonerId); + // If we find a non-player prisoner, replacement is possible + if ( + prisonerEntity instanceof + net.minecraft.world.entity.LivingEntity && + !(prisonerEntity instanceof Player) + ) { + replacementPossible = true; + break; + } + } + } + if (replacementPossible) break; + } + + if (replacementPossible) { + return true; // BringToCellGoal can replace an NPC prisoner + } + } + + TiedUpMod.LOGGER.debug( + "[DecideNextAction] {} camp {} has {} cells, all full", + kidnapper.getNpcName(), + campId.toString().substring(0, 8), + campCells.size() + ); + } else { + // MEDIUM FIX: Camp is dead or not found - kidnapper should NOT search for cells + // Without this check, dead camp kidnappers continue searching for cells, + // creating inconsistent behavior where they try to use other camps' cells. + // Instead, return false to let DecideNextAction handle the captive (sell/job/theft). + TiedUpMod.LOGGER.debug( + "[DecideNextAction] {} has campId {} but camp is DEAD or not found - will sell/assign job instead of searching cells", + kidnapper.getNpcName(), + campId.toString().substring(0, 8) + ); + return false; // No cell search for dead camp kidnappers + } + } + + // Step 2: Find nearest alive camp and check its cells (for wild kidnappers) + // This covers archers and other wild kidnappers who capture far from camps + // but should still use nearby camp cells instead of selling + CampOwnership.CampData nearestCamp = campOwnership.findNearestAliveCamp( + kidnapper.blockPosition(), + 200 // 200-block radius covers archers who shoot from distance + ); + if (nearestCamp != null) { + List nearestCampCells = cellRegistry.getCellsByCamp( + nearestCamp.getCampId() + ); + for (CellDataV2 cell : nearestCampCells) { + if (!cell.isFull()) { + return true; // Nearest camp has available cell + } + } + } + + // Step 3: Fallback to nearby cells (same as BringToCellGoal SEARCH_RADIUS = 64) + List nearbyCells = cellRegistry.findCellsNear( + kidnapper.blockPosition(), + 64 + ); + for (CellDataV2 cell : nearbyCells) { + if (!cell.isFull()) { + // Skip cells from dead camps + if (cell.isCampOwned()) { + java.util.UUID cellCampId = cell.getCampId(); + if ( + cellCampId != null && + !campOwnership.isCampAlive(cellCampId) + ) { + continue; + } + } + return true; // Found available nearby cell + } + } + + return false; // No cells available anywhere + } + + @Override + public boolean canContinueToUse() { + // Same conditions as canUse + return canUse(); + } + + @Override + public void start() { + this.decideTimer = 0; + this.decideDelay = + MIN_DECIDE_DELAY + + this.kidnapper.getRandom().nextInt( + MAX_DECIDE_DELAY - MIN_DECIDE_DELAY + ); + this.hasAnnounced = false; + + TiedUpMod.LOGGER.debug( + "[KidnapperDecideNextActionGoal] {} thinking what to do ({}s)", + this.kidnapper.getNpcName(), + this.decideDelay / 20 + ); + } + + @Override + public void stop() { + this.decideTimer = 0; + this.hasAnnounced = false; + } + + @Override + public void tick() { + this.decideTimer++; + + // Announce thinking after a short delay + if (!this.hasAnnounced && this.decideTimer > 40) { + announceThinking(); + this.hasAnnounced = true; + } + + // Time to decide + if (this.decideTimer >= this.decideDelay) { + makeDecision(); + } + } + + /** + * Announce that kidnapper is thinking about what to do. + */ + private void announceThinking() { + IBondageState slave = this.kidnapper.getCaptive(); + if (slave == null) return; + + // Talk to slave + if (slave.asLivingEntity() instanceof Player player) { + this.kidnapper.talkTo(player, DialogueCategory.GENERIC_TAUNT); + } + } + + /** + * Make the decision: theft, sell, or assign job. + * + * BUG FIX: Integrated theft behavior from ThiefGoal. + * Now decides between 3 options: 30% theft, 30% sell, 40% job. + * + * Jobs only work for Players (NPCs can't gather items). + * Theft only works for Players (NPCs are always sold). + */ + private void makeDecision() { + IBondageState slave = this.kidnapper.getCaptive(); + if (slave == null) return; + + boolean isPlayer = slave.asLivingEntity() instanceof Player; + + // ESCAPED SYSTEM: Check if this is a recaptured ESCAPED prisoner + // Recaptured prisoners (campId != null) should be brought back to their camp by BringToCellGoal + // DO NOT sell them or assign jobs - they belong to a camp! + if ( + isPlayer && + this.kidnapper.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel + ) { + com.tiedup.remake.prison.PrisonerManager manager = + com.tiedup.remake.prison.PrisonerManager.get(serverLevel); + com.tiedup.remake.prison.PrisonerRecord record = manager.getRecord( + slave.asLivingEntity().getUUID() + ); + java.util.UUID campId = record.getCampId(); + + if (campId != null) { + // This is a recaptured camp prisoner - DO NOT sell or assign job + // BringToCellGoal will handle bringing them back to their original camp + // + // Flow: ESCAPED → CAPTURED (campId preserved) → BringToCellGoal → IMPRISONED + // + // Verify the camp is still alive before skipping + com.tiedup.remake.cells.CampOwnership ownership = + com.tiedup.remake.cells.CampOwnership.get(serverLevel); + if (ownership.isCampAlive(campId)) { + TiedUpMod.LOGGER.info( + "[KidnapperDecideNextActionGoal] {} recaptured escaped prisoner {} - returning to camp {}", + this.kidnapper.getNpcName(), + slave.getKidnappedName(), + campId.toString().substring(0, 8) + ); + // Goal will stop on next tick - BringToCellGoal will take over + return; + } else { + // Camp is dead - release with grace period and treat as new prisoner + TiedUpMod.LOGGER.info( + "[KidnapperDecideNextActionGoal] {} captured escaped prisoner {} but camp {} is dead - treating as new prisoner", + this.kidnapper.getNpcName(), + slave.getKidnappedName(), + campId.toString().substring(0, 8) + ); + // Release prisoner from dead camp - PrisonerManager clears ownership data + manager.release( + slave.asLivingEntity().getUUID(), + serverLevel.getGameTime(), + 0 + ); + // Continue with normal decision flow + } + } + } + + float roll = this.kidnapper.getRandom().nextFloat(); + + // NPCs (Damsels) can't do jobs or be robbed - only sell them + if (!isPlayer) { + // Always sell NPCs + if (this.kidnapper.startSale()) { + TiedUpMod.LOGGER.info( + "[KidnapperDecideNextActionGoal] {} decided to sell NPC {}", + this.kidnapper.getNpcName(), + slave.getKidnappedName() + ); + } else { + // Can't sell NPC, just flee + TiedUpMod.LOGGER.warn( + "[KidnapperDecideNextActionGoal] {} couldn't sell NPC, fleeing", + this.kidnapper.getNpcName() + ); + this.kidnapper.setGetOutState(true); + } + return; + } + + // For players: theft, sell, or job + if (roll < THEFT_CHANCE) { + // THEFT: Steal items, release (but keep tied), flee + performTheft((ServerPlayer) slave.asLivingEntity()); + } else if (roll < THEFT_CHANCE + SELL_CHANCE) { + // SELL: Bring to merchant + if (this.kidnapper.startSale()) { + TiedUpMod.LOGGER.info( + "[KidnapperDecideNextActionGoal] {} decided to sell {}", + this.kidnapper.getNpcName(), + slave.getKidnappedName() + ); + } else { + // Sale failed, try job instead + assignJobFallback(slave); + } + } else { + // JOB: Assign labor task + if (this.kidnapper.assignRandomJob()) { + TiedUpMod.LOGGER.info( + "[KidnapperDecideNextActionGoal] {} assigned job to {}", + this.kidnapper.getNpcName(), + slave.getKidnappedName() + ); + + // Announce job to slave + Player player = (Player) slave.asLivingEntity(); + this.kidnapper.talkTo(player, DialogueCategory.JOB_ASSIGNED); + + // System message with job details + var job = this.kidnapper.getCurrentJob(); + if (job != null) { + SystemMessageManager.sendToPlayer( + player, + MessageCategory.SLAVE_JOB_ASSIGNED, + job.toDisplayString() + ); + } + } else { + // Job failed, try sale instead + if (this.kidnapper.startSale()) { + TiedUpMod.LOGGER.info( + "[KidnapperDecideNextActionGoal] {} decided to sell {} (job fallback)", + this.kidnapper.getNpcName(), + slave.getKidnappedName() + ); + } else { + // Both failed - try theft as last resort + performTheft((ServerPlayer) slave.asLivingEntity()); + } + } + } + + // Goal will stop on next canContinueToUse check (conditions changed) + } + + /** + * Fallback to assigning a job if sale fails. + * Only works for Players - NPCs just trigger flee. + */ + private void assignJobFallback(IBondageState slave) { + // NPCs can't do jobs - just flee + if (!(slave.asLivingEntity() instanceof Player player)) { + TiedUpMod.LOGGER.info( + "[KidnapperDecideNextActionGoal] {} can't sell NPC, fleeing", + this.kidnapper.getNpcName() + ); + this.kidnapper.setGetOutState(true); + return; + } + + if (this.kidnapper.assignRandomJob()) { + TiedUpMod.LOGGER.info( + "[KidnapperDecideNextActionGoal] {} assigned job to {} (sale fallback)", + this.kidnapper.getNpcName(), + slave.getKidnappedName() + ); + + this.kidnapper.talkTo(player, DialogueCategory.JOB_ASSIGNED); + } else { + // Both failed - just flee + TiedUpMod.LOGGER.warn( + "[KidnapperDecideNextActionGoal] {} couldn't decide, fleeing", + this.kidnapper.getNpcName() + ); + this.kidnapper.setGetOutState(true); + } + } + + /** + * Perform theft: steal items, take collar, release, grant immunity, flee. + * + * BUG FIX: Integrated from KidnapperThiefGoal. + * This is now the "theft" decision option (30% chance). + * + * Steps: + * 1. Steal 1-3 random items from player inventory + * 2. Take collar back + * 3. Release from captivity (but keep tied up!) + * 4. Grant 2-minute robbed immunity + * 5. Flee + */ + private void performTheft(ServerPlayer player) { + IBondageState captive = this.kidnapper.getCaptive(); + if (captive == null) return; + + TiedUpMod.LOGGER.info( + "[KidnapperDecideNextActionGoal] {} performing theft on {}", + this.kidnapper.getNpcName(), + player.getName().getString() + ); + + // 1. Steal random items from inventory + List stolenItems = stealRandomItems(player); + + // 2. Take collar back + ItemStack collar = captive.forceUnequip(BodyRegionV2.NECK); // Force remove + + // 3. Release from captivity (but keep tied up!) + // This releases the leash but does NOT remove the bind + captive.free(false); // false = don't drop leash (we take it) + + // 3b. Clean up PrisonerManager state (CAPTURED -> FREE) + PrisonerService.get().escape( + (ServerLevel) player.level(), + player.getUUID(), + "theft_release" + ); + + // 4. Grant robbed immunity (2 minutes) + this.kidnapper.grantRobbedImmunity(player.getUUID()); + + // 5. Notify player + StringBuilder message = new StringBuilder(); + message.append("The kidnapper robbed you"); + if (!stolenItems.isEmpty()) { + message.append(" of "); + for (int i = 0; i < stolenItems.size(); i++) { + if (i > 0) message.append(", "); + ItemStack item = stolenItems.get(i); + message + .append(item.getCount()) + .append("x ") + .append(item.getHoverName().getString()); + } + } + message.append(" and fled!"); + + player.sendSystemMessage( + Component.literal(message.toString()).withStyle(ChatFormatting.RED) + ); + + player.sendSystemMessage( + Component.literal( + "You're still tied up - struggle to break free!" + ).withStyle(ChatFormatting.YELLOW) + ); + + // 6. Kidnapper flees + this.kidnapper.setGetOutState(true); + + TiedUpMod.LOGGER.info( + "[KidnapperDecideNextActionGoal] {} completed theft and is fleeing", + this.kidnapper.getNpcName() + ); + } + + /** + * Steal random items from the player's inventory. + * + * @param player The player to steal from + * @return List of stolen items (for notification) + */ + private List stealRandomItems(ServerPlayer player) { + List stolenItems = new ArrayList<>(); + int itemsToSteal = + MIN_ITEMS_TO_STEAL + + this.kidnapper.getRandom().nextInt( + MAX_ITEMS_TO_STEAL - MIN_ITEMS_TO_STEAL + 1 + ); + + // Get non-empty inventory slots (excluding armor and offhand) + List nonEmptySlots = new ArrayList<>(); + for (int i = 0; i < player.getInventory().items.size(); i++) { + ItemStack stack = player.getInventory().items.get(i); + if (!stack.isEmpty()) { + nonEmptySlots.add(i); + } + } + + // Shuffle and steal + java.util.Collections.shuffle( + nonEmptySlots, + new java.util.Random(this.kidnapper.getRandom().nextLong()) + ); + + for (int i = 0; i < Math.min(itemsToSteal, nonEmptySlots.size()); i++) { + int slotIndex = nonEmptySlots.get(i); + ItemStack original = player.getInventory().items.get(slotIndex); + + // Take the whole stack + ItemStack stolen = original.copy(); + player.getInventory().items.set(slotIndex, ItemStack.EMPTY); + + stolenItems.add(stolen); + + TiedUpMod.LOGGER.debug( + "[KidnapperDecideNextActionGoal] Stole {} from slot {}", + stolen.getHoverName().getString(), + slotIndex + ); + } + + return stolenItems; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperDispersalGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperDispersalGoal.java new file mode 100644 index 0000000..4c0d16f --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperDispersalGoal.java @@ -0,0 +1,455 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.cells.CampOwnership; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityKidnapper; +import java.util.EnumSet; +import java.util.List; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.ai.goal.Goal; + +/** + * AI Goal that forces kidnappers to disperse when too many are near the camp center. + * + * Problem: All kidnappers cluster around cells/trader, creating unrealistic crowds. + * Solution: If more than MAX_NEAR_CENTER kidnappers are within CHECK_RADIUS of camp center, + * excess kidnappers in idle states are forced to move away. + * + * Only affects: GUARD, PATROL, IDLE, HUNT states + * Does NOT affect: CAPTURE, TRANSPORT, PUNISH, ALERT, SELLING, JOB_WATCH (active jobs) + * + * Hunters are sent further away (50 blocks) than regular kidnappers (40 blocks). + */ +public class KidnapperDispersalGoal extends Goal { + + private final EntityKidnapper kidnapper; + + /** Maximum kidnappers allowed near camp center */ + private static final int MAX_NEAR_CENTER = 8; + + /** Radius to check for overcrowding (blocks) - covers typical fortress size */ + private static final int CHECK_RADIUS = 40; + + /** Distance to send regular kidnappers away */ + private static final int DISPERSAL_DISTANCE = 40; + + /** Distance to send hunters away */ + private static final int HUNTER_DISPERSAL_DISTANCE = 50; + + /** Speed when dispersing */ + private static final double DISPERSAL_SPEED = 0.9D; + + /** Target position to move to */ + private BlockPos dispersalTarget; + + /** Ticks since dispersal started */ + private int dispersalTicks; + + /** Max ticks for dispersal (60 seconds) */ + private static final int MAX_DISPERSAL_TICKS = 1200; + + /** Ticks without progress before teleporting out */ + private static final int STUCK_TELEPORT_THRESHOLD = 200; // 10 seconds + + /** Last recorded position for stuck detection */ + private BlockPos lastPosition; + + /** Ticks spent stuck in same position */ + private int stuckTicks; + + /** Cooldown between dispersal checks (5 seconds) */ + private int checkCooldown = 0; + private static final int CHECK_COOLDOWN_DURATION = 100; + + public KidnapperDispersalGoal(EntityKidnapper kidnapper) { + this.kidnapper = kidnapper; + this.setFlags(EnumSet.of(Goal.Flag.MOVE)); + } + + @Override + public boolean canUse() { + // Cooldown check + if (this.checkCooldown > 0) { + this.checkCooldown--; + return false; + } + this.checkCooldown = CHECK_COOLDOWN_DURATION; + + // Must be associated with a camp + if (this.kidnapper.getAssociatedStructure() == null) { + return false; + } + + // Don't disperse if tied up + if (this.kidnapper.isTiedUp()) { + return false; + } + + // Only disperse if in an "idle" state (not doing active job) + KidnapperState state = this.kidnapper.getCurrentState(); + if (!isDispersibleState(state)) { + return false; + } + + // Check if overcrowded + return shouldDisperse(); + } + + /** + * Check if the current state allows dispersal. + * Only idle-like states should be forced to disperse. + */ + private boolean isDispersibleState(KidnapperState state) { + return ( + state == KidnapperState.GUARD || + state == KidnapperState.PATROL || + state == KidnapperState.IDLE || + state == KidnapperState.HUNT + ); + } + + /** + * Check if this kidnapper should disperse due to overcrowding. + */ + private boolean shouldDisperse() { + if (!(this.kidnapper.level() instanceof ServerLevel serverLevel)) { + return false; + } + + // Get camp center + BlockPos campCenter = getCampCenter(serverLevel); + if (campCenter == null) { + return false; + } + + // Check distance to camp center + double distToCenterSq = this.kidnapper.distanceToSqr( + campCenter.getX() + 0.5, + campCenter.getY(), + campCenter.getZ() + 0.5 + ); + + // If already far from center, no need to disperse + double checkRadiusSq = CHECK_RADIUS * CHECK_RADIUS; + if (distToCenterSq > checkRadiusSq) { + return false; + } + + // Count kidnappers near center + List nearbyKidnappers = serverLevel.getEntitiesOfClass( + EntityKidnapper.class, + new net.minecraft.world.phys.AABB( + campCenter.getX() - CHECK_RADIUS, + campCenter.getY() - 10, + campCenter.getZ() - CHECK_RADIUS, + campCenter.getX() + CHECK_RADIUS, + campCenter.getY() + 10, + campCenter.getZ() + CHECK_RADIUS + ), + k -> + k.getAssociatedStructure() != null && + k + .getAssociatedStructure() + .equals(this.kidnapper.getAssociatedStructure()) + ); + + // If not overcrowded, no need to disperse + if (nearbyKidnappers.size() <= MAX_NEAR_CENTER) { + return false; + } + + // Overcrowded! But only disperse if we're not in the "top 5" by some priority + // Priority: active states first, then by distance (closest stay) + // Sort by: 1) active state (stays), 2) distance to center (closest stays) + nearbyKidnappers.sort((a, b) -> { + // Active states have priority to stay + boolean aActive = !isDispersibleState(a.getCurrentState()); + boolean bActive = !isDispersibleState(b.getCurrentState()); + if (aActive != bActive) { + return aActive ? -1 : 1; // Active stays (sorted first) + } + // Otherwise sort by distance (closest first) + double aDist = a.distanceToSqr( + campCenter.getX(), + campCenter.getY(), + campCenter.getZ() + ); + double bDist = b.distanceToSqr( + campCenter.getX(), + campCenter.getY(), + campCenter.getZ() + ); + return Double.compare(aDist, bDist); + }); + + // Check if this kidnapper is in the "must disperse" group (beyond top 5) + int ourIndex = nearbyKidnappers.indexOf(this.kidnapper); + return ourIndex >= MAX_NEAR_CENTER; + } + + @Override + public boolean canContinueToUse() { + // Stop if tied up + if (this.kidnapper.isTiedUp()) { + return false; + } + + // Stop if got a target (should be chasing) + if (this.kidnapper.getTarget() != null) { + return false; + } + + // Stop if reached target + if (this.dispersalTarget != null && reachedTarget()) { + return false; + } + + // Stop after max time + if (this.dispersalTicks >= MAX_DISPERSAL_TICKS) { + return false; + } + + // Stop if state changed to active job + if (!isDispersibleState(this.kidnapper.getCurrentState())) { + return false; + } + + return true; + } + + @Override + public void start() { + this.dispersalTicks = 0; + this.stuckTicks = 0; + this.lastPosition = this.kidnapper.blockPosition(); + this.dispersalTarget = calculateDispersalTarget(); + + if (this.dispersalTarget != null) { + TiedUpMod.LOGGER.debug( + "[KidnapperDispersalGoal] {} dispersing to {} (hunter: {})", + this.kidnapper.getNpcName(), + this.dispersalTarget, + this.kidnapper.isHunter() + ); + } + } + + @Override + public void stop() { + this.kidnapper.getNavigation().stop(); + this.dispersalTarget = null; + // Reset cooldown to avoid immediate re-check + this.checkCooldown = CHECK_COOLDOWN_DURATION * 2; + } + + @Override + public void tick() { + this.dispersalTicks++; + + if (this.dispersalTarget == null) { + return; + } + + // Check if stuck (not making progress) + BlockPos currentPos = this.kidnapper.blockPosition(); + if (currentPos.equals(this.lastPosition)) { + this.stuckTicks++; + + // If stuck for too long, teleport to dispersal target + if (this.stuckTicks >= STUCK_TELEPORT_THRESHOLD) { + teleportToDispersalTarget(); + return; + } + } else { + // Made progress, reset stuck counter + this.stuckTicks = 0; + this.lastPosition = currentPos; + } + + // Navigate to dispersal target + if ( + this.dispersalTicks % 20 == 0 || + !this.kidnapper.getNavigation().isInProgress() + ) { + boolean pathStarted = this.kidnapper.getNavigation().moveTo( + this.dispersalTarget.getX() + 0.5, + this.dispersalTarget.getY(), + this.dispersalTarget.getZ() + 0.5, + DISPERSAL_SPEED + ); + + // If path couldn't be calculated, count as stuck + if (!pathStarted) { + this.stuckTicks += 20; + } + } + } + + /** + * Teleport kidnapper to dispersal target when stuck (can't path out of fortress). + * FIX: After teleport, mark target as reached to prevent infinite teleport loop. + */ + private void teleportToDispersalTarget() { + if (!(this.kidnapper.level() instanceof ServerLevel serverLevel)) { + return; + } + + // Find a safe landing spot near the dispersal target + int targetX = this.dispersalTarget.getX(); + int targetZ = this.dispersalTarget.getZ(); + + // FIX: Try to find a valid ground position instead of using heightmap blindly + // Heightmap returns surface level which may be a roof in structures + BlockPos safePos = findSafeGroundPosition( + serverLevel, + targetX, + targetZ + ); + + // Teleport + this.kidnapper.teleportTo( + safePos.getX() + 0.5, + safePos.getY(), + safePos.getZ() + 0.5 + ); + this.kidnapper.getNavigation().stop(); + + TiedUpMod.LOGGER.info( + "[KidnapperDispersalGoal] {} teleported out of fortress to {}", + this.kidnapper.getNpcName(), + safePos.toShortString() + ); + + // FIX: Mark target as reached to prevent re-teleporting in a loop + // Update dispersalTarget to actual position so reachedTarget() returns true + this.dispersalTarget = safePos; + this.stuckTicks = 0; + this.lastPosition = this.kidnapper.blockPosition(); + } + + /** + * Find a safe ground position for teleportation. + * Searches for a valid 2-block air space with solid ground below. + */ + private BlockPos findSafeGroundPosition( + ServerLevel level, + int targetX, + int targetZ + ) { + // Start from heightmap surface and search downward for valid ground + int surfaceY = level.getHeight( + net.minecraft.world.level.levelgen.Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, + targetX, + targetZ + ); + + // Search downward for a valid position (2 blocks air, 1 block solid below) + for (int y = surfaceY; y > level.getMinBuildHeight() + 2; y--) { + BlockPos testPos = new BlockPos(targetX, y, targetZ); + if (isValidStandingPosition(level, testPos)) { + return testPos; + } + } + + // Fallback: use heightmap position + return new BlockPos(targetX, surfaceY, targetZ); + } + + /** + * Check if a position is valid for standing (2 blocks air, solid floor). + */ + private boolean isValidStandingPosition(ServerLevel level, BlockPos pos) { + // Need air at feet and head level + if (!level.getBlockState(pos).isAir()) { + return false; + } + if (!level.getBlockState(pos.above()).isAir()) { + return false; + } + // Need solid ground below + return level.getBlockState(pos.below()).isSolid(); + } + + /** + * Check if reached dispersal target. + */ + private boolean reachedTarget() { + if (this.dispersalTarget == null) return true; + + double distSq = this.kidnapper.distanceToSqr( + this.dispersalTarget.getX() + 0.5, + this.dispersalTarget.getY(), + this.dispersalTarget.getZ() + 0.5 + ); + + return distSq < 16.0; // Within 4 blocks + } + + /** + * Calculate a position to disperse to. + */ + private BlockPos calculateDispersalTarget() { + if (!(this.kidnapper.level() instanceof ServerLevel serverLevel)) { + return null; + } + + BlockPos campCenter = getCampCenter(serverLevel); + if (campCenter == null) { + return null; + } + + int dispersalDist = this.kidnapper.isHunter() + ? HUNTER_DISPERSAL_DISTANCE + : DISPERSAL_DISTANCE; + + // Calculate direction away from camp center + double dx = this.kidnapper.getX() - campCenter.getX(); + double dz = this.kidnapper.getZ() - campCenter.getZ(); + double dist = Math.sqrt(dx * dx + dz * dz); + + // If very close to center, pick random direction + if (dist < 1) { + double angle = + this.kidnapper.getRandom().nextDouble() * Math.PI * 2; + dx = Math.cos(angle); + dz = Math.sin(angle); + } else { + dx /= dist; + dz /= dist; + } + + // Add some randomness to the direction (±30 degrees) + double angleOffset = + ((this.kidnapper.getRandom().nextDouble() - 0.5) * Math.PI) / 3; + double cos = Math.cos(angleOffset); + double sin = Math.sin(angleOffset); + double newDx = dx * cos - dz * sin; + double newDz = dx * sin + dz * cos; + + // Calculate target position + int targetX = campCenter.getX() + (int) (newDx * dispersalDist); + int targetZ = campCenter.getZ() + (int) (newDz * dispersalDist); + + // FIX: Use safe ground position finder instead of raw heightmap + return findSafeGroundPosition(serverLevel, targetX, targetZ); + } + + /** + * Get the camp center position. + */ + private BlockPos getCampCenter(ServerLevel serverLevel) { + java.util.UUID campId = this.kidnapper.getAssociatedStructure(); + if (campId == null) { + return null; + } + + CampOwnership ownership = CampOwnership.get(serverLevel); + CampOwnership.CampData camp = ownership.getCamp(campId); + if (camp != null) { + return camp.getCenter(); + } + + return null; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperFightBackGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperFightBackGoal.java new file mode 100644 index 0000000..8137cf3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperFightBackGoal.java @@ -0,0 +1,147 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.items.ItemTaser; +import com.tiedup.remake.items.ModItems; +import java.util.EnumSet; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * AI Goal: Kidnapper fights back when attacked while holding a captive. + * + * Conditions: + * - Kidnapper has a captive OR is waiting for job completion + * - A player attacked the kidnapper (not the master) + * + * Behavior: + * - Equips taser + * - Pursues and attacks the aggressor for 10 seconds + * - Returns to passive after timeout or target lost + */ +public class KidnapperFightBackGoal extends Goal { + + private final EntityKidnapper kidnapper; + private LivingEntity attacker; + private int aggressionTimer; + + /** Duration of aggression in ticks (10 seconds = 200 ticks) */ + private static final int AGGRESSION_DURATION = 200; + + /** Attack cooldown in ticks */ + private int attackCooldown = 0; + + public KidnapperFightBackGoal(EntityKidnapper kidnapper) { + this.kidnapper = kidnapper; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Must have captive or be waiting for job + if ( + !kidnapper.hasCaptives() && + !kidnapper.isWaitingForJobToBeCompleted() + ) { + return false; + } + + // Must have been attacked recently + LivingEntity lastAttacker = kidnapper.getLastAttacker(); + if (lastAttacker == null || !(lastAttacker instanceof Player)) { + return false; + } + + // Attacker must not be the master + if (kidnapper.isMaster((Player) lastAttacker)) { + return false; + } + + if (!lastAttacker.isAlive()) { + return false; + } + + this.attacker = lastAttacker; + return true; + } + + @Override + public void start() { + // Equip taser + kidnapper.setItemInHand( + InteractionHand.MAIN_HAND, + new ItemStack(ModItems.TASER.get()) + ); + + // Broadcast threatening dialogue + kidnapper.talkToPlayersInRadius( + "Back off! Don't touch my property!", + 20 + ); + + this.aggressionTimer = AGGRESSION_DURATION; + this.attackCooldown = 0; + } + + @Override + public void tick() { + aggressionTimer--; + if (attackCooldown > 0) { + attackCooldown--; + } + + if (attacker != null && attacker.isAlive()) { + // Look at attacker + kidnapper.getLookControl().setLookAt(attacker); + + // Pursue + kidnapper.getNavigation().moveTo(attacker, 1.3); + + // Attack if close enough and cooldown is ready + if (kidnapper.distanceTo(attacker) < 2.0 && attackCooldown <= 0) { + // Swing arm animation + kidnapper.swing(InteractionHand.MAIN_HAND); + + // Attack using standard mob attack (uses ATTACK_DAMAGE attribute) + boolean damaged = kidnapper.doHurtTarget(attacker); + + // Apply taser effects if damage was dealt + if (damaged) { + ItemStack heldItem = kidnapper.getItemInHand( + InteractionHand.MAIN_HAND + ); + if (heldItem.getItem() instanceof ItemTaser taserItem) { + taserItem.hurtEnemy(heldItem, attacker, kidnapper); + } + } + + attackCooldown = 20; // 1 second cooldown + } + } + } + + @Override + public boolean canContinueToUse() { + // Stop if timer expired + if (aggressionTimer <= 0) return false; + + // Stop if attacker dead or gone + if (attacker == null || !attacker.isAlive()) return false; + + // Stop if attacker too far + if (kidnapper.distanceTo(attacker) > 30) return false; + + return true; + } + + @Override + public void stop() { + // Unequip taser + kidnapper.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); + this.attacker = null; + this.aggressionTimer = 0; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperFindTargetGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperFindTargetGoal.java new file mode 100644 index 0000000..5f06cd7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperFindTargetGoal.java @@ -0,0 +1,133 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.entities.EntityKidnapper; +import java.util.EnumSet; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.Goal; + +/** + * AI Goal for EntityKidnapper to find suitable targets. + * + * Phase 14.3.2: Target acquisition AI + * + * This goal: + * 1. Searches for nearby players and damsels within range + * 2. Validates targets (not creative, not already enslaved, etc.) + * 3. Sets the kidnapper's target when found + * + * Based on original EntityAILookForPlayer from 1.12.2 + */ +public class KidnapperFindTargetGoal extends Goal { + + private final EntityKidnapper kidnapper; + private final int searchRadius; + private LivingEntity foundTarget; + private int cooldown; + + /** + * Create a new find target goal. + * + * @param kidnapper The kidnapper entity + * @param searchRadius Maximum search radius in blocks + */ + public KidnapperFindTargetGoal( + EntityKidnapper kidnapper, + int searchRadius + ) { + this.kidnapper = kidnapper; + this.searchRadius = searchRadius; + this.cooldown = 0; + this.setFlags(EnumSet.of(Goal.Flag.TARGET)); + } + + @Override + public boolean canUse() { + // Cooldown between searches + if (this.cooldown > 0) { + this.cooldown--; + return false; + } + + // Don't search if: + // - Already has a slave + // - Already has a target + // - Is tied up + // - Waiting for job to be completed + if (this.kidnapper.hasCaptives()) { + return false; + } + if (this.kidnapper.getTarget() != null) { + return false; + } + if (this.kidnapper.isTiedUp()) { + return false; + } + if (this.kidnapper.isWaitingForJobToBeCompleted()) { + return false; + } + + // Find closest suitable target (player or damsel) + this.foundTarget = this.kidnapper.getClosestSuitableTarget( + this.searchRadius + ); + + if (this.foundTarget != null) { + return true; + } + + // Set cooldown before next search (20 ticks = 1 second) + this.cooldown = 20; + return false; + } + + @Override + public boolean canContinueToUse() { + // Stop if: + // - Target is null + // - Kidnapper got tied up + // - Kidnapper already has a slave + // - Kidnapper waiting for job + // - Target is no longer valid + if (this.foundTarget == null) { + return false; + } + if (this.kidnapper.isTiedUp()) { + return false; + } + if (this.kidnapper.hasCaptives()) { + return false; + } + if (this.kidnapper.isWaitingForJobToBeCompleted()) { + return false; + } + if (!this.kidnapper.isSuitableTarget(this.foundTarget)) { + return false; + } + + return true; + } + + @Override + public void start() { + // Set the target on the kidnapper + this.kidnapper.setTarget(this.foundTarget); + } + + @Override + public void stop() { + this.foundTarget = null; + // Don't clear kidnapper target here - let capture goal handle it + } + + @Override + public void tick() { + // Keep looking at target + if (this.foundTarget != null) { + this.kidnapper.getLookControl().setLookAt( + this.foundTarget, + 30.0F, + 30.0F + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperFleeSafeGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperFleeSafeGoal.java new file mode 100644 index 0000000..d36262c --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperFleeSafeGoal.java @@ -0,0 +1,95 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityKidnapper; + +/** + * AI Goal for EntityKidnapper to flee to safety without a slave. + * + * Phase 14.3.5: Flee behavior without slave + * + * This goal activates when: + * - Kidnapper does NOT have a slave + * - getOutState is true (triggered after releasing slave or job/sale completion) + * + * Behavior: + * 1. Walk fast in random direction + * 2. After reaching safe distance or timeout, clear getOutState + * 3. Resume normal behavior (patrol, hunt, etc.) + * + * Based on original EntityAIGettingAwaySafeState from 1.12.2 + */ +public class KidnapperFleeSafeGoal extends AbstractKidnapperFleeGoal { + + /** Speed when fleeing (faster than normal) */ + private static final double FLEE_SPEED = 1.2D; + + /** How long to flee before considering safe (ticks) */ + private static final int FLEE_DURATION = 300; // 15 seconds + + /** How far to flee before considering safe (blocks) */ + private static final double SAFE_DISTANCE = 50.0D; + + /** Search range for flee targets */ + private static final int SEARCH_RANGE = 15; + + /** + * Create a new flee safe goal. + * + * @param kidnapper The kidnapper entity + */ + public KidnapperFleeSafeGoal(EntityKidnapper kidnapper) { + super(kidnapper); + } + + // ==================== CONFIGURATION ==================== + + @Override + protected double getFleeSpeed() { + return FLEE_SPEED; + } + + @Override + protected int getMaxFleeDuration() { + return FLEE_DURATION; + } + + @Override + protected double getMaxFleeDistance() { + return SAFE_DISTANCE; + } + + @Override + protected int getSearchRangeRadius() { + return SEARCH_RANGE; + } + + // ==================== LOGIC HOOKS ==================== + + @Override + protected boolean checkCaptiveCondition() { + // Must NOT have a captive (that's KidnapperFleeWithCaptiveGoal) + return !this.kidnapper.hasCaptives(); + } + + @Override + protected void onStart() { + TiedUpMod.LOGGER.debug( + "[KidnapperFleeSafeGoal] {} fleeing to safety", + this.kidnapper.getNpcName() + ); + } + + @Override + protected void onFleeComplete() { + TiedUpMod.LOGGER.info( + "[KidnapperFleeSafeGoal] {} reached safety, resuming normal behavior", + this.kidnapper.getNpcName() + ); + + // Clear the get out state + this.kidnapper.setGetOutState(false); + + // Goal will stop on next canContinueToUse check + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperFleeWithCaptiveGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperFleeWithCaptiveGoal.java new file mode 100644 index 0000000..b962ebf --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperFleeWithCaptiveGoal.java @@ -0,0 +1,164 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.state.IBondageState; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * AI Goal for EntityKidnapper to flee while carrying a captive. + * + * Phase 14.3.5: Flee behavior with captive + * + * This goal activates when: + * - Kidnapper has a captive + * - getOutState is true (triggered after job completion or sale failure) + * + * Behavior: + * 1. Walk fast in random direction away from current position + * 2. After timeout, release captive and drop a knife for them + * 3. Transition to KidnapperFleeSafeGoal (flee without captive) + * + * Based on original EntityAIWanderFastGettingAway from 1.12.2 + */ +public class KidnapperFleeWithCaptiveGoal extends AbstractKidnapperFleeGoal { + + /** Speed when fleeing (faster than normal) */ + private static final double FLEE_SPEED = 1.3D; + + /** How long to flee before releasing captive (ticks) */ + private static final int FLEE_DURATION = 200; // 10 seconds + + /** How far to flee before releasing captive (blocks) */ + private static final double FLEE_DISTANCE = 30.0D; + + /** Search range for flee targets */ + private static final int SEARCH_RANGE = 10; + + /** Dialogue radius */ + private static final int DIALOGUE_RADIUS = 15; + + /** + * Create a new flee with captive goal. + * + * @param kidnapper The kidnapper entity + */ + public KidnapperFleeWithCaptiveGoal(EntityKidnapper kidnapper) { + super(kidnapper); + } + + // ==================== CONFIGURATION ==================== + + @Override + protected double getFleeSpeed() { + return FLEE_SPEED; + } + + @Override + protected int getMaxFleeDuration() { + return FLEE_DURATION; + } + + @Override + protected double getMaxFleeDistance() { + return FLEE_DISTANCE; + } + + @Override + protected int getSearchRangeRadius() { + return SEARCH_RANGE; + } + + // ==================== LOGIC HOOKS ==================== + + @Override + protected boolean checkCaptiveCondition() { + // Must have a captive + return this.kidnapper.hasCaptives(); + } + + @Override + protected void onStart() { + // Announce fleeing + IBondageState captive = this.kidnapper.getCaptive(); + if (captive != null) { + // Talk to nearby players + this.kidnapper.talkToPlayersInRadius( + DialogueCategory.GET_OUT, + DIALOGUE_RADIUS + ); + + // Direct message to captive + if (captive.asLivingEntity() instanceof Player player) { + this.kidnapper.talkTo(player, DialogueCategory.GET_OUT); + } + } + + TiedUpMod.LOGGER.info( + "[KidnapperFleeWithCaptiveGoal] {} starting to flee with captive", + this.kidnapper.getNpcName() + ); + } + + @Override + protected void onFleeComplete() { + releaseCaptiveAndFlee(); + } + + // ==================== CAPTIVE RELEASE ==================== + + /** + * Release the captive and drop a knife for them. + */ + private void releaseCaptiveAndFlee() { + IBondageState captive = this.kidnapper.getCaptive(); + if (captive == null) return; + + // Talk to captive about being released + if (captive.asLivingEntity() instanceof Player player) { + // Kidnapper dialogue + this.kidnapper.talkTo(player, DialogueCategory.FREED); + + // System message + SystemMessageManager.sendFreed(player); + } + + // Drop an iron knife for the captive to escape + ItemStack knife = new ItemStack( + ModItems.getKnife(com.tiedup.remake.items.base.KnifeVariant.IRON) + ); + captive.asLivingEntity().spawnAtLocation(knife); + + TiedUpMod.LOGGER.info( + "[KidnapperFleeWithCaptiveGoal] {} released {} and dropped knife", + this.kidnapper.getNpcName(), + captive.getKidnappedName() + ); + + // Free the captive (but keep them tied - they have the knife to escape) + captive.free(true); + + // Clear captive reference to prevent escape spam + this.kidnapper.removeCaptive(captive, true); + + // Release from central PrisonerManager + if ( + this.kidnapper.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel + ) { + PrisonerManager manager = PrisonerManager.get(serverLevel); + manager.release( + captive.asLivingEntity().getUUID(), + serverLevel.getGameTime() + ); + } + + // Continue in get out state (will trigger KidnapperFleeSafeGoal) + // getOutState stays true, but now without captive + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperGuardGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperGuardGoal.java new file mode 100644 index 0000000..168dd4d --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperGuardGoal.java @@ -0,0 +1,609 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.cells.CampOwnership; +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.DialogueBridge; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.prison.LaborRecord; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerState; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.util.TiedUpSounds; +import java.util.EnumSet; +import java.util.List; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * AI Goal for EntityKidnapper to guard occupied cells. + * + * Behavior: + * 1. Activates when there are nearby occupied cells (with guard limit) + * 2. Patrols around the cell (not standing still!) + * 3. Looks at prisoners periodically + * 4. Detects escape attempts + * 5. Short guard duration (30-60 seconds), then switches to patrol + * 6. Maximum 1-2 guards per cell to avoid crowding + */ +public class KidnapperGuardGoal extends Goal { + + private final EntityKidnapper kidnapper; + + /** Visual detection radius (blocks) */ + private static final double VISUAL_DETECTION_RADIUS = 10.0; + + /** Patrol radius around cell */ + private static final double PATROL_RADIUS = 6.0; + + /** Minimum guard duration (30 seconds = 600 ticks) */ + private static final int MIN_GUARD_DURATION = 600; + + /** Maximum guard duration (60 seconds = 1200 ticks) */ + private static final int MAX_GUARD_DURATION = 1200; + + /** Check interval for escape detection (every second) */ + private static final int ESCAPE_CHECK_INTERVAL = 20; + + /** Interval between patrol waypoints (4 seconds) */ + private static final int PATROL_WAYPOINT_INTERVAL = 80; + + /** Maximum guards per cell */ + private static final int MAX_GUARDS_PER_CELL = 2; + + /** Current cell being guarded */ + private CellDataV2 guardedCell; + + /** Current patrol waypoint */ + private BlockPos currentWaypoint; + + /** Ticks spent guarding */ + private int guardTicks; + + /** Duration to guard before switching to patrol */ + private int guardDuration; + + /** Tick counter for escape checks */ + private int escapeCheckCounter; + + /** Tick counter for waypoint changes */ + private int waypointCounter; + + /** Tick counter for random spank behavior */ + private int spankCooldown; + + /** Minimum ticks between spanks (30 seconds) */ + private static final int SPANK_COOLDOWN_MIN = 600; + + /** Maximum ticks between spanks (2 minutes) */ + private static final int SPANK_COOLDOWN_MAX = 2400; + + /** Chance to spank when cooldown is ready (1/200 = 0.5% per tick = ~10% over 20 ticks) */ + private static final int SPANK_CHANCE = 200; + + /** Cooldown interval for canUse() checks (40 ticks = 2 seconds) */ + private static final int CAN_USE_CHECK_INTERVAL = 40; + + /** Next tick when canUse() can run expensive checks */ + private int nextCanUseCheckTick = 0; + + public KidnapperGuardGoal(EntityKidnapper kidnapper) { + this.kidnapper = kidnapper; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Cheap checks first (no cooldown needed) + if (this.kidnapper.isTiedUp()) { + return false; + } + + if (this.kidnapper.hasCaptives()) { + return false; + } + + if (this.kidnapper.getTarget() != null) { + return false; + } + + // Hunters don't guard cells - they hunt in the wilderness + if (this.kidnapper.isHunter()) { + return false; + } + + if (this.kidnapper.getCurrentState() == KidnapperState.ALERT) { + return false; + } + + if (this.kidnapper.isWaitingForJobToBeCompleted()) { + return false; + } + + if (this.kidnapper.isGetOutState()) { + return false; + } + + // Throttle expensive cell/guard checks + int currentTick = this.kidnapper.tickCount; + if (currentTick < this.nextCanUseCheckTick) { + return false; + } + this.nextCanUseCheckTick = currentTick + CAN_USE_CHECK_INTERVAL; + + // Expensive checks: cell search and guard count + List occupiedCells = + this.kidnapper.getNearbyCellsWithPrisoners(); + if (occupiedCells.isEmpty()) { + return false; + } + + this.guardedCell = selectCellNeedingGuard(occupiedCells); + return this.guardedCell != null; + } + + @Override + public boolean canContinueToUse() { + if (this.kidnapper.isTiedUp()) { + return false; + } + + if (this.kidnapper.hasCaptives()) { + return false; + } + + if (this.kidnapper.getTarget() != null) { + return false; + } + + // Stop if state changed (e.g., to ALERT) + if (this.kidnapper.getCurrentState() == KidnapperState.ALERT) { + return false; + } + + // Stop if entering "get out" state (should flee) + if (this.kidnapper.isGetOutState()) { + return false; + } + + // Stop if guard duration exceeded (switch to patrol) + if (this.guardTicks >= this.guardDuration) { + return false; + } + + // Stop if cell is no longer occupied + if (this.guardedCell == null || !this.guardedCell.isOccupied()) { + return false; + } + + return true; + } + + @Override + public void start() { + this.guardTicks = 0; + this.escapeCheckCounter = 0; + this.waypointCounter = 0; + this.spankCooldown = + SPANK_COOLDOWN_MIN + + this.kidnapper.getRandom().nextInt( + SPANK_COOLDOWN_MAX - SPANK_COOLDOWN_MIN + ); + + // Short random guard duration (30-60 seconds) + this.guardDuration = + MIN_GUARD_DURATION + + this.kidnapper.getRandom().nextInt( + MAX_GUARD_DURATION - MIN_GUARD_DURATION + ); + + // Start at a random patrol point + this.currentWaypoint = calculateRandomPatrolPoint(); + + // Set state to GUARD + this.kidnapper.setCurrentState(KidnapperState.GUARD); + + TiedUpMod.LOGGER.debug( + "[KidnapperGuardGoal] {} started guarding cell for {}s", + this.kidnapper.getNpcName(), + this.guardDuration / 20 + ); + } + + @Override + public void stop() { + // Return to IDLE state if not transitioning to ALERT + if (this.kidnapper.getCurrentState() == KidnapperState.GUARD) { + this.kidnapper.setCurrentState(KidnapperState.IDLE); + } + + this.guardedCell = null; + this.currentWaypoint = null; + this.kidnapper.getNavigation().stop(); + } + + @Override + public void tick() { + this.guardTicks++; + this.escapeCheckCounter++; + this.waypointCounter++; + + // Decrement spank cooldown + if (this.spankCooldown > 0) { + this.spankCooldown--; + } + + // Change waypoint periodically for patrol movement + if (this.waypointCounter >= PATROL_WAYPOINT_INTERVAL) { + this.waypointCounter = 0; + this.currentWaypoint = calculateRandomPatrolPoint(); + } + + // Move towards current waypoint (patrol around cell) + if (this.currentWaypoint != null) { + double distSq = this.kidnapper.distanceToSqr( + this.currentWaypoint.getX() + 0.5, + this.currentWaypoint.getY(), + this.currentWaypoint.getZ() + 0.5 + ); + + // If far from waypoint, navigate there + if (distSq > 4.0) { + if (this.guardTicks % 20 == 0) { + // Recalc path every second + this.kidnapper.getNavigation().moveTo( + this.currentWaypoint.getX() + 0.5, + this.currentWaypoint.getY(), + this.currentWaypoint.getZ() + 0.5, + 0.6 // Slow patrol speed + ); + } + } else { + // Reached waypoint - look at prisoners while standing + lookAtPrisoners(); + + // Random chance to spank a nearby prisoner + tryRandomSpank(); + } + } + + // Check for escapes periodically + if (this.escapeCheckCounter >= ESCAPE_CHECK_INTERVAL) { + this.escapeCheckCounter = 0; + checkForEscapes(); + } + } + + /** + * Select a cell that needs guarding, respecting guard limits. + * Returns null if all cells have enough guards. + */ + private CellDataV2 selectCellNeedingGuard(List cells) { + if (!(this.kidnapper.level() instanceof ServerLevel serverLevel)) { + return null; + } + + CellDataV2 bestCell = null; + double bestScore = Double.MAX_VALUE; + + for (CellDataV2 cell : cells) { + // Count current guards for this cell + int guardCount = countGuardsForCell(serverLevel, cell); + + // Skip if cell has enough guards + if (guardCount >= MAX_GUARDS_PER_CELL) { + continue; + } + + // Score based on distance (prefer closer) and guard count (prefer less guarded) + double distSq = this.kidnapper.blockPosition().distSqr( + cell.getCorePos() + ); + double score = distSq + (guardCount * 1000); // Penalize already-guarded cells + + if (score < bestScore) { + bestScore = score; + bestCell = cell; + } + } + + return bestCell; + } + + /** + * Count how many kidnappers are currently guarding a specific cell. + */ + private int countGuardsForCell(ServerLevel level, CellDataV2 cell) { + BlockPos cellPos = cell.getCorePos(); + int count = 0; + + for (EntityKidnapper other : level.getEntitiesOfClass( + EntityKidnapper.class, + this.kidnapper.getBoundingBox().inflate(30) + )) { + if (other == this.kidnapper) continue; + if (other.getCurrentState() != KidnapperState.GUARD) continue; + + // Check if this kidnapper is near the same cell + double distSq = other.blockPosition().distSqr(cellPos); + if (distSq < 100) { + // Within 10 blocks of cell + count++; + } + } + + return count; + } + + /** + * Calculate a random patrol point around the cell. + */ + private BlockPos calculateRandomPatrolPoint() { + if (this.guardedCell == null) { + return this.kidnapper.blockPosition(); + } + + BlockPos center = this.guardedCell.getCorePos(); + + // Random angle + double angle = this.kidnapper.getRandom().nextDouble() * Math.PI * 2; + + // Random distance (between 3 and PATROL_RADIUS) + double distance = + 3.0 + + this.kidnapper.getRandom().nextDouble() * (PATROL_RADIUS - 3.0); + + int offsetX = (int) (Math.cos(angle) * distance); + int offsetZ = (int) (Math.sin(angle) * distance); + + return center.offset(offsetX, 0, offsetZ); + } + + /** + * Look at prisoners in the guarded cell. + */ + private void lookAtPrisoners() { + if (this.guardedCell == null) return; + if ( + !(this.kidnapper.level() instanceof ServerLevel serverLevel) + ) return; + + // Get prisoner UUIDs + List prisonerIds = this.guardedCell.getPrisonerIds(); + if (prisonerIds.isEmpty()) return; + + // Look at a random prisoner + UUID targetId = prisonerIds.get( + this.kidnapper.getRandom().nextInt(prisonerIds.size()) + ); + + Player prisoner = serverLevel + .getServer() + .getPlayerList() + .getPlayer(targetId); + if (prisoner != null && prisoner.isAlive()) { + this.kidnapper.getLookControl().setLookAt(prisoner, 30.0F, 30.0F); + + // Occasional dialogue when watching (every ~30 seconds on average) + if ( + this.guardTicks % 600 == 0 && + this.kidnapper.getRandom().nextFloat() < 0.5f + ) { + DialogueBridge.talkTo( + this.kidnapper, + prisoner, + "guard.watching" + ); + } + } + } + + /** + * Try to randomly spank a nearby prisoner. + * Has a small chance to happen when cooldown is ready. + */ + private void tryRandomSpank() { + // Check cooldown + if (this.spankCooldown > 0) { + return; + } + + // Random chance check + if (this.kidnapper.getRandom().nextInt(SPANK_CHANCE) != 0) { + return; + } + + if (this.guardedCell == null) return; + if ( + !(this.kidnapper.level() instanceof ServerLevel serverLevel) + ) return; + + // Find a nearby tied prisoner + List prisonerIds = this.guardedCell.getPrisonerIds(); + if (prisonerIds.isEmpty()) return; + + // Pick a random prisoner + UUID targetId = prisonerIds.get( + this.kidnapper.getRandom().nextInt(prisonerIds.size()) + ); + + ServerPlayer prisoner = serverLevel + .getServer() + .getPlayerList() + .getPlayer(targetId); + if (prisoner == null || !prisoner.isAlive()) return; + + // Must be close enough to spank + if (this.kidnapper.distanceTo(prisoner) > 3.0) return; + + // Must be tied up + IBondageState state = KidnappedHelper.getKidnappedState(prisoner); + if (state == null || !state.isTiedUp()) return; + + // Execute the spank! + executeSpank(prisoner, serverLevel); + + // Reset cooldown + this.spankCooldown = + SPANK_COOLDOWN_MIN + + this.kidnapper.getRandom().nextInt( + SPANK_COOLDOWN_MAX - SPANK_COOLDOWN_MIN + ); + } + + /** + * Execute the spank action on a prisoner. + * Equips paddle, plays sound, shows particles. + * + * @param prisoner The prisoner to spank + * @param serverLevel The server level + */ + private void executeSpank(ServerPlayer prisoner, ServerLevel serverLevel) { + // Save current held item + ItemStack previousItem = this.kidnapper.getMainHandItem().copy(); + + // Equip paddle temporarily + this.kidnapper.setItemInHand( + InteractionHand.MAIN_HAND, + new ItemStack(ModItems.PADDLE.get()) + ); + + // Play spank sound + TiedUpSounds.playSlapSound(prisoner); + + // Spawn particles at prisoner's behind area + serverLevel.sendParticles( + ParticleTypes.SMOKE, + prisoner.getX(), + prisoner.getY() + 0.5, + prisoner.getZ(), + 5, + 0.2, + 0.1, + 0.2, + 0.02 + ); + + // Swing arm animation + this.kidnapper.swing(InteractionHand.MAIN_HAND); + + // Look at prisoner + this.kidnapper.getLookControl().setLookAt(prisoner, 30.0F, 30.0F); + + TiedUpMod.LOGGER.debug( + "[KidnapperGuardGoal] {} spanked prisoner {}", + this.kidnapper.getNpcName(), + prisoner.getName().getString() + ); + + // Schedule paddle removal after a short delay (restore previous item) + // We'll do it in next tick check via a simple flag approach + // For simplicity, just put back the original item immediately + // (the visual effect is enough) + this.kidnapper.setItemInHand(InteractionHand.MAIN_HAND, previousItem); + } + + /** + * Check if any prisoners have escaped their cells. + */ + private void checkForEscapes() { + if (this.guardedCell == null) return; + if ( + !(this.kidnapper.level() instanceof ServerLevel serverLevel) + ) return; + + for (UUID prisonerId : this.guardedCell.getPrisonerIds()) { + Player prisoner = serverLevel + .getServer() + .getPlayerList() + .getPlayer(prisonerId); + if (prisoner == null || !prisoner.isAlive()) { + continue; + } + + // FIX: Check if prisoner is on authorized labor task (extraction, working, returning) + // Don't trigger escape alert for prisoners legitimately outside their cell + PrisonerManager manager = PrisonerManager.get(serverLevel); + PrisonerState state = manager.getState(prisonerId); + if (state == PrisonerState.WORKING) { + continue; // Skip - prisoner is on authorized labor task + } + // Skip PROTECTED (released) and FREE prisoners - they're no longer captive + if ( + state == PrisonerState.PROTECTED || state == PrisonerState.FREE + ) { + continue; // Skip - prisoner was released or freed + } + // Also check labor phase for more detailed work state + LaborRecord.WorkPhase workPhase = manager.getWorkPhase(prisonerId); + if (workPhase != LaborRecord.WorkPhase.IDLE) { + continue; // Skip - prisoner is in some work phase (extracting, working, returning) + } + + // Check if prisoner is within visual detection range + double distance = this.kidnapper.distanceTo(prisoner); + if (distance > VISUAL_DETECTION_RADIUS) { + continue; + } + + // Check if prisoner is outside their cell + BlockPos prisonerPos = prisoner.blockPosition(); + if (!this.kidnapper.isInsideCell(prisonerPos, this.guardedCell)) { + // ESCAPE DETECTED! + onEscapeDetected(prisoner); + return; + } + } + } + + /** + * Handle escape detection - transition to ALERT state. + */ + private void onEscapeDetected(LivingEntity escapee) { + TiedUpMod.LOGGER.info( + "[KidnapperGuardGoal] {} detected escape! {} is outside cell", + this.kidnapper.getNpcName(), + escapee.getName().getString() + ); + + // Dialogue when detecting escape + if (escapee instanceof Player player) { + DialogueBridge.talkTo( + this.kidnapper, + player, + "guard.escape_detected" + ); + } + + // Check if prisoner is still tied up + IBondageState state = KidnappedHelper.getKidnappedState(escapee); + if (state == null || !state.isTiedUp()) { + // Prisoner freed themselves - set as alert target + this.kidnapper.setAlertTarget(escapee); + this.kidnapper.setCurrentState(KidnapperState.ALERT); + this.kidnapper.broadcastAlert(escapee); + } else { + // Prisoner still tied but moved - could be glitch or teleport + // Set as direct target for quick recapture + this.kidnapper.setTarget(escapee); + this.kidnapper.setAlertTarget(escapee); + this.kidnapper.setCurrentState(KidnapperState.ALERT); + this.kidnapper.broadcastAlert(escapee); + } + } + + @Override + public boolean requiresUpdateEveryTick() { + return false; // Don't need every tick, default interval is fine + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperHuntGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperHuntGoal.java new file mode 100644 index 0000000..9a07c14 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperHuntGoal.java @@ -0,0 +1,457 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.ai.StuckDetector; +import com.tiedup.remake.util.KidnapperAIHelper; +import java.util.EnumSet; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.level.Level; + +/** + * AI Goal for EntityKidnapper to patrol far from camp looking for prey. + * + * This goal activates for "hunter" kidnappers and makes them patrol + * at a distance of 80-150 blocks from the camp center. + * + * Purpose: Spread out NPCs so they don't all cluster in the camp structure. + * Hunters actively look for players in the wilderness around the camp. + */ +public class KidnapperHuntGoal extends Goal { + + private final EntityKidnapper kidnapper; + + /** Minimum hunt radius from camp center */ + private static final int MIN_HUNT_RADIUS = 80; + + /** Maximum hunt radius from camp center */ + private static final int MAX_HUNT_RADIUS = 150; + + /** Maximum distance before forced return (further than hunt radius) */ + private static final int MAX_DISTANCE_FROM_CAMP = 200; + + /** Ticks between hunt point changes */ + private static final int HUNT_INTERVAL = 400; // 20 seconds + + /** Current hunt target position */ + private BlockPos huntTarget; + + /** Ticks since last hunt point change */ + private int huntTimer; + + /** Speed when hunting (moderate pace) */ + private static final double HUNT_SPEED = 0.9D; + + /** Speed when returning to camp (faster) */ + private static final double RETURN_SPEED = 1.0D; + + /** Whether currently returning to camp */ + private boolean returningToCamp; + + /** Cooldown after failing to find a hunt point */ + private int huntFailureCooldown = 0; + + /** Cooldown duration after hunt point selection fails */ + private static final int HUNT_FAILURE_COOLDOWN = 100; + + /** Duration of a hunt session before returning to camp (5-10 minutes) */ + private int huntSessionDuration; + + /** Ticks spent in current hunt session */ + private int huntSessionTimer; + + /** Minimum hunt session duration (5 minutes) */ + private static final int MIN_HUNT_SESSION = 6000; + + /** Maximum hunt session duration (10 minutes) */ + private static final int MAX_HUNT_SESSION = 12000; + + /** Cooldown before starting another hunt (30-60 seconds) */ + private int huntCooldown = 0; + + /** Stuck detection — 2 detour attempts then pick new hunt point */ + private final StuckDetector stuckDetector = new StuckDetector(60, 2); + + /** Minimum cooldown between hunts (30 seconds) */ + private static final int MIN_HUNT_COOLDOWN = 600; + + /** Maximum cooldown between hunts (60 seconds) */ + private static final int MAX_HUNT_COOLDOWN = 1200; + + public KidnapperHuntGoal(EntityKidnapper kidnapper) { + this.kidnapper = kidnapper; + this.huntTarget = null; + this.huntTimer = 0; + this.returningToCamp = false; + this.setFlags(EnumSet.of(Goal.Flag.MOVE)); + } + + @Override + public boolean canUse() { + // Only hunters can use this goal + if (!this.kidnapper.isHunter()) { + return false; + } + + // Cooldown check + if (this.huntCooldown > 0) { + this.huntCooldown--; + return false; + } + + // Don't hunt if tied up + if (this.kidnapper.isTiedUp()) { + return false; + } + + // Don't hunt if has a target (should be chasing) + if (this.kidnapper.getTarget() != null) { + return false; + } + + // Don't hunt if has a captive (should be transporting) + if (this.kidnapper.hasCaptives()) { + return false; + } + + // Must be associated with a camp to hunt + if (this.kidnapper.getAssociatedStructure() == null) { + return false; + } + + return true; + } + + @Override + public boolean canContinueToUse() { + // Stop if kidnapper got tied up + if (this.kidnapper.isTiedUp()) { + return false; + } + + // Stop if got a target + if (this.kidnapper.getTarget() != null) { + return false; + } + + // Stop if got a captive + if (this.kidnapper.hasCaptives()) { + return false; + } + + // Stop if no longer associated with a camp + if (this.kidnapper.getAssociatedStructure() == null) { + return false; + } + + // Stop if hunt session is over + if (this.huntSessionTimer >= this.huntSessionDuration) { + return false; + } + + return true; + } + + @Override + public void start() { + this.huntTimer = 0; + this.huntTarget = null; + this.returningToCamp = false; + this.huntSessionTimer = 0; + this.stuckDetector.reset(); + this.huntSessionDuration = + MIN_HUNT_SESSION + + this.kidnapper.getRandom().nextInt( + MAX_HUNT_SESSION - MIN_HUNT_SESSION + ); + + // Set state to HUNT + this.kidnapper.setCurrentState(KidnapperState.HUNT); + + TiedUpMod.LOGGER.debug( + "[KidnapperHuntGoal] {} starting hunt session (duration: {} ticks)", + this.kidnapper.getNpcName(), + this.huntSessionDuration + ); + } + + @Override + public void stop() { + this.kidnapper.getNavigation().stop(); + this.huntTarget = null; + this.returningToCamp = false; + this.stuckDetector.reset(); + + // Set cooldown before next hunt + this.huntCooldown = + MIN_HUNT_COOLDOWN + + this.kidnapper.getRandom().nextInt( + MAX_HUNT_COOLDOWN - MIN_HUNT_COOLDOWN + ); + + // Return to IDLE state if still in HUNT + if (this.kidnapper.getCurrentState() == KidnapperState.HUNT) { + this.kidnapper.setCurrentState(KidnapperState.IDLE); + } + + TiedUpMod.LOGGER.debug( + "[KidnapperHuntGoal] {} ending hunt, cooldown: {} ticks", + this.kidnapper.getNpcName(), + this.huntCooldown + ); + } + + @Override + public void tick() { + this.huntSessionTimer++; + + // Get camp center + BlockPos campCenter = getCampCenter(); + if (campCenter == null) return; + + double distanceFromCamp = this.kidnapper.distanceToSqr( + campCenter.getX(), + campCenter.getY(), + campCenter.getZ() + ); + double maxDistSq = MAX_DISTANCE_FROM_CAMP * MAX_DISTANCE_FROM_CAMP; + + // Too far! Return to camp perimeter + if (distanceFromCamp > maxDistSq) { + if (!this.returningToCamp) { + this.returningToCamp = true; + TiedUpMod.LOGGER.debug( + "[KidnapperHuntGoal] {} too far from camp, returning", + this.kidnapper.getNpcName() + ); + } + returnToCampPerimeter(campCenter); + return; + } + + // Back in range, resume hunt + if (this.returningToCamp) { + this.returningToCamp = false; + this.huntTarget = null; + this.huntTimer = 0; + } + + // Update hunt timer + this.huntTimer++; + + // Handle failure cooldown + if (this.huntFailureCooldown > 0) { + this.huntFailureCooldown--; + return; + } + + // Check if need new hunt point + if ( + this.huntTarget == null || + this.huntTimer >= HUNT_INTERVAL || + reachedHuntTarget() + ) { + selectNewHuntPoint(campCenter); + this.huntTimer = 0; + this.stuckDetector.reset(); + + if (this.huntTarget == null) { + this.huntFailureCooldown = HUNT_FAILURE_COOLDOWN; + } + } + + // Update stuck detection + this.stuckDetector.update(this.kidnapper); + + if (this.stuckDetector.isStuck()) { + if (this.stuckDetector.shouldTeleport()) { + // Pick a new hunt point instead of teleporting + this.huntTarget = null; + this.stuckDetector.reset(); + TiedUpMod.LOGGER.debug( + "[KidnapperHuntGoal] {} stuck, picking new hunt point", + this.kidnapper.getNpcName() + ); + } else if ( + this.kidnapper.level() instanceof ServerLevel serverLevel + ) { + BlockPos detour = this.stuckDetector.tryDetour( + serverLevel, + this.kidnapper.blockPosition(), + 5 + ); + if (detour != null) { + this.kidnapper.getNavigation().moveTo( + detour.getX() + 0.5, + detour.getY(), + detour.getZ() + 0.5, + HUNT_SPEED + ); + } + } + return; + } + + // Skip normal navigation if in detour + if (this.stuckDetector.isInDetour()) { + return; + } + + // Move to hunt target + if ( + this.huntTarget != null && + (this.huntTimer % 10 == 0 || + !this.kidnapper.getNavigation().isInProgress()) + ) { + boolean pathFound = this.kidnapper.getNavigation().moveTo( + this.huntTarget.getX() + 0.5, + this.huntTarget.getY(), + this.huntTarget.getZ() + 0.5, + HUNT_SPEED + ); + if (!pathFound) { + this.stuckDetector.onPathFailed(); + } + } + } + + /** + * Get the camp center position. + */ + @javax.annotation.Nullable + private BlockPos getCampCenter() { + if (!(this.kidnapper.level() instanceof ServerLevel serverLevel)) { + return null; + } + + java.util.UUID campId = this.kidnapper.getAssociatedStructure(); + if (campId == null) { + return null; + } + + com.tiedup.remake.cells.CampOwnership ownership = + com.tiedup.remake.cells.CampOwnership.get(serverLevel); + com.tiedup.remake.cells.CampOwnership.CampData camp = ownership.getCamp( + campId + ); + if (camp != null) { + return camp.getCenter(); + } + + return null; + } + + /** + * Return to camp perimeter (not center, stay at hunt distance). + */ + private void returnToCampPerimeter(BlockPos campCenter) { + // Calculate direction from camp to kidnapper + double dx = this.kidnapper.getX() - campCenter.getX(); + double dz = this.kidnapper.getZ() - campCenter.getZ(); + double dist = Math.sqrt(dx * dx + dz * dz); + + if (dist < 1) { + // Already at camp, pick random direction + double angle = + this.kidnapper.getRandom().nextDouble() * Math.PI * 2; + dx = Math.cos(angle); + dz = Math.sin(angle); + } else { + dx /= dist; + dz /= dist; + } + + // Target: edge of hunt zone (MIN_HUNT_RADIUS from camp) + double targetX = campCenter.getX() + dx * MIN_HUNT_RADIUS; + double targetZ = campCenter.getZ() + dz * MIN_HUNT_RADIUS; + + this.kidnapper.getNavigation().moveTo( + targetX, + campCenter.getY(), + targetZ, + RETURN_SPEED + ); + } + + /** + * Check if reached current hunt target. + */ + private boolean reachedHuntTarget() { + if (this.huntTarget == null) return true; + + double distSq = this.kidnapper.distanceToSqr( + this.huntTarget.getX() + 0.5, + this.huntTarget.getY(), + this.huntTarget.getZ() + 0.5 + ); + + return distSq < 9.0; // Within 3 blocks + } + + /** + * Select a new hunt point in the hunt zone (80-150 blocks from camp). + */ + private void selectNewHuntPoint(BlockPos campCenter) { + Level level = this.kidnapper.level(); + + // Try to find a valid hunt point + for (int attempts = 0; attempts < 15; attempts++) { + // Random angle + double angle = + this.kidnapper.getRandom().nextDouble() * Math.PI * 2; + + // Random distance within hunt zone + int distance = + MIN_HUNT_RADIUS + + this.kidnapper.getRandom().nextInt( + MAX_HUNT_RADIUS - MIN_HUNT_RADIUS + ); + + int targetX = + campCenter.getX() + (int) (Math.cos(angle) * distance); + int targetZ = + campCenter.getZ() + (int) (Math.sin(angle) * distance); + int targetY = campCenter.getY(); + + BlockPos testPos = new BlockPos(targetX, targetY, targetZ); + + // Search up and down for valid ground + for (int yOffset = -10; yOffset <= 10; yOffset++) { + BlockPos checkPos = testPos.offset(0, yOffset, 0); + + if (isValidHuntPoint(level, checkPos)) { + this.huntTarget = checkPos; + TiedUpMod.LOGGER.debug( + "[KidnapperHuntGoal] {} new hunt point: {} ({} blocks from camp)", + this.kidnapper.getNpcName(), + checkPos, + distance + ); + return; + } + } + } + + // Failed to find valid point + this.huntTarget = null; + } + + /** + * Check if a position is valid for hunting (solid ground, air above). + */ + private boolean isValidHuntPoint(Level level, BlockPos pos) { + if (!KidnapperAIHelper.isValidGroundPosition(level, pos)) { + return false; + } + + // Check path is possible (not too far vertically from current position) + double yDiff = Math.abs(pos.getY() - this.kidnapper.getY()); + if (yDiff > 20) { + return false; + } + + return true; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperHuntMonstersGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperHuntMonstersGoal.java new file mode 100644 index 0000000..723c874 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperHuntMonstersGoal.java @@ -0,0 +1,240 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.items.ItemTaser; +import com.tiedup.remake.items.ModItems; +import java.util.EnumSet; +import java.util.List; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.monster.Creeper; +import net.minecraft.world.entity.monster.Monster; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.AABB; + +/** + * AI Goal for camp kidnappers to hunt and kill monsters. + * + * Purpose: Protect captives from hostile mobs (zombies, skeletons, etc.) + * + * This goal: + * 1. Only activates for camp kidnappers (with associated structure) + * 2. Scans for nearby monsters + * 3. Equips taser and attacks with stun effects + * 4. Uses smaller scan radius when protecting a captive + * + * Combat bonuses vs monsters: + * - 2x damage dealt (kidnappers are trained fighters) + * - Taser stun effects (Slowness + Weakness) + * - Creeper explosion cancelled for 1 second on hit + * - 50% damage reduction (handled in EntityKidnapper.hurt()) + * + * Priority: 1 (high - monster defense is important for captive safety) + */ +public class KidnapperHuntMonstersGoal extends Goal { + + private final EntityKidnapper kidnapper; + private LivingEntity targetMonster; + + /** Scan radius when patrolling */ + private static final int SCAN_RADIUS = 16; + + /** Smaller radius when protecting captive (stay close) */ + private static final int SCAN_RADIUS_WITH_CAPTIVE = 10; + + /** Ticks between attacks (faster than normal) */ + private static final int ATTACK_COOLDOWN = 15; + + /** Damage multiplier against monsters */ + private static final float MONSTER_DAMAGE_MULTIPLIER = 2.0f; + + /** Current attack cooldown timer */ + private int attackCooldown = 0; + + public KidnapperHuntMonstersGoal(EntityKidnapper kidnapper) { + this.kidnapper = kidnapper; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Only camp kidnappers hunt monsters + if (kidnapper.getAssociatedStructure() == null) { + return false; + } + + // Don't hunt if chasing a player target + if (kidnapper.getTarget() != null) { + return false; + } + + // Don't hunt if tied up + if (kidnapper.isTiedUp()) { + return false; + } + + // Hunt monsters (with or without captive - protect captives from mobs!) + this.targetMonster = findNearestMonster(); + return this.targetMonster != null; + } + + @Override + public boolean canContinueToUse() { + if (this.targetMonster == null || !this.targetMonster.isAlive()) { + return false; + } + + // Stop if we now have a player target + if (kidnapper.getTarget() != null) { + return false; + } + + // Stop if tied up + if (kidnapper.isTiedUp()) { + return false; + } + + // Stop if monster is too far away (double the scan radius) + int radius = kidnapper.hasCaptives() + ? SCAN_RADIUS_WITH_CAPTIVE + : SCAN_RADIUS; + return ( + kidnapper.distanceToSqr(this.targetMonster) < radius * radius * 4 + ); + } + + @Override + public void start() { + this.attackCooldown = 0; + + // Equip taser + this.kidnapper.setItemInHand( + InteractionHand.MAIN_HAND, + new ItemStack(ModItems.TASER.get()) + ); + } + + @Override + public void stop() { + this.targetMonster = null; + this.kidnapper.getNavigation().stop(); + + // Unequip taser + this.kidnapper.setItemInHand( + InteractionHand.MAIN_HAND, + ItemStack.EMPTY + ); + } + + @Override + public void tick() { + if (this.targetMonster == null) { + return; + } + + // Look at the monster + this.kidnapper.getLookControl().setLookAt(this.targetMonster); + + double distSq = this.kidnapper.distanceToSqr(this.targetMonster); + + // Move closer if not in attack range + if (distSq > 4.0) { + this.kidnapper.getNavigation().moveTo(this.targetMonster, 1.2); + } else { + // In attack range - stop and attack with bonus damage + this.kidnapper.getNavigation().stop(); + + if (this.attackCooldown <= 0) { + attackMonsterWithBonus(this.targetMonster); + this.attackCooldown = ATTACK_COOLDOWN; + } + } + + // Decrement cooldown + if (this.attackCooldown > 0) { + this.attackCooldown--; + } + } + + /** + * Attack a monster with taser - bonus damage and stun effects. + * Kidnappers deal 2x damage to monsters and apply taser stun. + * Creepers have their explosion cancelled for 1 second. + * + * @param target The monster to attack + */ + private void attackMonsterWithBonus(LivingEntity target) { + // Swing arm animation + this.kidnapper.swing(InteractionHand.MAIN_HAND); + + // Get base attack damage and apply multiplier + float baseDamage = (float) this.kidnapper.getAttributeValue( + Attributes.ATTACK_DAMAGE + ); + float bonusDamage = baseDamage * MONSTER_DAMAGE_MULTIPLIER; + + // Deal damage directly with bonus + boolean damaged = target.hurt( + this.kidnapper.damageSources().mobAttack(this.kidnapper), + bonusDamage + ); + + // Apply taser effects if damage was dealt + if (damaged) { + ItemStack heldItem = this.kidnapper.getItemInHand( + InteractionHand.MAIN_HAND + ); + if (heldItem.getItem() instanceof ItemTaser taserItem) { + taserItem.hurtEnemy(heldItem, target, this.kidnapper); + } + + // Special: Cancel creeper explosion for 1 second + if (target instanceof Creeper creeper) { + // Set swell direction to -1 (shrinking) to cancel explosion + creeper.setSwellDir(-1); + } + } + } + + /** + * Find the nearest monster within scan radius. + * + * @return The nearest monster, or null if none found + */ + private LivingEntity findNearestMonster() { + int radius = kidnapper.hasCaptives() + ? SCAN_RADIUS_WITH_CAPTIVE + : SCAN_RADIUS; + AABB searchBox = this.kidnapper.getBoundingBox().inflate(radius); + + List monsters = this.kidnapper.level().getEntitiesOfClass( + Monster.class, + searchBox, + m -> + m.isAlive() && + !m.isSpectator() && + Math.abs(m.getY() - this.kidnapper.getY()) < 5 && + this.kidnapper.getSensing().hasLineOfSight(m) + ); + + if (monsters.isEmpty()) { + return null; + } + + // Find nearest + Monster nearest = null; + double nearestDist = Double.MAX_VALUE; + + for (Monster m : monsters) { + double dist = this.kidnapper.distanceToSqr(m); + if (dist < nearestDist) { + nearestDist = dist; + nearest = m; + } + } + + return nearest; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperLookAtPlayerGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperLookAtPlayerGoal.java new file mode 100644 index 0000000..8c2a6af --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperLookAtPlayerGoal.java @@ -0,0 +1,88 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.entities.EntityKidnapper; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.ai.goal.LookAtPlayerGoal; +import net.minecraft.world.entity.player.Player; + +/** + * Custom LookAtPlayerGoal that ignores players who are imprisoned in cells. + * + * This prevents kidnappers from staring at players in cells, which is: + * 1. Creepy behavior + * 2. Gives the impression that the kidnapper wants to interact with them + * 3. The prisoner is already being handled by the maid + */ +public class KidnapperLookAtPlayerGoal extends LookAtPlayerGoal { + + private final EntityKidnapper kidnapper; + + public KidnapperLookAtPlayerGoal( + EntityKidnapper kidnapper, + Class target, + float range + ) { + super(kidnapper, target, range); + this.kidnapper = kidnapper; + } + + public KidnapperLookAtPlayerGoal( + EntityKidnapper kidnapper, + Class target, + float range, + float probability + ) { + super(kidnapper, target, range, probability); + this.kidnapper = kidnapper; + } + + @Override + public boolean canUse() { + if (!super.canUse()) { + return false; + } + + // Check if the target player is in a cell + if (this.lookAt instanceof Player player) { + if (isPlayerInCell(player)) { + return false; // Don't look at prisoners in cells + } + } + + return true; + } + + @Override + public boolean canContinueToUse() { + if (!super.canContinueToUse()) { + return false; + } + + // Stop looking if player enters a cell + if (this.lookAt instanceof Player player) { + if (isPlayerInCell(player)) { + return false; + } + } + + return true; + } + + /** + * Check if a player is inside a cell (registered as prisoner). + */ + private boolean isPlayerInCell(Player player) { + if (!(kidnapper.level() instanceof ServerLevel serverLevel)) { + return false; + } + + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + + // Check if player is registered as a prisoner in any cell + CellDataV2 cell = registry.findCellByPrisoner(player.getUUID()); + + return cell != null; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperPatrolGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperPatrolGoal.java new file mode 100644 index 0000000..552f81f --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperPatrolGoal.java @@ -0,0 +1,499 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.ai.StuckDetector; +import com.tiedup.remake.util.KidnapperAIHelper; +import com.tiedup.remake.util.teleport.Position; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +/** + * AI Goal for EntityKidnapper to patrol around their home area. + * + * Phase 14.3.4: Patrol behavior for kidnappers + * + * This goal: + * 1. Activates when kidnapper has no target and no slave + * 2. Makes kidnapper patrol around their home position + * 3. Keeps kidnapper within patrol radius + * 4. Returns to home if too far away + * + * Patrol behavior creates more realistic "guard" NPCs that protect an area. + */ +public class KidnapperPatrolGoal extends Goal { + + private final EntityKidnapper kidnapper; + + /** Default patrol radius if none set */ + private static final int DEFAULT_PATROL_RADIUS = 40; + + /** Maximum distance before forced return to home */ + private static final int MAX_DISTANCE_FROM_HOME = 80; + + /** Ticks between patrol point changes */ + private static final int PATROL_INTERVAL = 200; // 10 seconds + + /** Current patrol target */ + private BlockPos patrolTarget; + + /** Ticks since last patrol point change */ + private int patrolTimer; + + /** Speed when patrolling (slower than chasing) */ + private static final double PATROL_SPEED = 0.8D; + + /** Speed when returning to home (faster) */ + private static final double RETURN_SPEED = 1.0D; + + /** Whether currently returning to home */ + private boolean returningHome; + + /** Cached patrol markers for efficiency */ + private List cachedPatrolMarkers = new ArrayList<>(); + + /** Current index in the patrol marker sequence */ + private int currentPatrolIndex = 0; + + /** Ticks until next marker cache refresh */ + private int markerCacheRefreshTimer = 0; + + /** Cooldown after failing to find a patrol point (avoids expensive retries) */ + private int patrolFailureCooldown = 0; + + /** Cooldown duration after patrol point selection fails (60 ticks = 3 seconds) */ + private static final int PATROL_FAILURE_COOLDOWN = 60; + + /** Stuck detection — 2 detour attempts then pick new patrol point */ + private final StuckDetector stuckDetector = new StuckDetector(60, 2); + + /** How often to refresh the marker cache (30 seconds) */ + private static final int MARKER_CACHE_REFRESH_INTERVAL = 600; + + /** + * Create a new patrol goal. + * + * @param kidnapper The kidnapper entity + */ + public KidnapperPatrolGoal(EntityKidnapper kidnapper) { + this.kidnapper = kidnapper; + this.patrolTarget = null; + this.patrolTimer = 0; + this.returningHome = false; + this.setFlags(EnumSet.of(Goal.Flag.MOVE)); + } + + @Override + public boolean canUse() { + // Don't patrol if tied up + if (this.kidnapper.isTiedUp()) { + return false; + } + + // Don't patrol if has a target (should be chasing) + if (this.kidnapper.getTarget() != null) { + return false; + } + + // Don't patrol if has a slave (should be doing slave management) + if (this.kidnapper.hasCaptives()) { + return false; + } + + // Hunters use HUNT goal instead of PATROL (they patrol far from camp) + if (this.kidnapper.isHunter()) { + return false; + } + + // Need a cell assigned OR be associated with a camp to patrol + if ( + !this.kidnapper.hasCellAssignedFromCollar() && + this.kidnapper.getAssociatedStructure() == null + ) { + return false; + } + + // 10% chance to skip patrol and allow free exploration via WaterAvoidingRandomStrollGoal + if (this.kidnapper.getRandom().nextInt(10) == 0) { + return false; + } + + return true; + } + + @Override + public boolean canContinueToUse() { + // Stop if kidnapper got tied up + if (this.kidnapper.isTiedUp()) { + return false; + } + + // Stop if got a target + if (this.kidnapper.getTarget() != null) { + return false; + } + + // Stop if got a slave + if (this.kidnapper.hasCaptives()) { + return false; + } + + // Stop if no cell assigned AND not associated with a camp + if ( + !this.kidnapper.hasCellAssignedFromCollar() && + this.kidnapper.getAssociatedStructure() == null + ) { + return false; + } + + return true; + } + + @Override + public void start() { + this.patrolTimer = 0; + this.patrolTarget = null; + this.returningHome = false; + this.stuckDetector.reset(); + + // Set state to PATROL + this.kidnapper.setCurrentState(KidnapperState.PATROL); + + // Refresh patrol markers on start + refreshPatrolMarkers(); + + TiedUpMod.LOGGER.debug( + "[KidnapperPatrolGoal] {} started patrolling ({} markers found)", + this.kidnapper.getNpcName(), + this.cachedPatrolMarkers.size() + ); + } + + /** + * Refresh the cached patrol markers. + */ + private void refreshPatrolMarkers() { + this.cachedPatrolMarkers = this.kidnapper.getNearbyPatrolMarkers(64); + this.markerCacheRefreshTimer = MARKER_CACHE_REFRESH_INTERVAL; + + // Sort markers by distance for a more logical patrol route + if (!this.cachedPatrolMarkers.isEmpty()) { + this.cachedPatrolMarkers.sort((a, b) -> { + // Sort by angle from current position for circular patrol + BlockPos kidnapperPos = this.kidnapper.blockPosition(); + double angleA = Math.atan2( + a.getZ() - kidnapperPos.getZ(), + a.getX() - kidnapperPos.getX() + ); + double angleB = Math.atan2( + b.getZ() - kidnapperPos.getZ(), + b.getX() - kidnapperPos.getX() + ); + return Double.compare(angleA, angleB); + }); + } + } + + @Override + public void stop() { + this.kidnapper.getNavigation().stop(); + this.patrolTarget = null; + this.returningHome = false; + this.stuckDetector.reset(); + + // Return to IDLE state if still in PATROL + if (this.kidnapper.getCurrentState() == KidnapperState.PATROL) { + this.kidnapper.setCurrentState(KidnapperState.IDLE); + } + } + + @Override + public void tick() { + // Get cell spawn point as patrol center + BlockPos cellSpawn = getCellSpawnPoint(); + if (cellSpawn == null) return; + + // Refresh marker cache periodically + this.markerCacheRefreshTimer--; + if (this.markerCacheRefreshTimer <= 0) { + refreshPatrolMarkers(); + } + + // Check if too far from cell + double distanceFromCell = this.kidnapper.distanceToSqr( + cellSpawn.getX(), + cellSpawn.getY(), + cellSpawn.getZ() + ); + double maxDistSq = MAX_DISTANCE_FROM_HOME * MAX_DISTANCE_FROM_HOME; + + if (distanceFromCell > maxDistSq) { + // Too far! Return to cell area + if (!this.returningHome) { + this.returningHome = true; + TiedUpMod.LOGGER.debug( + "[KidnapperPatrolGoal] {} too far from home, returning", + this.kidnapper.getNpcName() + ); + } + returnToCell(cellSpawn); + return; + } + + // Back in range, resume patrol + if (this.returningHome) { + this.returningHome = false; + this.patrolTarget = null; + this.patrolTimer = 0; + } + + // Update patrol timer + this.patrolTimer++; + + // Handle failure cooldown - wait before retrying expensive patrol point selection + if (this.patrolFailureCooldown > 0) { + this.patrolFailureCooldown--; + return; + } + + // Check if need new patrol point + if ( + this.patrolTarget == null || + this.patrolTimer >= PATROL_INTERVAL || + reachedPatrolTarget() + ) { + selectNewPatrolPoint(cellSpawn); + this.patrolTimer = 0; + this.stuckDetector.reset(); + + // If selection failed, apply cooldown to avoid expensive retries every tick + if (this.patrolTarget == null) { + this.patrolFailureCooldown = PATROL_FAILURE_COOLDOWN; + } + } + + // Update stuck detection + this.stuckDetector.update(this.kidnapper); + + if (this.stuckDetector.isStuck()) { + if (this.stuckDetector.shouldTeleport()) { + // Pick a new patrol point instead of teleporting + this.patrolTarget = null; + this.stuckDetector.reset(); + TiedUpMod.LOGGER.debug( + "[KidnapperPatrolGoal] {} stuck, picking new patrol point", + this.kidnapper.getNpcName() + ); + } else if ( + this.kidnapper.level() instanceof ServerLevel serverLevel + ) { + BlockPos detour = this.stuckDetector.tryDetour( + serverLevel, + this.kidnapper.blockPosition(), + 5 + ); + if (detour != null) { + this.kidnapper.getNavigation().moveTo( + detour.getX() + 0.5, + detour.getY(), + detour.getZ() + 0.5, + PATROL_SPEED + ); + } + } + return; + } + + // Skip normal navigation if in detour + if (this.stuckDetector.isInDetour()) { + return; + } + + // Move to patrol target - recalculate every few ticks for smooth movement + if ( + this.patrolTarget != null && + (this.patrolTimer % 5 == 0 || + !this.kidnapper.getNavigation().isInProgress()) + ) { + boolean pathFound = this.kidnapper.getNavigation().moveTo( + this.patrolTarget.getX() + 0.5, + this.patrolTarget.getY(), + this.patrolTarget.getZ() + 0.5, + PATROL_SPEED + ); + if (!pathFound) { + this.stuckDetector.onPathFailed(); + } + } + } + + /** + * Get the patrol anchor point. + * Priority: cell spawn point > camp center > null + */ + @javax.annotation.Nullable + private BlockPos getCellSpawnPoint() { + if ( + !(this.kidnapper.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel) + ) { + return null; + } + + // First try: cell from collar + java.util.UUID cellId = this.kidnapper.getCellIdFromCollar(); + if (cellId != null) { + com.tiedup.remake.cells.CellDataV2 cell = + com.tiedup.remake.cells.CellRegistryV2.get(serverLevel).getCell( + cellId + ); + if (cell != null) { + return cell.getSpawnPoint(); + } + } + + // Second try: camp center (for camp kidnappers without collar cell) + java.util.UUID campId = this.kidnapper.getAssociatedStructure(); + if (campId != null) { + com.tiedup.remake.cells.CampOwnership ownership = + com.tiedup.remake.cells.CampOwnership.get(serverLevel); + com.tiedup.remake.cells.CampOwnership.CampData camp = + ownership.getCamp(campId); + if (camp != null) { + return camp.getCenter(); + } + } + + return null; + } + + /** + * Return to cell area. + */ + private void returnToCell(BlockPos cellSpawn) { + this.kidnapper.getNavigation().moveTo( + cellSpawn.getX() + 0.5, + cellSpawn.getY(), + cellSpawn.getZ() + 0.5, + RETURN_SPEED + ); + } + + /** + * Check if reached current patrol target. + */ + private boolean reachedPatrolTarget() { + if (this.patrolTarget == null) return true; + + double distSq = this.kidnapper.distanceToSqr( + this.patrolTarget.getX() + 0.5, + this.patrolTarget.getY(), + this.patrolTarget.getZ() + 0.5 + ); + + return distSq < 4.0; // Within 2 blocks + } + + /** + * Select a new patrol point. + * Uses patrol markers if available, otherwise falls back to random points. + */ + private void selectNewPatrolPoint(BlockPos cellSpawn) { + // First, try to use patrol markers + if (!this.cachedPatrolMarkers.isEmpty()) { + // Go to next marker in sequence + this.currentPatrolIndex = + (this.currentPatrolIndex + 1) % this.cachedPatrolMarkers.size(); + this.patrolTarget = this.cachedPatrolMarkers.get( + this.currentPatrolIndex + ); + + TiedUpMod.LOGGER.debug( + "[KidnapperPatrolGoal] {} moving to patrol marker {}/{}: {}", + this.kidnapper.getNpcName(), + this.currentPatrolIndex + 1, + this.cachedPatrolMarkers.size(), + this.patrolTarget + ); + return; + } + + // No patrol markers - fall back to random patrol + selectRandomPatrolPoint(cellSpawn); + } + + /** + * Select a random patrol point within radius of cell. + * Used as fallback when no patrol markers are available. + */ + private void selectRandomPatrolPoint(BlockPos cellSpawn) { + Level level = this.kidnapper.level(); + int patrolRadius = DEFAULT_PATROL_RADIUS; + + // Try to find a valid patrol point + for (int attempts = 0; attempts < 10; attempts++) { + // Random offset within radius + int offsetX = + this.kidnapper.getRandom().nextInt(patrolRadius * 2 + 1) - + patrolRadius; + int offsetZ = + this.kidnapper.getRandom().nextInt(patrolRadius * 2 + 1) - + patrolRadius; + + int targetX = cellSpawn.getX() + offsetX; + int targetZ = cellSpawn.getZ() + offsetZ; + int targetY = cellSpawn.getY(); + + // Find ground level at target position + BlockPos testPos = new BlockPos(targetX, targetY, targetZ); + + // Search up and down for valid ground + for (int yOffset = -5; yOffset <= 5; yOffset++) { + BlockPos checkPos = testPos.offset(0, yOffset, 0); + + if (isValidPatrolPoint(level, checkPos)) { + this.patrolTarget = checkPos; + TiedUpMod.LOGGER.debug( + "[KidnapperPatrolGoal] {} new random patrol point: {}", + this.kidnapper.getNpcName(), + checkPos + ); + return; + } + } + } + + // LOW FIX: Failed to find valid point - leave patrolTarget null to trigger cooldown + // instead of setting it to cellSpawn (which would bypass the cooldown check) + this.patrolTarget = null; + TiedUpMod.LOGGER.debug( + "[KidnapperPatrolGoal] {} failed to find valid patrol point after 10 attempts, cooldown applied", + this.kidnapper.getNpcName() + ); + } + + /** + * Check if a position is valid for patrolling (solid ground, air above). + * Uses KidnapperAIHelper for base ground check, adds Y distance constraint. + */ + private boolean isValidPatrolPoint(Level level, BlockPos pos) { + // Base ground position check + if (!KidnapperAIHelper.isValidGroundPosition(level, pos)) { + return false; + } + + // Check path is possible (not too far vertically from current position) + double yDiff = Math.abs(pos.getY() - this.kidnapper.getY()); + if (yDiff > 10) { + return false; + } + + return true; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperPunishGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperPunishGoal.java new file mode 100644 index 0000000..4b84f6a --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperPunishGoal.java @@ -0,0 +1,306 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.RestraintApplicator; +import java.util.EnumSet; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.Goal; + +/** + * AI Goal for EntityKidnapper to punish a recaptured prisoner. + * + * Phase 3: Kidnapper Revamp - Advanced AI + * + * This goal: + * 1. Activates when kidnapper is in PUNISH state with a captive + * 2. Tightens binds (resets resistance to max) + * 3. Adds gag if missing + * 4. Adds blindfold on repeat escapes + * 5. Resets struggle progress + * 6. Sends intimidating message + * 7. Duration: 5-10 seconds + */ +public class KidnapperPunishGoal extends Goal { + + private final EntityKidnapper kidnapper; + + /** Minimum punishment duration (30 seconds = 600 ticks) */ + private static final int MIN_PUNISH_DURATION = 600; + + /** Maximum punishment duration (90 seconds = 1800 ticks) */ + private static final int MAX_PUNISH_DURATION = 1800; + + /** Dialogue radius */ + private static final int DIALOGUE_RADIUS = 20; + + /** Ticks between punishment actions */ + private static final int ACTION_INTERVAL = 40; // 2 seconds + + /** The captive being punished */ + private IBondageState captive; + + /** Ticks spent punishing */ + private int punishTicks; + + /** Duration of this punishment session */ + private int punishDuration; + + /** Track which actions have been performed */ + private boolean tightenedBinds = false; + private boolean addedGag = false; + private boolean addedBlindfold = false; + + public KidnapperPunishGoal(EntityKidnapper kidnapper) { + this.kidnapper = kidnapper; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Must be in PUNISH state + if (this.kidnapper.getCurrentState() != KidnapperState.PUNISH) { + return false; + } + + // Must have a captive + if (!this.kidnapper.hasCaptives()) { + return false; + } + + // Must not be tied up + if (this.kidnapper.isTiedUp()) { + return false; + } + + this.captive = this.kidnapper.getCaptive(); + return this.captive != null; + } + + @Override + public boolean canContinueToUse() { + if (this.kidnapper.isTiedUp()) { + return false; + } + + if (!this.kidnapper.hasCaptives() || this.captive == null) { + return false; + } + + // Stop when punishment duration complete + if (this.punishTicks >= this.punishDuration) { + return false; + } + + return this.kidnapper.getCurrentState() == KidnapperState.PUNISH; + } + + @Override + public void start() { + this.punishTicks = 0; + this.tightenedBinds = false; + this.addedGag = false; + this.addedBlindfold = false; + + // Random punishment duration + this.punishDuration = + MIN_PUNISH_DURATION + + this.kidnapper.getRandom().nextInt( + MAX_PUNISH_DURATION - MIN_PUNISH_DURATION + ); + + // Stop navigation - stay in place + this.kidnapper.getNavigation().stop(); + + // Announce punishment + this.kidnapper.talkToPlayersInRadius( + DialogueCategory.PUNISH, + DIALOGUE_RADIUS + ); + + TiedUpMod.LOGGER.info( + "[KidnapperPunishGoal] {} started punishing {} for {} ticks", + this.kidnapper.getNpcName(), + this.captive.getKidnappedName(), + this.punishDuration + ); + } + + @Override + public void stop() { + // MEDIUM FIX: Validate before transitioning to TRANSPORT + // Without these checks, we could transition to TRANSPORT with no captive or dead camp, + // causing a deadlock where BringToCellGoal activates but has nothing to transport + if (this.kidnapper.getCurrentState() == KidnapperState.PUNISH) { + // Only transition to TRANSPORT if we still have a captive + if (this.kidnapper.hasCaptives()) { + // If this is a camp kidnapper, validate the camp is still alive + java.util.UUID campId = this.kidnapper.getAssociatedStructure(); + if (campId != null) { + // Camp kidnapper - check if camp still exists + if ( + this.kidnapper.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel + ) { + com.tiedup.remake.cells.CampOwnership ownership = + com.tiedup.remake.cells.CampOwnership.get( + serverLevel + ); + com.tiedup.remake.cells.CampOwnership.CampData campData = + ownership.getCamp(campId); + + if (campData != null && campData.isAlive()) { + // Camp is alive - safe to transport + this.kidnapper.setCurrentState( + KidnapperState.TRANSPORT + ); + } else { + // Camp is dead - release captive and return to idle + TiedUpMod.LOGGER.warn( + "[KidnapperPunishGoal] {} camp is dead, releasing captive instead of transporting", + this.kidnapper.getNpcName() + ); + this.kidnapper.abandonCaptive(); + this.kidnapper.setCurrentState(KidnapperState.IDLE); + } + } else { + // Client side or invalid level - default to transport + this.kidnapper.setCurrentState( + KidnapperState.TRANSPORT + ); + } + } else { + // Wild kidnapper - always safe to transport + this.kidnapper.setCurrentState(KidnapperState.TRANSPORT); + } + } else { + // No captive - return to idle + TiedUpMod.LOGGER.warn( + "[KidnapperPunishGoal] {} lost captive during punishment, returning to idle", + this.kidnapper.getNpcName() + ); + this.kidnapper.setCurrentState(KidnapperState.IDLE); + } + } + + TiedUpMod.LOGGER.info( + "[KidnapperPunishGoal] {} finished punishing {}", + this.kidnapper.getNpcName(), + this.captive != null ? this.captive.getKidnappedName() : "unknown" + ); + + this.captive = null; + this.punishTicks = 0; + } + + @Override + public void tick() { + if (this.captive == null) { + return; + } + + this.punishTicks++; + + // Look at the captive + LivingEntity captiveEntity = this.captive.asLivingEntity(); + if (captiveEntity != null) { + this.kidnapper.getLookControl().setLookAt( + captiveEntity, + 30.0F, + 30.0F + ); + } + + // Perform punishment actions at intervals + if (this.punishTicks % ACTION_INTERVAL == 0) { + performPunishmentAction(); + } + } + + /** + * Perform the next punishment action in sequence. + */ + private void performPunishmentAction() { + // Action 1: Tighten binds (reset resistance to max) + if (!this.tightenedBinds) { + tightenBinds(); + this.tightenedBinds = true; + return; + } + + // Action 2: Add gag if missing + if (!this.addedGag) { + addGagIfMissing(); + this.addedGag = true; + return; + } + + // Action 3: Add blindfold (for repeat escapes - check if already gagged means this is serious) + if (!this.addedBlindfold) { + addBlindfoldIfWarranted(); + this.addedBlindfold = true; + return; + } + } + + /** + * Tighten the captive's binds by resetting resistance to maximum. + */ + private void tightenBinds() { + LivingEntity captiveEntity = this.captive.asLivingEntity(); + if ( + RestraintApplicator.applyOrTightenBind( + captiveEntity, + this.kidnapper.getBindItem() + ) + ) { + TiedUpMod.LOGGER.debug( + "[KidnapperPunishGoal] Tightened/applied binds on {}", + this.captive.getKidnappedName() + ); + } + } + + /** + * Add a gag if the captive doesn't have one. + */ + private void addGagIfMissing() { + LivingEntity captiveEntity = this.captive.asLivingEntity(); + if ( + RestraintApplicator.applyGagIfMissing( + captiveEntity, + this.kidnapper.getGagItem() + ) + ) { + TiedUpMod.LOGGER.debug( + "[KidnapperPunishGoal] Applied gag to {}", + this.captive.getKidnappedName() + ); + } + } + + /** + * Add a blindfold if warranted (repeat escape or already has other accessories). + */ + private void addBlindfoldIfWarranted() { + LivingEntity captiveEntity = this.captive.asLivingEntity(); + if ( + RestraintApplicator.applyBlindfoldIfMissing( + captiveEntity, + this.kidnapper.getBlindfoldItem() + ) + ) { + TiedUpMod.LOGGER.debug( + "[KidnapperPunishGoal] Applied blindfold to {}", + this.captive.getKidnappedName() + ); + } + } + + @Override + public boolean requiresUpdateEveryTick() { + return false; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperRecaptureGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperRecaptureGoal.java new file mode 100644 index 0000000..d4fe51e --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperRecaptureGoal.java @@ -0,0 +1,243 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.EnumSet; +import java.util.UUID; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.Goal; + +/** + * AI Goal for EntityKidnapper to recapture escaped captives. + * + * Phase: Kidnapper Revamp - Escape System + * + * This goal: + * 1. Activates when the kidnapper has an escaped target remembered + * 2. Pursues the escaped captive + * 3. Re-captures them if close enough + * 4. Has higher priority than BringToCellGoal to prioritize recapture + */ +public class KidnapperRecaptureGoal extends Goal { + + private final EntityKidnapper kidnapper; + private final double speedModifier; + + /** Maximum distance to pursue escaped target */ + private static final double MAX_PURSUIT_DISTANCE = 50.0; + + /** Distance at which recapture can occur */ + private static final double RECAPTURE_DISTANCE = 2.0; + + /** Dialogue radius */ + private static final int DIALOGUE_RADIUS = 20; + + /** Current target being pursued */ + private LivingEntity target; + + /** Ticks spent pursuing */ + private int pursuitTicks; + + /** Max pursuit time (30 seconds) */ + private static final int MAX_PURSUIT_TICKS = 600; + + public KidnapperRecaptureGoal(EntityKidnapper kidnapper) { + this(kidnapper, 1.3); // Faster than normal pursuit + } + + public KidnapperRecaptureGoal( + EntityKidnapper kidnapper, + double speedModifier + ) { + this.kidnapper = kidnapper; + this.speedModifier = speedModifier; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Must have an escaped target + LivingEntity escaped = this.kidnapper.getEscapedTarget(); + if (escaped == null) { + return false; + } + + // Must not be tied up + if (this.kidnapper.isTiedUp()) { + return false; + } + + // Must not already have a captive + if (this.kidnapper.hasCaptives()) { + return false; + } + + // Target must be within range + if (this.kidnapper.distanceTo(escaped) > MAX_PURSUIT_DISTANCE) { + this.kidnapper.clearEscapedTarget(); + return false; + } + + // Target must not be captured by someone else + IBondageState state = KidnappedHelper.getKidnappedState(escaped); + if (state != null && state.isCaptive()) { + this.kidnapper.clearEscapedTarget(); + return false; + } + + this.target = escaped; + return true; + } + + @Override + public boolean canContinueToUse() { + if (this.target == null || !this.target.isAlive()) { + return false; + } + + if (this.kidnapper.isTiedUp()) { + return false; + } + + // Stop if we now have a captive (recapture successful) + if (this.kidnapper.hasCaptives()) { + return false; + } + + // Stop if pursuit time exceeded + if (this.pursuitTicks > MAX_PURSUIT_TICKS) { + return false; + } + + // Stop if target is too far + if (this.kidnapper.distanceTo(this.target) > MAX_PURSUIT_DISTANCE) { + return false; + } + + // Stop if target was captured by someone else + IBondageState state = KidnappedHelper.getKidnappedState(this.target); + if (state != null && state.isCaptive()) { + return false; + } + + return true; + } + + @Override + public void start() { + this.pursuitTicks = 0; + + // Set AI state to CAPTURE during pursuit (will switch to PUNISH after recapture) + this.kidnapper.setCurrentState(KidnapperState.CAPTURE); + + // Announce pursuit + this.kidnapper.talkToPlayersInRadius( + DialogueCategory.CAPTURE_CHASE, + DIALOGUE_RADIUS + ); + + TiedUpMod.LOGGER.info( + "[KidnapperRecaptureGoal] {} pursuing escaped captive {} (state: CAPTURE)", + this.kidnapper.getNpcName(), + this.target.getName().getString() + ); + } + + @Override + public void stop() { + // Clear escaped target if we've given up + if (!this.kidnapper.hasCaptives()) { + this.kidnapper.clearEscapedTarget(); + // Reset AI state to IDLE if we failed + this.kidnapper.setCurrentState(KidnapperState.IDLE); + } + // Note: If we succeeded, state will be PUNISH (set in attemptRecapture) + + this.target = null; + this.pursuitTicks = 0; + this.kidnapper.getNavigation().stop(); + } + + @Override + public void tick() { + if (this.target == null) { + return; + } + + this.pursuitTicks++; + + // Always look at target + this.kidnapper.getLookControl().setLookAt(this.target, 30.0F, 30.0F); + + double distance = this.kidnapper.distanceTo(this.target); + + // Recalculate path periodically + if (this.pursuitTicks % 10 == 0) { + this.kidnapper.getNavigation().moveTo( + this.target, + this.speedModifier + ); + } + + // Attempt recapture if close enough + if (distance <= RECAPTURE_DISTANCE) { + attemptRecapture(); + } + } + + /** + * Attempt to recapture the escaped target. + */ + private void attemptRecapture() { + IBondageState state = KidnappedHelper.getKidnappedState(this.target); + if (state == null) { + return; + } + + // Check if target can be recaptured + // They must still be tied up for quick recapture + if (!state.isTiedUp()) { + // Not tied anymore - switch to normal capture + this.kidnapper.setTarget(this.target); + this.kidnapper.clearEscapedTarget(); + return; + } + + // Quick recapture via PrisonerService (atomic: PrisonerManager + leash) + boolean recaptured = false; + if ( + this.kidnapper.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel + ) { + recaptured = + com.tiedup.remake.prison.service.PrisonerService.get().capture( + serverLevel, + this.target, + this.kidnapper + ); + } + if (recaptured) { + // Set state to PUNISH - KidnapperPunishGoal will handle punishment + this.kidnapper.setCurrentState(KidnapperState.PUNISH); + + // Announce success + this.kidnapper.talkToPlayersInRadius( + DialogueCategory.CAPTURE_ENSLAVED, + DIALOGUE_RADIUS + ); + + TiedUpMod.LOGGER.info( + "[KidnapperRecaptureGoal] {} recaptured {} (state: PUNISH)", + this.kidnapper.getNpcName(), + this.target.getName().getString() + ); + + // Clear escaped target on success + this.kidnapper.clearEscapedTarget(); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperSelfDefenseGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperSelfDefenseGoal.java new file mode 100644 index 0000000..52007e6 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperSelfDefenseGoal.java @@ -0,0 +1,144 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.items.ItemTaser; +import com.tiedup.remake.items.ModItems; +import java.util.EnumSet; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * AI Goal: Kidnapper fights back when attacked, regardless of captive status. + * + * Unlike KidnapperFightBackGoal, this does NOT require hasCaptives(). + * This prevents the exploit where PROTECTED players attack with impunity + * because the kidnapper has no captives and thus no self-defense. + * + * Behavior: + * - Triggers when kidnapper is hurt by a non-master player + * - Equips taser and pursues attacker for 10 seconds + * - Does NOT attempt to capture — only melee retaliation + */ +public class KidnapperSelfDefenseGoal extends Goal { + + private final EntityKidnapper kidnapper; + private LivingEntity attacker; + private int aggressionTimer; + + /** Duration of aggression in ticks (10 seconds = 200 ticks) */ + private static final int AGGRESSION_DURATION = 200; + + /** Attack cooldown in ticks */ + private int attackCooldown = 0; + + public KidnapperSelfDefenseGoal(EntityKidnapper kidnapper) { + this.kidnapper = kidnapper; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // If kidnapper has captives, KidnapperFightBackGoal handles it + if ( + kidnapper.hasCaptives() || kidnapper.isWaitingForJobToBeCompleted() + ) { + return false; + } + + // Must have been attacked recently + LivingEntity lastAttacker = kidnapper.getLastAttacker(); + if (lastAttacker == null || !(lastAttacker instanceof Player)) { + return false; + } + + // Attacker must not be the master + if (kidnapper.isMaster((Player) lastAttacker)) { + return false; + } + + if (!lastAttacker.isAlive()) { + return false; + } + + this.attacker = lastAttacker; + return true; + } + + @Override + public void start() { + // Equip taser + kidnapper.setItemInHand( + InteractionHand.MAIN_HAND, + new ItemStack(ModItems.TASER.get()) + ); + + // Broadcast threatening dialogue + kidnapper.talkToPlayersInRadius("You'll regret that!", 20); + + this.aggressionTimer = AGGRESSION_DURATION; + this.attackCooldown = 0; + } + + @Override + public void tick() { + aggressionTimer--; + if (attackCooldown > 0) { + attackCooldown--; + } + + if (attacker != null && attacker.isAlive()) { + // Look at attacker + kidnapper.getLookControl().setLookAt(attacker); + + // Pursue + kidnapper.getNavigation().moveTo(attacker, 1.3); + + // Attack if close enough and cooldown is ready + if (kidnapper.distanceTo(attacker) < 2.0 && attackCooldown <= 0) { + // Swing arm animation + kidnapper.swing(InteractionHand.MAIN_HAND); + + // Attack using standard mob attack (uses ATTACK_DAMAGE attribute) + boolean damaged = kidnapper.doHurtTarget(attacker); + + // Apply taser effects if damage was dealt + if (damaged) { + ItemStack heldItem = kidnapper.getItemInHand( + InteractionHand.MAIN_HAND + ); + if (heldItem.getItem() instanceof ItemTaser taserItem) { + taserItem.hurtEnemy(heldItem, attacker, kidnapper); + } + } + + attackCooldown = 20; // 1 second cooldown + } + } + } + + @Override + public boolean canContinueToUse() { + // Stop if timer expired + if (aggressionTimer <= 0) return false; + + // Stop if attacker dead or gone + if (attacker == null || !attacker.isAlive()) return false; + + // Stop if attacker too far + if (kidnapper.distanceTo(attacker) > 30) return false; + + return true; + } + + @Override + public void stop() { + // Unequip taser + kidnapper.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); + kidnapper.getNavigation().stop(); + this.attacker = null; + this.aggressionTimer = 0; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperState.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperState.java new file mode 100644 index 0000000..2c0e13a --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperState.java @@ -0,0 +1,97 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +/** + * Enum defining the behavioral states of a Kidnapper entity. + * + * Phase 3: Kidnapper Revamp - Advanced AI States + * + * These states drive the kidnapper's AI decision-making and help + * coordinate behavior between multiple kidnappers. + */ +public enum KidnapperState { + /** + * Idle state - Kidnapper has nothing to do. + * Default state when no other action is available. + */ + IDLE, + + /** + * Patrol state - Kidnapper is walking between patrol markers + * or randomly around their home area. + */ + PATROL, + + /** + * Guard state - Kidnapper is watching over occupied cells, + * looking for escape attempts. + */ + GUARD, + + /** + * Alert state - Kidnapper is actively searching for an escapee. + * Moves faster and broadcasts position to other kidnappers. + */ + ALERT, + + /** + * Punish state - Kidnapper is punishing a recaptured prisoner. + * Tightens binds, adds gag/blindfold, resets struggle progress. + */ + PUNISH, + + /** + * Capture state - Kidnapper is actively capturing a target. + * Binding and gagging in progress. + */ + CAPTURE, + + /** + * Transport state - Kidnapper is bringing a captive to prison/cell. + */ + TRANSPORT, + + /** + * Selling state - Kidnapper is waiting for a buyer for their captive. + */ + SELLING, + + /** + * Job Watch state - Kidnapper is supervising a prisoner doing a job. + */ + JOB_WATCH, + + /** + * Hunt state - Kidnapper patrols far from camp looking for prey. + * Larger patrol radius (80-150 blocks), actively searches for vulnerable targets. + * Returns to camp after capture or periodically. + */ + HUNT; + + /** + * Check if this state allows the kidnapper to detect escapes/struggles. + * IDLE, GUARD, PATROL, HUNT, and JOB_WATCH states can detect prisoner struggles. + */ + public boolean canDetectEscapes() { + return ( + this == IDLE || + this == GUARD || + this == PATROL || + this == HUNT || + this == JOB_WATCH + ); + } + + /** + * Check if this state allows receiving alert broadcasts. + */ + public boolean canReceiveAlerts() { + return this != ALERT && this != CAPTURE && this != TRANSPORT; + } + + /** + * Check if this is an active pursuit state. + */ + public boolean isPursuit() { + return this == ALERT || this == CAPTURE; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperWaitForBuyerGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperWaitForBuyerGoal.java new file mode 100644 index 0000000..37257de --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperWaitForBuyerGoal.java @@ -0,0 +1,707 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.EntityDialogueManager; +import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.util.MessageDispatcher; +import com.tiedup.remake.util.tasks.ItemTask; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; + +/** + * AI Goal for EntityKidnapper to wait for a buyer when selling a slave. + * + * Phase 14.3.5: Sale system + * + * This goal activates when: + * - Kidnapper has a slave + * - Slave is marked for sale (forSell = true) + * + * Behavior: + * 1. Broadcast sale announcement to all players + * 2. Stand in place waiting for a buyer + * 3. If timeout (5-8 minutes), cancel sale and flee + * + * Based on original EntityAIWaitForBuyer from 1.12.2 + */ +public class KidnapperWaitForBuyerGoal extends Goal { + + private final EntityKidnapper kidnapper; + + /** Minimum wait time for a buyer (ticks) - 5 minutes */ + private static final int MIN_WAIT_TIME = 6000; + + /** Maximum wait time for a buyer (ticks) - 8 minutes */ + private static final int MAX_WAIT_TIME = 9600; + + /** Time between sale announcements (ticks) - 1 minute */ + private static final int ANNOUNCE_INTERVAL = 1200; + + /** Current wait timer */ + private int waitTimer; + + /** Total wait duration for this sale */ + private int waitDuration; + + /** Timer for announcements */ + private int announceTimer; + + /** Whether we've announced the sale */ + private boolean hasAnnounced; + + /** Dialogue radius */ + private static final int DIALOGUE_RADIUS = 15; + + /** Players we've already offered the sale to (to avoid spam) */ + private final Set offeredPlayers = new HashSet<>(); + + /** Whether solo mode is active (no potential buyers) */ + private boolean soloMode = false; + + /** Cache for nearby players (refreshed every 20 ticks) */ + private List nearbyPlayersCache = List.of(); + + /** Tick counter for cache refresh */ + private int cacheRefreshTick = 0; + + /** How often to refresh the player cache (ticks) */ + private static final int CACHE_REFRESH_INTERVAL = 20; + + /** + * Create a new wait for buyer goal. + * + * @param kidnapper The kidnapper entity + */ + public KidnapperWaitForBuyerGoal(EntityKidnapper kidnapper) { + this.kidnapper = kidnapper; + this.waitTimer = 0; + this.waitDuration = 0; + this.announceTimer = 0; + this.hasAnnounced = false; + // Only block MOVE, allow LOOK so kidnapper can look around + this.setFlags(EnumSet.of(Goal.Flag.MOVE)); + } + + @Override + public boolean canUse() { + // Must have a slave + if (!this.kidnapper.hasCaptives()) { + return false; + } + + // Slave must be for sale + IRestrainable slave = this.kidnapper.getCaptive(); + if (slave == null || !slave.isForSell()) { + return false; + } + + // Must not be tied up + if (this.kidnapper.isTiedUp()) { + return false; + } + + // Must not be in get out state (fleeing) + if (this.kidnapper.isGetOutState()) { + return false; + } + + return true; + } + + @Override + public boolean canContinueToUse() { + // Stop if no longer has slave + if (!this.kidnapper.hasCaptives()) { + return false; + } + + // Stop if slave no longer for sale (bought or cancelled) + IRestrainable slave = this.kidnapper.getCaptive(); + if (slave == null || !slave.isForSell()) { + return false; + } + + // Stop if tied up + if (this.kidnapper.isTiedUp()) { + return false; + } + + // Stop if in get out state + if (this.kidnapper.isGetOutState()) { + return false; + } + + return true; + } + + @Override + public void start() { + this.waitTimer = 0; + this.announceTimer = 0; + this.hasAnnounced = false; + + // Check if solo mode is active + this.soloMode = detectSoloMode(); + + // Check if Master should spawn immediately for solo player + IRestrainable captive = this.kidnapper.getCaptive(); + boolean isMasterEnabled = ModConfig.SERVER.enableMasterSpawn.get(); + boolean isPlayer = + captive != null && captive.asLivingEntity() instanceof Player; + + if (this.soloMode && isMasterEnabled && isPlayer) { + // Solo mode with player captive: spawn Master who will walk to kidnapper + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForBuyerGoal] Solo player detected, spawning Master to approach" + ); + + // Spawn Master at distance - they will walk to kidnapper + handleMasterSpawn(captive); + + // Don't return - let the goal continue so kidnapper waits for Master + // The wait duration is set to allow Master to arrive + this.waitDuration = 6000; // 5 minutes max wait for Master to arrive + } else if (this.soloMode && ModConfig.SERVER.enableSoloFallback.get()) { + // Solo mode (non-player captive): use config timeout + this.waitDuration = ModConfig.SERVER.soloTimeoutSeconds.get() * 20; + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForBuyerGoal] Solo mode detected, using {}s timeout", + ModConfig.SERVER.soloTimeoutSeconds.get() + ); + } else { + // Normal mode: random 5-8 minutes + this.waitDuration = + MIN_WAIT_TIME + + this.kidnapper.getRandom().nextInt( + MAX_WAIT_TIME - MIN_WAIT_TIME + ); + } + + // Stop moving + this.kidnapper.getNavigation().stop(); + + // Announce sale + announceSale(); + + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForBuyerGoal] {} waiting for buyer ({}s timeout, soloMode={})", + this.kidnapper.getNpcName(), + this.waitDuration / 20, + this.soloMode + ); + } + + /** + * Detect if we're in solo mode (no potential buyers available). + * Solo mode is active when: + * - Single player world (isSingleplayer()) + * - OR multiplayer with only the captive player online + */ + private boolean detectSoloMode() { + if (this.kidnapper.level().getServer() == null) { + return false; + } + + // Get all online players + List players = this.kidnapper.level() + .getServer() + .getPlayerList() + .getPlayers(); + + // Check if it's truly solo (only captive online) + IRestrainable captive = this.kidnapper.getCaptive(); + if (captive == null) { + return false; + } + + UUID captiveUUID = captive.getKidnappedUniqueId(); + + // Count potential buyers (players who are not the captive) + long potentialBuyers = players + .stream() + .filter(p -> !p.getUUID().equals(captiveUUID)) + .filter(p -> !p.isCreative() && !p.isSpectator()) + .count(); + + return potentialBuyers == 0; + } + + @Override + public void stop() { + this.waitTimer = 0; + this.hasAnnounced = false; + this.offeredPlayers.clear(); + this.soloMode = false; + this.nearbyPlayersCache = List.of(); + this.cacheRefreshTick = 0; + this.masterSpawned = false; + this.masterPurchaseComplete = false; + } + + @Override + public void tick() { + this.waitTimer++; + this.announceTimer++; + this.cacheRefreshTick++; + + // Refresh nearby players cache periodically (every 20 ticks = 1 second) + if (this.cacheRefreshTick >= CACHE_REFRESH_INTERVAL) { + this.nearbyPlayersCache = this.kidnapper.level().getEntitiesOfClass( + Player.class, + this.kidnapper.getBoundingBox().inflate(16.0) // Use larger radius for both lookAt and offers + ); + this.cacheRefreshTick = 0; + } + + // Look at nearby players (using cache) + if (!this.nearbyPlayersCache.isEmpty()) { + // Find closest within 8 blocks for lookAt + Player closest = this.nearbyPlayersCache.stream() + .filter(p -> p.distanceToSqr(this.kidnapper) <= 64.0) // 8 blocks squared + .findFirst() + .orElse(null); + if (closest != null) { + this.kidnapper.getLookControl().setLookAt( + closest, + 30.0F, + 30.0F + ); + } + } + + // Offer sale to nearby players (every 5 seconds) + if (this.announceTimer % 100 == 0) { + offerSaleToNearbyPlayers(); + } + + // Periodic re-announcement + if (this.announceTimer >= ANNOUNCE_INTERVAL) { + announceSale(); + this.announceTimer = 0; + } + + // Check timeout + if (this.waitTimer >= this.waitDuration) { + saleTimeout(); + } + } + + /** + * Offer sale to nearby players who haven't been offered yet. + * Uses the cached nearby players list. + */ + private void offerSaleToNearbyPlayers() { + IRestrainable slave = this.kidnapper.getCaptive(); + if (slave == null) return; + + ItemTask price = slave.getSalePrice(); + if (price == null) return; + + // Use cached nearby players + for (Player player : this.nearbyPlayersCache) { + if (!offeredPlayers.contains(player.getUUID())) { + String offer = EntityDialogueManager.getSaleOfferDialogue( + player.getName().getString(), + price.toDisplayString() + ); + this.kidnapper.talkTo(player, offer); + offeredPlayers.add(player.getUUID()); + } + } + } + + /** + * Announce the sale to all online players. + */ + private void announceSale() { + IRestrainable slave = this.kidnapper.getCaptive(); + if (slave == null) return; + + ItemTask price = slave.getSalePrice(); + if (price == null) return; + + // Talk to slave + if (!this.hasAnnounced) { + if (slave.asLivingEntity() instanceof Player player) { + this.kidnapper.talkTo(player, DialogueCategory.SALE_WAITING); + } + this.kidnapper.talkToPlayersInRadius( + DialogueCategory.SALE_ANNOUNCE, + DIALOGUE_RADIUS + ); + this.hasAnnounced = true; + } + + // Build announcement message + String location = String.format( + "X: %.0f, Y: %.0f, Z: %.0f", + this.kidnapper.getX(), + this.kidnapper.getY(), + this.kidnapper.getZ() + ); + + Component announcement = Component.literal("") + .append(Component.literal("[SALE] ").withStyle(ChatFormatting.GOLD)) + .append( + Component.literal(this.kidnapper.getNpcName()).withStyle( + ChatFormatting.RED + ) + ) + .append( + Component.literal(" is selling ").withStyle( + ChatFormatting.YELLOW + ) + ) + .append( + Component.literal(slave.getKidnappedName()).withStyle( + ChatFormatting.AQUA + ) + ) + .append(Component.literal(" for ").withStyle(ChatFormatting.YELLOW)) + .append( + Component.literal(price.toDisplayString()).withStyle( + ChatFormatting.GREEN + ) + ) + .append(Component.literal(" at ").withStyle(ChatFormatting.YELLOW)) + .append( + Component.literal(location).withStyle(ChatFormatting.WHITE) + ); + + // Send to all players on the server (earplug-aware) + List players = this.kidnapper.level() + .getServer() + .getPlayerList() + .getPlayers(); + for (ServerPlayer player : players) { + MessageDispatcher.sendTo(player, announcement); + } + + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForBuyerGoal] Sale announced: {} selling {} for {}", + this.kidnapper.getNpcName(), + slave.getKidnappedName(), + price.toDisplayString() + ); + } + + /** + * Handle sale timeout - no buyer found. + * If a Master has been spawned and is approaching, extend wait time instead of fleeing. + */ + private void saleTimeout() { + IRestrainable captive = this.kidnapper.getCaptive(); + + // If Master has been spawned and is approaching, extend wait time + if (masterSpawned && !masterPurchaseComplete) { + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForBuyerGoal] Master is approaching, extending wait time for {}", + this.kidnapper.getNpcName() + ); + // Reset timer and add extra wait time (2.5 minutes) + this.waitTimer = 0; + this.waitDuration = 3000; + return; // Don't continue to flee logic + } + + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForBuyerGoal] {} sale timed out, no buyer (soloMode={})", + this.kidnapper.getNpcName(), + this.soloMode + ); + + // Cancel sale + if (captive != null) { + captive.cancelSale(); + } + + // Handle solo mode fallback + if (this.soloMode && ModConfig.SERVER.enableSoloFallback.get()) { + handleSoloModeFallback(captive); + } else { + // Normal multiplayer mode: just flee + this.kidnapper.setGetOutState(true); + } + + // Goal will stop on next canContinueToUse check + } + + /** + * Handle solo mode fallback when no buyers are available. + * Randomly chooses between: + * - Master: Spawn a Master NPC who buys the player (100% chance for solo players) + * - Keep: Kidnapper keeps the captive and assigns a job or brings to cell + * - Abandon: Kidnapper abandons the captive with blindfold in a remote location + */ + private void handleSoloModeFallback(IRestrainable captive) { + if (captive == null) { + this.kidnapper.setGetOutState(true); + return; + } + + // Check if Master spawn is enabled and captive is a player + boolean isMasterEnabled = ModConfig.SERVER.enableMasterSpawn.get(); + boolean isPlayer = captive.asLivingEntity() instanceof Player; + + if (isMasterEnabled && isPlayer) { + // Spawn Master to buy the player + handleMasterSpawn(captive); + return; + } + + // Fallback to original behavior + double keepChance = ModConfig.SERVER.soloKeepChance.get(); + double roll = this.kidnapper.getRandom().nextDouble(); + + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForBuyerGoal] Solo fallback: keepChance={}, roll={}", + keepChance, + roll + ); + + if (roll < keepChance) { + // Option A: Keep the captive + handleKeepCaptive(captive); + } else { + // Option B: Abandon the captive + handleAbandonCaptive(captive); + } + } + + /** Distance for Master spawn from player */ + private static final double MASTER_SPAWN_DISTANCE = 50.0; + + /** Flag indicating a Master has been spawned and is approaching */ + private boolean masterSpawned = false; + + /** Flag indicating the Master has completed the purchase - kidnapper can leave */ + private boolean masterPurchaseComplete = false; + + /** + * Option C: Spawn a Master NPC to buy the player. + * The Master spawns ~50 blocks away (behind the player) and walks to the Kidnapper. + */ + private void handleMasterSpawn(IRestrainable captive) { + if (!(captive.asLivingEntity() instanceof ServerPlayer player)) { + handleKeepCaptive(captive); + return; + } + + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForBuyerGoal] Spawning Master to buy {} from {}", + captive.getKidnappedName(), + this.kidnapper.getNpcName() + ); + + net.minecraft.server.level.ServerLevel serverLevel = + (net.minecraft.server.level.ServerLevel) this.kidnapper.level(); + + com.tiedup.remake.entities.EntityMaster master = + com.tiedup.remake.entities.ModEntities.MASTER.get().create( + serverLevel + ); + + if (master == null) { + TiedUpMod.LOGGER.error( + "[KidnapperWaitForBuyerGoal] Failed to create EntityMaster!" + ); + handleKeepCaptive(captive); + return; + } + + // Calculate spawn position: ~50 blocks away, BEHIND the player (opposite of where they're looking) + float playerYaw = player.getYRot(); + // Add 180 degrees to get the opposite direction (behind player) + // Add some randomness (-45 to +45 degrees) so it's not always directly behind + float spawnAngle = + playerYaw + + 180 + + (this.kidnapper.getRandom().nextFloat() - 0.5f) * 90; + double radians = Math.toRadians(spawnAngle); + + double spawnX = + this.kidnapper.getX() - Math.sin(radians) * MASTER_SPAWN_DISTANCE; + double spawnZ = + this.kidnapper.getZ() + Math.cos(radians) * MASTER_SPAWN_DISTANCE; + + // Find valid ground position + net.minecraft.core.BlockPos spawnPos = findValidSpawnPosition( + serverLevel, + (int) spawnX, + (int) this.kidnapper.getY(), + (int) spawnZ + ); + + if (spawnPos == null) { + // Fallback: spawn closer if no valid position found + spawnX = this.kidnapper.getX() - Math.sin(radians) * 20; + spawnZ = this.kidnapper.getZ() + Math.cos(radians) * 20; + spawnPos = findValidSpawnPosition( + serverLevel, + (int) spawnX, + (int) this.kidnapper.getY(), + (int) spawnZ + ); + } + + if (spawnPos == null) { + // Last resort: spawn near kidnapper + spawnPos = this.kidnapper.blockPosition(); + } + + // Position Master at spawn location, facing towards kidnapper + double dx = this.kidnapper.getX() - spawnPos.getX(); + double dz = this.kidnapper.getZ() - spawnPos.getZ(); + float facingYaw = + (float) (Math.atan2(dz, dx) * (180.0 / Math.PI)) - 90.0f; + + master.moveTo( + spawnPos.getX() + 0.5, + spawnPos.getY(), + spawnPos.getZ() + 0.5, + facingYaw, + 0 + ); + + // Tell Master about the selling kidnapper (for MasterBuyPlayerGoal) + master.setSellingKidnapper(this.kidnapper); + + // Add to world + serverLevel.addFreshEntity(master); + + // Mark that Master has spawned - kidnapper will wait + this.masterSpawned = true; + + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForBuyerGoal] Master {} spawned at ({}, {}, {}) - {} blocks from kidnapper", + master.getNpcName(), + spawnPos.getX(), + spawnPos.getY(), + spawnPos.getZ(), + (int) Math.sqrt(dx * dx + dz * dz) + ); + } + + /** + * Find a valid spawn position on solid ground. + */ + private net.minecraft.core.BlockPos findValidSpawnPosition( + net.minecraft.server.level.ServerLevel level, + int x, + int startY, + int z + ) { + // Search up and down from the start Y + for (int yOffset = 0; yOffset < 20; yOffset++) { + // Check above + net.minecraft.core.BlockPos posUp = new net.minecraft.core.BlockPos( + x, + startY + yOffset, + z + ); + if (isValidSpawnPos(level, posUp)) { + return posUp; + } + // Check below + net.minecraft.core.BlockPos posDown = + new net.minecraft.core.BlockPos(x, startY - yOffset, z); + if (isValidSpawnPos(level, posDown)) { + return posDown; + } + } + return null; + } + + /** + * Check if a position is valid for spawning (solid ground, air above). + */ + private boolean isValidSpawnPos( + net.minecraft.server.level.ServerLevel level, + net.minecraft.core.BlockPos pos + ) { + net.minecraft.world.level.block.state.BlockState below = + level.getBlockState(pos.below()); + net.minecraft.world.level.block.state.BlockState at = + level.getBlockState(pos); + net.minecraft.world.level.block.state.BlockState above = + level.getBlockState(pos.above()); + + return below.isSolid() && at.isAir() && above.isAir(); + } + + /** + * Check if a Master has been spawned and is approaching. + */ + public boolean isMasterSpawned() { + return masterSpawned; + } + + /** + * Called by MasterBuyPlayerGoal after the purchase is complete. + * Allows the kidnapper to flee (without the player who was transferred to the Master). + */ + public void onMasterPurchaseComplete() { + this.masterPurchaseComplete = true; + this.kidnapper.setGetOutState(true); + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForBuyerGoal] Master purchase complete, {} can now leave", + this.kidnapper.getNpcName() + ); + } + + /** + * Option A: Kidnapper decides to keep the captive. + * Dialogue + assign job or return to DecideNextAction. + */ + private void handleKeepCaptive(IRestrainable captive) { + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForBuyerGoal] {} decides to keep captive {}", + this.kidnapper.getNpcName(), + captive.getKidnappedName() + ); + + // Announce decision + if (captive.asLivingEntity() instanceof Player player) { + this.kidnapper.talkTo(player, DialogueCategory.SALE_KEPT); + } + + // Call EntityKidnapper's keepCaptive method + this.kidnapper.keepCaptive(); + + // Don't set getOutState - let DecideNextAction take over again + } + + /** + * Option B: Kidnapper abandons the captive. + * Applies blindfold, frees the captive, teleports them far away. + */ + private void handleAbandonCaptive(IRestrainable captive) { + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForBuyerGoal] {} abandons captive {}", + this.kidnapper.getNpcName(), + captive.getKidnappedName() + ); + + // Announce decision + if (captive.asLivingEntity() instanceof Player player) { + this.kidnapper.talkTo(player, DialogueCategory.SALE_ABANDONED); + } + + // Call EntityKidnapper's abandonCaptive method + this.kidnapper.abandonCaptive(); + + // Kidnapper flees after abandoning + this.kidnapper.setGetOutState(true); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperWaitForJobGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperWaitForJobGoal.java new file mode 100644 index 0000000..8e94513 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperWaitForJobGoal.java @@ -0,0 +1,543 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.SystemMessageManager.MessageCategory; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.EntityDialogueManager; +import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerRecord; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.util.tasks.ItemTask; +import java.util.EnumSet; +import java.util.List; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * AI Goal for EntityKidnapper to wait for a worker to complete a job. + * + * Phase 14.3.5: Job system + * + * This goal activates when: + * - Kidnapper has assigned a job (isWaitingForJobToBeCompleted) + * - Worker is tracked by UUID (freed from slavery, wearing shock collar) + * + * Behavior: + * 1. Wait for worker to bring the required items + * 2. If timeout, shock worker (via collar) + * 3. After multiple failures, give up and flee + * + * Based on original EntityAIWaitForJob from 1.12.2 + */ +public class KidnapperWaitForJobGoal extends Goal { + + private final EntityKidnapper kidnapper; + + /** Minimum wait time for job completion (ticks) - 3 minutes */ + private static final int MIN_JOB_TIME = 3600; + + /** Maximum wait time for job completion (ticks) - 5 minutes */ + private static final int MAX_JOB_TIME = 6000; + + /** Time between reminder announcements (ticks) - 30 seconds */ + private static final int REMINDER_INTERVAL = 600; + + /** Current wait timer */ + private int waitTimer; + + /** Total wait duration for this job */ + private int waitDuration; + + /** Timer for reminders */ + private int reminderTimer; + + /** Current job task */ + private ItemTask currentJob; + + /** Number of failed attempts */ + private int failCount; + + /** Maximum failures before giving up */ + private static final int MAX_FAILURES = 3; + + /** + * Create a new wait for job goal. + * + * @param kidnapper The kidnapper entity + */ + public KidnapperWaitForJobGoal(EntityKidnapper kidnapper) { + this.kidnapper = kidnapper; + this.waitTimer = 0; + this.waitDuration = 0; + this.reminderTimer = 0; + this.currentJob = null; + this.failCount = 0; + // Only block MOVE, allow LOOK so kidnapper can look around + this.setFlags(EnumSet.of(Goal.Flag.MOVE)); + } + + @Override + public boolean canUse() { + // Must be waiting for job (has job and worker UUID) + if (!this.kidnapper.isWaitingForJobToBeCompleted()) { + return false; + } + + // Must not be tied up + if (this.kidnapper.isTiedUp()) { + return false; + } + + // Must not be in get out state + if (this.kidnapper.isGetOutState()) { + return false; + } + + // Must not be selling (shouldn't happen with job, but safety check) + if (this.kidnapper.isSellingCaptive()) { + return false; + } + + return true; + } + + @Override + public boolean canContinueToUse() { + // Stop if no longer waiting for job + if (!this.kidnapper.isWaitingForJobToBeCompleted()) { + return false; + } + + // Stop if tied up + if (this.kidnapper.isTiedUp()) { + return false; + } + + // Stop if in get out state + if (this.kidnapper.isGetOutState()) { + return false; + } + + return true; + } + + @Override + public void start() { + this.waitTimer = 0; + this.waitDuration = + MIN_JOB_TIME + + this.kidnapper.getRandom().nextInt(MAX_JOB_TIME - MIN_JOB_TIME); + this.reminderTimer = 0; + this.currentJob = this.kidnapper.getCurrentJob(); + + // Stop moving + this.kidnapper.getNavigation().stop(); + + // Announce job to worker + announceJob(); + + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForJobGoal] {} waiting for job completion ({}s timeout)", + this.kidnapper.getNpcName(), + this.waitDuration / 20 + ); + } + + @Override + public void stop() { + this.waitTimer = 0; + this.currentJob = null; + } + + @Override + public void tick() { + this.waitTimer++; + this.reminderTimer++; + + // Look at nearby players (or specifically the job worker) + Player worker = this.kidnapper.getJobWorker(); + + // Check if worker died or lost their collar (respawned = no collar = job abandoned) + if (worker != null && !workerHasJobCollar(worker)) { + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForJobGoal] {} - worker {} no longer has collar (died/escaped?), abandoning job", + this.kidnapper.getNpcName(), + worker.getName().getString() + ); + jobAbandoned(); + return; + } + + if (worker != null && this.kidnapper.distanceTo(worker) <= 8.0) { + this.kidnapper.getLookControl().setLookAt(worker, 30.0F, 30.0F); + } else { + // Look at any nearby player + List nearbyPlayers = + this.kidnapper.level().getEntitiesOfClass( + Player.class, + this.kidnapper.getBoundingBox().inflate(8.0) + ); + if (!nearbyPlayers.isEmpty()) { + this.kidnapper.getLookControl().setLookAt( + nearbyPlayers.get(0), + 30.0F, + 30.0F + ); + } + } + + // Periodic reminder + if (this.reminderTimer >= REMINDER_INTERVAL) { + remindWorker(); + this.reminderTimer = 0; + } + + // Check if worker completed job + if (checkJobCompleted()) { + jobCompleted(); + return; + } + + // Check timeout + if (this.waitTimer >= this.waitDuration) { + jobFailed(); + } + } + + /** + * Check if the worker still has their job collar. + * If they died and respawned, they won't have it anymore. + * + * @param worker The worker to check + * @return true if worker still has a shock collar + */ + private boolean workerHasJobCollar(Player worker) { + IRestrainable state = KidnappedHelper.getKidnappedState(worker); + if (state == null) { + return false; + } + return state.hasCollar(); + } + + /** + * Handle job abandonment (worker died/escaped without completing). + * Clears job state without punishment since worker is gone. + */ + private void jobAbandoned() { + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForJobGoal] {} - job abandoned, clearing state", + this.kidnapper.getNpcName() + ); + + this.kidnapper.clearCurrentJob(); + this.kidnapper.setGetOutState(true); + this.failCount = 0; + } + + /** + * Announce the job to the worker. + */ + private void announceJob() { + Player worker = this.kidnapper.getJobWorker(); + if (worker == null || this.currentJob == null) return; + + // Talk to worker about the job (with item name) + String jobDialogue = EntityDialogueManager.getJobAssignmentDialogue( + this.currentJob.toDisplayString() + ); + this.kidnapper.talkTo(worker, jobDialogue); + + // System message with item + SystemMessageManager.sendJobAssigned( + worker, + this.currentJob.toDisplayString() + ); + + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForJobGoal] Job assigned: {} must fetch {}", + worker.getName().getString(), + this.currentJob.toDisplayString() + ); + } + + /** + * Remind worker about the job. + */ + private void remindWorker() { + Player worker = this.kidnapper.getJobWorker(); + if (worker == null) return; + + // Talk to worker about hurrying up + this.kidnapper.talkTo(worker, DialogueCategory.JOB_HURRY); + } + + /** + * Check if the worker has completed the job. + * Worker must be holding the required items and be nearby. + * + * @return true if job is complete + */ + private boolean checkJobCompleted() { + Player worker = this.kidnapper.getJobWorker(); + if (worker == null || this.currentJob == null) return false; + + // Only check if worker is nearby (5 blocks instead of 3 for more forgiving detection) + if (this.kidnapper.distanceTo(worker) > 5.0f) { + return false; + } + + // Check main hand first + ItemStack mainHand = worker.getMainHandItem(); + if (this.currentJob.isCompletedBy(mainHand)) { + return true; + } + + // Check off hand + ItemStack offHand = worker.getOffhandItem(); + if (this.currentJob.isCompletedBy(offHand)) { + return true; + } + + // Check inventory (player doesn't need to hold the item) + for (int i = 0; i < worker.getInventory().getContainerSize(); i++) { + ItemStack stack = worker.getInventory().getItem(i); + if (this.currentJob.isCompletedBy(stack)) { + return true; + } + } + + return false; + } + + /** + * Handle job completion - take items, remove collar, free worker. + */ + private void jobCompleted() { + Player worker = this.kidnapper.getJobWorker(); + if (worker == null || this.currentJob == null) return; + + // Consume items from worker (check hands first, then inventory) + ItemStack mainHand = worker.getMainHandItem(); + if (this.currentJob.isCompletedBy(mainHand)) { + this.currentJob.consumeFrom(mainHand); + } else { + ItemStack offHand = worker.getOffhandItem(); + if (this.currentJob.isCompletedBy(offHand)) { + this.currentJob.consumeFrom(offHand); + } else { + // Check inventory for the items + for ( + int i = 0; + i < worker.getInventory().getContainerSize(); + i++ + ) { + ItemStack stack = worker.getInventory().getItem(i); + if (this.currentJob.isCompletedBy(stack)) { + this.currentJob.consumeFrom(stack); + break; + } + } + } + } + + // Reward dialogue + this.kidnapper.talkTo(worker, DialogueCategory.JOB_COMPLETE); + + // System message + SystemMessageManager.sendToPlayer( + worker, + MessageCategory.SLAVE_JOB_COMPLETE + ); + + // Remove the shock collar from worker + IRestrainable workerState = KidnappedHelper.getKidnappedState(worker); + if (workerState != null) { + // Unregister from CollarRegistry before removal + unregisterJobCollar(worker); + + // Force remove the locked collar + workerState.forceUnequip(BodyRegionV2.NECK); + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForJobGoal] Removed collar from {}", + worker.getName().getString() + ); + } + + // Release from central PrisonerManager — no grace period for personal tasks + if ( + this.kidnapper.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel + ) { + PrisonerManager manager = PrisonerManager.get(serverLevel); + manager.release(worker.getUUID(), serverLevel.getGameTime(), 0); + } + + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForJobGoal] {} completed job", + worker.getName().getString() + ); + + // Clear job and enter flee state + this.kidnapper.clearCurrentJob(); + this.kidnapper.setGetOutState(true); + this.failCount = 0; + } + + /** + * Handle job failure - shock worker, warn, then kill on final failure. + */ + private void jobFailed() { + Player worker = this.kidnapper.getJobWorker(); + + this.failCount++; + + String workerName = + worker != null ? worker.getName().getString() : "worker"; + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForJobGoal] {} failed job (attempt {}/{})", + workerName, + this.failCount, + MAX_FAILURES + ); + + if (worker == null) { + // Worker disconnected - clean up and give up + // NOTE: We can't remove the collar directly since the player is offline, + // but we unregister it from CollarRegistry so it won't be tracked anymore + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForJobGoal] Worker disconnected, clearing job and unregistering collar" + ); + this.kidnapper.clearCurrentJob(); + this.kidnapper.setGetOutState(true); + return; + } + + IRestrainable workerState = KidnappedHelper.getKidnappedState(worker); + + if (this.failCount >= MAX_FAILURES) { + // Final failure - kill the worker + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForJobGoal] {} killed {} after {} failures", + this.kidnapper.getNpcName(), + workerName, + this.failCount + ); + + // Dialogue: killing + this.kidnapper.talkTo(worker, DialogueCategory.JOB_KILL); + + // System message + SystemMessageManager.sendToPlayer( + worker, + MessageCategory.SLAVE_JOB_KILLED + ); + + // Unregister collar from CollarRegistry before killing + unregisterJobCollar(worker); + + // Remove collar before killing + if (workerState != null) { + workerState.forceUnequip(BodyRegionV2.NECK); + } + + // Kill the worker + worker.hurt( + worker.damageSources().mobAttack(this.kidnapper), + Float.MAX_VALUE + ); + + this.kidnapper.clearCurrentJob(); + this.kidnapper.setGetOutState(true); + } else if (this.failCount == MAX_FAILURES - 1) { + // Second to last attempt - warn about death + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForJobGoal] {} warning {} - next failure is death", + this.kidnapper.getNpcName(), + workerName + ); + + // Shock as punishment + if (workerState != null) { + workerState.shockKidnapped("Job failure", 3.0f); // Stronger shock + } + + // Dialogue: death warning + this.kidnapper.talkTo(worker, DialogueCategory.JOB_LAST_CHANCE); + + // System message + SystemMessageManager.sendToPlayer( + worker, + MessageCategory.SLAVE_JOB_LAST_CHANCE + ); + + // Reset timer for final attempt + this.waitTimer = 0; + this.waitDuration = + MIN_JOB_TIME + + this.kidnapper.getRandom().nextInt(MAX_JOB_TIME - MIN_JOB_TIME); + this.reminderTimer = 0; + } else { + // Normal failure - shock and remind + if (workerState != null) { + workerState.shockKidnapped("Job failure", 2.0f); + } + + // Dialogue: failed + this.kidnapper.talkTo(worker, DialogueCategory.JOB_FAILED); + + // System message + SystemMessageManager.sendToPlayer( + worker, + MessageCategory.SLAVE_JOB_FAILED + ); + + // Reset timer for another attempt + this.waitTimer = 0; + this.waitDuration = + MIN_JOB_TIME + + this.kidnapper.getRandom().nextInt(MAX_JOB_TIME - MIN_JOB_TIME); + this.reminderTimer = 0; + + // Remind about the job + remindWorker(); + } + } + + /** + * Unregister a job collar from the CollarRegistry. + * Called when the job is completed or worker is killed. + * + * @param worker The worker whose collar is being removed + */ + private void unregisterJobCollar(Player worker) { + if (worker == null || this.kidnapper.level().isClientSide()) { + return; + } + + if ( + !(this.kidnapper.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel) + ) { + return; + } + + com.tiedup.remake.state.CollarRegistry registry = + com.tiedup.remake.state.CollarRegistry.get(serverLevel); + if (registry == null) { + return; + } + + // Unregister the worker's collar + registry.unregisterWearer(worker.getUUID()); + + TiedUpMod.LOGGER.debug( + "[KidnapperWaitForJobGoal] Unregistered job collar for {}", + worker.getName().getString() + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperWalkPrisonerGoal.java b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperWalkPrisonerGoal.java new file mode 100644 index 0000000..8d4b961 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/kidnapper/KidnapperWalkPrisonerGoal.java @@ -0,0 +1,1058 @@ +package com.tiedup.remake.entities.ai.kidnapper; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.DialogueBridge; +import com.tiedup.remake.entities.AbstractTiedUpNpc; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.ai.StuckDetector; +import com.tiedup.remake.entities.ai.WaypointNavigator; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.BindVariant; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.util.KidnapperAIHelper; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * AI Goal for EntityKidnapper to take a prisoner for a walk. + * + * This goal: + * 1. Randomly triggers when guarding cells with prisoners + * 2. Teleports prisoner out of cell to kidnapper + * 3. Changes their bind to DOGBINDER (pet play style) + * 4. Leads them on a walk around the area + * 5. Returns them to cell + * 6. Changes bind back to normal + * + * Can also happen with Damsels (NPCs). + * + * Note: Only regular kidnappers can do this, not maids or traders. + */ +public class KidnapperWalkPrisonerGoal extends Goal { + + private final EntityKidnapper kidnapper; + + /** Walk states */ + private static final int STATE_IDLE = 0; + private static final int STATE_PICKING_PRISONER = 1; + private static final int STATE_GOING_TO_CELL = 2; + private static final int STATE_WALKING = 3; + private static final int STATE_RETURNING = 4; + private static final int STATE_RECOVERING = 5; + private static final int STATE_DONE = 6; + + /** Current state */ + private int walkState = STATE_IDLE; + + /** Current prisoner being walked */ + @Nullable + private IBondageState walkedPrisoner; + + /** UUID of prisoner being walked (for recovery after escape) */ + @Nullable + private UUID walkedPrisonerId; + + /** Original bind item (to restore after walk) */ + @Nullable + private ItemStack originalBind; + + /** Cell the prisoner came from */ + @Nullable + private CellDataV2 originCell; + + /** Waypoint navigator for going to/returning from cell */ + @Nullable + private WaypointNavigator waypointNav; + + /** Current walk waypoint */ + @Nullable + private BlockPos currentWaypoint; + + /** Ticks spent in current state */ + private int stateTicks; + + /** Walk duration (2-4 minutes) */ + private int walkDuration; + + /** Minimum walk duration (2 minutes) */ + private static final int MIN_WALK_DURATION = 2400; + + /** Maximum walk duration (4 minutes) */ + private static final int MAX_WALK_DURATION = 4800; + + /** Timeout for transition states (30 seconds) */ + private static final int TRANSITION_STATE_TIMEOUT = 600; + + /** Interval between waypoint changes (10-20 seconds) */ + private static final int WAYPOINT_INTERVAL_MIN = 200; + private static final int WAYPOINT_INTERVAL_MAX = 400; + + /** Timer for waypoint changes */ + private int waypointTimer; + + /** Walk radius from cell */ + private static final double WALK_RADIUS = 30.0; + + /** Chance to trigger walk behavior (1/1000 per tick when guarding = ~5% per minute) */ + private static final int WALK_TRIGGER_CHANCE = 1000; + + /** Whether prisoner has been extracted from PrisonerManager */ + private boolean extracted = false; + + /** Cooldown after completing a walk (5 minutes) */ + private int walkCooldown = 0; + private static final int WALK_COOLDOWN_DURATION = 6000; + + /** Stuck detection for vanilla navigation in GOING_TO_CELL and RETURNING states */ + private final StuckDetector stuckDetector = new StuckDetector(60, 3); + + public KidnapperWalkPrisonerGoal(EntityKidnapper kidnapper) { + this.kidnapper = kidnapper; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Cooldown check + if (this.walkCooldown > 0) { + this.walkCooldown--; + return false; + } + + // Must not be tied up + if (this.kidnapper.isTiedUp()) { + return false; + } + + // Must not already have a captive + if (this.kidnapper.hasCaptives()) { + return false; + } + + // Must not have an active target + if (this.kidnapper.getTarget() != null) { + return false; + } + + // Must be in a walkable state (GUARD, PATROL, or IDLE) + KidnapperState state = this.kidnapper.getCurrentState(); + if ( + state != KidnapperState.GUARD && + state != KidnapperState.PATROL && + state != KidnapperState.IDLE + ) { + return false; + } + + // Must not be waiting for job + if (this.kidnapper.isWaitingForJobToBeCompleted()) { + return false; + } + + // Random chance trigger + if (this.kidnapper.getRandom().nextInt(WALK_TRIGGER_CHANCE) != 0) { + return false; + } + + // Need nearby cells with prisoners + List occupiedCells = + this.kidnapper.getNearbyCellsWithPrisoners(); + if (occupiedCells.isEmpty()) { + return false; + } + + // Only one dogwalk per camp at a time + if (isCampAlreadyDogwalking()) { + return false; + } + + return true; + } + + /** + * Check if any other kidnapper in this camp is already dogwalking. + */ + private boolean isCampAlreadyDogwalking() { + if (!(this.kidnapper.level() instanceof ServerLevel serverLevel)) { + return false; + } + + // Find nearby kidnappers + for (EntityKidnapper other : serverLevel.getEntitiesOfClass( + EntityKidnapper.class, + this.kidnapper.getBoundingBox().inflate(100) + )) { + if (other == this.kidnapper) continue; + + // Check if this kidnapper is dogwalking + if (other.isDogwalking()) { + return true; + } + } + + return false; + } + + @Override + public boolean canContinueToUse() { + if (this.walkState == STATE_DONE) { + return false; + } + + if (this.kidnapper.isTiedUp()) { + return false; + } + + // If prisoner escaped or died during walk + if ( + this.walkedPrisoner != null && + !this.walkedPrisoner.asLivingEntity().isAlive() + ) { + return false; + } + + return true; + } + + @Override + public void start() { + this.walkState = STATE_PICKING_PRISONER; + this.stateTicks = 0; + this.extracted = false; + this.stuckDetector.reset(); + this.walkDuration = + MIN_WALK_DURATION + + this.kidnapper.getRandom().nextInt( + MAX_WALK_DURATION - MIN_WALK_DURATION + ); + + TiedUpMod.LOGGER.debug( + "[KidnapperWalkPrisonerGoal] {} starting walk behavior", + this.kidnapper.getNpcName() + ); + } + + @Override + public void stop() { + // If walk was interrupted, restore prisoner to cell only if extracted + if (this.walkedPrisoner != null && this.walkState != STATE_DONE) { + if (this.extracted) { + // Prisoner was extracted from cell, must return + restorePrisonerToCell(); + } + // else: not yet extracted (PICKING/GOING_TO_CELL), nothing to clean up + } + + // Reset cooldown + this.walkCooldown = WALK_COOLDOWN_DURATION; + + // Clear dogwalking flag + this.kidnapper.setDogwalking(false); + + // Cleanup + this.walkedPrisoner = null; + this.walkedPrisonerId = null; + this.originalBind = null; + this.originCell = null; + this.currentWaypoint = null; + this.waypointNav = null; + this.extracted = false; + this.stuckDetector.reset(); + this.walkState = STATE_IDLE; + this.kidnapper.getNavigation().stop(); + + // Return to IDLE state + if (this.kidnapper.getCurrentState() != KidnapperState.ALERT) { + this.kidnapper.setCurrentState(KidnapperState.IDLE); + } + } + + @Override + public void tick() { + this.stateTicks++; + + // Update dogwalking flag on kidnapper + this.kidnapper.setDogwalking( + this.walkState != STATE_IDLE && this.walkState != STATE_DONE + ); + + switch (this.walkState) { + case STATE_PICKING_PRISONER -> tickPickPrisoner(); + case STATE_GOING_TO_CELL -> tickGoingToCell(); + case STATE_WALKING -> tickWalking(); + case STATE_RETURNING -> tickReturning(); + case STATE_RECOVERING -> tickRecovering(); + } + } + + /** + * Pick a prisoner from a cell to walk. + * This state selects the cell and prisoner, then transitions to GOING_TO_CELL. + */ + private void tickPickPrisoner() { + if (!(this.kidnapper.level() instanceof ServerLevel serverLevel)) { + this.walkState = STATE_DONE; + return; + } + + // Find a cell with prisoners + List occupiedCells = + this.kidnapper.getNearbyCellsWithPrisoners(); + if (occupiedCells.isEmpty()) { + this.walkState = STATE_DONE; + return; + } + + // Pick a random cell + CellDataV2 cell = occupiedCells.get( + this.kidnapper.getRandom().nextInt(occupiedCells.size()) + ); + List prisonerIds = cell.getPrisonerIds(); + if (prisonerIds.isEmpty()) { + this.walkState = STATE_DONE; + return; + } + + // Pick a random prisoner (player or damsel) + UUID prisonerId = prisonerIds.get( + this.kidnapper.getRandom().nextInt(prisonerIds.size()) + ); + + // Try to get the prisoner entity + IBondageState prisoner = findPrisoner(serverLevel, prisonerId); + if (prisoner == null) { + this.walkState = STATE_DONE; + return; + } + + // Check if prisoner is tied up + if (!prisoner.isTiedUp()) { + this.walkState = STATE_DONE; + return; + } + + // Store selection - actual pickup happens when we reach the cell + this.walkedPrisoner = prisoner; + this.walkedPrisonerId = prisonerId; + this.originCell = cell; + this.originalBind = prisoner.getEquipment(BodyRegionV2.ARMS).copy(); + + TiedUpMod.LOGGER.debug( + "[KidnapperWalkPrisonerGoal] {} going to cell to pick up {}", + this.kidnapper.getNpcName(), + prisoner.getKidnappedName() + ); + + // Set up waypoint navigation if cell has waypoints (forward to cell) + if (!cell.getPathWaypoints().isEmpty()) { + List path = new ArrayList<>(cell.getPathWaypoints()); + BlockPos deliveryPoint = getDeliveryOrSpawnPoint(cell); + path.add(deliveryPoint); // Add delivery as final waypoint + this.waypointNav = new WaypointNavigator(this.kidnapper, path, 0.8); + TiedUpMod.LOGGER.debug( + "[KidnapperWalkPrisonerGoal] Using {} waypoints to reach cell", + path.size() + ); + } else { + this.waypointNav = null; + } + + // Transition to going to cell + this.walkState = STATE_GOING_TO_CELL; + this.stateTicks = 0; + this.stuckDetector.reset(); + } + + /** + * Walk to the cell to pick up the prisoner. + */ + private void tickGoingToCell() { + // TIMEOUT: Prevent infinite pathfinding if stuck + if (this.stateTicks > TRANSITION_STATE_TIMEOUT) { + TiedUpMod.LOGGER.warn( + "[KidnapperWalkPrisonerGoal] {} timed out going to cell, aborting walk", + this.kidnapper.getNpcName() + ); + this.walkState = STATE_DONE; + return; + } + + if (this.originCell == null || this.walkedPrisoner == null) { + this.walkState = STATE_DONE; + return; + } + + // Check if prisoner is still valid + if ( + !this.walkedPrisoner.asLivingEntity().isAlive() || + !this.walkedPrisoner.isTiedUp() + ) { + this.walkState = STATE_DONE; + return; + } + + // WAYPOINT NAVIGATION MODE + if (this.waypointNav != null && this.waypointNav.hasWaypoints()) { + this.waypointNav.tick(); + + if (this.waypointNav.isComplete()) { + // Reached cell - pick up the prisoner + pickUpPrisoner(); + return; + } + + // Navigate to current waypoint + if (this.stateTicks % 20 == 0) { + this.waypointNav.navigateToCurrentWaypoint(); + } + return; + } + + // VANILLA NAVIGATION MODE + BlockPos deliveryPoint = getDeliveryOrSpawnPoint(this.originCell); + double distToCell = this.kidnapper.distanceToSqr( + deliveryPoint.getX() + 0.5, + deliveryPoint.getY(), + deliveryPoint.getZ() + 0.5 + ); + + // Arrived at cell - pick up the prisoner + if (distToCell <= 9.0) { + pickUpPrisoner(); + return; + } + + // Update stuck detection + this.stuckDetector.update(this.kidnapper); + + if (this.stuckDetector.isStuck()) { + if (this.stuckDetector.shouldTeleport()) { + // Teleport to delivery point + this.kidnapper.teleportTo( + deliveryPoint.getX() + 0.5, + deliveryPoint.getY(), + deliveryPoint.getZ() + 0.5 + ); + TiedUpMod.LOGGER.debug( + "[KidnapperWalkPrisonerGoal] {} teleported to cell delivery point to unblock", + this.kidnapper.getNpcName() + ); + this.stuckDetector.reset(); + pickUpPrisoner(); + } else if ( + this.kidnapper.level() instanceof ServerLevel serverLevel + ) { + BlockPos detour = this.stuckDetector.tryDetour( + serverLevel, + this.kidnapper.blockPosition(), + 5 + ); + if (detour != null) { + this.kidnapper.getNavigation().moveTo( + detour.getX() + 0.5, + detour.getY(), + detour.getZ() + 0.5, + 0.8 + ); + } + } + return; + } + + // Skip normal navigation if in detour + if (this.stuckDetector.isInDetour()) { + return; + } + + // Move towards cell delivery point + if (this.stateTicks % 20 == 0) { + boolean pathFound = this.kidnapper.getNavigation().moveTo( + deliveryPoint.getX() + 0.5, + deliveryPoint.getY(), + deliveryPoint.getZ() + 0.5, + 0.8 + ); + if (!pathFound) { + this.stuckDetector.onPathFailed(); + } + } + } + + /** + * Actually pick up the prisoner once at the cell. + * + * Uses PrisonerService.extractFromCell() for atomic state management: + * 1. PrisonerService.extractFromCell() — PrisonerManager + CellRegistry + leash (with rollback) + * 2. Teleport prisoner to kidnapper + * 3. Change bind to DOGBINDER + */ + private void pickUpPrisoner() { + if (this.walkedPrisoner == null || this.walkedPrisonerId == null) { + this.walkState = STATE_DONE; + return; + } + + if (!(this.kidnapper.level() instanceof ServerLevel serverLevel)) { + this.walkState = STATE_DONE; + return; + } + + if (this.originCell == null) { + this.walkState = STATE_DONE; + return; + } + + net.minecraft.world.entity.LivingEntity prisonerEntity = + this.walkedPrisoner.asLivingEntity(); + + // 1. Extract from cell via PrisonerService (PrisonerManager + CellRegistry + leash, atomic) + boolean success = + com.tiedup.remake.prison.service.PrisonerService.get().extractFromCell( + serverLevel, + prisonerEntity, + this.kidnapper, + this.originCell + ); + + if (!success) { + TiedUpMod.LOGGER.warn( + "[KidnapperWalkPrisonerGoal] {} failed to extract {}, aborting walk", + this.kidnapper.getNpcName(), + this.walkedPrisoner.getKidnappedName() + ); + this.walkState = STATE_DONE; + return; + } + + this.extracted = true; + + // 2. Teleport prisoner to kidnapper + prisonerEntity.teleportTo( + this.kidnapper.getX(), + this.kidnapper.getY(), + this.kidnapper.getZ() + ); + + // 3. Change bind to DOGBINDER + ItemStack dogBinder = new ItemStack( + ModItems.getBind(BindVariant.DOGBINDER) + ); + this.walkedPrisoner.equip(BodyRegionV2.ARMS, dogBinder); + + TiedUpMod.LOGGER.debug( + "[KidnapperWalkPrisonerGoal] {} taking {} for a walk", + this.kidnapper.getNpcName(), + this.walkedPrisoner.getKidnappedName() + ); + + // Dialogue: Start walk + if (prisonerEntity instanceof ServerPlayer player) { + DialogueBridge.talkTo(this.kidnapper, player, "dogwalk.start"); + } + + this.walkState = STATE_WALKING; + this.stateTicks = 0; + this.waypointTimer = 0; + this.currentWaypoint = calculateWalkWaypoint(); + } + + /** + * Walk the prisoner around. + */ + private void tickWalking() { + if (this.walkedPrisoner == null) { + this.walkState = STATE_DONE; + return; + } + + // Check if captive was lost (rope broke, fight back, etc.) + if (!this.kidnapper.hasCaptives()) { + TiedUpMod.LOGGER.debug( + "[KidnapperWalkPrisonerGoal] {} lost captive during walk, switching to recovery", + this.kidnapper.getNpcName() + ); + this.walkState = STATE_RECOVERING; + this.stateTicks = 0; + return; + } + + this.waypointTimer++; + + // Change waypoint periodically + int waypointInterval = + WAYPOINT_INTERVAL_MIN + + this.kidnapper.getRandom().nextInt( + WAYPOINT_INTERVAL_MAX - WAYPOINT_INTERVAL_MIN + ); + if ( + this.waypointTimer >= waypointInterval || + this.currentWaypoint == null + ) { + this.waypointTimer = 0; + this.currentWaypoint = calculateWalkWaypoint(); + } + + // Occasional dialogue during walk (every ~20 seconds) + if ( + this.stateTicks % 400 == 0 && + this.kidnapper.getRandom().nextFloat() < 0.5f + ) { + if ( + this.walkedPrisoner.asLivingEntity() instanceof + ServerPlayer player + ) { + DialogueBridge.talkTo(this.kidnapper, player, "dogwalk.during"); + } + } + + // Move towards waypoint + if (this.currentWaypoint != null) { + double distSq = this.kidnapper.distanceToSqr( + this.currentWaypoint.getX() + 0.5, + this.currentWaypoint.getY(), + this.currentWaypoint.getZ() + 0.5 + ); + + if (distSq > 4.0) { + if (this.stateTicks % 20 == 0) { + this.kidnapper.getNavigation().moveTo( + this.currentWaypoint.getX() + 0.5, + this.currentWaypoint.getY(), + this.currentWaypoint.getZ() + 0.5, + 0.7 // Moderate walking speed + ); + } + } + } + + // Look at prisoner occasionally + if (this.stateTicks % 40 == 0) { + this.kidnapper.getLookControl().setLookAt( + this.walkedPrisoner.asLivingEntity(), + 30.0F, + 30.0F + ); + } + + // Check if walk duration is complete + if (this.stateTicks >= this.walkDuration) { + TiedUpMod.LOGGER.debug( + "[KidnapperWalkPrisonerGoal] {} finished walking {}, returning to cell", + this.kidnapper.getNpcName(), + this.walkedPrisoner.getKidnappedName() + ); + + // Set up waypoint navigation for return to cell (forward direction - going TO cell) + if ( + this.originCell != null && + !this.originCell.getPathWaypoints().isEmpty() + ) { + List path = new ArrayList<>( + this.originCell.getPathWaypoints() + ); + BlockPos deliveryPoint = getDeliveryOrSpawnPoint( + this.originCell + ); + path.add(deliveryPoint); // Add delivery as final waypoint + this.waypointNav = new WaypointNavigator( + this.kidnapper, + path, + 0.8 + ); + TiedUpMod.LOGGER.debug( + "[KidnapperWalkPrisonerGoal] Using {} waypoints to return to cell", + path.size() + ); + } else { + this.waypointNav = null; + } + + this.walkState = STATE_RETURNING; + this.stateTicks = 0; + this.stuckDetector.reset(); + } + } + + /** + * Recover the escaped prisoner and resume walk. + */ + private void tickRecovering() { + if (this.walkedPrisonerId == null) { + this.walkState = STATE_DONE; + return; + } + + if (!(this.kidnapper.level() instanceof ServerLevel serverLevel)) { + this.walkState = STATE_DONE; + return; + } + + // Find the prisoner + IBondageState prisoner = findPrisoner(serverLevel, this.walkedPrisonerId); + if (prisoner == null || !prisoner.asLivingEntity().isAlive()) { + // Prisoner died or disappeared - abort + TiedUpMod.LOGGER.debug( + "[KidnapperWalkPrisonerGoal] {} lost prisoner during recovery, aborting walk", + this.kidnapper.getNpcName() + ); + this.walkState = STATE_DONE; + return; + } + + net.minecraft.world.entity.LivingEntity prisonerEntity = + prisoner.asLivingEntity(); + + // Move towards the prisoner + double distSq = this.kidnapper.distanceToSqr(prisonerEntity); + if (distSq > 4.0) { + if (this.stateTicks % 10 == 0) { + this.kidnapper.getNavigation().moveTo(prisonerEntity, 1.0); // Run to catch up + } + this.kidnapper.getLookControl().setLookAt( + prisonerEntity, + 30.0F, + 30.0F + ); + return; + } + + // Close enough - try to recapture + // Make sure they still have the dogbinder on + ItemStack currentBind = prisoner.getEquipment(BodyRegionV2.ARMS); + if (currentBind.isEmpty() || !prisoner.isTiedUp()) { + // They freed themselves - put dogbinder back on + ItemStack dogBinder = new ItemStack( + ModItems.getBind(BindVariant.DOGBINDER) + ); + prisoner.equip(BodyRegionV2.ARMS, dogBinder); + } + + // Recapture — use forceCapturedBy for NPCs (bypasses canCapture) + boolean recaptured; + if (prisoner.asLivingEntity() instanceof AbstractTiedUpNpc npc) { + recaptured = npc.forceCapturedBy(this.kidnapper); + } else { + recaptured = prisoner.getCapturedBy(this.kidnapper); + } + + if (recaptured) { + TiedUpMod.LOGGER.debug( + "[KidnapperWalkPrisonerGoal] {} recaptured {}, resuming walk", + this.kidnapper.getNpcName(), + prisoner.getKidnappedName() + ); + this.walkedPrisoner = prisoner; + this.walkState = STATE_WALKING; + this.stateTicks = 0; + } + } + + /** + * Return prisoner to their cell. + */ + private void tickReturning() { + // TIMEOUT: Prevent infinite pathfinding if stuck + if (this.stateTicks > TRANSITION_STATE_TIMEOUT) { + TiedUpMod.LOGGER.warn( + "[KidnapperWalkPrisonerGoal] {} timed out returning to cell, forcing restore via teleport", + this.kidnapper.getNpcName() + ); + restorePrisonerToCell(); // Force restore via teleport fallback + this.walkState = STATE_DONE; + return; + } + + if (this.walkedPrisoner == null || this.originCell == null) { + this.walkState = STATE_DONE; + return; + } + + // Check if captive was lost during return + if (!this.kidnapper.hasCaptives()) { + TiedUpMod.LOGGER.debug( + "[KidnapperWalkPrisonerGoal] {} lost captive during return, switching to recovery", + this.kidnapper.getNpcName() + ); + this.walkState = STATE_RECOVERING; + this.stateTicks = 0; + return; + } + + // WAYPOINT NAVIGATION MODE + if (this.waypointNav != null && this.waypointNav.hasWaypoints()) { + this.waypointNav.tick(); + + if (this.waypointNav.isComplete()) { + // Arrived at cell - restore prisoner + restorePrisonerToCell(); + this.walkState = STATE_DONE; + return; + } + + // Navigate to current waypoint + if (this.stateTicks % 20 == 0) { + this.waypointNav.navigateToCurrentWaypoint(); + } + return; + } + + // VANILLA NAVIGATION MODE + BlockPos deliveryPoint = getDeliveryOrSpawnPoint(this.originCell); + double distToCell = this.kidnapper.distanceToSqr( + deliveryPoint.getX() + 0.5, + deliveryPoint.getY(), + deliveryPoint.getZ() + 0.5 + ); + + // Arrived at cell - restore prisoner + if (distToCell <= 9.0) { + restorePrisonerToCell(); + this.walkState = STATE_DONE; + return; + } + + // Update stuck detection + this.stuckDetector.update(this.kidnapper); + + if (this.stuckDetector.isStuck()) { + if (this.stuckDetector.shouldTeleport()) { + // Teleport to delivery point and restore prisoner + this.kidnapper.teleportTo( + deliveryPoint.getX() + 0.5, + deliveryPoint.getY(), + deliveryPoint.getZ() + 0.5 + ); + TiedUpMod.LOGGER.debug( + "[KidnapperWalkPrisonerGoal] {} teleported to delivery point for return", + this.kidnapper.getNpcName() + ); + this.stuckDetector.reset(); + restorePrisonerToCell(); + this.walkState = STATE_DONE; + } else if ( + this.kidnapper.level() instanceof ServerLevel serverLevel + ) { + BlockPos detour = this.stuckDetector.tryDetour( + serverLevel, + this.kidnapper.blockPosition(), + 5 + ); + if (detour != null) { + this.kidnapper.getNavigation().moveTo( + detour.getX() + 0.5, + detour.getY(), + detour.getZ() + 0.5, + 0.8 + ); + } + } + return; + } + + // Skip normal navigation if in detour + if (this.stuckDetector.isInDetour()) { + return; + } + + // Move towards cell delivery point + if (this.stateTicks % 20 == 0) { + boolean pathFound = this.kidnapper.getNavigation().moveTo( + deliveryPoint.getX() + 0.5, + deliveryPoint.getY(), + deliveryPoint.getZ() + 0.5, + 0.8 + ); + if (!pathFound) { + this.stuckDetector.onPathFailed(); + } + } + } + + /** + * Restore prisoner to their cell with original bind. + * Uses PrisonerService.returnToCell() for atomic state management. + * Uses findGroundPosition() to ensure safe teleport location. + * Handles dead prisoners: still fixes PrisonerManager/CellRegistry state. + */ + private void restorePrisonerToCell() { + if (this.walkedPrisoner == null) return; + + if ( + !(this.kidnapper.level() instanceof ServerLevel serverLevel) + ) return; + + net.minecraft.world.entity.LivingEntity prisonerEntity = + this.walkedPrisoner.asLivingEntity(); + boolean isAlive = prisonerEntity.isAlive(); + + // 1. Restore original bind (only if alive) + if ( + isAlive && this.originalBind != null && !this.originalBind.isEmpty() + ) { + this.walkedPrisoner.equip(BodyRegionV2.ARMS, this.originalBind); + } + + // 2. Teleport to cell spawn point (only if alive) + if (isAlive && this.originCell != null) { + BlockPos spawn = + this.originCell.getSpawnPoint() != null + ? this.originCell.getSpawnPoint() + : this.originCell.getCorePos().above(); + BlockPos safeSpawn = findGroundPosition(serverLevel, spawn); + prisonerEntity.teleportTo( + safeSpawn.getX() + 0.5, + safeSpawn.getY(), + safeSpawn.getZ() + 0.5 + ); + } + + // 3. Return to cell via PrisonerService (unleash + PrisonerManager + CellRegistry, with force-repair) + if (this.extracted && this.originCell != null) { + com.tiedup.remake.prison.service.PrisonerService.get().returnToCell( + serverLevel, + prisonerEntity, + this.kidnapper, + this.originCell + ); + this.extracted = false; + } else if (this.originCell != null && this.walkedPrisonerId != null) { + // Not extracted (damsel or PrisonerManager wasn't involved) — just unleash + reassign cell + if (isAlive && this.walkedPrisoner.isCaptive()) { + this.walkedPrisoner.free(false); + } + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + registry.assignPrisoner( + this.originCell.getId(), + this.walkedPrisonerId + ); + } + + // 4. Dialogue: Return to cell (only if alive) + if (isAlive && prisonerEntity instanceof ServerPlayer player) { + DialogueBridge.talkTo(this.kidnapper, player, "dogwalk.return"); + } + + TiedUpMod.LOGGER.debug( + "[KidnapperWalkPrisonerGoal] {} returned {} to cell{}", + this.kidnapper.getNpcName(), + this.walkedPrisoner.getKidnappedName(), + isAlive ? "" : " (dead - state cleanup only)" + ); + } + + /** + * Find a prisoner by UUID - can be player or NPC. + */ + @Nullable + private IBondageState findPrisoner(ServerLevel level, UUID prisonerId) { + // Try player first + ServerPlayer player = level + .getServer() + .getPlayerList() + .getPlayer(prisonerId); + if (player != null && player.isAlive()) { + return KidnappedHelper.getKidnappedState(player); + } + + // Try NPC + net.minecraft.world.entity.Entity entity = level.getEntity(prisonerId); + if (entity instanceof AbstractTiedUpNpc npc && npc.isAlive()) { + return npc; + } + + return null; + } + + /** + * Calculate a random walk waypoint around the origin cell. + * Uses findGroundPosition() to ensure waypoints are on solid ground. + */ + private BlockPos calculateWalkWaypoint() { + BlockPos center; + if (this.originCell != null) { + center = this.originCell.getCorePos(); + } else { + center = this.kidnapper.blockPosition(); + } + + // Random angle + double angle = this.kidnapper.getRandom().nextDouble() * Math.PI * 2; + + // Random distance + double distance = + 10 + this.kidnapper.getRandom().nextDouble() * (WALK_RADIUS - 10); + + int offsetX = (int) (Math.cos(angle) * distance); + int offsetZ = (int) (Math.sin(angle) * distance); + + // Calculate raw position and find actual ground + BlockPos rawPos = center.offset(offsetX, 0, offsetZ); + return findGroundPosition(this.kidnapper.level(), rawPos); + } + + /** + * Find a safe ground position at or near the given XZ coordinates. + * Searches down first (max 10 blocks), then up if not found. + * + * @param level The level to search in + * @param pos The starting position + * @return A position on solid ground, or the original position if none found + */ + private BlockPos findGroundPosition(Level level, BlockPos pos) { + BlockPos.MutableBlockPos mutable = pos.mutable(); + + // Search downward first (max 10 blocks) + for (int y = 0; y > -10; y--) { + mutable.setY(pos.getY() + y); + if ( + level.getBlockState(mutable).isSolidRender(level, mutable) && + level.getBlockState(mutable.above()).isAir() + ) { + return mutable.above().immutable(); + } + } + + // Search upward if ground not found below (max 10 blocks) + for (int y = 1; y < 10; y++) { + mutable.setY(pos.getY() + y); + if ( + level + .getBlockState(mutable.below()) + .isSolidRender(level, mutable.below()) && + level.getBlockState(mutable).isAir() + ) { + return mutable.immutable(); + } + } + + // Fallback: return original position + return pos; + } + + /** + * Get the delivery point for a cell, or fall back to spawn point. + * Uses KidnapperAIHelper for shared cell navigation logic. + */ + private BlockPos getDeliveryOrSpawnPoint(CellDataV2 cell) { + if (cell == null) { + return this.kidnapper.blockPosition(); + } + return KidnapperAIHelper.getDeliveryOrSpawnPoint( + cell, + this.kidnapper.level() + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/maid/MaidCollectRansomGoal.java b/src/main/java/com/tiedup/remake/entities/ai/maid/MaidCollectRansomGoal.java new file mode 100644 index 0000000..e5363a8 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/maid/MaidCollectRansomGoal.java @@ -0,0 +1,306 @@ +package com.tiedup.remake.entities.ai.maid; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityMaid; +import com.tiedup.remake.entities.EntitySlaveTrader; +import java.util.EnumSet; +import java.util.List; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.Container; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; + +/** + * AI Goal for EntityMaid to collect ransom/items from a location. + * + * Behavior: + * - Active when state is COLLECTING + * - Walks to the collectTarget position + * - Collects items from containers or ground + * - Returns to trader with collected items + */ +public class MaidCollectRansomGoal extends Goal { + + private final EntityMaid maid; + + /** Distance at which collection starts */ + private static final double COLLECT_DISTANCE = 2.0; + + /** Distance at which to consider "returned to trader" */ + private static final double RETURN_DISTANCE = 3.0; + + /** Maximum ticks before giving up */ + private static final int MAX_COLLECT_TICKS = 1200; // 60 seconds + + /** Radius to search for items on ground */ + private static final double ITEM_SEARCH_RADIUS = 3.0; + + /** Ticks spent on collection task */ + private int collectTicks = 0; + + /** Whether items have been collected */ + private boolean itemsCollected = false; + + /** Cached trader for return journey */ + private EntitySlaveTrader cachedTrader; + + public MaidCollectRansomGoal(EntityMaid maid) { + this.maid = maid; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Must be in COLLECTING state + if (maid.getMaidState() != MaidState.COLLECTING) { + return false; + } + + // Must not be tied up + if (maid.isTiedUp()) { + return false; + } + + // Must have an assigned master trader + if (maid.getMasterTraderUUID() == null) { + return false; + } + + // Must have a collect target + BlockPos target = maid.getCollectTarget(); + if (target == null) { + return false; + } + + // Must be on server + if (!(maid.level() instanceof ServerLevel)) { + return false; + } + + // Cache the trader for return journey + cachedTrader = maid.getMasterTrader(); + + return true; + } + + @Override + public boolean canContinueToUse() { + if (maid.getMaidState() != MaidState.COLLECTING) { + return false; + } + + if (maid.isTiedUp()) { + return false; + } + + // Timeout check + if (collectTicks > MAX_COLLECT_TICKS) { + TiedUpMod.LOGGER.warn( + "[MaidCollectRansomGoal] {} collection timed out", + maid.getNpcName() + ); + maid.completeTask(); + return false; + } + + return true; + } + + @Override + public void start() { + collectTicks = 0; + itemsCollected = false; + + BlockPos target = maid.getCollectTarget(); + TiedUpMod.LOGGER.info( + "[MaidCollectRansomGoal] {} starting collection at {}", + maid.getNpcName(), + target != null ? target.toShortString() : "unknown" + ); + } + + @Override + public void stop() { + collectTicks = 0; + itemsCollected = false; + cachedTrader = null; + maid.getNavigation().stop(); + maid.setMaidState(MaidState.IDLE); + } + + @Override + public void tick() { + BlockPos target = maid.getCollectTarget(); + if (target == null) { + return; + } + + collectTicks++; + + if (!itemsCollected) { + // Phase 1: Go to collect target and collect items + tickCollectPhase(target); + } else { + // Phase 2: Return to trader + tickReturnPhase(); + } + } + + /** + * Phase 1: Walk to target and collect items. + */ + private void tickCollectPhase(BlockPos target) { + // Look at target + maid + .getLookControl() + .setLookAt( + target.getX() + 0.5, + target.getY() + 0.5, + target.getZ() + 0.5, + 30.0F, + 30.0F + ); + + double distSq = maid.distanceToSqr(Vec3.atCenterOf(target)); + + if (distSq < COLLECT_DISTANCE * COLLECT_DISTANCE) { + // Close enough - collect items + collectItems(target); + itemsCollected = true; + + TiedUpMod.LOGGER.info( + "[MaidCollectRansomGoal] {} collected items at {}", + maid.getNpcName(), + target.toShortString() + ); + } else { + // Move towards target + if (collectTicks % 10 == 0) { + maid + .getNavigation() + .moveTo( + target.getX() + 0.5, + target.getY(), + target.getZ() + 0.5, + 1.2 + ); + } + } + } + + /** + * Phase 2: Return to trader with collected items. + */ + private void tickReturnPhase() { + // Refresh trader reference if null + if (cachedTrader == null || !cachedTrader.isAlive()) { + cachedTrader = maid.getMasterTrader(); + } + + if (cachedTrader == null) { + // No trader to return to - just complete + TiedUpMod.LOGGER.warn( + "[MaidCollectRansomGoal] {} has no trader to return to", + maid.getNpcName() + ); + maid.completeTask(); + return; + } + + // Look at trader + maid.getLookControl().setLookAt(cachedTrader, 30.0F, 30.0F); + + double distSq = maid.distanceToSqr(cachedTrader); + + if (distSq < RETURN_DISTANCE * RETURN_DISTANCE) { + // Returned to trader - complete task + TiedUpMod.LOGGER.info( + "[MaidCollectRansomGoal] {} returned to trader {}", + maid.getNpcName(), + cachedTrader.getNpcName() + ); + maid.completeTask(); + } else { + // Move towards trader + if (collectTicks % 10 == 0) { + maid.getNavigation().moveTo(cachedTrader, 1.0); + } + } + } + + /** + * Collect items from the target location. + * Checks containers first, then ground items. + */ + private void collectItems(BlockPos target) { + if (!(maid.level() instanceof ServerLevel serverLevel)) { + return; + } + + // Try to collect from container first + BlockEntity blockEntity = serverLevel.getBlockEntity(target); + if (blockEntity instanceof Container container) { + collectFromContainer(container); + return; + } + + // Otherwise, collect items from ground + collectFromGround(serverLevel, target); + } + + /** + * Collect items from a container. + */ + private void collectFromContainer(Container container) { + int collected = 0; + + for (int i = 0; i < container.getContainerSize(); i++) { + ItemStack stack = container.getItem(i); + if (!stack.isEmpty()) { + // In a full implementation, we'd transfer to maid's inventory + // For now, just remove from container (simulating collection) + container.setItem(i, ItemStack.EMPTY); + collected++; + + TiedUpMod.LOGGER.debug( + "[MaidCollectRansomGoal] Collected {} from container", + stack.getDisplayName().getString() + ); + } + } + + if (collected > 0) { + container.setChanged(); + } + } + + /** + * Collect items from the ground near the target. + */ + private void collectFromGround(ServerLevel level, BlockPos target) { + AABB searchBox = new AABB(target).inflate(ITEM_SEARCH_RADIUS); + List items = level.getEntitiesOfClass( + ItemEntity.class, + searchBox + ); + + for (ItemEntity itemEntity : items) { + if (itemEntity.isAlive()) { + ItemStack stack = itemEntity.getItem(); + + TiedUpMod.LOGGER.debug( + "[MaidCollectRansomGoal] Collected {} from ground", + stack.getDisplayName().getString() + ); + + // Remove the item from the world + itemEntity.discard(); + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/maid/MaidDefendTraderGoal.java b/src/main/java/com/tiedup/remake/entities/ai/maid/MaidDefendTraderGoal.java new file mode 100644 index 0000000..37d61b3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/maid/MaidDefendTraderGoal.java @@ -0,0 +1,163 @@ +package com.tiedup.remake.entities.ai.maid; + +import com.tiedup.remake.entities.EntityMaid; +import com.tiedup.remake.entities.EntitySlaveTrader; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.target.TargetGoal; +import net.minecraft.world.entity.ai.targeting.TargetingConditions; + +/** + * AI Goal for EntityMaid to defend their master trader when attacked. + * + * Behavior: + * - Monitors trader's lastAttacker + * - Sets maid state to DEFENDING + * - Attacks anyone who hurts the trader + * - Timeout after 30 seconds of no combat + */ +public class MaidDefendTraderGoal extends TargetGoal { + + private final EntityMaid maid; + + /** How long to stay in defending mode after trader is no longer attacked */ + private static final int DEFEND_TIMEOUT_TICKS = 600; // 30 seconds + + /** Ticks since trader was last attacked */ + private int ticksSinceTraderAttacked = 0; + + /** Current target being attacked */ + private LivingEntity targetAttacker; + + private static final TargetingConditions DEFEND_TARGETING = + TargetingConditions.forCombat().range(32.0D).ignoreLineOfSight(); + + public MaidDefendTraderGoal(EntityMaid maid) { + super(maid, false); + this.maid = maid; + } + + @Override + public boolean canUse() { + // Must have a master trader + if (maid.getMasterTraderUUID() == null) { + return false; + } + + // Must not be freed + if (maid.isFreed()) { + return false; + } + + // Must not be tied up + if (maid.isTiedUp()) { + return false; + } + + // Cannot defend while busy with a task + if (maid.getMaidState().isBusy()) { + return false; + } + + // Get trader and check for attacker + EntitySlaveTrader trader = maid.getMasterTrader(); + if (trader == null || !trader.isAlive()) { + return false; + } + + LivingEntity traderAttacker = trader.getLastAttacker(); + if (traderAttacker == null || !traderAttacker.isAlive()) { + return false; + } + + // Don't attack other maids or traders + if ( + traderAttacker instanceof EntityMaid || + traderAttacker instanceof EntitySlaveTrader + ) { + return false; + } + + // Valid target found + this.targetAttacker = traderAttacker; + return true; + } + + @Override + public boolean canContinueToUse() { + // Stop if freed or tied up + if (maid.isFreed() || maid.isTiedUp()) { + return false; + } + + // Stop if trader is dead + EntitySlaveTrader trader = maid.getMasterTrader(); + if (trader == null || !trader.isAlive()) { + return false; + } + + // Check if target is still valid + if (targetAttacker == null || !targetAttacker.isAlive()) { + ticksSinceTraderAttacked++; + return ticksSinceTraderAttacked < DEFEND_TIMEOUT_TICKS; + } + + // Check if we're still in range + double distSq = maid.distanceToSqr(targetAttacker); + if (distSq > 32 * 32) { + ticksSinceTraderAttacked++; + return ticksSinceTraderAttacked < DEFEND_TIMEOUT_TICKS; + } + + // Reset timer if trader is being attacked + LivingEntity currentAttacker = trader.getLastAttacker(); + if (currentAttacker != null && currentAttacker.isAlive()) { + ticksSinceTraderAttacked = 0; + this.targetAttacker = currentAttacker; + } else { + ticksSinceTraderAttacked++; + } + + return ticksSinceTraderAttacked < DEFEND_TIMEOUT_TICKS; + } + + @Override + public void start() { + maid.setTarget(targetAttacker); + maid.setMaidState(MaidState.DEFENDING); + ticksSinceTraderAttacked = 0; + } + + @Override + public void stop() { + maid.setTarget(null); + targetAttacker = null; + ticksSinceTraderAttacked = 0; + + // Return to IDLE if we were DEFENDING + if (maid.getMaidState() == MaidState.DEFENDING) { + maid.setMaidState(MaidState.IDLE); + } + } + + @Override + public void tick() { + // Update target if trader's attacker changed + EntitySlaveTrader trader = maid.getMasterTrader(); + if (trader != null) { + LivingEntity currentAttacker = trader.getLastAttacker(); + if ( + currentAttacker != null && + currentAttacker.isAlive() && + currentAttacker != targetAttacker + ) { + this.targetAttacker = currentAttacker; + maid.setTarget(targetAttacker); + } + } + + // Look at target + if (targetAttacker != null && targetAttacker.isAlive()) { + maid.getLookControl().setLookAt(targetAttacker, 30.0F, 30.0F); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/maid/MaidDeliverCaptiveGoal.java b/src/main/java/com/tiedup/remake/entities/ai/maid/MaidDeliverCaptiveGoal.java new file mode 100644 index 0000000..5f03327 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/maid/MaidDeliverCaptiveGoal.java @@ -0,0 +1,362 @@ +package com.tiedup.remake.entities.ai.maid; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.entities.EntityMaid; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.EnumSet; +import java.util.UUID; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; + +/** + * AI Goal for EntityMaid to deliver a captive to a buyer. + * + * Behavior: + * - Active when state is DELIVERING + * - Walks towards buyer (3-block distance) + * - Releases captive upon arrival + */ +public class MaidDeliverCaptiveGoal extends Goal { + + private final EntityMaid maid; + + /** Distance at which delivery is complete */ + private static final double DELIVERY_DISTANCE = 3.0; + + /** Maximum ticks before giving up */ + private static final int MAX_DELIVERY_TICKS = 1200; // 60 seconds + + /** Cached buyer entity */ + private Player buyerEntity; + + /** Cached captive */ + private LivingEntity captiveEntity; + + /** Ticks spent delivering */ + private int deliveryTicks = 0; + + /** Consecutive path failures before forced teleport */ + private static final int PATH_FAIL_THRESHOLD = 3; + + /** Counter for consecutive path failures */ + private int pathFailCounter = 0; + + public MaidDeliverCaptiveGoal(EntityMaid maid) { + this.maid = maid; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Must be in DELIVERING state + if (maid.getMaidState() != MaidState.DELIVERING) { + return false; + } + + // Must not be tied up + if (maid.isTiedUp()) { + return false; + } + + // Must have target captive and buyer + UUID captiveUUID = maid.getTargetCaptiveUUID(); + UUID buyerUUID = maid.getTargetBuyerUUID(); + + if (captiveUUID == null || buyerUUID == null) { + return false; + } + + // Must be on server + if (!(maid.level() instanceof ServerLevel serverLevel)) { + return false; + } + + // Find buyer + var buyer = serverLevel.getEntity(buyerUUID); + if (!(buyer instanceof Player player) || !player.isAlive()) { + // Buyer logged off or died - cancel delivery + maid.completeTask(); + return false; + } + this.buyerEntity = player; + + // Find captive + var captive = serverLevel.getEntity(captiveUUID); + if (!(captive instanceof LivingEntity living) || !living.isAlive()) { + // Captive died - cancel delivery + maid.completeTask(); + return false; + } + this.captiveEntity = living; + + return true; + } + + @Override + public boolean canContinueToUse() { + if (maid.getMaidState() != MaidState.DELIVERING) { + return false; + } + + if (maid.isTiedUp()) { + return false; + } + + if (buyerEntity == null || !buyerEntity.isAlive()) { + return false; + } + + if (captiveEntity == null || !captiveEntity.isAlive()) { + return false; + } + + // Timeout check + if (deliveryTicks > MAX_DELIVERY_TICKS) { + TiedUpMod.LOGGER.warn( + "[MaidDeliverCaptiveGoal] {} delivery timed out", + maid.getNpcName() + ); + maid.completeTask(); + return false; + } + + return true; + } + + @Override + public void start() { + deliveryTicks = 0; + pathFailCounter = 0; + + TiedUpMod.LOGGER.info( + "[MaidDeliverCaptiveGoal] {} starting delivery to {}", + maid.getNpcName(), + buyerEntity != null ? buyerEntity.getName().getString() : "unknown" + ); + } + + @Override + public void stop() { + buyerEntity = null; + captiveEntity = null; + deliveryTicks = 0; + pathFailCounter = 0; + maid.getNavigation().stop(); + maid.setMaidState(MaidState.IDLE); + } + + @Override + public void tick() { + if (buyerEntity == null || captiveEntity == null) { + return; + } + + deliveryTicks++; + + // Check distance to buyer + double distSq = maid.distanceToSqr(buyerEntity); + + if (distSq < DELIVERY_DISTANCE * DELIVERY_DISTANCE) { + // Close enough - complete delivery + completeDelivery(); + } else { + // Move towards buyer + if (deliveryTicks % 10 == 0) { + boolean pathFound = maid + .getNavigation() + .moveTo(buyerEntity, 1.0); + + // Track path failures + if (!pathFound) { + pathFailCounter++; + TiedUpMod.LOGGER.debug( + "[MaidDeliverCaptiveGoal] {} path failed ({}/{})", + maid.getNpcName(), + pathFailCounter, + PATH_FAIL_THRESHOLD + ); + + // Teleport if path fails too many times + if (pathFailCounter >= PATH_FAIL_THRESHOLD) { + TiedUpMod.LOGGER.info( + "[MaidDeliverCaptiveGoal] {} can't find path, teleporting to buyer", + maid.getNpcName() + ); + maid.teleportTo( + buyerEntity.getX(), + buyerEntity.getY(), + buyerEntity.getZ() + ); + pathFailCounter = 0; + return; + } + } else { + pathFailCounter = 0; + } + } + + // Only look at buyer if we have an active path + if (!maid.getNavigation().isDone()) { + maid.getLookControl().setLookAt(buyerEntity, 30.0F, 30.0F); + } + } + } + + /** + * Complete the delivery - transfer captive to buyer with leash attached. + * Uses PrisonerService.transferCaptive() for atomic leash transfer. + */ + private void completeDelivery() { + if (captiveEntity == null || buyerEntity == null) { + return; + } + + // Get captive's kidnapped state + IRestrainable kidnappedState = KidnappedHelper.getKidnappedState( + captiveEntity + ); + + if (kidnappedState != null) { + // 1. Cancel sale state (they've been sold) + kidnappedState.cancelSale(); + + // 2. Transfer collar ownership to buyer + net.minecraft.world.item.ItemStack collar = + kidnappedState.getEquipment(BodyRegionV2.NECK); + if ( + !collar.isEmpty() && + collar.getItem() instanceof + com.tiedup.remake.items.base.ItemCollar collarItem + ) { + for (java.util.UUID ownerId : new java.util.ArrayList<>( + collarItem.getOwners(collar) + )) { + collarItem.removeOwner(collar, ownerId); + } + collarItem.addOwner( + collar, + buyerEntity.getUUID(), + buyerEntity.getName().getString() + ); + collarItem.setLocked(collar, false); + kidnappedState.equip(BodyRegionV2.NECK, collar); + + if ( + maid.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel + ) { + com.tiedup.remake.state.CollarRegistry collarRegistry = + com.tiedup.remake.state.CollarRegistry.get(serverLevel); + if (collarRegistry != null) { + collarRegistry.unregisterWearer( + captiveEntity.getUUID() + ); + collarRegistry.registerCollar( + captiveEntity.getUUID(), + buyerEntity.getUUID() + ); + } + } + + if ( + captiveEntity instanceof + net.minecraft.server.level.ServerPlayer captivePlayer + ) { + com.tiedup.remake.network.sync.SyncManager.syncBindState( + captivePlayer + ); + } + + TiedUpMod.LOGGER.info( + "[MaidDeliverCaptiveGoal] Transferred collar ownership to {}", + buyerEntity.getName().getString() + ); + } + + // 3. Transfer captive from maid to buyer via PrisonerService + com.tiedup.remake.state.PlayerBindState buyerState = + com.tiedup.remake.state.PlayerBindState.getInstance( + buyerEntity + ); + boolean transferred = false; + if ( + buyerState != null && + maid.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel + ) { + com.tiedup.remake.state.PlayerCaptorManager captorManager = + buyerState.getCaptorManager(); + if (captorManager != null) { + transferred = + com.tiedup.remake.prison.service.PrisonerService.get().transferCaptive( + serverLevel, + captiveEntity, + (com.tiedup.remake.state.ICaptor) maid, + captorManager + ); + } + } + + // 4. Release from PrisonerManager (bought = fully released from camp) + if ( + maid.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel + ) { + com.tiedup.remake.prison.PrisonerManager manager = + com.tiedup.remake.prison.PrisonerManager.get(serverLevel); + manager.release( + captiveEntity.getUUID(), + serverLevel.getGameTime() + ); + } + + if (transferred) { + // Give buyer a Lead item so they can manage the leash + buyerEntity + .getInventory() + .add( + new net.minecraft.world.item.ItemStack( + net.minecraft.world.item.Items.LEAD, + 1 + ) + ); + + TiedUpMod.LOGGER.info( + "[MaidDeliverCaptiveGoal] Leash attached to buyer {}", + buyerEntity.getName().getString() + ); + buyerEntity.sendSystemMessage( + net.minecraft.network.chat.Component.literal( + captiveEntity.getName().getString() + + " is now on your leash." + ).withStyle(net.minecraft.ChatFormatting.GREEN) + ); + } else { + TiedUpMod.LOGGER.warn( + "[MaidDeliverCaptiveGoal] Transfer to buyer failed, freeing captive" + ); + kidnappedState.free(true); + buyerEntity.sendSystemMessage( + net.minecraft.network.chat.Component.literal( + captiveEntity.getName().getString() + + " has been delivered to you." + ).withStyle(net.minecraft.ChatFormatting.GREEN) + ); + } + + TiedUpMod.LOGGER.info( + "[MaidDeliverCaptiveGoal] {} delivered {} to {}", + maid.getNpcName(), + captiveEntity.getName().getString(), + buyerEntity.getName().getString() + ); + } + + // Complete task + maid.completeTask(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/maid/MaidFetchCaptiveGoal.java b/src/main/java/com/tiedup/remake/entities/ai/maid/MaidFetchCaptiveGoal.java new file mode 100644 index 0000000..59b5cc5 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/maid/MaidFetchCaptiveGoal.java @@ -0,0 +1,296 @@ +package com.tiedup.remake.entities.ai.maid; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityMaid; +import com.tiedup.remake.state.ICaptor; +import java.util.EnumSet; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.Goal; + +/** + * AI Goal for EntityMaid to fetch a captive from their cell. + * + * Behavior: + * - Active when state is FETCHING + * - If cell has DELIVERY markers: navigate to delivery point + * - If no delivery markers: navigate toward cell with larger acceptance zone + * - Teleport prisoner TO maid (never maid into cell) + * - Capture + transition to DELIVERING + */ +public class MaidFetchCaptiveGoal extends Goal { + + private final EntityMaid maid; + + /** Distance for capture when navigating to delivery point */ + private static final double DELIVERY_CAPTURE_DIST = 3.0; + + /** Distance for capture when no delivery point (maid outside cell walls) */ + private static final double NO_DELIVERY_CAPTURE_DIST = 8.0; + + /** Maximum ticks before giving up */ + private static final int MAX_FETCH_TICKS = 1200; // 60 seconds + + /** If stuck for this long, just capture directly (TP prisoner out) */ + private static final int STUCK_CAPTURE_TICKS = 200; // 10 seconds + + /** Where the maid navigates to (delivery point or cell spawn) */ + private BlockPos navigationTarget; + + /** Whether the cell has a delivery point */ + private boolean hasDeliveryPoint; + + /** Target cell data */ + private CellDataV2 targetCell; + + /** Cell spawn point (for prisoner reference) */ + private BlockPos cellSpawnPoint; + + /** Target captive entity */ + private LivingEntity captiveEntity; + + /** Ticks spent fetching */ + private int fetchTicks = 0; + + /** Stuck detection */ + private int stuckTicks = 0; + private BlockPos lastProgressPos = null; + + public MaidFetchCaptiveGoal(EntityMaid maid) { + this.maid = maid; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (maid.getMaidState() != MaidState.FETCHING) { + return false; + } + if (maid.isTiedUp()) { + return false; + } + if (maid.hasCaptives()) { + return false; + } + + UUID captiveUUID = maid.getTargetCaptiveUUID(); + if (captiveUUID == null) { + return false; + } + if (!(maid.level() instanceof ServerLevel serverLevel)) { + return false; + } + + // Find the cell containing this captive + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + CellDataV2 cell = registry.findCellByPrisoner(captiveUUID); + if (cell == null) { + TiedUpMod.LOGGER.warn( + "[MaidFetchCaptiveGoal] Captive {} not found in any cell", + captiveUUID.toString().substring(0, 8) + ); + maid.completeTask(); + return false; + } + + this.targetCell = cell; + this.cellSpawnPoint = cell.getCorePos(); + + // Check for delivery point + BlockPos deliveryPos = cell.getDeliveryPoint(); + if (deliveryPos != null) { + this.navigationTarget = deliveryPos; + this.hasDeliveryPoint = true; + } else { + // No delivery point — navigate toward cell spawn, larger acceptance + this.navigationTarget = cellSpawnPoint; + this.hasDeliveryPoint = false; + } + + // Find captive entity + var entity = serverLevel.getEntity(captiveUUID); + if (!(entity instanceof LivingEntity living) || !living.isAlive()) { + maid.completeTask(); + return false; + } + this.captiveEntity = living; + + return true; + } + + @Override + public boolean canContinueToUse() { + if (maid.getMaidState() != MaidState.FETCHING) { + return false; + } + if (maid.isTiedUp()) { + return false; + } + if ( + navigationTarget == null || + captiveEntity == null || + !captiveEntity.isAlive() + ) { + return false; + } + if (fetchTicks > MAX_FETCH_TICKS) { + TiedUpMod.LOGGER.warn( + "[MaidFetchCaptiveGoal] {} fetch timed out", + maid.getNpcName() + ); + maid.completeTask(); + return false; + } + return true; + } + + @Override + public void start() { + fetchTicks = 0; + stuckTicks = 0; + lastProgressPos = null; + + TiedUpMod.LOGGER.info( + "[MaidFetchCaptiveGoal] {} starting fetch — {} at {}", + maid.getNpcName(), + hasDeliveryPoint ? "delivery point" : "cell approach", + navigationTarget != null + ? navigationTarget.toShortString() + : "unknown" + ); + } + + @Override + public void stop() { + navigationTarget = null; + cellSpawnPoint = null; + targetCell = null; + captiveEntity = null; + fetchTicks = 0; + maid.getNavigation().stop(); + // Only reset to IDLE if still in FETCHING state. + // capturePrisoner() may have already transitioned to DELIVERING. + if (maid.getMaidState() == MaidState.FETCHING) { + maid.setMaidState(MaidState.IDLE); + } + } + + @Override + public void tick() { + if (navigationTarget == null || captiveEntity == null) { + return; + } + + fetchTicks++; + + // Look at navigation target direction + maid + .getLookControl() + .setLookAt( + navigationTarget.getX() + 0.5, + navigationTarget.getY() + 1, + navigationTarget.getZ() + 0.5 + ); + + // Check distance to navigation target + double distToTarget = maid + .position() + .distanceTo( + new net.minecraft.world.phys.Vec3( + navigationTarget.getX() + 0.5, + navigationTarget.getY(), + navigationTarget.getZ() + 0.5 + ) + ); + + double captureDistance = hasDeliveryPoint + ? DELIVERY_CAPTURE_DIST + : NO_DELIVERY_CAPTURE_DIST; + + if (distToTarget <= captureDistance) { + // Close enough — capture (TP prisoner to maid) + capturePrisoner(); + return; + } + + // Navigate toward target — re-path every 20 ticks + if (fetchTicks % 20 == 0) { + maid + .getNavigation() + .moveTo( + navigationTarget.getX() + 0.5, + navigationTarget.getY(), + navigationTarget.getZ() + 0.5, + 1.0 + ); + } + + // Stuck detection: if no progress, capture directly (TP prisoner OUT to maid) + BlockPos currentPos = maid.blockPosition(); + if ( + lastProgressPos == null || + currentPos.distManhattan(lastProgressPos) >= 2 + ) { + lastProgressPos = currentPos; + stuckTicks = 0; + } else { + stuckTicks++; + } + + if (stuckTicks >= STUCK_CAPTURE_TICKS) { + TiedUpMod.LOGGER.info( + "[MaidFetchCaptiveGoal] {} stuck for {}s — capturing directly (TP prisoner to maid)", + maid.getNpcName(), + STUCK_CAPTURE_TICKS / 20 + ); + capturePrisoner(); + } + } + + /** + * Capture the prisoner from the cell. + * Prisoner is teleported TO the maid's position. + * Uses PrisonerService.extractFromCell() for atomic state management. + */ + private void capturePrisoner() { + if (captiveEntity == null || targetCell == null) { + return; + } + if (!(maid.level() instanceof ServerLevel serverLevel)) { + return; + } + + // 1. Extract from cell via PrisonerService (PrisonerManager + CellRegistry + leash) + boolean extracted = + com.tiedup.remake.prison.service.PrisonerService.get().extractFromCell( + serverLevel, + captiveEntity, + (ICaptor) maid, + targetCell + ); + + if (extracted) { + // 2. Teleport prisoner TO maid (never maid to cell) + captiveEntity.teleportTo(maid.getX(), maid.getY(), maid.getZ()); + + TiedUpMod.LOGGER.info( + "[MaidFetchCaptiveGoal] {} fetched {} from cell", + maid.getNpcName(), + captiveEntity.getName().getString() + ); + maid.transitionToDelivering(); + } else { + // Extraction failed — PrisonerService handles its own rollback + TiedUpMod.LOGGER.warn( + "[MaidFetchCaptiveGoal] {} extraction failed for {}", + maid.getNpcName(), + captiveEntity.getName().getString() + ); + maid.completeTask(); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/maid/MaidFollowTraderGoal.java b/src/main/java/com/tiedup/remake/entities/ai/maid/MaidFollowTraderGoal.java new file mode 100644 index 0000000..feb1610 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/maid/MaidFollowTraderGoal.java @@ -0,0 +1,277 @@ +package com.tiedup.remake.entities.ai.maid; + +import com.tiedup.remake.entities.EntityMaid; +import com.tiedup.remake.entities.EntitySlaveTrader; +import java.util.EnumSet; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.ai.goal.Goal; + +/** + * AI Goal for EntityMaid to follow their master trader. + * + * Behavior: + * - Active when state is IDLE (or DEFENDING) + * - Maintains close distance to trader + * - Wanders around trader when he is stationary (instead of letting random stroll goal take over) + * - Uses smooth movement to avoid jittering + */ +public class MaidFollowTraderGoal extends Goal { + + private final EntityMaid maid; + + /** Distance at which to start following */ + private static final double START_FOLLOW_DISTANCE = 10.0; + + /** Distance to maintain from trader (stop following when reached) */ + private static final double STOP_FOLLOW_DISTANCE = 4.0; + + /** Maximum distance to wander from trader when idle */ + private static final double MAX_WANDER_DISTANCE = 7.0; + + /** Minimum distance for wander points */ + private static final double MIN_WANDER_DISTANCE = 3.0; + + /** Ticks between path recalculations when following */ + private int pathRecalcCooldown = 0; + + /** Ticks trader has been stationary */ + private int traderStationaryTicks = 0; + + /** Last known trader position for stationary detection */ + private BlockPos lastTraderPos = null; + + /** Ticks before considering trader stationary (3 seconds) */ + private static final int STATIONARY_THRESHOLD = 60; + + /** Current wander target */ + private BlockPos wanderTarget = null; + + /** Ticks until next wander target (prevents constant recalculation) */ + private int wanderCooldown = 0; + + /** Cooldown after goal stops before it can restart (prevents jitter) */ + private int restartCooldown = 0; + + /** Random for wandering */ + private final java.util.Random random = new java.util.Random(); + + public MaidFollowTraderGoal(EntityMaid maid) { + this.maid = maid; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Restart cooldown to prevent jitter + if (restartCooldown > 0) { + restartCooldown--; + return false; + } + + // Must have a master trader + if (maid.getMasterTraderUUID() == null) { + return false; + } + + // Must be in a state that allows following + if (!maid.getMaidState().shouldFollowTrader()) { + return false; + } + + // Must not be freed + if (maid.isFreed()) { + return false; + } + + // Must not be tied up + if (maid.isTiedUp()) { + return false; + } + + // Trader must exist and be loaded + EntitySlaveTrader trader = maid.getMasterTrader(); + if (trader == null || !trader.isAlive()) { + return false; + } + + // Activate when too far from trader + double distSq = maid.distanceToSqr(trader); + return distSq > START_FOLLOW_DISTANCE * START_FOLLOW_DISTANCE; + } + + @Override + public boolean canContinueToUse() { + if (maid.getMasterTraderUUID() == null) { + return false; + } + + if (!maid.getMaidState().shouldFollowTrader()) { + return false; + } + + if (maid.isFreed() || maid.isTiedUp()) { + return false; + } + + EntitySlaveTrader trader = maid.getMasterTrader(); + if (trader == null || !trader.isAlive()) { + return false; + } + + // Keep running while within reasonable distance + // This prevents the goal from stopping and letting random stroll take over + double distSq = maid.distanceToSqr(trader); + return distSq <= START_FOLLOW_DISTANCE * START_FOLLOW_DISTANCE * 4; // 20 blocks + } + + @Override + public void start() { + pathRecalcCooldown = 0; + wanderTarget = null; + wanderCooldown = 0; + traderStationaryTicks = 0; + } + + @Override + public void stop() { + maid.getNavigation().stop(); + wanderTarget = null; + // Set restart cooldown to prevent immediate re-activation (jitter) + restartCooldown = 40; // 2 seconds + } + + @Override + public void tick() { + EntitySlaveTrader trader = maid.getMasterTrader(); + if (trader == null) { + return; + } + + double distSq = maid.distanceToSqr(trader); + double distance = Math.sqrt(distSq); + + // Check if trader is stationary + BlockPos traderPos = trader.blockPosition(); + if (lastTraderPos != null && lastTraderPos.equals(traderPos)) { + traderStationaryTicks++; + } else { + traderStationaryTicks = 0; + lastTraderPos = traderPos; + // Trader moved - clear wander target + wanderTarget = null; + } + + // Decrement cooldowns + if (pathRecalcCooldown > 0) pathRecalcCooldown--; + if (wanderCooldown > 0) wanderCooldown--; + + // Determine behavior based on distance and trader state + if (distance > START_FOLLOW_DISTANCE) { + // Too far - actively follow + if (pathRecalcCooldown <= 0) { + pathRecalcCooldown = 20; + double speed = distance > 15.0 ? 1.0 : 0.8; + maid.getNavigation().moveTo(trader, speed); + } + // Look at trader while following + maid.getLookControl().setLookAt(trader, 30.0F, 30.0F); + } else if (distance > STOP_FOLLOW_DISTANCE) { + // Medium distance - slow approach + if (pathRecalcCooldown <= 0) { + pathRecalcCooldown = 40; // Slower recalc + maid.getNavigation().moveTo(trader, 0.5); + } + } else if (traderStationaryTicks >= STATIONARY_THRESHOLD) { + // Close to trader and trader is stationary - wander around + tickWander(trader); + } else { + // Close to trader but trader is moving - stop and wait + if (maid.getNavigation().isInProgress()) { + maid.getNavigation().stop(); + } + // Look at trader occasionally + if (random.nextInt(40) == 0) { + maid.getLookControl().setLookAt(trader, 10.0F, 40.0F); + } + } + } + + /** + * Handle wandering behavior when close to stationary trader. + */ + private void tickWander(EntitySlaveTrader trader) { + // Check if we need a new wander target + if ( + wanderTarget == null || wanderCooldown <= 0 || reachedWanderTarget() + ) { + selectNewWanderTarget(trader); + wanderCooldown = 100 + random.nextInt(100); // 5-10 seconds + } + + // Move towards wander target + if (wanderTarget != null && !maid.getNavigation().isInProgress()) { + maid + .getNavigation() + .moveTo( + wanderTarget.getX() + 0.5, + wanderTarget.getY(), + wanderTarget.getZ() + 0.5, + 0.5 // Slow wander speed + ); + } + + // Look around occasionally (not always at trader) + if (random.nextInt(80) == 0) { + if (random.nextBoolean()) { + maid.getLookControl().setLookAt(trader, 10.0F, 40.0F); + } else { + // Look in random direction + double angle = random.nextDouble() * Math.PI * 2; + maid + .getLookControl() + .setLookAt( + maid.getX() + Math.cos(angle) * 5, + maid.getEyeY(), + maid.getZ() + Math.sin(angle) * 5, + 10.0F, + 40.0F + ); + } + } + } + + /** + * Check if maid reached current wander target. + */ + private boolean reachedWanderTarget() { + if (wanderTarget == null) return true; + double distSq = maid.distanceToSqr( + wanderTarget.getX() + 0.5, + wanderTarget.getY(), + wanderTarget.getZ() + 0.5 + ); + return distSq < 4.0; // Within 2 blocks + } + + /** + * Select a new wander target around the trader. + */ + private void selectNewWanderTarget(EntitySlaveTrader trader) { + // Pick a random point within wander range of trader + double angle = random.nextDouble() * 2 * Math.PI; + double radius = + MIN_WANDER_DISTANCE + + random.nextDouble() * (MAX_WANDER_DISTANCE - MIN_WANDER_DISTANCE); + + int targetX = (int) (trader.getX() + Math.cos(angle) * radius); + int targetZ = (int) (trader.getZ() + Math.sin(angle) * radius); + int targetY = (int) trader.getY(); + + wanderTarget = new BlockPos(targetX, targetY, targetZ); + } + + @Override + public boolean requiresUpdateEveryTick() { + return false; // Don't need every tick, default interval is fine + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/maid/MaidState.java b/src/main/java/com/tiedup/remake/entities/ai/maid/MaidState.java new file mode 100644 index 0000000..29e22c3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/maid/MaidState.java @@ -0,0 +1,88 @@ +package com.tiedup.remake.entities.ai.maid; + +/** + * Enum representing the behavioral states of an EntityMaid. + * + * States control what the maid is currently doing and determine + * which AI goals are active. + */ +public enum MaidState { + /** + * Default state - following trader, ready for orders. + */ + IDLE, + + /** + * Delivering a captive to a buyer. + */ + DELIVERING, + + /** + * Collecting ransom or items from a job. + */ + COLLECTING, + + /** + * Fetching a captive from a cell. + */ + FETCHING, + + /** + * Defending the trader from attack. + */ + DEFENDING, + + /** + * Patrolling cells proactively to check for escapes and wall damage. + */ + PATROL, + + /** + * Extracting a prisoner from their cell for labor. + */ + EXTRACTING_PRISONER, + + /** + * Returning a prisoner to their cell after labor completion. + */ + RETURNING_PRISONER, + + /** + * Freed state - trader is dead, maid is neutral. + */ + FREE; + + /** + * Check if the maid can receive new orders in this state. + */ + public boolean canReceiveOrders() { + return this == IDLE; + } + + /** + * Check if the maid is actively working on a task. + */ + public boolean isBusy() { + return ( + this == DELIVERING || + this == COLLECTING || + this == FETCHING || + this == EXTRACTING_PRISONER || + this == RETURNING_PRISONER + ); + } + + /** + * Check if the maid should follow the trader. + */ + public boolean shouldFollowTrader() { + return this == IDLE || this == DEFENDING; + } + + /** + * Check if the maid is in freed state (trader dead). + */ + public boolean isFreed() { + return this == FREE; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/maid/goals/MaidAssignTaskGoal.java b/src/main/java/com/tiedup/remake/entities/ai/maid/goals/MaidAssignTaskGoal.java new file mode 100644 index 0000000..160abd0 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/maid/goals/MaidAssignTaskGoal.java @@ -0,0 +1,321 @@ +package com.tiedup.remake.entities.ai.maid.goals; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityMaid; +import com.tiedup.remake.labor.LaborTask; +import com.tiedup.remake.labor.LaborTaskGenerator; +import com.tiedup.remake.prison.LaborRecord; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerRecord; +import com.tiedup.remake.prison.PrisonerState; +import com.tiedup.remake.prison.RansomRecord; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.EnumSet; +import java.util.Set; +import java.util.UUID; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.goal.Goal; + +/** + * Goal: Assign labor tasks to idle prisoners. + * + * Responsibility: ONE thing - assign tasks to IMPRISONED prisoners in IDLE phase. + * + * Checks for prisoners who are idle (no task, done resting) and assigns appropriate tasks. + */ +public class MaidAssignTaskGoal extends Goal { + + private static final int CHECK_INTERVAL_TICKS = 100; // 5 seconds + private static final long GRACE_PERIOD_TICKS = 6000; // 5 minutes protection after release + + private final EntityMaid maid; + private int tickCounter = 0; + + public MaidAssignTaskGoal(EntityMaid maid) { + this.maid = maid; + this.setFlags(EnumSet.noneOf(Flag.class)); // No flags - runs in background + } + + @Override + public boolean canUse() { + if (maid.isFreed() || maid.isTiedUp()) { + return false; + } + if (maid.getMasterTraderUUID() == null) { + return false; + } + return true; + } + + @Override + public void tick() { + tickCounter++; + if (tickCounter < CHECK_INTERVAL_TICKS) { + return; + } + tickCounter = 0; + + if (!(maid.level() instanceof ServerLevel level)) { + return; + } + + UUID campId = maid.getCampUUID(); + if (campId == null) { + return; + } + + PrisonerManager manager = PrisonerManager.get(level); + long currentTime = level.getGameTime(); + + // Get all imprisoned prisoners in this camp + Set prisoners = manager.getPrisonersInCampWithState( + campId, + PrisonerState.IMPRISONED + ); + + for (UUID prisonerId : prisoners) { + LaborRecord labor = manager.getLaborRecord(prisonerId); + + // Check if in IDLE or RESTING phase + LaborRecord.WorkPhase phase = labor.getPhase(); + + if (phase == LaborRecord.WorkPhase.RESTING) { + // Check if rest is complete (config: laborRestSeconds, default 120s) + long restTicks = ModConfig.SERVER.laborRestSeconds.get() * 20L; + long elapsed = labor.getTimeInPhase(currentTime); + if (elapsed >= restTicks) { + labor.finishRest(currentTime); + phase = LaborRecord.WorkPhase.IDLE; + TiedUpMod.LOGGER.info( + "[MaidAssignTaskGoal] {} RESTING→IDLE (rested {}s/{}s)", + prisonerId.toString().substring(0, 8), + elapsed / 20, + restTicks / 20 + ); + } + } + + if (phase != LaborRecord.WorkPhase.IDLE) { + continue; // Not ready for task + } + + // Check if they still have debt — auto-create ransom if missing + RansomRecord ransom = manager.getRansomRecord(prisonerId); + if (ransom == null) { + // MaidInitPrisonerGoal failed to create ransom (trader was unloaded?) — create fallback + int fallbackAmount = LaborTaskGenerator.calculateRansomAmount( + false, + false, + false + ); + manager.createRansom(prisonerId, fallbackAmount, currentTime); + ransom = manager.getRansomRecord(prisonerId); + TiedUpMod.LOGGER.warn( + "[MaidAssignTaskGoal] Created fallback ransom ({}) for {} — MaidInitPrisonerGoal missed", + fallbackAmount, + prisonerId.toString().substring(0, 8) + ); + } + if (ransom.isPaid()) { + // Ransom paid while prisoner is in cell — release them now + ServerPlayer paidPrisoner = level + .getServer() + .getPlayerList() + .getPlayer(prisonerId); + if (paidPrisoner != null) { + releasePaidPrisoner( + level, + paidPrisoner, + manager, + currentTime + ); + } else { + TiedUpMod.LOGGER.info( + "[MaidAssignTaskGoal] {} ransom isPaid (paid={}/debt={}) but offline — will release on login", + prisonerId.toString().substring(0, 8), + ransom.getAmountPaid(), + ransom.getTotalDebt() + ); + } + continue; + } + + // Assign a task + ServerPlayer prisoner = level + .getServer() + .getPlayerList() + .getPlayer(prisonerId); + if (prisoner == null) { + TiedUpMod.LOGGER.debug( + "[MaidAssignTaskGoal] {} is IDLE but offline — skipping", + prisonerId.toString().substring(0, 8) + ); + continue; // Offline + } + + TiedUpMod.LOGGER.info( + "[MaidAssignTaskGoal] {} ready for assignment (phase={}, ransom={}/{}, online=true)", + prisonerId.toString().substring(0, 8), + phase, + ransom.getAmountPaid(), + ransom.getTotalDebt() + ); + assignTask(manager, prisoner, campId, currentTime); + } + } + + /** + * Assign a labor task to a prisoner. + */ + private void assignTask( + PrisonerManager manager, + ServerPlayer prisoner, + UUID campId, + long currentTime + ) { + UUID prisonerId = prisoner.getUUID(); + + // Create a random task + LaborTask task = createRandomTask(campId); + + // Assign via manager + if ( + !manager.assignTask(prisonerId, task, maid.getUUID(), currentTime) + ) { + LaborRecord failLabor = manager.getLaborRecord(prisonerId); + TiedUpMod.LOGGER.warn( + "[MaidAssignTaskGoal] manager.assignTask FAILED for {} (phase={}, canAssign={})", + prisonerId.toString().substring(0, 8), + failLabor.getPhase(), + failLabor.canAssignTask() + ); + return; // Failed to assign + } + + // Notify prisoner + prisoner.sendSystemMessage( + net.minecraft.network.chat.Component.literal( + "Task assigned: " + task.getDescription() + ).withStyle(net.minecraft.ChatFormatting.YELLOW) + ); + prisoner.sendSystemMessage( + net.minecraft.network.chat.Component.literal( + "Reward: " + task.getValue() + " emeralds toward your debt." + ).withStyle(net.minecraft.ChatFormatting.GRAY) + ); + + TiedUpMod.LOGGER.info( + "[MaidAssignTaskGoal] Assigned task '{}' to {}", + task.getDescription(), + prisoner.getName().getString() + ); + } + + /** + * Release a prisoner whose ransom is paid while they're idle in their cell. + * Same logic as MaidReturnGoal.freePrisoner() but triggered from the background check. + */ + private void releasePaidPrisoner( + ServerLevel level, + ServerPlayer prisoner, + PrisonerManager manager, + long currentTime + ) { + UUID prisonerId = prisoner.getUUID(); + + // 1. Despawn guard if still alive + LaborRecord labor = manager.getLaborRecord(prisonerId); + UUID guardId = labor.getGuardId(); + if (guardId != null) { + net.minecraft.world.entity.Entity guardEntity = level.getEntity( + guardId + ); + if (guardEntity != null) { + guardEntity.discard(); + } + labor.setGuardId(null); + } + + // 2. Get cell ID BEFORE releasing (manager.release() clears cellId from the record) + PrisonerRecord record = manager.getRecord(prisonerId); + UUID cellId = record.getCellId(); + + // 3. Teleport prisoner out of cell to maid's position + net.minecraft.core.BlockPos maidPos = maid.blockPosition(); + prisoner.teleportTo( + maidPos.getX() + 0.5, + maidPos.getY(), + maidPos.getZ() + 0.5 + ); + + // 4. Release with grace period + manager.release(prisonerId, currentTime, GRACE_PERIOD_TICKS); + + // 5. Remove from cell registry + if (cellId != null) { + CellRegistryV2.get(level).releasePrisoner( + cellId, + prisonerId, + level.getServer() + ); + } + + // 6. Clear collar registry + com.tiedup.remake.state.CollarRegistry.get(level).unregisterWearer( + prisonerId + ); + + // 7. Free kidnapped state and remove all restraints + IBondageState cap = KidnappedHelper.getKidnappedState(prisoner); + if (cap != null) { + net.minecraft.world.item.ItemStack collar = cap.getEquipment(BodyRegionV2.NECK); + if (!collar.isEmpty()) { + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.unequipFromRegion( + prisoner, + com.tiedup.remake.v2.BodyRegionV2.NECK, + true + ); + } + cap.untie(true); + cap.free(false); + } + + prisoner.sendSystemMessage( + net.minecraft.network.chat.Component.literal( + "Your debt is paid. You are FREE!" + ).withStyle( + net.minecraft.ChatFormatting.GREEN, + net.minecraft.ChatFormatting.BOLD + ) + ); + + TiedUpMod.LOGGER.info( + "[MaidAssignTaskGoal] Released {} — ransom paid (debt satisfied while in cell)", + prisoner.getName().getString() + ); + } + + /** + * Create a random labor task using the centralized task generator. + */ + private LaborTask createRandomTask(UUID campId) { + ServerLevel level = (maid.level() instanceof ServerLevel sl) + ? sl + : null; + LaborTask task = + com.tiedup.remake.labor.LaborTaskGenerator.generateRandom(level); + task.setCampId(campId); + return task; + } + + @Override + public boolean canContinueToUse() { + return canUse(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/maid/goals/MaidExtractGoal.java b/src/main/java/com/tiedup/remake/entities/ai/maid/goals/MaidExtractGoal.java new file mode 100644 index 0000000..43cd3ec --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/maid/goals/MaidExtractGoal.java @@ -0,0 +1,482 @@ +package com.tiedup.remake.entities.ai.maid.goals; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityMaid; +import com.tiedup.remake.labor.LaborTask; +import com.tiedup.remake.prison.LaborRecord; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerRecord; +import com.tiedup.remake.prison.PrisonerState; +import com.tiedup.remake.prison.service.BondageService; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.util.KidnapperAIHelper; +import java.util.EnumSet; +import java.util.Set; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.goal.Goal; + +/** + * Goal: Extract prisoners from cells for labor. + * + * Responsibility: ONE thing - extract IMPRISONED prisoners with PENDING_EXTRACTION phase. + * + * Simple flow: + * 1. Find prisoner in PENDING_EXTRACTION + * 2. Navigate to their cell + * 3. Extract them (save bondage, remove restraints, give tools) + * 4. Transition to WORKING state + */ +public class MaidExtractGoal extends Goal { + + private static final double CELL_REACH_DISTANCE = 4.0; + private static final int MAX_DURATION_TICKS = 600; // 30 seconds timeout + private static final int STUCK_TELEPORT_TICKS = 200; // 10 seconds without progress → teleport + + private final EntityMaid maid; + + // Current extraction state + private UUID targetPrisonerId; + private BlockPos targetCellPos; + private int ticksActive; + private boolean navigationStarted; + private int stuckTicks; + private BlockPos lastProgressPos; + + public MaidExtractGoal(EntityMaid maid) { + this.maid = maid; + this.setFlags(EnumSet.of(Flag.MOVE)); // Uses movement + } + + /** Tick counter for periodic diagnostic logging */ + private int diagCounter = 0; + + @Override + public boolean canUse() { + // Periodic diagnostic: log which check blocks every 10 seconds + boolean diag = (++diagCounter % 200 == 0); + + if (maid.isFreed() || maid.isTiedUp()) { + if (diag) TiedUpMod.LOGGER.warn( + "[MaidExtractGoal] BLOCKED: freed={} tiedUp={}", + maid.isFreed(), + maid.isTiedUp() + ); + return false; + } + if (maid.getMasterTraderUUID() == null) { + if (diag) TiedUpMod.LOGGER.warn( + "[MaidExtractGoal] BLOCKED: getMasterTraderUUID()=null" + ); + return false; + } + if (maid.getMaidState().isBusy()) { + if (diag) TiedUpMod.LOGGER.warn( + "[MaidExtractGoal] BLOCKED: maidState={} (isBusy=true)", + maid.getMaidState() + ); + return false; + } + if (!(maid.level() instanceof ServerLevel level)) { + return false; + } + + UUID campId = maid.getCampUUID(); + if (campId == null) { + return false; + } + + // Find a prisoner needing extraction + PrisonerManager manager = PrisonerManager.get(level); + Set prisoners = manager.getPrisonersInCampWithState( + campId, + PrisonerState.IMPRISONED + ); + + for (UUID prisonerId : prisoners) { + LaborRecord labor = manager.getLaborRecord(prisonerId); + if (labor.getPhase() == LaborRecord.WorkPhase.PENDING_EXTRACTION) { + PrisonerRecord record = manager.getRecord(prisonerId); + UUID cellId = record.getCellId(); + if (cellId != null) { + CellDataV2 cell = CellRegistryV2.get(level).getCell(cellId); + if (cell != null) { + this.targetPrisonerId = prisonerId; + this.targetCellPos = + KidnapperAIHelper.getDeliveryOrSpawnPoint( + cell, + level + ); + return true; + } + } + } + } + + // Diagnostic: log why no prisoner found (only for players, NPCs don't get labor tasks) + if (diag) { + Set allPrisoners = manager.getPrisonersInCamp(campId); + boolean hasPlayers = false; + for (UUID pid : allPrisoners) { + // Only log players — NPCs stay in IDLE forever (no labor) + if (level.getServer().getPlayerList().getPlayer(pid) != null) { + hasPlayers = true; + PrisonerRecord rec = manager.getRecord(pid); + LaborRecord lab = manager.getLaborRecord(pid); + TiedUpMod.LOGGER.warn( + "[MaidExtractGoal] BLOCKED: Player {} state={}, phase={} (need IMPRISONED+PENDING_EXTRACTION)", + pid.toString().substring(0, 8), + rec.getState(), + lab.getPhase() + ); + } + } + if (!hasPlayers && allPrisoners.isEmpty()) { + TiedUpMod.LOGGER.warn( + "[MaidExtractGoal] BLOCKED: No prisoners found in camp {}", + campId.toString().substring(0, 8) + ); + } + } + + return false; + } + + @Override + public void start() { + ticksActive = 0; + navigationStarted = false; + stuckTicks = 0; + lastProgressPos = null; + + maid.setMaidState( + com.tiedup.remake.entities.ai.maid.MaidState.EXTRACTING_PRISONER + ); + + TiedUpMod.LOGGER.debug( + "[MaidExtractGoal] {} starting extraction of {}", + maid.getNpcName(), + targetPrisonerId != null + ? targetPrisonerId.toString().substring(0, 8) + : "null" + ); + } + + @Override + public void tick() { + ticksActive++; + + if (!(maid.level() instanceof ServerLevel level)) { + return; + } + + // Timeout check + if (ticksActive > MAX_DURATION_TICKS) { + TiedUpMod.LOGGER.warn("[MaidExtractGoal] Extraction timed out"); + stop(); + return; + } + + if (targetCellPos == null) return; + + // Check if we've arrived + double distance = maid + .position() + .distanceTo( + new net.minecraft.world.phys.Vec3( + targetCellPos.getX() + 0.5, + targetCellPos.getY(), + targetCellPos.getZ() + 0.5 + ) + ); + + if (distance <= CELL_REACH_DISTANCE) { + // At cell - perform extraction + performExtraction(level); + stop(); + return; + } + + // Navigate to cell - re-path every 20 ticks + if (!navigationStarted || ticksActive % 20 == 0) { + maid + .getNavigation() + .moveTo( + targetCellPos.getX() + 0.5, + targetCellPos.getY(), + targetCellPos.getZ() + 0.5, + 1.0 + ); + navigationStarted = true; + } + + // Stuck detection: teleport to cell if no progress + BlockPos currentPos = maid.blockPosition(); + if ( + lastProgressPos == null || + currentPos.distManhattan(lastProgressPos) >= 2 + ) { + lastProgressPos = currentPos; + stuckTicks = 0; + } else { + stuckTicks++; + } + if (stuckTicks >= STUCK_TELEPORT_TICKS) { + TiedUpMod.LOGGER.info( + "[MaidExtractGoal] {} stuck for {}s, teleporting to cell", + maid.getNpcName(), + STUCK_TELEPORT_TICKS / 20 + ); + maid.teleportTo( + targetCellPos.getX() + 0.5, + targetCellPos.getY() + 1, + targetCellPos.getZ() + 0.5 + ); + stuckTicks = 0; + lastProgressPos = null; + } + } + + /** + * Perform the extraction sequence. + */ + private void performExtraction(ServerLevel level) { + if (targetPrisonerId == null) { + return; + } + + ServerPlayer prisoner = level + .getServer() + .getPlayerList() + .getPlayer(targetPrisonerId); + if (prisoner == null) { + TiedUpMod.LOGGER.warn( + "[MaidExtractGoal] Prisoner offline during extraction" + ); + return; + } + + PrisonerManager manager = PrisonerManager.get(level); + LaborRecord labor = manager.getLaborRecord(targetPrisonerId); + LaborTask task = labor.getTask(); + + if (task == null) { + TiedUpMod.LOGGER.warn( + "[MaidExtractGoal] No task assigned during extraction" + ); + return; + } + + IBondageState cap = KidnappedHelper.getKidnappedState(prisoner); + if (cap == null) { + return; + } + + long currentTime = level.getGameTime(); + + // 1. Save bondage state + CompoundTag bondageSnapshot = BondageService.get().saveSnapshot(cap); + labor.setBondageSnapshot(bondageSnapshot); + + // 2. Remove restraints for labor + BondageService.get().removeForLabor(cap); + + // 3. Give equipment + task.giveEquipment(prisoner); + + // 3.5. Teleport prisoner out of cell to maid's position + BlockPos maidPos = maid.blockPosition(); + com.tiedup.remake.util.teleport.Position teleportTarget = + new com.tiedup.remake.util.teleport.Position( + maidPos.above(), + level.dimension() + ); + com.tiedup.remake.util.teleport.TeleportHelper.teleportEntity( + prisoner, + teleportTarget + ); + + TiedUpMod.LOGGER.debug( + "[MaidExtractGoal] Teleported {} to maid at {}", + prisoner.getName().getString(), + maidPos.toShortString() + ); + + // 4. Transition prisoner state: IMPRISONED → WORKING (do this BEFORE updating labor record) + // If this fails (e.g., state changed between ticks), abort before modifying labor record + UUID cellId = null; + PrisonerRecord record = manager.getRecord(targetPrisonerId); + if (record != null) { + cellId = record.getCellId(); + } + if (!manager.extract(targetPrisonerId, currentTime)) { + TiedUpMod.LOGGER.warn( + "[MaidExtractGoal] manager.extract() FAILED for {} — aborting extraction", + targetPrisonerId.toString().substring(0, 8) + ); + return; + } + + // 5. Update labor record (only after successful state transition) + labor.startWorking(currentTime); + labor.updateActivity( + currentTime, + prisoner.blockPosition().getX(), + prisoner.blockPosition().getY(), + prisoner.blockPosition().getZ() + ); + if (cellId != null) { + CellRegistryV2.get(level).releasePrisoner( + cellId, + targetPrisonerId, + level.getServer() + ); + } + + // 6. Spawn guard if no guard already assigned + if (labor.getGuardId() == null) { + spawnGuard(level, prisoner, labor, currentTime); + } + + // 7. Notify prisoner + prisoner.sendSystemMessage( + net.minecraft.network.chat.Component.literal( + "You have been extracted for labor. Complete your task: " + + task.getDescription() + ).withStyle(net.minecraft.ChatFormatting.YELLOW) + ); + + TiedUpMod.LOGGER.info( + "[MaidExtractGoal] Extracted {} for task '{}'", + prisoner.getName().getString(), + task.getDescription() + ); + } + + /** + * Spawn a guard entity near the prisoner at a safe position. + */ + private void spawnGuard( + ServerLevel level, + ServerPlayer prisoner, + LaborRecord labor, + long currentTime + ) { + com.tiedup.remake.entities.EntityLaborGuard guard = + new com.tiedup.remake.entities.EntityLaborGuard( + com.tiedup.remake.entities.ModEntities.LABOR_GUARD.get(), + level + ); + + // Spawn guard near the maid (not the prisoner, who may still be in the cell) + BlockPos maidPos = maid.blockPosition(); + BlockPos safePos = findSafeSpawnPos(level, maidPos); + + guard.setPos( + safePos.getX() + 0.5, + safePos.getY(), + safePos.getZ() + 0.5 + ); + + // Configure guard + guard.setPrisonerUUID(prisoner.getUUID()); + guard.setCampId(maid.getCampUUID()); + guard.setSpawnerMaidId(maid.getUUID()); + + // Add to world + level.addFreshEntity(guard); + + // Store guard ID in labor record + labor.setGuardId(guard.getUUID()); + + TiedUpMod.LOGGER.info( + "[MaidExtractGoal] Spawned guard {} for prisoner {}", + guard.getUUID().toString().substring(0, 8), + prisoner.getName().getString() + ); + } + + /** + * Find a safe position to spawn the guard near the given position. + * Checks that the position has 2 blocks of air and solid ground below. + */ + private BlockPos findSafeSpawnPos(ServerLevel level, BlockPos center) { + // First try: maid's exact position (always walkable since she's standing there) + if (isSafeForSpawn(level, center)) { + return center; + } + + // Try nearby offsets: cardinal directions first, then diagonals + int[][] offsets = { + { 1, 0 }, + { -1, 0 }, + { 0, 1 }, + { 0, -1 }, + { 2, 0 }, + { -2, 0 }, + { 0, 2 }, + { 0, -2 }, + }; + + for (int[] offset : offsets) { + for (int dy = 0; dy <= 2; dy++) { + BlockPos candidate = center.offset(offset[0], dy, offset[1]); + if (isSafeForSpawn(level, candidate)) { + return candidate; + } + } + } + + // Fallback: maid's exact position (two entities can share a block) + return center; + } + + /** + * Check if a position is safe for entity spawn: solid below, 2 air blocks at feet and head. + */ + private boolean isSafeForSpawn(ServerLevel level, BlockPos pos) { + net.minecraft.world.level.block.state.BlockState below = + level.getBlockState(pos.below()); + net.minecraft.world.level.block.state.BlockState atFeet = + level.getBlockState(pos); + net.minecraft.world.level.block.state.BlockState atHead = + level.getBlockState(pos.above()); + + return ( + below.isSolid() && + !atFeet.isSolid() && + !atFeet.liquid() && + !atHead.isSolid() && + !atHead.liquid() + ); + } + + @Override + public boolean canContinueToUse() { + if (targetPrisonerId == null) { + return false; + } + if (maid.isFreed() || maid.isTiedUp()) { + return false; + } + return ticksActive < MAX_DURATION_TICKS; + } + + @Override + public void stop() { + maid.getNavigation().stop(); + maid.setMaidState(com.tiedup.remake.entities.ai.maid.MaidState.IDLE); + + targetPrisonerId = null; + targetCellPos = null; + navigationStarted = false; + + TiedUpMod.LOGGER.debug("[MaidExtractGoal] Stopped"); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/maid/goals/MaidIdleGoal.java b/src/main/java/com/tiedup/remake/entities/ai/maid/goals/MaidIdleGoal.java new file mode 100644 index 0000000..d85d8ab --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/maid/goals/MaidIdleGoal.java @@ -0,0 +1,332 @@ +package com.tiedup.remake.entities.ai.maid.goals; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityMaid; +import com.tiedup.remake.entities.EntitySlaveTrader; +import com.tiedup.remake.labor.LaborTask; +import com.tiedup.remake.prison.LaborRecord; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerState; +import java.util.*; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.goal.Goal; + +/** + * Goal: Idle behavior and work monitoring. + * + * Responsibilities: + * - Monitor working prisoners for task completion + * - Check for task timeouts and failures + * - Patrol cells when idle + * - Stay near trader when nothing to do + */ +public class MaidIdleGoal extends Goal { + + private static final int MONITOR_INTERVAL_TICKS = 100; // 5 seconds + private static final int PATROL_INTERVAL_TICKS = 1200; // 1 minute + private static final double TRADER_FOLLOW_DISTANCE = 10.0; + + private final EntityMaid maid; + private final Random random = new Random(); + + private int monitorCounter = 0; + private int patrolCounter = 0; + private BlockPos patrolTarget; + private boolean isPatrolling; + + public MaidIdleGoal(EntityMaid maid) { + this.maid = maid; + this.setFlags(EnumSet.of(Flag.MOVE)); + } + + @Override + public boolean canUse() { + if (maid.isFreed() || maid.isTiedUp()) { + return false; + } + if (maid.getMaidState().isBusy()) { + return false; + } + return true; + } + + @Override + public void tick() { + monitorCounter++; + patrolCounter++; + + if (!(maid.level() instanceof ServerLevel level)) { + return; + } + + // Monitor working prisoners + if (monitorCounter >= MONITOR_INTERVAL_TICKS) { + monitorCounter = 0; + monitorWorkers(level); + } + + // Patrol or follow trader + if (isPatrolling) { + tickPatrol(level); + } else if (patrolCounter >= PATROL_INTERVAL_TICKS) { + patrolCounter = 0; + maybeStartPatrol(level); + } else { + followTrader(); + } + } + + /** + * Monitor working prisoners for task completion. + */ + private void monitorWorkers(ServerLevel level) { + UUID campId = maid.getCampUUID(); + if (campId == null) { + return; + } + + PrisonerManager manager = PrisonerManager.get(level); + long currentTime = level.getGameTime(); + + Set workers = manager.getPrisonersInCampWithState( + campId, + PrisonerState.WORKING + ); + + for (UUID prisonerId : workers) { + LaborRecord labor = manager.getLaborRecord(prisonerId); + + // Only monitor WORKING phase + if (labor.getPhase() != LaborRecord.WorkPhase.WORKING) { + continue; + } + + // Skip prisoners with an active guard (guard handles monitoring) + // BUT verify the guard entity actually exists - if it disappeared + // without cleanup (crash, chunk corruption), clear the stale reference + if (labor.getGuardId() != null) { + net.minecraft.world.entity.Entity guardEntity = level.getEntity( + labor.getGuardId() + ); + if (guardEntity != null && guardEntity.isAlive()) { + continue; // Guard is alive, it handles monitoring + } + // Guard is gone - clear stale reference so maid takes over + TiedUpMod.LOGGER.warn( + "[MaidIdleGoal] Guard {} for prisoner {} is missing/dead - clearing stale reference", + labor.getGuardId().toString().substring(0, 8), + prisonerId.toString().substring(0, 8) + ); + labor.setGuardId(null); + manager.markDirty(); + } + + ServerPlayer prisoner = level + .getServer() + .getPlayerList() + .getPlayer(prisonerId); + if (prisoner == null) { + continue; + } + + LaborTask task = labor.getTask(); + if (task == null) { + continue; + } + + // Check task progress + task.checkProgress(prisoner, level); + + if (task.isComplete()) { + // Task complete - trigger return + labor.completeTask(currentTime); + + prisoner.sendSystemMessage( + net.minecraft.network.chat.Component.literal( + "Task complete! Walk back to camp." + ).withStyle(net.minecraft.ChatFormatting.GREEN) + ); + + TiedUpMod.LOGGER.info( + "[MaidIdleGoal] {} completed task", + prisoner.getName().getString() + ); + } else { + // Check for inactivity + checkActivity(prisoner, labor, currentTime); + } + } + } + + /** + * Check prisoner activity and punish inactivity. + */ + private void checkActivity( + ServerPlayer prisoner, + LaborRecord labor, + long currentTime + ) { + BlockPos currentPos = prisoner.blockPosition(); + boolean hasMoved = labor.hasMovedFrom( + currentPos.getX(), + currentPos.getY(), + currentPos.getZ(), + 2 // 2 block threshold + ); + + if (hasMoved) { + // Active - update position and reset inactivity + labor.updateActivity( + currentTime, + currentPos.getX(), + currentPos.getY(), + currentPos.getZ() + ); + labor.resetShockLevel(); + } else { + // Inactive - check time + long inactiveTime = currentTime - labor.getLastActivityTime(); + long inactiveThreshold = 600; // 30 seconds + + if (inactiveTime > inactiveThreshold) { + // Punish inactivity + labor.incrementShockLevel(); + labor.updateActivity( + currentTime, + currentPos.getX(), + currentPos.getY(), + currentPos.getZ() + ); + + int shockLevel = labor.getShockLevel(); + if (shockLevel >= 3) { + // Max punishment - fail task + labor.failTask(currentTime); + prisoner.sendSystemMessage( + net.minecraft.network.chat.Component.literal( + "Task failed due to inactivity! You will be returned to your cell." + ).withStyle(net.minecraft.ChatFormatting.RED) + ); + } else { + // Warning + prisoner.sendSystemMessage( + net.minecraft.network.chat.Component.literal( + "Warning: Work or face punishment! (" + + shockLevel + + "/3)" + ).withStyle(net.minecraft.ChatFormatting.YELLOW) + ); + + // Shock the prisoner + var cap = + com.tiedup.remake.util.KidnappedHelper.getKidnappedState( + prisoner + ); + if (cap != null) { + cap.shockKidnapped("Get back to work!", shockLevel); + } + } + } + } + } + + /** + * Maybe start a patrol. + */ + private void maybeStartPatrol(ServerLevel level) { + // 20% chance to patrol + if (random.nextFloat() > 0.2f) { + return; + } + + UUID campId = maid.getCampUUID(); + if (campId == null) { + return; + } + + // Get camp cells + CellRegistryV2 registry = CellRegistryV2.get(level); + List cells = registry.getCellsByCamp(campId); + + if (cells.isEmpty()) { + return; + } + + // Pick a random cell to check + CellDataV2 targetCell = cells.get(random.nextInt(cells.size())); + patrolTarget = targetCell.getCorePos(); + isPatrolling = true; + + TiedUpMod.LOGGER.debug("[MaidIdleGoal] Starting patrol to cell"); + } + + /** + * Tick patrol behavior. + */ + private void tickPatrol(ServerLevel level) { + if (patrolTarget == null) { + isPatrolling = false; + return; + } + + // Navigate to target + if (maid.getNavigation().isDone()) { + maid + .getNavigation() + .moveTo( + patrolTarget.getX() + 0.5, + patrolTarget.getY(), + patrolTarget.getZ() + 0.5, + 0.8 + ); + } + + // Check if arrived + double distance = maid + .position() + .distanceTo( + new net.minecraft.world.phys.Vec3( + patrolTarget.getX() + 0.5, + patrolTarget.getY(), + patrolTarget.getZ() + 0.5 + ) + ); + + if (distance < 3.0) { + // Arrived - done patrolling + isPatrolling = false; + patrolTarget = null; + TiedUpMod.LOGGER.debug("[MaidIdleGoal] Patrol complete"); + } + } + + /** + * Follow the trader when idle. + */ + private void followTrader() { + EntitySlaveTrader trader = maid.getMasterTrader(); + if (trader == null) { + return; + } + + double distance = maid.distanceTo(trader); + if (distance > TRADER_FOLLOW_DISTANCE) { + maid.getNavigation().moveTo(trader, 0.6); + } + } + + @Override + public boolean canContinueToUse() { + return canUse(); + } + + @Override + public void stop() { + isPatrolling = false; + patrolTarget = null; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/maid/goals/MaidInitPrisonerGoal.java b/src/main/java/com/tiedup/remake/entities/ai/maid/goals/MaidInitPrisonerGoal.java new file mode 100644 index 0000000..d44fb1c --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/maid/goals/MaidInitPrisonerGoal.java @@ -0,0 +1,268 @@ +package com.tiedup.remake.entities.ai.maid.goals; + +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityMaid; +import com.tiedup.remake.labor.LaborTaskGenerator; +import com.tiedup.remake.prison.LaborRecord; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerRecord; +import com.tiedup.remake.prison.PrisonerState; +import com.tiedup.remake.prison.service.ItemService; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +/** + * Goal: Detect new prisoners and initialize their labor records. + * + * Responsibility: ONE thing - detect new IMPRISONED prisoners and create their ransom. + * + * Runs periodically, checks for prisoners in camp cells who don't have ransoms yet, + * creates ransom records, and confiscates valuables. + */ +public class MaidInitPrisonerGoal extends Goal { + + private static final int CHECK_INTERVAL_TICKS = 100; // 5 seconds + // Ransom is now calculated dynamically via LaborTaskGenerator.calculateRansomAmount() + + private final EntityMaid maid; + private int tickCounter = 0; + + public MaidInitPrisonerGoal(EntityMaid maid) { + this.maid = maid; + this.setFlags(EnumSet.noneOf(Flag.class)); // No flags - runs in background + } + + @Override + public boolean canUse() { + if (maid.isFreed() || maid.isTiedUp()) { + return false; + } + if (maid.getMasterTraderUUID() == null) { + return false; + } + return true; + } + + @Override + public void tick() { + tickCounter++; + if (tickCounter < CHECK_INTERVAL_TICKS) { + return; + } + tickCounter = 0; + + if (!(maid.level() instanceof ServerLevel level)) { + return; + } + + UUID campId = maid.getCampUUID(); + if (campId == null) { + return; + } + + PrisonerManager manager = PrisonerManager.get(level); + CellRegistryV2 cells = CellRegistryV2.get(level); + + // Get all prisoners in this camp + Set campPrisoners = manager.getPrisonersInCamp(campId); + + for (UUID prisonerId : campPrisoners) { + PrisonerRecord record = manager.getRecord(prisonerId); + + // Only process IMPRISONED prisoners + if (record.getState() != PrisonerState.IMPRISONED) { + continue; + } + + // Check if they already have a ransom + if (manager.getRansomRecord(prisonerId) != null) { + continue; + } + + // New prisoner - initialize! + ServerPlayer prisoner = level + .getServer() + .getPlayerList() + .getPlayer(prisonerId); + if (prisoner == null) { + continue; // Offline + } + + initializePrisoner(level, manager, prisoner, campId); + } + } + + /** + * Initialize a new prisoner with ransom and confiscation. + */ + private void initializePrisoner( + ServerLevel level, + PrisonerManager manager, + ServerPlayer prisoner, + UUID campId + ) { + UUID prisonerId = prisoner.getUUID(); + long currentTime = level.getGameTime(); + + // 1. Evaluate player equipment BEFORE confiscation + boolean hasIronArmor = hasArmorTier( + prisoner, + Items.IRON_CHESTPLATE, + Items.IRON_LEGGINGS, + Items.IRON_HELMET, + Items.IRON_BOOTS + ); + boolean hasDiamondArmor = hasArmorTier( + prisoner, + Items.DIAMOND_CHESTPLATE, + Items.DIAMOND_LEGGINGS, + Items.DIAMOND_HELMET, + Items.DIAMOND_BOOTS, + Items.NETHERITE_CHESTPLATE, + Items.NETHERITE_LEGGINGS, + Items.NETHERITE_HELMET, + Items.NETHERITE_BOOTS + ); + boolean hasValuables = hasAnyItem( + prisoner, + Items.EMERALD, + Items.DIAMOND, + Items.EMERALD_BLOCK, + Items.DIAMOND_BLOCK + ); + + // 2. Confiscate valuables + List confiscated = ItemService.get().confiscateValuables( + prisoner + ); + + // 3. Calculate ransom using centralized formula (100-200+ emeralds) + int baseRansom = LaborTaskGenerator.calculateRansomAmount( + hasIronArmor, + hasDiamondArmor, + hasValuables + ); + int confiscatedValue = calculateConfiscatedValue(confiscated); + int totalRansom = baseRansom + confiscatedValue; + + // 3. Create ransom record + manager.createRansom(prisonerId, totalRansom, currentTime); + + // 4. Initialize labor record to IDLE + LaborRecord labor = manager.getLaborRecord(prisonerId); + labor.setPhase(LaborRecord.WorkPhase.IDLE, currentTime); + + // 5. Store confiscated items in camp chest + storeConfiscatedItems(level, campId, confiscated); + + // 6. Notify prisoner + prisoner.sendSystemMessage( + net.minecraft.network.chat.Component.literal( + String.format( + "You have been imprisoned. Your debt: %d emeralds.", + totalRansom + ) + ).withStyle(net.minecraft.ChatFormatting.RED) + ); + + if (!confiscated.isEmpty()) { + prisoner.sendSystemMessage( + net.minecraft.network.chat.Component.literal( + "Your valuables have been confiscated." + ).withStyle(net.minecraft.ChatFormatting.GRAY) + ); + } + + TiedUpMod.LOGGER.info( + "[MaidInitPrisonerGoal] Initialized prisoner {} with ransom {}", + prisoner.getName().getString(), + totalRansom + ); + } + + /** + * Calculate emerald value of confiscated items. + */ + private int calculateConfiscatedValue(List items) { + int value = 0; + for (ItemStack stack : items) { + value += getItemValue(stack) * stack.getCount(); + } + return value; + } + + private int getItemValue(ItemStack stack) { + var item = stack.getItem(); + if ( + item == Items.NETHERITE_INGOT || item == Items.NETHERITE_BLOCK + ) return 10; + if (item == Items.DIAMOND || item == Items.DIAMOND_BLOCK) return 5; + if (item == Items.EMERALD || item == Items.EMERALD_BLOCK) return 1; + if (item == Items.GOLD_INGOT || item == Items.GOLD_BLOCK) return 2; + if (item == Items.IRON_INGOT || item == Items.IRON_BLOCK) return 1; + return 0; + } + + /** + * Check if the player has any armor piece from the given set equipped. + */ + private boolean hasArmorTier( + ServerPlayer player, + net.minecraft.world.item.Item... armorItems + ) { + for (ItemStack armorSlot : player.getArmorSlots()) { + if (armorSlot.isEmpty()) continue; + for (net.minecraft.world.item.Item target : armorItems) { + if (armorSlot.getItem() == target) return true; + } + } + return false; + } + + /** + * Check if the player has any of the given items in inventory. + */ + private boolean hasAnyItem( + ServerPlayer player, + net.minecraft.world.item.Item... items + ) { + var inventory = player.getInventory(); + for (int i = 0; i < inventory.getContainerSize(); i++) { + ItemStack stack = inventory.getItem(i); + if (stack.isEmpty()) continue; + for (net.minecraft.world.item.Item target : items) { + if (stack.getItem() == target) return true; + } + } + return false; + } + + /** + * Store confiscated items in camp chest. + */ + private void storeConfiscatedItems( + ServerLevel level, + UUID campId, + List items + ) { + if (items.isEmpty()) return; + + var chestPos = ItemService.get().findCampChest(level, campId); + if (chestPos != null) { + ItemService.get().storeInChest(level, chestPos, items); + } + } + + @Override + public boolean canContinueToUse() { + return canUse(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/maid/goals/MaidReturnGoal.java b/src/main/java/com/tiedup/remake/entities/ai/maid/goals/MaidReturnGoal.java new file mode 100644 index 0000000..2ff8d5c --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/maid/goals/MaidReturnGoal.java @@ -0,0 +1,830 @@ +package com.tiedup.remake.entities.ai.maid.goals; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityMaid; +import com.tiedup.remake.entities.ai.WaypointNavigator; +import com.tiedup.remake.labor.LaborTask; +import com.tiedup.remake.prison.LaborRecord; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerRecord; +import com.tiedup.remake.prison.PrisonerState; +import com.tiedup.remake.prison.RansomRecord; +import com.tiedup.remake.prison.service.BondageService; +import com.tiedup.remake.prison.service.ItemService; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.ICaptor; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.util.KidnapperAIHelper; +import com.tiedup.remake.util.MessageDispatcher; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.item.ItemStack; + +/** + * Goal: Return prisoners to cells after labor. + * + * Flow: + * 1. Detect prisoner in PENDING_RETURN within 20 blocks of maid + * 2. Walk to prisoner + * 3. Leash prisoner (getCapturedBy) + * 4. Walk to cell, prisoner follows via leash traction + * 5. At cell: collect items, restore bondage, credit earnings + * 6. Transition to IMPRISONED/RESTING + */ +public class MaidReturnGoal extends Goal { + + /** Maid detects prisoner returning from task within this radius */ + private static final double DETECTION_RADIUS = 20.0; + /** Distance to leash the prisoner */ + private static final double LEASH_REACH = 3.0; + /** Distance to cell to consider arrival */ + private static final double CELL_REACH_DISTANCE = 4.0; + private static final int MAX_DURATION_TICKS = 6000; // 5 minutes + private static final long GRACE_PERIOD_TICKS = 6000; // 5 minutes protection + private static final int STUCK_TELEPORT_TICKS = 200; // 10s stuck → teleport + + private enum Phase { + /** Walking toward prisoner to leash them */ + APPROACHING_PRISONER, + /** Leashed prisoner, walking to cell */ + WALKING_TO_CELL, + } + + private final EntityMaid maid; + + // Current return state + private Phase phase; + private UUID targetPrisonerId; + private BlockPos targetCellPos; + private int ticksActive; + private boolean navigationStarted; + private int stuckTicks; + private BlockPos lastProgressPos; + private boolean leashed; + private CellDataV2 targetCell; + private WaypointNavigator waypointNav; + + public MaidReturnGoal(EntityMaid maid) { + this.maid = maid; + this.setFlags(EnumSet.of(Flag.MOVE, Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (maid.isFreed() || maid.isTiedUp()) { + return false; + } + if (maid.getMasterTraderUUID() == null) { + return false; + } + if (maid.getMaidState().isBusy()) { + return false; + } + if (!(maid.level() instanceof ServerLevel level)) { + return false; + } + + UUID campId = maid.getCampUUID(); + if (campId == null) { + return false; + } + + // Find a prisoner needing return AND within detection radius of the maid + PrisonerManager manager = PrisonerManager.get(level); + Set prisoners = manager.getPrisonersInCampWithState( + campId, + PrisonerState.WORKING + ); + + for (UUID prisonerId : prisoners) { + LaborRecord labor = manager.getLaborRecord(prisonerId); + if (labor.getPhase() == LaborRecord.WorkPhase.PENDING_RETURN) { + ServerPlayer prisoner = level + .getServer() + .getPlayerList() + .getPlayer(prisonerId); + if (prisoner != null && prisoner.isAlive()) { + double distance = maid.distanceTo(prisoner); + if (distance <= DETECTION_RADIUS) { + PrisonerRecord record = manager.getRecord(prisonerId); + UUID cellId = record.getCellId(); + if (cellId != null) { + CellDataV2 cell = CellRegistryV2.get(level).getCell( + cellId + ); + if (cell != null) { + this.targetPrisonerId = prisonerId; + this.targetCell = cell; + this.targetCellPos = + KidnapperAIHelper.getDeliveryOrSpawnPoint( + cell, + level + ); + return true; + } + } + } + } + } + } + + return false; + } + + @Override + public void start() { + ticksActive = 0; + phase = Phase.APPROACHING_PRISONER; + navigationStarted = false; + stuckTicks = 0; + lastProgressPos = null; + leashed = false; + + // Build waypoint path if cell has waypoints (same pattern as KidnapperBringToCellGoal) + if (targetCell != null && !targetCell.getPathWaypoints().isEmpty()) { + List fullPath = new ArrayList<>( + targetCell.getPathWaypoints() + ); + fullPath.add(targetCellPos); // delivery/spawn as final waypoint + this.waypointNav = new WaypointNavigator(maid, fullPath, 1.0); + TiedUpMod.LOGGER.debug( + "[MaidReturnGoal] {} using {} waypoints for navigation to cell", + maid.getNpcName(), + fullPath.size() + ); + } else { + this.waypointNav = null; + } + + maid.setMaidState( + com.tiedup.remake.entities.ai.maid.MaidState.RETURNING_PRISONER + ); + + TiedUpMod.LOGGER.info( + "[MaidReturnGoal] {} approaching prisoner {} for return escort", + maid.getNpcName(), + targetPrisonerId != null + ? targetPrisonerId.toString().substring(0, 8) + : "null" + ); + } + + @Override + public void tick() { + ticksActive++; + + if (!(maid.level() instanceof ServerLevel level)) { + return; + } + + if (ticksActive > MAX_DURATION_TICKS) { + TiedUpMod.LOGGER.warn("[MaidReturnGoal] Return timed out"); + return; // canContinueToUse will stop the goal + } + + ServerPlayer prisoner = level + .getServer() + .getPlayerList() + .getPlayer(targetPrisonerId); + if (prisoner == null || !prisoner.isAlive()) { + TiedUpMod.LOGGER.warn("[MaidReturnGoal] Prisoner offline or dead"); + return; + } + + switch (phase) { + case APPROACHING_PRISONER: + tickApproachPrisoner(level, prisoner); + break; + case WALKING_TO_CELL: + tickWalkToCell(level, prisoner); + break; + } + } + + /** + * Phase 1: Walk toward prisoner. When close enough, leash them. + */ + private void tickApproachPrisoner( + ServerLevel level, + ServerPlayer prisoner + ) { + maid.getLookControl().setLookAt(prisoner, 30.0f, 30.0f); + + double distance = maid.distanceTo(prisoner); + + if (distance <= LEASH_REACH) { + // Close enough — leash the prisoner + performLeash(level, prisoner); + phase = Phase.WALKING_TO_CELL; + navigationStarted = false; + stuckTicks = 0; + lastProgressPos = null; + return; + } + + // Navigate toward prisoner — re-path every 20 ticks + if (!navigationStarted || ticksActive % 20 == 0) { + maid.getNavigation().moveTo(prisoner, 1.0); + navigationStarted = true; + } + + // Stuck detection: teleport maid to prisoner if stuck + tickStuckDetection(prisoner.blockPosition()); + } + + /** + * Phase 2: Walk to cell with leashed prisoner following. + * Uses WaypointNavigator if path waypoints are defined, else vanilla pathfinding. + */ + private void tickWalkToCell(ServerLevel level, ServerPlayer prisoner) { + if (targetCellPos == null) return; + + if (waypointNav != null && waypointNav.hasWaypoints()) { + // === WAYPOINT MODE === + waypointNav.tick(); + + if (waypointNav.isComplete()) { + collectAndReturn(level, prisoner); + return; + } + + // Re-path to current waypoint every 20 ticks + if (!navigationStarted || ticksActive % 20 == 0) { + waypointNav.navigateToCurrentWaypoint(); + navigationStarted = true; + } + + // Look at current waypoint + BlockPos currentWp = waypointNav.getCurrentWaypoint(); + if (currentWp != null) { + maid + .getLookControl() + .setLookAt( + currentWp.getX() + 0.5, + currentWp.getY() + 1, + currentWp.getZ() + 0.5 + ); + } + + // Stuck detection toward current waypoint (not final destination) + tickStuckDetection(currentWp != null ? currentWp : targetCellPos); + } else { + // === VANILLA FALLBACK === + // Navigate to cell — re-path every 40 ticks + if (!navigationStarted || ticksActive % 40 == 0) { + maid + .getNavigation() + .moveTo( + targetCellPos.getX() + 0.5, + targetCellPos.getY(), + targetCellPos.getZ() + 0.5, + 1.0 + ); + navigationStarted = true; + } + + // Check if reached cell + double cellDistance = maid + .position() + .distanceTo( + new net.minecraft.world.phys.Vec3( + targetCellPos.getX() + 0.5, + targetCellPos.getY(), + targetCellPos.getZ() + 0.5 + ) + ); + + if (cellDistance <= CELL_REACH_DISTANCE) { + collectAndReturn(level, prisoner); + return; + } + + // Stuck detection toward final destination + tickStuckDetection(targetCellPos); + } + } + + /** + * Bind + leash the prisoner and despawn the guard (maid takes over escort). + * + * getCapturedBy requires isEnslavable() (= isTiedUp), but during labor + * restraints were removed. Re-apply wrist binds from the bondage snapshot + * first so the leash can attach. + */ + private void performLeash(ServerLevel level, ServerPlayer prisoner) { + IBondageState cap = KidnappedHelper.getKidnappedState(prisoner); + if (cap == null) return; + + PrisonerManager manager = PrisonerManager.get(level); + LaborRecord labor = manager.getLaborRecord(targetPrisonerId); + + // 1. Re-apply wrist binds from bondage snapshot (makes isEnslavable() = true) + CompoundTag snapshot = labor.getBondageSnapshot(); + if (snapshot != null && snapshot.contains("BindItem")) { + ItemStack bind = ItemStack.of(snapshot.getCompound("BindItem")); + if (!bind.isEmpty()) { + cap.equip(BodyRegionV2.ARMS, bind); + } + } else { + // Fallback: use basic rope if no snapshot + cap.equip(BodyRegionV2.ARMS, + new ItemStack( + com.tiedup.remake.items.ModItems.getBind( + com.tiedup.remake.items.base.BindVariant.ROPES + ) + ) + ); + } + + // 2. Leash — now works because player is tied up + boolean success = cap.getCapturedBy((ICaptor) maid); + if (success) { + leashed = true; + } else { + TiedUpMod.LOGGER.warn( + "[MaidReturnGoal] getCapturedBy failed for {} — fallback direct leash", + prisoner.getName().getString() + ); + // Fallback: direct leash attachment bypassing captivity checks + if ( + prisoner instanceof + com.tiedup.remake.state.IPlayerLeashAccess access + ) { + access.tiedup$attachLeash(maid); + leashed = true; + } + } + + // 3. Despawn guard — maid takes over escort + despawnGuard(level, labor); + + // 4. Transition labor phase to RETURNING + labor.startReturn(maid.getUUID(), level.getGameTime()); + + maidSay( + prisoner, + "maid.labor.leash", + "Come with me. Back to your cell." + ); + + TiedUpMod.LOGGER.info( + "[MaidReturnGoal] {} bound and leashed prisoner {}, escorting to cell", + maid.getNpcName(), + prisoner.getName().getString() + ); + } + + /** + * Stuck detection: teleport maid to target if no progress. + */ + private void tickStuckDetection(BlockPos target) { + BlockPos currentPos = maid.blockPosition(); + if ( + lastProgressPos == null || + currentPos.distManhattan(lastProgressPos) >= 2 + ) { + lastProgressPos = currentPos; + stuckTicks = 0; + } else { + stuckTicks++; + } + if (stuckTicks >= STUCK_TELEPORT_TICKS) { + TiedUpMod.LOGGER.info( + "[MaidReturnGoal] {} stuck, teleporting to {}", + maid.getNpcName(), + target.toShortString() + ); + maid.teleportTo( + target.getX() + 0.5, + target.getY() + 1, + target.getZ() + 0.5 + ); + stuckTicks = 0; + lastProgressPos = null; + } + } + + /** + * Collect task items from prisoner and complete the return. + * Called when maid arrives at the cell with the leashed prisoner. + */ + private void collectAndReturn(ServerLevel level, ServerPlayer prisoner) { + PrisonerManager manager = PrisonerManager.get(level); + LaborRecord labor = manager.getLaborRecord(targetPrisonerId); + LaborTask task = labor.getTask(); + UUID campId = manager.getRecord(targetPrisonerId).getCampId(); + + // Collect task items + if (task != null) { + List collected = ItemService.get().collectTaskItems( + prisoner, + task + ); + List tools = ItemService.get().reclaimEquipment( + prisoner, + task + ); + + // Store in camp chest + if (campId != null) { + BlockPos chestPos = ItemService.get().findCampChest( + level, + campId + ); + if (chestPos != null) { + ItemService.get().storeInChest(level, chestPos, collected); + ItemService.get().storeInChest(level, chestPos, tools); + } + } + + // Credit earnings + if (!labor.isTaskFailed()) { + RansomRecord ransom = manager.getRansomRecord(targetPrisonerId); + if (ransom != null) { + labor.addEarnings(task.getValue()); + boolean paid = ransom.addPayment(task.getValue(), null); + + String earningsFallback = + "Good work. " + + task.getValue() + + " emeralds have been deducted."; + String earningsText = + com.tiedup.remake.dialogue.DialogueBridge.getDialogue( + maid, + prisoner, + "maid.labor.earnings" + ); + if (earningsText != null) { + earningsText = earningsText.replace( + "{value}", + String.valueOf(task.getValue()) + ); + String mn = maid.getNpcName(); + if (mn == null || mn.isEmpty()) mn = "Maid"; + prisoner.sendSystemMessage( + net.minecraft.network.chat.Component.literal( + "<" + mn + "> " + ) + .withStyle( + net.minecraft.network.chat.Style.EMPTY.withColor( + net.minecraft.network.chat.TextColor.fromRgb( + EntityMaid.MAID_NAME_COLOR + ) + ) + ) + .append( + net.minecraft.network.chat.Component.literal( + earningsText + ).withStyle( + net.minecraft.ChatFormatting.WHITE + ) + ) + ); + } else { + maidSay( + prisoner, + "maid.labor.earnings", + earningsFallback + ); + } + } + } + } + + // Complete the return (teleport, restore bondage, state transition) + completeReturn(level, prisoner); + + // Mark as completed so stop() doesn't try to undo anything + leashed = false; + targetPrisonerId = null; + + TiedUpMod.LOGGER.info( + "[MaidReturnGoal] {} collected items and returned prisoner to cell", + maid.getNpcName() + ); + } + + /** + * Complete the return - restore bondage and update state. + * PrisonerService.returnToCell handles unleashing the prisoner. + */ + private void completeReturn(ServerLevel level, ServerPlayer prisoner) { + PrisonerManager manager = PrisonerManager.get(level); + LaborRecord labor = manager.getLaborRecord(targetPrisonerId); + PrisonerRecord record = manager.getRecord(targetPrisonerId); + RansomRecord ransom = manager.getRansomRecord(targetPrisonerId); + long currentTime = level.getGameTime(); + + // Check if ransom is paid + if (ransom != null && ransom.isPaid()) { + // Free the prisoner! + freePrisoner(level, prisoner, manager, currentTime); + return; + } + + // Teleport prisoner to cell spawn point (not core pos which is in the wall) + UUID cellId2 = record.getCellId(); + if (cellId2 != null) { + CellDataV2 cell2 = CellRegistryV2.get(level).getCell(cellId2); + if (cell2 != null && cell2.getSpawnPoint() != null) { + BlockPos spawn = cell2.getSpawnPoint(); + prisoner.teleportTo( + spawn.getX() + 0.5, + spawn.getY() + 0.1, + spawn.getZ() + 0.5 + ); + } else if (targetCellPos != null) { + prisoner.teleportTo( + targetCellPos.getX() + 0.5, + targetCellPos.getY() + 1.0, + targetCellPos.getZ() + 0.5 + ); + } + } else if (targetCellPos != null) { + prisoner.teleportTo( + targetCellPos.getX() + 0.5, + targetCellPos.getY() + 1.0, + targetCellPos.getZ() + 0.5 + ); + } + + // Restore bondage + IBondageState cap = KidnappedHelper.getKidnappedState(prisoner); + if (cap != null) { + CompoundTag snapshot = labor.getBondageSnapshot(); + if (snapshot != null) { + BondageService.get().restoreSnapshot(cap, snapshot); + } + } + + // Return to cell via PrisonerService (handles unleash + PrisonerManager + CellRegistry) + UUID cellId = record.getCellId(); + if (cellId != null) { + CellDataV2 cell = CellRegistryV2.get(level).getCell(cellId); + if (cell != null) { + com.tiedup.remake.prison.service.PrisonerService.get().returnToCell( + level, + prisoner, + null, + cell + ); + // Start rest period + labor.startRest(currentTime); + maidSay( + prisoner, + "maid.labor.cell_return", + "Back to your cell. Rest before your next task." + ); + TiedUpMod.LOGGER.info( + "[MaidReturnGoal] Returned {} to cell (state={})", + prisoner.getName().getString(), + record.getState() + ); + } else { + // Cell destroyed — reset to PENDING_RETURN, let PrisonerService timeout handle + labor.completeTask(currentTime); + TiedUpMod.LOGGER.warn( + "[MaidReturnGoal] Cell {} destroyed for {} - resetting to PENDING_RETURN", + cellId.toString().substring(0, 8), + prisoner.getName().getString() + ); + } + } else { + // No cell ID — same fallback + labor.completeTask(currentTime); + TiedUpMod.LOGGER.warn( + "[MaidReturnGoal] No cellId for {} - resetting to PENDING_RETURN", + prisoner.getName().getString() + ); + } + } + + /** + * Free a prisoner whose ransom is paid. + */ + private void freePrisoner( + ServerLevel level, + ServerPlayer prisoner, + PrisonerManager manager, + long currentTime + ) { + UUID prisonerId = prisoner.getUUID(); + + // 0. Despawn guard if present (might already be done in performLeash) + LaborRecord labor = manager.getLaborRecord(prisonerId); + despawnGuard(level, labor); + + // 1. Get cell ID before releasing (for removal from cell registry) + PrisonerRecord record = manager.getRecord(prisonerId); + UUID cellId = record.getCellId(); + + // 2. Release with grace period (sets state to PROTECTED) + manager.release(prisonerId, currentTime, GRACE_PERIOD_TICKS); + + // 3. Remove from cell registry (so guards don't track them) + if (cellId != null) { + CellRegistryV2 cellRegistry = CellRegistryV2.get(level); + cellRegistry.releasePrisoner(cellId, prisonerId, level.getServer()); + TiedUpMod.LOGGER.debug( + "[MaidReturnGoal] Removed {} from cell registry", + prisoner.getName().getString() + ); + } + + // 4. Clear collar from CollarRegistry FIRST (before removing the item) + com.tiedup.remake.state.CollarRegistry collarRegistry = + com.tiedup.remake.state.CollarRegistry.get(level); + collarRegistry.unregisterWearer(prisonerId); + + // 5. Free kidnapped state and remove all restraints + IBondageState cap = KidnappedHelper.getKidnappedState(prisoner); + if (cap != null) { + // Remove collar (force=true to bypass lock) + net.minecraft.world.item.ItemStack collar = cap.getEquipment(BodyRegionV2.NECK); + if (!collar.isEmpty()) { + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.unequipFromRegion( + prisoner, + com.tiedup.remake.v2.BodyRegionV2.NECK, + true + ); + TiedUpMod.LOGGER.debug( + "[MaidReturnGoal] Force-removed collar from {}", + prisoner.getName().getString() + ); + } + + // Remove all restraints (binds, blindfold, gag) + cap.untie(true); // Full untie including legs + + // Free from captivity state + cap.free(false); + } + + prisoner.sendSystemMessage( + net.minecraft.network.chat.Component.literal( + "Your debt is paid. You are FREE!" + ).withStyle( + net.minecraft.ChatFormatting.GREEN, + net.minecraft.ChatFormatting.BOLD + ) + ); + + TiedUpMod.LOGGER.info( + "[MaidReturnGoal] Released {} - ransom paid, collar removed, freed from cell", + prisoner.getName().getString() + ); + } + + /** + * Send a formatted maid speech message to a player. + * Tries JSON dialogue first, falls back to provided message. + */ + private void maidSay( + ServerPlayer player, + String dialogueId, + String fallback + ) { + String text = com.tiedup.remake.dialogue.DialogueBridge.getDialogue( + maid, + player, + dialogueId + ); + if (text == null) { + text = fallback; + } + MessageDispatcher.talkTo(maid, player, text); + } + + /** + * Despawn the guard entity assigned to this prisoner's labor record. + */ + private void despawnGuard(ServerLevel level, LaborRecord labor) { + java.util.UUID guardId = labor.getGuardId(); + if (guardId == null) return; + + net.minecraft.world.entity.Entity guardEntity = level.getEntity( + guardId + ); + if (guardEntity != null) { + guardEntity.discard(); + TiedUpMod.LOGGER.debug( + "[MaidReturnGoal] Despawned guard {}", + guardId.toString().substring(0, 8) + ); + } else { + TiedUpMod.LOGGER.debug( + "[MaidReturnGoal] Guard {} not found for despawn (may have died)", + guardId.toString().substring(0, 8) + ); + } + + labor.setGuardId(null); + } + + @Override + public boolean canContinueToUse() { + if (targetPrisonerId == null) { + return false; + } + if (maid.isFreed() || maid.isTiedUp()) { + return false; + } + if (ticksActive >= MAX_DURATION_TICKS) { + return false; + } + // Check prisoner still online + if (maid.level() instanceof ServerLevel level) { + ServerPlayer prisoner = level + .getServer() + .getPlayerList() + .getPlayer(targetPrisonerId); + if (prisoner == null || !prisoner.isAlive()) { + return false; + } + } + return true; + } + + @Override + public void stop() { + // Release leash if still active (interrupted before completing return) + if ( + leashed && + targetPrisonerId != null && + maid.level() instanceof ServerLevel level + ) { + ServerPlayer prisoner = level + .getServer() + .getPlayerList() + .getPlayer(targetPrisonerId); + if (prisoner != null) { + IBondageState cap = KidnappedHelper.getKidnappedState(prisoner); + if (cap != null && cap.isCaptive()) { + cap.free(false); + } + } + } + + // If interrupted during WALKING_TO_CELL (phase=RETURNING), teleport prisoner to cell + // instead of leaving them wandering for 2.5 min until PENDING_RETURN timeout kicks in + if ( + targetPrisonerId != null && + maid.level() instanceof ServerLevel level + ) { + PrisonerManager manager = PrisonerManager.get(level); + LaborRecord labor = manager.getLaborRecord(targetPrisonerId); + if ( + labor != null && + labor.getPhase() == LaborRecord.WorkPhase.RETURNING + ) { + labor.completeTask(level.getGameTime()); // Back to PENDING_RETURN + + // Teleport prisoner to cell immediately if possible + ServerPlayer strandedPrisoner = level + .getServer() + .getPlayerList() + .getPlayer(targetPrisonerId); + if (strandedPrisoner != null && targetCellPos != null) { + strandedPrisoner.teleportTo( + targetCellPos.getX() + 0.5, + targetCellPos.getY(), + targetCellPos.getZ() + 0.5 + ); + TiedUpMod.LOGGER.warn( + "[MaidReturnGoal] Interrupted during escort of {} — teleported to cell", + targetPrisonerId.toString().substring(0, 8) + ); + } else { + TiedUpMod.LOGGER.warn( + "[MaidReturnGoal] Interrupted during escort of {} — PENDING_RETURN timeout will handle", + targetPrisonerId.toString().substring(0, 8) + ); + } + } + } + + maid.getNavigation().stop(); + maid.setMaidState(com.tiedup.remake.entities.ai.maid.MaidState.IDLE); + + targetPrisonerId = null; + targetCellPos = null; + targetCell = null; + waypointNav = null; + phase = null; + navigationStarted = false; + leashed = false; + + TiedUpMod.LOGGER.debug("[MaidReturnGoal] Stopped"); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/master/MasterBuyPlayerGoal.java b/src/main/java/com/tiedup/remake/entities/ai/master/MasterBuyPlayerGoal.java new file mode 100644 index 0000000..781757b --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/master/MasterBuyPlayerGoal.java @@ -0,0 +1,510 @@ +package com.tiedup.remake.entities.ai.master; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.dialogue.DialogueBridge; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.state.IRestrainable; +import java.util.EnumSet; +import java.util.List; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; + +/** + * AI Goal for EntityMaster to approach and buy a player from a Kidnapper. + * + * Flow: + * 1. Master spawns 50 blocks away + * 2. Master walks towards the Kidnapper + * 3. Master arrives and greets Kidnapper (RP dialogue) + * 4. Master negotiates/inspects the "merchandise" + * 5. Master completes purchase + * 6. Kidnapper leaves, Master takes ownership + */ +public class MasterBuyPlayerGoal extends Goal { + + private final EntityMaster master; + + /** Search radius for kidnappers (if not pre-set) */ + private static final double SEARCH_RADIUS = 64.0; + + /** Distance to start negotiation dialogue */ + private static final double GREETING_DISTANCE = 8.0; + + /** Distance to complete purchase */ + private static final double PURCHASE_DISTANCE = 3.0; + + /** Target kidnapper selling a player */ + private EntityKidnapper targetKidnapper = null; + + /** Purchase phase */ + private PurchasePhase phase = PurchasePhase.APPROACHING; + + /** Timer for current phase */ + private int phaseTimer = 0; + + /** Phase durations (in ticks) */ + private static final int GREETING_DURATION = 60; // 3 seconds + private static final int INSPECT_DURATION = 80; // 4 seconds + private static final int NEGOTIATE_DURATION = 60; // 3 seconds + private static final int PURCHASE_DURATION = 60; // 3 seconds + + /** Whether we've said the approach dialogue */ + private boolean hasSaidApproach = false; + + /** Pathfinding recalculation cooldown to avoid path thrashing */ + private int pathRecalcCooldown = 0; + + private enum PurchasePhase { + APPROACHING, // Walking to kidnapper + GREETING, // Arrived, greeting + INSPECTING, // Looking at the merchandise + NEGOTIATING, // Discussing price + PURCHASING, // Completing purchase + COMPLETE, // Done + } + + public MasterBuyPlayerGoal(EntityMaster master) { + this.master = master; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Preserve state for ALL phases in progress (including APPROACHING) + // This prevents goal from restarting during approach + if (targetKidnapper != null && phase != PurchasePhase.COMPLETE) { + // Validate target is still valid + if (targetKidnapper.isAlive()) { + return true; // Continue without restarting + } + // Target died - reset + targetKidnapper = null; + phase = PurchasePhase.APPROACHING; + } + + // Must be in purchasing state or idle without pet + MasterState state = master.getStateManager().getCurrentState(); + if (state != MasterState.PURCHASING && state != MasterState.IDLE) { + return false; + } + + // Don't search if already has a pet + if (master.hasPet()) { + return false; + } + + // First check if we have a pre-set selling kidnapper + if (master.hasSellingKidnapper()) { + targetKidnapper = master.getSellingKidnapper(); + return true; + } + + // Otherwise search for one nearby + targetKidnapper = findSellingKidnapper(); + return targetKidnapper != null; + } + + @Override + public boolean canContinueToUse() { + // Once we've started greeting, we're committed to the purchase + if ( + phase.ordinal() >= PurchasePhase.GREETING.ordinal() && + phase != PurchasePhase.COMPLETE + ) { + // Only stop if kidnapper dies or we completed + return targetKidnapper != null && targetKidnapper.isAlive(); + } + + // During approach phase, check if target is still valid + if (targetKidnapper == null || !targetKidnapper.isAlive()) { + return false; + } + + IRestrainable captive = targetKidnapper.getCaptive(); + if (captive == null) { + return false; + } + + // Stop if already has pet (purchase completed) + return !master.hasPet() && phase != PurchasePhase.COMPLETE; + } + + @Override + public void start() { + // Don't reset if we're already in progress (goal was briefly interrupted or resuming) + // This includes APPROACHING phase - only reset when starting from scratch or COMPLETE + if (targetKidnapper != null && phase != PurchasePhase.COMPLETE) { + TiedUpMod.LOGGER.debug( + "[MasterBuyPlayerGoal] Resuming {} at phase {}", + master.getNpcName(), + phase + ); + // Ensure state is set + master.setMasterState(MasterState.PURCHASING); + return; + } + + // Starting fresh - reset everything + this.phaseTimer = 0; + this.phase = PurchasePhase.APPROACHING; + this.hasSaidApproach = false; + this.pathRecalcCooldown = 0; + master.setMasterState(MasterState.PURCHASING); + + TiedUpMod.LOGGER.debug( + "[MasterBuyPlayerGoal] {} approaching {} to buy player", + master.getNpcName(), + targetKidnapper != null + ? targetKidnapper.getNpcName() + : "unknown" + ); + } + + @Override + public void stop() { + master.getNavigation().stop(); + + // Only fully reset if purchase completed or truly failed + if (phase == PurchasePhase.COMPLETE || master.hasPet()) { + TiedUpMod.LOGGER.debug( + "[MasterBuyPlayerGoal] {} completed purchase, transitioning to FOLLOWING", + master.getNpcName() + ); + this.targetKidnapper = null; + this.phaseTimer = 0; + this.phase = PurchasePhase.APPROACHING; + this.hasSaidApproach = false; + master.clearSellingKidnapper(); + master.setMasterState(MasterState.FOLLOWING); + } + // Otherwise keep state for resume (goal was just temporarily interrupted) + } + + @Override + public void tick() { + if (targetKidnapper == null) return; + + // Look at kidnapper + master.getLookControl().setLookAt(targetKidnapper, 30.0F, 30.0F); + + double distSq = master.distanceToSqr(targetKidnapper); + double dist = Math.sqrt(distSq); + + switch (phase) { + case APPROACHING -> tickApproaching(dist); + case GREETING -> tickGreeting(); + case INSPECTING -> tickInspecting(); + case NEGOTIATING -> tickNegotiating(); + case PURCHASING -> tickPurchasing(); + case COMPLETE -> { + } // Do nothing + } + } + + private void tickApproaching(double dist) { + // Approach dialogue when getting close (once) + if (!hasSaidApproach && dist < 30) { + IRestrainable captive = targetKidnapper.getCaptive(); + if ( + captive != null && + captive.asLivingEntity() instanceof Player player + ) { + DialogueBridge.talkTo(master, player, "purchase.interested"); + } + hasSaidApproach = true; + } + + if (dist > GREETING_DISTANCE) { + // Recalculate path every 20 ticks (1 second) instead of every tick + // This prevents path thrashing which dramatically slows movement + if (pathRecalcCooldown <= 0) { + master.getNavigation().moveTo(targetKidnapper, 1.0); + pathRecalcCooldown = 20; + } else { + pathRecalcCooldown--; + } + } else { + // Arrived - start greeting + master.getNavigation().stop(); + phase = PurchasePhase.GREETING; + phaseTimer = 0; + pathRecalcCooldown = 0; + + // Greeting dialogue + IRestrainable captive = targetKidnapper.getCaptive(); + if ( + captive != null && + captive.asLivingEntity() instanceof Player player + ) { + DialogueBridge.talkTo(master, player, "idle.greeting_stranger"); + } + + TiedUpMod.LOGGER.debug( + "[MasterBuyPlayerGoal] {} arrived, greeting {}", + master.getNpcName(), + targetKidnapper.getNpcName() + ); + } + } + + private void tickGreeting() { + phaseTimer++; + + // Move closer during greeting + double dist = Math.sqrt(master.distanceToSqr(targetKidnapper)); + if (dist > PURCHASE_DISTANCE) { + master.getNavigation().moveTo(targetKidnapper, 0.5); + } + + if (phaseTimer >= GREETING_DURATION) { + phase = PurchasePhase.INSPECTING; + phaseTimer = 0; + + // Kidnapper offers sale dialogue + IRestrainable captive = targetKidnapper.getCaptive(); + if ( + captive != null && + captive.asLivingEntity() instanceof Player player + ) { + targetKidnapper.talkTo( + player, + com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory.SALE_OFFER + ); + } + + TiedUpMod.LOGGER.debug( + "[MasterBuyPlayerGoal] {} inspecting merchandise", + master.getNpcName() + ); + } + } + + private void tickInspecting() { + phaseTimer++; + + // Look at the captive during inspection + IRestrainable captive = targetKidnapper.getCaptive(); + if (captive != null) { + master + .getLookControl() + .setLookAt(captive.asLivingEntity(), 30.0F, 30.0F); + } + + if (phaseTimer >= INSPECT_DURATION) { + phase = PurchasePhase.NEGOTIATING; + phaseTimer = 0; + + // Master negotiates + if ( + captive != null && + captive.asLivingEntity() instanceof Player player + ) { + DialogueBridge.talkTo(master, player, "purchase.negotiating"); + } + + TiedUpMod.LOGGER.debug( + "[MasterBuyPlayerGoal] {} negotiating", + master.getNpcName() + ); + } + } + + private void tickNegotiating() { + phaseTimer++; + + // Look back at kidnapper + master.getLookControl().setLookAt(targetKidnapper, 30.0F, 30.0F); + + if (phaseTimer >= NEGOTIATE_DURATION) { + phase = PurchasePhase.PURCHASING; + phaseTimer = 0; + + // Sale complete dialogue from kidnapper + IRestrainable captive = targetKidnapper.getCaptive(); + if ( + captive != null && + captive.asLivingEntity() instanceof Player player + ) { + targetKidnapper.talkTo( + player, + com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory.SALE_COMPLETE + ); + } + + TiedUpMod.LOGGER.debug( + "[MasterBuyPlayerGoal] {} completing purchase", + master.getNpcName() + ); + } + } + + private void tickPurchasing() { + phaseTimer++; + + if (phaseTimer >= PURCHASE_DURATION) { + completePurchase(); + phase = PurchasePhase.COMPLETE; + } + } + + /** + * Find a nearby kidnapper who is selling a player. + */ + private EntityKidnapper findSellingKidnapper() { + List kidnappers = master + .level() + .getEntitiesOfClass( + EntityKidnapper.class, + master.getBoundingBox().inflate(SEARCH_RADIUS), + k -> { + if (!k.isAlive() || k.isTiedUp()) return false; + IRestrainable captive = k.getCaptive(); + if (captive == null) return false; + if (!captive.isForSell()) return false; + // Only buy players, not NPCs + return captive.asLivingEntity() instanceof Player; + } + ); + + if (kidnappers.isEmpty()) return null; + + // Return closest one + return kidnappers + .stream() + .min((a, b) -> + Double.compare(master.distanceToSqr(a), master.distanceToSqr(b)) + ) + .orElse(null); + } + + /** + * Complete the purchase - take the player from kidnapper. + * Properly transfers the captive with their bindings to the Master. + */ + private void completePurchase() { + if (targetKidnapper == null) return; + + IRestrainable captive = targetKidnapper.getCaptive(); + if (captive == null) return; + + if (!(captive.asLivingEntity() instanceof ServerPlayer player)) { + TiedUpMod.LOGGER.warn( + "[MasterBuyPlayerGoal] Captive is not a player!" + ); + return; + } + + TiedUpMod.LOGGER.debug( + "[MasterBuyPlayerGoal] {} bought {} from {}", + master.getNpcName(), + player.getName().getString(), + targetKidnapper.getNpcName() + ); + + // Purchase complete dialogue to player + DialogueBridge.talkTo(master, player, "purchase.complete"); + + // Cancel the sale before transfer + captive.cancelSale(); + + // IMPORTANT: Enable captive transfer flag on Kidnapper BEFORE transfer + // This is required for transferCaptivityTo() to work + targetKidnapper.setAllowCaptiveTransferFlag(true); + + // Use transferCaptivityTo for proper leash transfer + // This detaches the leash from Kidnapper and attaches it to Master + captive.transferCaptivityTo(master); + + // Disable transfer flag after transfer + targetKidnapper.setAllowCaptiveTransferFlag(false); + + // Set up pet relationship in Master's state manager + master.setPetPlayer(player); + + // IMPORTANT: Detach leash after purchase + // The leash should only be attached during specific activities (walks, etc.) + // Pet play mode uses collar control, not constant leashing + if ( + player instanceof com.tiedup.remake.state.IPlayerLeashAccess access + ) { + access.tiedup$detachLeash(); + TiedUpMod.LOGGER.debug( + "[MasterBuyPlayerGoal] Detached leash from {} after purchase", + player.getName().getString() + ); + } + + // Replace shock collar with choke collar for pet play + master.putPetCollar(player); + + // Remove bindings (arms/legs) but keep the collar + // The pet starts fresh without restraints (events can add them later) + removeBindingsFromNewPet(captive); + + // Collaring dialogue + DialogueBridge.talkTo(master, player, "purchase.collaring"); + + // Notify kidnapper that purchase is complete via the WaitForBuyer goal + var buyerGoal = targetKidnapper.getWaitForBuyerGoal(); + if (buyerGoal != null) { + buyerGoal.onMasterPurchaseComplete(); + } else { + // Fallback: just set get out state directly + targetKidnapper.setGetOutState(true); + } + + // Introduction dialogue + DialogueBridge.talkTo(master, player, "purchase.introduction"); + + TiedUpMod.LOGGER.debug( + "[MasterBuyPlayerGoal] Transfer complete - {} now owns {}", + master.getNpcName(), + player.getName().getString() + ); + + targetKidnapper = null; + } + + /** + * Remove bindings from the new pet (but keep the collar and clothes). + * The pet starts with only the choke collar - events may add restraints later. + * + *

Note: We cannot use {@code untie(false)} because that clears ALL slots + * including the collar. Instead, we manually remove specific regions.

+ * + * @param captive The captive IRestrainable state + */ + private void removeBindingsFromNewPet(IRestrainable captive) { + if (!(captive.asLivingEntity() instanceof ServerPlayer player)) { + return; + } + + // Manually remove each region EXCEPT neck (collar) and torso (clothes) + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.unequipFromRegion( + player, com.tiedup.remake.v2.BodyRegionV2.ARMS, true + ); + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.unequipFromRegion( + player, com.tiedup.remake.v2.BodyRegionV2.MOUTH, true + ); + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.unequipFromRegion( + player, com.tiedup.remake.v2.BodyRegionV2.EYES, true + ); + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.unequipFromRegion( + player, com.tiedup.remake.v2.BodyRegionV2.EARS, true + ); + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.unequipFromRegion( + player, com.tiedup.remake.v2.BodyRegionV2.HANDS, true + ); + + // V1 speed reduction handled by MovementStyleManager (V2 tick-based). + // See H6 fix — removing V1 calls prevents double stacking. + + TiedUpMod.LOGGER.debug( + "[MasterBuyPlayerGoal] Removed bindings from new pet {}", + captive.getKidnappedName() + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/master/MasterDogwalkGoal.java b/src/main/java/com/tiedup/remake/entities/ai/master/MasterDogwalkGoal.java new file mode 100644 index 0000000..9802772 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/master/MasterDogwalkGoal.java @@ -0,0 +1,386 @@ +package com.tiedup.remake.entities.ai.master; + +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.DialogueBridge; +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.state.IPlayerLeashAccess; +import com.tiedup.remake.state.PlayerBindState; +import java.util.EnumSet; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.Vec3; + +/** + * AI Goal for EntityMaster dogwalk mode. + * + * Two modes: + * - Master leads (masterLeads=true): Master walks, player is pulled by leash + * - Pet leads (masterLeads=false): Master follows the pet at close distance + * + * The leash physics automatically pulls the player when Master moves away, + * handled by the LeashProxyEntity and player leash mixin. + */ +public class MasterDogwalkGoal extends Goal { + + private final EntityMaster master; + + /** If true, Master walks and pulls pet. If false, Master follows pet. */ + private boolean masterLeads = false; + + /** Distance to maintain from pet when following */ + private static final double LEASH_DISTANCE_PET_LEADS = 3.5; + + /** Maximum distance before waiting for pet (when master leads) */ + private static final double MAX_DISTANCE = 10.0; + + /** Navigation speed (used by masterLeads) */ + private static final double WALK_SPEED = 0.6; + + /** Speed when pet is close (3.5-6 blocks) - gentle follow */ + private static final double FOLLOW_CLOSE_SPEED = 0.5; + + /** Speed when pet is far (6+ blocks) - catching up */ + private static final double FOLLOW_CATCHUP_SPEED = 0.9; + + /** Stop following when closer than this */ + private static final double FOLLOW_STOP_DISTANCE = 2.5; + + /** Timer for random direction changes when master leads */ + private int directionChangeTimer = 0; + + /** Current walk destination (when master leads) */ + private Vec3 walkTarget = null; + + /** Cooldown for "wait for pet" dialogue */ + private int waitDialogueCooldown = 0; + + /** Maximum dogwalk duration (ticks) - 1-2 minutes */ + private static final int MIN_WALK_DURATION = 1200; // 1 minute + private static final int MAX_WALK_DURATION = 2400; // 2 minutes + + /** Current walk duration for this session */ + private int walkDuration = 0; + + /** Walk timer */ + private int walkTimer = 0; + + /** FIX: Stuck timer - counts ticks while waiting for pet */ + private int stuckTimer = 0; + + /** FIX: Maximum stuck time before teleporting pet (15 seconds) */ + private static final int MAX_STUCK_TIME = 300; + + public MasterDogwalkGoal(EntityMaster master) { + this.master = master; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + /** + * Set whether Master leads the walk. + */ + public void setMasterLeads(boolean masterLeads) { + this.masterLeads = masterLeads; + } + + @Override + public boolean canUse() { + return ( + master.getStateManager().getCurrentState() == MasterState.DOGWALK && + master.hasPet() + ); + } + + @Override + public boolean canContinueToUse() { + if (!master.hasPet()) { + return false; + } + + ServerPlayer pet = master.getPetPlayer(); + if (pet == null || !pet.isAlive()) { + return false; + } + + // End walk if duration exceeded + if (walkTimer >= walkDuration) { + return false; + } + + return ( + master.getStateManager().getCurrentState() == MasterState.DOGWALK + ); + } + + @Override + public void start() { + this.directionChangeTimer = 0; + this.walkTarget = null; + this.waitDialogueCooldown = 0; + this.walkTimer = 0; + this.stuckTimer = 0; + + // Random walk duration between 2-3 minutes + this.walkDuration = + MIN_WALK_DURATION + + master.getRandom().nextInt(MAX_WALK_DURATION - MIN_WALK_DURATION); + + // Get current mode from Master + this.masterLeads = master.isDogwalkMasterLeads(); + + // When pet leads, give extra leash slack so they can walk ahead + if (!masterLeads) { + ServerPlayer pet = master.getPetPlayer(); + if (pet instanceof IPlayerLeashAccess access) { + access.tiedup$setLeashSlack(5.0); // 3+5=8 blocks before pull + } + } + + TiedUpMod.LOGGER.debug( + "[MasterDogwalkGoal] {} started dogwalk (masterLeads={}, duration={} ticks)", + master.getNpcName(), + masterLeads, + walkDuration + ); + } + + @Override + public void stop() { + master.getNavigation().stop(); + this.walkTarget = null; + + // Clean up: remove dogbind and detach leash + cleanupDogwalk(); + + // FIX: Transition out of DOGWALK state so the goal doesn't restart + if (master.getStateManager().getCurrentState() == MasterState.DOGWALK) { + master.setMasterState(MasterState.FOLLOWING); + } + + TiedUpMod.LOGGER.debug( + "[MasterDogwalkGoal] {} stopped dogwalk", + master.getNpcName() + ); + } + + /** + * Clean up after dogwalk ends. + * Removes dogbind from player and detaches leash. + */ + private void cleanupDogwalk() { + ServerPlayer pet = master.getPetPlayer(); + if (pet == null) return; + + // Reset leash slack + if (pet instanceof IPlayerLeashAccess access) { + access.tiedup$setLeashSlack(0.0); + } + + // Detach leash + master.detachLeashFromPet(); + + // Remove dogbind from player + PlayerBindState state = PlayerBindState.getInstance(pet); + if (state != null && state.isTiedUp()) { + state.unequip(BodyRegionV2.ARMS); + TiedUpMod.LOGGER.debug( + "[MasterDogwalkGoal] Removed dogbind from {} after walk", + pet.getName().getString() + ); + } + + // Dialogue for walk end + DialogueBridge.talkTo(master, pet, "petplay.walk_end"); + } + + @Override + public void tick() { + ServerPlayer pet = master.getPetPlayer(); + if (pet == null) return; + + // Always look at pet + master.getLookControl().setLookAt(pet, 30.0F, 30.0F); + + double dist = master.distanceTo(pet); + + // Increment walk timer + walkTimer++; + + // Decrement dialogue cooldown + if (waitDialogueCooldown > 0) { + waitDialogueCooldown--; + } + + if (masterLeads) { + tickMasterLeads(pet, dist); + } else { + tickMasterFollows(pet, dist); + } + } + + /** + * Tick when Master leads the walk. + * Master walks in a direction, pet is pulled by leash physics. + */ + private void tickMasterLeads(ServerPlayer pet, double dist) { + // If pet is too far behind, wait for them + if (dist > MAX_DISTANCE) { + master.getNavigation().stop(); + stuckTimer++; + + // Occasionally tell pet to keep up + if (waitDialogueCooldown <= 0) { + DialogueBridge.talkTo(master, pet, "petplay.wait_pet"); + waitDialogueCooldown = 200; // 10 seconds cooldown + } + + // FIX: If stuck too long, teleport pet to master + if (stuckTimer >= MAX_STUCK_TIME) { + teleportPetToMaster(pet); + stuckTimer = 0; + } + return; + } + + // Pet caught up, reset stuck timer + stuckTimer = 0; + + // Update direction timer + directionChangeTimer--; + + // Pick new random direction periodically or if no target + if ( + directionChangeTimer <= 0 || + walkTarget == null || + !master.getNavigation().isInProgress() + ) { + pickNewWalkDirection(); + directionChangeTimer = 100 + master.getRandom().nextInt(100); // 5-10 seconds + } + + // Navigate to target + if (walkTarget != null && !master.getNavigation().isInProgress()) { + master + .getNavigation() + .moveTo(walkTarget.x, walkTarget.y, walkTarget.z, WALK_SPEED); + } + } + + /** + * Pick a new random direction for walking. + */ + private void pickNewWalkDirection() { + // Random angle + double angle = master.getRandom().nextDouble() * Math.PI * 2; + double distance = 8.0 + master.getRandom().nextDouble() * 8.0; // 8-16 blocks + + double x = master.getX() + Math.cos(angle) * distance; + double z = master.getZ() + Math.sin(angle) * distance; + + // FIX: Use safe ground finder instead of heightmap (works indoors) + int groundY = findSafeGroundY( + master.level(), + (int) x, + (int) z, + (int) master.getY() + 10 + ); + double y = groundY; + + walkTarget = new Vec3(x, y, z); + + TiedUpMod.LOGGER.debug( + "[MasterDogwalkGoal] {} picked new walk target: ({}, {}, {})", + master.getNpcName(), + (int) x, + (int) y, + (int) z + ); + } + + /** + * Find a safe ground Y position by scanning downward. + * Works correctly indoors (doesn't return roof height like heightmap). + * + * @param level The level + * @param x Target X coordinate + * @param z Target Z coordinate + * @param startY Starting Y to scan from (usually entity Y + some offset) + * @return A safe Y position with solid ground below and 2 air blocks above + */ + private int findSafeGroundY(Level level, int x, int z, int startY) { + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos( + x, + startY, + z + ); + + // Scan downward to find a solid floor with 2 air blocks above + for (int y = startY; y > level.getMinBuildHeight(); y--) { + pos.setY(y); + BlockState below = level.getBlockState(pos.below()); + BlockState at = level.getBlockState(pos); + BlockState above = level.getBlockState(pos.above()); + + // Solid floor + 2 blocks of clearance above + if (below.isSolid() && !at.isSolid() && !above.isSolid()) { + return y; + } + } + return startY; // Fallback if nothing found + } + + /** + * Tick when Master follows the pet. + * Uses progressive speed ramp: slow when close, faster to catch up. + */ + private void tickMasterFollows(ServerPlayer pet, double dist) { + if (dist > LEASH_DISTANCE_PET_LEADS) { + // Speed ramp: slow when close, faster to catch up + double speed; + if (dist > 6.0) { + speed = FOLLOW_CATCHUP_SPEED; // 0.9 - catching up + } else { + speed = FOLLOW_CLOSE_SPEED; // 0.5 - gentle follow + } + master.getNavigation().moveTo(pet, speed); + } else if (dist < FOLLOW_STOP_DISTANCE) { + master.getNavigation().stop(); + } + // Between FOLLOW_STOP_DISTANCE and LEASH_DISTANCE_PET_LEADS: + // keep current movement, don't snap + } + + /** + * FIX: Teleport pet to master when stuck too long. + */ + private void teleportPetToMaster(ServerPlayer pet) { + double angle = master.getRandom().nextDouble() * Math.PI * 2; + double distance = 1.5; + + double x = master.getX() + Math.cos(angle) * distance; + double z = master.getZ() + Math.sin(angle) * distance; + double y = master.getY(); + + pet.teleportTo(x, y, z); + + DialogueBridge.talkTo(master, pet, "petplay.come_here"); + + TiedUpMod.LOGGER.debug( + "[MasterDogwalkGoal] {} teleported stuck pet {} to master", + master.getNpcName(), + pet.getName().getString() + ); + } + + /** + * End the dogwalk and return to FOLLOWING state. + * Cleanup (remove dogbind, detach leash) happens automatically in stop(). + */ + public void endDogwalk() { + // Just change state - stop() will be called automatically and handle cleanup + master.setMasterState(MasterState.FOLLOWING); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/master/MasterFollowPlayerGoal.java b/src/main/java/com/tiedup/remake/entities/ai/master/MasterFollowPlayerGoal.java new file mode 100644 index 0000000..83fb742 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/master/MasterFollowPlayerGoal.java @@ -0,0 +1,302 @@ +package com.tiedup.remake.entities.ai.master; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.util.teleport.Position; +import com.tiedup.remake.util.teleport.TeleportHelper; +import java.util.EnumSet; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.state.BlockState; + +/** + * AI Goal for EntityMaster to follow their pet player. + * + * Unlike normal follower mechanics, the Master follows the player. + * Maintains a distance of 2-8 blocks from the player. + */ +public class MasterFollowPlayerGoal extends Goal { + + private final EntityMaster master; + + /** Minimum distance to maintain from pet */ + private static final double MIN_DISTANCE = 2.0; + + /** Ideal distance to maintain from pet (active following) */ + private static final double IDEAL_DISTANCE = 4.0; + + /** Maximum distance before starting to follow */ + private static final double MAX_DISTANCE = 8.0; + + /** Distance at which to teleport (if too far) */ + private static final double TELEPORT_DISTANCE = 32.0; + + /** Navigation speed */ + private static final double FOLLOW_SPEED = 1.0; + + /** Path recalculation cooldown (ticks) */ + private static final int PATH_RECALC_COOLDOWN = 10; + + private int pathRecalcCooldown = 0; + private double targetX, targetY, targetZ; + + public MasterFollowPlayerGoal(EntityMaster master) { + this.master = master; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Must have a pet + if (!master.hasPet()) { + return false; + } + + // Must be in a following-compatible state + MasterState state = master.getStateManager().getCurrentState(); + if ( + state != MasterState.FOLLOWING && + state != MasterState.OBSERVING && + state != MasterState.DISTRACTED + ) { + return false; + } + + // Pet must be online + ServerPlayer pet = master.getPetPlayer(); + if (pet == null || !pet.isAlive()) { + return false; + } + + // FIX: Always activate in FOLLOWING state - distance is managed in tick() + // This fixes the bug where Master wouldn't move after buying player + return true; + } + + @Override + public boolean canContinueToUse() { + if (!master.hasPet()) { + return false; + } + + ServerPlayer pet = master.getPetPlayer(); + if (pet == null || !pet.isAlive()) { + return false; + } + + // Only continue in following-compatible states + MasterState state = master.getStateManager().getCurrentState(); + if ( + state != MasterState.FOLLOWING && + state != MasterState.OBSERVING && + state != MasterState.DISTRACTED + ) { + return false; + } + + return true; + } + + @Override + public void start() { + this.pathRecalcCooldown = 0; + + TiedUpMod.LOGGER.debug( + "[MasterFollowPlayerGoal] {} started following pet", + master.getNpcName() + ); + } + + @Override + public void stop() { + master.getNavigation().stop(); + + TiedUpMod.LOGGER.debug( + "[MasterFollowPlayerGoal] {} stopped following pet", + master.getNpcName() + ); + } + + @Override + public void tick() { + ServerPlayer pet = master.getPetPlayer(); + if (pet == null) return; + + // Check for dimension change - teleport to pet's dimension if different + if (!master.level().dimension().equals(pet.level().dimension())) { + teleportToPetDimension(pet); + return; + } + + // Look at pet + master.getLookControl().setLookAt(pet, 30.0F, 30.0F); + + double distSq = master.distanceToSqr(pet); + + // Teleport if too far (pet probably teleported within same dimension) + if (distSq > TELEPORT_DISTANCE * TELEPORT_DISTANCE) { + teleportNearPet(pet); + return; + } + + // Update path periodically + this.pathRecalcCooldown--; + if (this.pathRecalcCooldown <= 0) { + this.pathRecalcCooldown = PATH_RECALC_COOLDOWN; + + // FIX: Active following behavior + // Problem: If Master stops in "comfort zone" (2-8 blocks) and player + // is on leash, nobody moves = deadlock. + // Solution: Master actively follows, maintaining IDEAL_DISTANCE from pet. + + if (distSq < MIN_DISTANCE * MIN_DISTANCE) { + // Too close - stop and let pet have some space + master.getNavigation().stop(); + } else if (distSq > IDEAL_DISTANCE * IDEAL_DISTANCE) { + // Beyond ideal distance - follow the pet + master.getNavigation().moveTo(pet, FOLLOW_SPEED); + } else if (!master.getNavigation().isInProgress()) { + // At ideal distance and not moving - pick a random spot near pet + // This creates natural wandering behavior around the pet + wanderNearPet(pet); + } + } + } + + /** + * Wander to a random position near the pet. + * Creates natural movement behavior instead of standing still. + */ + private void wanderNearPet(ServerPlayer pet) { + // Pick a random angle and position around the pet + double angle = master.getRandom().nextDouble() * Math.PI * 2; + double distance = + IDEAL_DISTANCE + (master.getRandom().nextDouble() - 0.5) * 2; + + double x = pet.getX() + Math.cos(angle) * distance; + double z = pet.getZ() + Math.sin(angle) * distance; + + // FIX: Use safe ground finder instead of heightmap (works indoors) + int groundY = findSafeGroundY( + pet.level(), + (int) x, + (int) z, + (int) pet.getY() + 10 + ); + double y = groundY; + + // Move to the spot at slower speed (wandering, not chasing) + master.getNavigation().moveTo(x, y, z, FOLLOW_SPEED * 0.6); + } + + /** + * Teleport master near pet when too far (same dimension). + */ + private void teleportNearPet(ServerPlayer pet) { + double angle = master.getRandom().nextDouble() * Math.PI * 2; + double distance = MIN_DISTANCE + master.getRandom().nextDouble() * 2; + + double x = pet.getX() + Math.cos(angle) * distance; + double z = pet.getZ() + Math.sin(angle) * distance; + + // FIX: Use safe ground finder instead of heightmap (works indoors) + int groundY = findSafeGroundY( + pet.level(), + (int) x, + (int) z, + (int) pet.getY() + 10 + ); + double y = groundY; + + master.teleportTo(x, y, z); + + TiedUpMod.LOGGER.debug( + "[MasterFollowPlayerGoal] {} teleported near pet {}", + master.getNpcName(), + pet.getName().getString() + ); + } + + /** + * Teleport master to pet's dimension. + * Used when pet changes dimension (nether, end, etc.) + */ + private void teleportToPetDimension(ServerPlayer pet) { + // Calculate position near pet + double angle = master.getRandom().nextDouble() * Math.PI * 2; + double distance = MIN_DISTANCE + master.getRandom().nextDouble() * 2; + + double x = pet.getX() + Math.cos(angle) * distance; + double z = pet.getZ() + Math.sin(angle) * distance; + + // FIX: Use safe ground finder instead of heightmap (works indoors) + int groundY = findSafeGroundY( + pet.level(), + (int) x, + (int) z, + (int) pet.getY() + 10 + ); + double y = groundY; + + // Create position with dimension info + Position targetPos = new Position( + x, + y, + z, + master.getYRot(), + master.getXRot(), + pet.level().dimension() + ); + + TiedUpMod.LOGGER.debug( + "[MasterFollowPlayerGoal] {} teleporting to pet's dimension: {} -> {}", + master.getNpcName(), + master.level().dimension().location(), + pet.level().dimension().location() + ); + + // Use TeleportHelper for cross-dimension teleportation + TeleportHelper.teleportEntity(master, targetPos); + + TiedUpMod.LOGGER.debug( + "[MasterFollowPlayerGoal] {} arrived in {} near pet {}", + master.getNpcName(), + pet.level().dimension().location(), + pet.getName().getString() + ); + } + + /** + * Find a safe ground Y position by scanning downward. + * Works correctly indoors (doesn't return roof height like heightmap). + * + * @param level The level + * @param x Target X coordinate + * @param z Target Z coordinate + * @param startY Starting Y to scan from (usually entity Y + some offset) + * @return A safe Y position with solid ground below and 2 air blocks above + */ + private int findSafeGroundY(Level level, int x, int z, int startY) { + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos( + x, + startY, + z + ); + + // Scan downward to find a solid floor with 2 air blocks above + for (int y = startY; y > level.getMinBuildHeight(); y--) { + pos.setY(y); + BlockState below = level.getBlockState(pos.below()); + BlockState at = level.getBlockState(pos); + BlockState above = level.getBlockState(pos.above()); + + // Solid floor + 2 blocks of clearance above + if (below.isSolid() && !at.isSolid() && !above.isSolid()) { + return y; + } + } + return startY; // Fallback if nothing found + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/master/MasterHumanChairGoal.java b/src/main/java/com/tiedup/remake/entities/ai/master/MasterHumanChairGoal.java new file mode 100644 index 0000000..03c6a5a --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/master/MasterHumanChairGoal.java @@ -0,0 +1,488 @@ +package com.tiedup.remake.entities.ai.master; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.dialogue.DialogueBridge; +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.BindVariant; +import com.tiedup.remake.state.HumanChairHelper; +import com.tiedup.remake.state.PlayerBindState; +import java.util.EnumSet; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.AABB; + +/** + * AI Goal for EntityMaster to use the pet as human furniture. + * + * The pet is forced on all fours (dogbind pose without visible restraint), + * frozen in place, while the Master sits on them. + * Lasts ~2 minutes. During this time the Master does idle behaviors: + * looks around, comments, observes nearby entities. + * + * Flow: + * 1. APPROACHING: Master walks to pet + * 2. SETTLING: Master positions on pet, applies pose + freeze + * 3. SITTING: Main phase - Master sits, does idle stuff + * 4. GETTING_UP: Master stands, cleanup + */ +public class MasterHumanChairGoal extends Goal { + + private final EntityMaster master; + + /** Duration of the sitting phase (ticks) - ~2 minutes */ + private static final int SITTING_DURATION = 2400; + + /** Time to settle into position (ticks) */ + private static final int SETTLE_DURATION = 40; + + /** Approach distance to start settling */ + private static final double APPROACH_DISTANCE = 1.5; + + /** Interval between idle comments (ticks) */ + private static final int IDLE_COMMENT_INTERVAL = 400; // 20 seconds + + /** Y offset to place the master on the pet's back (compensates sitting animation lowering ~0.25 blocks) */ + private static final double PET_BACK_Y_OFFSET = 0.55; + + /** NBT tag to mark the temporary chair dogbind */ + private static final String NBT_HUMAN_CHAIR_BIND = HumanChairHelper.NBT_KEY; + + private enum Phase { + APPROACHING, + SETTLING, + SITTING, + GETTING_UP, + } + + private Phase phase = Phase.APPROACHING; + private int phaseTimer = 0; + private int totalTimer = 0; + private int lastCommentTime = 0; + private boolean hasAppliedPose = false; + /** Pet facing direction locked at pose start — decoupled from camera */ + private float lockedPetFacing = 0f; + + /** Persistent look target — refreshed every tick to prevent vanilla LookControl snap-back */ + private double lastLookX, lastLookY, lastLookZ; + + public MasterHumanChairGoal(EntityMaster master) { + this.master = master; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + return ( + master.hasPet() && + master.getPetPlayer() != null && + master.getStateManager().getCurrentState() == + MasterState.HUMAN_CHAIR + ); + } + + @Override + public boolean canContinueToUse() { + ServerPlayer pet = master.getPetPlayer(); + return ( + pet != null && + pet.isAlive() && + master.getStateManager().getCurrentState() == + MasterState.HUMAN_CHAIR && + phase != Phase.GETTING_UP + ); + } + + @Override + public void start() { + this.phase = Phase.APPROACHING; + this.phaseTimer = 0; + this.totalTimer = 0; + this.lastCommentTime = 0; + this.hasAppliedPose = false; + + TiedUpMod.LOGGER.debug( + "[MasterHumanChairGoal] {} starting human chair", + master.getNpcName() + ); + } + + @Override + public void stop() { + // Cleanup: remove pose, effects, and standing up + cleanupPetPose(); + + // Master stands up + master.setSitting(false); + master.setNoGravity(false); + master.noPhysics = false; + // Return to following + if (master.hasPet()) { + master.setMasterState(MasterState.FOLLOWING); + } + + this.phase = Phase.APPROACHING; + this.phaseTimer = 0; + this.totalTimer = 0; + this.hasAppliedPose = false; + + TiedUpMod.LOGGER.debug( + "[MasterHumanChairGoal] {} ended human chair", + master.getNpcName() + ); + } + + @Override + public void tick() { + ServerPlayer pet = master.getPetPlayer(); + if (pet == null) return; + + totalTimer++; + + switch (phase) { + case APPROACHING -> tickApproaching(pet); + case SETTLING -> tickSettling(pet); + case SITTING -> tickSitting(pet); + case GETTING_UP -> { + } // handled by canContinueToUse returning false + } + } + + // ======================================== + // PHASE: APPROACHING + // ======================================== + + private void tickApproaching(ServerPlayer pet) { + master.getLookControl().setLookAt(pet, 30.0F, 30.0F); + + double distSq = master.distanceToSqr(pet); + if (distSq > APPROACH_DISTANCE * APPROACH_DISTANCE) { + master.getNavigation().moveTo(pet, 1.0); + } else { + // Close enough - start settling + master.getNavigation().stop(); + phase = Phase.SETTLING; + phaseTimer = 0; + + // Tell pet what's about to happen + DialogueBridge.talkTo(master, pet, "petplay.human_chair_command"); + } + } + + // ======================================== + // PHASE: SETTLING + // ======================================== + + private void tickSettling(ServerPlayer pet) { + master.getLookControl().setLookAt(pet, 30.0F, 30.0F); + master.getNavigation().stop(); + + // Apply the pose on first tick of settling + if (!hasAppliedPose) { + lockedPetFacing = pet.getYRot(); + applyPetPose(pet); + hasAppliedPose = true; + } + + phaseTimer++; + + if (phaseTimer >= SETTLE_DURATION) { + // Settled - Master sits down + master.setSitting(true); + master.setNoGravity(true); + master.noPhysics = true; + + // Position master centered on pet's back + positionOnPet(pet); + + // Initialize look target: forward along sideways direction + float sideYaw = lockedPetFacing + 90f; + float sideRad = (float) Math.toRadians(sideYaw); + lastLookX = master.getX() + (-Math.sin(sideRad)) * 5; + lastLookY = master.getEyeY(); + lastLookZ = master.getZ() + Math.cos(sideRad) * 5; + + phase = Phase.SITTING; + phaseTimer = 0; + lastCommentTime = totalTimer; + + DialogueBridge.talkTo(master, pet, "petplay.human_chair_sitting"); + + TiedUpMod.LOGGER.debug( + "[MasterHumanChairGoal] {} settled on {}", + master.getNpcName(), + pet.getName().getString() + ); + } + } + + // ======================================== + // PHASE: SITTING + // ======================================== + + private void tickSitting(ServerPlayer pet) { + phaseTimer++; + + // Force master position on pet every tick to prevent drifting + positionOnPet(pet); + + // Refresh slowness to keep pet frozen + if (phaseTimer % 60 == 0) { + pet.addEffect( + new MobEffectInstance( + MobEffects.MOVEMENT_SLOWDOWN, + 80, + 255, + false, + false, + false + ) + ); + pet.addEffect( + new MobEffectInstance( + MobEffects.JUMP, + 80, + 128, + false, + false, + false + ) + ); + } + + // Idle behaviors during sitting (may update lastLook target) + tickIdleBehavior(pet); + + // Refresh look target every tick to prevent vanilla LookControl snap-back + // (vanilla lookAtCooldown = 2 → target expires after 2 ticks → head snaps to center) + master + .getLookControl() + .setLookAt(lastLookX, lastLookY, lastLookZ, 10f, 10f); + + // Check if duration expired + if (phaseTimer >= SITTING_DURATION) { + phase = Phase.GETTING_UP; + + DialogueBridge.talkTo(master, pet, "petplay.human_chair_end"); + + TiedUpMod.LOGGER.debug( + "[MasterHumanChairGoal] {} getting up from {}", + master.getNpcName(), + pet.getName().getString() + ); + } + } + + // ======================================== + // IDLE BEHAVIORS DURING SITTING + // ======================================== + + private void tickIdleBehavior(ServerPlayer pet) { + // Periodically look at nearby entities or comment + if (totalTimer - lastCommentTime >= IDLE_COMMENT_INTERVAL) { + lastCommentTime = totalTimer; + + // 50% chance: look at a nearby entity, 50% chance: idle comment + if (master.getRandom().nextFloat() < 0.5f) { + lookAtNearbyEntity(pet); + } else { + // Idle comment about sitting/pet + String[] idleDialogues = { + "petplay.human_chair_idle", + "idle.content", + "idle.observing", + }; + String dialogue = idleDialogues[master + .getRandom() + .nextInt(idleDialogues.length)]; + DialogueBridge.talkTo(master, pet, dialogue); + } + } + + // Between comments, slowly look around + if (phaseTimer % 100 == 0) { + float randomYaw = + master.getYRot() + + (master.getRandom().nextFloat() - 0.5f) * 120; + lastLookX = master.getX() + Math.sin(Math.toRadians(randomYaw)) * 5; + lastLookY = master.getEyeY(); + lastLookZ = master.getZ() + Math.cos(Math.toRadians(randomYaw)) * 5; + } + } + + /** + * Look at a random nearby living entity (NPC, player, mob). + */ + private void lookAtNearbyEntity(ServerPlayer pet) { + AABB searchBox = master.getBoundingBox().inflate(12.0); + var nearbyEntities = master + .level() + .getEntitiesOfClass( + LivingEntity.class, + searchBox, + e -> e != master && e != pet && e.isAlive() + ); + + if (!nearbyEntities.isEmpty()) { + LivingEntity target = nearbyEntities.get( + master.getRandom().nextInt(nearbyEntities.size()) + ); + lastLookX = target.getX(); + lastLookY = target.getEyeY(); + lastLookZ = target.getZ(); + + // Comment about the entity + if (target instanceof Player) { + DialogueBridge.talkTo( + master, + pet, + "petplay.human_chair_notice_player" + ); + } else { + DialogueBridge.talkTo( + master, + pet, + "petplay.human_chair_notice_entity" + ); + } + } + } + + // ======================================== + // POSITIONING + // ======================================== + + /** + * Position master on pet's back, facing sideways (perpendicular). + * Called every tick during SITTING to prevent any drift from collision/physics. + * Body rotation is locked so only the head turns when looking at entities. + */ + private void positionOnPet(ServerPlayer pet) { + // Use locked facing — decoupled from player camera + float petYaw = (float) Math.toRadians(lockedPetFacing); + double offsetX = -Math.sin(petYaw) * 0.15; + double offsetZ = Math.cos(petYaw) * 0.15; + + // Face perpendicular to the pet (sitting sideways) + float sideYaw = lockedPetFacing + 90f; + + master.moveTo( + pet.getX() + offsetX, + pet.getY() + PET_BACK_Y_OFFSET, + pet.getZ() + offsetZ, + sideYaw, + 0.0F + ); + + // Lock body rotation so look-at only turns the head + master.yBodyRot = sideYaw; + master.yBodyRotO = sideYaw; + + // Zero out velocity to prevent physics drift + master.setDeltaMovement(0, 0, 0); + } + + // ======================================== + // POSE MANAGEMENT + // ======================================== + + /** + * Apply the human chair pose to the pet: + * - Temporary invisible dogbind for the on-all-fours animation + * - Slowness 255 to freeze movement + * - Jump boost negative to prevent jumping + */ + private void applyPetPose(ServerPlayer pet) { + PlayerBindState bindState = PlayerBindState.getInstance(pet); + if (bindState == null) return; + + // Apply invisible dogbind for the pose animation + if (!bindState.isTiedUp()) { + ItemStack dogbind = new ItemStack( + ModItems.getBind(BindVariant.DOGBINDER) + ); + CompoundTag tag = dogbind.getOrCreateTag(); + tag.putBoolean(NBT_HUMAN_CHAIR_BIND, true); + tag.putBoolean("tempMasterEvent", true); + tag.putLong( + "expirationTime", + master.level().getGameTime() + SITTING_DURATION + 200 + ); + tag.putUUID("masterUUID", master.getUUID()); + tag.putFloat(HumanChairHelper.NBT_FACING_KEY, pet.getYRot()); + bindState.equip(BodyRegionV2.ARMS, dogbind); + } + + // Freeze the pet + pet.addEffect( + new MobEffectInstance( + MobEffects.MOVEMENT_SLOWDOWN, + SITTING_DURATION + 100, + 255, + false, + false, + false + ) + ); + pet.addEffect( + new MobEffectInstance( + MobEffects.JUMP, + SITTING_DURATION + 100, + 128, + false, + false, + false + ) + ); + + TiedUpMod.LOGGER.debug( + "[MasterHumanChairGoal] Applied chair pose to {}", + pet.getName().getString() + ); + } + + /** + * Remove the human chair pose from the pet. + */ + private void cleanupPetPose() { + ServerPlayer pet = master.getPetPlayer(); + if (pet == null) return; + + // Remove the temporary dogbind + PlayerBindState bindState = PlayerBindState.getInstance(pet); + if (bindState != null && bindState.isTiedUp()) { + ItemStack bind = bindState.getEquipment(BodyRegionV2.ARMS); + if (!bind.isEmpty()) { + CompoundTag tag = bind.getTag(); + if (tag != null && tag.getBoolean(NBT_HUMAN_CHAIR_BIND)) { + bindState.unequip(BodyRegionV2.ARMS); + TiedUpMod.LOGGER.debug( + "[MasterHumanChairGoal] Removed chair bind from {}", + pet.getName().getString() + ); + } + } + } + + // Remove slowness and jump effects + pet.removeEffect(MobEffects.MOVEMENT_SLOWDOWN); + pet.removeEffect(MobEffects.JUMP); + } + + /** + * Check if an item is a human chair temporary bind. + */ + public static boolean isHumanChairBind(ItemStack stack) { + if (stack.isEmpty()) return false; + CompoundTag tag = stack.getTag(); + return tag != null && tag.getBoolean(NBT_HUMAN_CHAIR_BIND); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/master/MasterHuntMonstersGoal.java b/src/main/java/com/tiedup/remake/entities/ai/master/MasterHuntMonstersGoal.java new file mode 100644 index 0000000..169ec2e --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/master/MasterHuntMonstersGoal.java @@ -0,0 +1,212 @@ +package com.tiedup.remake.entities.ai.master; + +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.items.ItemTaser; +import com.tiedup.remake.items.ModItems; +import java.util.EnumSet; +import java.util.List; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.monster.Creeper; +import net.minecraft.world.entity.monster.Monster; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.AABB; + +/** + * AI Goal for Masters to hunt and kill monsters near their pet. + * + * Purpose: Protect the pet player from hostile mobs + * + * This goal: + * 1. Only activates when master has a pet + * 2. Scans for monsters near the pet (not the master) + * 3. Equips taser and attacks with stun effects + * 4. Returns to pet after killing monster + * + * Combat bonuses vs monsters: + * - 2x damage dealt (masters are elite fighters) + * - Taser stun effects (Slowness + Weakness) + * - Creeper explosion cancelled on hit + * + * Priority: 1 (high - pet protection is paramount) + */ +public class MasterHuntMonstersGoal extends Goal { + + private final EntityMaster master; + private LivingEntity targetMonster; + + /** Scan radius around pet */ + private static final int SCAN_RADIUS = 12; + + /** Ticks between attacks */ + private static final int ATTACK_COOLDOWN = 15; + + /** Damage multiplier against monsters */ + private static final float MONSTER_DAMAGE_MULTIPLIER = 2.0f; + + /** Current attack cooldown timer */ + private int attackCooldown = 0; + + public MasterHuntMonstersGoal(EntityMaster master) { + this.master = master; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Must have a pet to protect + if (!master.hasPet()) { + return false; + } + + ServerPlayer pet = master.getPetPlayer(); + if (pet == null || !pet.isAlive()) { + return false; + } + + // Find monsters near the pet (not the master) + this.targetMonster = findNearestMonsterNearPet(pet); + return this.targetMonster != null; + } + + @Override + public boolean canContinueToUse() { + if (this.targetMonster == null || !this.targetMonster.isAlive()) { + return false; + } + + // Stop if pet is gone + if (!master.hasPet()) { + return false; + } + + // Stop if monster is too far from master (chase limit) + return master.distanceToSqr(this.targetMonster) < 400; // 20 blocks + } + + @Override + public void start() { + this.attackCooldown = 0; + + // Equip taser + this.master.setItemInHand( + InteractionHand.MAIN_HAND, + new ItemStack(ModItems.TASER.get()) + ); + } + + @Override + public void stop() { + this.targetMonster = null; + this.master.getNavigation().stop(); + + // Unequip taser + this.master.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); + } + + @Override + public void tick() { + if (this.targetMonster == null) { + return; + } + + // Look at the monster + this.master.getLookControl().setLookAt(this.targetMonster); + + double distSq = this.master.distanceToSqr(this.targetMonster); + + // Move closer if not in attack range + if (distSq > 4.0) { + this.master.getNavigation().moveTo(this.targetMonster, 1.3); + } else { + // In attack range - stop and attack + this.master.getNavigation().stop(); + + if (this.attackCooldown <= 0) { + attackMonsterWithBonus(this.targetMonster); + this.attackCooldown = ATTACK_COOLDOWN; + } + } + + // Decrement cooldown + if (this.attackCooldown > 0) { + this.attackCooldown--; + } + } + + /** + * Attack a monster with taser - bonus damage and stun effects. + * Masters deal 2x damage to monsters and apply taser stun. + * Creepers have their explosion cancelled. + * + * @param target The monster to attack + */ + private void attackMonsterWithBonus(LivingEntity target) { + // Swing arm animation + this.master.swing(InteractionHand.MAIN_HAND); + + // Get base attack damage and apply multiplier + float baseDamage = (float) this.master.getAttributeValue( + Attributes.ATTACK_DAMAGE + ); + float bonusDamage = baseDamage * MONSTER_DAMAGE_MULTIPLIER; + + // Deal damage directly with bonus + boolean damaged = target.hurt( + this.master.damageSources().mobAttack(this.master), + bonusDamage + ); + + // Apply taser effects if damage was dealt + if (damaged) { + ItemStack heldItem = this.master.getItemInHand( + InteractionHand.MAIN_HAND + ); + if (heldItem.getItem() instanceof ItemTaser taserItem) { + taserItem.hurtEnemy(heldItem, target, this.master); + } + + // Special: Cancel creeper explosion + if (target instanceof Creeper creeper) { + creeper.setSwellDir(-1); + } + } + } + + /** + * Find the nearest monster within scan radius of the pet. + * + * @param pet The pet player to protect + * @return The nearest monster, or null if none found + */ + private LivingEntity findNearestMonsterNearPet(ServerPlayer pet) { + AABB searchBox = pet.getBoundingBox().inflate(SCAN_RADIUS); + + List monsters = this.master.level().getEntitiesOfClass( + Monster.class, + searchBox, + m -> m.isAlive() && !m.isSpectator() + ); + + if (monsters.isEmpty()) { + return null; + } + + // Find nearest to pet + Monster nearest = null; + double nearestDist = Double.MAX_VALUE; + + for (Monster m : monsters) { + double dist = pet.distanceToSqr(m); + if (dist < nearestDist) { + nearestDist = dist; + nearest = m; + } + } + + return nearest; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/master/MasterIdleBehaviorGoal.java b/src/main/java/com/tiedup/remake/entities/ai/master/MasterIdleBehaviorGoal.java new file mode 100644 index 0000000..a3b325d --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/master/MasterIdleBehaviorGoal.java @@ -0,0 +1,293 @@ +package com.tiedup.remake.entities.ai.master; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.DialogueBridge; +import com.tiedup.remake.entities.EntityMaster; +import java.util.EnumSet; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.phys.Vec3; + +/** + * AI Goal for EntityMaster to perform idle micro-behaviors. + * + * Fills the gap between tasks/events with small, natural-looking actions. + * Lower priority than tasks/events/inspect, but higher than look-at goals. + */ +public class MasterIdleBehaviorGoal extends Goal { + + private final EntityMaster master; + + /** Chance to start idle behavior per tick */ + private static final float IDLE_CHANCE = 0.003f; // ~6% per 10 seconds + + /** Minimum time between idle behaviors (ticks) - 20 seconds */ + private static final int IDLE_COOLDOWN = 400; + + /** Distance to approach pet for close behaviors */ + private static final double APPROACH_DISTANCE = 2.0; + + /** Distance to walk away for CHECK_SURROUNDINGS */ + private static final double SCOUT_DISTANCE = 8.0; + + /** + * Types of idle micro-behaviors. + */ + private enum IdleBehavior { + LOOK_AROUND(60), // 3s - random look angles + EXAMINE_PET(80), // 4s - walk to pet, stare + PAT_HEAD(60), // 3s - approach, pat, step back + ADJUST_COLLAR(60), // 3s - approach, adjust collar dialogue + IDLE_COMMENT(20), // 1s - random idle comment + STRETCH(40), // 2s - stop, do nothing + CHECK_SURROUNDINGS(100); // 5s - walk away, look around, return + + final int duration; + + IdleBehavior(int duration) { + this.duration = duration; + } + } + + private long lastIdleTime = 0; + private IdleBehavior currentBehavior = null; + private int behaviorTimer = 0; + private boolean hasPerformedAction = false; + + /** For CHECK_SURROUNDINGS: the position to walk to */ + private Vec3 scoutTarget = null; + /** For CHECK_SURROUNDINGS: whether we've reached the scout target */ + private boolean reachedScoutTarget = false; + + public MasterIdleBehaviorGoal(EntityMaster master) { + this.master = master; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (!master.hasPet()) return false; + + MasterState state = master.getStateManager().getCurrentState(); + + // Only idle behaviors from FOLLOWING or OBSERVING + if (state != MasterState.FOLLOWING && state != MasterState.OBSERVING) { + return false; + } + + // No idle behaviors if there's an active task + if (master.hasActiveTask()) return false; + + // No idle behaviors during cold shoulder + if (master.isGivingColdShoulder()) return false; + + // Check cooldown + long currentTime = master.level().getGameTime(); + if (currentTime - lastIdleTime < IDLE_COOLDOWN) { + return false; + } + + // Random chance (modulated by engagement cadence) + float multiplier = master.getEngagementMultiplier(); + return ( + multiplier > 0 && + master.getRandom().nextFloat() < IDLE_CHANCE * multiplier + ); + } + + @Override + public boolean canContinueToUse() { + if (!master.hasPet()) return false; + if (currentBehavior == null) return false; + if (behaviorTimer >= currentBehavior.duration) return false; + + MasterState state = master.getStateManager().getCurrentState(); + return state == MasterState.FOLLOWING || state == MasterState.OBSERVING; + } + + @Override + public void start() { + this.behaviorTimer = 0; + this.hasPerformedAction = false; + this.scoutTarget = null; + this.reachedScoutTarget = false; + + // Select random idle behavior + IdleBehavior[] behaviors = IdleBehavior.values(); + this.currentBehavior = behaviors[master + .getRandom() + .nextInt(behaviors.length)]; + + TiedUpMod.LOGGER.debug( + "[MasterIdleBehaviorGoal] {} starting idle: {}", + master.getNpcName(), + currentBehavior + ); + } + + @Override + public void stop() { + lastIdleTime = master.level().getGameTime(); + master.markEngagement(); + + this.currentBehavior = null; + this.behaviorTimer = 0; + this.hasPerformedAction = false; + this.scoutTarget = null; + this.reachedScoutTarget = false; + + master.getNavigation().stop(); + } + + @Override + public void tick() { + ServerPlayer pet = master.getPetPlayer(); + if (pet == null) return; + + behaviorTimer++; + + switch (currentBehavior) { + case LOOK_AROUND -> tickLookAround(); + case EXAMINE_PET -> tickExaminePet(pet); + case PAT_HEAD -> tickPatHead(pet); + case ADJUST_COLLAR -> tickAdjustCollar(pet); + case IDLE_COMMENT -> tickIdleComment(pet); + case STRETCH -> tickStretch(); + case CHECK_SURROUNDINGS -> tickCheckSurroundings(pet); + } + } + + private void tickLookAround() { + // Randomly change look direction every 20 ticks + if (behaviorTimer % 20 == 1) { + float yaw = + master.getYRot() + + (master.getRandom().nextFloat() - 0.5f) * 120; + float pitch = (master.getRandom().nextFloat() - 0.5f) * 40; + master + .getLookControl() + .setLookAt( + master.getX() + Math.sin(Math.toRadians(-yaw)) * 5, + master.getEyeY() + pitch * 0.1, + master.getZ() + Math.cos(Math.toRadians(-yaw)) * 5 + ); + } + master.getNavigation().stop(); + } + + private void tickExaminePet(ServerPlayer pet) { + double dist = master.distanceTo(pet); + if (dist > APPROACH_DISTANCE) { + master.getNavigation().moveTo(pet, 0.8); + } else { + master.getNavigation().stop(); + master.getLookControl().setLookAt(pet, 30.0F, 30.0F); + + if (!hasPerformedAction) { + DialogueBridge.talkTo(master, pet, "idle.examine"); + hasPerformedAction = true; + } + } + } + + private void tickPatHead(ServerPlayer pet) { + double dist = master.distanceTo(pet); + if (dist > APPROACH_DISTANCE) { + master.getNavigation().moveTo(pet, 0.8); + } else { + master.getNavigation().stop(); + master.getLookControl().setLookAt(pet, 30.0F, 30.0F); + + if (!hasPerformedAction) { + DialogueBridge.talkTo(master, pet, "idle.pat_head"); + hasPerformedAction = true; + } + } + } + + private void tickAdjustCollar(ServerPlayer pet) { + double dist = master.distanceTo(pet); + if (dist > APPROACH_DISTANCE) { + master.getNavigation().moveTo(pet, 0.8); + } else { + master.getNavigation().stop(); + master.getLookControl().setLookAt(pet, 30.0F, 30.0F); + + if (!hasPerformedAction) { + DialogueBridge.talkTo(master, pet, "idle.adjust_collar"); + hasPerformedAction = true; + } + } + } + + private void tickIdleComment(ServerPlayer pet) { + master.getNavigation().stop(); + if (!hasPerformedAction) { + // Pick randomly between existing idle dialogues + String dialogueId = master.getRandom().nextBoolean() + ? "idle.content" + : "idle.bored"; + DialogueBridge.talkTo(master, pet, dialogueId); + hasPerformedAction = true; + } + } + + private void tickStretch() { + // Do nothing - natural pause + master.getNavigation().stop(); + } + + private void tickCheckSurroundings(ServerPlayer pet) { + if (scoutTarget == null) { + // Pick a random direction to walk + double angle = master.getRandom().nextDouble() * Math.PI * 2; + double dist = 5 + master.getRandom().nextDouble() * 3; // 5-8 blocks + scoutTarget = new Vec3( + master.getX() + Math.cos(angle) * dist, + master.getY(), + master.getZ() + Math.sin(angle) * dist + ); + } + + if (!reachedScoutTarget) { + // Walk to scout target + master + .getNavigation() + .moveTo(scoutTarget.x, scoutTarget.y, scoutTarget.z, 0.8); + + // Check if we arrived or enough time passed + double distToTarget = master.position().distanceTo(scoutTarget); + if ( + distToTarget < 2.0 || + behaviorTimer > currentBehavior.duration / 2 + ) { + reachedScoutTarget = true; + master.getNavigation().stop(); + + if (!hasPerformedAction) { + DialogueBridge.talkTo( + master, + pet, + "idle.check_surroundings" + ); + hasPerformedAction = true; + } + } + } else { + // Look around at scout target, then return is handled by goal ending + // (follow goal will bring master back to pet) + if (behaviorTimer % 20 == 0) { + float yaw = + master.getYRot() + + (master.getRandom().nextFloat() - 0.5f) * 180; + master + .getLookControl() + .setLookAt( + master.getX() + Math.sin(Math.toRadians(-yaw)) * 5, + master.getEyeY(), + master.getZ() + Math.cos(Math.toRadians(-yaw)) * 5 + ); + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/master/MasterInventoryInspectGoal.java b/src/main/java/com/tiedup/remake/entities/ai/master/MasterInventoryInspectGoal.java new file mode 100644 index 0000000..4f8e6df --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/master/MasterInventoryInspectGoal.java @@ -0,0 +1,249 @@ +package com.tiedup.remake.entities.ai.master; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityMaster; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.tags.TagKey; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +/** + * AI Goal for EntityMaster to inspect pet's inventory for contraband. + * + * Contraband items: + * - Lockpicks, knives, shears + * - Weapons (swords, axes) + * - Items tagged with tiedup:escape_tool or tiedup:weapon + * + * Behavior: + * 1. Periodically checks inventory (random chance) + * 2. Confiscates contraband items + * 3. Punishes if contraband found + */ +public class MasterInventoryInspectGoal extends Goal { + + private final EntityMaster master; + + /** Chance to start inspection when following (per tick) */ + private static final float INSPECT_CHANCE = 0.002f; // ~4% per 10 seconds + + /** Minimum time between inspections (ticks) - 2.5 minutes */ + private static final int INSPECT_COOLDOWN = 3000; + + /** Distance to inspect */ + private static final double INSPECT_DISTANCE = 2.0; + + /** Inspection duration (ticks) */ + private static final int INSPECT_DURATION = 40; // 2 seconds + + private int inspectTimer = 0; + private long lastInspectTime = 0; + private List confiscatedItems = new ArrayList<>(); + + /** Tag for escape tools */ + private static final TagKey ESCAPE_TOOL_TAG = TagKey.create( + Registries.ITEM, + ResourceLocation.fromNamespaceAndPath("tiedup", "escape_tool") + ); + + /** Tag for weapons */ + private static final TagKey WEAPON_TAG = TagKey.create( + Registries.ITEM, + ResourceLocation.fromNamespaceAndPath("tiedup", "weapon") + ); + + public MasterInventoryInspectGoal(EntityMaster master) { + this.master = master; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (!master.hasPet()) return false; + + MasterState state = master.getStateManager().getCurrentState(); + + // Already inspecting + if (state == MasterState.INSPECT) return true; + + // Can start new inspection + if (state != MasterState.FOLLOWING && state != MasterState.OBSERVING) { + return false; + } + + // Check cooldown + long currentTime = master.level().getGameTime(); + if (currentTime - lastInspectTime < INSPECT_COOLDOWN) { + return false; + } + + // Random chance to inspect (modulated by engagement cadence) + float multiplier = master.getEngagementMultiplier(); + return ( + multiplier > 0 && + master.getRandom().nextFloat() < INSPECT_CHANCE * multiplier + ); + } + + @Override + public boolean canContinueToUse() { + return ( + master.hasPet() && + master.getStateManager().getCurrentState() == MasterState.INSPECT && + inspectTimer < INSPECT_DURATION + ); + } + + @Override + public void start() { + this.inspectTimer = 0; + this.confiscatedItems.clear(); + master.setMasterState(MasterState.INSPECT); + master.markEngagement(); + + TiedUpMod.LOGGER.debug( + "[MasterInventoryInspectGoal] {} starting inspection", + master.getNpcName() + ); + } + + @Override + public void stop() { + lastInspectTime = master.level().getGameTime(); + + // Announce results + ServerPlayer pet = master.getPetPlayer(); + if (pet != null && !confiscatedItems.isEmpty()) { + pet.sendSystemMessage( + Component.literal( + master.getNpcName() + + " confiscated " + + confiscatedItems.size() + + " contraband item(s) from you!" + ).withStyle( + Style.EMPTY.withColor(EntityMaster.MASTER_NAME_COLOR) + ) + ); + + // Transition to punish state + master.setMasterState(MasterState.PUNISH); + } else if (master.hasPet()) { + // Clean inspection + master.setMasterState(MasterState.FOLLOWING); + } + + this.inspectTimer = 0; + this.confiscatedItems.clear(); + + TiedUpMod.LOGGER.debug( + "[MasterInventoryInspectGoal] {} inspection complete", + master.getNpcName() + ); + } + + @Override + public void tick() { + ServerPlayer pet = master.getPetPlayer(); + if (pet == null) return; + + // Look at pet + master.getLookControl().setLookAt(pet, 30.0F, 30.0F); + + double distSq = master.distanceToSqr(pet); + + if (distSq > INSPECT_DISTANCE * INSPECT_DISTANCE) { + // Move close + master.getNavigation().moveTo(pet, 1.0); + } else { + master.getNavigation().stop(); + + // Perform inspection on first tick when close + if (inspectTimer == 0) { + performInspection(pet); + } + } + + inspectTimer++; + } + + /** + * Inspect pet's inventory and confiscate contraband. + */ + private void performInspection(ServerPlayer pet) { + pet.sendSystemMessage( + Component.literal( + master.getNpcName() + " is inspecting your inventory..." + ).withStyle(Style.EMPTY.withColor(0xFFFF00)) + ); + + // Check all inventory slots + for (int i = 0; i < pet.getInventory().getContainerSize(); i++) { + ItemStack stack = pet.getInventory().getItem(i); + if (stack.isEmpty()) continue; + + if (isContraband(stack)) { + // Confiscate + confiscatedItems.add(stack.copy()); + pet.getInventory().setItem(i, ItemStack.EMPTY); + + TiedUpMod.LOGGER.debug( + "[MasterInventoryInspectGoal] {} confiscated {} from {}", + master.getNpcName(), + stack.getDisplayName().getString(), + pet.getName().getString() + ); + } + } + } + + /** + * Check if an item is contraband. + */ + private boolean isContraband(ItemStack stack) { + Item item = stack.getItem(); + + // Check tags + if (stack.is(ESCAPE_TOOL_TAG) || stack.is(WEAPON_TAG)) { + return true; + } + + // Check specific vanilla items + if ( + item == Items.SHEARS || + item == Items.IRON_SWORD || + item == Items.DIAMOND_SWORD || + item == Items.NETHERITE_SWORD || + item == Items.IRON_AXE || + item == Items.DIAMOND_AXE || + item == Items.NETHERITE_AXE || + item == Items.BOW || + item == Items.CROSSBOW + ) { + return true; + } + + // Check for mod items by registry name + ResourceLocation registryName = + net.minecraftforge.registries.ForgeRegistries.ITEMS.getKey(item); + if ( + registryName != null && registryName.getNamespace().equals("tiedup") + ) { + String path = registryName.getPath(); + // Lockpicks and knives are contraband + if (path.contains("lockpick") || path.contains("knife")) { + return true; + } + } + + return false; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/master/MasterObservePlayerGoal.java b/src/main/java/com/tiedup/remake/entities/ai/master/MasterObservePlayerGoal.java new file mode 100644 index 0000000..214660d --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/master/MasterObservePlayerGoal.java @@ -0,0 +1,114 @@ +package com.tiedup.remake.entities.ai.master; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityMaster; +import java.util.EnumSet; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.goal.Goal; + +/** + * AI Goal for EntityMaster to observe their pet player. + * + * In OBSERVING state, the Master watches the player intently. + * This state prevents escape attempts from succeeding. + * + * The Master periodically enters DISTRACTED state, creating + * windows where the player can attempt to struggle free. + */ +public class MasterObservePlayerGoal extends Goal { + + private final EntityMaster master; + + /** How long to observe before potentially doing something else (ticks) */ + private static final int OBSERVE_DURATION = 200; // 10 seconds + + /** Chance to transition to OBSERVING each tick when FOLLOWING */ + private static final float OBSERVE_CHANCE = 0.005f; // ~10% per 10 seconds + + private int observeTimer = 0; + + public MasterObservePlayerGoal(EntityMaster master) { + this.master = master; + this.setFlags(EnumSet.of(Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Must have a pet + if (!master.hasPet()) { + return false; + } + + MasterState state = master.getStateManager().getCurrentState(); + + // If already observing, continue + if (state == MasterState.OBSERVING) { + return true; + } + + // Random chance to start observing when following + if (state == MasterState.FOLLOWING) { + return master.getRandom().nextFloat() < OBSERVE_CHANCE; + } + + return false; + } + + @Override + public boolean canContinueToUse() { + if (!master.hasPet()) { + return false; + } + + ServerPlayer pet = master.getPetPlayer(); + if (pet == null || !pet.isAlive()) { + return false; + } + + // Continue observing until timer expires + return ( + observeTimer < OBSERVE_DURATION && + master.getStateManager().getCurrentState() == MasterState.OBSERVING + ); + } + + @Override + public void start() { + this.observeTimer = 0; + master.setMasterState(MasterState.OBSERVING); + + TiedUpMod.LOGGER.debug( + "[MasterObservePlayerGoal] {} started observing pet", + master.getNpcName() + ); + } + + @Override + public void stop() { + this.observeTimer = 0; + + // Return to following if still has pet + if ( + master.hasPet() && + master.getStateManager().getCurrentState() == MasterState.OBSERVING + ) { + master.setMasterState(MasterState.FOLLOWING); + } + + TiedUpMod.LOGGER.debug( + "[MasterObservePlayerGoal] {} stopped observing", + master.getNpcName() + ); + } + + @Override + public void tick() { + ServerPlayer pet = master.getPetPlayer(); + if (pet == null) return; + + // Stare at pet intensely + master.getLookControl().setLookAt(pet, 180.0F, 180.0F); + + observeTimer++; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/master/MasterPlaceBlockGoal.java b/src/main/java/com/tiedup/remake/entities/ai/master/MasterPlaceBlockGoal.java new file mode 100644 index 0000000..3f1136d --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/master/MasterPlaceBlockGoal.java @@ -0,0 +1,454 @@ +package com.tiedup.remake.entities.ai.master; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.DialogueBridge; +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.v2.V2Blocks; +import com.tiedup.remake.v2.blocks.PetBedBlock; +import com.tiedup.remake.v2.blocks.PetBowlBlock; +import com.tiedup.remake.v2.blocks.PetBowlBlockEntity; +import java.util.EnumSet; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; + +/** + * AI Goal for EntityMaster to place Bowl/Bed blocks for pet. + * + * This goal is triggered by specific pet needs: + * - Hungry: Place PET_BOWL, wait for pet to eat, then retrieve + * - Tired: Place PET_BED, wait for pet to rest, then retrieve + * + * The Master places blocks temporarily and retrieves them after use. + * Can be triggered automatically (pet hunger) or manually via menu. + * + * Uses V2 blocks: + * - Bowl: V2Blocks.PET_BOWL for pet feeding + * - Bed: V2Blocks.PET_BED for pet sleeping + */ +public class MasterPlaceBlockGoal extends Goal { + + private final EntityMaster master; + + /** Current action being performed */ + private PlaceAction currentAction = PlaceAction.NONE; + + /** Position where block was placed */ + private BlockPos placedBlockPos = null; + + /** Timer for current action */ + private int actionTimer = 0; + + /** Distance to place blocks from pet */ + private static final double PLACE_DISTANCE = 2.0; + + /** Time to wait for pet to use the block (ticks) */ + private static final int USE_WAIT_TIME = 600; // 30 seconds (increased from 10s) + + /** Time to wait before retrieving block (ticks) */ + private static final int RETRIEVE_DELAY = 100; // 5 seconds after use + + private enum PlaceAction { + NONE, + PLACING_BOWL, // Walking to place bowl + WAITING_EAT, // Waiting for pet to eat + PLACING_PET_BED, // Walking to place pet bed + WAITING_SLEEP, // Waiting for pet to rest + RETRIEVING, // Picking up blocks + } + + public MasterPlaceBlockGoal(EntityMaster master) { + this.master = master; + this.setFlags(EnumSet.of(Goal.Flag.MOVE)); + } + + @Override + public boolean canUse() { + // Must have a pet + if (!master.hasPet()) return false; + + // If an action is triggered manually (via triggerFeeding/triggerResting), always allow + if (currentAction != PlaceAction.NONE) return true; + + // For automatic triggers, must be in following state + MasterState state = master.getStateManager().getCurrentState(); + if (state != MasterState.FOLLOWING) return false; + + // Check pet needs (only trigger on actual needs) + ServerPlayer pet = master.getPetPlayer(); + if (pet == null) return false; + + // Check if pet is hungry (food level < 10) + if (pet.getFoodData().getFoodLevel() < 10) { + currentAction = PlaceAction.PLACING_BOWL; + return true; + } + + // Check if pet is tired (could check for sleep deprivation effect, etc.) + // For now, this is disabled - only triggered manually or via events + // if (isPetTired(pet)) { + // currentAction = PlaceAction.PLACING_PET_BED; + // return true; + // } + + return false; + } + + @Override + public boolean canContinueToUse() { + if (!master.hasPet()) return false; + return currentAction != PlaceAction.NONE; + } + + @Override + public void start() { + this.actionTimer = 0; + this.placedBlockPos = null; + + ServerPlayer pet = master.getPetPlayer(); + if (pet == null) return; + + // Announce intent based on action + switch (currentAction) { + case PLACING_BOWL -> { + DialogueBridge.talkTo(master, pet, "petplay.feeding"); + TiedUpMod.LOGGER.debug( + "[MasterPlaceBlockGoal] {} placing food bowl for {}", + master.getNpcName(), + pet.getName().getString() + ); + } + case PLACING_PET_BED -> { + DialogueBridge.talkTo(master, pet, "petplay.resting"); + TiedUpMod.LOGGER.debug( + "[MasterPlaceBlockGoal] {} placing pet bed for {}", + master.getNpcName(), + pet.getName().getString() + ); + } + default -> { + } + } + } + + @Override + public void stop() { + // Cleanup - retrieve any placed blocks + if ( + placedBlockPos != null && + master.level() instanceof ServerLevel serverLevel + ) { + serverLevel.setBlock( + placedBlockPos, + Blocks.AIR.defaultBlockState(), + 3 + ); + placedBlockPos = null; + } + + this.currentAction = PlaceAction.NONE; + this.actionTimer = 0; + } + + @Override + public void tick() { + ServerPlayer pet = master.getPetPlayer(); + if (pet == null) { + currentAction = PlaceAction.NONE; + return; + } + + actionTimer++; + + switch (currentAction) { + case PLACING_BOWL -> tickPlacingBowl(pet); + case WAITING_EAT -> tickWaitingEat(pet); + case PLACING_PET_BED -> tickPlacingPetBed(pet); + case WAITING_SLEEP -> tickWaitingSleep(pet); + case RETRIEVING -> tickRetrieving(); + default -> { + } + } + } + + private void tickPlacingBowl(ServerPlayer pet) { + // Move near pet + double distSq = master.distanceToSqr(pet); + if (distSq > PLACE_DISTANCE * PLACE_DISTANCE + 4) { + master.getNavigation().moveTo(pet, 1.0); + return; + } + + master.getNavigation().stop(); + + // Find placement position and place bowl (facing toward the pet) + BlockPos pos = findPlacementPos(pet); + Direction facing = Direction.fromYRot(master.getYRot()).getOpposite(); + BlockState bowlState = V2Blocks.PET_BOWL.get() + .defaultBlockState() + .setValue(PetBowlBlock.FACING, facing); + if (pos != null && placeBlock(pos, bowlState)) { + placedBlockPos = pos; + currentAction = PlaceAction.WAITING_EAT; + actionTimer = 0; + + // Fill the bowl with food (20 = full) + if (master.level() instanceof ServerLevel serverLevel) { + if ( + serverLevel.getBlockEntity(pos) instanceof + PetBowlBlockEntity bowl + ) { + bowl.fillBowl(20); + } + } + + // Tell pet to eat + DialogueBridge.talkTo(master, pet, "petplay.eat_command"); + + TiedUpMod.LOGGER.debug( + "[MasterPlaceBlockGoal] {} placed food bowl at {}", + master.getNpcName(), + pos + ); + } else { + // Can't place, abort + TiedUpMod.LOGGER.warn( + "[MasterPlaceBlockGoal] {} couldn't find place for bowl", + master.getNpcName() + ); + currentAction = PlaceAction.NONE; + } + } + + private void tickWaitingEat(ServerPlayer pet) { + // Look at pet + master.getLookControl().setLookAt(pet, 30.0F, 30.0F); + + // Check if pet has eaten (food level restored) + if (pet.getFoodData().getFoodLevel() >= 16) { + // Pet finished eating + DialogueBridge.talkTo(master, pet, "petplay.good_pet"); + currentAction = PlaceAction.RETRIEVING; + actionTimer = 0; + return; + } + + // Timeout - pet didn't eat + if (actionTimer > USE_WAIT_TIME) { + DialogueBridge.talkTo(master, pet, "petplay.disappointed"); + currentAction = PlaceAction.RETRIEVING; + actionTimer = 0; + } + } + + private void tickPlacingPetBed(ServerPlayer pet) { + // Move near pet + double distSq = master.distanceToSqr(pet); + if (distSq > PLACE_DISTANCE * PLACE_DISTANCE + 4) { + master.getNavigation().moveTo(pet, 1.0); + return; + } + + master.getNavigation().stop(); + + // Find placement position and place bed (facing toward the pet) + BlockPos pos = findPlacementPos(pet); + Direction facing = Direction.fromYRot(master.getYRot()).getOpposite(); + BlockState bedState = V2Blocks.PET_BED.get() + .defaultBlockState() + .setValue(PetBedBlock.FACING, facing); + if (pos != null && placeBlock(pos, bedState)) { + placedBlockPos = pos; + currentAction = PlaceAction.WAITING_SLEEP; + actionTimer = 0; + + // Tell pet to rest + DialogueBridge.talkTo(master, pet, "petplay.rest_command"); + + TiedUpMod.LOGGER.debug( + "[MasterPlaceBlockGoal] {} placed pet bed at {}", + master.getNpcName(), + pos + ); + } else { + // Can't place, abort + currentAction = PlaceAction.NONE; + } + } + + private void tickWaitingSleep(ServerPlayer pet) { + // Look at pet + master.getLookControl().setLookAt(pet, 30.0F, 30.0F); + + // FIX: Check if pet is currently sleeping + if (pet.isSleeping()) { + // Pet is sleeping - reset timer to let them sleep as long as they want + // Only start counting down after they wake up + actionTimer = 0; + return; + } + + // Pet is not sleeping (either hasn't started or has woken up) + // Timeout - retrieve pet bed after some time of NOT sleeping + if (actionTimer > USE_WAIT_TIME) { + DialogueBridge.talkTo(master, pet, "petplay.wake_up"); + currentAction = PlaceAction.RETRIEVING; + actionTimer = 0; + } + } + + private void tickRetrieving() { + if (actionTimer > RETRIEVE_DELAY) { + // FIX: Safety check - don't remove block if pet is still sleeping + ServerPlayer pet = master.getPetPlayer(); + if (pet != null && pet.isSleeping()) { + // Pet is still sleeping - wait for them to wake up + actionTimer = 0; // Reset timer + return; + } + + // Remove the placed block + if ( + placedBlockPos != null && + master.level() instanceof ServerLevel serverLevel + ) { + serverLevel.setBlock( + placedBlockPos, + Blocks.AIR.defaultBlockState(), + 3 + ); + + TiedUpMod.LOGGER.debug( + "[MasterPlaceBlockGoal] {} retrieved block at {}", + master.getNpcName(), + placedBlockPos + ); + + placedBlockPos = null; + } + + // Done with this action + currentAction = PlaceAction.NONE; + } + } + + /** + * Find a suitable position to place a block near the pet. + */ + private BlockPos findPlacementPos(ServerPlayer pet) { + BlockPos petPos = pet.blockPosition(); + + // Search for valid placement nearby + for (int dx = -2; dx <= 2; dx++) { + for (int dz = -2; dz <= 2; dz++) { + if (dx == 0 && dz == 0) continue; // Not on pet + + BlockPos pos = petPos.offset(dx, 0, dz); + if (isValidPlacement(pos)) { + return pos; + } + + // Try one block lower + BlockPos lower = pos.below(); + if (isValidPlacement(lower)) { + return lower; + } + } + } + + return null; + } + + /** + * Check if position is valid for block placement. + */ + private boolean isValidPlacement(BlockPos pos) { + BlockState below = master.level().getBlockState(pos.below()); + BlockState at = master.level().getBlockState(pos); + + return below.isSolidRender(master.level(), pos.below()) && at.isAir(); + } + + /** + * Place a block at the given position. + */ + private boolean placeBlock(BlockPos pos, BlockState state) { + if (!(master.level() instanceof ServerLevel serverLevel)) { + return false; + } + + serverLevel.setBlock(pos, state, 3); + return true; + } + + /** + * Manually trigger feeding action (called from events/commands). + */ + public void triggerFeeding() { + if (currentAction == PlaceAction.NONE && master.hasPet()) { + currentAction = PlaceAction.PLACING_BOWL; + } + } + + /** + * Manually trigger resting action (called from events/commands). + */ + public void triggerResting() { + if (currentAction == PlaceAction.NONE && master.hasPet()) { + currentAction = PlaceAction.PLACING_PET_BED; + } + } + + // ======================================== + // FIX: NBT PERSISTENCE FOR PLACED BLOCKS + // ======================================== + + /** + * Get the currently placed block position (for NBT saving). + */ + public BlockPos getPlacedBlockPos() { + return placedBlockPos; + } + + /** + * Set the placed block position (for NBT loading). + * Also cleans up the block if position is set on load. + */ + public void setPlacedBlockPos(BlockPos pos) { + this.placedBlockPos = pos; + } + + /** + * Cleanup orphaned blocks after entity load. + * Called from EntityMaster after loading NBT if a position was saved. + */ + public void cleanupOrphanedBlock() { + if ( + placedBlockPos != null && + master.level() instanceof ServerLevel serverLevel + ) { + BlockState state = serverLevel.getBlockState(placedBlockPos); + // Only remove if it's one of our V2 pet blocks + if ( + state.is(V2Blocks.PET_BOWL.get()) || + state.is(V2Blocks.PET_BED.get()) + ) { + serverLevel.setBlock( + placedBlockPos, + Blocks.AIR.defaultBlockState(), + 3 + ); + TiedUpMod.LOGGER.info( + "[MasterPlaceBlockGoal] {} cleaned up orphaned block at {}", + master.getNpcName(), + placedBlockPos + ); + } + placedBlockPos = null; + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/master/MasterPunishGoal.java b/src/main/java/com/tiedup/remake/entities/ai/master/MasterPunishGoal.java new file mode 100644 index 0000000..28c97db --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/master/MasterPunishGoal.java @@ -0,0 +1,555 @@ +package com.tiedup.remake.entities.ai.master; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.DialogueBridge; +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.items.ItemChokeCollar; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.BindVariant; +import com.tiedup.remake.items.base.BlindfoldVariant; +import com.tiedup.remake.items.base.GagVariant; +import com.tiedup.remake.items.base.MittensVariant; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageEquipment; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.Vec3; + +/** + * AI Goal for EntityMaster to punish their pet player. + * + * Triggered when: + * - Struggle attempt detected while master was watching + * - Contraband found during inspection + * - Task failure + * - Pet attacks the master (dual punishment: choke + physical restraint) + * + * Punishment types (selected from available options): + * - Choke collar activation (requires choke collar) + * - Temporary blindfold (requires empty blindfold slot) + * - Temporary gag (requires empty gag slot) + * - Temporary mittens (requires empty mittens slot) + * - Tighten restraints / apply armbinder (requires pet unbound) + * - Cold shoulder (always available) + * - Leash tug (always available) + */ +public class MasterPunishGoal extends Goal { + + private final EntityMaster master; + + /** Approach distance for punishment */ + private static final double PUNISH_DISTANCE = 2.0; + + /** Duration of punishment sequence (ticks) */ + private static final int PUNISH_DURATION = 80; + + /** Maximum choke duration before deactivating (ticks) - 3 seconds */ + private static final int MAX_CHOKE_TIME = 60; + + /** Duration of temporary punishment items (2 minutes) */ + private static final int TEMP_ITEM_DURATION = 2400; + + /** Fallback damage if no punishment method available */ + private static final float FALLBACK_DAMAGE = 2.0f; + + /** Delay between primary choke and secondary restraint (ticks) - 0.75s */ + private static final int SECONDARY_PUNISHMENT_DELAY = 15; + + /** Maximum punishment duration safety cap (ticks) - 8 seconds */ + private static final int MAX_PUNISH_DURATION = 160; + + /** NBT tags for temporary punishment items */ + private static final String NBT_TEMP_MASTER_EVENT = "tempMasterEvent"; + private static final String NBT_EXPIRATION_TIME = "expirationTime"; + + private int punishTimer = 0; + private boolean hasAppliedPunishment = false; + private PunishmentType selectedPunishment = null; + + /** Timer for tracking choke duration - 0 means not choking */ + private int chokeActiveTimer = 0; + + /** Reference to the active choke collar for deactivation */ + private ItemStack activeChokeCollar = ItemStack.EMPTY; + + /** Leash tug timer */ + private int leashTugTimer = 0; + + // --- Attack punishment (dual: choke + physical restraint) --- + + /** Whether this is an attack-triggered dual punishment */ + private boolean isAttackPunishment = false; + + /** The physical restraint to apply after the choke */ + private PunishmentType secondaryPunishment = null; + + /** Whether the secondary restraint has been applied */ + private boolean hasAppliedSecondary = false; + + /** Tick count when the primary punishment was applied (for delay timing) */ + private int primaryAppliedAtTick = -1; + + public MasterPunishGoal(EntityMaster master) { + this.master = master; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + return ( + master.getStateManager().getCurrentState() == MasterState.PUNISH && + master.hasPet() && + master.getPetPlayer() != null + ); + } + + @Override + public boolean canContinueToUse() { + if (master.getStateManager().getCurrentState() != MasterState.PUNISH) { + return false; + } + + // Extend duration if waiting for secondary punishment, with safety cap + if ( + isAttackPunishment && + !hasAppliedSecondary && + punishTimer < MAX_PUNISH_DURATION + ) { + return true; + } + + return punishTimer < PUNISH_DURATION; + } + + @Override + public void start() { + this.punishTimer = 0; + this.hasAppliedPunishment = false; + this.chokeActiveTimer = 0; + this.activeChokeCollar = ItemStack.EMPTY; + this.leashTugTimer = 0; + this.isAttackPunishment = false; + this.secondaryPunishment = null; + this.hasAppliedSecondary = false; + this.primaryAppliedAtTick = -1; + + // Check if this is an attack-triggered punishment (dual: choke + restraint) + if (master.consumeAttackPunishment()) { + this.isAttackPunishment = true; + this.selectedPunishment = PunishmentType.CHOKE_COLLAR; + this.secondaryPunishment = selectPhysicalRestraint(); + + TiedUpMod.LOGGER.debug( + "[MasterPunishGoal] {} starting ATTACK punishment: {} + {}", + master.getNpcName(), + selectedPunishment, + secondaryPunishment + ); + } else { + this.selectedPunishment = selectPunishment(); + + TiedUpMod.LOGGER.debug( + "[MasterPunishGoal] {} starting punishment: {}", + master.getNpcName(), + selectedPunishment + ); + } + + master.markEngagement(); + } + + @Override + public void stop() { + deactivateChoke(); + + this.punishTimer = 0; + this.hasAppliedPunishment = false; + this.chokeActiveTimer = 0; + this.activeChokeCollar = ItemStack.EMPTY; + this.leashTugTimer = 0; + this.selectedPunishment = null; + this.isAttackPunishment = false; + this.secondaryPunishment = null; + this.hasAppliedSecondary = false; + this.primaryAppliedAtTick = -1; + + if (master.hasPet()) { + master.setMasterState(MasterState.FOLLOWING); + } + + TiedUpMod.LOGGER.debug( + "[MasterPunishGoal] {} punishment complete", + master.getNpcName() + ); + } + + @Override + public void tick() { + ServerPlayer pet = master.getPetPlayer(); + if (pet == null) return; + + master.getLookControl().setLookAt(pet, 30.0F, 30.0F); + + double distSq = master.distanceToSqr(pet); + + // Move close for punishment + if (distSq > PUNISH_DISTANCE * PUNISH_DISTANCE) { + master.getNavigation().moveTo(pet, 1.2); + } else { + master.getNavigation().stop(); + + if (!hasAppliedPunishment) { + applyPunishment(pet); + hasAppliedPunishment = true; + primaryAppliedAtTick = punishTimer; + } + } + + // Apply secondary punishment after delay (attack punishment only) + if ( + isAttackPunishment && + !hasAppliedSecondary && + primaryAppliedAtTick >= 0 && + punishTimer >= primaryAppliedAtTick + SECONDARY_PUNISHMENT_DELAY + ) { + applySecondaryPunishment(pet); + hasAppliedSecondary = true; + } + + // Track choke duration + if (chokeActiveTimer > 0) { + chokeActiveTimer++; + if (chokeActiveTimer >= MAX_CHOKE_TIME) { + deactivateChoke(); + } + } + + // Track leash tug + if (leashTugTimer > 0) { + leashTugTimer--; + if (leashTugTimer <= 0) { + master.detachLeashFromPet(); + } + } + + punishTimer++; + } + + /** + * Select a punishment type from available options (normal punishments). + */ + private PunishmentType selectPunishment() { + // Check for forced punishment (e.g., from other systems) + PunishmentType forced = master.consumeForcedPunishment(); + if (forced != null) return forced; + + ServerPlayer pet = master.getPetPlayer(); + if (pet == null) return PunishmentType.COLD_SHOULDER; + + PlayerBindState bindState = PlayerBindState.getInstance(pet); + List available = new ArrayList<>(); + + // CHOKE: only if pet has choke collar + if (bindState != null && bindState.hasCollar()) { + ItemStack collar = bindState.getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemChokeCollar) { + available.add(PunishmentType.CHOKE_COLLAR); + } + } + + // BLINDFOLD: only if eyes region is empty + if (!V2EquipmentHelper.isRegionOccupied(pet, BodyRegionV2.EYES)) { + available.add(PunishmentType.BLINDFOLD); + } + + // GAG: only if mouth region is empty + if (!V2EquipmentHelper.isRegionOccupied(pet, BodyRegionV2.MOUTH)) { + available.add(PunishmentType.GAG); + } + + // MITTENS: only if hands region is empty + if (!V2EquipmentHelper.isRegionOccupied(pet, BodyRegionV2.HANDS)) { + available.add(PunishmentType.MITTENS); + } + + // TIGHTEN: only if pet is not already bound + if (!V2EquipmentHelper.isRegionOccupied(pet, BodyRegionV2.ARMS)) { + available.add(PunishmentType.TIGHTEN_RESTRAINTS); + } + + // COLD_SHOULDER and LEASH_TUG: always available + available.add(PunishmentType.COLD_SHOULDER); + available.add(PunishmentType.LEASH_TUG); + + return available.get(master.getRandom().nextInt(available.size())); + } + + /** + * Select a physical restraint for attack punishment secondary. + * Excludes CHOKE_COLLAR (already primary) and COLD_SHOULDER (not physical). + * Always includes LEASH_TUG as fallback when all equipment slots are full. + */ + private PunishmentType selectPhysicalRestraint() { + ServerPlayer pet = master.getPetPlayer(); + if (pet == null) return PunishmentType.LEASH_TUG; + + List available = new ArrayList<>(); + + // BLINDFOLD: only if eyes region is empty + if (!V2EquipmentHelper.isRegionOccupied(pet, BodyRegionV2.EYES)) { + available.add(PunishmentType.BLINDFOLD); + } + + // GAG: only if mouth region is empty + if (!V2EquipmentHelper.isRegionOccupied(pet, BodyRegionV2.MOUTH)) { + available.add(PunishmentType.GAG); + } + + // MITTENS: only if hands region is empty + if (!V2EquipmentHelper.isRegionOccupied(pet, BodyRegionV2.HANDS)) { + available.add(PunishmentType.MITTENS); + } + + // TIGHTEN: only if pet is not already bound + if (!V2EquipmentHelper.isRegionOccupied(pet, BodyRegionV2.ARMS)) { + available.add(PunishmentType.TIGHTEN_RESTRAINTS); + } + + // LEASH_TUG: always available as fallback + available.add(PunishmentType.LEASH_TUG); + + return available.get(master.getRandom().nextInt(available.size())); + } + + /** + * Apply the selected punishment to the pet. + */ + private void applyPunishment(ServerPlayer pet) { + if (selectedPunishment == null) { + selectedPunishment = PunishmentType.COLD_SHOULDER; + } + + // Send dialogue + DialogueBridge.talkTo(master, pet, selectedPunishment.getDialogueId()); + + switch (selectedPunishment) { + case CHOKE_COLLAR -> applyChoke(pet); + case BLINDFOLD -> applyTempAccessory(pet, BodyRegionV2.EYES); + case GAG -> applyTempAccessory(pet, BodyRegionV2.MOUTH); + case MITTENS -> applyTempAccessory(pet, BodyRegionV2.HANDS); + case TIGHTEN_RESTRAINTS -> applyTighten(pet); + case COLD_SHOULDER -> applyColdShoulder(); + case LEASH_TUG -> applyLeashTug(pet); + } + + TiedUpMod.LOGGER.info( + "[MasterPunishGoal] {} applied {} to {}", + master.getNpcName(), + selectedPunishment, + pet.getName().getString() + ); + } + + /** + * Apply the secondary physical restraint after the choke (attack punishment only). + */ + private void applySecondaryPunishment(ServerPlayer pet) { + if (secondaryPunishment == null) return; + + // Send dialogue for the secondary punishment + DialogueBridge.talkTo(master, pet, secondaryPunishment.getDialogueId()); + + switch (secondaryPunishment) { + case BLINDFOLD -> applyTempAccessory(pet, BodyRegionV2.EYES); + case GAG -> applyTempAccessory(pet, BodyRegionV2.MOUTH); + case MITTENS -> applyTempAccessory(pet, BodyRegionV2.HANDS); + case TIGHTEN_RESTRAINTS -> applyTighten(pet); + case LEASH_TUG -> applyLeashTug(pet); + default -> { + } // CHOKE_COLLAR and COLD_SHOULDER excluded from secondary + } + + TiedUpMod.LOGGER.info( + "[MasterPunishGoal] {} applied secondary {} to {}", + master.getNpcName(), + secondaryPunishment, + pet.getName().getString() + ); + } + + /** + * Apply choke collar punishment (existing logic). + */ + private void applyChoke(ServerPlayer pet) { + PlayerBindState bindState = PlayerBindState.getInstance(pet); + if (bindState != null && bindState.hasCollar()) { + ItemStack collar = bindState.getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemChokeCollar chokeCollar) { + chokeCollar.setChoking(collar, true); + this.activeChokeCollar = collar; + this.chokeActiveTimer = 1; + + pet + .level() + .playSound( + null, + pet.getX(), + pet.getY(), + pet.getZ(), + SoundEvents.PLAYER_HURT, + SoundSource.HOSTILE, + 0.8f, + 0.5f + master.getRandom().nextFloat() * 0.2f + ); + return; + } + } + // Fallback + pet.hurt(pet.damageSources().magic(), FALLBACK_DAMAGE); + } + + /** + * Apply a temporary accessory (blindfold, gag, or mittens) as punishment. + * Uses the same temp-item pattern as MasterRandomEventGoal. + */ + private void applyTempAccessory(ServerPlayer pet, BodyRegionV2 region) { + ItemStack accessory = createAccessory(region); + if (accessory.isEmpty()) return; + + // Mark as temporary with expiration + long expirationTime = master.level().getGameTime() + TEMP_ITEM_DURATION; + CompoundTag tag = accessory.getOrCreateTag(); + tag.putBoolean(NBT_TEMP_MASTER_EVENT, true); + tag.putLong(NBT_EXPIRATION_TIME, expirationTime); + tag.putUUID("masterUUID", master.getUUID()); + + IV2BondageEquipment equip = V2EquipmentHelper.getEquipment(pet); + if (equip != null) { + equip.setInRegion(region, accessory); + V2EquipmentHelper.sync(pet); + } + + pet + .level() + .playSound( + null, + pet.getX(), + pet.getY(), + pet.getZ(), + SoundEvents.ARMOR_EQUIP_LEATHER, + SoundSource.HOSTILE, + 1.0f, + 0.8f + ); + } + + /** + * Create an accessory item for the given body region. + */ + private ItemStack createAccessory(BodyRegionV2 region) { + return switch (region) { + case EYES -> new ItemStack( + ModItems.getBlindfold(BlindfoldVariant.CLASSIC) + ); + case MOUTH -> new ItemStack(ModItems.getGag(GagVariant.BALL_GAG)); + case HANDS -> new ItemStack( + ModItems.getMittens(MittensVariant.LEATHER) + ); + default -> ItemStack.EMPTY; + }; + } + + /** + * Apply armbinder as punishment. + */ + private void applyTighten(ServerPlayer pet) { + ItemStack armbinder = new ItemStack( + ModItems.getBind(BindVariant.ARMBINDER) + ); + + // Mark as temporary + long expirationTime = master.level().getGameTime() + TEMP_ITEM_DURATION; + CompoundTag tag = armbinder.getOrCreateTag(); + tag.putBoolean(NBT_TEMP_MASTER_EVENT, true); + tag.putLong(NBT_EXPIRATION_TIME, expirationTime); + tag.putUUID("masterUUID", master.getUUID()); + + IV2BondageEquipment equip = V2EquipmentHelper.getEquipment(pet); + if (equip != null) { + equip.setInRegion(BodyRegionV2.ARMS, armbinder); + V2EquipmentHelper.sync(pet); + } + + pet + .level() + .playSound( + null, + pet.getX(), + pet.getY(), + pet.getZ(), + SoundEvents.ARMOR_EQUIP_CHAIN, + SoundSource.HOSTILE, + 1.0f, + 0.7f + ); + } + + /** + * Apply cold shoulder - master ignores pet interactions. + */ + private void applyColdShoulder() { + master.startColdShoulder( + PunishmentType.COLD_SHOULDER.getDurationTicks() + ); + } + + /** + * Apply leash tug - yank pet toward master. + */ + private void applyLeashTug(ServerPlayer pet) { + // Attach temp leash + master.attachLeashToPet(); + this.leashTugTimer = PunishmentType.LEASH_TUG.getDurationTicks(); + + // Apply velocity toward master + Vec3 direction = master.position().subtract(pet.position()).normalize(); + pet.setDeltaMovement(direction.scale(1.2)); + pet.hurtMarked = true; + + pet + .level() + .playSound( + null, + pet.getX(), + pet.getY(), + pet.getZ(), + SoundEvents.CHAIN_HIT, + SoundSource.HOSTILE, + 1.0f, + 1.0f + ); + } + + /** + * Deactivate the choke collar if active. + */ + private void deactivateChoke() { + if ( + !activeChokeCollar.isEmpty() && + activeChokeCollar.getItem() instanceof ItemChokeCollar chokeCollar + ) { + chokeCollar.setChoking(activeChokeCollar, false); + } + this.activeChokeCollar = ItemStack.EMPTY; + this.chokeActiveTimer = 0; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/master/MasterRandomEventGoal.java b/src/main/java/com/tiedup/remake/entities/ai/master/MasterRandomEventGoal.java new file mode 100644 index 0000000..3e2be87 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/master/MasterRandomEventGoal.java @@ -0,0 +1,428 @@ +package com.tiedup.remake.entities.ai.master; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.DialogueBridge; +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.BindVariant; +import com.tiedup.remake.items.base.BlindfoldVariant; +import com.tiedup.remake.items.base.GagVariant; +import com.tiedup.remake.items.base.MittensVariant; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageEquipment; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.EnumSet; +import java.util.List; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +/** + * AI Goal for EntityMaster to trigger random events: + * - Random bind/accessory placement (temporary) + * - Master-initiated dogwalk + * + * Events are less frequent than tasks and add variety to gameplay. + */ +public class MasterRandomEventGoal extends Goal { + + private final EntityMaster master; + + /** Chance to trigger an event per tick */ + private static final float EVENT_CHANCE = 0.002f; // ~4% per 10 seconds + + /** Minimum time between events (ticks) - 2 minutes */ + private static final int EVENT_COOLDOWN = 2400; + + /** Duration of random bind event (ticks) - 2 minutes */ + private static final int RANDOM_BIND_DURATION = 2400; + + /** NBT tag key for temporary master event items */ + private static final String NBT_TEMP_MASTER_EVENT = "tempMasterEvent"; + private static final String NBT_EXPIRATION_TIME = "expirationTime"; + + /** Event types that can be triggered randomly */ + private enum RandomEvent { + RANDOM_BIND, + DOGWALK, + HUMAN_CHAIR, + } + + /** Accessory regions that can be randomly equipped (eyes, mouth, hands) */ + private static final List RANDOM_ACCESSORY_REGIONS = List.of( + BodyRegionV2.EYES, + BodyRegionV2.MOUTH, + BodyRegionV2.HANDS + ); + + private long lastEventTime = 0; + private RandomEvent currentEvent = null; + private int eventTimer = 0; + + /** For RANDOM_BIND: the body region that was equipped */ + private BodyRegionV2 appliedAccessoryRegion = null; + private ItemStack appliedAccessory = ItemStack.EMPTY; + + public MasterRandomEventGoal(EntityMaster master) { + this.master = master; + this.setFlags(EnumSet.of(Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (!master.hasPet()) return false; + + MasterState state = master.getStateManager().getCurrentState(); + + // Don't trigger events during other activities + if (state != MasterState.FOLLOWING && state != MasterState.OBSERVING) { + return false; + } + + // Don't trigger if there's an active task + if (master.hasActiveTask()) return false; + + // Check cooldown + long currentTime = master.level().getGameTime(); + if (currentTime - lastEventTime < EVENT_COOLDOWN) { + return false; + } + + // Random chance (modulated by engagement cadence) + float multiplier = master.getEngagementMultiplier(); + return ( + multiplier > 0 && + master.getRandom().nextFloat() < EVENT_CHANCE * multiplier + ); + } + + @Override + public boolean canContinueToUse() { + // Only continue for timed events like RANDOM_BIND + return ( + currentEvent == RandomEvent.RANDOM_BIND && + eventTimer < RANDOM_BIND_DURATION + ); + } + + @Override + public void start() { + this.eventTimer = 0; + this.appliedAccessoryRegion = null; + this.appliedAccessory = ItemStack.EMPTY; + + // Select random event + RandomEvent[] events = RandomEvent.values(); + this.currentEvent = events[master.getRandom().nextInt(events.length)]; + + master.markEngagement(); + triggerEvent(); + + TiedUpMod.LOGGER.debug( + "[MasterRandomEventGoal] {} triggered event: {}", + master.getNpcName(), + currentEvent + ); + } + + @Override + public void stop() { + lastEventTime = master.level().getGameTime(); + + // Only remove accessory on natural completion (timer expired), + // NOT on goal preemption (e.g. MasterObservePlayerGoal taking over LOOK flag). + // If preempted, the NBT expiration timer in cleanupExpiredTempItems() handles removal. + if ( + currentEvent == RandomEvent.RANDOM_BIND && + appliedAccessoryRegion != null && + eventTimer >= RANDOM_BIND_DURATION + ) { + removeAppliedAccessory(); + } + + this.currentEvent = null; + this.eventTimer = 0; + this.appliedAccessoryRegion = null; + this.appliedAccessory = ItemStack.EMPTY; + + TiedUpMod.LOGGER.debug( + "[MasterRandomEventGoal] {} event completed", + master.getNpcName() + ); + } + + @Override + public void tick() { + if (currentEvent == RandomEvent.RANDOM_BIND) { + eventTimer++; + } + } + + /** + * Trigger the selected event. + */ + private void triggerEvent() { + ServerPlayer pet = master.getPetPlayer(); + if (pet == null) return; + + switch (currentEvent) { + case RANDOM_BIND -> triggerRandomBind(pet); + case DOGWALK -> triggerDogwalk(pet); + case HUMAN_CHAIR -> triggerHumanChair(pet); + } + } + + /** + * Trigger random bind event - apply a temporary accessory. + */ + private void triggerRandomBind(ServerPlayer pet) { + PlayerBindState bindState = PlayerBindState.getInstance(pet); + if (bindState == null) return; + + // Collect all empty accessory regions, then pick one at random + List emptyRegions = new java.util.ArrayList<>(); + for (BodyRegionV2 region : RANDOM_ACCESSORY_REGIONS) { + if (!V2EquipmentHelper.isRegionOccupied(pet, region)) { + emptyRegions.add(region); + } + } + + BodyRegionV2 selectedRegion = null; + if (!emptyRegions.isEmpty()) { + selectedRegion = emptyRegions.get( + master.getRandom().nextInt(emptyRegions.size()) + ); + } else if (master.getRandom().nextFloat() < 0.3f) { + // If no empty region, pick random and replace (less likely) + selectedRegion = RANDOM_ACCESSORY_REGIONS.get( + master.getRandom().nextInt(RANDOM_ACCESSORY_REGIONS.size()) + ); + } + + if (selectedRegion == null) { + // Pet is fully accessorized, skip event + currentEvent = null; + return; + } + + // Create the accessory item + ItemStack accessory = createRandomAccessory(selectedRegion); + if (accessory.isEmpty()) { + currentEvent = null; + return; + } + + // Mark the accessory as temporary with expiration time + // This ensures cleanup even after server restart + long expirationTime = + master.level().getGameTime() + RANDOM_BIND_DURATION; + CompoundTag tag = accessory.getOrCreateTag(); + tag.putBoolean(NBT_TEMP_MASTER_EVENT, true); + tag.putLong(NBT_EXPIRATION_TIME, expirationTime); + tag.putUUID("masterUUID", master.getUUID()); + + // Apply the accessory + this.appliedAccessoryRegion = selectedRegion; + this.appliedAccessory = accessory; + + IV2BondageEquipment equip = V2EquipmentHelper.getEquipment(pet); + if (equip != null) { + equip.setInRegion(selectedRegion, accessory); + V2EquipmentHelper.sync(pet); + } + + // Dialogue - both messages sent (random_bind is the action, random_bind_done is the completion) + DialogueBridge.talkTo(master, pet, "petplay.random_bind"); + + TiedUpMod.LOGGER.debug( + "[MasterRandomEventGoal] {} applied {} to {}", + master.getNpcName(), + selectedRegion, + pet.getName().getString() + ); + } + + /** + * Create a random accessory item for the given body region. + */ + private ItemStack createRandomAccessory(BodyRegionV2 region) { + Item item = switch (region) { + case EYES -> ModItems.getBlindfold(BlindfoldVariant.CLASSIC); + case MOUTH -> ModItems.getGag(GagVariant.BALL_GAG); + case HANDS -> ModItems.getMittens(MittensVariant.LEATHER); + default -> null; + }; + + if (item == null) return ItemStack.EMPTY; + return new ItemStack(item); + } + + /** + * Remove the applied accessory when event ends. + */ + private void removeAppliedAccessory() { + ServerPlayer pet = master.getPetPlayer(); + if (pet == null) return; + + if (appliedAccessoryRegion != null) { + // Check if it's still the same item we applied + ItemStack current = V2EquipmentHelper.getInRegion( + pet, + appliedAccessoryRegion + ); + if (current.getItem() == appliedAccessory.getItem()) { + V2EquipmentHelper.unequipFromRegion( + pet, + appliedAccessoryRegion, + true + ); + + // Dialogue + DialogueBridge.talkTo( + master, + pet, + "petplay.random_bind_remove" + ); + + TiedUpMod.LOGGER.debug( + "[MasterRandomEventGoal] {} removed {} from {}", + master.getNpcName(), + appliedAccessoryRegion, + pet.getName().getString() + ); + } + } + } + + /** + * Trigger dogwalk event - Master initiates a walk. + */ + private void triggerDogwalk(ServerPlayer pet) { + // Put pet in dogbind if not already tied + PlayerBindState bindState = PlayerBindState.getInstance(pet); + if (bindState != null && !bindState.isTiedUp()) { + ItemStack dogbind = new ItemStack( + ModItems.getBind(BindVariant.DOGBINDER) + ); + bindState.equip(BodyRegionV2.ARMS, dogbind); + } + + // Attach leash + master.attachLeashToPet(); + + // Set dogwalk mode - master leads (pulls the pet) + master.setDogwalkMode(true); + master.setMasterState(MasterState.DOGWALK); + + // Dialogue + DialogueBridge.talkTo(master, pet, "petplay.start_dogwalk"); + + TiedUpMod.LOGGER.debug( + "[MasterRandomEventGoal] {} started dogwalk with {}", + master.getNpcName(), + pet.getName().getString() + ); + + // Event is instant - dogwalk continues until manually ended or timeout + this.currentEvent = null; // Don't continue in this goal + } + + /** + * Trigger human chair event - Master uses pet as furniture. + * Delegates to MasterHumanChairGoal for the actual behavior. + */ + private void triggerHumanChair(ServerPlayer pet) { + // Don't trigger if pet is already tied up (dogbind would conflict) + PlayerBindState bindState = PlayerBindState.getInstance(pet); + if (bindState != null && bindState.isTiedUp()) { + // Fall back to random bind instead + currentEvent = RandomEvent.RANDOM_BIND; + triggerRandomBind(pet); + return; + } + + // Set state - MasterHumanChairGoal will handle the rest + master.setMasterState(MasterState.HUMAN_CHAIR); + + // Dialogue + DialogueBridge.talkTo(master, pet, "petplay.human_chair_start"); + + TiedUpMod.LOGGER.debug( + "[MasterRandomEventGoal] {} started human chair with {}", + master.getNpcName(), + pet.getName().getString() + ); + + // Event is instant - human chair continues in dedicated goal + this.currentEvent = null; + } + + // ======================================== + // STATIC CLEANUP METHODS + // ======================================== + + /** All body regions that can have temporary master event items */ + private static final List TEMP_ITEM_CLEANUP_REGIONS = + List.of( + BodyRegionV2.ARMS, + BodyRegionV2.EYES, + BodyRegionV2.MOUTH, + BodyRegionV2.HANDS + ); + + /** + * Check and cleanup any expired temporary master event items on a player. + * Should be called periodically from EntityMaster.tick() or on player login. + * + * @param pet The player to check + * @param currentTime Current game time + */ + public static void cleanupExpiredTempItems( + ServerPlayer pet, + long currentTime + ) { + for (BodyRegionV2 region : TEMP_ITEM_CLEANUP_REGIONS) { + ItemStack item = V2EquipmentHelper.getInRegion(pet, region); + if (item.isEmpty()) continue; + + CompoundTag tag = item.getTag(); + if (tag == null) continue; + + if (tag.getBoolean(NBT_TEMP_MASTER_EVENT)) { + long expirationTime = tag.getLong(NBT_EXPIRATION_TIME); + if (currentTime >= expirationTime) { + // Item expired - use proper removal to trigger onUnequipped callbacks + // ARMS (bind) needs PlayerBindState.unequip(BodyRegionV2.ARMS) for speed/animation cleanup + if (region == BodyRegionV2.ARMS) { + PlayerBindState bindState = PlayerBindState.getInstance( + pet + ); + if (bindState != null && bindState.isTiedUp()) { + bindState.unequip(BodyRegionV2.ARMS); + } + } else { + V2EquipmentHelper.unequipFromRegion(pet, region, true); + } + + TiedUpMod.LOGGER.info( + "[MasterRandomEventGoal] Removed expired temp {} from {}", + region, + pet.getName().getString() + ); + } + } + } + } + + /** + * Check if an item is a temporary master event item. + */ + public static boolean isTempMasterEventItem(ItemStack stack) { + if (stack.isEmpty()) return false; + CompoundTag tag = stack.getTag(); + return tag != null && tag.getBoolean(NBT_TEMP_MASTER_EVENT); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/master/MasterState.java b/src/main/java/com/tiedup/remake/entities/ai/master/MasterState.java new file mode 100644 index 0000000..aabe26b --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/master/MasterState.java @@ -0,0 +1,123 @@ +package com.tiedup.remake.entities.ai.master; + +/** + * Enum defining the behavioral states of a Master entity. + * + * Master NPC follows a different pattern than Kidnapper: + * - FOLLOWING: Master follows their pet (player) + * - OBSERVING: Master is watching the pet (can detect struggle) + * - DISTRACTED: Master is distracted (pet can struggle safely) + * - TASK_ASSIGN: Master is assigning a task to pet + * - TASK_WATCH: Master is watching pet perform a task + * - INSPECT: Master is inspecting pet's inventory for contraband + * - PUNISH: Master is punishing pet for misbehavior + * - PURCHASING: Master is buying a pet from a Kidnapper + */ +public enum MasterState { + /** + * Idle state - Master has nothing specific to do. + * Default state when no other action is available. + */ + IDLE, + + /** + * Purchasing state - Master is buying a captive from a Kidnapper. + * This is the initial state when Master spawns. + */ + PURCHASING, + + /** + * Following state - Master follows the pet player. + * Unlike normal NPCs, the Master follows the player, not vice versa. + * Maintains 2-8 block distance. + */ + FOLLOWING, + + /** + * Observing state - Master is actively watching the pet. + * Can detect struggle attempts in this state. + * Pet cannot escape while being observed. + */ + OBSERVING, + + /** + * Distracted state - Master is temporarily distracted. + * Pet can safely struggle in this state. + * Lasts 30-60 seconds, occurs every 2-5 minutes. + */ + DISTRACTED, + + /** + * Task assign state - Master is assigning a task to the pet. + * Transitions to TASK_WATCH after assignment. + */ + TASK_ASSIGN, + + /** + * Task watch state - Master is supervising pet's task execution. + * Similar to OBSERVING but focused on task completion. + */ + TASK_WATCH, + + /** + * Inspect state - Master is checking pet's inventory for contraband. + * Will confiscate lockpicks, knives, weapons, etc. + */ + INSPECT, + + /** + * Punish state - Master is punishing the pet. + * Uses shock collar, paddle, or other punishment methods. + * Triggered by: struggle detected, task failed, contraband found. + */ + PUNISH, + + /** + * Dogwalk state - Master is taking pet on a walk (event). + * Similar to FOLLOWING but with specific destination. + */ + DOGWALK, + + /** + * Human Chair state - Master uses pet as furniture. + * Pet is forced on all fours, cannot move. Master sits on pet. + * Lasts ~2 minutes with idle dialogue. + */ + HUMAN_CHAIR; + + /** + * Check if this state allows the master to detect struggles. + * OBSERVING, TASK_WATCH, and FOLLOWING can detect struggles. + */ + public boolean canDetectStruggle() { + return this == OBSERVING || this == TASK_WATCH || this == FOLLOWING; + } + + /** + * Check if this state is a "watching" state where Master pays attention. + */ + public boolean isWatching() { + return this == OBSERVING || this == TASK_WATCH || this == INSPECT; + } + + /** + * Check if this state allows the pet to struggle without detection. + */ + public boolean allowsSafeStruggle() { + return this == DISTRACTED || this == IDLE; + } + + /** + * Check if Master can transition to punishment from this state. + */ + public boolean canTransitionToPunish() { + return this != PUNISH && this != PURCHASING; + } + + /** + * Check if Master is actively engaged with pet (not idle). + */ + public boolean isEngaged() { + return this != IDLE && this != DISTRACTED; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/master/MasterTaskAssignGoal.java b/src/main/java/com/tiedup/remake/entities/ai/master/MasterTaskAssignGoal.java new file mode 100644 index 0000000..4874049 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/master/MasterTaskAssignGoal.java @@ -0,0 +1,435 @@ +package com.tiedup.remake.entities.ai.master; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.DialogueBridge; +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.util.MessageDispatcher; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.enchantment.EnchantmentHelper; + +/** + * AI Goal for EntityMaster to assign tasks to their pet player. + * + * Tasks include: + * - HEEL: Stay close to master (< 3 blocks) + * - WAIT_HERE: Stay at current location (< 1 block movement) + * - FETCH_ITEM: Bring a specific item + * + * After assignment, transitions to TASK_WATCH where MasterTaskWatchGoal monitors compliance. + */ +public class MasterTaskAssignGoal extends Goal { + + private final EntityMaster master; + + /** Chance to assign task when following (per tick) */ + private static final float TASK_CHANCE = 0.003f; // ~6% per 10 seconds + + /** Minimum time between tasks (ticks) - 1 minute */ + private static final int TASK_COOLDOWN = 1200; + + /** Task assignment duration (ticks) */ + private static final int ASSIGN_DURATION = 40; + + /** Items that can be requested for FETCH_ITEM task */ + private static final List FETCHABLE_ITEMS = List.of( + Items.APPLE, + Items.BREAD, + Items.COOKED_BEEF, + Items.COOKED_CHICKEN, + Items.GOLDEN_APPLE, + Items.COOKIE, + Items.DIAMOND, + Items.EMERALD, + Items.IRON_INGOT, + Items.GOLD_INGOT, + Items.BONE, + Items.FLOWER_BANNER_PATTERN, + Items.ROSE_BUSH, + Items.DANDELION, + Items.POPPY + ); + + /** Tasks that can be randomly assigned (excludes events like RANDOM_BIND, DOGWALK) */ + private static final PetTask[] ASSIGNABLE_TASKS = { + PetTask.HEEL, + PetTask.WAIT_HERE, + PetTask.FETCH_ITEM, + PetTask.KNEEL, + PetTask.COME, + PetTask.PRESENT, + PetTask.SPEAK, + PetTask.DROP, + PetTask.FOLLOW_CLOSE, + PetTask.DEMAND, + }; + + /** Items the Master considers valuable enough to demand (high-value items) */ + private static final Set HIGH_VALUE_ITEMS = Set.of( + Items.DIAMOND, + Items.EMERALD, + Items.GOLD_INGOT, + Items.GOLDEN_APPLE, + Items.ENCHANTED_GOLDEN_APPLE, + Items.NETHERITE_INGOT, + Items.NETHER_STAR, + Items.TOTEM_OF_UNDYING, + Items.DIAMOND_SWORD, + Items.DIAMOND_PICKAXE, + Items.DIAMOND_AXE, + Items.DIAMOND_CHESTPLATE, + Items.DIAMOND_HELMET, + Items.DIAMOND_LEGGINGS, + Items.DIAMOND_BOOTS, + Items.NETHERITE_SWORD, + Items.NETHERITE_PICKAXE, + Items.NETHERITE_AXE, + Items.NETHERITE_CHESTPLATE, + Items.NETHERITE_HELMET, + Items.NETHERITE_LEGGINGS, + Items.NETHERITE_BOOTS, + Items.TRIDENT, + Items.ELYTRA, + Items.ENDER_PEARL, + Items.BLAZE_ROD, + Items.GHAST_TEAR + ); + + /** Items of moderate value - fallback if no high-value items found */ + private static final Set MEDIUM_VALUE_ITEMS = Set.of( + Items.IRON_INGOT, + Items.GOLD_INGOT, + Items.LAPIS_LAZULI, + Items.REDSTONE, + Items.IRON_SWORD, + Items.IRON_PICKAXE, + Items.IRON_AXE, + Items.IRON_CHESTPLATE, + Items.COOKED_BEEF, + Items.COOKED_PORKCHOP, + Items.GOLDEN_CARROT, + Items.EXPERIENCE_BOTTLE, + Items.BOOK, + Items.NAME_TAG, + Items.SADDLE, + Items.COMPASS, + Items.CLOCK, + Items.SPYGLASS + ); + + private int assignTimer = 0; + private long lastTaskTime = 0; + private boolean hasAssigned = false; + private PetTask selectedTask = null; + + public MasterTaskAssignGoal(EntityMaster master) { + this.master = master; + this.setFlags(EnumSet.of(Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (!master.hasPet()) return false; + + MasterState state = master.getStateManager().getCurrentState(); + + // Already assigning + if (state == MasterState.TASK_ASSIGN) return true; + + // Don't assign if already has active task + if (master.hasActiveTask()) return false; + + // Can only assign from following/observing + if (state != MasterState.FOLLOWING && state != MasterState.OBSERVING) { + return false; + } + + // Check cooldown + long currentTime = master.level().getGameTime(); + if (currentTime - lastTaskTime < TASK_COOLDOWN) { + return false; + } + + // Random chance (modulated by engagement cadence) + float multiplier = master.getEngagementMultiplier(); + return ( + multiplier > 0 && + master.getRandom().nextFloat() < TASK_CHANCE * multiplier + ); + } + + @Override + public boolean canContinueToUse() { + return ( + master.hasPet() && + master.getStateManager().getCurrentState() == + MasterState.TASK_ASSIGN && + assignTimer < ASSIGN_DURATION + ); + } + + @Override + public void start() { + this.assignTimer = 0; + this.hasAssigned = false; + this.selectedTask = selectRandomTask(); + master.setMasterState(MasterState.TASK_ASSIGN); + master.markEngagement(); + + TiedUpMod.LOGGER.debug( + "[MasterTaskAssignGoal] {} assigning task: {}", + master.getNpcName(), + selectedTask + ); + } + + @Override + public void stop() { + lastTaskTime = master.level().getGameTime(); + this.assignTimer = 0; + this.hasAssigned = false; + + // Transition to task watch if task was assigned + if (master.hasPet() && master.hasActiveTask()) { + master.setMasterState(MasterState.TASK_WATCH); + } else if (master.hasPet()) { + master.setMasterState(MasterState.FOLLOWING); + } + + this.selectedTask = null; + + TiedUpMod.LOGGER.debug( + "[MasterTaskAssignGoal] {} task assignment complete", + master.getNpcName() + ); + } + + @Override + public void tick() { + ServerPlayer pet = master.getPetPlayer(); + if (pet == null) return; + + // Look at pet + master.getLookControl().setLookAt(pet, 30.0F, 30.0F); + + // Assign task at start + if (!hasAssigned && selectedTask != null) { + assignTask(pet, selectedTask); + hasAssigned = true; + } + + assignTimer++; + } + + /** + * Select a random task to assign. + */ + private PetTask selectRandomTask() { + return ASSIGNABLE_TASKS[master + .getRandom() + .nextInt(ASSIGNABLE_TASKS.length)]; + } + + /** + * Assign a task to the pet. + */ + private void assignTask(ServerPlayer pet, PetTask task) { + String message; + Item requestedItem = null; + + // Handle task-specific setup + switch (task) { + case HEEL, FOLLOW_CLOSE -> { + message = task.getMessageTemplate(); + // Distance tracked to master, no position needed + } + case WAIT_HERE -> { + message = task.getMessageTemplate(); + // Store pet's current position as the anchor point + master.setTaskStartPosition(pet.position()); + } + case KNEEL -> { + message = task.getMessageTemplate(); + // Store pet's current position - must not move + master.setTaskStartPosition(pet.position()); + } + case PRESENT -> { + message = task.getMessageTemplate(); + // Distance tracked to master + } + case COME -> { + message = task.getMessageTemplate(); + // Pet must reach master quickly - no special setup + } + case SPEAK -> { + message = task.getMessageTemplate(); + // Pet must right-click on master - no special setup + } + case DROP -> { + message = task.getMessageTemplate(); + // Pet must empty hands - no special setup + } + case FETCH_ITEM -> { + // Select random item to fetch + requestedItem = FETCHABLE_ITEMS.get( + master.getRandom().nextInt(FETCHABLE_ITEMS.size()) + ); + String itemName = requestedItem.getDescription().getString(); + message = String.format(task.getMessageTemplate(), itemName); + master.setRequestedItem(requestedItem); + } + case DEMAND -> { + // Scan pet's inventory for the most valuable item + requestedItem = findMostValuableItem(pet); + if (requestedItem == null) { + // Pet has nothing worth taking - fallback to FETCH_ITEM + TiedUpMod.LOGGER.debug( + "[MasterTaskAssignGoal] {} found nothing to demand, falling back to FETCH", + master.getNpcName() + ); + task = PetTask.FETCH_ITEM; + requestedItem = FETCHABLE_ITEMS.get( + master.getRandom().nextInt(FETCHABLE_ITEMS.size()) + ); + String fallbackName = requestedItem + .getDescription() + .getString(); + message = String.format( + PetTask.FETCH_ITEM.getMessageTemplate(), + fallbackName + ); + master.setRequestedItem(requestedItem); + master.setActiveTask(task); + } else { + String itemName = requestedItem + .getDescription() + .getString(); + message = String.format( + task.getMessageTemplate(), + itemName + ); + master.setRequestedItem(requestedItem); + } + } + default -> { + message = task.getMessageTemplate(); + } + } + + // Set the active task on the master + master.setActiveTask(task); + + // FIX: Use MessageDispatcher for consistency with earplug system + MessageDispatcher.sendChat( + pet, + Component.literal( + master.getNpcName() + ": \"" + message + "\"" + ).withStyle(Style.EMPTY.withColor(EntityMaster.MASTER_NAME_COLOR)) + ); + + // Also use dialogue system for more variation + String dialogueId = switch (task) { + case HEEL, FOLLOW_CLOSE -> "petplay.task_heel"; + case WAIT_HERE -> "petplay.task_wait"; + case FETCH_ITEM -> "petplay.task_fetch"; + case KNEEL -> "petplay.task_kneel"; + case COME -> "petplay.task_come"; + case PRESENT -> "petplay.task_present"; + case SPEAK -> "petplay.task_speak"; + case DROP -> "petplay.task_drop"; + case DEMAND -> "petplay.task_demand"; + default -> null; + }; + + if (dialogueId != null) { + DialogueBridge.talkTo(master, pet, dialogueId); + } + + TiedUpMod.LOGGER.debug( + "[MasterTaskAssignGoal] {} assigned {} task to {}{}", + master.getNpcName(), + task, + pet.getName().getString(), + requestedItem != null + ? " (item: " + requestedItem.getDescription().getString() + ")" + : "" + ); + } + + /** + * Force assign a specific task (used by external triggers like random events). + */ + public void forceAssignTask(PetTask task) { + this.selectedTask = task; + this.hasAssigned = false; + master.setMasterState(MasterState.TASK_ASSIGN); + } + + // ======================================== + // DEMAND TASK - INVENTORY SCANNING + // ======================================== + + /** + * Scan pet's inventory and find the most valuable item to demand. + * Prioritizes: enchanted items > high-value items > medium-value items. + * + * @param pet The pet player to scan + * @return The most valuable item found, or null if inventory is empty/worthless + */ + @javax.annotation.Nullable + private Item findMostValuableItem(ServerPlayer pet) { + List highValue = new ArrayList<>(); + List mediumValue = new ArrayList<>(); + List enchantedItems = new ArrayList<>(); + + for (int i = 0; i < pet.getInventory().getContainerSize(); i++) { + ItemStack stack = pet.getInventory().getItem(i); + if (stack.isEmpty()) continue; + + Item item = stack.getItem(); + + // Enchanted items are always high priority (even enchanted books) + if ( + stack.isEnchanted() || + EnchantmentHelper.getEnchantments(stack).size() > 0 + ) { + enchantedItems.add(stack); + continue; + } + + if (HIGH_VALUE_ITEMS.contains(item)) { + highValue.add(stack); + } else if (MEDIUM_VALUE_ITEMS.contains(item)) { + mediumValue.add(stack); + } + } + + // Pick from the highest tier available + if (!enchantedItems.isEmpty()) { + return enchantedItems + .get(master.getRandom().nextInt(enchantedItems.size())) + .getItem(); + } + if (!highValue.isEmpty()) { + return highValue + .get(master.getRandom().nextInt(highValue.size())) + .getItem(); + } + if (!mediumValue.isEmpty()) { + return mediumValue + .get(master.getRandom().nextInt(mediumValue.size())) + .getItem(); + } + + return null; // Nothing worth demanding + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/master/MasterTaskWatchGoal.java b/src/main/java/com/tiedup/remake/entities/ai/master/MasterTaskWatchGoal.java new file mode 100644 index 0000000..ea0789a --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/master/MasterTaskWatchGoal.java @@ -0,0 +1,489 @@ +package com.tiedup.remake.entities.ai.master; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.dialogue.DialogueBridge; +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.items.ItemChokeCollar; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.util.MessageDispatcher; +import java.util.EnumSet; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.Vec3; + +/** + * AI Goal for EntityMaster to watch and enforce active tasks. + * + * Monitors task compliance and triggers choke collar punishment on violation: + * - HEEL: Player must stay within 3 blocks of Master + * - WAIT_HERE: Player must not move more than 1 block from start position + * - FETCH_ITEM: Player must give item within time limit (handled separately via interaction) + */ +public class MasterTaskWatchGoal extends Goal { + + private final EntityMaster master; + + /** Ticks between compliance checks */ + private static final int CHECK_INTERVAL = 10; + + /** Choke duration on violation (ticks) - 2 seconds */ + private static final int CHOKE_DURATION = 40; + + /** Grace period before starting to check (ticks) - 3 seconds */ + private static final int GRACE_PERIOD = 60; + + /** Warning messages when task is violated */ + private static final String[] HEEL_VIOLATION_MESSAGES = { + "You're too far! Heel!", + "Stay close, pet!", + "*tugs at your collar*", + }; + + private static final String[] WAIT_VIOLATION_MESSAGES = { + "I said don't move!", + "Stay still!", + "*activates your collar*", + }; + + private static final String[] KNEEL_VIOLATION_MESSAGES = { + "I said kneel!", + "Get back down!", + "On your knees, pet!", + }; + + private static final String[] COME_VIOLATION_MESSAGES = { + "Too slow!", + "I said come HERE!", + "You're taking too long!", + }; + + private static final String[] PRESENT_VIOLATION_MESSAGES = { + "Stand still!", + "Don't move away from me!", + "Present yourself properly!", + }; + + private static final String[] DROP_VIOLATION_MESSAGES = { + "Drop it!", + "Empty your hands. Now.", + "I said drop what you're holding!", + }; + + private static final String[] FOLLOW_CLOSE_VIOLATION_MESSAGES = { + "Closer!", + "Stay right beside me!", + "*yanks your collar*", + }; + + private int checkTimer = 0; + private int taskTimer = 0; + private int chokeTimer = 0; + private ItemStack activeChokeCollar = ItemStack.EMPTY; + + public MasterTaskWatchGoal(EntityMaster master) { + this.master = master; + this.setFlags(EnumSet.of(Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Only active in TASK_WATCH state with an active task + return ( + master.getStateManager().getCurrentState() == + MasterState.TASK_WATCH && + master.hasPet() && + master.hasActiveTask() + ); + } + + @Override + public boolean canContinueToUse() { + if (!master.hasPet()) return false; + + ServerPlayer pet = master.getPetPlayer(); + if (pet == null || !pet.isAlive()) return false; + + // Continue while in TASK_WATCH state + if ( + master.getStateManager().getCurrentState() != MasterState.TASK_WATCH + ) { + return false; + } + + // Check if task duration expired + PetTask task = master.getCurrentTask(); + if ( + task != null && + task.getDurationTicks() > 0 && + taskTimer >= task.getDurationTicks() + ) { + return false; + } + + return master.hasActiveTask(); + } + + @Override + public void start() { + this.checkTimer = 0; + this.taskTimer = 0; + this.chokeTimer = 0; + this.activeChokeCollar = ItemStack.EMPTY; + + TiedUpMod.LOGGER.debug( + "[MasterTaskWatchGoal] {} started watching task: {}", + master.getNpcName(), + master.getCurrentTask() + ); + } + + @Override + public void stop() { + // Ensure choke is deactivated + deactivateChoke(); + + // Check if task completed successfully + PetTask task = master.getCurrentTask(); + + // Determine success based on task type + boolean success = false; + ServerPlayer pet = master.getPetPlayer(); + + if ( + task != null && + task.getDurationTicks() > 0 && + taskTimer >= task.getDurationTicks() + ) { + // Duration expired - check end-state success for specific tasks + switch (task) { + case FETCH_ITEM, DEMAND -> { + // Reaching timeout = failure (success handled via handleFetchItemGive/mobInteract) + success = false; + } + case SPEAK -> { + // Reaching timeout = failure (success handled via mobInteract) + success = false; + } + case COME -> { + // Success if pet is within range at end + success = + pet != null && + master.distanceTo(pet) <= task.getMaxDistance(); + } + case DROP -> { + // Success if hands are empty at end + success = + pet != null && + pet.getMainHandItem().isEmpty() && + pet.getOffhandItem().isEmpty(); + } + default -> { + // Duration-based tasks: completing duration IS success + success = true; + } + } + } + + if (success) { + if (pet != null) { + DialogueBridge.talkTo(master, pet, "petplay.task_complete"); + TiedUpMod.LOGGER.debug( + "[MasterTaskWatchGoal] {} completed task {} successfully", + pet.getName().getString(), + task + ); + } + } else if ( + task != null && + task.getDurationTicks() > 0 && + taskTimer >= task.getDurationTicks() + ) { + // Task timed out without success + if (pet != null) { + DialogueBridge.talkTo(master, pet, "petplay.disappointed"); + // Trigger punishment for failure + master.setMasterState(MasterState.PUNISH); + master.clearActiveTask(); + this.checkTimer = 0; + this.taskTimer = 0; + this.chokeTimer = 0; + this.activeChokeCollar = ItemStack.EMPTY; + return; // Don't fall through to FOLLOWING + } + } + + // Clear task and return to following + master.clearActiveTask(); + + if (master.hasPet()) { + master.setMasterState(MasterState.FOLLOWING); + } + + this.checkTimer = 0; + this.taskTimer = 0; + this.chokeTimer = 0; + this.activeChokeCollar = ItemStack.EMPTY; + + TiedUpMod.LOGGER.debug( + "[MasterTaskWatchGoal] {} stopped watching task (success={})", + master.getNpcName(), + success + ); + } + + @Override + public void tick() { + ServerPlayer pet = master.getPetPlayer(); + if (pet == null) return; + + PetTask task = master.getCurrentTask(); + if (task == null) return; + + // Always look at pet + master.getLookControl().setLookAt(pet, 30.0F, 30.0F); + + // Increment task timer + taskTimer++; + + // Handle choke cooldown + if (chokeTimer > 0) { + chokeTimer--; + if (chokeTimer <= 0) { + deactivateChoke(); + } + } + + // Show progress every 5 seconds via action bar + if ( + task.getDurationTicks() > 0 && + taskTimer % 100 == 0 && + taskTimer >= GRACE_PERIOD + ) { + int remainingSec = (task.getDurationTicks() - taskTimer) / 20; + if (remainingSec > 0) { + MessageDispatcher.sendActionBar( + pet, + Component.literal( + task.name() + " - " + remainingSec + "s remaining" + ).withStyle(Style.EMPTY.withColor(0xFFAA00)) + ); + } + } + + // Skip compliance checks during grace period + if (taskTimer < GRACE_PERIOD) { + return; + } + + // Check compliance periodically + checkTimer++; + if (checkTimer >= CHECK_INTERVAL) { + checkTimer = 0; + checkTaskCompliance(pet, task); + } + } + + /** + * Check if pet is complying with the current task. + */ + private void checkTaskCompliance(ServerPlayer pet, PetTask task) { + boolean violation = false; + String[] violationMessages = null; + + switch (task) { + case HEEL -> { + double dist = master.distanceTo(pet); + if (dist > task.getMaxDistance()) { + violation = true; + violationMessages = HEEL_VIOLATION_MESSAGES; + } + } + case WAIT_HERE -> { + Vec3 startPos = master.getTaskStartPosition(); + if (startPos != null) { + double dist = pet.position().distanceTo(startPos); + if (dist > task.getMaxDistance()) { + violation = true; + violationMessages = WAIT_VIOLATION_MESSAGES; + } + } + } + case KNEEL -> { + // Must be crouching AND near start position + if (!pet.isCrouching()) { + violation = true; + violationMessages = KNEEL_VIOLATION_MESSAGES; + } else { + Vec3 startPos = master.getTaskStartPosition(); + if (startPos != null) { + double dist = pet.position().distanceTo(startPos); + if (dist > task.getMaxDistance()) { + violation = true; + violationMessages = KNEEL_VIOLATION_MESSAGES; + } + } + } + } + case COME -> { + // Grace period: only check after half the duration has passed + double dist = master.distanceTo(pet); + if ( + taskTimer > task.getDurationTicks() / 2 && + dist > task.getMaxDistance() + ) { + violation = true; + violationMessages = COME_VIOLATION_MESSAGES; + } + } + case PRESENT -> { + // Must stay near master + double dist = master.distanceTo(pet); + if (dist > task.getMaxDistance()) { + violation = true; + violationMessages = PRESENT_VIOLATION_MESSAGES; + } + } + case SPEAK -> { + // No continuous check - just late warning at 75% time + if ( + taskTimer >= (int) (task.getDurationTicks() * 0.75) && + taskTimer % 60 == 0 + ) { + MessageDispatcher.sendChat( + pet, + Component.literal( + master.getNpcName() + ": \"I'm waiting...\"" + ).withStyle( + Style.EMPTY.withColor( + EntityMaster.MASTER_NAME_COLOR + ) + ) + ); + } + } + case DEMAND -> { + // No continuous check - just late warning at 60% time (more impatient) + if ( + taskTimer >= (int) (task.getDurationTicks() * 0.6) && + taskTimer % 60 == 0 + ) { + String[] demandWarnings = { + "I said give it to me.", + "Don't make me ask again.", + "Hand it over. Now.", + "*extends hand impatiently*", + }; + String warning = demandWarnings[master + .getRandom() + .nextInt(demandWarnings.length)]; + MessageDispatcher.sendChat( + pet, + Component.literal( + master.getNpcName() + ": \"" + warning + "\"" + ).withStyle( + Style.EMPTY.withColor( + EntityMaster.MASTER_NAME_COLOR + ) + ) + ); + } + } + case DROP -> { + if ( + !pet.getMainHandItem().isEmpty() || + !pet.getOffhandItem().isEmpty() + ) { + violation = true; + violationMessages = DROP_VIOLATION_MESSAGES; + } + } + case FOLLOW_CLOSE -> { + double dist = master.distanceTo(pet); + if (dist > task.getMaxDistance()) { + violation = true; + violationMessages = FOLLOW_CLOSE_VIOLATION_MESSAGES; + } + } + default -> { + // Other tasks don't have continuous checks + } + } + + if (violation && violationMessages != null) { + triggerPunishment(pet, violationMessages); + } + } + + /** + * Trigger choke collar punishment for task violation. + */ + private void triggerPunishment(ServerPlayer pet, String[] messages) { + // Only punish if not already choking + if (chokeTimer > 0) return; + + // FIX: Use MessageDispatcher for consistency with earplug system + String message = messages[master.getRandom().nextInt(messages.length)]; + MessageDispatcher.sendChat( + pet, + Component.literal( + master.getNpcName() + ": \"" + message + "\"" + ).withStyle(Style.EMPTY.withColor(EntityMaster.MASTER_NAME_COLOR)) + ); + + // Activate choke collar + PlayerBindState bindState = PlayerBindState.getInstance(pet); + if (bindState != null && bindState.hasCollar()) { + ItemStack collar = bindState.getEquipment(BodyRegionV2.NECK); + + if (collar.getItem() instanceof ItemChokeCollar chokeCollar) { + chokeCollar.setChoking(collar, true); + this.activeChokeCollar = collar; + this.chokeTimer = CHOKE_DURATION; + + // Play choking sound + pet + .level() + .playSound( + null, + pet.getX(), + pet.getY(), + pet.getZ(), + SoundEvents.PLAYER_HURT, + SoundSource.HOSTILE, + 0.8f, + 0.5f + master.getRandom().nextFloat() * 0.2f + ); + + TiedUpMod.LOGGER.debug( + "[MasterTaskWatchGoal] {} triggered choke punishment on {}", + master.getNpcName(), + pet.getName().getString() + ); + } + } + } + + /** + * Deactivate the choke collar if active. + */ + private void deactivateChoke() { + if ( + !activeChokeCollar.isEmpty() && + activeChokeCollar.getItem() instanceof ItemChokeCollar chokeCollar + ) { + chokeCollar.setChoking(activeChokeCollar, false); + TiedUpMod.LOGGER.debug( + "[MasterTaskWatchGoal] {} deactivated choke", + master.getNpcName() + ); + } + this.activeChokeCollar = ItemStack.EMPTY; + this.chokeTimer = 0; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/master/PetTask.java b/src/main/java/com/tiedup/remake/entities/ai/master/PetTask.java new file mode 100644 index 0000000..f63dbc4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/master/PetTask.java @@ -0,0 +1,173 @@ +package com.tiedup.remake.entities.ai.master; + +/** + * Enum defining the types of tasks a Master can assign to their pet. + */ +public enum PetTask { + /** + * Heel - Pet must stay within 3 blocks of Master. + * Punishment: Choke if distance > 3 blocks + */ + HEEL("Heel, pet. Stay close to me. [Stay within 3 blocks]", 1200, 3.0), + + /** + * Wait Here - Pet cannot move from current position. + * Punishment: Choke if moves more than 2 blocks + */ + WAIT_HERE("Stay here. Don't move. [Stay within 2 blocks]", 800, 2.0), + + /** + * Fetch Item - Pet must give Master a specific item. + * Punishment: Choke if item not given within time limit + */ + FETCH_ITEM("Bring me %s. [Right-click on me with the item]", 2400, 0.0), + + /** + * Kneel - Pet must crouch and not move. + * Punishment if pet stands up or moves. + */ + KNEEL("Kneel. [Hold shift, don't move]", 600, 1.5), + + /** + * Come - Pet must reach Master quickly. + * Speed test - must be within range before time runs out. + */ + COME("Come here! Now! [Reach me within 10 seconds]", 200, 2.0), + + /** + * Present - Pet must stand near Master without moving. + * Punishment if pet moves away. + */ + PRESENT( + "Present yourself. Stand before me. [Stand still near me]", + 400, + 2.0 + ), + + /** + * Speak - Pet must right-click on Master. + * Interaction-based task. + */ + SPEAK("Speak, pet. [Right-click on me]", 300, 0.0), + + /** + * Drop - Pet must empty both hands. + * Punishment if hands are not empty. + */ + DROP("Drop what you're holding. [Empty both hands]", 400, 0.0), + + /** + * Follow Close - Like HEEL but tighter distance, longer duration. + * Punishment if pet strays more than 1.5 blocks. + */ + FOLLOW_CLOSE( + "Stay right beside me. Don't stray. [Stay within 1.5 blocks]", + 1600, + 1.5 + ), + + /** + * Demand - Master demands a valuable item from pet's inventory. + * Scans inventory and picks the most precious item the pet has. + * Interaction-based completion (same as FETCH_ITEM). + */ + DEMAND("Give me that %s. Now. [Right-click on me with the item]", 600, 0.0), + + /** + * Random Bind - Master puts a random bind/accessory on pet temporarily. + * This is not really a "task" but an event. + */ + RANDOM_BIND("Hold still...", 0, 0.0), + + /** + * Dogwalk - Master initiates a walk with the pet. + * Not a task per se, but a special event. + */ + DOGWALK("Time for a walk.", 0, 0.0); + + private final String messageTemplate; + private final int durationTicks; + private final double maxDistance; + + PetTask(String messageTemplate, int durationTicks, double maxDistance) { + this.messageTemplate = messageTemplate; + this.durationTicks = durationTicks; + this.maxDistance = maxDistance; + } + + /** + * Get the message template for this task. + * May contain %s for item names. + */ + public String getMessageTemplate() { + return messageTemplate; + } + + /** + * Get the duration of this task in ticks. + */ + public int getDurationTicks() { + return durationTicks; + } + + /** + * Get the maximum allowed distance for distance-based tasks. + * For HEEL: max distance from Master. + * For WAIT_HERE: max distance from starting position. + */ + public double getMaxDistance() { + return maxDistance; + } + + /** + * Check if this task requires distance monitoring. + */ + public boolean requiresDistanceCheck() { + return ( + this == HEEL || + this == WAIT_HERE || + this == FOLLOW_CLOSE || + this == KNEEL || + this == PRESENT + ); + } + + /** + * Check if this is a real task (vs an event like RANDOM_BIND or DOGWALK). + */ + public boolean isRealTask() { + return ( + this == HEEL || + this == WAIT_HERE || + this == FETCH_ITEM || + this == KNEEL || + this == COME || + this == PRESENT || + this == SPEAK || + this == DROP || + this == FOLLOW_CLOSE || + this == DEMAND + ); + } + + /** + * Check if this task requires the pet to be crouching. + */ + public boolean requiresCrouching() { + return this == KNEEL; + } + + /** + * Check if this task requires an interaction to complete. + */ + public boolean requiresInteraction() { + return this == SPEAK || this == DEMAND; + } + + /** + * Check if this task requires empty hands. + */ + public boolean requiresEmptyHands() { + return this == DROP; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/master/PunishmentType.java b/src/main/java/com/tiedup/remake/entities/ai/master/PunishmentType.java new file mode 100644 index 0000000..3ce4dd4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/master/PunishmentType.java @@ -0,0 +1,49 @@ +package com.tiedup.remake.entities.ai.master; + +/** + * Enum defining the types of punishments a Master can inflict on their pet. + */ +public enum PunishmentType { + /** Activate choke collar - requires pet to have a choke collar */ + CHOKE_COLLAR(80, "punishment.choke"), + + /** Apply temporary blindfold - requires blindfold slot empty */ + BLINDFOLD(2400, "punishment.blindfold"), + + /** Apply temporary gag - requires gag slot empty */ + GAG(2400, "punishment.gag"), + + /** Apply temporary mittens - requires mittens slot empty */ + MITTENS(2400, "punishment.mittens"), + + /** Apply armbinder - requires pet to not be bound */ + TIGHTEN_RESTRAINTS(0, "punishment.tighten"), + + /** Ignore pet interactions for a duration */ + COLD_SHOULDER(1200, "punishment.cold_shoulder"), + + /** Yank pet toward master with brief leash */ + LEASH_TUG(40, "punishment.leash_tug"); + + private final int durationTicks; + private final String dialogueId; + + PunishmentType(int durationTicks, String dialogueId) { + this.durationTicks = durationTicks; + this.dialogueId = dialogueId; + } + + /** + * Get the duration of this punishment in ticks. + */ + public int getDurationTicks() { + return durationTicks; + } + + /** + * Get the dialogue ID for this punishment type. + */ + public String getDialogueId() { + return dialogueId; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/AbstractNpcJobGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/AbstractNpcJobGoal.java new file mode 100644 index 0000000..f16ddf2 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/AbstractNpcJobGoal.java @@ -0,0 +1,290 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.NpcCommand; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * Base class for NPC job goals that follow the chest-hub pattern: + * scan zone for work targets, perform job-specific work, store results in chest. + * + *

Shared phases: SCANNING, STORING, IDLE. Subclasses define job-specific phases + * via their own enums and handle them in {@link #tickJobSpecific()}.

+ * + *

Phase dispatch uses an int to distinguish common vs job-specific phases: + *

    + *
  • {@link #PHASE_SCANNING} = 0
  • + *
  • {@link #PHASE_STORING} = 1
  • + *
  • {@link #PHASE_IDLE} = 2
  • + *
  • Values >= {@link #PHASE_JOB_SPECIFIC} are dispatched to {@link #tickJobSpecific()}
  • + *
+ */ +public abstract class AbstractNpcJobGoal extends Goal { + + protected static final int PHASE_SCANNING = 0; + protected static final int PHASE_STORING = 1; + protected static final int PHASE_IDLE = 2; + /** Subclass phases start at this value */ + protected static final int PHASE_JOB_SPECIFIC = 10; + + /** Work zone radius from chest */ + protected static final int WORK_RADIUS = 8; + + /** Ticks between zone scans */ + protected static final int SCAN_INTERVAL = 40; + + /** Ticks to wait when no work before considering idle */ + protected static final int IDLE_TIMEOUT = 400; + + /** Ticks between XP awards */ + protected static final int XP_INTERVAL = 1200; + + /** Max items to hold before storing */ + protected static final int MAX_HELD_ITEMS = 16; + + protected final EntityDamsel npc; + protected final double speedModifier; + + @Nullable protected BlockPos chestPos; + @Nullable protected Player master; + + protected List heldItems = new ArrayList<>(); + protected int phase; + protected int scanTimer; + protected int idleTimer; + protected int xpTimer; + protected int pathRecalcTimer; + + protected AbstractNpcJobGoal(EntityDamsel npc, double speedModifier) { + this.npc = npc; + this.speedModifier = speedModifier; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + // ==================== Abstract Methods ==================== + + /** The NpcCommand this goal serves (FARM, FISH, etc.) */ + protected abstract NpcCommand getCommand(); + + /** + * Find the nearest work target in the zone around the chest. + * @return a BlockPos if work is available, null otherwise + */ + @Nullable + protected abstract BlockPos findWorkTarget(); + + /** + * Called when a work target is found during scanning. + * Subclass should store the target and set its job-specific phase. + * @param target the work target position + */ + protected abstract void onWorkTargetFound(BlockPos target); + + /** + * Tick job-specific phases (anything not SCANNING, STORING, or IDLE). + * The current phase is available via {@link #phase}. + */ + protected abstract void tickJobSpecific(); + + /** + * Additional preconditions beyond collar + command check. + * Default returns true. Override to add checks (e.g. fishing rod in hand). + */ + protected boolean extraCanUse() { + return true; + } + + /** + * Additional continue conditions beyond collar + command check. + * Default returns true. + */ + protected boolean extraCanContinueToUse() { + return true; + } + + /** + * Called during start() after common init. Subclasses initialize their own fields here. + */ + protected abstract void onStart(); + + /** + * Called during stop() before common cleanup. Subclasses clean up their own fields here. + */ + protected abstract void onStop(); + + /** + * Hook called each tick during IDLE phase, after the re-scan check. + * Default is empty. Farm overrides this for dialogue. + */ + protected void onIdleTick() { + // Default: no-op + } + + /** + * Interact distance for this job (squared comparison). + * Default 2.0. Fish overrides to 2.5. + */ + protected double getInteractDistance() { + return 2.0; + } + + // ==================== Goal Lifecycle ==================== + + @Override + public boolean canUse() { + if (npc.getActiveCommand() != getCommand()) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + if (!extraCanUse()) { + return false; + } + this.master = NpcGoalHelper.findCommandingPlayer(npc); + return this.master != null; + } + + @Override + public boolean canContinueToUse() { + if (npc.getActiveCommand() != getCommand()) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + return extraCanContinueToUse(); + } + + @Override + public void start() { + NpcGoalHelper.ensureOutsideCell(npc); + var state = npc.getPersonalityState(); + if (state != null && state.getCommandTarget() != null) { + this.chestPos = state.getCommandTarget(); + } else { + npc.cancelCommand(); + return; + } + + this.phase = PHASE_SCANNING; + this.heldItems.clear(); + this.scanTimer = 0; + this.idleTimer = 0; + this.xpTimer = 0; + this.pathRecalcTimer = 0; + onStart(); + } + + @Override + public void stop() { + onStop(); + + if (!heldItems.isEmpty() && chestPos != null) { + NpcGoalHelper.storeItemsInChest(npc, npc.level(), chestPos, heldItems); + } + + this.chestPos = null; + this.master = null; + this.heldItems.clear(); + npc.getNavigation().stop(); + } + + @Override + public void tick() { + // Award XP periodically + if (++this.xpTimer >= XP_INTERVAL) { + this.xpTimer = 0; + NpcGoalHelper.incrementJobExperience(npc, getCommand()); + } + + switch (this.phase) { + case PHASE_SCANNING -> tickScanning(); + case PHASE_STORING -> tickStoring(); + case PHASE_IDLE -> tickIdle(); + default -> tickJobSpecific(); + } + } + + // ==================== Common Phase Ticks ==================== + + private void tickScanning() { + if (--this.scanTimer <= 0) { + float efficiency = NpcGoalHelper.getJobEfficiency(npc, getCommand()); + this.scanTimer = (int) (SCAN_INTERVAL / efficiency); + + // Check if we need to store items first + if (heldItems.size() >= MAX_HELD_ITEMS) { + this.phase = PHASE_STORING; + return; + } + + BlockPos target = findWorkTarget(); + + if (target != null) { + onWorkTargetFound(target); + this.idleTimer = 0; + } else { + // No work available + this.idleTimer += SCAN_INTERVAL; + + if (!heldItems.isEmpty()) { + this.phase = PHASE_STORING; + } else if (this.idleTimer >= IDLE_TIMEOUT) { + this.phase = PHASE_IDLE; + } + } + } + } + + private void tickStoring() { + if (this.chestPos == null) { + this.phase = PHASE_SCANNING; + return; + } + + // Move towards chest + if (--this.pathRecalcTimer <= 0) { + this.pathRecalcTimer = 10; + npc.getNavigation().moveTo( + chestPos.getX() + 0.5, + chestPos.getY(), + chestPos.getZ() + 0.5, + speedModifier + ); + } + + // Check if close enough to store + double dist = npc.blockPosition().distSqr(chestPos); + double interactDist = getInteractDistance(); + if (dist <= interactDist * interactDist) { + NpcGoalHelper.storeItemsInChest(npc, npc.level(), chestPos, heldItems); + heldItems.clear(); + this.phase = PHASE_SCANNING; + this.scanTimer = 0; + } + } + + private void tickIdle() { + // Periodically check for new work + if (--this.scanTimer <= 0) { + this.scanTimer = SCAN_INTERVAL * 2; + BlockPos target = findWorkTarget(); + + if (target != null) { + onWorkTargetFound(target); + this.idleTimer = 0; + return; + } + } + + onIdleTick(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcAutoEatGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcAutoEatGoal.java new file mode 100644 index 0000000..8a6d783 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcAutoEatGoal.java @@ -0,0 +1,258 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.NpcNeeds; +import com.tiedup.remake.personality.PersonalityState; +import java.util.EnumSet; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.food.FoodProperties; +import net.minecraft.world.item.ItemStack; + +/** + * AI Goal: NPC automatically eats food from their inventory when hungry. + * + *

Triggers when hunger drops below 30 (hungry) or 10 (starving). + * Searches inventory for edible items and consumes them. + * + *

Priority: Should run at priority 2 to allow interruption of lower goals + * but not override critical command goals. + */ +public class NpcAutoEatGoal extends Goal { + + private final EntityDamsel npc; + + /** Ticks between inventory scans when not eating */ + private static final int SCAN_INTERVAL = 40; + + /** Ticks to "eat" the food (animation time) */ + private static final int EAT_DURATION = 32; + + /** Hunger threshold to start seeking food */ + private static final float HUNGER_THRESHOLD = 30.0f; + + /** Critical hunger - higher priority */ + private static final float CRITICAL_THRESHOLD = 10.0f; + + private enum EatPhase { + IDLE, + SEARCHING, + EATING, + } + + private EatPhase phase = EatPhase.IDLE; + private int scanTimer = 0; + private int eatTimer = 0; + private int foodSlot = -1; + private ItemStack foodToEat = ItemStack.EMPTY; + + public NpcAutoEatGoal(EntityDamsel npc) { + this.npc = npc; + // Don't set MOVE flag - eating doesn't require movement + this.setFlags(EnumSet.noneOf(Goal.Flag.class)); + } + + @Override + public boolean canUse() { + PersonalityState state = npc.getPersonalityState(); + if (state == null) return false; + + NpcNeeds needs = state.getNeeds(); + + // Check if hungry + if (needs.getHunger() >= HUNGER_THRESHOLD) { + return false; + } + + // Don't interrupt if already doing a high-priority command + // (but allow eating during idle/patrol/etc) + return true; + } + + @Override + public boolean canContinueToUse() { + PersonalityState state = npc.getPersonalityState(); + if (state == null) return false; + + // Stop if no longer hungry (fed enough) + if (state.getNeeds().getHunger() >= 80.0f) { + return false; + } + + // Continue if still eating or searching + return ( + phase != EatPhase.IDLE || + state.getNeeds().getHunger() < HUNGER_THRESHOLD + ); + } + + @Override + public void start() { + this.phase = EatPhase.SEARCHING; + this.scanTimer = 0; + this.eatTimer = 0; + this.foodSlot = -1; + this.foodToEat = ItemStack.EMPTY; + } + + @Override + public void stop() { + this.phase = EatPhase.IDLE; + this.foodSlot = -1; + this.foodToEat = ItemStack.EMPTY; + } + + @Override + public boolean requiresUpdateEveryTick() { + return true; + } + + @Override + public void tick() { + switch (phase) { + case SEARCHING -> tickSearching(); + case EATING -> tickEating(); + case IDLE -> { + } // Do nothing + } + } + + private void tickSearching() { + if (--scanTimer > 0) return; + scanTimer = SCAN_INTERVAL; + + // Search inventory for food + var inventory = npc.getNpcInventory(); + int bestSlot = -1; + int bestNutrition = 0; + + for (int i = 0; i < npc.getNpcInventorySize(); i++) { + ItemStack stack = inventory.get(i); + if (stack.isEmpty()) continue; + + FoodProperties food = stack.getItem().getFoodProperties(); + if (food != null && food.getNutrition() > bestNutrition) { + bestSlot = i; + bestNutrition = food.getNutrition(); + } + } + + if (bestSlot >= 0) { + // Found food - start eating + this.foodSlot = bestSlot; + this.foodToEat = inventory.get(bestSlot); + this.phase = EatPhase.EATING; + this.eatTimer = EAT_DURATION; + + // Play eating sound start + npc + .level() + .playSound( + null, + npc.blockPosition(), + SoundEvents.GENERIC_EAT, + SoundSource.NEUTRAL, + 0.5f, + 1.0f + (npc.getRandom().nextFloat() - 0.5f) * 0.2f + ); + } else { + // No food found - go idle (will retry on next canUse check) + this.phase = EatPhase.IDLE; + } + } + + private void tickEating() { + if (--eatTimer > 0) { + // Still eating - play occasional eating particles/sounds + if (eatTimer % 8 == 0) { + // Eating particles + npc + .level() + .playSound( + null, + npc.blockPosition(), + SoundEvents.GENERIC_EAT, + SoundSource.NEUTRAL, + 0.3f, + 1.0f + (npc.getRandom().nextFloat() - 0.5f) * 0.4f + ); + } + return; + } + + // Finished eating - consume food and restore hunger + if (foodSlot >= 0 && !foodToEat.isEmpty()) { + var inventory = npc.getNpcInventory(); + ItemStack currentStack = inventory.get(foodSlot); + + // Verify food is still there (in case inventory changed) + if ( + !currentStack.isEmpty() && + ItemStack.isSameItem(currentStack, foodToEat) + ) { + FoodProperties food = currentStack + .getItem() + .getFoodProperties(); + + if (food != null) { + // Consume one food item + currentStack.shrink(1); + + // Feed the NPC + PersonalityState state = npc.getPersonalityState(); + if (state != null) { + // feed() multiplies by 5, so nutrition 6 = 30 hunger restored + state.getNeeds().feed(food.getNutrition()); + + // Slight mood boost from eating + state.modifyMood(2); + } + + // Burp sound on completion + npc + .level() + .playSound( + null, + npc.blockPosition(), + SoundEvents.PLAYER_BURP, + SoundSource.NEUTRAL, + 0.5f, + 1.0f + ); + } + } + } + + // Reset and check if still hungry + this.foodSlot = -1; + this.foodToEat = ItemStack.EMPTY; + + PersonalityState state = npc.getPersonalityState(); + if (state != null && state.getNeeds().getHunger() < HUNGER_THRESHOLD) { + // Still hungry - search for more food + this.phase = EatPhase.SEARCHING; + this.scanTimer = 10; // Quick re-scan + } else { + // Satisfied + this.phase = EatPhase.IDLE; + } + } + + /** + * Get the priority adjustment based on hunger level. + * More hungry = higher effective priority. + * + * @return Priority modifier (lower = more urgent) + */ + public int getAdjustedPriority() { + PersonalityState state = npc.getPersonalityState(); + if (state == null) return 2; + + float hunger = state.getNeeds().getHunger(); + if (hunger < CRITICAL_THRESHOLD) { + return 1; // Critical - very high priority + } + return 2; // Normal hungry priority + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcAutoRestGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcAutoRestGoal.java new file mode 100644 index 0000000..7e6d3ac --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcAutoRestGoal.java @@ -0,0 +1,398 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.HomeType; +import com.tiedup.remake.personality.NpcCommand; +import com.tiedup.remake.personality.NpcNeeds; +import com.tiedup.remake.personality.PersonalityState; +import java.util.EnumSet; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.ai.goal.Goal; + +/** + * AI Goal: NPC automatically goes home to rest when tired. + * + *

Triggers when: + *

    + *
  • Auto-rest is enabled
  • + *
  • Rest drops below 30 (tired)
  • + *
  • NPC has an assigned home
  • + *
+ * + *

Behavior: + *

    + *
  • Saves current command
  • + *
  • Walks to home position
  • + *
  • Rests until rest >= 70 (rested)
  • + *
  • Restores previous command
  • + *
+ * + *

Priority: 2 (passive goal, doesn't block other goals until moving) + */ +public class NpcAutoRestGoal extends Goal { + + private final EntityDamsel npc; + + /** Rest level below which NPC seeks rest */ + private static final float TIRED_THRESHOLD = 30.0f; + + /** Rest level to reach before stopping rest */ + private static final float RESTED_THRESHOLD = 70.0f; + + /** Critical exhaustion - higher priority */ + private static final float CRITICAL_THRESHOLD = 10.0f; + + /** Distance considered "arrived" at home */ + private static final double ARRIVAL_DISTANCE_SQ = 4.0; // 2 blocks squared + + /** Ticks between rest recovery ticks */ + private static final int REST_TICK_INTERVAL = 20; + + private enum RestPhase { + /** Checking if we should rest */ + CHECKING, + /** Walking to home position */ + MOVING_TO_HOME, + /** Resting at home */ + RESTING, + /** Done resting */ + DONE, + } + + private RestPhase phase = RestPhase.CHECKING; + private NpcCommand previousCommand; + private UUID previousCommandingPlayer; + private BlockPos previousCommandTarget; + private BlockPos homePos; + private BlockPos navigationTarget; + private int restTimer = 0; + + public NpcAutoRestGoal(EntityDamsel npc) { + this.npc = npc; + // Start as passive goal (no flags) + this.setFlags(EnumSet.noneOf(Goal.Flag.class)); + } + + @Override + public boolean canUse() { + PersonalityState state = npc.getPersonalityState(); + if (state == null) return false; + + // Check if auto-rest is enabled + if (!state.isAutoRestEnabled()) { + return false; + } + + NpcNeeds needs = state.getNeeds(); + + // Check if tired + if (needs.getRest() >= TIRED_THRESHOLD) { + return false; + } + + // Check if NPC has a home + if (!state.hasHome()) { + return false; + } + + // Don't start if already at home and IDLE (will rest naturally) + if (state.getActiveCommand() == NpcCommand.IDLE) { + BlockPos currentHome = state.getHomePos(); + if ( + currentHome != null && + npc.distanceToSqr( + currentHome.getX() + 0.5, + currentHome.getY(), + currentHome.getZ() + 0.5 + ) < + ARRIVAL_DISTANCE_SQ + ) { + return false; + } + } + + return true; + } + + @Override + public boolean canContinueToUse() { + PersonalityState state = npc.getPersonalityState(); + if (state == null) return false; + + // Stop if auto-rest was disabled + if (!state.isAutoRestEnabled()) { + return false; + } + + // Continue until rested + if (state.getNeeds().getRest() >= RESTED_THRESHOLD) { + return false; + } + + // Continue if not done + return phase != RestPhase.DONE; + } + + @Override + public void start() { + PersonalityState state = npc.getPersonalityState(); + if (state == null) return; + + // Save current command for restoration later + this.previousCommand = state.getActiveCommand(); + this.previousCommandingPlayer = state.getCommandingPlayer(); + this.previousCommandTarget = state.getCommandTarget(); + this.homePos = state.getHomePos(); + this.navigationTarget = NpcGoalHelper.getHomeNavigationTarget(npc); + + TiedUpMod.LOGGER.debug( + "[NpcAutoRestGoal] {} starting auto-rest, saving command: {}", + npc.getNpcName(), + previousCommand + ); + + // Start checking + this.phase = RestPhase.CHECKING; + this.restTimer = 0; + } + + @Override + public void stop() { + // Teleport outside cell if inside + NpcGoalHelper.ensureOutsideCell(npc); + + PersonalityState state = npc.getPersonalityState(); + + // Restore previous command if we have one and it wasn't GO_HOME + if ( + state != null && + previousCommand != null && + previousCommand != NpcCommand.GO_HOME + ) { + // Only restore if currently IDLE (we set it during resting) + if (state.getActiveCommand() == NpcCommand.IDLE) { + state.setActiveCommand( + previousCommand, + previousCommandingPlayer, + previousCommandTarget + ); + TiedUpMod.LOGGER.debug( + "[NpcAutoRestGoal] {} restored command: {}", + npc.getNpcName(), + previousCommand + ); + } + } + + // Reset rest pose + npc.setSitting(false); + npc.setKneeling(false); + + // Reset state + this.phase = RestPhase.CHECKING; + this.previousCommand = null; + this.previousCommandingPlayer = null; + this.previousCommandTarget = null; + this.homePos = null; + this.navigationTarget = null; + this.restTimer = 0; + + // Remove MOVE flag + this.setFlags(EnumSet.noneOf(Goal.Flag.class)); + npc.getNavigation().stop(); + } + + @Override + public boolean requiresUpdateEveryTick() { + return true; + } + + @Override + public void tick() { + switch (phase) { + case CHECKING -> tickChecking(); + case MOVING_TO_HOME -> tickMoving(); + case RESTING -> tickResting(); + case DONE -> { + } // Do nothing, will be stopped + } + } + + private void tickChecking() { + PersonalityState state = npc.getPersonalityState(); + if (state == null || homePos == null) { + phase = RestPhase.DONE; + return; + } + + // Check distance to navigation target (deliveryPoint or homePos) + double distanceSq = npc.distanceToSqr( + navigationTarget.getX() + 0.5, + navigationTarget.getY(), + navigationTarget.getZ() + 0.5 + ); + + if (distanceSq < ARRIVAL_DISTANCE_SQ) { + // Already at home - start resting + phase = RestPhase.RESTING; + restTimer = 0; + npc.getNavigation().stop(); + + // Teleport inside cell if navigated to delivery point + if (homePos != null && !navigationTarget.equals(homePos)) { + NpcGoalHelper.teleportTo(npc, homePos); + } + + // Set to IDLE while resting + state.setActiveCommand(NpcCommand.IDLE, null, homePos); + applyRestPose(); + + TiedUpMod.LOGGER.debug( + "[NpcAutoRestGoal] {} already at home, starting rest", + npc.getNpcName() + ); + } else { + // Need to walk home + phase = RestPhase.MOVING_TO_HOME; + + // Set MOVE flag now that we're navigating + this.setFlags(EnumSet.of(Goal.Flag.MOVE)); + + // Start navigation + npc + .getNavigation() + .moveTo( + navigationTarget.getX() + 0.5, + navigationTarget.getY(), + navigationTarget.getZ() + 0.5, + 1.0 + ); + + TiedUpMod.LOGGER.debug( + "[NpcAutoRestGoal] {} walking home to rest (distance: {})", + npc.getNpcName(), + Math.sqrt(distanceSq) + ); + } + } + + private void tickMoving() { + if (homePos == null) { + phase = RestPhase.DONE; + return; + } + + // Check if arrived at navigation target + double distanceSq = npc.distanceToSqr( + navigationTarget.getX() + 0.5, + navigationTarget.getY(), + navigationTarget.getZ() + 0.5 + ); + + if (distanceSq < ARRIVAL_DISTANCE_SQ) { + // Arrived at home - start resting + phase = RestPhase.RESTING; + restTimer = 0; + npc.getNavigation().stop(); + + // Teleport inside cell if navigated to delivery point + if (homePos != null && !navigationTarget.equals(homePos)) { + NpcGoalHelper.teleportTo(npc, homePos); + } + + // Remove MOVE flag during rest + this.setFlags(EnumSet.noneOf(Goal.Flag.class)); + + // Set to IDLE while resting + PersonalityState state = npc.getPersonalityState(); + if (state != null) { + state.setActiveCommand(NpcCommand.IDLE, null, homePos); + applyRestPose(); + } + + TiedUpMod.LOGGER.debug( + "[NpcAutoRestGoal] {} arrived home, starting rest", + npc.getNpcName() + ); + } else { + // Still moving - keep navigation going + if (npc.getNavigation().isDone()) { + // Path finished but not there - try again + npc + .getNavigation() + .moveTo( + navigationTarget.getX() + 0.5, + navigationTarget.getY(), + navigationTarget.getZ() + 0.5, + 1.0 + ); + } + } + } + + private void tickResting() { + restTimer++; + + // Only process rest every interval + if (restTimer % REST_TICK_INTERVAL != 0) { + return; + } + + PersonalityState state = npc.getPersonalityState(); + if (state == null) { + phase = RestPhase.DONE; + return; + } + + NpcNeeds needs = state.getNeeds(); + float currentRest = needs.getRest(); + + // Check if rested enough + if (currentRest >= RESTED_THRESHOLD) { + TiedUpMod.LOGGER.debug( + "[NpcAutoRestGoal] {} finished resting (rest: {})", + npc.getNpcName(), + currentRest + ); + phase = RestPhase.DONE; + return; + } + + // The actual rest recovery is handled by NpcNeeds.tick() + // We just need to keep the NPC at home in IDLE state + } + + /** + * Apply appropriate rest pose based on home type. + */ + private void applyRestPose() { + PersonalityState state = npc.getPersonalityState(); + if (state == null) return; + + switch (state.getHomeType()) { + case BED, PET_BED -> npc.setSitting(true); + case CELL -> npc.setKneeling(true); + default -> { + } // NONE - no pose + } + } + + /** + * Get the priority adjustment based on exhaustion level. + * More tired = higher effective priority. + * + * @return Priority modifier (lower = more urgent) + */ + public int getAdjustedPriority() { + PersonalityState state = npc.getPersonalityState(); + if (state == null) return 2; + + float rest = state.getNeeds().getRest(); + if (rest < CRITICAL_THRESHOLD) { + return 1; // Critical exhaustion - very high priority + } + return 2; // Normal tired priority + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcBreedCommandGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcBreedCommandGoal.java new file mode 100644 index 0000000..9b118e7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcBreedCommandGoal.java @@ -0,0 +1,380 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.NpcCommand; +import com.tiedup.remake.personality.PersonalityState; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.Container; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.animal.Animal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * AI Goal: NPC breeds animals in a zone around a chest hub. + * + *

Takes food from chest, feeds pairs of same-species animals to breed them.

+ *

Cap: max 8 animals per species in zone to prevent lag.

+ */ +public class NpcBreedCommandGoal extends Goal { + + private final EntityDamsel npc; + private final double speedModifier; + + private static final int WORK_RADIUS = 16; + private static final double INTERACT_DISTANCE = 2.0; + private static final int SCAN_INTERVAL = 60; + private static final int IDLE_TIMEOUT = 400; + private static final int XP_INTERVAL = 1200; + private static final int FEED_COOLDOWN = 30; + private static final int MAX_ANIMALS_PER_SPECIES = 8; + private static final float WORK_INTENSITY = 0.7f; + + private enum BreedPhase { + SCANNING, + MOVING, + FEEDING, + IDLE, + } + + private BreedPhase phase; + + @Nullable + private BlockPos chestPos; + + @Nullable + private Animal targetAnimal; + + @Nullable + private Animal partnerAnimal; + + @Nullable + private Player master; + + @Nullable + private ItemStack heldFood; + + private int scanTimer; + private int idleTimer; + private int xpTimer; + private int pathRecalcTimer; + private int feedTimer; + private boolean fedFirst; + + public NpcBreedCommandGoal(EntityDamsel npc, double speedModifier) { + this.npc = npc; + this.speedModifier = speedModifier; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (npc.getActiveCommand() != NpcCommand.BREED) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + + this.master = NpcGoalHelper.findCommandingPlayer(npc); + return this.master != null; + } + + @Override + public boolean canContinueToUse() { + if (npc.getActiveCommand() != NpcCommand.BREED) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + return true; + } + + @Override + public void start() { + NpcGoalHelper.ensureOutsideCell(npc); + var state = npc.getPersonalityState(); + if (state != null && state.getCommandTarget() != null) { + this.chestPos = state.getCommandTarget(); + } else { + npc.cancelCommand(); + return; + } + + this.phase = BreedPhase.SCANNING; + this.scanTimer = 0; + this.idleTimer = 0; + this.xpTimer = 0; + this.pathRecalcTimer = 0; + this.feedTimer = 0; + this.targetAnimal = null; + this.partnerAnimal = null; + this.heldFood = null; + this.fedFirst = false; + } + + @Override + public void stop() { + this.chestPos = null; + this.targetAnimal = null; + this.partnerAnimal = null; + this.master = null; + this.heldFood = null; + npc.getNavigation().stop(); + } + + @Override + public void tick() { + // Award XP periodically + if (++this.xpTimer >= XP_INTERVAL) { + this.xpTimer = 0; + awardXP(2); + NpcGoalHelper.incrementJobExperience(npc, NpcCommand.BREED); + } + + switch (this.phase) { + case SCANNING -> tickScanning(); + case MOVING -> tickMoving(); + case FEEDING -> tickFeeding(); + case IDLE -> tickIdle(); + } + } + + private void tickScanning() { + if (--this.scanTimer <= 0) { + float efficiency = NpcGoalHelper.getJobEfficiency( + npc, + NpcCommand.BREED + ); + this.scanTimer = (int) (SCAN_INTERVAL / efficiency); + + // Find a breedable pair + if (findBreedablePair()) { + // Try to get food from chest + this.heldFood = takeFoodFromChest(); + if (this.heldFood != null) { + this.phase = BreedPhase.MOVING; + this.idleTimer = 0; + this.fedFirst = false; + } else { + // No suitable food in chest + this.idleTimer += SCAN_INTERVAL; + if (this.idleTimer >= IDLE_TIMEOUT) { + this.phase = BreedPhase.IDLE; + } + } + } else { + this.idleTimer += SCAN_INTERVAL; + if (this.idleTimer >= IDLE_TIMEOUT) { + this.phase = BreedPhase.IDLE; + } + } + } + } + + private void tickMoving() { + Animal target = this.fedFirst ? this.partnerAnimal : this.targetAnimal; + + if (target == null || !target.isAlive()) { + this.phase = BreedPhase.SCANNING; + this.scanTimer = 0; + return; + } + + if (--this.pathRecalcTimer <= 0) { + this.pathRecalcTimer = 10; + npc.getNavigation().moveTo(target, speedModifier); + } + + npc.getLookControl().setLookAt(target, 30.0f, 30.0f); + + double dist = npc.distanceToSqr(target); + if (dist <= INTERACT_DISTANCE * INTERACT_DISTANCE) { + npc.getNavigation().stop(); + this.phase = BreedPhase.FEEDING; + this.feedTimer = FEED_COOLDOWN; + } + } + + private void tickFeeding() { + Animal target = this.fedFirst ? this.partnerAnimal : this.targetAnimal; + + if (target == null || !target.isAlive()) { + this.phase = BreedPhase.SCANNING; + this.scanTimer = 0; + return; + } + + npc.getLookControl().setLookAt(target, 30.0f, 30.0f); + + if (--this.feedTimer > 0) { + return; + } + + // Feed the animal + target.setInLove(null); + + npc + .level() + .playSound( + null, + target.getX(), + target.getY(), + target.getZ(), + SoundEvents.GENERIC_EAT, + SoundSource.NEUTRAL, + 1.0f, + 1.0f + ); + + if (!this.fedFirst) { + // Feed the partner next + this.fedFirst = true; + this.phase = BreedPhase.MOVING; + this.pathRecalcTimer = 0; + } else { + // Both fed, breeding triggered + awardXP(2); + + // Drain rest + PersonalityState personalityState = npc.getPersonalityState(); + if (personalityState != null) { + personalityState.getNeeds().drainFromWork(WORK_INTENSITY); + } + + this.targetAnimal = null; + this.partnerAnimal = null; + this.heldFood = null; + this.phase = BreedPhase.SCANNING; + this.scanTimer = 40; // Short delay + } + } + + private void tickIdle() { + if (--this.scanTimer <= 0) { + this.scanTimer = SCAN_INTERVAL * 2; + + if (findBreedablePair()) { + this.heldFood = takeFoodFromChest(); + if (this.heldFood != null) { + this.phase = BreedPhase.MOVING; + this.idleTimer = 0; + this.fedFirst = false; + } + } + } + } + + private boolean findBreedablePair() { + Level level = npc.level(); + + // Find all animals in zone + List animals = level.getEntitiesOfClass( + Animal.class, + npc.getBoundingBox().inflate(WORK_RADIUS), + animal -> + animal.isAlive() && !animal.isBaby() && animal.canFallInLove() + ); + + if (animals.size() < 2) return false; + + // Group by species type + Map, List> bySpecies = animals + .stream() + .collect(Collectors.groupingBy(Animal::getType)); + + for (var entry : bySpecies.entrySet()) { + List speciesAnimals = entry.getValue(); + + // Check cap + if (speciesAnimals.size() >= MAX_ANIMALS_PER_SPECIES) { + continue; // Too many of this species already + } + + // Need at least 2 non-baby, non-in-love animals + List breedable = speciesAnimals + .stream() + .filter(a -> !a.isBaby() && a.canFallInLove()) + .toList(); + + if (breedable.size() >= 2) { + // Check if we have food for this species in the chest + if (hasFoodForAnimal(breedable.get(0))) { + this.targetAnimal = breedable.get(0); + this.partnerAnimal = breedable.get(1); + return true; + } + } + } + + return false; + } + + private boolean hasFoodForAnimal(Animal animal) { + if (chestPos == null) return false; + + Container container = NpcGoalHelper.getChestContainer( + npc.level(), + chestPos + ); + if (container == null) return false; + + for (int i = 0; i < container.getContainerSize(); i++) { + ItemStack stack = container.getItem(i); + if ( + !stack.isEmpty() && + animal.isFood(stack) && + stack.getCount() >= 2 + ) { + return true; + } + } + + return false; + } + + @Nullable + private ItemStack takeFoodFromChest() { + if (chestPos == null || targetAnimal == null) return null; + + Container container = NpcGoalHelper.getChestContainer( + npc.level(), + chestPos + ); + if (container == null) return null; + + for (int i = 0; i < container.getContainerSize(); i++) { + ItemStack stack = container.getItem(i); + if ( + !stack.isEmpty() && + targetAnimal.isFood(stack) && + stack.getCount() >= 2 + ) { + // Take 2 food items (one for each animal) + ItemStack taken = stack.split(2); + if (stack.isEmpty()) { + container.setItem(i, ItemStack.EMPTY); + } + container.setChanged(); + return taken; + } + } + + return null; + } + + private void awardXP(int amount) { + if (this.master != null) { + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcCollectCommandGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcCollectCommandGoal.java new file mode 100644 index 0000000..804c02d --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcCollectCommandGoal.java @@ -0,0 +1,320 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.NpcCommand; +import com.tiedup.remake.personality.PersonalityState; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.AABB; + +/** + * AI Goal: NPC collects all items in a zone and brings them to master. + * + * Personality System Phase D: AI Goals + * + *

Behavior:

+ *
    + *
  • Only active when NPC has COLLECT command
  • + *
  • Scans zone for items
  • + *
  • Picks up items and stores them (up to inventory capacity)
  • + *
  • Returns to master when full or zone is clear
  • + *
  • Repeats until zone stays clear for a while
  • + *
  • Awards XP periodically and on completion
  • + *
+ */ +public class NpcCollectCommandGoal extends Goal { + + private final EntityDamsel npc; + private final double speedModifier; + + /** Collection zone radius */ + private static final int COLLECT_RADIUS = 12; + + /** Max items to hold before returning */ + private static final int MAX_HELD_ITEMS = 8; + + /** Distance to pick up item */ + private static final double PICKUP_DISTANCE = 1.5; + + /** Distance to deliver to master */ + private static final double DELIVER_DISTANCE = 2.0; + + /** Ticks between zone scans */ + private static final int SCAN_INTERVAL = 60; + + /** Ticks to wait when zone is empty before considering done */ + private static final int EMPTY_ZONE_TIMEOUT = 200; + + /** Ticks between XP awards */ + private static final int XP_INTERVAL = 1200; // 1 minute + + /** Rest drain intensity for collecting (light work) */ + private static final float WORK_INTENSITY = 0.7f; + + private enum CollectPhase { + SCANNING, + COLLECTING, + RETURNING, + } + + private CollectPhase phase; + + @Nullable + private BlockPos collectCenter; + + @Nullable + private ItemEntity targetItem; + + @Nullable + private Player master; + + private List collectedItems = new ArrayList<>(); + private int scanTimer; + private int emptyZoneTimer; + private int xpTimer; + private int pathRecalcTimer; + + public NpcCollectCommandGoal(EntityDamsel npc, double speedModifier) { + this.npc = npc; + this.speedModifier = speedModifier; + this.setFlags(EnumSet.of(Goal.Flag.MOVE)); + } + + @Override + public boolean canUse() { + if (npc.getActiveCommand() != NpcCommand.COLLECT) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + + this.master = NpcGoalHelper.findCommandingPlayer(npc); + return this.master != null; + } + + @Override + public boolean canContinueToUse() { + if (npc.getActiveCommand() != NpcCommand.COLLECT) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + if (this.master == null || !this.master.isAlive()) { + return false; + } + return true; + } + + @Override + public void start() { + NpcGoalHelper.ensureOutsideCell(npc); + var state = npc.getPersonalityState(); + if (state != null && state.getCommandTarget() != null) { + this.collectCenter = state.getCommandTarget(); + } else { + this.collectCenter = npc.blockPosition(); + } + + this.phase = CollectPhase.SCANNING; + this.collectedItems.clear(); + this.scanTimer = 0; + this.emptyZoneTimer = 0; + this.xpTimer = 0; + this.pathRecalcTimer = 0; + this.targetItem = null; + } + + @Override + public void stop() { + // Deliver any remaining items + if (!collectedItems.isEmpty() && master != null) { + deliverItems(); + } + + this.collectCenter = null; + this.targetItem = null; + this.master = null; + this.collectedItems.clear(); + npc.getNavigation().stop(); + } + + @Override + public void tick() { + // Award XP periodically + if (++this.xpTimer >= XP_INTERVAL) { + this.xpTimer = 0; + awardXP(2); + NpcGoalHelper.incrementJobExperience(npc, NpcCommand.COLLECT); + } + + switch (this.phase) { + case SCANNING -> tickScanning(); + case COLLECTING -> tickCollecting(); + case RETURNING -> tickReturning(); + } + } + + private void tickScanning() { + if (--this.scanTimer <= 0) { + // Apply efficiency modifier - tired/inexperienced NPCs scan slower + float efficiency = NpcGoalHelper.getJobEfficiency( + npc, + NpcCommand.COLLECT + ); + this.scanTimer = (int) (SCAN_INTERVAL / efficiency); + + // Find items in zone + this.targetItem = findNearestItemInZone(); + + if (this.targetItem != null) { + this.phase = CollectPhase.COLLECTING; + this.emptyZoneTimer = 0; + } else { + // Zone is empty + this.emptyZoneTimer += SCAN_INTERVAL; + + // If we have items, return them + if (!collectedItems.isEmpty()) { + this.phase = CollectPhase.RETURNING; + } else if (this.emptyZoneTimer >= EMPTY_ZONE_TIMEOUT) { + // Zone has been empty for a while, job complete + awardXP(NpcCommand.COLLECT.getCompletionXP()); + npc.cancelCommand(); + } + } + } + } + + private void tickCollecting() { + if (this.targetItem == null || !this.targetItem.isAlive()) { + // Item gone, scan again + this.phase = CollectPhase.SCANNING; + this.scanTimer = 0; + return; + } + + // Move towards item + if (--this.pathRecalcTimer <= 0) { + this.pathRecalcTimer = 10; + npc.getNavigation().moveTo(targetItem, speedModifier); + } + + // Check if close enough to pick up + double dist = npc.distanceTo(targetItem); + if (dist <= PICKUP_DISTANCE) { + pickUpItem(); + } + } + + private void tickReturning() { + if (this.master == null || !this.master.isAlive()) { + return; + } + + // Move towards master + if (--this.pathRecalcTimer <= 0) { + this.pathRecalcTimer = 10; + npc.getNavigation().moveTo(master, speedModifier); + } + + // Check if close enough to deliver + double dist = npc.distanceTo(master); + if (dist <= DELIVER_DISTANCE) { + deliverItems(); + // Go back to scanning + this.phase = CollectPhase.SCANNING; + this.scanTimer = 0; + } + } + + private void pickUpItem() { + if (this.targetItem == null) return; + + // Add to collected items + collectedItems.add(targetItem.getItem().copy()); + + // Apply yield bonus from experience + float yieldMult = NpcGoalHelper.getJobYieldMultiplier( + npc, + NpcCommand.COLLECT + ); + if ( + yieldMult > 1.0f && npc.getRandom().nextFloat() < (yieldMult - 1.0f) + ) { + collectedItems.add(targetItem.getItem().copy()); // Bonus duplicate + } + + targetItem.discard(); + this.targetItem = null; + + // Drain rest from work + PersonalityState personalityState = npc.getPersonalityState(); + if (personalityState != null) { + personalityState.getNeeds().drainFromWork(WORK_INTENSITY); + } + + // Check if full + if (collectedItems.size() >= MAX_HELD_ITEMS) { + this.phase = CollectPhase.RETURNING; + } else { + this.phase = CollectPhase.SCANNING; + this.scanTimer = 0; + } + } + + private void deliverItems() { + if (this.master == null) return; + + for (ItemStack item : collectedItems) { + if (!master.getInventory().add(item.copy())) { + // Inventory full, drop it + npc.spawnAtLocation(item.copy()); + } + } + + int itemCount = collectedItems.size(); + collectedItems.clear(); + + // Bonus XP for delivering items + if (itemCount > 0) { + awardXP(1); + } + } + + @Nullable + private ItemEntity findNearestItemInZone() { + if (this.collectCenter == null) return null; + + AABB searchArea = new AABB(collectCenter).inflate(COLLECT_RADIUS); + List items = npc + .level() + .getEntitiesOfClass(ItemEntity.class, searchArea); + + ItemEntity nearest = null; + double nearestDist = Double.MAX_VALUE; + + for (ItemEntity item : items) { + double dist = npc.distanceToSqr(item); + if (dist < nearestDist) { + nearest = item; + nearestDist = dist; + } + } + + return nearest; + } + + private void awardXP(int amount) { + if (this.master != null) { + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcComeCommandGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcComeCommandGoal.java new file mode 100644 index 0000000..34f2361 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcComeCommandGoal.java @@ -0,0 +1,147 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.NpcCommand; +import java.util.EnumSet; +import javax.annotation.Nullable; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; + +/** + * AI Goal: NPC comes to their master when COME command is given. + * + * Personality System Phase D: AI Goals + * + *

Behavior:

+ *
    + *
  • Only active when NPC has COME command
  • + *
  • NPC walks to master's position
  • + *
  • Completes (awards XP) when within 2 blocks
  • + *
  • Instant command - auto-cancels on completion
  • + *
+ */ +public class NpcComeCommandGoal extends Goal { + + private final EntityDamsel npc; + private final double speedModifier; + + /** Distance at which command is considered complete */ + private static final double COMPLETION_DISTANCE = 2.0; + + /** Maximum time to reach master before giving up (10 seconds) */ + private static final int MAX_TICKS = 200; + + /** Ticks between path recalculations */ + private static final int PATH_RECALC_INTERVAL = 10; + + @Nullable + private LivingEntity master; + + private int ticksRunning; + private int pathRecalcTimer; + + public NpcComeCommandGoal(EntityDamsel npc, double speedModifier) { + this.npc = npc; + this.speedModifier = speedModifier; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Must have COME command active + if (npc.getActiveCommand() != NpcCommand.COME) { + return false; + } + + // Must have collar + if (!npc.hasCollar()) { + return false; + } + + // Find the commanding player + this.master = NpcGoalHelper.findCommandingPlayer(npc); + return this.master != null; + } + + @Override + public boolean canContinueToUse() { + // Stop if command changed + if (npc.getActiveCommand() != NpcCommand.COME) { + return false; + } + + // Stop if no collar + if (!npc.hasCollar()) { + return false; + } + + // Stop if master is gone + if (this.master == null || !this.master.isAlive()) { + return false; + } + + // Stop if master is in different dimension + if (this.master.level() != this.npc.level()) { + return false; + } + + // Stop if took too long + if (this.ticksRunning >= MAX_TICKS) { + return false; + } + + // Stop if reached master (success!) + double distance = this.npc.distanceTo(this.master); + if (distance <= COMPLETION_DISTANCE) { + return false; + } + + return true; + } + + @Override + public void start() { + this.ticksRunning = 0; + this.pathRecalcTimer = 0; + } + + @Override + public void stop() { + // Check if completed successfully + if (this.master != null) { + double distance = this.npc.distanceTo(this.master); + if (distance <= COMPLETION_DISTANCE) { + // Success! Award XP + if (this.master instanceof Player player) { + // Command completed + } + } + } + + // Cancel command (instant command completes) + this.npc.cancelCommand(); + this.master = null; + this.npc.getNavigation().stop(); + } + + @Override + public void tick() { + if (this.master == null) return; + + this.ticksRunning++; + + // Look at master + this.npc.getLookControl().setLookAt( + this.master, + 10.0F, + (float) this.npc.getMaxHeadXRot() + ); + + // Move towards master + if (--this.pathRecalcTimer <= 0) { + this.pathRecalcTimer = PATH_RECALC_INTERVAL; + this.npc.getNavigation().moveTo(this.master, this.speedModifier); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcCookCommandGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcCookCommandGoal.java new file mode 100644 index 0000000..262713b --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcCookCommandGoal.java @@ -0,0 +1,530 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.NpcCommand; +import com.tiedup.remake.personality.PersonalityState; +import java.util.EnumSet; +import java.util.List; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.world.Container; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.crafting.RecipeType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.AbstractFurnaceBlock; +import net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +/** + * AI Goal: NPC cooks items using furnaces, with resources from chest hub. + * + *

Chest-Hub System:

+ *
    + *
  • Chest provides items to cook and fuel
  • + *
  • Cooked items are stored back in chest
  • + *
  • Searches for furnaces in work zone
  • + *
+ * + *

Behavior:

+ *
    + *
  • Scan zone for furnaces
  • + *
  • Load items from chest into furnace
  • + *
  • Wait for cooking to complete
  • + *
  • Collect output and store in chest
  • + *
  • Repeat while cookable items remain
  • + *
+ */ +public class NpcCookCommandGoal extends Goal { + + private final EntityDamsel npc; + private final double speedModifier; + + private static final int WORK_RADIUS = 8; + private static final double INTERACT_DISTANCE = 2.0; + private static final int SCAN_INTERVAL = 60; + private static final int WAIT_CHECK_INTERVAL = 40; + private static final int IDLE_TIMEOUT = 600; + private static final int XP_INTERVAL = 1200; + + /** Rest drain intensity for cooking (heavy work) */ + private static final float WORK_INTENSITY = 1.3f; + + private enum CookPhase { + SCANNING, + LOADING, + WAITING, + COLLECTING, + STORING, + IDLE, + } + + private CookPhase phase; + + @Nullable + private BlockPos chestPos; + + @Nullable + private BlockPos targetFurnace; + + @Nullable + private Player master; + + private ItemStack heldOutput = ItemStack.EMPTY; + private int scanTimer; + private int waitTimer; + private int idleTimer; + private int xpTimer; + private int pathRecalcTimer; + + public NpcCookCommandGoal(EntityDamsel npc, double speedModifier) { + this.npc = npc; + this.speedModifier = speedModifier; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (npc.getActiveCommand() != NpcCommand.COOK) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + + this.master = NpcGoalHelper.findCommandingPlayer(npc); + return this.master != null; + } + + @Override + public boolean canContinueToUse() { + if (npc.getActiveCommand() != NpcCommand.COOK) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + return true; + } + + @Override + public void start() { + NpcGoalHelper.ensureOutsideCell(npc); + var state = npc.getPersonalityState(); + if (state != null && state.getCommandTarget() != null) { + this.chestPos = state.getCommandTarget(); + } else { + npc.cancelCommand(); + return; + } + + this.phase = CookPhase.SCANNING; + this.heldOutput = ItemStack.EMPTY; + this.scanTimer = 0; + this.waitTimer = 0; + this.idleTimer = 0; + this.xpTimer = 0; + this.pathRecalcTimer = 0; + this.targetFurnace = null; + } + + @Override + public void stop() { + // Store any held items + if (!heldOutput.isEmpty() && chestPos != null) { + storeInChest(heldOutput); + heldOutput = ItemStack.EMPTY; + } + + this.chestPos = null; + this.targetFurnace = null; + this.master = null; + npc.getNavigation().stop(); + } + + @Override + public void tick() { + if (++this.xpTimer >= XP_INTERVAL) { + this.xpTimer = 0; + awardXP(2); + NpcGoalHelper.incrementJobExperience(npc, NpcCommand.COOK); + } + + switch (this.phase) { + case SCANNING -> tickScanning(); + case LOADING -> tickLoading(); + case WAITING -> tickWaiting(); + case COLLECTING -> tickCollecting(); + case STORING -> tickStoring(); + case IDLE -> tickIdle(); + } + } + + private void tickScanning() { + if (--this.scanTimer <= 0) { + // Apply efficiency modifier - tired/inexperienced NPCs scan slower + float efficiency = NpcGoalHelper.getJobEfficiency( + npc, + NpcCommand.COOK + ); + this.scanTimer = (int) (SCAN_INTERVAL / efficiency); + + // First check if we have output to store + if (!heldOutput.isEmpty()) { + this.phase = CookPhase.STORING; + return; + } + + // Find a furnace that needs attention + this.targetFurnace = findFurnaceNeedingWork(); + + if (this.targetFurnace != null) { + // Determine what to do with the furnace + AbstractFurnaceBlockEntity furnace = getFurnaceEntity( + targetFurnace + ); + if (furnace != null) { + if (!furnace.getItem(2).isEmpty()) { + // Has output - collect it + this.phase = CookPhase.COLLECTING; + } else if ( + furnace.getItem(0).isEmpty() && hasCookableInChest() + ) { + // Empty and we have cookables - load it + this.phase = CookPhase.LOADING; + } else if (!furnace.getItem(0).isEmpty()) { + // Cooking - wait + this.phase = CookPhase.WAITING; + } + } + this.idleTimer = 0; + } else { + // No work available + this.idleTimer += SCAN_INTERVAL; + + if (this.idleTimer >= IDLE_TIMEOUT) { + this.phase = CookPhase.IDLE; + } + } + } + } + + private void tickLoading() { + if (targetFurnace == null || chestPos == null) { + this.phase = CookPhase.SCANNING; + return; + } + + // Move towards furnace + if (moveTowards(targetFurnace)) { + // Close enough - load furnace + AbstractFurnaceBlockEntity furnace = getFurnaceEntity( + targetFurnace + ); + if (furnace != null) { + // Get cookable item from chest + ItemStack cookable = extractCookableFromChest(); + if (!cookable.isEmpty()) { + furnace.setItem(0, cookable); + + // Add fuel if needed + if ( + furnace.getItem(1).isEmpty() || + furnace.getItem(1).getCount() < 8 + ) { + ItemStack fuel = extractFuelFromChest(); + if (!fuel.isEmpty()) { + ItemStack existingFuel = furnace.getItem(1); + if (existingFuel.isEmpty()) { + furnace.setItem(1, fuel); + } else if ( + ItemStack.isSameItemSameTags(existingFuel, fuel) + ) { + existingFuel.grow(fuel.getCount()); + } + } + } + + awardXP(1); + + // Drain rest from work + PersonalityState personalityState = + npc.getPersonalityState(); + if (personalityState != null) { + personalityState + .getNeeds() + .drainFromWork(WORK_INTENSITY); + } + } + } + + this.targetFurnace = null; + this.phase = CookPhase.SCANNING; + this.scanTimer = 20; + } + } + + private void tickWaiting() { + if (--this.waitTimer <= 0) { + this.waitTimer = WAIT_CHECK_INTERVAL; + + // Check if furnace is done + if (targetFurnace != null) { + AbstractFurnaceBlockEntity furnace = getFurnaceEntity( + targetFurnace + ); + if (furnace != null && !furnace.getItem(2).isEmpty()) { + this.phase = CookPhase.COLLECTING; + return; + } + } + } + + // While waiting, stay near furnace area but don't block + if (targetFurnace != null) { + double dist = npc.blockPosition().distSqr(targetFurnace); + if (dist > 16) { + moveTowards(targetFurnace); + } + } + } + + private void tickCollecting() { + if (targetFurnace == null) { + this.phase = CookPhase.SCANNING; + return; + } + + if (moveTowards(targetFurnace)) { + // Close enough - collect output + AbstractFurnaceBlockEntity furnace = getFurnaceEntity( + targetFurnace + ); + if (furnace != null) { + ItemStack output = furnace.getItem(2); + if (!output.isEmpty()) { + this.heldOutput = output.copy(); + furnace.setItem(2, ItemStack.EMPTY); + + // Apply yield bonus from experience + float yieldMult = NpcGoalHelper.getJobYieldMultiplier( + npc, + NpcCommand.COOK + ); + if ( + yieldMult > 1.0f && + npc.getRandom().nextFloat() < (yieldMult - 1.0f) + ) { + this.heldOutput.grow(1); // Bonus item + } + + awardXP(2); + + // Drain rest from work + PersonalityState personalityState = + npc.getPersonalityState(); + if (personalityState != null) { + personalityState + .getNeeds() + .drainFromWork(WORK_INTENSITY); + } + } + } + + this.targetFurnace = null; + this.phase = CookPhase.STORING; + } + } + + private void tickStoring() { + if (chestPos == null || heldOutput.isEmpty()) { + this.heldOutput = ItemStack.EMPTY; + this.phase = CookPhase.SCANNING; + return; + } + + if (moveTowards(chestPos)) { + storeInChest(heldOutput); + this.heldOutput = ItemStack.EMPTY; + this.phase = CookPhase.SCANNING; + this.scanTimer = 0; + } + } + + private void tickIdle() { + if (--this.scanTimer <= 0) { + this.scanTimer = SCAN_INTERVAL * 2; + + // Check for new work + this.targetFurnace = findFurnaceNeedingWork(); + if (this.targetFurnace != null || hasCookableInChest()) { + this.phase = CookPhase.SCANNING; + this.idleTimer = 0; + } + } + } + + private boolean moveTowards(BlockPos pos) { + if (--this.pathRecalcTimer <= 0) { + this.pathRecalcTimer = 10; + npc + .getNavigation() + .moveTo( + pos.getX() + 0.5, + pos.getY(), + pos.getZ() + 0.5, + speedModifier + ); + } + + double dist = npc.blockPosition().distSqr(pos); + return dist <= INTERACT_DISTANCE * INTERACT_DISTANCE; + } + + @Nullable + private BlockPos findFurnaceNeedingWork() { + if (chestPos == null) return null; + + Level level = npc.level(); + BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos(); + + for (int x = -WORK_RADIUS; x <= WORK_RADIUS; x++) { + for (int z = -WORK_RADIUS; z <= WORK_RADIUS; z++) { + for (int y = -2; y <= 2; y++) { + mutable.set( + chestPos.getX() + x, + chestPos.getY() + y, + chestPos.getZ() + z + ); + BlockState state = level.getBlockState(mutable); + + if (state.getBlock() instanceof AbstractFurnaceBlock) { + AbstractFurnaceBlockEntity furnace = getFurnaceEntity( + mutable + ); + if (furnace != null) { + // Check if furnace needs work + if (!furnace.getItem(2).isEmpty()) { + // Has output to collect + return mutable.immutable(); + } + if ( + furnace.getItem(0).isEmpty() && + hasCookableInChest() + ) { + // Empty and we have items to cook + return mutable.immutable(); + } + } + } + } + } + } + + return null; + } + + @Nullable + private AbstractFurnaceBlockEntity getFurnaceEntity(BlockPos pos) { + if ( + npc.level().getBlockEntity(pos) instanceof + AbstractFurnaceBlockEntity furnace + ) { + return furnace; + } + return null; + } + + private boolean hasCookableInChest() { + if (chestPos == null) return false; + + Container container = getChestContainer(); + if (container == null) return false; + + Level level = npc.level(); + for (int i = 0; i < container.getContainerSize(); i++) { + ItemStack stack = container.getItem(i); + if (!stack.isEmpty() && isCookable(stack, level)) { + return true; + } + } + return false; + } + + private boolean isCookable(ItemStack stack, Level level) { + return level + .getRecipeManager() + .getRecipeFor( + RecipeType.SMELTING, + new net.minecraft.world.SimpleContainer(stack), + level + ) + .isPresent(); + } + + private ItemStack extractCookableFromChest() { + Container container = getChestContainer(); + if (container == null) return ItemStack.EMPTY; + + Level level = npc.level(); + for (int i = 0; i < container.getContainerSize(); i++) { + ItemStack stack = container.getItem(i); + if (!stack.isEmpty() && isCookable(stack, level)) { + ItemStack extracted = stack.split( + Math.min(stack.getCount(), 8) + ); + return extracted; + } + } + return ItemStack.EMPTY; + } + + private ItemStack extractFuelFromChest() { + Container container = getChestContainer(); + if (container == null) return ItemStack.EMPTY; + + for (int i = 0; i < container.getContainerSize(); i++) { + ItemStack stack = container.getItem(i); + if (!stack.isEmpty() && isFuel(stack)) { + return stack.split(Math.min(stack.getCount(), 8)); + } + } + return ItemStack.EMPTY; + } + + private boolean isFuel(ItemStack stack) { + return ( + stack.is(Items.COAL) || + stack.is(Items.CHARCOAL) || + stack.is(Items.COAL_BLOCK) || + stack.is(Items.LAVA_BUCKET) || + stack.is(Items.BLAZE_ROD) + ); + } + + @Nullable + private Container getChestContainer() { + if (chestPos == null) return null; + return NpcGoalHelper.getChestContainer(npc.level(), chestPos); + } + + private void storeInChest(ItemStack stack) { + if (chestPos == null) { + npc.spawnAtLocation(stack); + return; + } + NpcGoalHelper.storeItemsInChest( + npc, + npc.level(), + chestPos, + List.of(stack) + ); + } + + private void awardXP(int amount) { + if (this.master != null) { + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcFarmCommandGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcFarmCommandGoal.java new file mode 100644 index 0000000..d88947f --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcFarmCommandGoal.java @@ -0,0 +1,329 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.NpcCommand; +import com.tiedup.remake.personality.PersonalityState; +import java.util.List; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.*; +import net.minecraft.world.level.block.state.BlockState; + +/** + * AI Goal: NPC farms crops in a zone around a chest hub. + * + *

Chest-Hub System:

+ *
    + *
  • Chest position defines the work zone center (radius 8)
  • + *
  • Chest provides seeds for replanting
  • + *
  • Harvested crops are stored in the chest
  • + *
+ * + *

Behavior:

+ *
    + *
  • Scans zone for mature crops
  • + *
  • Harvests mature crops
  • + *
  • Replants using seeds from drops or chest
  • + *
  • Stores harvest in chest
  • + *
  • Awards XP periodically
  • + *
+ */ +public class NpcFarmCommandGoal extends AbstractNpcJobGoal { + + /** Distance to interact with block */ + private static final double INTERACT_DISTANCE = 2.0; + + /** Rest drain intensity for farming (medium work) */ + private static final float WORK_INTENSITY = 1.0f; + + /** Minimum cooldown for job idle dialogue (30 sec) */ + private static final int JOB_IDLE_TALK_MIN = 600; + + /** Maximum cooldown for job idle dialogue (1.5 min) */ + private static final int JOB_IDLE_TALK_MAX = 1800; + + // Job-specific phases + private static final int PHASE_MOVING = PHASE_JOB_SPECIFIC; + private static final int PHASE_HARVESTING = PHASE_JOB_SPECIFIC + 1; + + @Nullable + private BlockPos targetCrop; + + private int harvestCooldown; + private int jobIdleTalkCooldown; + + public NpcFarmCommandGoal(EntityDamsel npc, double speedModifier) { + super(npc, speedModifier); + } + + // ==================== Abstract Implementations ==================== + + @Override + protected NpcCommand getCommand() { + return NpcCommand.FARM; + } + + @Override + @Nullable + protected BlockPos findWorkTarget() { + return findMatureCropInZone(); + } + + @Override + protected void onWorkTargetFound(BlockPos target) { + this.targetCrop = target; + this.phase = PHASE_MOVING; + } + + @Override + protected void onStart() { + this.harvestCooldown = 0; + this.jobIdleTalkCooldown = 0; + this.targetCrop = null; + } + + @Override + protected void onStop() { + this.targetCrop = null; + } + + @Override + protected void onIdleTick() { + // Job idle dialogue + if (--this.jobIdleTalkCooldown <= 0 && !npc.isGagged()) { + if (npc.getRandom().nextFloat() < 0.01f) { + speakJobIdleDialogue(); + this.jobIdleTalkCooldown = + JOB_IDLE_TALK_MIN + + npc.getRandom().nextInt(JOB_IDLE_TALK_MAX - JOB_IDLE_TALK_MIN); + } + } + } + + @Override + protected void tickJobSpecific() { + if (harvestCooldown > 0) harvestCooldown--; + + switch (this.phase) { + case PHASE_MOVING -> tickMoving(); + case PHASE_HARVESTING -> tickHarvesting(); + } + } + + // ==================== Farm-Specific Phase Ticks ==================== + + private void tickMoving() { + if (this.targetCrop == null) { + this.phase = PHASE_SCANNING; + return; + } + + // Check if crop still valid + BlockState state = npc.level().getBlockState(targetCrop); + if (!isMatureCrop(state)) { + this.targetCrop = null; + this.phase = PHASE_SCANNING; + this.scanTimer = 0; + return; + } + + // Move towards crop + if (--this.pathRecalcTimer <= 0) { + this.pathRecalcTimer = 10; + npc.getNavigation().moveTo( + targetCrop.getX() + 0.5, + targetCrop.getY(), + targetCrop.getZ() + 0.5, + speedModifier + ); + } + + // Check if close enough to harvest + double dist = npc.blockPosition().distSqr(targetCrop); + if (dist <= INTERACT_DISTANCE * INTERACT_DISTANCE) { + this.phase = PHASE_HARVESTING; + } + } + + private void tickHarvesting() { + if (this.targetCrop == null) { + this.phase = PHASE_SCANNING; + return; + } + if (harvestCooldown > 0) { + return; // stay in HARVESTING phase, cooldown decrements in tickJobSpecific() + } + + Level level = npc.level(); + BlockState state = level.getBlockState(targetCrop); + + if (isMatureCrop(state)) { + // Get drops + List drops = Block.getDrops( + state, + (net.minecraft.server.level.ServerLevel) level, + targetCrop, + null + ); + + // Add drops to held items (separate seeds for replanting) + ItemStack seedForReplant = ItemStack.EMPTY; + for (ItemStack drop : drops) { + if (isSeed(drop) && seedForReplant.isEmpty()) { + seedForReplant = drop.split(1); + if (!drop.isEmpty()) { + heldItems.add(drop); + } + } else { + heldItems.add(drop); + } + } + + // Apply yield bonus from experience + float yieldMult = NpcGoalHelper.getJobYieldMultiplier(npc, NpcCommand.FARM); + if (yieldMult > 1.0f && npc.getRandom().nextFloat() < (yieldMult - 1.0f)) { + if (!drops.isEmpty()) { + ItemStack bonusDrop = drops + .get(npc.getRandom().nextInt(drops.size())) + .copy(); + bonusDrop.setCount(1); + heldItems.add(bonusDrop); + } + } + + // Destroy the crop + level.destroyBlock(targetCrop, false); + + // Replant if we have a seed + if (!seedForReplant.isEmpty()) { + BlockState newCrop = getCropStateForSeed(seedForReplant); + if (newCrop != null) { + level.setBlock(targetCrop, newCrop, 3); + } + } + + // Sound effect + level.playSound(null, targetCrop, SoundEvents.CROP_BREAK, SoundSource.BLOCKS, 1.0f, 1.0f); + + // Look at the crop + npc.getLookControl().setLookAt( + targetCrop.getX() + 0.5, + targetCrop.getY() + 0.5, + targetCrop.getZ() + 0.5 + ); + + harvestCooldown = 10; + + // Drain rest from work + PersonalityState personalityState = npc.getPersonalityState(); + if (personalityState != null) { + personalityState.getNeeds().drainFromWork(WORK_INTENSITY); + } + } + + this.targetCrop = null; + this.phase = PHASE_SCANNING; + this.scanTimer = 20; // Short delay before next scan + } + + // ==================== Farm-Specific Utility ==================== + + /** + * Speak job-specific idle dialogue based on current state. + */ + private void speakJobIdleDialogue() { + Player player = this.master; + if (player == null) { + player = npc.level().getNearestPlayer(npc, 16); + } + if (player == null) return; + + String dialogueId = + com.tiedup.remake.dialogue.DialogueTriggerSystem.selectProactiveDialogue(npc); + + if (dialogueId == null || dialogueId.startsWith("idle.")) { + dialogueId = "jobs.idle.farm"; + } + + com.tiedup.remake.dialogue.EntityDialogueManager.talkByDialogueId(npc, player, dialogueId); + } + + @Nullable + private BlockPos findMatureCropInZone() { + if (this.chestPos == null) return null; + + Level level = npc.level(); + BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos(); + + BlockPos nearest = null; + double nearestDist = Double.MAX_VALUE; + + for (int x = -WORK_RADIUS; x <= WORK_RADIUS; x++) { + for (int z = -WORK_RADIUS; z <= WORK_RADIUS; z++) { + for (int y = -2; y <= 2; y++) { + mutable.set( + chestPos.getX() + x, + chestPos.getY() + y, + chestPos.getZ() + z + ); + BlockState state = level.getBlockState(mutable); + + if (isMatureCrop(state)) { + double dist = npc.blockPosition().distSqr(mutable); + if (dist < nearestDist) { + nearest = mutable.immutable(); + nearestDist = dist; + } + } + } + } + } + + return nearest; + } + + private boolean isMatureCrop(BlockState state) { + Block block = state.getBlock(); + + if (block instanceof CropBlock crop) { + return crop.isMaxAge(state); + } + if (block instanceof StemBlock) { + return false; + } + if (block == Blocks.PUMPKIN || block == Blocks.MELON) { + return true; + } + if (block instanceof SweetBerryBushBlock) { + return state.getValue(SweetBerryBushBlock.AGE) >= 2; + } + + return false; + } + + private boolean isSeed(ItemStack stack) { + return ( + stack.is(Items.WHEAT_SEEDS) || + stack.is(Items.BEETROOT_SEEDS) || + stack.is(Items.CARROT) || + stack.is(Items.POTATO) || + stack.is(Items.MELON_SEEDS) || + stack.is(Items.PUMPKIN_SEEDS) + ); + } + + @Nullable + private BlockState getCropStateForSeed(ItemStack seed) { + if (seed.is(Items.WHEAT_SEEDS)) return Blocks.WHEAT.defaultBlockState(); + if (seed.is(Items.BEETROOT_SEEDS)) return Blocks.BEETROOTS.defaultBlockState(); + if (seed.is(Items.CARROT)) return Blocks.CARROTS.defaultBlockState(); + if (seed.is(Items.POTATO)) return Blocks.POTATOES.defaultBlockState(); + return null; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcFetchCommandGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcFetchCommandGoal.java new file mode 100644 index 0000000..6f148f5 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcFetchCommandGoal.java @@ -0,0 +1,255 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.NpcCommand; +import com.tiedup.remake.personality.PersonalityState; +import java.util.EnumSet; +import java.util.List; +import javax.annotation.Nullable; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.AABB; + +/** + * AI Goal: NPC fetches a nearby item and brings it to the master. + * + * Personality System Phase D: AI Goals + * + *

Behavior:

+ *
    + *
  • Only active when NPC has FETCH command
  • + *
  • Finds the nearest item entity
  • + *
  • Walks to item and picks it up
  • + *
  • Returns to master and drops the item
  • + *
  • Awards XP on completion
  • + *
+ */ +public class NpcFetchCommandGoal extends Goal { + + private final EntityDamsel npc; + private final double speedModifier; + + /** Search radius for items */ + private static final double SEARCH_RADIUS = 16.0; + + /** Distance to pick up item */ + private static final double PICKUP_DISTANCE = 1.5; + + /** Distance to deliver to master */ + private static final double DELIVER_DISTANCE = 2.0; + + /** Maximum time to complete fetch */ + private static final int MAX_TICKS = 600; // 30 seconds + + /** Rest drain intensity for fetching (light work) */ + private static final float WORK_INTENSITY = 0.7f; + + private enum FetchPhase { + SEARCHING, + FETCHING, + RETURNING, + } + + private FetchPhase phase; + + @Nullable + private ItemEntity targetItem; + + @Nullable + private Player master; + + private int ticksRunning; + private int pathRecalcTimer; + + public NpcFetchCommandGoal(EntityDamsel npc, double speedModifier) { + this.npc = npc; + this.speedModifier = speedModifier; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (npc.getActiveCommand() != NpcCommand.FETCH) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + + // Find master + this.master = NpcGoalHelper.findCommandingPlayer(npc); + if (this.master == null) { + return false; + } + + // Find nearest item + this.targetItem = findNearestItem(); + return this.targetItem != null; + } + + @Override + public boolean canContinueToUse() { + if (npc.getActiveCommand() != NpcCommand.FETCH) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + if (this.master == null || !this.master.isAlive()) { + return false; + } + if (this.ticksRunning >= MAX_TICKS) { + return false; + } + + // In FETCHING phase, item must still exist + if (this.phase == FetchPhase.FETCHING) { + if (this.targetItem == null || !this.targetItem.isAlive()) { + // Item gone, find new one or abort + this.targetItem = findNearestItem(); + if (this.targetItem == null) { + return false; + } + } + } + + return true; + } + + @Override + public void start() { + this.phase = FetchPhase.FETCHING; + this.ticksRunning = 0; + this.pathRecalcTimer = 0; + } + + @Override + public void stop() { + // Check if we completed successfully (returned item to master) + if (this.phase == FetchPhase.RETURNING && this.master != null) { + double dist = npc.distanceTo(master); + if (dist <= DELIVER_DISTANCE) { + // Success! Award XP + } + } + + // Cancel command + npc.cancelCommand(); + this.targetItem = null; + this.master = null; + npc.getNavigation().stop(); + } + + @Override + public void tick() { + this.ticksRunning++; + + switch (this.phase) { + case FETCHING -> tickFetching(); + case RETURNING -> tickReturning(); + default -> { + } + } + } + + private void tickFetching() { + if (this.targetItem == null || !this.targetItem.isAlive()) { + return; + } + + // Look at item + npc.getLookControl().setLookAt(targetItem, 10.0F, 30.0F); + + // Move towards item + if (--this.pathRecalcTimer <= 0) { + this.pathRecalcTimer = 10; + npc.getNavigation().moveTo(targetItem, speedModifier); + } + + // Check if close enough to pick up + double dist = npc.distanceTo(targetItem); + if (dist <= PICKUP_DISTANCE) { + pickUpItem(); + } + } + + private void tickReturning() { + if (this.master == null || !this.master.isAlive()) { + return; + } + + // Look at master + npc.getLookControl().setLookAt(master, 10.0F, 30.0F); + + // Move towards master + if (--this.pathRecalcTimer <= 0) { + this.pathRecalcTimer = 10; + npc.getNavigation().moveTo(master, speedModifier); + } + + // Check if close enough to deliver + double dist = npc.distanceTo(master); + if (dist <= DELIVER_DISTANCE) { + deliverItem(); + } + } + + private void pickUpItem() { + if (this.targetItem == null) return; + + // Give item to NPC's hand (uses custom EntityDamsel method, not vanilla) + npc.setMainHandItem(targetItem.getItem().copy()); + targetItem.discard(); + this.targetItem = null; + + // Drain rest from work + PersonalityState personalityState = npc.getPersonalityState(); + if (personalityState != null) { + personalityState.getNeeds().drainFromWork(WORK_INTENSITY); + } + + // Switch to returning phase + this.phase = FetchPhase.RETURNING; + this.pathRecalcTimer = 0; + } + + private void deliverItem() { + if (this.master == null) return; + + // Drop item at master's feet (uses custom EntityDamsel method) + ItemStack heldItem = npc.getMainHandItem(); + if (!heldItem.isEmpty()) { + // Give to player or drop + if (!master.getInventory().add(heldItem.copy())) { + // Inventory full, drop it + npc.spawnAtLocation(heldItem.copy()); + } + npc.setMainHandItem(ItemStack.EMPTY); + } + + // Command complete - stop() will award XP + } + + @Nullable + private ItemEntity findNearestItem() { + AABB searchArea = npc.getBoundingBox().inflate(SEARCH_RADIUS); + List items = npc + .level() + .getEntitiesOfClass(ItemEntity.class, searchArea); + + ItemEntity nearest = null; + double nearestDist = Double.MAX_VALUE; + + for (ItemEntity item : items) { + double dist = npc.distanceToSqr(item); + if (dist < nearestDist) { + nearest = item; + nearestDist = dist; + } + } + + return nearest; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcFishCommandGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcFishCommandGoal.java new file mode 100644 index 0000000..ec11bde --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcFishCommandGoal.java @@ -0,0 +1,380 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.entities.NpcFishingBobber; +import com.tiedup.remake.personality.NpcCommand; +import com.tiedup.remake.personality.PersonalityState; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.storage.loot.BuiltInLootTables; +import net.minecraft.world.level.storage.loot.LootParams; +import net.minecraft.world.level.storage.loot.LootTable; +import net.minecraft.world.level.storage.loot.parameters.LootContextParamSets; +import net.minecraft.world.level.storage.loot.parameters.LootContextParams; +import net.minecraft.world.phys.Vec3; + +/** + * AI Goal: NPC fishes near water blocks close to chest hub using a fishing bobber entity. + * + *

Requires a fishing rod in the NPC's main hand. Uses the chest-hub system + * (commandTarget = chest position, work zone around it).

+ * + *

Job-specific phases: MOVING_TO_WATER, CASTING, WAITING, REELING.

+ */ +public class NpcFishCommandGoal extends AbstractNpcJobGoal { + + private static final double INTERACT_DISTANCE = 2.5; + private static final float WORK_INTENSITY = 0.5f; + + // Job-specific phases + private static final int PHASE_MOVING_TO_WATER = PHASE_JOB_SPECIFIC; + private static final int PHASE_CASTING = PHASE_JOB_SPECIFIC + 1; + private static final int PHASE_WAITING = PHASE_JOB_SPECIFIC + 2; + private static final int PHASE_REELING = PHASE_JOB_SPECIFIC + 3; + + @Nullable + private BlockPos waterPos; + + @Nullable + private NpcFishingBobber activeBobber; + + private int castDelay; + + public NpcFishCommandGoal(EntityDamsel npc, double speedModifier) { + super(npc, speedModifier); + } + + // ==================== Abstract Implementations ==================== + + @Override + protected NpcCommand getCommand() { + return NpcCommand.FISH; + } + + @Override + protected boolean extraCanUse() { + return npc.getMainHandItem().is(Items.FISHING_ROD); + } + + @Override + protected boolean extraCanContinueToUse() { + return npc.getMainHandItem().is(Items.FISHING_ROD); + } + + @Override + @Nullable + protected BlockPos findWorkTarget() { + return findWaterBlock(); + } + + @Override + protected void onWorkTargetFound(BlockPos target) { + this.waterPos = target; + this.phase = PHASE_MOVING_TO_WATER; + } + + @Override + protected void onStart() { + this.castDelay = 0; + this.waterPos = null; + this.activeBobber = null; + } + + @Override + protected void onStop() { + discardBobber(); + this.waterPos = null; + } + + @Override + protected double getInteractDistance() { + return INTERACT_DISTANCE; + } + + @Override + protected void tickJobSpecific() { + switch (this.phase) { + case PHASE_MOVING_TO_WATER -> tickMovingToWater(); + case PHASE_CASTING -> tickCasting(); + case PHASE_WAITING -> tickWaiting(); + case PHASE_REELING -> tickReeling(); + } + } + + // ==================== Fish-Specific Phase Ticks ==================== + + private void tickMovingToWater() { + if (this.waterPos == null) { + this.phase = PHASE_SCANNING; + return; + } + + // Move to a block adjacent to water (not into it) + if (--this.pathRecalcTimer <= 0) { + this.pathRecalcTimer = 10; + BlockPos adjacentPos = findAdjacentSolidBlock(waterPos); + if (adjacentPos != null) { + npc.getNavigation().moveTo( + adjacentPos.getX() + 0.5, + adjacentPos.getY(), + adjacentPos.getZ() + 0.5, + speedModifier + ); + } else { + npc.getNavigation().moveTo( + waterPos.getX() + 0.5, + waterPos.getY() + 1, + waterPos.getZ() + 0.5, + speedModifier + ); + } + } + + double dist = npc.blockPosition().distSqr(waterPos); + if (dist <= INTERACT_DISTANCE * INTERACT_DISTANCE) { + npc.getNavigation().stop(); + this.phase = PHASE_CASTING; + this.castDelay = 10; + } + } + + private void tickCasting() { + if (this.waterPos == null) { + this.phase = PHASE_SCANNING; + return; + } + + // Look at water while preparing to cast + npc.getLookControl().setLookAt( + waterPos.getX() + 0.5, + waterPos.getY() + 0.5, + waterPos.getZ() + 0.5 + ); + + if (--this.castDelay > 0) { + return; + } + + // Swing arm (cast animation) + npc.swing(InteractionHand.MAIN_HAND); + + // Spawn bobber + Level level = npc.level(); + NpcFishingBobber bobber = NpcFishingBobber.create(level, npc, waterPos); + level.addFreshEntity(bobber); + this.activeBobber = bobber; + + // Cast sound + level.playSound( + null, + npc.getX(), npc.getY(), npc.getZ(), + SoundEvents.FISHING_BOBBER_THROW, + SoundSource.NEUTRAL, + 0.5f, + 0.4f / (npc.getRandom().nextFloat() * 0.4f + 0.8f) + ); + + this.phase = PHASE_WAITING; + } + + private void tickWaiting() { + if (activeBobber == null || !activeBobber.isAlive()) { + this.activeBobber = null; + this.phase = PHASE_SCANNING; + this.scanTimer = 0; + return; + } + + // Look at bobber while waiting + npc.getLookControl().setLookAt( + activeBobber.getX(), + activeBobber.getY(), + activeBobber.getZ() + ); + + if (activeBobber.isBiting()) { + this.phase = PHASE_REELING; + } + } + + private void tickReeling() { + // Swing arm (reel animation) + npc.swing(InteractionHand.MAIN_HAND); + + // Discard bobber + discardBobber(); + + // Generate loot from fishing loot table + Level level = npc.level(); + List loot = generateFishingLoot(level); + for (ItemStack item : loot) { + heldItems.add(item); + } + + // Apply yield bonus + float yieldMult = NpcGoalHelper.getJobYieldMultiplier(npc, NpcCommand.FISH); + if (yieldMult > 1.0f && npc.getRandom().nextFloat() < (yieldMult - 1.0f)) { + if (!loot.isEmpty()) { + ItemStack bonusDrop = loot + .get(npc.getRandom().nextInt(loot.size())) + .copy(); + bonusDrop.setCount(1); + heldItems.add(bonusDrop); + } + } + + // Reel sound + level.playSound( + null, + npc.getX(), npc.getY(), npc.getZ(), + SoundEvents.FISHING_BOBBER_RETRIEVE, + SoundSource.NEUTRAL, + 1.0f, 1.0f + ); + + // Drain rest + PersonalityState personalityState = npc.getPersonalityState(); + if (personalityState != null) { + personalityState.getNeeds().drainFromWork(WORK_INTENSITY); + } + + // Check if inventory full -> store, otherwise fish again + if (heldItems.size() >= MAX_HELD_ITEMS) { + this.phase = PHASE_STORING; + } else { + this.phase = PHASE_SCANNING; + this.scanTimer = 0; + } + } + + // ==================== Fish-Specific Utility ==================== + + private void discardBobber() { + if (activeBobber != null) { + if (activeBobber.isAlive()) { + activeBobber.discard(); + } + activeBobber = null; + } + } + + private List generateFishingLoot(Level level) { + if (!(level instanceof ServerLevel serverLevel)) { + return generateFallbackLoot(); + } + + try { + LootTable lootTable = serverLevel + .getServer() + .getLootData() + .getLootTable(BuiltInLootTables.FISHING); + + float experienceLuck = + NpcGoalHelper.getJobYieldMultiplier(npc, NpcCommand.FISH) - 1.0f; + + LootParams params = new LootParams.Builder(serverLevel) + .withParameter(LootContextParams.ORIGIN, new Vec3(npc.getX(), npc.getY(), npc.getZ())) + .withParameter(LootContextParams.THIS_ENTITY, npc) + .withLuck(experienceLuck) + .create(LootContextParamSets.FISHING); + + return lootTable.getRandomItems(params); + } catch (Exception e) { + return generateFallbackLoot(); + } + } + + private List generateFallbackLoot() { + List loot = new ArrayList<>(); + float roll = npc.getRandom().nextFloat(); + + if (roll < 0.50f) { + loot.add(new ItemStack(Items.COD)); + } else if (roll < 0.75f) { + loot.add(new ItemStack(Items.SALMON)); + } else if (roll < 0.88f) { + loot.add(new ItemStack(Items.TROPICAL_FISH)); + } else if (roll < 0.95f) { + loot.add(new ItemStack(Items.PUFFERFISH)); + } else { + float rare = npc.getRandom().nextFloat(); + if (rare < 0.3f) { + loot.add(new ItemStack(Items.BOW)); + } else if (rare < 0.6f) { + loot.add(new ItemStack(Items.FISHING_ROD)); + } else if (rare < 0.8f) { + loot.add(new ItemStack(Items.SADDLE)); + } else { + loot.add(new ItemStack(Items.NAME_TAG)); + } + } + + return loot; + } + + @Nullable + private BlockPos findWaterBlock() { + if (this.chestPos == null) return null; + + Level level = npc.level(); + BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos(); + + BlockPos nearest = null; + double nearestDist = Double.MAX_VALUE; + + for (int x = -WORK_RADIUS; x <= WORK_RADIUS; x++) { + for (int z = -WORK_RADIUS; z <= WORK_RADIUS; z++) { + for (int y = -2; y <= 2; y++) { + mutable.set( + chestPos.getX() + x, + chestPos.getY() + y, + chestPos.getZ() + z + ); + + if (level.getBlockState(mutable).getBlock() == Blocks.WATER) { + double dist = npc.blockPosition().distSqr(mutable); + if (dist < nearestDist) { + nearest = mutable.immutable(); + nearestDist = dist; + } + } + } + } + } + + return nearest; + } + + @Nullable + private BlockPos findAdjacentSolidBlock(BlockPos water) { + Level level = npc.level(); + BlockPos[] offsets = { + water.north(), + water.south(), + water.east(), + water.west(), + }; + + for (BlockPos adjacent : offsets) { + if ( + level.getBlockState(adjacent).isAir() && + level.getBlockState(adjacent.below()).isSolidRender(level, adjacent.below()) + ) { + return adjacent; + } + } + + return null; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcFollowCommandGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcFollowCommandGoal.java new file mode 100644 index 0000000..b00582d --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcFollowCommandGoal.java @@ -0,0 +1,721 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.personality.NpcCommand; +import com.tiedup.remake.personality.ToolMode; +import java.util.EnumSet; +import java.util.List; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.tags.BlockTags; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.monster.Monster; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.AABB; + +/** + * AI Goal: NPC follows their master when FOLLOW command is active. + * Tool-based behaviors are activated based on the item in NPC's main hand. + * + *

Tool Modes:

+ *
    + *
  • PASSIVE: No tool - just follow
  • + *
  • ATTACK: Sword - attack hostile mobs near master
  • + *
  • MINING: Pickaxe - mine stone/ores near master
  • + *
  • WOODCUTTING: Axe - chop logs near master
  • + *
  • CAPTURE: Bondage bind item - capture untied damsels
  • + *
+ * + *

Training tier affects damage, speed, and cooldown. + * Mood affects behavior frequency.

+ */ +public class NpcFollowCommandGoal extends Goal { + + private final EntityDamsel npc; + private final double speedModifier; + + /** Distance at which to teleport */ + private static final double TELEPORT_DISTANCE = 20.0; + + /** Tool behavior search radius */ + private static final double BEHAVIOR_RADIUS = 12.0; + + /** Block break radius */ + private static final double BLOCK_RADIUS = 6.0; + + /** Attack range */ + private static final double ATTACK_RANGE = 2.0; + + /** Block interact range */ + private static final double BLOCK_RANGE = 2.5; + + @Nullable + private LivingEntity master; + + private int pathRecalcTimer; + private int xpTimer; + private int stuckTimer; + + // Tool behavior state + private ToolMode currentToolMode = ToolMode.PASSIVE; + private int behaviorCooldown; + + @Nullable + private LivingEntity combatTarget; + + @Nullable + private BlockPos targetBlock; + + private int breakProgress; + + /** Cooldown for block search when no target found (prevents expensive searches every tick) */ + private int noTargetCooldown; + + @Nullable + private EntityDamsel captureTarget; + + /** + * Get the current follow distance configuration. + */ + private NpcCommand.FollowDistance getFollowDistance() { + return npc.getPersonalityState().getFollowDistance(); + } + + public NpcFollowCommandGoal(EntityDamsel npc, double speedModifier) { + this.npc = npc; + this.speedModifier = speedModifier; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + // Must have FOLLOW command active + if (npc.getActiveCommand() != NpcCommand.FOLLOW) { + return false; + } + + // Must have collar + if (!npc.hasCollar()) { + return false; + } + + // Find the commanding player + this.master = NpcGoalHelper.findCommandingPlayer(npc); + return this.master != null; + } + + @Override + public boolean canContinueToUse() { + // Stop if command changed + if (npc.getActiveCommand() != NpcCommand.FOLLOW) { + return false; + } + + // Stop if no collar + if (!npc.hasCollar()) { + return false; + } + + // Stop if master is gone + if (this.master == null || !this.master.isAlive()) { + return false; + } + + // Stop if master is in different dimension + if (this.master.level() != this.npc.level()) { + return false; + } + + return true; + } + + @Override + public void start() { + this.pathRecalcTimer = 0; + this.xpTimer = 0; + this.stuckTimer = 0; + this.behaviorCooldown = 0; + this.noTargetCooldown = 0; + this.combatTarget = null; + this.targetBlock = null; + this.captureTarget = null; + this.breakProgress = 0; + } + + @Override + public void stop() { + this.master = null; + this.combatTarget = null; + this.targetBlock = null; + this.captureTarget = null; + this.npc.getNavigation().stop(); + } + + @Override + public void tick() { + if (this.master == null) return; + + NpcCommand.FollowDistance followDist = getFollowDistance(); + double distance = this.npc.distanceToSqr(this.master); + double distanceFlat = Math.sqrt(distance); + + // Detect tool mode + ItemStack mainHand = npc.getMainHandItem(); + this.currentToolMode = ToolMode.fromItem(mainHand); + + // Look at master (unless engaged in combat) + if (this.combatTarget == null || !this.combatTarget.isAlive()) { + this.npc.getLookControl().setLookAt( + this.master, + 10.0F, + (float) this.npc.getMaxHeadXRot() + ); + } + + // Handle teleport if too far + if (distanceFlat > TELEPORT_DISTANCE) { + if (!tryTeleportNear()) { + // Can't teleport, try pathing anyway + this.stuckTimer++; + if (this.stuckTimer > 100) { + // Give up after 5 seconds of being stuck + this.npc.cancelCommand(); + } + } + return; + } + + // Reset stuck timer if within range + this.stuckTimer = 0; + + // Move towards master if far enough + if (distanceFlat > followDist.startDistance) { + if (--this.pathRecalcTimer <= 0) { + this.pathRecalcTimer = followDist.pathRecalcInterval; + + // Calculate target position + double targetX, targetZ; + if (followDist.followBehind) { + // HEEL mode: follow behind the master + double yaw = Math.toRadians(this.master.getYRot()); + double behindDist = followDist.minDistance + 0.5; + targetX = this.master.getX() + Math.sin(yaw) * behindDist; + targetZ = this.master.getZ() - Math.cos(yaw) * behindDist; + this.npc.getNavigation().moveTo( + targetX, + this.master.getY(), + targetZ, + this.speedModifier * 1.2 // Slightly faster to keep up + ); + } else { + // Normal follow: move towards master + this.npc.getNavigation().moveTo( + this.master, + this.speedModifier + ); + } + } + } else if (distanceFlat < followDist.minDistance) { + // Too close, stop moving + this.npc.getNavigation().stop(); + } + + // Tool-based behavior (only when not too far from master) + if ( + distanceFlat <= BEHAVIOR_RADIUS && + this.currentToolMode != ToolMode.PASSIVE + ) { + tickToolBehavior(); + } + + // Award XP periodically for successful following + if (++this.xpTimer >= followDist.xpInterval) { + this.xpTimer = 0; + if (this.master instanceof Player player) { + } + } + } + + /** + * Tick tool-based behavior based on current tool mode. + */ + private void tickToolBehavior() { + if (--this.behaviorCooldown > 0) { + return; + } + + switch (this.currentToolMode) { + case ATTACK -> tickCombatBehavior(); + case MINING -> tickMiningBehavior(); + case WOODCUTTING -> tickWoodcuttingBehavior(); + case CAPTURE -> tickCaptureBehavior(); + default -> { + } + } + } + + /** + * Combat behavior: Find and attack hostile mobs near master. + */ + private void tickCombatBehavior() { + // Continue attacking current target + if (this.combatTarget != null && this.combatTarget.isAlive()) { + double dist = npc.distanceTo(combatTarget); + + // Look at target + npc.getLookControl().setLookAt(combatTarget, 30.0F, 30.0F); + + if (dist <= ATTACK_RANGE) { + // Attack with base damage + float baseDamage = 3.0f; + + // Apply damage directly (bypasses ATTACK_DAMAGE attribute) + combatTarget.hurt( + npc.damageSources().mobAttack(npc), + baseDamage + ); + npc.swing(InteractionHand.MAIN_HAND); + + // Cooldown based on mood + this.behaviorCooldown = 20; + + // Award XP for hit + if (this.master instanceof Player player) { + } + } else { + // Move towards target + npc.getNavigation().moveTo(combatTarget, speedModifier * 1.3); + } + return; + } + + // Find new target + this.combatTarget = findHostileMob(); + // Cooldown whether target found or not - prevents expensive search every tick + this.behaviorCooldown = (this.combatTarget != null) ? 10 : 40; + } + + /** + * Mining behavior: Find and mine stone/ores near master. + */ + private void tickMiningBehavior() { + // Continue mining current block + if (this.targetBlock != null) { + BlockState state = npc.level().getBlockState(targetBlock); + if (!isMineable(state)) { + this.targetBlock = null; + this.breakProgress = 0; + return; + } + + double dist = Math.sqrt(npc.blockPosition().distSqr(targetBlock)); + if (dist <= BLOCK_RANGE) { + // Look at block + npc + .getLookControl() + .setLookAt( + targetBlock.getX() + 0.5, + targetBlock.getY() + 0.5, + targetBlock.getZ() + 0.5 + ); + + // Mine progress + this.breakProgress += 5; + + // Block broken at 100 + if (this.breakProgress >= 100) { + Level level = npc.level(); + if (level instanceof ServerLevel serverLevel) { + // Break block with drops + Block.dropResources( + state, + level, + targetBlock, + null, + npc, + npc.getMainHandItem() + ); + level.destroyBlock(targetBlock, false); + level.playSound( + null, + targetBlock, + SoundEvents.STONE_BREAK, + SoundSource.BLOCKS, + 1.0f, + 1.0f + ); + + // Award XP + if (this.master instanceof Player player) { + } + } + this.targetBlock = null; + this.breakProgress = 0; + + // Cooldown + this.behaviorCooldown = 30; + } + } else { + // Move towards block + npc + .getNavigation() + .moveTo( + targetBlock.getX() + 0.5, + targetBlock.getY(), + targetBlock.getZ() + 0.5, + speedModifier + ); + } + return; + } + + // Cooldown for expensive block search when no target found + if (this.noTargetCooldown > 0) { + this.noTargetCooldown--; + return; + } + + // Find new block to mine + this.targetBlock = findMineableBlock(); + this.breakProgress = 0; + + // If no block found, wait before searching again (2 seconds) + if (this.targetBlock == null) { + this.noTargetCooldown = 40; + } + } + + /** + * Woodcutting behavior: Find and chop logs near master. + */ + private void tickWoodcuttingBehavior() { + // Continue chopping current block + if (this.targetBlock != null) { + BlockState state = npc.level().getBlockState(targetBlock); + if (!isChoppable(state)) { + this.targetBlock = null; + this.breakProgress = 0; + return; + } + + double dist = Math.sqrt(npc.blockPosition().distSqr(targetBlock)); + if (dist <= BLOCK_RANGE) { + // Look at block + npc + .getLookControl() + .setLookAt( + targetBlock.getX() + 0.5, + targetBlock.getY() + 0.5, + targetBlock.getZ() + 0.5 + ); + + // Chop progress + this.breakProgress += 6; + + // Block broken at 100 + if (this.breakProgress >= 100) { + Level level = npc.level(); + if (level instanceof ServerLevel serverLevel) { + // Break block with drops + Block.dropResources( + state, + level, + targetBlock, + null, + npc, + npc.getMainHandItem() + ); + level.destroyBlock(targetBlock, false); + level.playSound( + null, + targetBlock, + SoundEvents.WOOD_BREAK, + SoundSource.BLOCKS, + 1.0f, + 1.0f + ); + + // Award XP + if (this.master instanceof Player player) { + } + } + this.targetBlock = null; + this.breakProgress = 0; + + // Cooldown + this.behaviorCooldown = 25; + } + } else { + // Move towards block + npc + .getNavigation() + .moveTo( + targetBlock.getX() + 0.5, + targetBlock.getY(), + targetBlock.getZ() + 0.5, + speedModifier + ); + } + return; + } + + // Cooldown for expensive block search when no target found + if (this.noTargetCooldown > 0) { + this.noTargetCooldown--; + return; + } + + // Find new block to chop + this.targetBlock = findChoppableBlock(); + this.breakProgress = 0; + + // If no block found, wait before searching again (2 seconds) + if (this.targetBlock == null) { + this.noTargetCooldown = 40; + } + } + + /** + * Capture behavior: Find and capture untied damsels. + */ + private void tickCaptureBehavior() { + // Continue capturing current target + if (this.captureTarget != null && this.captureTarget.isAlive()) { + // Skip if already tied + if (this.captureTarget.isTiedUp()) { + this.captureTarget = null; + return; + } + + double dist = npc.distanceTo(captureTarget); + + // Look at target + npc.getLookControl().setLookAt(captureTarget, 30.0F, 30.0F); + + if (dist <= ATTACK_RANGE) { + // Try to capture using bind item + ItemStack bindItem = npc.getMainHandItem(); + if ( + bindItem.getItem() instanceof ItemBind + ) { + // Apply bind to target + captureTarget.equip(BodyRegionV2.ARMS, bindItem.copy()); + + // Consume one bind item (or reduce durability) + bindItem.shrink(1); + npc.setMainHandItem(bindItem); + + // Award XP + if (this.master instanceof Player player) { + } + + // Cooldown + this.behaviorCooldown = 60; + } + this.captureTarget = null; + } else { + // Move towards target + npc.getNavigation().moveTo(captureTarget, speedModifier * 1.2); + } + return; + } + + // Find new target + this.captureTarget = findCapturableDamsel(); + // Cooldown whether target found or not - prevents expensive search every tick + this.behaviorCooldown = (this.captureTarget != null) ? 10 : 40; + } + + // --- Target finding methods --- + + @Nullable + private Monster findHostileMob() { + if (this.master == null) return null; + + AABB searchArea = new AABB(master.blockPosition()).inflate( + BEHAVIOR_RADIUS + ); + List monsters = npc + .level() + .getEntitiesOfClass(Monster.class, searchArea); + + Monster nearest = null; + double nearestDist = Double.MAX_VALUE; + + for (Monster monster : monsters) { + double dist = npc.distanceToSqr(monster); + if (dist < nearestDist) { + nearest = monster; + nearestDist = dist; + } + } + + return nearest; + } + + @Nullable + private BlockPos findMineableBlock() { + if (this.master == null) return null; + + BlockPos masterPos = master.blockPosition(); + Level level = npc.level(); + BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos(); + + BlockPos nearest = null; + double nearestDist = Double.MAX_VALUE; + + int radius = (int) BLOCK_RADIUS; + for (int x = -radius; x <= radius; x++) { + for (int z = -radius; z <= radius; z++) { + for (int y = -2; y <= 2; y++) { + mutable.set( + masterPos.getX() + x, + masterPos.getY() + y, + masterPos.getZ() + z + ); + BlockState state = level.getBlockState(mutable); + + if (isMineable(state)) { + double dist = npc.blockPosition().distSqr(mutable); + if (dist < nearestDist) { + nearest = mutable.immutable(); + nearestDist = dist; + } + } + } + } + } + + return nearest; + } + + @Nullable + private BlockPos findChoppableBlock() { + if (this.master == null) return null; + + BlockPos masterPos = master.blockPosition(); + Level level = npc.level(); + BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos(); + + BlockPos nearest = null; + double nearestDist = Double.MAX_VALUE; + + int radius = (int) BLOCK_RADIUS; + for (int x = -radius; x <= radius; x++) { + for (int z = -radius; z <= radius; z++) { + for (int y = -2; y <= 4; y++) { + // Trees are taller + mutable.set( + masterPos.getX() + x, + masterPos.getY() + y, + masterPos.getZ() + z + ); + BlockState state = level.getBlockState(mutable); + + if (isChoppable(state)) { + double dist = npc.blockPosition().distSqr(mutable); + if (dist < nearestDist) { + nearest = mutable.immutable(); + nearestDist = dist; + } + } + } + } + } + + return nearest; + } + + @Nullable + private EntityDamsel findCapturableDamsel() { + if (this.master == null) return null; + + AABB searchArea = new AABB(master.blockPosition()).inflate( + BEHAVIOR_RADIUS + ); + List damsels = npc + .level() + .getEntitiesOfClass(EntityDamsel.class, searchArea); + + EntityDamsel nearest = null; + double nearestDist = Double.MAX_VALUE; + + for (EntityDamsel damsel : damsels) { + // Skip self + if (damsel == this.npc) continue; + // Skip already tied + if (damsel.isTiedUp()) continue; + // Skip collared (owned) + if (damsel.hasCollar()) continue; + + double dist = npc.distanceToSqr(damsel); + if (dist < nearestDist) { + nearest = damsel; + nearestDist = dist; + } + } + + return nearest; + } + + private boolean isMineable(BlockState state) { + return state.is(BlockTags.MINEABLE_WITH_PICKAXE); + } + + private boolean isChoppable(BlockState state) { + return state.is(BlockTags.LOGS); + } + + /** + * Try to teleport near the master. + */ + private boolean tryTeleportNear() { + if (this.master == null) return false; + + // Don't teleport if NPC is tied up (can't move freely) + if (this.npc.isTiedUp()) { + return false; + } + + // Find a safe spot near master + double x = + this.master.getX() + (this.npc.getRandom().nextDouble() - 0.5) * 4; + double y = this.master.getY(); + double z = + this.master.getZ() + (this.npc.getRandom().nextDouble() - 0.5) * 4; + + // Check if spot is safe + if ( + this.npc.level().noCollision( + this.npc, + this.npc.getBoundingBox().move( + x - this.npc.getX(), + y - this.npc.getY(), + z - this.npc.getZ() + ) + ) + ) { + this.npc.teleportTo(x, y, z); + this.npc.getNavigation().stop(); + return true; + } + + return false; + } + + @Override + public boolean requiresUpdateEveryTick() { + return true; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcGoHomeGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcGoHomeGoal.java new file mode 100644 index 0000000..54ed64a --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcGoHomeGoal.java @@ -0,0 +1,142 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.NpcCommand; +import com.tiedup.remake.personality.PersonalityState; +import java.util.EnumSet; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.ai.goal.Goal; + +/** + * AI Goal for GO_HOME command. + * Makes NPC walk to their assigned home position. + * + *

Behavior: + *

    + *
  • Walks to home position
  • + *
  • Stays there for 2 seconds
  • + *
  • Switches to IDLE command near home
  • + *
  • Cancels if no home is assigned
  • + *
+ */ +public class NpcGoHomeGoal extends Goal { + + private final EntityDamsel npc; + private BlockPos targetPos; + private BlockPos actualHomePos; + private int ticksAtHome; + + /** Distance considered "arrived" at home */ + private static final int ARRIVAL_DISTANCE = 2; + + /** Ticks to stay at home before completing (2 seconds) */ + private static final int COMPLETION_TICKS = 40; + + public NpcGoHomeGoal(EntityDamsel npc) { + this.npc = npc; + this.setFlags(EnumSet.of(Goal.Flag.MOVE)); + } + + @Override + public boolean canUse() { + PersonalityState state = npc.getPersonalityState(); + if (state == null) return false; + + // Check if GO_HOME command is active + if (state.getActiveCommand() != NpcCommand.GO_HOME) { + return false; + } + + // Check if NPC has a home + if (!state.hasHome()) { + // No home - cancel command + state.clearCommand(); + return false; + } + + this.actualHomePos = state.getHomePos(); + this.targetPos = NpcGoalHelper.getHomeNavigationTarget(npc); + return true; + } + + @Override + public boolean canContinueToUse() { + PersonalityState state = npc.getPersonalityState(); + if (state == null) return false; + return state.getActiveCommand() == NpcCommand.GO_HOME; + } + + @Override + public void start() { + this.ticksAtHome = 0; + if (targetPos != null) { + npc + .getNavigation() + .moveTo( + targetPos.getX() + 0.5, + targetPos.getY(), + targetPos.getZ() + 0.5, + 1.0 + ); + } + } + + @Override + public void tick() { + if (targetPos == null) return; + + // Check if arrived + double distance = npc.distanceToSqr( + targetPos.getX() + 0.5, + targetPos.getY(), + targetPos.getZ() + 0.5 + ); + + if (distance < ARRIVAL_DISTANCE * ARRIVAL_DISTANCE) { + ticksAtHome++; + npc.getNavigation().stop(); + + // If we navigated to deliveryPoint (exterior), teleport to homePos (interior) + if ( + actualHomePos != null && + !targetPos.equals(actualHomePos) && + ticksAtHome == 1 + ) { + NpcGoalHelper.teleportTo(npc, actualHomePos); + } + + // Complete after staying at home + if (ticksAtHome >= COMPLETION_TICKS) { + PersonalityState state = npc.getPersonalityState(); + if (state != null) { + state.clearCommand(); + // Switch to IDLE near home + state.setActiveCommand( + NpcCommand.IDLE, + null, + actualHomePos + ); + } + } + } else { + // Not there yet, keep moving + ticksAtHome = 0; + if (npc.getNavigation().isDone()) { + npc + .getNavigation() + .moveTo( + targetPos.getX() + 0.5, + targetPos.getY(), + targetPos.getZ() + 0.5, + 1.0 + ); + } + } + } + + @Override + public void stop() { + NpcGoalHelper.ensureOutsideCell(npc); + npc.getNavigation().stop(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcGoalHelper.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcGoalHelper.java new file mode 100644 index 0000000..e6a03b9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcGoalHelper.java @@ -0,0 +1,356 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.JobExperience; +import com.tiedup.remake.personality.JobPersonalityModifiers; +import com.tiedup.remake.personality.NpcCommand; +import com.tiedup.remake.personality.PersonalityState; +import java.util.List; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.core.NonNullList; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.Container; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.ChestBlock; +import net.minecraft.world.level.block.entity.ChestBlockEntity; + +/** + * Utility methods shared by NPC command goals. + * + * Personality System: Shared goal utilities + */ +public class NpcGoalHelper { + + /** + * Find the player who gave the current command to this NPC. + * Uses O(1) ServerLevel.getPlayerByUUID instead of O(N) iteration. + * + * @param npc The NPC entity + * @return The commanding player, or null if not found + */ + @Nullable + public static Player findCommandingPlayer(EntityDamsel npc) { + var state = npc.getPersonalityState(); + if (state == null) return null; + + UUID commanderUUID = state.getCommandingPlayer(); + if (commanderUUID == null) return null; + + // O(1) lookup via ServerLevel + if (npc.level() instanceof ServerLevel serverLevel) { + return serverLevel.getPlayerByUUID(commanderUUID); + } + return null; + } + + /** + * Award XP to the NPC's commanding player. + * Consolidates duplicated XP awarding logic from multiple command goals. + * + * @param npc The NPC entity + * @param amount The XP amount to award + * @return true if XP was awarded, false if no commander found + */ + public static boolean awardXPToMaster(EntityDamsel npc, int amount) { + Player commander = findCommandingPlayer(npc); + if (commander != null) { + return true; + } + return false; + } + + /** + * Get combined job efficiency for an NPC performing a command. + * Combines: rest efficiency × personality modifier × experience speed. + * + * @param npc The NPC entity + * @param command The job command being performed + * @return Combined efficiency multiplier (typically 0.3 to 2.0) + */ + public static float getJobEfficiency(EntityDamsel npc, NpcCommand command) { + PersonalityState state = npc.getPersonalityState(); + if (state == null) return 1.0f; + + float restEfficiency = state.getNeeds().getEfficiencyModifier(); + float personalityMod = JobPersonalityModifiers.getEfficiencyModifier( + state.getPersonality(), + command + ); + float experienceSpeed = state + .getJobExperience() + .getSpeedMultiplier(command); + + return restEfficiency * personalityMod * experienceSpeed; + } + + /** + * Increment job experience for an NPC on a specific command. + * Should be called once per work cycle (~1200 ticks). + * + * @param npc The NPC entity + * @param command The job command being performed + */ + public static void incrementJobExperience( + EntityDamsel npc, + NpcCommand command + ) { + PersonalityState state = npc.getPersonalityState(); + if (state == null) return; + state.getJobExperience().addExperience(command); + } + + /** + * Get the yield multiplier for an NPC performing a job command. + * Based on experience level - higher experience = better yields. + * + * @param npc The NPC entity + * @param command The job command being performed + * @return Yield multiplier (1.0 to 1.3) + */ + public static float getJobYieldMultiplier( + EntityDamsel npc, + NpcCommand command + ) { + PersonalityState state = npc.getPersonalityState(); + if (state == null) return 1.0f; + return state.getJobExperience().getYieldMultiplier(command); + } + + // ==================== Cell Navigation Utilities ==================== + + /** + * Teleport NPC to a position (centered on block). + */ + public static void teleportTo(EntityDamsel npc, BlockPos target) { + npc.teleportTo(target.getX() + 0.5, target.getY(), target.getZ() + 0.5); + npc.getNavigation().stop(); + } + + /** + * Check if NPC is near a position. + * @param distSq Squared distance threshold + */ + public static boolean isNearPos( + EntityDamsel npc, + BlockPos pos, + double distSq + ) { + return ( + pos != null && + npc.distanceToSqr(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5) < + distSq + ); + } + + /** + * Teleport NPC outside cell if currently inside. + * Used by job goals when starting work - NPC needs to leave the cell first. + * @return true if teleported + */ + public static boolean ensureOutsideCell(EntityDamsel npc) { + PersonalityState state = npc.getPersonalityState(); + if (state == null) return false; + BlockPos delivery = state.getCellDeliveryPoint(); + BlockPos home = state.getHomePos(); + if ( + delivery != null && + home != null && + !delivery.equals(home) && + isNearPos(npc, home, 9.0) + ) { + // < 3 blocks + teleportTo(npc, delivery); + return true; + } + return false; + } + + /** + * Get navigation target for going home (deliveryPoint if cell, otherwise homePos). + * When a cell has a deliveryPoint different from homePos, NPC should navigate + * to the deliveryPoint (exterior) then teleport inside. + */ + public static BlockPos getHomeNavigationTarget(EntityDamsel npc) { + PersonalityState state = npc.getPersonalityState(); + if (state == null) return npc.blockPosition(); + BlockPos delivery = state.getCellDeliveryPoint(); + BlockPos home = state.getHomePos(); + if (delivery != null && home != null && !delivery.equals(home)) { + return delivery; + } + return home != null ? home : npc.blockPosition(); + } + + // ==================== Chest / Inventory Utilities ==================== + + /** + * Get the container for a chest at the given position. + * Supports double chests via {@link ChestBlock#getContainer}. + * + * @param level The world level + * @param pos The chest block position + * @return The container, or null if no chest at that position + */ + @Nullable + public static Container getChestContainer(Level level, BlockPos pos) { + if (level.getBlockEntity(pos) instanceof ChestBlockEntity chest) { + return ChestBlock.getContainer( + (ChestBlock) chest.getBlockState().getBlock(), + chest.getBlockState(), + level, + pos, + false + ); + } + return null; + } + + /** + * Insert an ItemStack into a container, stacking with existing items first, + * then filling empty slots. Calls {@code container.setChanged()} after any + * modification so changes persist to disk. + * + * @param container The target container + * @param stack The stack to insert (count is mutated in-place) + * @return The remaining ItemStack that could not be inserted (or {@link ItemStack#EMPTY}) + */ + public static ItemStack insertIntoContainer( + Container container, + ItemStack stack + ) { + boolean modified = false; + + // Try to stack with existing items first + for ( + int i = 0; + i < container.getContainerSize() && !stack.isEmpty(); + i++ + ) { + ItemStack slotStack = container.getItem(i); + if ( + !slotStack.isEmpty() && + ItemStack.isSameItemSameTags(slotStack, stack) + ) { + int space = slotStack.getMaxStackSize() - slotStack.getCount(); + int toTransfer = Math.min(space, stack.getCount()); + if (toTransfer > 0) { + slotStack.grow(toTransfer); + stack.shrink(toTransfer); + modified = true; + } + } + } + + // Then try empty slots + for ( + int i = 0; + i < container.getContainerSize() && !stack.isEmpty(); + i++ + ) { + if (container.getItem(i).isEmpty()) { + container.setItem(i, stack.copy()); + stack.setCount(0); + modified = true; + } + } + + if (modified) { + container.setChanged(); + } + + return stack.isEmpty() ? ItemStack.EMPTY : stack; + } + + /** + * Store a list of items into a chest, dropping any overflow via the NPC. + * Plays the chest-close sound on completion. + * + * @param npc The NPC (used for overflow drops) + * @param level The world level + * @param chestPos The chest block position + * @param items The items to store (list is NOT cleared by this method) + */ + public static void storeItemsInChest( + EntityDamsel npc, + Level level, + BlockPos chestPos, + List items + ) { + Container container = getChestContainer(level, chestPos); + if (container != null) { + for (ItemStack item : items) { + ItemStack remaining = insertIntoContainer(container, item); + if (!remaining.isEmpty()) { + npc.spawnAtLocation(remaining); + } + } + } else { + // No chest — drop everything + for (ItemStack item : items) { + if (!item.isEmpty()) { + npc.spawnAtLocation(item); + } + } + } + + level.playSound( + null, + chestPos, + SoundEvents.CHEST_CLOSE, + SoundSource.BLOCKS, + 0.5f, + 1.0f + ); + } + + /** + * Insert an ItemStack into the NPC's internal inventory (NonNullList). + * Stacks with existing matching items first, then fills empty slots. + * Drops any overflow via {@code npc.spawnAtLocation()}. + * + * @param npc The NPC entity + * @param stack The stack to insert + * @return true if at least some items were added to inventory + */ + public static boolean addToNpcInventory(EntityDamsel npc, ItemStack stack) { + NonNullList inventory = npc.getNpcInventory(); + int originalCount = stack.getCount(); + + // Try to stack with existing items first + for (int i = 0; i < inventory.size() && !stack.isEmpty(); i++) { + ItemStack slotStack = inventory.get(i); + if ( + !slotStack.isEmpty() && + ItemStack.isSameItemSameTags(slotStack, stack) + ) { + int space = slotStack.getMaxStackSize() - slotStack.getCount(); + int toTransfer = Math.min(space, stack.getCount()); + if (toTransfer > 0) { + slotStack.grow(toTransfer); + stack.shrink(toTransfer); + } + } + } + + // Then try empty slots + for (int i = 0; i < inventory.size() && !stack.isEmpty(); i++) { + if (inventory.get(i).isEmpty()) { + inventory.set(i, stack.copy()); + stack.setCount(0); + } + } + + // Drop overflow + if (!stack.isEmpty()) { + npc.spawnAtLocation(stack); + } + + return stack.getCount() < originalCount; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcGuardCommandGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcGuardCommandGoal.java new file mode 100644 index 0000000..d1375dd --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcGuardCommandGoal.java @@ -0,0 +1,573 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.personality.NpcCommand; +import com.tiedup.remake.personality.PersonalityState; +import java.util.EnumSet; +import java.util.List; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.core.NonNullList; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.monster.Monster; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.AABB; + +/** + * AI Goal: NPC guards a position and alerts on intruders. + * + * Personality System Phase D: AI Goals + * + *

Behavior:

+ *
    + *
  • Only active when NPC has GUARD command
  • + *
  • Stays at guard position
  • + *
  • Looks around for hostile mobs
  • + *
  • Alerts master when hostiles are detected
  • + *
  • Awards XP periodically while guarding
  • + *
+ */ +public class NpcGuardCommandGoal extends Goal { + + private final EntityDamsel npc; + + /** Detection radius for hostiles */ + private static final double DETECTION_RADIUS = 16.0; + + /** Detection radius for slaves (work zone) */ + private static final double SLAVE_DETECTION_RADIUS = 16.0; + + /** Maximum drift from guard position */ + private static final double MAX_DRIFT = 2.0; + + /** Ticks between detection scans */ + private static final int SCAN_INTERVAL = 40; // 2 seconds + + /** Ticks between XP awards */ + private static final int XP_INTERVAL = 2400; // 2 minutes + + /** Cooldown between alert messages */ + private static final int ALERT_COOLDOWN = 200; // 10 seconds + + /** Chase speed modifier */ + private static final double CHASE_SPEED = 1.4; + + /** Distance to consider slave "caught" */ + private static final double CATCH_DISTANCE = 2.0; + + /** Guard behavior state */ + private enum GuardState { + GUARDING, // Normal guard behavior + CHASING, // Pursuing fleeing slave + RETURNING_SLAVE, // Bringing slave back to their home + } + + private GuardState guardState = GuardState.GUARDING; + + @Nullable + private BlockPos guardPosition; + + private int scanTimer; + private int xpTimer; + private int alertCooldown; + + @Nullable + private LivingEntity lastDetectedThreat; + + /** Current slave being chased/escorted */ + @Nullable + private EntityDamsel chaseTarget; + + /** Home position to return slave to */ + @Nullable + private BlockPos targetHome; + + public NpcGuardCommandGoal(EntityDamsel npc) { + this.npc = npc; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (npc.getActiveCommand() != NpcCommand.GUARD) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + return true; + } + + @Override + public boolean canContinueToUse() { + if (npc.getActiveCommand() != NpcCommand.GUARD) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + return true; + } + + @Override + public void start() { + NpcGoalHelper.ensureOutsideCell(npc); + var state = npc.getPersonalityState(); + if (state != null && state.getCommandTarget() != null) { + this.guardPosition = state.getCommandTarget(); + } else { + this.guardPosition = npc.blockPosition(); + } + + this.scanTimer = 0; + this.xpTimer = 0; + this.alertCooldown = 0; + this.lastDetectedThreat = null; + this.guardState = GuardState.GUARDING; + this.chaseTarget = null; + this.targetHome = null; + + npc.getNavigation().stop(); + } + + @Override + public void stop() { + this.guardPosition = null; + this.lastDetectedThreat = null; + this.guardState = GuardState.GUARDING; + this.chaseTarget = null; + this.targetHome = null; + } + + @Override + public void tick() { + switch (guardState) { + case GUARDING -> tickGuarding(); + case CHASING -> tickChasing(); + case RETURNING_SLAVE -> tickReturning(); + } + } + + /** + * Normal guard behavior - maintain position, scan for threats and fleeing slaves. + */ + private void tickGuarding() { + // Maintain guard position + if (this.guardPosition != null) { + double distSq = npc.distanceToSqr( + guardPosition.getX() + 0.5, + guardPosition.getY(), + guardPosition.getZ() + 0.5 + ); + + if (distSq > MAX_DRIFT * MAX_DRIFT) { + npc + .getNavigation() + .moveTo( + guardPosition.getX() + 0.5, + guardPosition.getY(), + guardPosition.getZ() + 0.5, + 1.0 + ); + } else if (npc.getNavigation().isInProgress()) { + npc.getNavigation().stop(); + } + } + + // Scan for threats and fleeing slaves + if (--this.scanTimer <= 0) { + this.scanTimer = SCAN_INTERVAL; + scanForThreats(); + scanForFleeingSlaves(); + } + + // Look at detected threat + if ( + this.lastDetectedThreat != null && this.lastDetectedThreat.isAlive() + ) { + npc.getLookControl().setLookAt(lastDetectedThreat, 30.0F, 30.0F); + } else { + // Look around randomly + if (npc.getRandom().nextInt(60) == 0) { + float yRot = + npc.getYRot() + (npc.getRandom().nextFloat() - 0.5f) * 90; + npc + .getLookControl() + .setLookAt( + npc.getX() + Math.sin((-yRot * Math.PI) / 180) * 10, + npc.getEyeY(), + npc.getZ() + Math.cos((-yRot * Math.PI) / 180) * 10 + ); + } + } + + // Decrement alert cooldown + if (this.alertCooldown > 0) { + this.alertCooldown--; + } + + // Award XP periodically + if (++this.xpTimer >= XP_INTERVAL) { + this.xpTimer = 0; + awardXP(2); + } + } + + /** + * Chase a fleeing slave. + */ + private void tickChasing() { + if (chaseTarget == null || !chaseTarget.isAlive()) { + returnToGuarding(); + return; + } + + double distSq = npc.distanceToSqr(chaseTarget); + + // Caught the slave + if (distSq < CATCH_DISTANCE * CATCH_DISTANCE) { + captureAndReturn(); + return; + } + + // Lost the slave (too far) + if (distSq > SLAVE_DETECTION_RADIUS * SLAVE_DETECTION_RADIUS * 4) { + returnToGuarding(); + return; + } + + // Continue chase + npc.getNavigation().moveTo(chaseTarget, CHASE_SPEED); + } + + /** + * Return a captured slave to their home. + */ + private void tickReturning() { + if (chaseTarget == null || targetHome == null) { + returnToGuarding(); + return; + } + + // Check if slave is at their home + double slaveDistToHome = chaseTarget.distanceToSqr( + targetHome.getX() + 0.5, + targetHome.getY(), + targetHome.getZ() + 0.5 + ); + + if (slaveDistToHome < 4.0) { + // 2 blocks + // Slave returned home - give command to stay + PersonalityState slaveState = chaseTarget.getPersonalityState(); + if (slaveState != null) { + slaveState.setActiveCommand( + NpcCommand.STAY, + slaveState.getCommandingPlayer(), + targetHome + ); + } + returnToGuarding(); + return; + } + + // Navigate slave to their home (guard escorts them) + double guardDistToSlave = npc.distanceToSqr(chaseTarget); + if (guardDistToSlave > 9.0) { + // 3 blocks + // Stay close to slave + npc.getNavigation().moveTo(chaseTarget, CHASE_SPEED); + } else { + // Push slave toward home + npc + .getNavigation() + .moveTo( + targetHome.getX() + 0.5, + targetHome.getY(), + targetHome.getZ() + 0.5, + 1.0 + ); + } + } + + /** + * Return to normal guarding state. + */ + private void returnToGuarding() { + this.guardState = GuardState.GUARDING; + this.chaseTarget = null; + this.targetHome = null; + + // Return to guard position + if (this.guardPosition != null) { + npc + .getNavigation() + .moveTo( + guardPosition.getX() + 0.5, + guardPosition.getY(), + guardPosition.getZ() + 0.5, + 1.0 + ); + } + } + + /** + * Scan for slaves trying to flee within work zone. + * Criteria: Has collar with same master, not tied, far from home, high fear OR no command. + */ + private void scanForFleeingSlaves() { + // Don't scan if already chasing + if (guardState != GuardState.GUARDING) return; + + var state = npc.getPersonalityState(); + if (state == null) return; + + UUID masterUUID = state.getCommandingPlayer(); + if (masterUUID == null) return; + + AABB scanArea = npc.getBoundingBox().inflate(SLAVE_DETECTION_RADIUS); + List slaves = npc + .level() + .getEntitiesOfClass( + EntityDamsel.class, + scanArea, + d -> d != this.npc + ); + + for (EntityDamsel slave : slaves) { + if (isSlaveAttemptingEscape(slave, masterUUID)) { + // Found fleeing slave - start chase + this.chaseTarget = slave; + this.guardState = GuardState.CHASING; + alertMasterAboutFleeing(slave); + return; + } + } + } + + /** + * Check if a slave is attempting to escape. + */ + private boolean isSlaveAttemptingEscape( + EntityDamsel slave, + UUID masterUUID + ) { + // Must have collar owned by same master + if (!slave.hasCollar()) return false; + UUID collarOwner = getCollarOwnerUUID(slave); + if ( + collarOwner == null || !collarOwner.equals(masterUUID) + ) return false; + + // Must not be tied up (already restrained) + if (slave.isTiedUp()) return false; + + PersonalityState slaveState = slave.getPersonalityState(); + if (slaveState == null) return false; + + // Check if fleeing: no active command AND far from home + if (slaveState.getActiveCommand() == NpcCommand.NONE) { + BlockPos slaveHome = slaveState.getHomePos(); + if (slaveHome == null) return true; // No home = wandering = escape attempt + + double distFromHome = slave.distanceToSqr( + slaveHome.getX() + 0.5, + slaveHome.getY(), + slaveHome.getZ() + 0.5 + ); + if (distFromHome > 100) { + // 10+ blocks from home + return true; + } + } + + return false; + } + + /** + * Capture the fleeing slave and prepare to return them. + */ + private void captureAndReturn() { + if (chaseTarget == null) return; + + // Try to tie up the slave (if guard has bind item in inventory) + tryTieUpSlave(chaseTarget); + + // Apply capture effects: mood penalty + PersonalityState slaveState = chaseTarget.getPersonalityState(); + if (slaveState != null) { + slaveState.modifyMood(-5); + + // Get slave's home for return destination + this.targetHome = slaveState.getHomePos(); + } + + if (this.targetHome == null) { + // No home - just tie them up and return to guarding + returnToGuarding(); + return; + } + + // Start returning slave to their home + this.guardState = GuardState.RETURNING_SLAVE; + } + + /** + * Try to tie up the slave using a bind item from guard's inventory. + */ + private boolean tryTieUpSlave(EntityDamsel slave) { + // Search guard's inventory for bind item + NonNullList inventory = npc.getNpcInventory(); + for (int i = 0; i < inventory.size(); i++) { + ItemStack stack = inventory.get(i); + if ( + stack.getItem() instanceof ItemBind + ) { + // Apply bind to slave + slave.equip(BodyRegionV2.ARMS, stack.copy()); + stack.shrink(1); + return true; + } + } + return false; + } + + /** + * Alert the master about a fleeing slave. + */ + private void alertMasterAboutFleeing(EntityDamsel slave) { + var state = npc.getPersonalityState(); + if (state == null) return; + + UUID commanderUUID = state.getCommandingPlayer(); + if (commanderUUID == null) return; + + for (Player player : npc.level().players()) { + if (player.getUUID().equals(commanderUUID)) { + String slaveName = slave.getNpcName(); + player.displayClientMessage( + net.minecraft.network.chat.Component.literal( + npc.getNpcName() + " is chasing " + slaveName + "!" + ).withStyle(net.minecraft.ChatFormatting.RED), + true // Action bar + ); + break; + } + } + } + + /** + * Find the commanding player entity. + */ + @Nullable + private Player findCommandingPlayer() { + var state = npc.getPersonalityState(); + if (state == null) return null; + + UUID commanderUUID = state.getCommandingPlayer(); + if (commanderUUID == null) return null; + + for (Player player : npc.level().players()) { + if (player.getUUID().equals(commanderUUID)) { + return player; + } + } + return null; + } + + /** + * Get the collar owner UUID from a slave. + */ + @Nullable + private UUID getCollarOwnerUUID(EntityDamsel slave) { + if (!slave.hasCollar()) return null; + ItemStack collar = slave.getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemCollar collarItem) { + java.util.List owners = collarItem.getOwners(collar); + if (!owners.isEmpty()) { + return owners.get(0); + } + } + return null; + } + + /** + * Scan for hostile entities nearby. + */ + private void scanForThreats() { + AABB scanArea = npc.getBoundingBox().inflate(DETECTION_RADIUS); + List monsters = npc + .level() + .getEntitiesOfClass(Monster.class, scanArea); + + if (!monsters.isEmpty()) { + Monster closest = null; + double closestDist = Double.MAX_VALUE; + + for (Monster monster : monsters) { + double dist = npc.distanceToSqr(monster); + if (dist < closestDist) { + closest = monster; + closestDist = dist; + } + } + + if (closest != null) { + this.lastDetectedThreat = closest; + + // Alert master if cooldown allows + if (this.alertCooldown <= 0) { + alertMaster(closest); + this.alertCooldown = ALERT_COOLDOWN; + } + } + } else { + this.lastDetectedThreat = null; + } + } + + /** + * Alert the master about a threat. + */ + private void alertMaster(Monster threat) { + var state = npc.getPersonalityState(); + if (state == null) return; + + UUID commanderUUID = state.getCommandingPlayer(); + if (commanderUUID == null) return; + + for (Player player : npc.level().players()) { + if (player.getUUID().equals(commanderUUID)) { + // Send alert message (could be enhanced with dialogue system) + String threatName = threat.getName().getString(); + player.displayClientMessage( + net.minecraft.network.chat.Component.literal( + npc.getNpcName() + " spotted: " + threatName + "!" + ).withStyle(net.minecraft.ChatFormatting.YELLOW), + true // Action bar + ); + + // Award bonus XP for alerting + break; + } + } + } + + /** + * Award XP to the commanding player. + */ + private void awardXP(int amount) { + NpcGoalHelper.awardXPToMaster(npc, amount); + } + + @Override + public boolean requiresUpdateEveryTick() { + return true; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcIdleCommandGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcIdleCommandGoal.java new file mode 100644 index 0000000..ef910d7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcIdleCommandGoal.java @@ -0,0 +1,284 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.dialogue.DialogueTriggerSystem; +import com.tiedup.remake.dialogue.EntityDialogueManager; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.HomeType; +import com.tiedup.remake.personality.NpcCommand; +import com.tiedup.remake.personality.PersonalityState; +import java.util.EnumSet; +import java.util.Set; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; + +/** + * AI Goal: NPC behavior when IDLE command is active. + * + *

When inside a cell, NPCs exhibit varied idle behaviors: + * resting at bed, kneeling, sitting, wandering within cell, or standing. + * When outside a cell, stays in place (original behavior).

+ * + *

Also handles idle dialogue and XP awards.

+ * + * Personality System Phase 5: Living Jobs + */ +public class NpcIdleCommandGoal extends Goal { + + private final EntityDamsel npc; + + /** Minimum cooldown for idle dialogue (1 min) */ + private static final int IDLE_TALK_MIN = 1200; + + /** Maximum cooldown for idle dialogue (3 min) */ + private static final int IDLE_TALK_MAX = 3600; + + /** Ticks between XP awards (2 min) */ + private static final int XP_INTERVAL = 2400; + + private int idleTalkCooldown; + private int xpTimer; + + // --- Cell idle behavior --- + + private enum CellIdleBehavior { + REST_AT_BED, + KNEEL, + SIT, + WANDER, + STAND, + } + + private CellIdleBehavior currentBehavior = CellIdleBehavior.STAND; + private int behaviorTimer = 0; + private int behaviorDuration = 0; + private boolean inCell = false; + + @Nullable + private BlockPos wanderTarget = null; + + public NpcIdleCommandGoal(EntityDamsel npc) { + this.npc = npc; + this.setFlags(EnumSet.of(Goal.Flag.MOVE)); + } + + @Override + public boolean canUse() { + if (npc.getActiveCommand() != NpcCommand.IDLE) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + return true; + } + + @Override + public boolean canContinueToUse() { + if (npc.getActiveCommand() != NpcCommand.IDLE) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + return true; + } + + @Override + public void start() { + this.idleTalkCooldown = IDLE_TALK_MIN / 2; // Initial delay shorter + this.xpTimer = 0; + npc.getNavigation().stop(); + + // Detect if NPC is in a cell + PersonalityState state = npc.getPersonalityState(); + this.inCell = + state != null && + state.getCellId() != null && + state.getHomePos() != null && + NpcGoalHelper.isNearPos(npc, state.getHomePos(), 25.0); // 5 blocks + if (inCell) { + chooseBehavior(); + } + } + + @Override + public void stop() { + resetPose(); + inCell = false; + currentBehavior = CellIdleBehavior.STAND; + } + + @Override + public void tick() { + if (inCell) { + // Cell idle behavior timer + behaviorTimer++; + if (behaviorTimer >= behaviorDuration) { + resetPose(); + chooseBehavior(); + } + + // If WANDER and arrived at destination, stop moving + if ( + currentBehavior == CellIdleBehavior.WANDER && + wanderTarget != null + ) { + if (NpcGoalHelper.isNearPos(npc, wanderTarget, 4.0)) { + npc.getNavigation().stop(); + wanderTarget = null; + } + } + } else { + // Outside cell: stay in place + npc.getNavigation().stop(); + } + + // Award XP periodically for resting + if (++this.xpTimer >= XP_INTERVAL) { + this.xpTimer = 0; + Player master = NpcGoalHelper.findCommandingPlayer(npc); + if (master != null) { + } + } + + // Idle dialogue + if (--this.idleTalkCooldown <= 0 && !npc.isGagged()) { + if (npc.getRandom().nextFloat() < 0.005f) { + speakIdleDialogue(); + this.idleTalkCooldown = + IDLE_TALK_MIN + + npc.getRandom().nextInt(IDLE_TALK_MAX - IDLE_TALK_MIN); + } + } + } + + /** + * Choose a random idle behavior for the NPC in their cell. + * Weighted random: 25% bed rest, 20% kneel, 20% sit, 20% wander, 15% stand. + * NPCs tied to a pole can only do stationary behaviors (kneel/sit/stand). + */ + private void chooseBehavior() { + PersonalityState state = npc.getPersonalityState(); + if (state == null) return; + + behaviorTimer = 0; + boolean canMove = !npc.isTiedToPole(); + + int roll = npc.getRandom().nextInt(100); + + if ( + roll < 25 && + canMove && + state.getHomeType() != HomeType.CELL && + state.getHomeType() != HomeType.NONE + ) { + // 25% - Rest at bed + currentBehavior = CellIdleBehavior.REST_AT_BED; + behaviorDuration = 600 + npc.getRandom().nextInt(1200); // 30-90s + BlockPos bed = state.getHomePos(); + if (bed != null && !NpcGoalHelper.isNearPos(npc, bed, 4.0)) { + npc + .getNavigation() + .moveTo( + bed.getX() + 0.5, + bed.getY(), + bed.getZ() + 0.5, + 0.6 + ); + } + npc.setSitting(true); + } else if (roll < 45 || (!canMove && roll < 55)) { + // 20% - Kneel (expanded range if can't move) + currentBehavior = CellIdleBehavior.KNEEL; + behaviorDuration = 400 + npc.getRandom().nextInt(800); // 20-60s + npc.setKneeling(true); + } else if (roll < 65 || (!canMove && roll < 75)) { + // 20% - Sit (expanded range if can't move) + currentBehavior = CellIdleBehavior.SIT; + behaviorDuration = 400 + npc.getRandom().nextInt(800); // 20-60s + npc.setSitting(true); + } else if (roll < 85 && canMove) { + // 20% - Wander in cell + currentBehavior = CellIdleBehavior.WANDER; + behaviorDuration = 200 + npc.getRandom().nextInt(400); // 10-30s + wanderToRandomInterior(state); + } else { + // 15% - Stand + currentBehavior = CellIdleBehavior.STAND; + behaviorDuration = 300 + npc.getRandom().nextInt(600); // 15-45s + } + } + + /** + * Pick a random interior block in the cell and navigate there. + */ + private void wanderToRandomInterior(PersonalityState state) { + UUID cellUuid = state.getCellId(); + if (cellUuid == null) return; + + if (!(npc.level() instanceof ServerLevel serverLevel)) return; + CellDataV2 cell = CellRegistryV2.get(serverLevel).getCell(cellUuid); + if (cell == null) return; + + Set interior = cell.getInteriorBlocks(); + if (interior.size() < 4) return; // Cell too small for wandering + + BlockPos[] blocks = interior.toArray(BlockPos[]::new); + wanderTarget = blocks[npc.getRandom().nextInt(blocks.length)]; + npc + .getNavigation() + .moveTo( + wanderTarget.getX() + 0.5, + wanderTarget.getY(), + wanderTarget.getZ() + 0.5, + 0.4 + ); + } + + /** + * Reset pose (sitting/kneeling) and navigation. + */ + private void resetPose() { + npc.setSitting(false); + npc.setKneeling(false); + wanderTarget = null; + npc.getNavigation().stop(); + } + + /** + * Speak idle dialogue based on current state. + * Priority: needs > mood > environment > generic idle + */ + private void speakIdleDialogue() { + Player player = NpcGoalHelper.findCommandingPlayer(npc); + if (player == null) { + player = npc.level().getNearestPlayer(npc, 16); + } + if (player == null) return; + + // Try proactive dialogue first (needs/mood) + String dialogueId = DialogueTriggerSystem.selectProactiveDialogue(npc); + + // Try environment dialogue + if (dialogueId == null || dialogueId.startsWith("idle.")) { + String envDialogue = + DialogueTriggerSystem.selectEnvironmentDialogue(npc); + if (envDialogue != null && npc.getRandom().nextFloat() < 0.3f) { + dialogueId = envDialogue; + } + } + + // Fallback to resting idle + if (dialogueId == null) { + dialogueId = "idle.resting"; + } + + EntityDialogueManager.talkByDialogueId(npc, player, dialogueId); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcKneelCommandGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcKneelCommandGoal.java new file mode 100644 index 0000000..24278aa --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcKneelCommandGoal.java @@ -0,0 +1,124 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.NpcCommand; +import java.util.EnumSet; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; + +/** + * AI Goal: NPC kneels when KNEEL command is given. + * + * Personality System Phase D: AI Goals + * + *

Behavior:

+ *
    + *
  • Only active when NPC has KNEEL command
  • + *
  • NPC enters kneeling pose (stops movement)
  • + *
  • Awards XP on command start
  • + *
  • Stays kneeling until command is cancelled
  • + *
+ */ +public class NpcKneelCommandGoal extends Goal { + + private final EntityDamsel npc; + + /** Has XP been awarded for this kneel session? */ + private boolean xpAwarded; + + public NpcKneelCommandGoal(EntityDamsel npc) { + this.npc = npc; + // Block movement and jumping while kneeling + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.JUMP)); + } + + @Override + public boolean canUse() { + // Must have KNEEL command active + if (npc.getActiveCommand() != NpcCommand.KNEEL) { + return false; + } + + // Must have collar + if (!npc.hasCollar()) { + return false; + } + + return true; + } + + @Override + public boolean canContinueToUse() { + // Stop if command changed + if (npc.getActiveCommand() != NpcCommand.KNEEL) { + return false; + } + + // Stop if no collar + if (!npc.hasCollar()) { + return false; + } + + return true; + } + + @Override + public void start() { + this.xpAwarded = false; + + // Stop all movement + this.npc.getNavigation().stop(); + + // Set kneeling pose + this.npc.setKneeling(true); + + // Award XP for obeying + awardXP(); + } + + @Override + public void stop() { + // Clear kneeling pose + this.npc.setKneeling(false); + } + + @Override + public void tick() { + // Ensure we stay still + if (this.npc.getNavigation().isInProgress()) { + this.npc.getNavigation().stop(); + } + + // Award XP if not already done + if (!this.xpAwarded) { + awardXP(); + } + } + + @Override + public boolean requiresUpdateEveryTick() { + return true; + } + + /** + * Award XP to the commanding player. + */ + private void awardXP() { + if (this.xpAwarded) return; + + var state = this.npc.getPersonalityState(); + if (state == null) return; + + UUID commanderUUID = state.getCommandingPlayer(); + if (commanderUUID == null) return; + + for (Player player : this.npc.level().players()) { + if (player.getUUID().equals(commanderUUID)) { + this.xpAwarded = true; + break; + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcMineCommandGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcMineCommandGoal.java new file mode 100644 index 0000000..2709ee0 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcMineCommandGoal.java @@ -0,0 +1,486 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.NpcCommand; +import com.tiedup.remake.personality.PersonalityState; +import java.util.EnumSet; +import java.util.List; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; + +/** + * AI Goal: NPC follows the player and mines nearby ores. + * + *

Requires a pickaxe in main hand. Drops are stored in the NPC's + * internal inventory (accessible via the NPC inventory GUI).

+ * + *

Behavior:

+ *
    + *
  • Follows the commanding player
  • + *
  • Scans for ores within 6 blocks of the player
  • + *
  • Moves to ore, mines it, stores drops in NPC inventory
  • + *
  • Returns to following the player
  • + *
  • Teleports if player gets too far away
  • + *
+ */ +public class NpcMineCommandGoal extends Goal { + + private final EntityDamsel npc; + private final double speedModifier; + + /** Ore search radius around the player */ + private static final int ORE_SEARCH_RADIUS = 6; + + /** Vertical search range for ores */ + private static final int ORE_SEARCH_Y = 3; + + /** Distance to interact with a block */ + private static final double INTERACT_DISTANCE = 2.5; + + /** Distance at which to start following master */ + private static final double FOLLOW_START_DISTANCE = 6.0; + + /** Distance to teleport to master */ + private static final double TELEPORT_DISTANCE = 20.0; + + /** Base ticks to mine a block (modified by efficiency) */ + private static final int BASE_MINE_DELAY = 20; + + /** Ticks between ore scans */ + private static final int SCAN_INTERVAL = 30; + + /** Ticks between XP awards */ + private static final int XP_INTERVAL = 1200; + + /** Rest drain intensity for mining (heavy work) */ + private static final float WORK_INTENSITY = 1.5f; + + private enum MinePhase { + FOLLOWING, + MOVING_TO_ORE, + MINING, + RETURNING, + } + + private MinePhase phase; + + @Nullable + private Player master; + + @Nullable + private BlockPos targetOre; + + private int pathRecalcTimer; + private int scanTimer; + private int mineTimer; + private int xpTimer; + + public NpcMineCommandGoal(EntityDamsel npc, double speedModifier) { + this.npc = npc; + this.speedModifier = speedModifier; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (npc.getActiveCommand() != NpcCommand.MINE) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + if (!isPickaxe(npc.getMainHandItem())) { + return false; + } + + this.master = NpcGoalHelper.findCommandingPlayer(npc); + return this.master != null; + } + + @Override + public boolean canContinueToUse() { + if (npc.getActiveCommand() != NpcCommand.MINE) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + if (this.master == null || !this.master.isAlive()) { + return false; + } + if (this.master.level() != this.npc.level()) { + return false; + } + return true; + } + + @Override + public void start() { + NpcGoalHelper.ensureOutsideCell(npc); + this.phase = MinePhase.FOLLOWING; + this.pathRecalcTimer = 0; + this.scanTimer = 0; + this.mineTimer = 0; + this.xpTimer = 0; + this.targetOre = null; + } + + @Override + public void stop() { + this.master = null; + this.targetOre = null; + npc.getNavigation().stop(); + } + + @Override + public void tick() { + if (this.master == null) return; + + // Check pickaxe + if (!isPickaxe(npc.getMainHandItem())) { + // Lost pickaxe, just follow + this.phase = MinePhase.FOLLOWING; + this.targetOre = null; + } + + // Award XP periodically + if (++this.xpTimer >= XP_INTERVAL) { + this.xpTimer = 0; + NpcGoalHelper.incrementJobExperience(npc, NpcCommand.MINE); + } + + // Teleport if too far + double distToMaster = npc.distanceTo(master); + if (distToMaster > TELEPORT_DISTANCE) { + tryTeleportNear(); + this.phase = MinePhase.FOLLOWING; + this.targetOre = null; + return; + } + + switch (this.phase) { + case FOLLOWING -> tickFollowing(); + case MOVING_TO_ORE -> tickMovingToOre(); + case MINING -> tickMining(); + case RETURNING -> tickReturning(); + } + } + + @Override + public boolean requiresUpdateEveryTick() { + return true; + } + + private void tickFollowing() { + if (this.master == null) return; + + double distToMaster = npc.distanceTo(master); + + // Follow master if far enough + if (distToMaster > FOLLOW_START_DISTANCE) { + if (--this.pathRecalcTimer <= 0) { + this.pathRecalcTimer = 10; + npc.getNavigation().moveTo(master, speedModifier); + } + } else { + npc.getNavigation().stop(); + } + + // Look at master + npc.getLookControl().setLookAt(master, 30.0f, 30.0f); + + // Scan for ores periodically (only when close to master) + if ( + distToMaster <= FOLLOW_START_DISTANCE + 4 && + isPickaxe(npc.getMainHandItem()) + ) { + if (--this.scanTimer <= 0) { + float efficiency = NpcGoalHelper.getJobEfficiency( + npc, + NpcCommand.MINE + ); + this.scanTimer = (int) (SCAN_INTERVAL / efficiency); + + this.targetOre = findOreNearPlayer(); + if (this.targetOre != null) { + this.phase = MinePhase.MOVING_TO_ORE; + this.pathRecalcTimer = 0; + } + } + } + } + + private void tickMovingToOre() { + if (this.targetOre == null || this.master == null) { + this.phase = MinePhase.FOLLOWING; + return; + } + + // Check ore is still valid + BlockState state = npc.level().getBlockState(targetOre); + if (!isOre(state)) { + this.targetOre = null; + this.phase = MinePhase.FOLLOWING; + return; + } + + // If master moves too far, abort and follow + double distToMaster = npc.distanceTo(master); + if (distToMaster > TELEPORT_DISTANCE * 0.75) { + this.targetOre = null; + this.phase = MinePhase.FOLLOWING; + return; + } + + // Move towards ore + if (--this.pathRecalcTimer <= 0) { + this.pathRecalcTimer = 10; + npc + .getNavigation() + .moveTo( + targetOre.getX() + 0.5, + targetOre.getY(), + targetOre.getZ() + 0.5, + speedModifier + ); + } + + // Look at ore + npc + .getLookControl() + .setLookAt( + targetOre.getX() + 0.5, + targetOre.getY() + 0.5, + targetOre.getZ() + 0.5 + ); + + // Check if close enough to mine + double dist = npc.blockPosition().distSqr(targetOre); + if (dist <= INTERACT_DISTANCE * INTERACT_DISTANCE) { + npc.getNavigation().stop(); + float efficiency = NpcGoalHelper.getJobEfficiency( + npc, + NpcCommand.MINE + ); + this.mineTimer = (int) (BASE_MINE_DELAY / efficiency); + this.phase = MinePhase.MINING; + } + } + + private void tickMining() { + if (this.targetOre == null) { + this.phase = MinePhase.FOLLOWING; + return; + } + + // Look at block while mining + npc + .getLookControl() + .setLookAt( + targetOre.getX() + 0.5, + targetOre.getY() + 0.5, + targetOre.getZ() + 0.5 + ); + + if (--this.mineTimer > 0) { + return; + } + + Level level = npc.level(); + BlockState state = level.getBlockState(targetOre); + + if (isOre(state) && level instanceof ServerLevel serverLevel) { + // Get drops using the NPC's tool (respects harvest level) + List drops = Block.getDrops( + state, + serverLevel, + targetOre, + level.getBlockEntity(targetOre), + npc, + npc.getMainHandItem() + ); + + // Store drops in NPC inventory + for (ItemStack drop : drops) { + NpcGoalHelper.addToNpcInventory(npc, drop); + } + + // Apply yield bonus from experience + float yieldMult = NpcGoalHelper.getJobYieldMultiplier( + npc, + NpcCommand.MINE + ); + if ( + yieldMult > 1.0f && + npc.getRandom().nextFloat() < (yieldMult - 1.0f) + ) { + if (!drops.isEmpty()) { + ItemStack bonusDrop = drops + .get(npc.getRandom().nextInt(drops.size())) + .copy(); + bonusDrop.setCount(1); + NpcGoalHelper.addToNpcInventory(npc, bonusDrop); + } + } + + // Destroy block + level.destroyBlock(targetOre, false); + + // Damage pickaxe + ItemStack pickaxe = npc.getMainHandItem(); + if (isPickaxe(pickaxe)) { + pickaxe.hurtAndBreak(1, npc, entity -> {}); + } + + // Sound + level.playSound( + null, + targetOre, + SoundEvents.STONE_BREAK, + SoundSource.BLOCKS, + 1.0f, + 1.0f + ); + + // Drain rest + PersonalityState personalityState = npc.getPersonalityState(); + if (personalityState != null) { + personalityState.getNeeds().drainFromWork(WORK_INTENSITY); + } + } + + this.targetOre = null; + this.phase = MinePhase.RETURNING; + this.pathRecalcTimer = 0; + } + + private void tickReturning() { + if (this.master == null) { + this.phase = MinePhase.FOLLOWING; + return; + } + + double distToMaster = npc.distanceTo(master); + + // If close enough, switch back to following (which also scans for ore) + if (distToMaster <= FOLLOW_START_DISTANCE + 2) { + this.phase = MinePhase.FOLLOWING; + this.scanTimer = 10; // Short scan delay after mining + return; + } + + // Move towards master + if (--this.pathRecalcTimer <= 0) { + this.pathRecalcTimer = 10; + npc.getNavigation().moveTo(master, speedModifier); + } + + npc.getLookControl().setLookAt(master, 30.0f, 30.0f); + } + + // ========== Helper Methods ========== + + @Nullable + private BlockPos findOreNearPlayer() { + if (this.master == null) return null; + + BlockPos masterPos = master.blockPosition(); + Level level = npc.level(); + BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos(); + + BlockPos nearest = null; + double nearestDist = Double.MAX_VALUE; + + for (int x = -ORE_SEARCH_RADIUS; x <= ORE_SEARCH_RADIUS; x++) { + for (int z = -ORE_SEARCH_RADIUS; z <= ORE_SEARCH_RADIUS; z++) { + for (int y = -ORE_SEARCH_Y; y <= ORE_SEARCH_Y; y++) { + mutable.set( + masterPos.getX() + x, + masterPos.getY() + y, + masterPos.getZ() + z + ); + BlockState state = level.getBlockState(mutable); + + if (isOre(state)) { + double dist = npc.blockPosition().distSqr(mutable); + if (dist < nearestDist) { + nearest = mutable.immutable(); + nearestDist = dist; + } + } + } + } + } + + return nearest; + } + + private boolean isOre(BlockState state) { + Block block = state.getBlock(); + return ( + block == Blocks.IRON_ORE || + block == Blocks.COAL_ORE || + block == Blocks.GOLD_ORE || + block == Blocks.COPPER_ORE || + block == Blocks.DIAMOND_ORE || + block == Blocks.LAPIS_ORE || + block == Blocks.REDSTONE_ORE || + block == Blocks.EMERALD_ORE || + block == Blocks.NETHER_GOLD_ORE || + block == Blocks.NETHER_QUARTZ_ORE || + block == Blocks.DEEPSLATE_IRON_ORE || + block == Blocks.DEEPSLATE_COAL_ORE || + block == Blocks.DEEPSLATE_GOLD_ORE || + block == Blocks.DEEPSLATE_COPPER_ORE || + block == Blocks.DEEPSLATE_DIAMOND_ORE || + block == Blocks.DEEPSLATE_LAPIS_ORE || + block == Blocks.DEEPSLATE_REDSTONE_ORE || + block == Blocks.DEEPSLATE_EMERALD_ORE + ); + } + + private boolean isPickaxe(ItemStack stack) { + return ( + stack.is(Items.WOODEN_PICKAXE) || + stack.is(Items.STONE_PICKAXE) || + stack.is(Items.IRON_PICKAXE) || + stack.is(Items.GOLDEN_PICKAXE) || + stack.is(Items.DIAMOND_PICKAXE) || + stack.is(Items.NETHERITE_PICKAXE) + ); + } + + private void tryTeleportNear() { + if (this.master == null) return; + if (this.npc.isTiedUp()) return; + + double x = master.getX() + (npc.getRandom().nextDouble() - 0.5) * 4; + double y = master.getY(); + double z = master.getZ() + (npc.getRandom().nextDouble() - 0.5) * 4; + + if ( + npc + .level() + .noCollision( + npc, + npc + .getBoundingBox() + .move(x - npc.getX(), y - npc.getY(), z - npc.getZ()) + ) + ) { + npc.teleportTo(x, y, z); + npc.getNavigation().stop(); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcPatrolCommandGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcPatrolCommandGoal.java new file mode 100644 index 0000000..78e0706 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcPatrolCommandGoal.java @@ -0,0 +1,176 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.NpcCommand; +import java.util.EnumSet; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.pathfinder.Path; + +/** + * AI Goal: NPC patrols around a defined zone. + * + * Personality System Phase D: AI Goals + * + *

Behavior:

+ *
    + *
  • Only active when NPC has PATROL command
  • + *
  • Walks randomly within a radius of the command target
  • + *
  • Awards XP periodically while patrolling
  • + *
  • Job command - continues until cancelled
  • + *
+ */ +public class NpcPatrolCommandGoal extends Goal { + + private final EntityDamsel npc; + private final double speedModifier; + + /** Patrol radius around center */ + private static final int PATROL_RADIUS = 10; + + /** Ticks between finding new patrol point */ + private static final int PATROL_INTERVAL_MIN = 100; + private static final int PATROL_INTERVAL_MAX = 200; + + /** Ticks between XP awards */ + private static final int XP_INTERVAL = 2400; // 2 minutes + + @Nullable + private BlockPos patrolCenter; + + @Nullable + private BlockPos currentTarget; + + private int patrolTimer; + private int xpTimer; + + public NpcPatrolCommandGoal(EntityDamsel npc, double speedModifier) { + this.npc = npc; + this.speedModifier = speedModifier; + this.setFlags(EnumSet.of(Goal.Flag.MOVE)); + } + + @Override + public boolean canUse() { + if (npc.getActiveCommand() != NpcCommand.PATROL) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + + var state = npc.getPersonalityState(); + if (state == null) return false; + + this.patrolCenter = state.getCommandTarget(); + // If no target set, use current position + if (this.patrolCenter == null) { + this.patrolCenter = npc.blockPosition(); + } + + return true; + } + + @Override + public boolean canContinueToUse() { + if (npc.getActiveCommand() != NpcCommand.PATROL) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + return true; + } + + @Override + public void start() { + NpcGoalHelper.ensureOutsideCell(npc); + this.patrolTimer = 0; + this.xpTimer = 0; + this.currentTarget = null; + findNewPatrolPoint(); + } + + @Override + public void stop() { + this.npc.getNavigation().stop(); + this.patrolCenter = null; + this.currentTarget = null; + } + + @Override + public void tick() { + // Decrement patrol timer + if (--this.patrolTimer <= 0) { + findNewPatrolPoint(); + } + + // If we reached the target or got stuck, find new point + if (this.currentTarget != null) { + double dist = npc.distanceToSqr( + currentTarget.getX() + 0.5, + currentTarget.getY(), + currentTarget.getZ() + 0.5 + ); + if (dist < 4.0 || npc.getNavigation().isDone()) { + findNewPatrolPoint(); + } + } + + // Award XP periodically + if (++this.xpTimer >= XP_INTERVAL) { + this.xpTimer = 0; + awardXP(2); + } + } + + /** + * Find a new random point to patrol to. + */ + private void findNewPatrolPoint() { + if (this.patrolCenter == null) return; + + // Random offset within radius + int dx = npc.getRandom().nextInt(PATROL_RADIUS * 2 + 1) - PATROL_RADIUS; + int dz = npc.getRandom().nextInt(PATROL_RADIUS * 2 + 1) - PATROL_RADIUS; + + BlockPos target = patrolCenter.offset(dx, 0, dz); + + // Find valid Y level + for (int dy = 3; dy >= -3; dy--) { + BlockPos check = target.offset(0, dy, 0); + if ( + npc.level().getBlockState(check.below()).isSolid() && + !npc.level().getBlockState(check).isSolid() && + !npc.level().getBlockState(check.above()).isSolid() + ) { + target = check; + break; + } + } + + this.currentTarget = target; + this.patrolTimer = + PATROL_INTERVAL_MIN + + npc.getRandom().nextInt(PATROL_INTERVAL_MAX - PATROL_INTERVAL_MIN); + + npc + .getNavigation() + .moveTo( + target.getX() + 0.5, + target.getY(), + target.getZ() + 0.5, + speedModifier + ); + } + + /** + * Award XP to the commanding player. + */ + private void awardXP(int amount) { + NpcGoalHelper.awardXPToMaster(npc, amount); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcShearCommandGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcShearCommandGoal.java new file mode 100644 index 0000000..dc85e9e --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcShearCommandGoal.java @@ -0,0 +1,289 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.NpcCommand; +import com.tiedup.remake.personality.PersonalityState; +import java.util.EnumSet; +import java.util.List; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.animal.Sheep; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; + +/** + * AI Goal: NPC shears sheep in a work zone. + * + *

Requires shears in NPC's main hand.

+ *

Work flow:

+ *
    + *
  • Find un-sheared sheep in zone
  • + *
  • Move to sheep
  • + *
  • Shear sheep (if holding shears)
  • + *
  • Collect wool into inventory
  • + *
  • Repeat
  • + *
+ */ +public class NpcShearCommandGoal extends Goal { + + private final EntityDamsel npc; + private final double speedModifier; + + private static final double WORK_RADIUS = 16.0; + private static final double SHEAR_DISTANCE = 2.0; + private static final int SHEAR_COOLDOWN = 40; // 2 seconds + + /** Rest drain intensity for shearing (medium work) */ + private static final float WORK_INTENSITY = 1.0f; + + private enum ShearPhase { + SEARCHING, + MOVING, + SHEARING, + IDLE, + } + + private ShearPhase phase; + + @Nullable + private BlockPos workCenter; + + @Nullable + private Sheep targetSheep; + + @Nullable + private Player master; + + private int pathRecalcTimer; + private int actionTimer; + private int idleTimer; + + public NpcShearCommandGoal(EntityDamsel npc, double speedModifier) { + this.npc = npc; + this.speedModifier = speedModifier; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (npc.getActiveCommand() != NpcCommand.SHEAR) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + + this.master = NpcGoalHelper.findCommandingPlayer(npc); + return this.master != null; + } + + @Override + public boolean canContinueToUse() { + if (npc.getActiveCommand() != NpcCommand.SHEAR) { + return false; + } + return true; + } + + @Override + public void start() { + NpcGoalHelper.ensureOutsideCell(npc); + var state = npc.getPersonalityState(); + if (state != null && state.getCommandTarget() != null) { + this.workCenter = state.getCommandTarget(); + } else { + // Use current position as work center + this.workCenter = npc.blockPosition(); + } + + this.phase = ShearPhase.SEARCHING; + this.pathRecalcTimer = 0; + this.actionTimer = 0; + this.idleTimer = 0; + this.targetSheep = null; + } + + @Override + public void stop() { + this.workCenter = null; + this.targetSheep = null; + this.master = null; + npc.getNavigation().stop(); + } + + @Override + public void tick() { + // Check if holding shears + ItemStack mainHand = npc.getMainHandItem(); + if (!mainHand.is(Items.SHEARS)) { + // No shears, just idle + this.phase = ShearPhase.IDLE; + this.idleTimer++; + if (this.idleTimer > 200) { + // 10 seconds without shears + // Could trigger dialogue about needing shears + this.idleTimer = 0; + } + return; + } + + switch (this.phase) { + case SEARCHING -> tickSearching(); + case MOVING -> tickMoving(); + case SHEARING -> tickShearing(); + case IDLE -> tickIdle(); + } + } + + private void tickSearching() { + if (workCenter == null) { + this.phase = ShearPhase.IDLE; + return; + } + + // Find un-sheared sheep in work zone + Level level = npc.level(); + List sheepList = level.getEntitiesOfClass( + Sheep.class, + npc.getBoundingBox().inflate(WORK_RADIUS), + sheep -> !sheep.isSheared() && sheep.isAlive() + ); + + if (sheepList.isEmpty()) { + this.phase = ShearPhase.IDLE; + this.idleTimer = 0; + return; + } + + // Find closest sheep + Sheep closest = null; + double closestDist = Double.MAX_VALUE; + for (Sheep sheep : sheepList) { + double dist = npc.distanceToSqr(sheep); + if (dist < closestDist) { + closestDist = dist; + closest = sheep; + } + } + + this.targetSheep = closest; + this.phase = ShearPhase.MOVING; + this.pathRecalcTimer = 0; + } + + private void tickMoving() { + if ( + targetSheep == null || + !targetSheep.isAlive() || + targetSheep.isSheared() + ) { + this.phase = ShearPhase.SEARCHING; + return; + } + + // Move towards sheep + if (--this.pathRecalcTimer <= 0) { + this.pathRecalcTimer = 10; + npc.getNavigation().moveTo(targetSheep, speedModifier); + } + + // Look at sheep + npc.getLookControl().setLookAt(targetSheep, 30.0f, 30.0f); + + // Check if close enough + double dist = npc.distanceToSqr(targetSheep); + if (dist <= SHEAR_DISTANCE * SHEAR_DISTANCE) { + npc.getNavigation().stop(); + this.phase = ShearPhase.SHEARING; + this.actionTimer = SHEAR_COOLDOWN; + } + } + + private void tickShearing() { + if ( + targetSheep == null || + !targetSheep.isAlive() || + targetSheep.isSheared() + ) { + this.phase = ShearPhase.SEARCHING; + return; + } + + // Look at sheep while shearing + npc.getLookControl().setLookAt(targetSheep, 30.0f, 30.0f); + + if (--this.actionTimer > 0) { + return; // Still shearing animation + } + + // Perform shear + Level level = npc.level(); + ItemStack shears = npc.getMainHandItem(); + + if (shears.is(Items.SHEARS) && !targetSheep.isSheared()) { + // Shear the sheep + targetSheep.shear(SoundSource.NEUTRAL); + + // Apply yield bonus from experience + float yieldMult = NpcGoalHelper.getJobYieldMultiplier( + npc, + NpcCommand.SHEAR + ); + if ( + yieldMult > 1.0f && + npc.getRandom().nextFloat() < (yieldMult - 1.0f) + ) { + // Bonus wool - drop extra + npc.spawnAtLocation( + new ItemStack(net.minecraft.world.item.Items.WHITE_WOOL, 1) + ); + } + + // Damage shears + shears.hurtAndBreak(1, npc, entity -> { + // Shears broke + }); + + // Play sound + level.playSound( + null, + npc.getX(), + npc.getY(), + npc.getZ(), + SoundEvents.SHEEP_SHEAR, + SoundSource.NEUTRAL, + 1.0f, + 1.0f + ); + + // Award XP and experience + NpcGoalHelper.incrementJobExperience(npc, NpcCommand.SHEAR); + if (master != null) { + } + + // Drain rest from work + PersonalityState personalityState = npc.getPersonalityState(); + if (personalityState != null) { + personalityState.getNeeds().drainFromWork(WORK_INTENSITY); + } + } + + this.targetSheep = null; + this.phase = ShearPhase.SEARCHING; + } + + private void tickIdle() { + this.idleTimer++; + + // Every 3 seconds, search again + if (this.idleTimer >= 60) { + this.idleTimer = 0; + this.phase = ShearPhase.SEARCHING; + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcSitCommandGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcSitCommandGoal.java new file mode 100644 index 0000000..006df72 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcSitCommandGoal.java @@ -0,0 +1,139 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.NpcCommand; +import java.util.EnumSet; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; + +/** + * AI Goal: NPC sits down when SIT command is given. + * + * Personality System Phase D: AI Goals + * + *

Behavior:

+ *
    + *
  • Only active when NPC has SIT command
  • + *
  • NPC enters sitting pose (stops movement)
  • + *
  • Awards XP on command start
  • + *
  • Stays sitting until command is cancelled
  • + *
+ */ +public class NpcSitCommandGoal extends Goal { + + private final EntityDamsel npc; + + /** Has XP been awarded for this sit session? */ + private boolean xpAwarded; + + public NpcSitCommandGoal(EntityDamsel npc) { + this.npc = npc; + // Block movement and jumping while sitting + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.JUMP)); + } + + @Override + public boolean canUse() { + // Must have SIT command active + NpcCommand activeCmd = npc.getActiveCommand(); + if (activeCmd != NpcCommand.SIT) { + return false; + } + + // Must have collar + if (!npc.hasCollar()) { + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[NpcSitCommandGoal] canUse=false: NPC {} has SIT command but no collar", + npc.getNpcName() + ); + return false; + } + + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[NpcSitCommandGoal] canUse=true for NPC {}", + npc.getNpcName() + ); + return true; + } + + @Override + public boolean canContinueToUse() { + // Stop if command changed + if (npc.getActiveCommand() != NpcCommand.SIT) { + return false; + } + + // Stop if no collar + if (!npc.hasCollar()) { + return false; + } + + return true; + } + + @Override + public void start() { + this.xpAwarded = false; + + // Stop all movement + this.npc.getNavigation().stop(); + + // Set sitting pose + this.npc.setSitting(true); + + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[NpcSitCommandGoal] STARTED - NPC {} is now sitting (isSitting={})", + npc.getNpcName(), + npc.isSitting() + ); + + // Award XP for obeying + awardXP(); + } + + @Override + public void stop() { + // Clear sitting pose + this.npc.setSitting(false); + } + + @Override + public void tick() { + // Ensure we stay still + if (this.npc.getNavigation().isInProgress()) { + this.npc.getNavigation().stop(); + } + + // Award XP if not already done + if (!this.xpAwarded) { + awardXP(); + } + } + + @Override + public boolean requiresUpdateEveryTick() { + return true; + } + + /** + * Award XP to the commanding player. + */ + private void awardXP() { + if (this.xpAwarded) return; + + var state = this.npc.getPersonalityState(); + if (state == null) return; + + UUID commanderUUID = state.getCommandingPlayer(); + if (commanderUUID == null) return; + + for (Player player : this.npc.level().players()) { + if (player.getUUID().equals(commanderUUID)) { + this.xpAwarded = true; + break; + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcSortCommandGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcSortCommandGoal.java new file mode 100644 index 0000000..ed044aa --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcSortCommandGoal.java @@ -0,0 +1,523 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.NpcCommand; +import com.tiedup.remake.personality.PersonalityState; +import java.util.*; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.Container; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.entity.ChestBlockEntity; + +/** + * AI Goal: NPC sorts items by distributing them to matching chests. + * + *

Takes items from source chest (commandTarget) and distributes them + * to nearby chests (16 block radius) that already contain the same item. + * If no matching chest is found, the item stays in the source.

+ * + *

Behavior:

+ *
    + *
  • Build content map of all chests in zone
  • + *
  • Take an item from source chest
  • + *
  • Find a destination chest containing the same item
  • + *
  • Move to destination and store it
  • + *
  • Repeat while source has sortable items
  • + *
+ */ +public class NpcSortCommandGoal extends Goal { + + private final EntityDamsel npc; + private final double speedModifier; + + /** Radius to search for destination chests */ + private static final int CHEST_SEARCH_RADIUS = 16; + + private static final double INTERACT_DISTANCE = 2.0; + private static final int XP_INTERVAL = 1200; + private static final int IDLE_RESCAN_INTERVAL = 100; + private static final int ACTION_DELAY = 20; + private static final int CACHE_REFRESH_INTERVAL = 200; + private static final float WORK_INTENSITY = 0.6f; + + private enum SortPhase { + BUILDING_MAP, + MOVING_TO_SOURCE, + COLLECTING, + FINDING_DEST, + MOVING_TO_DEST, + STORING, + IDLE, + } + + private SortPhase phase; + + @Nullable + private BlockPos sourceChest; + + @Nullable + private BlockPos destChest; + + @Nullable + private Player master; + + @Nullable + private ItemStack heldItem; + + /** Cache: maps Item -> list of chest positions containing that item */ + private Map> chestContentMap = new HashMap<>(); + + /** Ticks since last cache refresh */ + private int cacheAge; + + private int pathRecalcTimer; + private int xpTimer; + private int idleTimer; + private int actionTimer; + + public NpcSortCommandGoal(EntityDamsel npc, double speedModifier) { + this.npc = npc; + this.speedModifier = speedModifier; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (npc.getActiveCommand() != NpcCommand.SORT) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + + this.master = NpcGoalHelper.findCommandingPlayer(npc); + return this.master != null; + } + + @Override + public boolean canContinueToUse() { + if (npc.getActiveCommand() != NpcCommand.SORT) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + return true; + } + + @Override + public void start() { + NpcGoalHelper.ensureOutsideCell(npc); + var state = npc.getPersonalityState(); + if (state == null || state.getCommandTarget() == null) { + npc.cancelCommand(); + return; + } + + this.sourceChest = state.getCommandTarget(); + this.destChest = null; + this.phase = SortPhase.BUILDING_MAP; + this.chestContentMap.clear(); + this.cacheAge = 0; + this.pathRecalcTimer = 0; + this.xpTimer = 0; + this.idleTimer = 0; + this.actionTimer = 0; + this.heldItem = null; + } + + @Override + public void stop() { + // Return held item to source if possible + if (heldItem != null && !heldItem.isEmpty() && sourceChest != null) { + Container container = NpcGoalHelper.getChestContainer( + npc.level(), + sourceChest + ); + if (container != null) { + NpcGoalHelper.insertIntoContainer(container, heldItem); + } + if (heldItem != null && !heldItem.isEmpty()) { + npc.spawnAtLocation(heldItem); + } + } + + this.sourceChest = null; + this.destChest = null; + this.master = null; + this.heldItem = null; + this.chestContentMap.clear(); + npc.getNavigation().stop(); + } + + @Override + public void tick() { + // Award XP periodically + if (++this.xpTimer >= XP_INTERVAL) { + this.xpTimer = 0; + awardXP(2); + NpcGoalHelper.incrementJobExperience(npc, NpcCommand.SORT); + } + + // Age the cache + this.cacheAge++; + + switch (this.phase) { + case BUILDING_MAP -> tickBuildingMap(); + case MOVING_TO_SOURCE -> tickMovingToSource(); + case COLLECTING -> tickCollecting(); + case FINDING_DEST -> tickFindingDest(); + case MOVING_TO_DEST -> tickMovingToDest(); + case STORING -> tickStoring(); + case IDLE -> tickIdle(); + } + } + + private void tickBuildingMap() { + buildChestContentMap(); + this.cacheAge = 0; + this.phase = SortPhase.MOVING_TO_SOURCE; + this.pathRecalcTimer = 0; + } + + private void tickMovingToSource() { + if (this.sourceChest == null) { + this.phase = SortPhase.IDLE; + return; + } + + if (--this.pathRecalcTimer <= 0) { + this.pathRecalcTimer = 10; + npc + .getNavigation() + .moveTo( + sourceChest.getX() + 0.5, + sourceChest.getY(), + sourceChest.getZ() + 0.5, + speedModifier + ); + } + + double dist = npc.blockPosition().distSqr(sourceChest); + if (dist <= INTERACT_DISTANCE * INTERACT_DISTANCE) { + npc.getNavigation().stop(); + this.phase = SortPhase.COLLECTING; + this.actionTimer = ACTION_DELAY; + } + } + + private void tickCollecting() { + if (sourceChest == null) { + this.phase = SortPhase.IDLE; + return; + } + + if (--this.actionTimer > 0) { + return; + } + + // Refresh cache if stale + if (this.cacheAge >= CACHE_REFRESH_INTERVAL) { + this.phase = SortPhase.BUILDING_MAP; + return; + } + + Level level = npc.level(); + Container container = NpcGoalHelper.getChestContainer( + level, + sourceChest + ); + + if (container == null) { + this.phase = SortPhase.IDLE; + return; + } + + // Find first item that has a matching destination chest + for (int i = 0; i < container.getContainerSize(); i++) { + ItemStack stack = container.getItem(i); + if (stack.isEmpty()) continue; + + Item item = stack.getItem(); + List destinations = chestContentMap.get(item); + if (destinations != null && !destinations.isEmpty()) { + // Take the stack + this.heldItem = stack.copy(); + container.setItem(i, ItemStack.EMPTY); + container.setChanged(); + + level.playSound( + null, + sourceChest, + SoundEvents.CHEST_OPEN, + SoundSource.BLOCKS, + 0.5f, + 1.0f + ); + + // Drain rest + PersonalityState personalityState = npc.getPersonalityState(); + if (personalityState != null) { + personalityState.getNeeds().drainFromWork(WORK_INTENSITY); + } + + this.phase = SortPhase.FINDING_DEST; + return; + } + } + + // No sortable items found + this.phase = SortPhase.IDLE; + this.idleTimer = 0; + } + + private void tickFindingDest() { + if (heldItem == null || heldItem.isEmpty()) { + this.phase = SortPhase.MOVING_TO_SOURCE; + return; + } + + Item item = heldItem.getItem(); + List destinations = chestContentMap.get(item); + + if (destinations == null || destinations.isEmpty()) { + // No destination — return to source and put it back + returnItemToSource(); + this.phase = SortPhase.MOVING_TO_SOURCE; + this.pathRecalcTimer = 0; + return; + } + + // Pick the best destination (most items of same type = best consolidation) + BlockPos best = findBestDestination(destinations, item); + if (best == null) { + returnItemToSource(); + this.phase = SortPhase.MOVING_TO_SOURCE; + this.pathRecalcTimer = 0; + return; + } + + this.destChest = best; + this.phase = SortPhase.MOVING_TO_DEST; + this.pathRecalcTimer = 0; + } + + private void tickMovingToDest() { + if (this.destChest == null) { + this.phase = SortPhase.FINDING_DEST; + return; + } + + if (--this.pathRecalcTimer <= 0) { + this.pathRecalcTimer = 10; + npc + .getNavigation() + .moveTo( + destChest.getX() + 0.5, + destChest.getY(), + destChest.getZ() + 0.5, + speedModifier + ); + } + + double dist = npc.blockPosition().distSqr(destChest); + if (dist <= INTERACT_DISTANCE * INTERACT_DISTANCE) { + npc.getNavigation().stop(); + this.phase = SortPhase.STORING; + this.actionTimer = ACTION_DELAY; + } + } + + private void tickStoring() { + if (destChest == null || heldItem == null || heldItem.isEmpty()) { + this.heldItem = null; + this.destChest = null; + this.phase = SortPhase.MOVING_TO_SOURCE; + this.pathRecalcTimer = 0; + return; + } + + if (--this.actionTimer > 0) { + return; + } + + Level level = npc.level(); + Container container = NpcGoalHelper.getChestContainer(level, destChest); + + if (container != null) { + ItemStack remaining = NpcGoalHelper.insertIntoContainer( + container, + heldItem + ); + + if (!remaining.isEmpty()) { + // Couldn't store all — hold remainder for next trip + this.heldItem = remaining; + } else { + this.heldItem = null; + } + + level.playSound( + null, + destChest, + SoundEvents.CHEST_CLOSE, + SoundSource.BLOCKS, + 0.5f, + 1.0f + ); + } + + awardXP(1); + + this.destChest = null; + + if (this.heldItem != null && !this.heldItem.isEmpty()) { + // Still have items, find another dest or return to source + this.phase = SortPhase.FINDING_DEST; + } else { + // Go back for more + this.phase = SortPhase.MOVING_TO_SOURCE; + this.pathRecalcTimer = 0; + } + } + + private void tickIdle() { + if (++this.idleTimer >= IDLE_RESCAN_INTERVAL) { + this.idleTimer = 0; + + // Rebuild cache and try again + this.phase = SortPhase.BUILDING_MAP; + } + } + + // ========== Content Map ========== + + /** + * Scan all chests within CHEST_SEARCH_RADIUS of the source chest, + * build an index of which items are in which chests. + * The source chest itself is excluded from destinations. + */ + private void buildChestContentMap() { + this.chestContentMap.clear(); + + if (sourceChest == null) return; + + Level level = npc.level(); + BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos(); + + for (int x = -CHEST_SEARCH_RADIUS; x <= CHEST_SEARCH_RADIUS; x++) { + for (int z = -CHEST_SEARCH_RADIUS; z <= CHEST_SEARCH_RADIUS; z++) { + for (int y = -2; y <= 2; y++) { + mutable.set( + sourceChest.getX() + x, + sourceChest.getY() + y, + sourceChest.getZ() + z + ); + + // Skip the source chest itself + if (mutable.equals(sourceChest)) continue; + + if ( + !(level.getBlockEntity(mutable) instanceof + ChestBlockEntity) + ) { + continue; + } + + BlockPos chestPos = mutable.immutable(); + Container container = NpcGoalHelper.getChestContainer( + level, + chestPos + ); + if (container == null) continue; + + // Index all items in this chest + Set seen = new HashSet<>(); + for (int i = 0; i < container.getContainerSize(); i++) { + ItemStack stack = container.getItem(i); + if (!stack.isEmpty()) { + Item item = stack.getItem(); + if (seen.add(item)) { + chestContentMap + .computeIfAbsent(item, k -> + new ArrayList<>() + ) + .add(chestPos); + } + } + } + } + } + } + } + + /** + * Find the best destination chest for an item: the one with the most + * of the same item already (best consolidation). + */ + @Nullable + private BlockPos findBestDestination(List candidates, Item item) { + Level level = npc.level(); + BlockPos best = null; + int bestCount = -1; + + for (BlockPos pos : candidates) { + Container container = NpcGoalHelper.getChestContainer(level, pos); + if (container == null) continue; + + // Check if chest has space and count matching items + int count = 0; + boolean hasSpace = false; + for (int i = 0; i < container.getContainerSize(); i++) { + ItemStack stack = container.getItem(i); + if (stack.isEmpty()) { + hasSpace = true; + } else if (stack.getItem() == item) { + count += stack.getCount(); + if (stack.getCount() < stack.getMaxStackSize()) { + hasSpace = true; + } + } + } + + if (hasSpace && count > bestCount) { + bestCount = count; + best = pos; + } + } + + return best; + } + + // ========== Utility ========== + + private void returnItemToSource() { + if ( + heldItem == null || heldItem.isEmpty() || sourceChest == null + ) return; + + Container container = NpcGoalHelper.getChestContainer( + npc.level(), + sourceChest + ); + if (container != null) { + NpcGoalHelper.insertIntoContainer(container, heldItem); + } + if (heldItem != null && !heldItem.isEmpty()) { + npc.spawnAtLocation(heldItem); + } + this.heldItem = null; + } + + private void awardXP(int amount) { + if (this.master != null) { + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcStayCommandGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcStayCommandGoal.java new file mode 100644 index 0000000..5a95a1b --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcStayCommandGoal.java @@ -0,0 +1,139 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.NpcCommand; +import java.util.EnumSet; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; + +/** + * AI Goal: NPC stays in place when STAY command is active. + * + * Personality System Phase D: AI Goals + * + *

Behavior:

+ *
    + *
  • Only active when NPC has STAY command
  • + *
  • NPC stays at their current position
  • + *
  • Cancels other movement goals
  • + *
  • Awards training XP periodically while staying
  • + *
+ */ +public class NpcStayCommandGoal extends Goal { + + private final EntityDamsel npc; + + /** Ticks between XP awards */ + private static final int XP_INTERVAL = 2400; // 2 minutes + + /** Maximum allowed drift from stay position */ + private static final double MAX_DRIFT = 3.0; + + @Nullable + private BlockPos stayPosition; + + private int xpTimer; + + public NpcStayCommandGoal(EntityDamsel npc) { + this.npc = npc; + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.JUMP)); + } + + @Override + public boolean canUse() { + // Must have STAY command active + if (npc.getActiveCommand() != NpcCommand.STAY) { + return false; + } + + // Must have collar + if (!npc.hasCollar()) { + return false; + } + + return true; + } + + @Override + public boolean canContinueToUse() { + // Stop if command changed + if (npc.getActiveCommand() != NpcCommand.STAY) { + return false; + } + + // Stop if no collar + if (!npc.hasCollar()) { + return false; + } + + return true; + } + + @Override + public void start() { + // Record current position as stay position + this.stayPosition = this.npc.blockPosition(); + this.xpTimer = 0; + + // Stop all movement + this.npc.getNavigation().stop(); + } + + @Override + public void stop() { + this.stayPosition = null; + } + + @Override + public void tick() { + // Stop any navigation attempts + if (this.npc.getNavigation().isInProgress()) { + this.npc.getNavigation().stop(); + } + + // Check if drifted too far from stay position + if (this.stayPosition != null) { + double distSq = this.npc.distanceToSqr( + this.stayPosition.getX() + 0.5, + this.stayPosition.getY(), + this.stayPosition.getZ() + 0.5 + ); + + // If drifted, try to return + if (distSq > MAX_DRIFT * MAX_DRIFT) { + this.npc.getNavigation().moveTo( + this.stayPosition.getX() + 0.5, + this.stayPosition.getY(), + this.stayPosition.getZ() + 0.5, + 1.0 + ); + } + } + + // Award XP periodically for staying + if (++this.xpTimer >= XP_INTERVAL) { + this.xpTimer = 0; + + // Find commanding player for XP + var state = this.npc.getPersonalityState(); + if (state != null) { + UUID commanderUUID = state.getCommandingPlayer(); + if (commanderUUID != null) { + for (Player player : this.npc.level().players()) { + if (player.getUUID().equals(commanderUUID)) { + break; + } + } + } + } + } + } + + @Override + public boolean requiresUpdateEveryTick() { + return true; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcStruggleGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcStruggleGoal.java new file mode 100644 index 0000000..babd254 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcStruggleGoal.java @@ -0,0 +1,367 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.items.base.IHasResistance; +import com.tiedup.remake.personality.PersonalityState; +import com.tiedup.remake.personality.PersonalityType; +import com.tiedup.remake.core.SettingsAccessor; +import com.tiedup.remake.util.TiedUpSounds; +import java.util.EnumSet; +import java.util.List; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.AABB; + +/** + * AI Goal: NPC attempts to struggle free from restraints. + * + * Personality System Phase I: Struggle System + * + *

Behavior:

+ *
    + *
  • Only active when NPC is tied up (has bind item)
  • + *
  • Ticks the struggle timer from PersonalityState
  • + *
  • When timer expires, attempts to struggle
  • + *
  • Success chance based on personality, training, mood, bind resistance
  • + *
  • On success: decrease bind resistance, free if resistance = 0
  • + *
  • On failure: gain training XP, reset timer
  • + *
  • Plays struggle animation during attempt
  • + *
+ */ +public class NpcStruggleGoal extends Goal { + + private final EntityDamsel npc; + + /** Duration of struggle animation in ticks (must match animation JSON endTick) */ + private static final int STRUGGLE_ANIMATION_TICKS = 200; // 10 seconds (~2.5 animation loops) + + /** Radius to check for captor/allies */ + private static final double CHECK_RADIUS = 16.0; + + /** Current struggle state */ + private enum StrugglePhase { + WAITING, + ANIMATING, + RESOLVING, + } + + private StrugglePhase phase = StrugglePhase.WAITING; + + /** Animation countdown */ + private int animationTicks; + + /** Cached success roll result (calculated at start of animation) */ + private boolean pendingSuccess; + + public NpcStruggleGoal(EntityDamsel npc) { + this.npc = npc; + // Don't use movement flags - struggling doesn't require movement control + this.setFlags(EnumSet.noneOf(Goal.Flag.class)); + } + + @Override + public boolean canUse() { + // Check game rule + if (!SettingsAccessor.isNpcStruggleEnabled(npc.level().getGameRules())) { + return false; + } + + // Must be tied up + if (!npc.isTiedUp()) { + return false; + } + + // Check if bind can be struggled out of + ItemStack bind = npc.getEquipment(BodyRegionV2.ARMS); + if (bind.isEmpty()) { + return false; + } + if (bind.getItem() instanceof IHasResistance resistanceItem) { + if (!resistanceItem.canBeStruggledOut(bind)) { + return false; + } + } + + // Must have personality state + PersonalityState state = npc.getPersonalityState(); + if (state == null) { + return false; + } + + // MASOCHIST personality rarely struggles + if (state.getPersonality() == PersonalityType.MASOCHIST) { + if (npc.getRandom().nextFloat() > 0.2f) { + return false; // 80% chance to skip struggle + } + } + + return true; + } + + @Override + public boolean canContinueToUse() { + // Stop if no longer tied + if (!npc.isTiedUp()) { + return false; + } + + // Continue if animating + if ( + phase == StrugglePhase.ANIMATING || phase == StrugglePhase.RESOLVING + ) { + return true; + } + + return canUse(); + } + + @Override + public void start() { + this.phase = StrugglePhase.WAITING; + this.animationTicks = 0; + + // Fix: initialize timer on first bind to prevent immediate struggle attempt + PersonalityState state = npc.getPersonalityState(); + if (state != null && state.getStruggleTimer() <= 0) { + int baseInterval = getBaseInterval(); + state.resetStruggleTimer(baseInterval); + } + } + + @Override + public void stop() { + // Clear struggle animation + npc.setStruggling(false); + this.phase = StrugglePhase.WAITING; + } + + @Override + public void tick() { + PersonalityState state = npc.getPersonalityState(); + if (state == null) return; + + switch (phase) { + case WAITING -> tickWaiting(state); + case ANIMATING -> tickAnimating(state); + case RESOLVING -> tickResolving(state); + } + } + + /** + * Wait phase: tick timer until struggle attempt. + */ + private void tickWaiting(PersonalityState state) { + // Tick the struggle timer + if (state.tickStruggleTimer()) { + // Timer expired - start struggle attempt + beginStruggleAttempt(state); + } + } + + /** + * Begin a struggle attempt - calculate success and start animation. + */ + private void beginStruggleAttempt(PersonalityState state) { + // Calculate success chance + float bindResistance = getBindResistance(); + boolean captorNearby = isCaptorNearby(); + boolean allyNearby = isAllyNearby(); + + float successChance = state.calculateStruggleChance( + bindResistance, + captorNearby, + allyNearby + ); + this.pendingSuccess = npc.getRandom().nextFloat() < successChance; + + // Start animation + this.phase = StrugglePhase.ANIMATING; + this.animationTicks = STRUGGLE_ANIMATION_TICKS; + npc.setStruggling(true); + + // Play struggle sound + TiedUpSounds.playStruggleSound(npc); + } + + /** + * Animation phase: play struggle animation. + */ + private void tickAnimating(PersonalityState state) { + if (--this.animationTicks <= 0) { + // Animation finished, resolve result + this.phase = StrugglePhase.RESOLVING; + } + } + + /** + * Resolve phase: apply struggle result. + */ + private void tickResolving(PersonalityState state) { + // Stop animation + npc.setStruggling(false); + + if (this.pendingSuccess) { + handleStruggleSuccess(state); + } else { + handleStruggleFailure(state); + } + + // Back to waiting + this.phase = StrugglePhase.WAITING; + } + + /** + * Handle successful struggle - decrease bind resistance. + */ + private void handleStruggleSuccess(PersonalityState state) { + ItemStack bind = npc.getEquipment(BodyRegionV2.ARMS); + if (bind.isEmpty()) return; + + if (bind.getItem() instanceof IHasResistance resistanceItem) { + int newResistance = resistanceItem.decreaseResistance(bind, npc); + + // Update the bind item in entity (stores the modified resistance) + npc.replaceEquipment(BodyRegionV2.ARMS, bind, false); + + if (newResistance <= 0) { + // Broke free! + handleEscape(state); + } else { + // Loosened but not free + state.recordStruggleResult(true, getBaseInterval()); + } + } + } + + /** + * Handle full escape - remove bind item. + */ + private void handleEscape(PersonalityState state) { + // Remove bind and drop it + ItemStack bind = npc.unequip(BodyRegionV2.ARMS); + if (!bind.isEmpty()) { + npc.spawnAtLocation(bind); + } + + // Record escape + state.recordStruggleResult(true, getBaseInterval()); + + // Personality-specific behavior after escape + PersonalityType personality = state.getPersonality(); + if ( + personality == PersonalityType.FIERCE || + personality == PersonalityType.DEFIANT + ) { + // Aggressive personalities might try to flee or attack + // (Could trigger flee behavior here) + } + } + + /** + * Handle failed struggle - gain XP, reset timer. + */ + private void handleStruggleFailure(PersonalityState state) { + state.recordStruggleResult(false, getBaseInterval()); + + // Training XP is awarded in recordStruggleResult + // Timer is reset in recordStruggleResult + } + + /** + * Get the base struggle interval from game rules. + */ + private int getBaseInterval() { + return SettingsAccessor.getNpcStruggleInterval(npc.level().getGameRules()); + } + + /** + * Get the resistance value of the current bind item. + */ + private float getBindResistance() { + ItemStack bind = npc.getEquipment(BodyRegionV2.ARMS); + if (bind.isEmpty()) return 1.0f; + + if (bind.getItem() instanceof IHasResistance resistanceItem) { + return resistanceItem.getCurrentResistance(bind, npc); + } + return 100.0f; // Default high resistance + } + + /** + * Check if the captor (collar owner) is nearby. + */ + private boolean isCaptorNearby() { + if (!npc.hasCollar()) return false; + + // Get collar owner UUID + ItemStack collar = npc.getEquipment(BodyRegionV2.NECK); + if (collar.isEmpty()) return false; + + if ( + collar.getItem() instanceof + com.tiedup.remake.items.base.ItemCollar collarItem + ) { + List ownerUUIDs = collarItem.getOwners(collar); + if (!ownerUUIDs.isEmpty()) { + // Check if any owner is nearby + List players = npc + .level() + .getEntitiesOfClass( + Player.class, + npc.getBoundingBox().inflate(CHECK_RADIUS) + ); + for (Player player : players) { + if (ownerUUIDs.contains(player.getUUID())) { + return true; + } + } + } + } + return false; + } + + /** + * Get the first captor UUID (collar owner) if available. + */ + @Nullable + private UUID getCaptorUUID() { + if (!npc.hasCollar()) return null; + + ItemStack collar = npc.getEquipment(BodyRegionV2.NECK); + if (collar.isEmpty()) return null; + + if ( + collar.getItem() instanceof + com.tiedup.remake.items.base.ItemCollar collarItem + ) { + List ownerUUIDs = collarItem.getOwners(collar); + if (!ownerUUIDs.isEmpty()) { + return ownerUUIDs.get(0); + } + } + return null; + } + + /** + * Check if an ally NPC (another captured damsel) is nearby. + */ + private boolean isAllyNearby() { + List nearbyDamsels = npc + .level() + .getEntitiesOfClass( + EntityDamsel.class, + npc.getBoundingBox().inflate(CHECK_RADIUS), + d -> d != npc && !d.isTiedUp() // Free damsels only + ); + return !nearbyDamsels.isEmpty(); + } + + @Override + public boolean requiresUpdateEveryTick() { + return true; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/personality/NpcTransferCommandGoal.java b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcTransferCommandGoal.java new file mode 100644 index 0000000..0749a51 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/personality/NpcTransferCommandGoal.java @@ -0,0 +1,439 @@ +package com.tiedup.remake.entities.ai.personality; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.NpcCommand; +import com.tiedup.remake.personality.PersonalityState; +import java.util.EnumSet; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.Container; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * AI Goal: NPC transfers items from chest A to chest B in a continuous loop. + * + *

Work flow:

+ *
    + *
  • Move to source chest (A)
  • + *
  • Collect items into NPC inventory
  • + *
  • Move to destination chest (B)
  • + *
  • Store items from inventory
  • + *
  • Repeat if source chest has more items
  • + *
  • If empty, wait and rescan periodically
  • + *
+ */ +public class NpcTransferCommandGoal extends Goal { + + private final EntityDamsel npc; + private final double speedModifier; + + private static final double INTERACT_DISTANCE = 2.0; + private static final int XP_INTERVAL = 1200; // Award XP every 60 seconds + private static final int IDLE_RESCAN_INTERVAL = 100; // Rescan every 5 seconds when idle + + /** Rest drain intensity for transferring (heavy work) */ + private static final float WORK_INTENSITY = 1.3f; + + private enum TransferPhase { + MOVING_TO_SOURCE, + COLLECTING, + MOVING_TO_DEST, + STORING, + IDLE, + } + + private TransferPhase phase; + + @Nullable + private BlockPos sourceChest; // Chest A + + @Nullable + private BlockPos destChest; // Chest B + + @Nullable + private Player master; + + private int pathRecalcTimer; + private int xpTimer; + private int idleTimer; + private int actionTimer; + + public NpcTransferCommandGoal(EntityDamsel npc, double speedModifier) { + this.npc = npc; + this.speedModifier = speedModifier; + this.setFlags(EnumSet.of(Goal.Flag.MOVE)); + } + + @Override + public boolean canUse() { + if (npc.getActiveCommand() != NpcCommand.TRANSFER) { + return false; + } + if (!npc.hasCollar()) { + return false; + } + + this.master = NpcGoalHelper.findCommandingPlayer(npc); + return this.master != null; + } + + @Override + public boolean canContinueToUse() { + if (npc.getActiveCommand() != NpcCommand.TRANSFER) { + return false; + } + return true; + } + + @Override + public void start() { + var state = npc.getPersonalityState(); + if (state == null) { + npc.cancelCommand(); + return; + } + + // Get source chest A (commandTarget) + this.sourceChest = state.getCommandTarget(); + // Get destination chest B (commandTarget2) + this.destChest = state.getCommandTarget2(); + + if (this.sourceChest == null || this.destChest == null) { + // Both chests are required + npc.cancelCommand(); + return; + } + + this.phase = TransferPhase.MOVING_TO_SOURCE; + this.pathRecalcTimer = 0; + this.xpTimer = 0; + this.idleTimer = 0; + this.actionTimer = 0; + } + + @Override + public void stop() { + this.sourceChest = null; + this.destChest = null; + this.master = null; + npc.getNavigation().stop(); + } + + @Override + public void tick() { + // Periodic XP award + if (++this.xpTimer >= XP_INTERVAL) { + this.xpTimer = 0; + awardXP(2); + } + + switch (this.phase) { + case MOVING_TO_SOURCE -> tickMovingToSource(); + case COLLECTING -> tickCollecting(); + case MOVING_TO_DEST -> tickMovingToDest(); + case STORING -> tickStoring(); + case IDLE -> tickIdle(); + } + } + + private void tickMovingToSource() { + if (this.sourceChest == null) { + this.phase = TransferPhase.IDLE; + return; + } + + // Move towards source chest + if (--this.pathRecalcTimer <= 0) { + this.pathRecalcTimer = 10; + npc + .getNavigation() + .moveTo( + sourceChest.getX() + 0.5, + sourceChest.getY(), + sourceChest.getZ() + 0.5, + speedModifier + ); + } + + // Check if close enough + double dist = npc.blockPosition().distSqr(sourceChest); + if (dist <= INTERACT_DISTANCE * INTERACT_DISTANCE) { + npc.getNavigation().stop(); + this.phase = TransferPhase.COLLECTING; + this.actionTimer = 20; // Brief delay before collecting + } + } + + private void tickCollecting() { + if (sourceChest == null) { + this.phase = TransferPhase.IDLE; + return; + } + + // Wait for action timer (simulates opening chest) + if (--this.actionTimer > 0) { + return; + } + + Level level = npc.level(); + Container container = getChestContainer(level, sourceChest); + + if (container == null) { + // Chest was removed? + this.phase = TransferPhase.IDLE; + return; + } + + // Collect items from chest into NPC inventory + var npcInventory = npc.getNpcInventory(); + int collectedCount = 0; + boolean hasMoreItems = false; + + for (int i = 0; i < container.getContainerSize(); i++) { + ItemStack chestStack = container.getItem(i); + if (chestStack.isEmpty()) continue; + + hasMoreItems = true; + + // Try to add to NPC inventory + ItemStack remaining = addToNpcInventory( + npcInventory, + chestStack.copy() + ); + int taken = chestStack.getCount() - remaining.getCount(); + + if (taken > 0) { + chestStack.shrink(taken); + container.setItem( + i, + chestStack.isEmpty() ? ItemStack.EMPTY : chestStack + ); + collectedCount += taken; + } + + // Check if NPC inventory is full + if ( + !remaining.isEmpty() && + remaining.getCount() == chestStack.getCount() + taken + ) { + // Couldn't take any more - inventory is full + break; + } + } + + // Play chest sound + if (collectedCount > 0) { + level.playSound( + null, + sourceChest, + SoundEvents.CHEST_OPEN, + SoundSource.BLOCKS, + 0.5f, + 1.0f + ); + + // Drain rest from work + PersonalityState personalityState = npc.getPersonalityState(); + if (personalityState != null) { + personalityState.getNeeds().drainFromWork(WORK_INTENSITY); + } + } + + // Check if we have items to deliver + if (hasNpcInventoryItems(npcInventory)) { + this.phase = TransferPhase.MOVING_TO_DEST; + this.pathRecalcTimer = 0; + } else if (!hasMoreItems) { + // Source is empty, go idle + this.phase = TransferPhase.IDLE; + this.idleTimer = 0; + } else { + // Can't carry more but source has items - still go deliver + this.phase = TransferPhase.MOVING_TO_DEST; + this.pathRecalcTimer = 0; + } + } + + private void tickMovingToDest() { + if (this.destChest == null) { + this.phase = TransferPhase.IDLE; + return; + } + + // Move towards destination chest + if (--this.pathRecalcTimer <= 0) { + this.pathRecalcTimer = 10; + npc + .getNavigation() + .moveTo( + destChest.getX() + 0.5, + destChest.getY(), + destChest.getZ() + 0.5, + speedModifier + ); + } + + // Check if close enough + double dist = npc.blockPosition().distSqr(destChest); + if (dist <= INTERACT_DISTANCE * INTERACT_DISTANCE) { + npc.getNavigation().stop(); + this.phase = TransferPhase.STORING; + this.actionTimer = 20; // Brief delay before storing + } + } + + private void tickStoring() { + if (destChest == null) { + this.phase = TransferPhase.IDLE; + return; + } + + // Wait for action timer + if (--this.actionTimer > 0) { + return; + } + + Level level = npc.level(); + Container container = getChestContainer(level, destChest); + + if (container == null) { + // Chest was removed? + this.phase = TransferPhase.IDLE; + return; + } + + // Store items from NPC inventory into destination chest + var npcInventory = npc.getNpcInventory(); + int storedCount = 0; + + for (int i = 0; i < npcInventory.size(); i++) { + ItemStack stack = npcInventory.get(i); + if (stack.isEmpty()) continue; + + ItemStack toStore = stack.copy(); + insertIntoContainer(container, toStore); + + if (toStore.isEmpty()) { + npcInventory.set(i, ItemStack.EMPTY); + storedCount++; + } else if (toStore.getCount() < stack.getCount()) { + // Partially stored + npcInventory.set(i, toStore); + storedCount++; + } + // else: couldn't store, dest is full + } + + // Play chest sound + if (storedCount > 0) { + level.playSound( + null, + destChest, + SoundEvents.CHEST_CLOSE, + SoundSource.BLOCKS, + 0.5f, + 1.0f + ); + + // Drain rest from work + PersonalityState personalityState = npc.getPersonalityState(); + if (personalityState != null) { + personalityState.getNeeds().drainFromWork(WORK_INTENSITY); + } + } + + // Go back to source for more items + this.phase = TransferPhase.MOVING_TO_SOURCE; + this.pathRecalcTimer = 0; + } + + private void tickIdle() { + // Periodically check if source chest has new items + if (++this.idleTimer >= IDLE_RESCAN_INTERVAL) { + this.idleTimer = 0; + + if (sourceChest != null) { + Level level = npc.level(); + Container container = getChestContainer(level, sourceChest); + + if (container != null && !isContainerEmpty(container)) { + // Source has items, go collect + this.phase = TransferPhase.MOVING_TO_SOURCE; + this.pathRecalcTimer = 0; + } + } + } + } + + // ========== Utility Methods ========== + + @Nullable + private Container getChestContainer(Level level, BlockPos pos) { + return NpcGoalHelper.getChestContainer(level, pos); + } + + private boolean isContainerEmpty(Container container) { + for (int i = 0; i < container.getContainerSize(); i++) { + if (!container.getItem(i).isEmpty()) { + return false; + } + } + return true; + } + + private boolean hasNpcInventoryItems( + net.minecraft.core.NonNullList inventory + ) { + for (ItemStack stack : inventory) { + if (!stack.isEmpty()) { + return true; + } + } + return false; + } + + private ItemStack addToNpcInventory( + net.minecraft.core.NonNullList inventory, + ItemStack stack + ) { + // Try to stack with existing items first + for (int i = 0; i < inventory.size() && !stack.isEmpty(); i++) { + ItemStack slotStack = inventory.get(i); + if ( + !slotStack.isEmpty() && + ItemStack.isSameItemSameTags(slotStack, stack) + ) { + int space = slotStack.getMaxStackSize() - slotStack.getCount(); + int toTransfer = Math.min(space, stack.getCount()); + if (toTransfer > 0) { + slotStack.grow(toTransfer); + stack.shrink(toTransfer); + } + } + } + + // Then try empty slots + for (int i = 0; i < inventory.size() && !stack.isEmpty(); i++) { + if (inventory.get(i).isEmpty()) { + inventory.set(i, stack.copy()); + stack.setCount(0); + } + } + + return stack; + } + + private void insertIntoContainer(Container container, ItemStack stack) { + NpcGoalHelper.insertIntoContainer(container, stack); + } + + private void awardXP(int amount) { + if (this.master != null) { + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/trader/goals/TraderIdleGoal.java b/src/main/java/com/tiedup/remake/entities/ai/trader/goals/TraderIdleGoal.java new file mode 100644 index 0000000..0da1ccb --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/trader/goals/TraderIdleGoal.java @@ -0,0 +1,289 @@ +package com.tiedup.remake.entities.ai.trader.goals; + +import com.tiedup.remake.cells.CampOwnership; +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntitySlaveTrader; +import com.tiedup.remake.prison.PrisonerManager; +import java.util.EnumSet; +import java.util.List; +import java.util.Random; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.ai.goal.Goal; + +/** + * Goal: Trader idle/patrol behavior. + * + * Simplified from TraderManageCampGoal. + * Removed: prisoner management, maid ordering, complex cleanup. + * Those are now handled by PrisonerManager and PrisonerService. + * + * Responsibilities: + * - Stay near camp center + * - Occasionally inspect cells (visual behavior only) + * - Look professional + */ +public class TraderIdleGoal extends Goal { + + private static final double MAX_WANDER_DISTANCE = 20.0; + private static final double INSPECTION_DISTANCE = 3.0; + private static final int INSPECTION_INTERVAL_MIN = 600; // 30 seconds + private static final int INSPECTION_INTERVAL_MAX = 1200; // 60 seconds + private static final int MAX_TRAVEL_TIME = 200; // 10 seconds + + private final EntitySlaveTrader trader; + private final Random random = new Random(); + + private CampOwnership.CampData campData; + private BlockPos campCenter; + + // Inspection state + private int inspectionCooldown = 0; + private CellDataV2 inspectionTarget = null; + private int inspectionLookTime = 0; + private int travelTime = 0; + private boolean returningHome = false; + + public TraderIdleGoal(EntitySlaveTrader trader) { + this.trader = trader; + this.setFlags(EnumSet.of(Flag.MOVE, Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (trader.isTiedUp()) { + return false; + } + if (!(trader.level() instanceof ServerLevel level)) { + return false; + } + + // Get camp data + UUID campId = trader.getCampUUID(); + if (campId == null) { + return false; + } + + CampOwnership ownership = CampOwnership.get(level); + campData = ownership.getCamp(campId); + if (campData == null || !campData.isAlive()) { + return false; + } + + campCenter = campData.getCenter(); + return campCenter != null; + } + + @Override + public void tick() { + if (!(trader.level() instanceof ServerLevel level)) { + return; + } + + inspectionCooldown++; + + if (inspectionTarget != null) { + tickInspection(level); + } else if (returningHome) { + tickReturningHome(); + } else { + tickIdle(level); + } + } + + /** + * Normal idle behavior. + */ + private void tickIdle(ServerLevel level) { + // Stay near camp center + if (campCenter != null) { + double distance = trader.blockPosition().distSqr(campCenter); + if (distance > MAX_WANDER_DISTANCE * MAX_WANDER_DISTANCE) { + // Too far - return home + trader + .getNavigation() + .moveTo( + campCenter.getX() + 0.5, + campCenter.getY(), + campCenter.getZ() + 0.5, + 0.6 + ); + } + } + + // Maybe start inspection + if (inspectionCooldown >= INSPECTION_INTERVAL_MIN) { + int threshold = + INSPECTION_INTERVAL_MIN + + random.nextInt( + INSPECTION_INTERVAL_MAX - INSPECTION_INTERVAL_MIN + ); + + if (inspectionCooldown >= threshold) { + maybeStartInspection(level); + inspectionCooldown = 0; + } + } + } + + /** + * Try to start a cell inspection. + */ + private void maybeStartInspection(ServerLevel level) { + UUID campId = trader.getCampUUID(); + if (campId == null) { + return; + } + + CellRegistryV2 registry = CellRegistryV2.get(level); + List cells = registry.getCellsByCamp(campId); + + if (cells.isEmpty()) { + return; + } + + // Pick a random cell + inspectionTarget = cells.get(random.nextInt(cells.size())); + inspectionLookTime = 0; + travelTime = 0; + + TiedUpMod.LOGGER.debug( + "[TraderIdleGoal] {} starting inspection", + trader.getNpcName() + ); + } + + /** + * Tick inspection behavior. + */ + private void tickInspection(ServerLevel level) { + if (inspectionTarget == null) { + return; + } + + BlockPos targetPos = + inspectionTarget.getSpawnPoint() != null + ? inspectionTarget.getSpawnPoint() + : inspectionTarget.getCorePos().above(); + double distance = trader + .position() + .distanceTo( + new net.minecraft.world.phys.Vec3( + targetPos.getX() + 0.5, + targetPos.getY(), + targetPos.getZ() + 0.5 + ) + ); + + if (distance <= INSPECTION_DISTANCE) { + // At cell - look around + inspectionLookTime++; + + // Look at cell + trader + .getLookControl() + .setLookAt( + targetPos.getX() + 0.5, + targetPos.getY() + 1.0, + targetPos.getZ() + 0.5, + 30.0f, + 30.0f + ); + + // Done looking after 2-3 seconds + if (inspectionLookTime >= 40 + random.nextInt(20)) { + completeInspection(); + } + } else { + // Navigate to cell + travelTime++; + + if (trader.getNavigation().isDone()) { + trader + .getNavigation() + .moveTo( + targetPos.getX() + 0.5, + targetPos.getY(), + targetPos.getZ() + 0.5, + 0.7 + ); + } + + // Timeout + if (travelTime > MAX_TRAVEL_TIME) { + TiedUpMod.LOGGER.debug( + "[TraderIdleGoal] Inspection travel timeout" + ); + cancelInspection(); + } + } + } + + /** + * Complete inspection and return home. + */ + private void completeInspection() { + inspectionTarget = null; + returningHome = true; + + TiedUpMod.LOGGER.debug("[TraderIdleGoal] Inspection complete"); + } + + /** + * Cancel inspection without completing. + */ + private void cancelInspection() { + inspectionTarget = null; + returningHome = true; + } + + /** + * Tick returning home behavior. + */ + private void tickReturningHome() { + if (campCenter == null) { + returningHome = false; + return; + } + + double distance = trader + .position() + .distanceTo( + new net.minecraft.world.phys.Vec3( + campCenter.getX() + 0.5, + campCenter.getY(), + campCenter.getZ() + 0.5 + ) + ); + + if (distance <= 3.0) { + // Home + returningHome = false; + trader.getNavigation().stop(); + } else if (trader.getNavigation().isDone()) { + trader + .getNavigation() + .moveTo( + campCenter.getX() + 0.5, + campCenter.getY(), + campCenter.getZ() + 0.5, + 0.6 + ); + } + } + + @Override + public boolean canContinueToUse() { + return canUse(); + } + + @Override + public void stop() { + inspectionTarget = null; + returningHome = false; + trader.getNavigation().stop(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/ai/trader/goals/TraderSellGoal.java b/src/main/java/com/tiedup/remake/entities/ai/trader/goals/TraderSellGoal.java new file mode 100644 index 0000000..20dd780 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/ai/trader/goals/TraderSellGoal.java @@ -0,0 +1,238 @@ +package com.tiedup.remake.entities.ai.trader.goals; + +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntitySlaveTrader; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.prison.PrisonerManager; +import java.util.EnumSet; +import java.util.List; +import java.util.UUID; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.AABB; + +/** + * Goal: Trader selling behavior. + * + * Simplified from TraderSellCaptiveGoal. + * Detects potential buyers and initiates trade dialogue. + * + * Responsibilities: + * - Detect players holding trade tokens nearby + * - Look at and approach potential buyers + * - Initiate trade dialogue + */ +public class TraderSellGoal extends Goal { + + private static final double DETECTION_RADIUS = 8.0; + private static final double INTERACTION_DISTANCE = 4.0; + private static final int PITCH_COOLDOWN = 100; // 5 seconds + + private final EntitySlaveTrader trader; + + private Player potentialBuyer; + private int pitchCooldown = 0; + private boolean hasGreeted = false; + + public TraderSellGoal(EntitySlaveTrader trader) { + this.trader = trader; + this.setFlags(EnumSet.of(Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (trader.isTiedUp()) { + return false; + } + if (trader.getTarget() != null) { + return false; + } + if (!(trader.level() instanceof ServerLevel level)) { + return false; + } + + // Find potential buyer + potentialBuyer = findTokenHolderNearby(level); + if (potentialBuyer == null) { + return false; + } + + // Must have prisoners to sell + if (!hasPrisonersToSell(level)) { + return false; + } + + return true; + } + + /** + * Find a player holding a trade token nearby. + */ + private Player findTokenHolderNearby(ServerLevel level) { + AABB searchBox = trader.getBoundingBox().inflate(DETECTION_RADIUS); + List players = level.getEntitiesOfClass( + Player.class, + searchBox + ); + + for (Player player : players) { + if (player.isSpectator()) continue; + + // Check for token in hands + ItemStack mainHand = player.getMainHandItem(); + ItemStack offHand = player.getOffhandItem(); + + if (isTradeToken(mainHand) || isTradeToken(offHand)) { + return player; + } + } + + return null; + } + + /** + * Check if item is a trade token. + */ + private boolean isTradeToken(ItemStack stack) { + if (stack.isEmpty()) return false; + return stack.getItem() == ModItems.TOKEN.get(); + } + + /** + * Check if trader has prisoners to sell. + */ + private boolean hasPrisonersToSell(ServerLevel level) { + UUID campId = trader.getCampUUID(); + if (campId == null) { + return false; + } + + PrisonerManager manager = PrisonerManager.get(level); + return manager.getPrisonerCountInCamp(campId) > 0; + } + + @Override + public void start() { + hasGreeted = false; + + TiedUpMod.LOGGER.debug( + "[TraderSellGoal] {} detected buyer: {}", + trader.getNpcName(), + potentialBuyer.getName().getString() + ); + } + + @Override + public void tick() { + if (potentialBuyer == null) { + return; + } + + pitchCooldown--; + + // Look at buyer + trader.getLookControl().setLookAt(potentialBuyer, 30.0f, 30.0f); + + // Check distance + double distance = trader.distanceTo(potentialBuyer); + + if (distance <= INTERACTION_DISTANCE) { + // Close enough - greet/pitch + if (!hasGreeted && pitchCooldown <= 0) { + greetBuyer(); + hasGreeted = true; + pitchCooldown = PITCH_COOLDOWN; + } + } else if (distance <= DETECTION_RADIUS) { + // Approach buyer slowly + if (trader.getNavigation().isDone()) { + trader.getNavigation().moveTo(potentialBuyer, 0.5); + } + } + } + + /** + * Greet potential buyer. + */ + private void greetBuyer() { + if (potentialBuyer == null) { + return; + } + if (!(trader.level() instanceof ServerLevel level)) { + return; + } + + UUID campId = trader.getCampUUID(); + if (campId == null) { + return; + } + + int prisonerCount = PrisonerManager.get(level).getPrisonerCountInCamp( + campId + ); + + // Send greeting message + String greeting = + prisonerCount == 1 + ? "Interested in my merchandise? I have one fine specimen available." + : String.format( + "Interested in my merchandise? I have %d specimens available.", + prisonerCount + ); + + potentialBuyer.sendSystemMessage( + Component.literal( + "[" + trader.getNpcName() + "] " + greeting + ).withStyle(net.minecraft.ChatFormatting.GOLD) + ); + + potentialBuyer.sendSystemMessage( + Component.literal("Right-click me to browse my stock.").withStyle( + net.minecraft.ChatFormatting.GRAY + ) + ); + + TiedUpMod.LOGGER.debug( + "[TraderSellGoal] {} greeted {}", + trader.getNpcName(), + potentialBuyer.getName().getString() + ); + } + + @Override + public boolean canContinueToUse() { + if (trader.isTiedUp()) { + return false; + } + if (trader.getTarget() != null) { + return false; + } + if (potentialBuyer == null || !potentialBuyer.isAlive()) { + return false; + } + + // Check if still holding token + ItemStack mainHand = potentialBuyer.getMainHandItem(); + ItemStack offHand = potentialBuyer.getOffhandItem(); + if (!isTradeToken(mainHand) && !isTradeToken(offHand)) { + return false; + } + + // Check distance + double distance = trader.distanceTo(potentialBuyer); + return distance <= DETECTION_RADIUS * 1.5; + } + + @Override + public void stop() { + potentialBuyer = null; + hasGreeted = false; + trader.getNavigation().stop(); + + TiedUpMod.LOGGER.debug("[TraderSellGoal] Stopped"); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/armorstand/ArmorStandBondageClientCache.java b/src/main/java/com/tiedup/remake/entities/armorstand/ArmorStandBondageClientCache.java new file mode 100644 index 0000000..ed1ad05 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/armorstand/ArmorStandBondageClientCache.java @@ -0,0 +1,135 @@ +package com.tiedup.remake.entities.armorstand; + +import com.tiedup.remake.v2.BodyRegionV2; +import java.util.EnumMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Client-side cache for armor stand bondage data. + * + * Since getPersistentData() is not synced to clients, we use this cache + * to store bondage item data received via network packets. + */ +@OnlyIn(Dist.CLIENT) +public class ArmorStandBondageClientCache { + + /** + * Cache mapping entity ID to bondage data. + */ + private static final Map CACHE = + new ConcurrentHashMap<>(); + + /** + * Data holder for an armor stand's bondage items. + * Uses an EnumMap keyed by BodyRegionV2. + */ + public static class BondageData { + + private final EnumMap items = + new EnumMap<>(BodyRegionV2.class); + + public ItemStack getItem(BodyRegionV2 region) { + return items.getOrDefault(region, ItemStack.EMPTY); + } + + public void setItem(BodyRegionV2 region, ItemStack stack) { + if (stack == null || stack.isEmpty()) { + items.remove(region); + } else { + items.put(region, stack); + } + } + + public boolean hasAnyItems() { + return !items.isEmpty(); + } + } + + /** + * Get bondage data for an entity. + * + * @param entityId The entity ID + * @return The bondage data, or null if none cached + */ + public static BondageData getData(int entityId) { + return CACHE.get(entityId); + } + + /** + * Get or create bondage data for an entity. + * + * @param entityId The entity ID + * @return The bondage data + */ + public static BondageData getOrCreateData(int entityId) { + return CACHE.computeIfAbsent(entityId, id -> new BondageData()); + } + + /** + * Update a specific item in the cache. + * + * @param entityId The entity ID + * @param region The body region + * @param stack The item stack + */ + public static void updateItem( + int entityId, + BodyRegionV2 region, + ItemStack stack + ) { + BondageData data = getOrCreateData(entityId); + data.setItem(region, stack); + + // Clean up empty entries + if (!data.hasAnyItems()) { + CACHE.remove(entityId); + } + } + + /** + * Check if an armor stand has any bondage items cached. + * + * @param entityId The entity ID + * @return True if any items are cached + */ + public static boolean hasItems(int entityId) { + BondageData data = CACHE.get(entityId); + return data != null && data.hasAnyItems(); + } + + /** + * Get an item from the cache. + * + * @param entityId The entity ID + * @param region The body region + * @return The item, or empty if none + */ + public static ItemStack getItem(int entityId, BodyRegionV2 region) { + BondageData data = CACHE.get(entityId); + if (data == null) { + return ItemStack.EMPTY; + } + return data.getItem(region); + } + + /** + * Remove cached data for an entity. + * + * @param entityId The entity ID + */ + public static void remove(int entityId) { + CACHE.remove(entityId); + } + + /** + * Clear all cached data. + * Should be called when leaving a world. + */ + public static void clear() { + CACHE.clear(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/armorstand/ArmorStandBondageHelper.java b/src/main/java/com/tiedup/remake/entities/armorstand/ArmorStandBondageHelper.java new file mode 100644 index 0000000..58a1f49 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/armorstand/ArmorStandBondageHelper.java @@ -0,0 +1,297 @@ +package com.tiedup.remake.entities.armorstand; + +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.items.base.PoseType; +import com.tiedup.remake.v2.BodyRegionV2; +import net.minecraft.core.Rotations; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.entity.decoration.ArmorStand; +import net.minecraft.world.item.ItemStack; + +import java.util.Map; + +/** + * Helper class for storing and retrieving bondage items on armor stands. + * Uses NBT storage in the armor stand's persistent data. + * + *

Keys in PersistentData use {@link BodyRegionV2#name()} (e.g., "ARMS", "MOUTH"). + * Legacy keys from pre-Epic-7D ("bind", "gag", etc.) are read transparently + * via {@link #LEGACY_KEYS} fallback in {@link #getStackInSlot}. + */ +public class ArmorStandBondageHelper { + + private static final String NBT_KEY = "TiedUpBondage"; + private static final String ORIGINAL_POSE_KEY = "originalPose"; + + /** + * The 7 body regions used by armor stand bondage slots. + * The 7 bondage-relevant body regions for armor stands. + */ + public static final BodyRegionV2[] VALID_REGIONS = { + BodyRegionV2.ARMS, + BodyRegionV2.MOUTH, + BodyRegionV2.EYES, + BodyRegionV2.EARS, + BodyRegionV2.NECK, + BodyRegionV2.TORSO, + BodyRegionV2.HANDS + }; + + /** + * Legacy NBT key mapping from pre-Epic-7D format. + * Used as fallback when reading old armor stand data. + */ + private static final Map LEGACY_KEYS = Map.of( + "bind", BodyRegionV2.ARMS, + "gag", BodyRegionV2.MOUTH, + "blindfold", BodyRegionV2.EYES, + "earplugs", BodyRegionV2.EARS, + "collar", BodyRegionV2.NECK, + "clothes", BodyRegionV2.TORSO, + "mittens", BodyRegionV2.HANDS + ); + + /** + * Get a bondage item from an armor stand slot. + * + *

Tries the V2 key ({@code region.name()}) first, then falls back to + * the legacy V1 key for backwards compatibility with existing worlds. + * + * @param stand The armor stand + * @param region The body region + * @return The item in that slot, or empty if none + */ + public static ItemStack getStackInSlot( + ArmorStand stand, + BodyRegionV2 region + ) { + CompoundTag tag = stand.getPersistentData(); + if (!tag.contains(NBT_KEY)) return ItemStack.EMPTY; + + CompoundTag bondageTag = tag.getCompound(NBT_KEY); + String key = region.name(); + + // Try V2 key first + if (bondageTag.contains(key)) { + return ItemStack.of(bondageTag.getCompound(key)); + } + + // Fallback: try legacy V1 key + for (Map.Entry entry : LEGACY_KEYS.entrySet()) { + if (entry.getValue() == region && bondageTag.contains(entry.getKey())) { + return ItemStack.of(bondageTag.getCompound(entry.getKey())); + } + } + + return ItemStack.EMPTY; + } + + /** + * Set a bondage item in an armor stand slot. + * + *

Always writes using the V2 key ({@code region.name()}). + * Also removes any legacy V1 key for the same region to avoid duplication. + * + * @param stand The armor stand + * @param region The body region + * @param stack The item to set (empty to clear) + */ + public static void setStackInSlot( + ArmorStand stand, + BodyRegionV2 region, + ItemStack stack + ) { + CompoundTag tag = stand.getPersistentData(); + CompoundTag bondageTag = tag.contains(NBT_KEY) + ? tag.getCompound(NBT_KEY) + : new CompoundTag(); + + String v2Key = region.name(); + + if (stack.isEmpty()) { + bondageTag.remove(v2Key); + } else { + bondageTag.put(v2Key, stack.save(new CompoundTag())); + } + + // Remove legacy key if present to avoid duplication + for (Map.Entry entry : LEGACY_KEYS.entrySet()) { + if (entry.getValue() == region) { + bondageTag.remove(entry.getKey()); + break; + } + } + + tag.put(NBT_KEY, bondageTag); + + // Update pose when bind (ARMS region) changes + if (region == BodyRegionV2.ARMS) { + updateArmorStandPose(stand); + } + } + + /** + * Check if an armor stand has any bondage items equipped. + * + * @param stand The armor stand + * @return True if any bondage slot has an item + */ + public static boolean hasBondageItems(ArmorStand stand) { + for (BodyRegionV2 region : VALID_REGIONS) { + if (!getStackInSlot(stand, region).isEmpty()) { + return true; + } + } + return false; + } + + /** + * Get all bondage items from an armor stand. + * + * @param stand The armor stand + * @return Array of items indexed by {@link #VALID_REGIONS} order + */ + public static ItemStack[] getAllItems(ArmorStand stand) { + ItemStack[] items = new ItemStack[VALID_REGIONS.length]; + for (int i = 0; i < VALID_REGIONS.length; i++) { + items[i] = getStackInSlot(stand, VALID_REGIONS[i]); + } + return items; + } + + /** + * Update the armor stand's pose based on equipped bind. + * + * @param stand The armor stand + */ + public static void updateArmorStandPose(ArmorStand stand) { + ItemStack bindStack = getStackInSlot(stand, BodyRegionV2.ARMS); + + if (bindStack.isEmpty()) { + // Restore original pose if saved + restoreOriginalPose(stand); + return; + } + + // Save original pose if not already saved + saveOriginalPose(stand); + + // Get pose type from bind + PoseType poseType = PoseType.STANDARD; + if (bindStack.getItem() instanceof ItemBind bind) { + poseType = bind.getPoseType(); + } + + // Apply pose + applyBondagePose(stand, poseType); + } + + /** + * Save the armor stand's current pose as the original pose. + */ + private static void saveOriginalPose(ArmorStand stand) { + CompoundTag tag = stand.getPersistentData(); + CompoundTag bondageTag = tag.contains(NBT_KEY) + ? tag.getCompound(NBT_KEY) + : new CompoundTag(); + + if (bondageTag.contains(ORIGINAL_POSE_KEY)) { + return; // Already saved + } + + CompoundTag poseTag = new CompoundTag(); + poseTag.put("head", rotationsToTag(stand.getHeadPose())); + poseTag.put("body", rotationsToTag(stand.getBodyPose())); + poseTag.put("leftArm", rotationsToTag(stand.getLeftArmPose())); + poseTag.put("rightArm", rotationsToTag(stand.getRightArmPose())); + poseTag.put("leftLeg", rotationsToTag(stand.getLeftLegPose())); + poseTag.put("rightLeg", rotationsToTag(stand.getRightLegPose())); + + bondageTag.put(ORIGINAL_POSE_KEY, poseTag); + tag.put(NBT_KEY, bondageTag); + } + + /** + * Restore the armor stand's original pose. + */ + private static void restoreOriginalPose(ArmorStand stand) { + CompoundTag tag = stand.getPersistentData(); + if (!tag.contains(NBT_KEY)) return; + + CompoundTag bondageTag = tag.getCompound(NBT_KEY); + if (!bondageTag.contains(ORIGINAL_POSE_KEY)) return; + + CompoundTag poseTag = bondageTag.getCompound(ORIGINAL_POSE_KEY); + + stand.setHeadPose(tagToRotations(poseTag.getCompound("head"))); + stand.setBodyPose(tagToRotations(poseTag.getCompound("body"))); + stand.setLeftArmPose(tagToRotations(poseTag.getCompound("leftArm"))); + stand.setRightArmPose(tagToRotations(poseTag.getCompound("rightArm"))); + stand.setLeftLegPose(tagToRotations(poseTag.getCompound("leftLeg"))); + stand.setRightLegPose(tagToRotations(poseTag.getCompound("rightLeg"))); + + // Remove saved pose + bondageTag.remove(ORIGINAL_POSE_KEY); + tag.put(NBT_KEY, bondageTag); + } + + /** + * Apply a bondage pose to an armor stand. + */ + private static void applyBondagePose(ArmorStand stand, PoseType poseType) { + switch (poseType) { + case STANDARD -> { + // Arms behind back + stand.setRightArmPose(new Rotations(51.5f, 57.3f, 0f)); + stand.setLeftArmPose(new Rotations(51.5f, -57.3f, 0f)); + stand.setRightLegPose(new Rotations(0f, 0f, -5.7f)); + stand.setLeftLegPose(new Rotations(0f, 0f, 5.7f)); + } + case STRAITJACKET -> { + // Arms crossed in front + stand.setRightArmPose(new Rotations(-45.8f, -28.6f, -17.2f)); + stand.setLeftArmPose(new Rotations(-45.8f, 28.6f, 17.2f)); + stand.setRightLegPose(new Rotations(0f, 0f, 0f)); + stand.setLeftLegPose(new Rotations(0f, 0f, 0f)); + } + case WRAP -> { + // Arms at sides, wrapped + stand.setRightArmPose(new Rotations(0f, 0f, 10f)); + stand.setLeftArmPose(new Rotations(0f, 0f, -10f)); + stand.setRightLegPose(new Rotations(0f, 0f, -3f)); + stand.setLeftLegPose(new Rotations(0f, 0f, 3f)); + } + case LATEX_SACK -> { + // Full enclosure, legs together + stand.setRightArmPose(new Rotations(0f, 0f, 5f)); + stand.setLeftArmPose(new Rotations(0f, 0f, -5f)); + stand.setRightLegPose(new Rotations(0f, 0f, -2f)); + stand.setLeftLegPose(new Rotations(0f, 0f, 2f)); + } + case DOG, HUMAN_CHAIR -> { + // On all fours (for armor stand, just bent forward) + stand.setBodyPose(new Rotations(45f, 0f, 0f)); + stand.setRightArmPose(new Rotations(-90f, 0f, 0f)); + stand.setLeftArmPose(new Rotations(-90f, 0f, 0f)); + stand.setRightLegPose(new Rotations(-45f, 0f, 0f)); + stand.setLeftLegPose(new Rotations(-45f, 0f, 0f)); + } + } + } + + private static CompoundTag rotationsToTag(Rotations rotations) { + CompoundTag tag = new CompoundTag(); + tag.putFloat("x", rotations.getX()); + tag.putFloat("y", rotations.getY()); + tag.putFloat("z", rotations.getZ()); + return tag; + } + + private static Rotations tagToRotations(CompoundTag tag) { + return new Rotations( + tag.getFloat("x"), + tag.getFloat("y"), + tag.getFloat("z") + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/armorstand/ArmorStandInteractionHandler.java b/src/main/java/com/tiedup/remake/entities/armorstand/ArmorStandInteractionHandler.java new file mode 100644 index 0000000..8c14619 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/armorstand/ArmorStandInteractionHandler.java @@ -0,0 +1,213 @@ +package com.tiedup.remake.entities.armorstand; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.armorstand.PacketSyncArmorStandBondage; +import com.tiedup.remake.v2.BodyRegionV2; +import java.util.Set; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.decoration.ArmorStand; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.event.entity.player.PlayerEvent; +import net.minecraftforge.event.entity.player.PlayerInteractEvent; +import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Handles player interaction with armor stands for bondage item equipping. + * + * Right-click with bondage item: Equip to armor stand + * Shift+Right-click (empty hand): Remove bondage items from armor stand + * + * Uses EntityInteractSpecific event which is what armor stands use for position-based interactions. + */ +@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID) +public class ArmorStandInteractionHandler { + + static { + TiedUpMod.LOGGER.info( + "[ArmorStandInteractionHandler] Event handler class loaded!" + ); + } + + /** + * Handle armor stand interaction via EntityInteractSpecific event. + * This is the event that armor stands use for position-based interactions. + */ + @SubscribeEvent(priority = EventPriority.HIGHEST) + public static void onArmorStandInteract( + PlayerInteractEvent.EntityInteractSpecific event + ) { + // Server-side only + if (event.getLevel().isClientSide()) return; + if (!(event.getTarget() instanceof ArmorStand armorStand)) return; + + Player player = event.getEntity(); + ItemStack heldItem = player.getItemInHand(event.getHand()); + + // Shift+click with empty hand to remove items + if (player.isShiftKeyDown() && heldItem.isEmpty()) { + if (tryRemoveBondageItem(player, armorStand)) { + event.setCanceled(true); + event.setCancellationResult(InteractionResult.SUCCESS); + } + return; + } + + // Try to equip bondage item + if (heldItem.getItem() instanceof IV2BondageItem bondageItem) { + // Use primary region (first occupied region) + Set regions = bondageItem.getOccupiedRegions(heldItem); + if (regions.isEmpty()) return; // Data-driven item with missing definition + BodyRegionV2 region = regions.iterator().next(); + + ItemStack existing = ArmorStandBondageHelper.getStackInSlot( + armorStand, + region + ); + ItemStack toEquip = heldItem.copy(); + toEquip.setCount(1); + + // Set the item in the slot + ArmorStandBondageHelper.setStackInSlot(armorStand, region, toEquip); + + // Consume item from player + if (!player.isCreative()) { + heldItem.shrink(1); + } + + // Give back existing item if swapping + if (!existing.isEmpty()) { + if (!player.addItem(existing)) { + player.drop(existing, false); + } + } + + // Sync to clients + syncSlotToClients(armorStand, region, toEquip); + + TiedUpMod.LOGGER.debug( + "[ArmorStandInteraction] Player {} equipped {} on armor stand", + player.getName().getString(), + region.name() + ); + + event.setCanceled(true); + event.setCancellationResult(InteractionResult.SUCCESS); + } + } + + /** + * Try to remove a bondage item from an armor stand. + * Removes items in reverse priority (accessories first, then bind/ARMS). + * + * @param player The player removing items + * @param armorStand The armor stand + * @return True if an item was removed + */ + private static boolean tryRemoveBondageItem( + Player player, + ArmorStand armorStand + ) { + // Remove items in reverse priority (accessories first, then bind) + BodyRegionV2[] removeOrder = { + BodyRegionV2.HANDS, + BodyRegionV2.EARS, + BodyRegionV2.EYES, + BodyRegionV2.MOUTH, + BodyRegionV2.NECK, + BodyRegionV2.TORSO, + BodyRegionV2.ARMS, + }; + + for (BodyRegionV2 region : removeOrder) { + ItemStack stack = ArmorStandBondageHelper.getStackInSlot( + armorStand, + region + ); + if (!stack.isEmpty()) { + // Clear the slot + ArmorStandBondageHelper.setStackInSlot( + armorStand, + region, + ItemStack.EMPTY + ); + + // Give item to player + if (!player.addItem(stack)) { + player.drop(stack, false); + } + + // Sync to clients + syncSlotToClients(armorStand, region, ItemStack.EMPTY); + + TiedUpMod.LOGGER.debug( + "[ArmorStandInteraction] Player {} removed {} from armor stand", + player.getName().getString(), + region.name() + ); + + return true; // Remove one item per click + } + } + + return false; + } + + /** + * Sync a bondage slot to all tracking clients. + * + * @param armorStand The armor stand + * @param region The body region + * @param stack The item in the slot + */ + private static void syncSlotToClients( + ArmorStand armorStand, + BodyRegionV2 region, + ItemStack stack + ) { + PacketSyncArmorStandBondage packet = new PacketSyncArmorStandBondage( + armorStand.getId(), + region, + stack + ); + ModNetwork.sendToAllTrackingEntity(packet, armorStand); + } + + /** + * Sync bondage data when a player starts tracking an armor stand. + * This ensures data is synced when: + * - Player joins the world and armor stands are in range + * - Player moves into range of an armor stand + * - Armor stand is loaded from save + */ + @SubscribeEvent + public static void onStartTracking(PlayerEvent.StartTracking event) { + if (!(event.getTarget() instanceof ArmorStand armorStand)) return; + if (!(event.getEntity() instanceof ServerPlayer serverPlayer)) return; + + // Check if this armor stand has any bondage items + if (!ArmorStandBondageHelper.hasBondageItems(armorStand)) return; + + // Sync all bondage slots to the player + for (BodyRegionV2 region : ArmorStandBondageHelper.VALID_REGIONS) { + ItemStack stack = ArmorStandBondageHelper.getStackInSlot( + armorStand, + region + ); + if (!stack.isEmpty()) { + PacketSyncArmorStandBondage packet = + new PacketSyncArmorStandBondage( + armorStand.getId(), + region, + stack + ); + ModNetwork.sendToPlayer(packet, serverPlayer); + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/components/DamselAIController.java b/src/main/java/com/tiedup/remake/entities/damsel/components/DamselAIController.java new file mode 100644 index 0000000..f89633c --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/components/DamselAIController.java @@ -0,0 +1,472 @@ +package com.tiedup.remake.entities.damsel.components; + +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.entities.ai.damsel.*; +import com.tiedup.remake.entities.ai.personality.*; +import com.tiedup.remake.state.ICaptor; +import java.util.List; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.ai.goal.FloatGoal; +import net.minecraft.world.entity.ai.goal.OpenDoorGoal; +import net.minecraft.world.entity.ai.goal.RandomLookAroundGoal; +import net.minecraft.world.entity.ai.goal.target.TargetGoal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; + +/** + * Manages all AI-related systems for EntityDamsel: + * - AI goals registration (25+ goals in priority order) + * - Call for help system (captive calling for rescue) + * - Leash traction system (prevents stuck while leashed) + * + * Phase 5: Extracted from EntityDamsel.java (~450 lines, 5 methods) + */ +public class DamselAIController { + + private final EntityDamsel entity; + private final IAIHost host; + + // ======================================== + // CALL FOR HELP SYSTEM + // ======================================== + + /** Cooldown between call for help messages */ + private int callForHelpCooldown = 0; + + /** Radius to search for players to call for help (blocks) */ + private static final int CALL_FOR_HELP_RADIUS = 15; + + /** Minimum cooldown for call for help (5 seconds) */ + private static final int CALL_FOR_HELP_COOLDOWN_MIN = 100; + + /** Maximum cooldown for call for help (10 seconds) */ + private static final int CALL_FOR_HELP_COOLDOWN_MAX = 200; + + // ======================================== + // LEASH TRACTION SYSTEM + // ======================================== + + /** Counter for how long NPC has been stuck while leashed */ + private int leashStuckCounter = 0; + + /** Previous X position for stuck detection */ + private double leashPrevX; + + /** Previous Z position for stuck detection */ + private double leashPrevZ; + + /** Distance from holder at which pulling starts (blocks) */ + private static final double LEASH_PULL_START_DISTANCE = 2.5; + + /** Distance at which stuck teleport begins (blocks) */ + private static final double LEASH_TELEPORT_DISTANCE = 5.0; + + /** Maximum distance before forced teleport (blocks) */ + private static final double LEASH_MAX_DISTANCE = 8.0; + + /** Ticks stuck before teleport (40 ticks = 2 seconds) */ + private static final int LEASH_STUCK_THRESHOLD = 40; + + /** FIX: Max traction force - reduced for smoother movement */ + private static final double LEASH_MAX_FORCE = 0.12; + + /** FIX: Force ramp factor - gradual increase based on distance */ + private static final double LEASH_FORCE_RAMP = 0.05; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public DamselAIController(EntityDamsel entity, IAIHost host) { + this.entity = entity; + this.host = host; + } + + // ======================================== + // AI GOALS REGISTRATION + // ======================================== + + /** + * Register all AI goals for this entity. + * Must be called from EntityDamsel.registerGoals() with goalSelector and targetSelector. + * + * @param goalSelector The goal selector from Mob + * @param targetSelector The target selector from Mob + */ + public void registerGoals( + net.minecraft.world.entity.ai.goal.GoalSelector goalSelector, + net.minecraft.world.entity.ai.goal.GoalSelector targetSelector + ) { + // Priority 0: Always swim (FloatGoal) + goalSelector.addGoal(0, new FloatGoal(entity)); + + // Priority 1: Personality command goals (take precedence when active) + goalSelector.addGoal(1, new NpcFollowCommandGoal(entity, 1.0)); + // NpcHeelCommandGoal removed - HEEL is now a FollowDistance mode in NpcFollowCommandGoal + goalSelector.addGoal(1, new NpcStayCommandGoal(entity)); + goalSelector.addGoal(1, new NpcIdleCommandGoal(entity)); + goalSelector.addGoal(1, new NpcComeCommandGoal(entity, 1.2)); + goalSelector.addGoal(1, new NpcGoHomeGoal(entity)); + goalSelector.addGoal(1, new NpcSitCommandGoal(entity)); + goalSelector.addGoal(1, new NpcKneelCommandGoal(entity)); + goalSelector.addGoal(1, new NpcPatrolCommandGoal(entity, 0.9)); + goalSelector.addGoal(1, new NpcGuardCommandGoal(entity)); + goalSelector.addGoal(1, new NpcFetchCommandGoal(entity, 1.1)); + goalSelector.addGoal(1, new NpcCollectCommandGoal(entity, 1.0)); + // NOTE: Combat goals (Defend, Attack, Capture) removed. + // Combat behavior is now handled by NpcFollowCommandGoal based on main hand item. + // Work commands (FARM, COOK, STORE) - chest-hub system + goalSelector.addGoal(1, new NpcFarmCommandGoal(entity, 0.9)); + goalSelector.addGoal(1, new NpcCookCommandGoal(entity, 1.0)); + goalSelector.addGoal(1, new NpcTransferCommandGoal(entity, 1.0)); + goalSelector.addGoal(1, new NpcShearCommandGoal(entity, 1.0)); + goalSelector.addGoal(1, new NpcMineCommandGoal(entity, 0.9)); + goalSelector.addGoal(1, new NpcBreedCommandGoal(entity, 1.0)); + goalSelector.addGoal(1, new NpcFishCommandGoal(entity, 0.9)); + goalSelector.addGoal(1, new NpcSortCommandGoal(entity, 1.0)); + + // Priority 2: Struggle goal (passive, runs alongside other goals - no flags) + goalSelector.addGoal(2, new NpcStruggleGoal(entity)); + + // Priority 2: Auto-eat goal (passive, eats from inventory when hungry) + goalSelector.addGoal(2, new NpcAutoEatGoal(entity)); + + // Priority 2: Auto-rest goal (passive, goes home to rest when tired) + goalSelector.addGoal(2, new NpcAutoRestGoal(entity)); + + // Priority 2-5: Custom conditional AI goals + goalSelector.addGoal( + 2, + new DamselFleeFromPlayerGoal(entity, 10.0f, 1.1, 1.3) + ); + goalSelector.addGoal(3, new DamselPanicGoal(entity, 1.0)); + goalSelector.addGoal(4, new DamselWanderGoal(entity, 1.2)); + goalSelector.addGoal( + 5, + new DamselWatchPlayerGoal(entity, Player.class, 6.0f) + ); + + // Priority 6-7: Basic vanilla goals + goalSelector.addGoal(6, new RandomLookAroundGoal(entity)); + goalSelector.addGoal(7, new OpenDoorGoal(entity, false)); + } + + // ======================================== + // COMMAND GOALS UTILITY + // ======================================== + + /** + * Register all NPC command goals on a goal selector. + * Used by subclasses (EntityMaid, EntitySlaveTrader, EntityMaster) that override + * registerGoals() but still need command functionality. + * + * @param goalSelector The goal selector to register on + * @param entity The entity (must extend EntityDamsel) + * @param priority The priority level for command goals + */ + public static void registerCommandGoals( + net.minecraft.world.entity.ai.goal.GoalSelector goalSelector, + EntityDamsel entity, + int priority + ) { + goalSelector.addGoal(priority, new NpcFollowCommandGoal(entity, 1.0)); + goalSelector.addGoal(priority, new NpcStayCommandGoal(entity)); + goalSelector.addGoal(priority, new NpcIdleCommandGoal(entity)); + goalSelector.addGoal(priority, new NpcComeCommandGoal(entity, 1.2)); + goalSelector.addGoal(priority, new NpcGoHomeGoal(entity)); + goalSelector.addGoal(priority, new NpcSitCommandGoal(entity)); + goalSelector.addGoal(priority, new NpcKneelCommandGoal(entity)); + goalSelector.addGoal(priority, new NpcPatrolCommandGoal(entity, 0.9)); + goalSelector.addGoal(priority, new NpcGuardCommandGoal(entity)); + goalSelector.addGoal(priority, new NpcFetchCommandGoal(entity, 1.1)); + goalSelector.addGoal(priority, new NpcCollectCommandGoal(entity, 1.0)); + goalSelector.addGoal(priority, new NpcFarmCommandGoal(entity, 0.9)); + goalSelector.addGoal(priority, new NpcCookCommandGoal(entity, 1.0)); + goalSelector.addGoal(priority, new NpcTransferCommandGoal(entity, 1.0)); + goalSelector.addGoal(priority, new NpcShearCommandGoal(entity, 1.0)); + goalSelector.addGoal(priority, new NpcMineCommandGoal(entity, 0.9)); + goalSelector.addGoal(priority, new NpcBreedCommandGoal(entity, 1.0)); + goalSelector.addGoal(priority, new NpcFishCommandGoal(entity, 0.9)); + goalSelector.addGoal(priority, new NpcSortCommandGoal(entity, 1.0)); + } + + // ======================================== + // CALL FOR HELP SYSTEM + // ======================================== + + /** + * Periodically call for help to nearby players when being led as a captive. + * Only works if: + * - Damsel is tied up + * - Damsel is a captive (being led) + * - Not gagged (can still speak) + * - There's a player nearby who is NOT the captor + * + * Call this from EntityDamsel.aiStep(). + */ + public void tickCallForHelp() { + // Decrement cooldown + if (this.callForHelpCooldown > 0) { + this.callForHelpCooldown--; + return; + } + + // Check if this entity can call for help (overridable in EntityKidnapper) + if (!entity.canCallForHelp()) { + return; + } + + // Must be tied and a captive to call for help + if (!host.isTiedUp() || !host.isCaptive()) { + return; + } + + // Can't call for help if gagged + if (host.isGagged()) { + return; + } + + // Find nearby players who could help + List nearbyPlayers = host + .level() + .getEntitiesOfClass( + Player.class, + host + .getBoundingBox() + .inflate(ModConfig.SERVER.dialogueRadius.get()) + ); + + // Get captor entity for comparison + Entity captorEntity = + host.getBondageManager().getCaptor() != null + ? host.getBondageManager().getCaptor().getEntity() + : null; + + boolean foundTarget = false; + + for (Player player : nearbyPlayers) { + // Skip creative/spectator + if (player.isCreative() || player.isSpectator()) continue; + + // Skip the captor - don't ask your captor for help! + if (captorEntity != null && player == captorEntity) continue; + + // Call for help to this player + TiedUpMod.LOGGER.debug( + "[EntityDamsel] {} calling for help to {}", + host.getNpcName(), + player.getName().getString() + ); + com.tiedup.remake.dialogue.EntityDialogueManager.callForHelp( + entity, + player + ); + + // Found a target, set flag + foundTarget = true; + break; + } + + // Reset cooldown + int baseCooldown = ModConfig.SERVER.dialogueCooldown.get(); + if (foundTarget) { + // Full cooldown if we talked + this.callForHelpCooldown = + baseCooldown + host.getRandom().nextInt(baseCooldown); + } else { + // Short cooldown if no one found (prevent spam) - 2 to 4 seconds + this.callForHelpCooldown = 40 + host.getRandom().nextInt(40); + } + } + + // ======================================== + // LEASH TRACTION SYSTEM + // ======================================== + + /** + * Tick leash traction system for NPCs. + * Detects when NPC is stuck while leashed and teleports to holder. + * This mirrors the player leash system in MixinServerPlayer. + * + * FIX: Now applies ACTIVE traction force towards holder (was missing). + * FIX: Increases step height temporarily when being pulled to climb stairs. + * + * Call this from EntityDamsel.aiStep(). + */ + public void tickLeashTraction() { + // Only process if leashed + Entity leashHolder = host.getLeashHolder(); + if (leashHolder == null) { + this.leashStuckCounter = 0; + // Reset step height when not leashed + if (host.maxUpStep() > 0.6f) { + host.setMaxUpStep(0.6f); + } + return; + } + + // Same level check + if (leashHolder.level() != host.level()) { + return; + } + + float distance = host.distanceTo(leashHolder); + + // Close enough: reset stuck counter, reset step height + if (distance < LEASH_PULL_START_DISTANCE) { + this.leashStuckCounter = 0; + if (host.maxUpStep() > 0.6f) { + host.setMaxUpStep(0.6f); + } + return; + } + + // Too far: forced teleport to holder (regardless of stuck status) + if (distance > LEASH_MAX_DISTANCE) { + teleportToSafePositionNearHolder(leashHolder); + this.leashStuckCounter = 0; + return; + } + + // Calculate direction to holder + double dx = leashHolder.getX() - host.getX(); + double dy = leashHolder.getY() - host.getY(); + double dz = leashHolder.getZ() - host.getZ(); + + // Calculate movement since last tick + double movedX = host.getX() - this.leashPrevX; + double movedZ = host.getZ() - this.leashPrevZ; + double movedHorizontal = Math.sqrt(movedX * movedX + movedZ * movedZ); + + // Store current position for next tick + this.leashPrevX = host.getX(); + this.leashPrevZ = host.getZ(); + + // FIX: Apply ACTIVE traction force towards holder with smooth ramping + if (distance > LEASH_PULL_START_DISTANCE) { + // Normalize direction + double horizontalDist = Math.sqrt(dx * dx + dz * dz); + double dirX = horizontalDist > 0.01 ? dx / horizontalDist : 0; + double dirZ = horizontalDist > 0.01 ? dz / horizontalDist : 0; + + // FIX: Smoother force calculation - gradual ramp up + // Force increases with distance but is capped for smooth movement + double distanceBeyond = distance - LEASH_PULL_START_DISTANCE; + double forceFactor = Math.min( + LEASH_MAX_FORCE, + distanceBeyond * LEASH_FORCE_RAMP + ); + + // FIX: Blend with current motion instead of adding directly + // This prevents jerky acceleration + net.minecraft.world.phys.Vec3 currentMotion = + host.getDeltaMovement(); + double blendFactor = 0.7; // 70% new direction, 30% current momentum + + double newVelX = + currentMotion.x * (1 - blendFactor) + + dirX * forceFactor * blendFactor * 3; + double newVelZ = + currentMotion.z * (1 - blendFactor) + + dirZ * forceFactor * blendFactor * 3; + double newVelY = currentMotion.y + (dy > 0.5 ? 0.03 : 0); // Gentler Y boost + + host.setDeltaMovement( + new net.minecraft.world.phys.Vec3(newVelX, newVelY, newVelZ) + ); + + // Increase step height while being pulled (helps with stairs) + host.setMaxUpStep(1.0f); + } + + // Strict stuck detection: not moving despite being far + boolean isStuck = + distance > LEASH_TELEPORT_DISTANCE && + host.getDeltaMovement().horizontalDistanceSqr() < 0.001 && + movedHorizontal < 0.05; + + if (isStuck) { + this.leashStuckCounter++; + + // Safety teleport if stuck for too long (30 ticks = 1.5 sec) + if (this.leashStuckCounter >= LEASH_STUCK_THRESHOLD) { + teleportToSafePositionNearHolder(leashHolder); + this.leashStuckCounter = 0; + } + } else { + // Reset stuck counter if moving + this.leashStuckCounter = 0; + } + } + + /** + * Teleport NPC to a safe position near the leash holder. + */ + private void teleportToSafePositionNearHolder(Entity holder) { + // Target: 2 blocks away from holder in our direction + double dx = host.getX() - holder.getX(); + double dz = host.getZ() - holder.getZ(); + double dist = Math.sqrt(dx * dx + dz * dz); + + double offsetX = 0; + double offsetZ = 0; + if (dist > 0.1) { + offsetX = (dx / dist) * 2.0; + offsetZ = (dz / dist) * 2.0; + } + + double targetX = holder.getX() + offsetX; + double targetZ = holder.getZ() + offsetZ; + + // Find safe Y (ground level) + double targetY = findSafeY(targetX, holder.getY(), targetZ); + + host.teleportTo(targetX, targetY, targetZ); + + TiedUpMod.LOGGER.debug( + "[EntityDamsel] {} stuck while leashed, teleported to holder", + host.getNpcName() + ); + } + + /** + * Find a safe Y coordinate for teleporting. + */ + private double findSafeY(double x, double startY, double z) { + BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos(); + + // Search down first (max 5 blocks) + for (int y = 0; y > -5; y--) { + mutable.set((int) x, (int) startY + y, (int) z); + if ( + host + .level() + .getBlockState(mutable) + .isSolidRender(host.level(), mutable) && + host.level().getBlockState(mutable.above()).isAir() + ) { + return mutable.getY() + 1; + } + } + + // Search up (max 5 blocks) + for (int y = 1; y < 5; y++) { + mutable.set((int) x, (int) startY + y, (int) z); + if ( + host + .level() + .getBlockState(mutable.below()) + .isSolidRender(host.level(), mutable.below()) && + host.level().getBlockState(mutable).isAir() + ) { + return mutable.getY(); + } + } + + // Fallback: use holder's Y + return startY; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/components/DamselAnimationController.java b/src/main/java/com/tiedup/remake/entities/damsel/components/DamselAnimationController.java new file mode 100644 index 0000000..fca96c4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/components/DamselAnimationController.java @@ -0,0 +1,263 @@ +package com.tiedup.remake.entities.damsel.components; + +import com.tiedup.remake.util.RotationSmoother; +import dev.kosmx.playerAnim.api.layered.AnimationStack; +import dev.kosmx.playerAnim.api.layered.IAnimation; +import dev.kosmx.playerAnim.impl.animation.AnimationApplier; +import java.util.HashMap; +import java.util.Map; +import org.jetbrains.annotations.Nullable; +import net.minecraft.resources.ResourceLocation; + +/** + * Manages all animation-related systems for EntityDamsel: + * - IAnimatedPlayer implementation (PlayerAnimator mod) + * - Animation stack management + * - Pose management (sitting, kneeling, dog, struggling, trembling) + * - Dog pose rotation smoothing + * + * Phase 3: Extracted from EntityDamsel.java (~220 lines, 14 methods) + */ +public class DamselAnimationController { + + private final IAnimationHost host; + + // ======================================== + // ANIMATION SYSTEM + // ======================================== + + /** Animation stack for PlayerAnimator mod */ + private final AnimationStack animationStack = new AnimationStack(); + + /** Animation applier for model rendering */ + private final AnimationApplier animationApplier = new AnimationApplier( + animationStack + ); + + /** Stored animations by ID */ + private final Map storedAnimations = + new HashMap<>(); + + // ======================================== + // DOG POSE ROTATION SMOOTHING + // ======================================== + + /** Rotation smoother for DOG pose (prevents sudden spinning) */ + private final RotationSmoother dogPoseRotationSmoother = + new RotationSmoother(); + + /** Track DOG pose state for transition detection */ + private boolean wasInDogPose = false; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public DamselAnimationController(IAnimationHost host) { + this.host = host; + } + + // ======================================== + // TICK LOGIC + // ======================================== + + /** + * Tick animation systems BEFORE super.tick(). + * Call this from EntityDamsel.tick() BEFORE super.tick(). + * + * Handles: + * - DOG pose transition detection (initializes smoother) + */ + public void tickAnimationBeforeSuperTick() { + // DOG pose: Detect transition to initialize smoother + boolean inDogPose = host.isDogPose(); + if (inDogPose && !wasInDogPose) { + // Just entered DOG pose - initialize smoother to current rotation + this.dogPoseRotationSmoother.setCurrent(host.getYBodyRot()); + } + this.wasInDogPose = inDogPose; + } + + /** + * Tick DOG pose rotation smoothing AFTER super.tick(). + * Call this from EntityDamsel.tick() AFTER super.tick(). + * + * FIX: Must run AFTER super.tick() because: + * 1. super.tick() calculates new yBodyRot based on movement direction + * 2. We smooth that final calculated value + * 3. If we smooth before, Minecraft overwrites our smoothed value + * + * FIX: Only set yBodyRot, not yBodyRotO: + * - yBodyRotO is the "old" rotation from last tick, used for interpolation + * - Setting both to the same value breaks inter-frame interpolation + * - Minecraft's renderer uses lerp(yBodyRotO, yBodyRot, partialTicks) + * - If both are equal, there's no interpolation = visual snapping + */ + public void tickDogPoseRotationSmoothing() { + if (!host.isDogPose()) { + return; + } + + // Get the target rotation (what Minecraft calculated based on movement) + float targetRot = host.getYBodyRot(); + + // Smooth towards target (0.15 = 15% per tick, faster but still smooth) + float smoothedRot = this.dogPoseRotationSmoother.smooth( + targetRot, + 0.15f + ); + + // Only set yBodyRot - let yBodyRotO be managed by Minecraft for interpolation + host.setYBodyRot(smoothedRot); + } + + /** + * Tick animation stack (client-side only). + * Call this from EntityDamsel.tick() AFTER tickAnimationBeforeSuperTick(). + * + * @return true if animation tick was handled (client-side), false if server should continue + */ + public boolean tickAnimationStack() { + // Client-side: tick animation stack + if (host.level().isClientSide) { + this.animationStack.tick(); + return true; // Signal to caller to return early + } + return false; // Server-side, continue with tick logic + } + + // ======================================== + // IANIMATEDPLAYER INTERFACE + // ======================================== + + /** + * Get the animation stack. + * Required by IAnimatedPlayer interface. + * + * @return AnimationStack for this entity + */ + public AnimationStack getAnimationStack() { + return this.animationStack; + } + + /** + * Get the animation applier for model rendering. + * Required by IAnimatedPlayer interface. + * + *

The AnimationApplier is used by DamselModel.setupAnim() to apply + * current animation transforms to model parts via emote.updatePart(). + * + * @return AnimationApplier for this entity + */ + public AnimationApplier playerAnimator_getAnimation() { + return this.animationApplier; + } + + /** + * Get a stored animation by ID. + * Required by IAnimatedPlayer interface. + * + * @param id Animation identifier + * @return The stored animation, or null if not found + */ + @Nullable + public IAnimation playerAnimator_getAnimation(ResourceLocation id) { + return this.storedAnimations.get(id); + } + + /** + * Store an animation by ID. + * Required by IAnimatedPlayer interface. + * + * @param id Animation identifier + * @param animation Animation to store, or null to remove + * @return The previously stored animation, or null + */ + @Nullable + public IAnimation playerAnimator_setAnimation( + ResourceLocation id, + @Nullable IAnimation animation + ) { + if (animation == null) { + return this.storedAnimations.remove(id); + } + return this.storedAnimations.put(id, animation); + } + + // ======================================== + // POSE MANAGEMENT + // ======================================== + + /** + * Check if NPC is currently sitting. + * @return true if in sitting pose + */ + public boolean isSitting() { + return host.isSittingFromData(); + } + + /** + * Set sitting pose state. + * @param sitting true to enter sitting pose + */ + public void setSitting(boolean sitting) { + host.setSittingToData(sitting); + // Clear kneeling if sitting + if (sitting) { + host.setKneelingToData(false); + } + } + + /** + * Check if NPC is currently kneeling. + * @return true if in kneeling pose + */ + public boolean isKneeling() { + return host.isKneelingFromData(); + } + + /** + * Set kneeling pose state. + * @param kneeling true to enter kneeling pose + */ + public void setKneeling(boolean kneeling) { + host.setKneelingToData(kneeling); + // Clear sitting if kneeling + if (kneeling) { + host.setSittingToData(false); + } + } + + /** + * Check if NPC is in any pose (sitting, kneeling, or dog). + * @return true if in a pose + */ + public boolean isInPose() { + return this.isSitting() || this.isKneeling() || host.isDogPose(); + } + + /** + * Check if NPC is in DOG pose (based on equipped bind). + * @return true if equipped bind has DOG pose type + */ + public boolean isDogPose() { + return host.isDogPose(); + } + + /** + * Check if NPC is currently struggling against restraints. + * Used for animation sync. + * @return true if struggling + */ + public boolean isStruggling() { + return host.isStrugglingFromData(); + } + + /** + * Set struggling state for animation. + * @param struggling true if starting struggle animation + */ + public void setStruggling(boolean struggling) { + host.setStrugglingToData(struggling); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/components/DamselAppearance.java b/src/main/java/com/tiedup/remake/entities/damsel/components/DamselAppearance.java new file mode 100644 index 0000000..c833d4b --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/components/DamselAppearance.java @@ -0,0 +1,248 @@ +package com.tiedup.remake.entities.damsel.components; + +import com.tiedup.remake.entities.DamselVariant; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.entities.skins.DamselSkinManager; +import com.tiedup.remake.entities.skins.Gender; +import org.jetbrains.annotations.Nullable; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; + +/** + * Manages the visual appearance of a Damsel NPC. + * Responsibilities: + * - Variant selection and management + * - Skin texture resolution + * - Gender + * - Slim arms flag + * - Custom name + * + * Phase 1 of EntityDamsel refactoring (8 phases total). + */ +public class DamselAppearance { + + private final EntityDamsel entity; + + /** + * Current skin variant (cached from DATA_VARIANT_ID). + * Thread-safe: accessed from render thread. + */ + @Nullable + private volatile DamselVariant variant; + + /** + * Default texture for damsel entities. + */ + private static final ResourceLocation DEFAULT_DAMSEL_TEXTURE = + ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/damsel/dam_mob_1.png" + ); + + public DamselAppearance(EntityDamsel entity) { + this.entity = entity; + } + + // ======================================== + // VARIANT SYSTEM + // ======================================== + + /** + * Get the current variant. + * Lazy-loads from DATA_VARIANT_ID if not cached. + */ + @Nullable + public DamselVariant getVariant() { + // Try to load from synced variant ID first + if (this.variant == null && !this.getVariantId().isEmpty()) { + this.variant = DamselSkinManager.CORE.getVariant( + this.getVariantId() + ); + } + // Fallback: compute from UUID if not yet synced (client-side race condition fix) + if (this.variant == null) { + this.variant = DamselSkinManager.CORE.getVariantForEntity( + this.entity.getUUID() + ); + } + return this.variant; + } + + /** + * Set the variant. + * Updates both cache and synced data. + */ + public void setVariant(DamselVariant variant) { + this.variant = variant; + this.entity.getEntityData().set( + EntityDamsel.DATA_VARIANT_ID, + variant.id() + ); + this.setSlimArms(variant.hasSlimArms()); + this.setGender(variant.gender()); + applyVariantName(variant); + } + + /** + * Apply the name from variant to this entity. + * Numbered variants (dam_mob_*) get random names. + * Named variants (Anastasia, Blizz, etc.) use their default name. + */ + protected void applyVariantName(DamselVariant variant) { + if (variant.id().startsWith("dam_mob_")) { + this.setNpcName( + com.tiedup.remake.util.NameGenerator.getRandomDamselName() + ); + } else { + this.setNpcName(variant.defaultName()); + } + } + + /** + * Get variant ID (synced data). + */ + public String getVariantId() { + return this.entity.getEntityData().get(EntityDamsel.DATA_VARIANT_ID); + } + + /** + * Invalidate variant cache (called when DATA_VARIANT_ID changes). + */ + public void invalidateVariantCache() { + this.variant = null; + } + + // ======================================== + // SLIM ARMS + // ======================================== + + /** + * Check if this damsel uses slim arms model. + * Reads from synced entityData - no local skin JSONs needed on client. + */ + public boolean hasSlimArms() { + return this.entity.getEntityData().get(EntityDamsel.DATA_SLIM_ARMS); + } + + /** + * Set slim arms flag directly. + * Used by subclasses that have their own variant systems. + */ + protected void setSlimArms(boolean slimArms) { + this.entity.getEntityData().set(EntityDamsel.DATA_SLIM_ARMS, slimArms); + } + + // ======================================== + // GENDER + // ======================================== + + public void setGender(Gender gender) { + this.entity.getEntityData().set( + EntityDamsel.DATA_GENDER, + gender.getSerializedName() + ); + } + + public Gender getGender() { + return Gender.fromName( + this.entity.getEntityData().get(EntityDamsel.DATA_GENDER) + ); + } + + // ======================================== + // SKIN TEXTURE + // ======================================== + + /** + * Get the skin texture for this entity. + * Computes texture path from variant ID - no local skin JSONs needed on client. + * Implements ISkinnedEntity to eliminate instanceof cascades in renderers. + */ + public ResourceLocation getSkinTexture() { + String variantId = this.getVariantId(); + if (!variantId.isEmpty()) { + return ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/damsel/" + variantId + ".png" + ); + } + return DEFAULT_DAMSEL_TEXTURE; + } + + // ======================================== + // NAME SYSTEM + // ======================================== + + /** + * Get NPC's custom name. + */ + public String getNpcName() { + return this.entity.getEntityData().get(EntityDamsel.DATA_DAMSEL_NAME); + } + + /** + * Set NPC's custom name. + * Also updates Minecraft's custom name display. + */ + public void setNpcName(String name) { + this.entity.getEntityData().set(EntityDamsel.DATA_DAMSEL_NAME, name); + + // Make the name visible in-game + this.entity.setCustomName(Component.literal(name)); + this.entity.setCustomNameVisible(true); + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + /** + * Save appearance data to NBT tag. + * Writes directly to top-level keys for backward compatibility. + */ + public void saveToTag(CompoundTag tag) { + String variantId = this.entity.getEntityData().get( + EntityDamsel.DATA_VARIANT_ID + ); + if (!variantId.isEmpty()) { + tag.putString("Variant", variantId); + } + + String name = this.getNpcName(); + if (!name.isEmpty()) { + tag.putString("DamselName", name); + } + + tag.putBoolean("SlimArms", this.hasSlimArms()); + tag.putString("Gender", this.getGender().getSerializedName()); + } + + /** + * Load appearance data from NBT tag. + * Reads from top-level keys for backward compatibility. + */ + public void loadFromTag(CompoundTag tag) { + if (tag.contains("Variant")) { + String variantId = tag.getString("Variant"); + this.entity.getEntityData().set( + EntityDamsel.DATA_VARIANT_ID, + variantId + ); + this.variant = null; // Invalidate cache + } + + if (tag.contains("DamselName")) { + this.setNpcName(tag.getString("DamselName")); + } + + if (tag.contains("SlimArms")) { + this.setSlimArms(tag.getBoolean("SlimArms")); + } + + if (tag.contains("Gender")) { + String genderName = tag.getString("Gender"); + this.setGender(Gender.fromName(genderName)); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/components/DamselBondageManager.java b/src/main/java/com/tiedup/remake/entities/damsel/components/DamselBondageManager.java new file mode 100644 index 0000000..1a82af0 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/components/DamselBondageManager.java @@ -0,0 +1,468 @@ +package com.tiedup.remake.entities.damsel.components; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.AbstractTiedUpNpc; +import com.tiedup.remake.entities.BondageServiceHandler; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.state.IRestrainableEntity; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.state.ICaptor; +import com.tiedup.remake.util.RestraintEffectUtils; +import com.tiedup.remake.util.tasks.ItemTask; +import com.tiedup.remake.util.teleport.Position; +import com.tiedup.remake.util.teleport.TeleportHelper; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * DamselBondageManager - Facade over NpcEquipmentManager and NpcCaptivityManager. + * + *

H9 refactor: Split from a 1441-line god-component into:

+ *
    + *
  • {@link NpcEquipmentManager} -- equipment CRUD, coercion, bulk ops (~700L)
  • + *
  • {@link NpcCaptivityManager} -- captor, leash, free/capture (~250L)
  • + *
  • {@link BondageServiceHandler} -- existing, unchanged
  • + *
  • This facade -- delegates everything, owns sale state inline (~250L)
  • + *
+ * + *

Implements {@link IRestrainable} so all existing callers via + * {@code getBondageManager().xxx()} continue to work unchanged.

+ */ +public class DamselBondageManager implements IRestrainable { + + // ======================================== + // FIELDS + // ======================================== + + private final AbstractTiedUpNpc entity; + private final IBondageHost host; + private final NpcEquipmentManager equipment; + private final NpcCaptivityManager captivity; + private final BondageServiceHandler bondageService; + + // Sale fields (inline -- too small for own class) + private boolean forSale; + private ItemTask salePrice; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public DamselBondageManager(AbstractTiedUpNpc entity, IBondageHost host) { + this.entity = entity; + this.host = host; + this.equipment = new NpcEquipmentManager(entity, host); + this.captivity = new NpcCaptivityManager(entity, host, equipment); + this.bondageService = new BondageServiceHandler(entity); + this.forSale = false; + this.salePrice = null; + } + + // ======================================== + // SUB-COMPONENT ACCESS + // ======================================== + + /** Expose equipment sub-component for direct access where needed. */ + public NpcEquipmentManager getEquipmentManager() { + return equipment; + } + + /** Expose captivity sub-component for direct access where needed. */ + public NpcCaptivityManager getCaptivityManager() { + return captivity; + } + + // ======================================== + // IBondageState DELEGATION -> equipment + // ======================================== + + @Override public boolean isTiedUp() { return equipment.isTiedUp(); } + @Override public boolean isGagged() { return equipment.isGagged(); } + @Override public boolean isBlindfolded() { return equipment.isBlindfolded(); } + @Override public boolean hasEarplugs() { return equipment.hasEarplugs(); } + @Override public boolean hasCollar() { return equipment.hasCollar(); } + @Override public boolean hasClothes() { return equipment.hasClothes(); } + @Override public boolean hasMittens() { return equipment.hasMittens(); } + @Override public boolean isBoundAndGagged() { return equipment.isBoundAndGagged(); } + @Override public boolean hasGaggingEffect() { return equipment.hasGaggingEffect(); } + @Override public boolean hasBlindingEffect() { return equipment.hasBlindingEffect(); } + @Override public boolean hasKnives() { return equipment.hasKnives(); } + @Override public boolean hasClothesWithSmallArms() { return equipment.hasClothesWithSmallArms(); } + @Override public boolean hasLockedCollar() { return equipment.hasLockedCollar(); } + @Override public boolean hasNamedCollar() { return equipment.hasNamedCollar(); } + + // V2 region-based equipment access + @Override public ItemStack getEquipment(BodyRegionV2 region) { return equipment.getInRegion(region); } + + @Override + public void equip(BodyRegionV2 region, ItemStack stack) { + switch (region) { + case ARMS -> equipment.putBindOn(stack); + case MOUTH -> equipment.putGagOn(stack); + case EYES -> equipment.putBlindfoldOn(stack); + case EARS -> equipment.putEarplugsOn(stack); + case NECK -> equipment.putCollarOn(stack); + case TORSO -> equipment.putClothesOn(stack); + case HANDS -> equipment.putMittensOn(stack); + default -> {} + } + } + + // Equipment putters (local delegates — no longer in interface, but still called by legacy code) + public void putBindOn(ItemStack bind) { equipment.putBindOn(bind); } + public void putGagOn(ItemStack gag) { equipment.putGagOn(gag); } + public void putBlindfoldOn(ItemStack blindfold) { equipment.putBlindfoldOn(blindfold); } + public void putEarplugsOn(ItemStack earplugs) { equipment.putEarplugsOn(earplugs); } + public void putCollarOn(ItemStack collar) { equipment.putCollarOn(collar); } + public void putClothesOn(ItemStack clothes) { equipment.putClothesOn(clothes); } + public void putMittensOn(ItemStack mittens) { equipment.putMittensOn(mittens); } + + // Equipment removers (V2 region-based) + @Override + public ItemStack unequip(BodyRegionV2 region) { + return switch (region) { + case ARMS -> equipment.takeBindOff(); + case MOUTH -> equipment.takeGagOff(); + case EYES -> equipment.takeBlindfoldOff(); + case EARS -> equipment.takeEarplugsOff(); + case NECK -> equipment.takeCollarOff(false); + case TORSO -> equipment.takeClothesOff(); + case HANDS -> equipment.takeMittensOff(); + default -> ItemStack.EMPTY; + }; + } + + @Override + public ItemStack forceUnequip(BodyRegionV2 region) { + // Delegate to NpcEquipmentManager.forceRemoveFromRegion which does the same + // work as takeXOff (clear slot, sync, onUnequipped, side effects) but + // bypasses ILockable.isLocked() checks. See RISK-001. + return equipment.forceRemoveFromRegion(region); + } + + // Legacy equipment removers (local delegates for internal use) + public ItemStack takeBindOff() { return equipment.takeBindOff(); } + public ItemStack takeGagOff() { return equipment.takeGagOff(); } + public ItemStack takeBlindfoldOff() { return equipment.takeBlindfoldOff(); } + public ItemStack takeEarplugsOff() { return equipment.takeEarplugsOff(); } + public ItemStack takeCollarOff() { return equipment.takeCollarOff(); } + public ItemStack takeCollarOff(boolean force) { return equipment.takeCollarOff(force); } + public ItemStack takeClothesOff() { return equipment.takeClothesOff(); } + public ItemStack takeMittensOff() { return equipment.takeMittensOff(); } + + // Equipment replacer (V2 region-based) + @Override + public ItemStack replaceEquipment(BodyRegionV2 region, ItemStack newStack, boolean force) { + return switch (region) { + case ARMS -> equipment.replaceBind(newStack, force); + case MOUTH -> equipment.replaceGag(newStack, force); + case EYES -> equipment.replaceBlindfold(newStack, force); + case EARS -> equipment.replaceEarplugs(newStack, force); + case NECK -> equipment.replaceCollar(newStack, force); + case TORSO -> equipment.replaceClothes(newStack, force); + case HANDS -> equipment.replaceMittens(newStack, force); + default -> ItemStack.EMPTY; + }; + } + + // Bulk operations + @Override + public void applyBondage( + ItemStack bind, ItemStack gag, ItemStack blindfold, + ItemStack earplugs, ItemStack collar, ItemStack clothes + ) { + equipment.applyBondage(bind, gag, blindfold, earplugs, collar, clothes); + } + + @Override public void dropBondageItems(boolean drop) { equipment.dropBondageItems(drop); } + @Override public void dropBondageItems(boolean drop, boolean dropBind) { equipment.dropBondageItems(drop, dropBind); } + @Override + public void dropBondageItems( + boolean drop, boolean dropBind, boolean dropGag, + boolean dropBlindfold, boolean dropEarplugs, + boolean dropCollar, boolean dropClothes + ) { + equipment.dropBondageItems(drop, dropBind, dropGag, dropBlindfold, + dropEarplugs, dropCollar, dropClothes); + } + + @Override public void dropClothes() { equipment.dropClothes(); } + @Override public int getBondageItemsWhichCanBeRemovedCount() { return equipment.getBondageItemsWhichCanBeRemovedCount(); } + @Override public boolean canBeTiedUp() { return equipment.canBeTiedUp(); } + + // Permissions + @Override public boolean canTakeOffClothes(Player player) { return equipment.canTakeOffClothes(player); } + @Override public boolean canChangeClothes(Player player) { return equipment.canChangeClothes(player); } + @Override public boolean canChangeClothes() { return equipment.canChangeClothes(); } + + // ICoercible DELEGATION -> equipment + @Override public void tighten(Player tightener) { equipment.tighten(tightener); } + @Override public void applyChloroform(int duration) { equipment.applyChloroform(duration); } + @Override public void shockKidnapped() { equipment.shockKidnapped(); } + @Override public void shockKidnapped(String messageAddon, float damage) { equipment.shockKidnapped(messageAddon, damage); } + @Override public void takeBondageItemBy(IRestrainableEntity taker, int slotIndex) { equipment.takeBondageItemBy(taker, slotIndex); } + + // ======================================== + // ICapturable DELEGATION -> captivity + // ======================================== + + @Override public boolean isCaptive() { return captivity.isCaptive(); } + @Override public boolean isEnslavable() { return captivity.isEnslavable(); } + @Override public ICaptor getCaptor() { return captivity.getCaptor(); } + @Override public boolean getCapturedBy(ICaptor newCaptor) { return captivity.getCapturedBy(newCaptor); } + @Override public void free() { captivity.free(); } + @Override public void free(boolean transportState) { captivity.free(transportState); } + @Override public void transferCaptivityTo(ICaptor newCaptor) { captivity.transferCaptivityTo(newCaptor); } + @Override public boolean isTiedToPole() { return captivity.isTiedToPole(); } + @Override public boolean tieToClosestPole(int searchRadius) { return captivity.tieToClosestPole(searchRadius); } + @Override public boolean canBeKidnappedByEvents() { return captivity.canBeKidnappedByEvents(); } + @Override @Nullable public Entity getTransport() { return null; } // NPCs use vanilla leash directly + + // Public methods not on IRestrainable but used externally + public void clearCaptor() { captivity.clearCaptor(); } + public boolean forceCapturedBy(ICaptor newCaptor) { return captivity.forceCapturedBy(newCaptor); } + public void restoreCaptorFromUUID() { captivity.restoreCaptorFromUUID(); } + + // Collar owner check (public, used by AbstractTiedUpNpc) + public boolean isCollarOwner(Player player) { return equipment.isCollarOwner(player); } + + // ======================================== + // ISaleable -- INLINE (4 methods, 2 fields) + // ======================================== + + @Override + public boolean isForSell() { + return forSale && salePrice != null; + } + + @Override + @Nullable + public ItemTask getSalePrice() { + return salePrice; + } + + @Override + public void putForSale(ItemTask price) { + if (price == null) return; + + forSale = true; + salePrice = price; + + TiedUpMod.LOGGER.info( + "[EntityDamsel] {} put for sale at {}", + host.getNpcName(), + price.toDisplayString() + ); + } + + @Override + public void cancelSale() { + if (!forSale) return; + + TiedUpMod.LOGGER.info( + "[EntityDamsel] {} sale cancelled", + host.getNpcName() + ); + + forSale = false; + salePrice = null; + } + + // ======================================== + // IRestrainableEntity -- IDENTITY (stays in facade) + // ======================================== + + @Override + public LivingEntity asLivingEntity() { + return entity; + } + + @Override + public UUID getKidnappedUniqueId() { + return host.getUUID(); + } + + @Override + public String getKidnappedName() { + return host.getNpcName(); + } + + @Override + public String getNameFromCollar() { + ItemStack collar = getEquipment(BodyRegionV2.NECK); + if (collar.isEmpty()) { + return host.getNpcName(); + } + + if (collar.hasCustomHoverName()) { + return collar.getHoverName().getString(); + } + + return host.getNpcName(); + } + + @Override + public void kidnappedDropItem(ItemStack stack) { + host.dropItemStack(stack); + } + + @Override + public void teleportToPosition(Position position) { + if (position == null || entity.level().isClientSide) return; + TeleportHelper.teleportEntity(entity, position); + } + + // ======================================== + // CROSS-CUTTING METHODS (stay in facade) + // ======================================== + + /** + * Untie: crosses both equipment and captivity domains. + * Drops items (if requested), clears equipment, removes speed reduction. + */ + @Override + public void untie(boolean drop) { + if (drop) { + equipment.dropBondageItems(true); + } else { + // Clear all V2 regions at once, then sync ONCE (batch) + equipment.clearAllAndSync(); + } + + // Remove speed reduction + RestraintEffectUtils.removeBindSpeedReduction(entity); + + TiedUpMod.LOGGER.info( + "[EntityDamsel] {} freed from restraints", + host.getNpcName() + ); + } + + /** + * Death handler: crosses captivity (free) and equipment (unlock). + */ + @Override + public boolean onDeathKidnapped(Level world) { + if (world.isClientSide) return false; + + // Check if we have a collar with cell configured + ItemStack collar = getEquipment(BodyRegionV2.NECK); + if (collar.isEmpty()) return false; + + if (!(collar.getItem() instanceof ItemCollar itemCollar)) return false; + + UUID cellId = itemCollar.getCellId(collar); + if (cellId == null) return false; + + // Get cell position from registry + if ( + !(entity.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel) + ) return false; + com.tiedup.remake.cells.CellDataV2 cell = + com.tiedup.remake.cells.CellRegistryV2.get(serverLevel).getCell( + cellId + ); + if (cell == null) return false; + + Position cellPosition = new Position( + cell.getSpawnPoint().above(), + serverLevel.dimension() + ); + + // We have a cell -- respawn there instead of dying + + // 1. Free from captivity if captured + if (isCaptive()) { + captivity.free(false); + } + + // 2. Unlock all locked items + equipment.unlockAllItems(); + + // 3. Heal to full + host.setHealth(entity.getMaxHealth()); + + // 4. Teleport to cell + teleportToPosition(cellPosition); + + TiedUpMod.LOGGER.info( + "[EntityDamsel] {} respawned at cell instead of dying", + entity.getName().getString() + ); + + return true; + } + + // ======================================== + // BONDAGE SERVICE DELEGATION + // ======================================== + + public boolean isBondageServiceEnabled() { + return bondageService.isEnabled(); + } + + public String getBondageServiceMessage() { + return bondageService.getMessage(); + } + + /** + * Handle damage with bondage service logic. + * Call this from EntityDamsel.hurt() if service is enabled. + * + * @param source damage source + * @param amount damage amount + * @return true if damage was handled (cancel default), false otherwise + */ + public boolean handleDamageWithService(DamageSource source, float amount) { + if (!isBondageServiceEnabled()) return false; + + // Only process player attacks + if (!(source.getEntity() instanceof Player player)) return false; + + // Cancel actual damage (service NPCs don't take damage from hits) + return true; + } + + // ======================================== + // PERSISTENCE ORCHESTRATION + // ======================================== + + /** + * Save bondage state to NBT. + * Delegates to sub-components, handles sale state inline. + */ + public void saveToTag(CompoundTag tag) { + equipment.saveEquipmentToTag(tag); + captivity.saveCaptivityToTag(tag); + + // Sale state (inline) + tag.putBoolean("ForSale", forSale); + if (salePrice != null) { + tag.put("SalePrice", salePrice.save()); + } + } + + /** + * Load bondage state from NBT. + * Delegates to sub-components, handles sale state inline. + */ + public void loadFromTag(CompoundTag tag) { + equipment.loadEquipmentFromTag(tag); + captivity.loadCaptivityFromTag(tag); + + // Sale state (inline) + forSale = tag.getBoolean("ForSale"); + if (tag.contains("SalePrice")) { + salePrice = ItemTask.load(tag.getCompound("SalePrice")); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/components/DamselDataSerializer.java b/src/main/java/com/tiedup/remake/entities/damsel/components/DamselDataSerializer.java new file mode 100644 index 0000000..9e44c9e --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/components/DamselDataSerializer.java @@ -0,0 +1,57 @@ +package com.tiedup.remake.entities.damsel.components; + +import com.tiedup.remake.entities.EntityDamsel; +import net.minecraft.nbt.CompoundTag; + +/** + * Orchestrates NBT serialization/deserialization for all EntityDamsel components. + * Provides a single entry point for save/load operations. + * + * Phase 8: Final refactoring phase - NBT orchestration. + */ +public class DamselDataSerializer { + + private final EntityDamsel entity; + + public DamselDataSerializer(EntityDamsel entity) { + this.entity = entity; + } + + /** + * Save all component data to NBT. + * Call this from EntityDamsel.addAdditionalSaveData(). + * + * @param tag NBT compound tag to write to + */ + public void save(CompoundTag tag) { + // Phase 1: Appearance (variant, gender, name, slim arms) + entity.getAppearance().saveToTag(tag); + + // Bondage + Inventory are now saved by AbstractTiedUpNpc.addAdditionalSaveData() + + // Phase 6: Personality (traits, needs, relationships, commands) + entity.getPersonalitySystem().saveToTag(tag); + + // Reward tracker (savior, rewards) + entity.getRewardTracker().save(tag); + } + + /** + * Load all component data from NBT. + * Call this from EntityDamsel.readAdditionalSaveData(). + * + * @param tag NBT compound tag to read from + */ + public void load(CompoundTag tag) { + // Phase 1: Appearance (variant, gender, name, slim arms) + entity.getAppearance().loadFromTag(tag); + + // Bondage + Inventory are now loaded by AbstractTiedUpNpc.readAdditionalSaveData() + + // Phase 6: Personality (traits, needs, relationships, commands) + entity.getPersonalitySystem().loadFromTag(tag); + + // Reward tracker (savior, rewards) + entity.getRewardTracker().load(tag); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/components/DamselDialogueHandler.java b/src/main/java/com/tiedup/remake/entities/damsel/components/DamselDialogueHandler.java new file mode 100644 index 0000000..2a6494b --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/components/DamselDialogueHandler.java @@ -0,0 +1,140 @@ +package com.tiedup.remake.entities.damsel.components; + +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.dialogue.EntityDialogueManager; +import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory; +import java.util.HashMap; +import java.util.Map; +import net.minecraft.world.entity.player.Player; + +/** + * Manages all dialogue-related systems for EntityDamsel: + * - Direct dialogue (talkTo, actionTo) + * - Radius-based dialogue (talkToPlayersInRadius, actionToPlayersInRadius) + * - Cooldown management for dialogue categories + * + * Phase 4: Extracted from EntityDamsel.java (~180 lines, 9 methods) + */ +public class DamselDialogueHandler { + + private final IDialogueHost host; + + /** Track last time each dialogue category was used (for cooldowns) */ + private final Map dialogueCooldowns = + new HashMap<>(); + + public DamselDialogueHandler(IDialogueHost host) { + this.host = host; + } + + // ======================================== + // DIRECT DIALOGUE + // ======================================== + + /** + * Send a direct message to a player. + * @param player Target player + * @param message Message to send + */ + public void talkTo(Player player, String message) { + EntityDialogueManager.talkTo(host.getEntity(), player, message); + } + + /** + * Send a dialogue message to a player using a category. + * @param player Target player + * @param category Dialogue category (selects random message) + */ + public void talkTo(Player player, DialogueCategory category) { + EntityDialogueManager.talkTo(host.getEntity(), player, category); + } + + /** + * Send an action message to a player (e.g. "*struggles*"). + * @param player Target player + * @param action Action description + */ + public void actionTo(Player player, String action) { + EntityDialogueManager.actionTo(host.getEntity(), player, action); + } + + /** + * Send an action message to a player using a category. + * @param player Target player + * @param category Action category + */ + public void actionTo(Player player, DialogueCategory category) { + EntityDialogueManager.actionTo(host.getEntity(), player, category); + } + + // ======================================== + // RADIUS-BASED DIALOGUE + // ======================================== + + /** + * Send a message to all players within radius. + * @param message Message to send + * @param radius Radius in blocks + */ + public void talkToPlayersInRadius(String message, int radius) { + EntityDialogueManager.talkToNearby(host.getEntity(), message, radius); + } + + /** + * Send a dialogue message to all players within radius using a category. + * @param category Dialogue category + * @param radius Radius in blocks + */ + public void talkToPlayersInRadius(DialogueCategory category, int radius) { + EntityDialogueManager.talkToNearby(host.getEntity(), category, radius); + } + + /** + * Send a dialogue message to nearby players with cooldown management. + * Only sends if cooldown has expired for this category. + * + * @param category Dialogue category + * @param radius Radius in blocks + * @return true if message was sent, false if on cooldown + */ + public boolean talkToPlayersInRadiusWithCooldown( + DialogueCategory category, + int radius + ) { + long currentTick = host.getGameTime(); + Long lastUsed = dialogueCooldowns.get(category); + + if ( + lastUsed != null && + (currentTick - lastUsed) < ModConfig.SERVER.dialogueCooldown.get() + ) { + return false; // Still on cooldown + } + + dialogueCooldowns.put(category, currentTick); + EntityDialogueManager.talkToNearby(host.getEntity(), category, radius); + return true; + } + + /** + * Send an action message to all players within radius. + * @param action Action description + * @param radius Radius in blocks + */ + public void actionToPlayersInRadius(String action, int radius) { + EntityDialogueManager.actionToNearby(host.getEntity(), action, radius); + } + + /** + * Send an action message to all players within radius using a category. + * @param category Action category + * @param radius Radius in blocks + */ + public void actionToPlayersInRadius(DialogueCategory category, int radius) { + EntityDialogueManager.actionToNearby( + host.getEntity(), + category, + radius + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/components/DamselInventoryManager.java b/src/main/java/com/tiedup/remake/entities/damsel/components/DamselInventoryManager.java new file mode 100644 index 0000000..933f12f --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/components/DamselInventoryManager.java @@ -0,0 +1,500 @@ +package com.tiedup.remake.entities.damsel.components; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.AbstractTiedUpNpc; +import com.tiedup.remake.personality.NpcNeeds; +import com.tiedup.remake.personality.PersonalityState; +import com.tiedup.remake.util.FoodEffects; +import net.minecraft.core.BlockPos; +import net.minecraft.core.NonNullList; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.Container; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.food.FoodProperties; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.ChestBlock; +import net.minecraft.world.level.block.entity.ChestBlockEntity; + +/** + * Manages inventory, equipment, and feeding for a Damsel NPC. + * Responsibilities: + * - NPC inventory (expandable 9/18/27 slots) + * - Armor inventory (4 slots) + * - Equipment slots (main hand) + * - Feeding system (auto-eat, player feeding) + * - MenuProvider (GUI access) + * + * Phase 2 of EntityDamsel refactoring (8 phases total). + */ +public class DamselInventoryManager { + + private final AbstractTiedUpNpc entity; + + /** + * NPC inventory for carrying items. + * Default 9 slots, expandable to 18 or 27 via upgrade. + */ + private NonNullList npcInventory; + + /** + * Current inventory size (9 default, 18 or 27 with upgrades). + */ + private int npcInventorySize; + + /** + * Armor inventory (4 slots: HEAD, CHEST, LEGS, FEET). + * Index 0 = HEAD, 1 = CHEST, 2 = LEGS, 3 = FEET. + */ + private final NonNullList armorInventory; + + public DamselInventoryManager(AbstractTiedUpNpc entity) { + this.entity = entity; + this.npcInventory = NonNullList.withSize(9, ItemStack.EMPTY); + this.npcInventorySize = 9; + this.armorInventory = NonNullList.withSize(4, ItemStack.EMPTY); + } + + // ======================================== + // EQUIPMENT SLOTS + // ======================================== + + /** + * Get item in equipment slot. + * Implements Mob.getItemBySlot(). + */ + public ItemStack getItemBySlot(EquipmentSlot slot) { + return switch (slot) { + case HEAD -> armorInventory.get(0); + case CHEST -> armorInventory.get(1); + case LEGS -> armorInventory.get(2); + case FEET -> armorInventory.get(3); + case MAINHAND -> this.entity.getEntityData().get( + AbstractTiedUpNpc.DATA_MAIN_HAND + ); + case OFFHAND -> ItemStack.EMPTY; + }; + } + + /** + * Set item in equipment slot. + * Implements Mob.setItemSlot(). + * Note: Entity should call verifyEquippedItem before delegating to this method. + */ + public void setItemSlot(EquipmentSlot slot, ItemStack stack) { + switch (slot) { + case HEAD -> armorInventory.set(0, stack); + case CHEST -> armorInventory.set(1, stack); + case LEGS -> armorInventory.set(2, stack); + case FEET -> armorInventory.set(3, stack); + case MAINHAND -> this.entity.getEntityData().set( + AbstractTiedUpNpc.DATA_MAIN_HAND, + stack + ); + case OFFHAND -> { + } // Not supported + } + } + + /** + * Get armor slots iterable. + * Implements Mob.getArmorSlots(). + */ + public Iterable getArmorSlots() { + return this.armorInventory; + } + + /** + * Get main hand item. + * Implements Mob.getMainHandItem(). + */ + public ItemStack getMainHandItem() { + return this.entity.getEntityData().get(AbstractTiedUpNpc.DATA_MAIN_HAND); + } + + /** + * Set the main hand item. + * + * @param stack Item to hold + */ + public void setMainHandItem(ItemStack stack) { + this.entity.getEntityData().set(AbstractTiedUpNpc.DATA_MAIN_HAND, stack); + } + + // ======================================== + // NPC INVENTORY + // ======================================== + + /** + * Get the NPC's carry inventory. + * + * @return NonNullList of ItemStacks + */ + public NonNullList getNpcInventory() { + return this.npcInventory; + } + + /** + * Get the current NPC inventory size. + * + * @return Inventory size (9, 18, or 27) + */ + public int getNpcInventorySize() { + return this.npcInventorySize; + } + + /** + * Set/upgrade the NPC inventory size. + * + * @param newSize New size (9, 18, or 27) + */ + public void setNpcInventorySize(int newSize) { + if (newSize != 9 && newSize != 18 && newSize != 27) { + TiedUpMod.LOGGER.warn( + "[DamselInventoryManager] Invalid inventory size: {}. Must be 9, 18, or 27.", + newSize + ); + return; + } + + if (newSize <= this.npcInventorySize) return; // Can't downgrade + + // Create new inventory preserving existing items + NonNullList newInventory = NonNullList.withSize( + newSize, + ItemStack.EMPTY + ); + for (int i = 0; i < this.npcInventory.size(); i++) { + newInventory.set(i, this.npcInventory.get(i)); + } + this.npcInventory = newInventory; + this.npcInventorySize = newSize; + } + + // ======================================== + // FEEDING SYSTEM + // ======================================== + + /** + * Check if the NPC has any edible item in their inventory. + * + * @return true if food is available + */ + public boolean hasEdibleInInventory() { + for (int i = 0; i < this.npcInventorySize; i++) { + ItemStack stack = this.npcInventory.get(i); + if ( + !stack.isEmpty() && stack.getItem().getFoodProperties() != null + ) { + return true; + } + } + return false; + } + + /** + * Try to eat food from inventory to satisfy hunger. + * Used by job systems and AI goals. + * + * @param personalityState Personality state for needs access + * @return true if food was consumed + */ + public boolean tryEatFromInventory(PersonalityState personalityState) { + if (personalityState == null) return false; + + NpcNeeds needs = personalityState.getNeeds(); + if (needs.getHunger() >= 80.0f) return false; // Not hungry enough + + // Find best food item + int bestSlot = -1; + int bestNutrition = 0; + + for (int i = 0; i < this.npcInventorySize; i++) { + ItemStack stack = this.npcInventory.get(i); + if (stack.isEmpty()) continue; + + FoodProperties food = stack.getItem().getFoodProperties(); + if (food != null && food.getNutrition() > bestNutrition) { + bestSlot = i; + bestNutrition = food.getNutrition(); + } + } + + if (bestSlot >= 0) { + ItemStack foodStack = this.npcInventory.get(bestSlot); + FoodProperties food = foodStack.getItem().getFoodProperties(); + + if (food != null) { + // Consume food + foodStack.shrink(1); + needs.feed(food.getNutrition()); + personalityState.modifyMood(2); + + // Play eating sound + this.entity.level().playSound( + null, + this.entity.blockPosition(), + SoundEvents.GENERIC_EAT, + SoundSource.NEUTRAL, + 0.5f, + 1.0f + ); + + return true; + } + } + + return false; + } + + /** + * Try to eat food from a container (chest) when inventory is empty. + * Used by job systems for autonomous feeding. + * + * @param chestPos Position of the chest to eat from + * @param personalityState Personality state for needs access + * @return true if food was consumed + */ + public boolean tryEatFromChest( + BlockPos chestPos, + PersonalityState personalityState + ) { + if (personalityState == null || chestPos == null) return false; + + NpcNeeds needs = personalityState.getNeeds(); + if (needs.getHunger() >= 80.0f) return false; + + // Get chest container + if ( + !(this.entity.level().getBlockEntity(chestPos) instanceof + ChestBlockEntity chest) + ) { + return false; + } + + Container container = ChestBlock.getContainer( + (ChestBlock) chest.getBlockState().getBlock(), + chest.getBlockState(), + this.entity.level(), + chestPos, + false + ); + + if (container == null) return false; + + // Find food in chest + for (int i = 0; i < container.getContainerSize(); i++) { + ItemStack stack = container.getItem(i); + if (stack.isEmpty()) continue; + + FoodProperties food = stack.getItem().getFoodProperties(); + if (food != null) { + // Consume food from chest + stack.shrink(1); + needs.feed(food.getNutrition()); + personalityState.modifyMood(2); + + // Play eating sound + this.entity.level().playSound( + null, + this.entity.blockPosition(), + SoundEvents.GENERIC_EAT, + SoundSource.NEUTRAL, + 0.5f, + 1.0f + ); + + return true; + } + } + + return false; + } + + /** + * Feed the NPC directly with a food item. + * Effects: restore hunger, boost mood, heal HP, increase affinity. + * + * @param player The player feeding + * @param foodStack The food item (will be consumed) + * @param personalityState Personality state for needs and relationship access + * @return true if feeding was successful + */ + public boolean feedByPlayer( + Player player, + ItemStack foodStack, + PersonalityState personalityState + ) { + if (foodStack.isEmpty()) return false; + if (this.entity.level().isClientSide) return false; + + FoodEffects.FeedResult result = FoodEffects.calculateFeedEffects( + foodStack + ); + if (result == null) return false; + if (personalityState == null) return false; + + // Restore hunger + personalityState.getNeeds().feed(result.hunger()); + + // Boost mood + personalityState.modifyMood(result.mood()); + + // Heal HP + this.entity.heal(result.heal()); + + // Consume the food item + foodStack.shrink(1); + + // Play eating sound + this.entity.playSound(SoundEvents.GENERIC_EAT, 0.5f, 1.0f); + + // Spawn heart particles + if (this.entity.level() instanceof ServerLevel serverLevel) { + serverLevel.sendParticles( + ParticleTypes.HEART, + this.entity.getX(), + this.entity.getY() + this.entity.getBbHeight(), + this.entity.getZ(), + 2, + 0.3, + 0.2, + 0.3, + 0.0 + ); + } + + // Trigger dialogue based on hunger state + String dialogueKey = personalityState.getNeeds().isStarving() + ? "action.feed.starving" + : "action.feed"; + // Cast safe: feedByPlayer is only called from EntityDamsel which IS an EntityDamsel + if (this.entity instanceof com.tiedup.remake.entities.EntityDamsel damsel) { + com.tiedup.remake.dialogue.EntityDialogueManager.talkByDialogueId( + damsel, + player, + dialogueKey + ); + } + + return true; + } + + // ======================================== + // MENU PROVIDER + // ======================================== + + /** + * Create inventory menu for player access. + * Implements MenuProvider.createMenu(). + * + * @param containerId Container ID + * @param playerInventory Player's inventory + * @param player The player opening the container + * @return NpcInventoryMenu instance + */ + public AbstractContainerMenu createMenu( + int containerId, + Inventory playerInventory, + Player player + ) { + // Cast safe: createMenu is only called from EntityDamsel (MenuProvider) + com.tiedup.remake.entities.EntityDamsel damsel = + (com.tiedup.remake.entities.EntityDamsel) this.entity; + return new com.tiedup.remake.entities.NpcInventoryMenu( + containerId, + playerInventory, + damsel + ); + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + /** + * Save inventory data to NBT tag. + * Writes directly to top-level keys for backward compatibility. + */ + public void saveToTag(CompoundTag tag) { + // NPC Inventory + tag.putInt("NpcInventorySize", this.npcInventorySize); + ListTag inventoryTag = new ListTag(); + for (int i = 0; i < this.npcInventory.size(); i++) { + ItemStack stack = this.npcInventory.get(i); + if (!stack.isEmpty()) { + CompoundTag slotTag = new CompoundTag(); + slotTag.putByte("Slot", (byte) i); + stack.save(slotTag); + inventoryTag.add(slotTag); + } + } + tag.put("NpcInventory", inventoryTag); + + // Equipment (Armor + Main Hand) + ListTag armorTag = new ListTag(); + for (int i = 0; i < 4; i++) { + ItemStack stack = this.armorInventory.get(i); + if (!stack.isEmpty()) { + CompoundTag slotTag = new CompoundTag(); + slotTag.putByte("Slot", (byte) i); + stack.save(slotTag); + armorTag.add(slotTag); + } + } + tag.put("ArmorInventory", armorTag); + if (!this.getMainHandItem().isEmpty()) { + tag.put( + "MainHandItem", + this.getMainHandItem().save(new CompoundTag()) + ); + } + } + + /** + * Load inventory data from NBT tag. + * Reads from top-level keys for backward compatibility. + */ + public void loadFromTag(CompoundTag tag) { + // Restore NPC Inventory + if (tag.contains("NpcInventorySize")) { + this.npcInventorySize = tag.getInt("NpcInventorySize"); + this.npcInventory = NonNullList.withSize( + this.npcInventorySize, + ItemStack.EMPTY + ); + } + if (tag.contains("NpcInventory")) { + ListTag inventoryTag = tag.getList("NpcInventory", 10); + for (int i = 0; i < inventoryTag.size(); i++) { + CompoundTag slotTag = inventoryTag.getCompound(i); + int slot = slotTag.getByte("Slot") & 255; + if (slot < this.npcInventory.size()) { + this.npcInventory.set(slot, ItemStack.of(slotTag)); + } + } + } + + // Restore Equipment (Armor + Main Hand) + if (tag.contains("ArmorInventory")) { + ListTag armorTag = tag.getList("ArmorInventory", 10); + for (int i = 0; i < armorTag.size(); i++) { + CompoundTag slotTag = armorTag.getCompound(i); + int slot = slotTag.getByte("Slot") & 255; + if (slot < 4) { + this.armorInventory.set(slot, ItemStack.of(slotTag)); + } + } + } + if (tag.contains("MainHandItem")) { + this.setMainHandItem(ItemStack.of(tag.getCompound("MainHandItem"))); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/components/DamselPersonalitySystem.java b/src/main/java/com/tiedup/remake/entities/damsel/components/DamselPersonalitySystem.java new file mode 100644 index 0000000..a94c18b --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/components/DamselPersonalitySystem.java @@ -0,0 +1,673 @@ +package com.tiedup.remake.entities.damsel.components; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.dialogue.EntityDialogueManager; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.personality.*; +import java.util.*; +import org.jetbrains.annotations.Nullable; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.entity.ai.attributes.AttributeInstance; +import net.minecraft.world.entity.ai.attributes.AttributeModifier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * Manages all personality-related systems for EntityDamsel: + * - PersonalityState (personality type, training, commands, needs, relationships) + * - Dialogue systems (idle, approach, environment, needs) + * - Rest/fatigue modifiers + * - Command execution + * + * Phase 6: Extracted from EntityDamsel.java (~700 lines, 22 methods) + */ +public class DamselPersonalitySystem { + + private final com.tiedup.remake.entities.EntityDamsel entity; + private final IPersonalityTickContext context; + + // ======================================== + // DIALOGUE SYSTEM CONSTANTS + // ======================================== + + /** Minimum cooldown between idle dialogues (4 minutes). */ + private static final int IDLE_DIALOGUE_COOLDOWN_MIN = 4800; + + /** Maximum cooldown between idle dialogues (10 minutes). */ + private static final int IDLE_DIALOGUE_COOLDOWN_MAX = 12000; + + /** Cooldown per player for approach detection (2 minutes). */ + private static final int APPROACH_COOLDOWN = 2400; + + /** Cooldown for environmental dialogue (6 minutes). */ + private static final int ENVIRONMENT_DIALOGUE_COOLDOWN = 7200; + + /** Approach detection radius (blocks). */ + private static final double APPROACH_RADIUS = 5.0; + + // ======================================== + // REST SYSTEM CONSTANTS + // ======================================== + + /** UUID for rest-based speed modifier. */ + private static final UUID REST_SPEED_MODIFIER_UUID = UUID.fromString( + "8f4c8c9e-0e5f-5d8b-9f60-2b3c4d5e6f7a" + ); + + /** UUID for rest-based damage modifier. */ + private static final UUID REST_DAMAGE_MODIFIER_UUID = UUID.fromString( + "9a5d9d0f-1f6a-6e9c-0a70-3c4d5e6f7a8b" + ); + + /** Ticks between rest modifier updates (1 second). */ + private static final int REST_MODIFIER_UPDATE_INTERVAL = 20; + + // ======================================== + // PERSONALITY STATE + // ======================================== + + /** + * Complete personality state including: + * - Personality type (TIMID, FIERCE, etc.) + * - Training level (WILD → DEVOTED) + * - Secondary traits (TRAINED, DEVOTED, SUBJUGATED, etc.) + * - Needs (hunger, comfort, rest, dignity) + * - Relationships (per-player affinity, trust, fear, respect) + * - Active command (FOLLOW, STAY, PATROL, etc.) + * + * Lazy-initialized on first access (server-side only). + */ + @Nullable + private PersonalityState personalityState; + + // ======================================== + // DIALOGUE COOLDOWNS + // ======================================== + + /** Cooldown for idle dialogue (in ticks). */ + private int idleDialogueCooldown = 0; + + /** Tracks cooldown for player approach reactions. */ + private final Map approachCooldowns = new HashMap<>(); + + /** Tracks which players were nearby last tick. */ + private final Set playersNearbyLastTick = new HashSet<>(); + + /** Throttle for approach detection - only scan every 10 ticks (0.5 sec). */ + private int approachCheckCooldown = 0; + + /** Cooldown for environment dialogue. */ + private int environmentDialogueCooldown = 0; + + /** Track last time each dialogue category was used */ + private final Map< + EntityDialogueManager.DialogueCategory, + Long + > dialogueCooldowns = new HashMap<>(); + + // ======================================== + // REST SYSTEM MODIFIERS + // ======================================== + + /** Last applied speed modifier from rest system */ + private float lastRestSpeedMod = 0.0f; + + /** Last applied damage modifier from rest system */ + private float lastRestDamageMod = 0.0f; + + // ======================================== + // ANTI-FLEE SYSTEM + // ======================================== + + /** + * World time when last whipped. + * Used to prevent fleeing for a short duration after discipline. + */ + private long lastWhipTime = 0; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public DamselPersonalitySystem( + com.tiedup.remake.entities.EntityDamsel entity, + IPersonalityTickContext context + ) { + this.entity = entity; + this.context = context; + } + + // ======================================== + // INITIALIZATION & SYNC + // ======================================== + + /** + * Initialize personality state with random generation. + * Called on first spawn or lazy init. + */ + public void initializePersonality() { + // Generate random personality for Damsel + this.personalityState = PersonalityState.generateForDamsel( + entity.getUUID() + ); + + // Sync to client + syncPersonalityData(); + + TiedUpMod.LOGGER.debug( + "[EntityDamsel] {} initialized with personality: {}", + context.getAppearance().getNpcName(), + this.personalityState.getPersonality().name() + ); + } + + /** + * Sync personality data to clients via EntityDataAccessors. + * Called after personality changes. + */ + public void syncPersonalityData() { + if (this.personalityState != null) { + entity + .getEntityData() + .set( + com.tiedup.remake.entities.EntityDamsel.DATA_PERSONALITY_TYPE, + this.personalityState.getPersonality().name() + ); + entity + .getEntityData() + .set( + com.tiedup.remake.entities.EntityDamsel.DATA_ACTIVE_COMMAND, + this.personalityState.getActiveCommand().name() + ); + } + } + + // ======================================== + // TICK LOGIC + // ======================================== + + /** + * Main personality tick - delegates to PersonalityState and handles side effects. + * Call this from EntityDamsel.aiStep(). + */ + public void tickPersonality() { + if (this.personalityState == null) return; + + // Get current master UUID from collar (if any) + UUID masterUUID = null; + if (context.hasCollar()) { + ItemStack collar = context.getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemCollar collarItem) { + List owners = collarItem.getOwners(collar); + if (!owners.isEmpty()) { + masterUUID = owners.get(0); + } + } + } + + // Check if it's night (for rest decay) + boolean isNight = !context.level().isDay(); + + // Delegate tick to PersonalityState (handles needs, mood, relationships, leash effects) + var transitions = this.personalityState.tick( + entity, + isNight, + masterUUID, + context.getBondageManager().isCaptive() + ); + + // Hunger is a player→NPC feature only; keep hunger full for unowned NPCs + // (prevents camp kidnappers/traders/maids from starving and complaining) + if (masterUUID == null) { + this.personalityState.getNeeds().setHunger(NpcNeeds.MAX_VALUE); + } + + // Trigger dialogues for need transitions (if not gagged) + if (transitions.hasAny() && !context.getBondageManager().isGagged()) { + triggerNeedDialogue(transitions); + } + + // Update rest-based attribute modifiers (every 20 ticks) + if (context.getTickCount() % 20 == 0) { + updateRestModifiers(); + } + } + + /** + * Trigger dialogue when a need crosses a threshold. + * Priority: critical states (starving, exhausted) > regular (hungry, tired, etc.) + * + * @param transitions The need transitions that occurred this tick + */ + private void triggerNeedDialogue(NpcNeeds.NeedTransitions transitions) { + // Find closest player to speak to + Player nearestPlayer = context.level().getNearestPlayer(entity, 16); + if (nearestPlayer == null) return; + + // Priority order: critical > regular + String dialogueId = null; + + // Critical states first (use regular IDs for now, context makes them urgent) + if (transitions.becameStarving) { + dialogueId = "needs.starving"; + } else if (transitions.becameTired) { + dialogueId = "needs.dignity_low"; + } + // Regular states + else if (transitions.becameHungry) { + dialogueId = "needs.hungry"; + } + + if (dialogueId != null) { + EntityDialogueManager.talkByDialogueId( + entity, + nearestPlayer, + dialogueId + ); + } + } + + /** + * Update attribute modifiers based on rest level. + * Tired NPCs move slower and deal less damage. + * Called every REST_MODIFIER_UPDATE_INTERVAL ticks. + */ + private void updateRestModifiers() { + if (this.personalityState == null) return; + + NpcNeeds needs = this.personalityState.getNeeds(); + float rest = needs.getRest(); + + // Calculate modifiers based on rest level + float speedMod = 0.0f; + float damageMod = 0.0f; + + if (rest < 25) { + // Exhausted + speedMod = -0.3f; // -30% speed + damageMod = -0.4f; // -40% damage + } else if (rest < 50) { + // Tired + speedMod = -0.15f; // -15% speed + damageMod = -0.2f; // -20% damage + } + + // Only update if changed (avoid spamming attribute system) + if (speedMod != lastRestSpeedMod) { + AttributeInstance speedAttr = entity.getAttribute( + Attributes.MOVEMENT_SPEED + ); + if (speedAttr != null) { + speedAttr.removeModifier(REST_SPEED_MODIFIER_UUID); + if (speedMod != 0) { + speedAttr.addTransientModifier( + new AttributeModifier( + REST_SPEED_MODIFIER_UUID, + "Rest fatigue", + speedMod, + AttributeModifier.Operation.MULTIPLY_TOTAL + ) + ); + } + } + lastRestSpeedMod = speedMod; + } + + if (damageMod != lastRestDamageMod) { + AttributeInstance damageAttr = entity.getAttribute( + Attributes.ATTACK_DAMAGE + ); + if (damageAttr != null) { + damageAttr.removeModifier(REST_DAMAGE_MODIFIER_UUID); + if (damageMod != 0) { + damageAttr.addTransientModifier( + new AttributeModifier( + REST_DAMAGE_MODIFIER_UUID, + "Rest fatigue", + damageMod, + AttributeModifier.Operation.MULTIPLY_TOTAL + ) + ); + } + } + lastRestDamageMod = damageMod; + } + } + + // ======================================== + // DIALOGUE SYSTEMS + // ======================================== + + /** + * Tick idle dialogue system (random chatter). + * Call this from EntityDamsel.aiStep(). + */ + public void tickIdleDialogue() { + // Decrement cooldown + if (this.idleDialogueCooldown > 0) { + this.idleDialogueCooldown--; + return; + } + + // Random chance to speak FIRST (~1 in 1000 per tick = once per ~50 seconds on average) + // Check this before expensive getNearestPlayer() call - saves 99.9% of lookups + if (entity.getRandom().nextFloat() > 0.001f) { + return; + } + + // Don't speak if gagged + if (context.getBondageManager().isGagged()) { + return; + } + + // Don't speak if following a command (busy) + if ( + this.personalityState != null && + this.personalityState.getActiveCommand() != NpcCommand.NONE + ) { + return; + } + + // Find a player to talk to + Player nearestPlayer = context.level().getNearestPlayer(entity, 16); + if (nearestPlayer == null) { + return; + } + + // Select dialogue based on state + String dialogueId = selectIdleDialogueId(); + if (dialogueId != null) { + EntityDialogueManager.talkByDialogueId( + entity, + nearestPlayer, + dialogueId + ); + // Reset cooldown (4-10 minutes) + this.idleDialogueCooldown = + IDLE_DIALOGUE_COOLDOWN_MIN + + entity + .getRandom() + .nextInt( + IDLE_DIALOGUE_COOLDOWN_MAX - IDLE_DIALOGUE_COOLDOWN_MIN + ); + } + } + + /** + * Select appropriate idle dialogue ID based on current state. + * Priority: needs > mood > generic idle. + * + * @return Dialogue ID or null + */ + @Nullable + private String selectIdleDialogueId() { + if (this.personalityState == null) { + return "idle.free"; + } + + NpcNeeds needs = this.personalityState.getNeeds(); + + // Priority 1: Critical needs + if (needs.isStarving()) return "needs.starving"; + + // Priority 2: Regular needs + if (needs.isHungry()) return "needs.hungry"; + if (needs.isTired()) return "needs.dignity_low"; + + // Priority 3: Mood-based (if very low) + float mood = this.personalityState.getMood(); + if (mood < 10) return "mood.miserable"; + if (mood < 40) return "mood.sad"; + + // Priority 4: State-based idle + if (context.getBondageManager().isTiedUp()) { + return "idle.captive"; + } + + return "idle.free"; + } + + /** + * Detect when players approach and trigger reaction dialogue. + * Uses per-player cooldowns to avoid spam. + * Throttled to every 10 ticks (0.5 sec) to reduce entity search overhead. + * Call this from EntityDamsel.aiStep(). + */ + public void tickApproachDetection() { + // Throttle approach detection - only scan every 10 ticks (0.5 sec) + if (--this.approachCheckCooldown > 0) { + return; + } + this.approachCheckCooldown = 10; + + // Decrement all cooldowns + approachCooldowns + .entrySet() + .removeIf(e -> { + int newVal = e.getValue() - 10; // Decrement by 10 since we only check every 10 ticks + if (newVal <= 0) return true; + e.setValue(newVal); + return false; + }); + + // Don't react if gagged + if (context.getBondageManager().isGagged()) { + playersNearbyLastTick.clear(); + return; + } + + // Find nearby players + Set currentNearby = new HashSet<>(); + List nearbyPlayers = context + .level() + .getEntitiesOfClass( + Player.class, + entity.getBoundingBox().inflate(APPROACH_RADIUS), + p -> p.isAlive() && entity.distanceTo(p) <= APPROACH_RADIUS + ); + + for (Player player : nearbyPlayers) { + UUID uuid = player.getUUID(); + currentNearby.add(uuid); + + // Skip if player was already nearby last tick + if (playersNearbyLastTick.contains(uuid)) { + continue; + } + + // Skip if on cooldown + if (approachCooldowns.containsKey(uuid)) { + continue; + } + + // Player just approached - trigger reaction + onPlayerApproach(player); + approachCooldowns.put(uuid, APPROACH_COOLDOWN); + } + + // Update tracking set + playersNearbyLastTick.clear(); + playersNearbyLastTick.addAll(currentNearby); + } + + /** + * Called when a player approaches (enters APPROACH_RADIUS). + * Triggers appropriate dialogue based on relationship. + */ + private void onPlayerApproach(Player player) { + if (this.personalityState == null) return; + + // Use DialogueTriggerSystem to select appropriate approach dialogue + String dialogueId = + com.tiedup.remake.dialogue.DialogueTriggerSystem.selectApproachDialogue( + entity, + player + ); + + if (dialogueId != null) { + EntityDialogueManager.talkByDialogueId(entity, player, dialogueId); + } + } + + /** + * Tick environment dialogue (comments on weather/time). + * Call this from EntityDamsel.aiStep(). + */ + public void tickEnvironmentDialogue() { + // Decrement cooldown + if (this.environmentDialogueCooldown > 0) { + this.environmentDialogueCooldown--; + return; + } + + // Random chance (~1 in 2000 per tick) + if (entity.getRandom().nextFloat() > 0.0005f) { + return; + } + + // Don't speak if gagged + if (context.getBondageManager().isGagged()) { + return; + } + + // Find a player to talk to + Player nearestPlayer = context.level().getNearestPlayer(entity, 16); + if (nearestPlayer == null) { + return; + } + + // Use DialogueTriggerSystem to select environment dialogue + String dialogueId = + com.tiedup.remake.dialogue.DialogueTriggerSystem.selectEnvironmentDialogue( + entity + ); + + if (dialogueId != null) { + EntityDialogueManager.talkByDialogueId( + entity, + nearestPlayer, + dialogueId + ); + this.environmentDialogueCooldown = ENVIRONMENT_DIALOGUE_COOLDOWN; + } + } + + // ======================================== + // PUBLIC PERSONALITY API + // ======================================== + + /** + * Get the personality state (lazy-init on server side). + * @return PersonalityState instance, or null on client + */ + @Nullable + public PersonalityState getPersonalityState() { + // Lazy init on server side + if (this.personalityState == null && !context.level().isClientSide) { + initializePersonality(); + } + return this.personalityState; + } + + /** + * Get the personality type (client-safe via synced data). + * @return PersonalityType enum value + */ + public PersonalityType getPersonalityType() { + String typeName = entity + .getEntityData() + .get(com.tiedup.remake.entities.EntityDamsel.DATA_PERSONALITY_TYPE); + try { + return PersonalityType.valueOf(typeName); + } catch (IllegalArgumentException e) { + return PersonalityType.CALM; // Default fallback + } + } + + /** + * Set the personality type (debug/testing only). + * Creates a new PersonalityState with the given type, preserving training XP. + * + * @param newType The new personality type + */ + public void setPersonalityType(PersonalityType newType) { + if (context.level().isClientSide) return; + + // Create new state with new personality + this.personalityState = new PersonalityState(newType); + + // Sync to clients + syncPersonalityData(); + + TiedUpMod.LOGGER.info( + "[EntityDamsel] {} personality set to: {}", + context.getAppearance().getNpcName(), + newType.name() + ); + } + + /** + * Get the active command (client-safe via synced data). + * @return NpcCommand enum value + */ + public NpcCommand getActiveCommand() { + String cmdName = entity + .getEntityData() + .get(com.tiedup.remake.entities.EntityDamsel.DATA_ACTIVE_COMMAND); + return NpcCommand.fromString(cmdName); + } + + /** + * Check if recently whipped. + * @return true if whipped within last 1200 ticks (1 minute) + */ + public boolean wasRecentlyWhipped() { + return (context.level().getGameTime() - lastWhipTime) < 1200; + } + + /** + * Get the world time when this NPC was last whipped. + * @return World time of last whip, or 0 if never whipped + */ + public long getLastWhipTime() { + return this.lastWhipTime; + } + + /** + * Set the world time when this NPC was whipped. + * @param time Current world game time + */ + public void setLastWhipTime(long time) { + this.lastWhipTime = time; + } + + // ======================================== + // NBT PERSISTENCE + // ======================================== + + /** + * Save personality data to NBT. + * @param tag The compound tag to save to + */ + public void saveToTag(CompoundTag tag) { + if (this.personalityState != null) { + tag.put("Personality", this.personalityState.save()); + } + } + + /** + * Load personality data from NBT. + * @param tag The compound tag to load from + */ + public void loadFromTag(CompoundTag tag) { + if (tag.contains("Personality")) { + this.personalityState = PersonalityState.load( + tag.getCompound("Personality") + ); + // CRITICAL: Must sync to client immediately after load + syncPersonalityData(); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/components/IAIHost.java b/src/main/java/com/tiedup/remake/entities/damsel/components/IAIHost.java new file mode 100644 index 0000000..6ebf90c --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/components/IAIHost.java @@ -0,0 +1,136 @@ +package com.tiedup.remake.entities.damsel.components; + +import java.util.UUID; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; + +/** + * Interface for AI controller callbacks to EntityDamsel. + * Provides access to entity state and methods needed by AI systems. + * + * Phase 5: Created for DamselAIController component. + */ +public interface IAIHost { + // ======================================== + // BASIC ENTITY ACCESS + // ======================================== + + /** + * Get the entity's level/world. + */ + Level level(); + + /** + * Get the entity's UUID. + */ + UUID getUUID(); + + /** + * Get the entity's display name. + */ + String getNpcName(); + + /** + * Get the entity's random instance. + */ + net.minecraft.util.RandomSource getRandom(); + + /** + * Get the entity's bounding box. + */ + AABB getBoundingBox(); + + // ======================================== + // LEASH SYSTEM ACCESS + // ======================================== + + /** + * Check if entity is leashed. + */ + boolean isLeashed(); + + /** + * Get the entity holding the leash. + */ + Entity getLeashHolder(); + + /** + * Get current movement delta. + */ + Vec3 getDeltaMovement(); + + /** + * Set movement delta. + */ + void setDeltaMovement(Vec3 motion); + + /** + * Get max step height. + */ + float maxUpStep(); + + /** + * Set max step height. + */ + void setMaxUpStep(float height); + + /** + * Teleport entity to coordinates. + */ + void teleportTo(double x, double y, double z); + + /** + * Calculate distance to another entity. + */ + float distanceTo(Entity other); + + /** + * Get entity X position. + */ + double getX(); + + /** + * Get entity Y position. + */ + double getY(); + + /** + * Get entity Z position. + */ + double getZ(); + + // ======================================== + // COMPONENT ACCESS + // ======================================== + + /** + * Get bondage manager component. + */ + DamselBondageManager getBondageManager(); + + /** + * Get personality system component. + */ + DamselPersonalitySystem getPersonalitySystem(); + + // ======================================== + // BONDAGE STATE QUERIES (delegated) + // ======================================== + + /** + * Check if entity is tied up. + */ + boolean isTiedUp(); + + /** + * Check if entity is gagged. + */ + boolean isGagged(); + + /** + * Check if entity is a captive. + */ + boolean isCaptive(); +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/components/IAnimationHost.java b/src/main/java/com/tiedup/remake/entities/damsel/components/IAnimationHost.java new file mode 100644 index 0000000..4320896 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/components/IAnimationHost.java @@ -0,0 +1,84 @@ +package com.tiedup.remake.entities.damsel.components; + +import net.minecraft.world.level.Level; + +/** + * Interface for animation controller callbacks to EntityDamsel. + * Provides access to entity state and methods needed by animation systems. + * + * Phase 3: Created for DamselAnimationController component. + */ +public interface IAnimationHost { + // ======================================== + // LEVEL ACCESS + // ======================================== + + /** + * Get the entity's level/world. + */ + Level level(); + + // ======================================== + // BODY ROTATION ACCESS + // ======================================== + + /** + * Get current body rotation Y. + */ + float getYBodyRot(); + + /** + * Set body rotation Y. + */ + void setYBodyRot(float rot); + + /** + * Get previous body rotation Y. + */ + float getYBodyRotO(); + + /** + * Set previous body rotation Y. + */ + void setYBodyRotO(float rot); + + // ======================================== + // POSE STATE QUERIES + // ======================================== + + /** + * Check if entity is in "dog pose" (hogtied with armbinder). + * This is determined by bondage manager. + */ + boolean isDogPose(); + + /** + * Check if entity is sitting (from synced entity data). + */ + boolean isSittingFromData(); + + /** + * Set sitting state (to synced entity data). + */ + void setSittingToData(boolean sitting); + + /** + * Check if entity is kneeling (from synced entity data). + */ + boolean isKneelingFromData(); + + /** + * Set kneeling state (to synced entity data). + */ + void setKneelingToData(boolean kneeling); + + /** + * Check if entity is struggling (from synced entity data). + */ + boolean isStrugglingFromData(); + + /** + * Set struggling state (to synced entity data). + */ + void setStrugglingToData(boolean struggling); +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/components/IBondageHost.java b/src/main/java/com/tiedup/remake/entities/damsel/components/IBondageHost.java new file mode 100644 index 0000000..c808bbe --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/components/IBondageHost.java @@ -0,0 +1,88 @@ +package com.tiedup.remake.entities.damsel.components; + +import com.tiedup.remake.personality.PersonalityState; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * Interface for EntityDamsel to provide callbacks to DamselBondageManager. + * This prevents circular dependencies while allowing the bondage component + * to access necessary host entity functionality. + * + * Phase 7: Created for DamselBondageManager extraction. + */ +public interface IBondageHost { + /** + * Get the personality state for conditioning/training logic. + * @return personality state instance + */ + PersonalityState getPersonalityState(); + + /** + * Get the inventory manager for equipment access. + * @return inventory manager instance + */ + com.tiedup.remake.entities.damsel.components.DamselInventoryManager getInventory(); + + /** + * Drop an item stack at this entity's location. + * @param stack item to drop + */ + void dropItemStack(ItemStack stack); + + /** + * Play a sound at this entity's location. + * @param sound sound event to play + */ + void playSound(SoundEvent sound); + + /** + * Set the entity's health. + * @param health new health value + */ + void setHealth(float health); + + /** + * Remove this entity from the world. + * @param reason removal reason + */ + void remove(Entity.RemovalReason reason); + + /** + * Get the level this entity is in. + * @return level instance + */ + Level level(); + + /** + * Get the block position of this entity. + * @return block position + */ + BlockPos blockPosition(); + + /** + * Get the UUID of this entity. + * @return entity UUID + */ + UUID getUUID(); + + /** + * Get the damsel's name for logging. + * @return damsel name + */ + String getNpcName(); + + /** + * Broadcast dialogue to nearby players. + * @param category dialogue category + * @param radius search radius + */ + void talkToPlayersInRadius( + com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory category, + int radius + ); +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/components/IDialogueHost.java b/src/main/java/com/tiedup/remake/entities/damsel/components/IDialogueHost.java new file mode 100644 index 0000000..28eac52 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/components/IDialogueHost.java @@ -0,0 +1,39 @@ +package com.tiedup.remake.entities.damsel.components; + +import java.util.List; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; + +/** + * Interface for dialogue handler callbacks to EntityDamsel. + * Provides access to entity state and methods needed by dialogue systems. + * + * Phase 4: Created for DamselDialogueHandler component. + */ +public interface IDialogueHost { + /** + * Get the entity's level/world. + */ + Level level(); + + /** + * Get the entity's bounding box for radius detection. + */ + AABB getBoundingBox(); + + /** + * Get the entity itself for dialogue source. + */ + com.tiedup.remake.entities.EntityDamsel getEntity(); + + /** + * Get current game time for cooldown tracking. + */ + long getGameTime(); + + /** + * Find nearby players within radius. + */ + List findNearbyPlayers(double radius); +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/components/IPersonalityTickContext.java b/src/main/java/com/tiedup/remake/entities/damsel/components/IPersonalityTickContext.java new file mode 100644 index 0000000..30ecca0 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/components/IPersonalityTickContext.java @@ -0,0 +1,113 @@ +package com.tiedup.remake.entities.damsel.components; + +import com.tiedup.remake.v2.BodyRegionV2; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +/** + * Interface for EntityDamsel to provide callbacks to DamselPersonalitySystem. + * This prevents circular dependencies while allowing the personality component + * to access necessary host entity functionality. + * + * Phase 6: Created for DamselPersonalitySystem extraction. + */ +public interface IPersonalityTickContext { + /** + * Get the level this entity is in. + * @return level instance + */ + Level level(); + + /** + * Get the current tick count for this entity. + * @return tick count + */ + int getTickCount(); + + /** + * Check if this entity is currently leashed. + * @return true if leashed + */ + boolean isLeashed(); + + /** + * Get the entity's current movement delta. + * @return movement vector + */ + Vec3 getDeltaMovement(); + + /** + * Check if this entity has a collar equipped. + * @return true if collar present + */ + boolean hasCollar(); + + /** + * Get the item equipped in a V2 body region. + * @param region The body region to query + * @return The equipped ItemStack, or empty if not present + */ + ItemStack getEquipment(BodyRegionV2 region); + + /** + * Get the bondage manager for checking bondage state. + * @return bondage manager instance + */ + DamselBondageManager getBondageManager(); + + /** + * Get the inventory manager for feeding logic. + * @return inventory manager instance + */ + DamselInventoryManager getInventoryManager(); + + /** + * Get the appearance manager for name access. + * @return appearance instance + */ + DamselAppearance getAppearance(); + + /** + * Get the entity's UUID. + * @return entity UUID + */ + UUID getUUID(); + + /** + * Get the block position of this entity. + * @return block position + */ + BlockPos blockPosition(); + + /** + * Stop the entity's navigation. + */ + void stopNavigation(); + + /** + * Get the entity's leash holder entity. + * @return leash holder, or null if not leashed + */ + net.minecraft.world.entity.Entity getLeashHolder(); + + /** + * Talk to nearby players with a dialogue message. + * @param category dialogue category + * @param radius search radius + */ + void talkToPlayersInRadius( + com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory category, + int radius + ); + + /** + * Talk directly to a specific player. + * @param player target player + * @param message message text + */ + void talkTo(Player player, String message); +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/components/NpcCaptivityManager.java b/src/main/java/com/tiedup/remake/entities/damsel/components/NpcCaptivityManager.java new file mode 100644 index 0000000..05af612 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/components/NpcCaptivityManager.java @@ -0,0 +1,305 @@ +package com.tiedup.remake.entities.damsel.components; + +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.AbstractTiedUpNpc; +import com.tiedup.remake.state.ICaptor; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.util.RestraintEffectUtils; +import java.util.UUID; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; + +/** + * Captivity lifecycle for NPCs: capture, free, transfer, leash, captor restoration. + * + *

Extracted from DamselBondageManager (H9 split). Owns the captor reference + * and pending UUID for restoration after world load.

+ */ +public class NpcCaptivityManager { + + // ======================================== + // FIELDS + // ======================================== + + private final AbstractTiedUpNpc entity; + private final IBondageHost host; + private final NpcEquipmentManager equipment; + + /** Current captor (if captured) */ + private ICaptor captor; + + /** Pending captor UUID for restoration after world load */ + private UUID pendingCaptorUUID; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public NpcCaptivityManager( + AbstractTiedUpNpc entity, + IBondageHost host, + NpcEquipmentManager equipment + ) { + this.entity = entity; + this.host = host; + this.equipment = equipment; + } + + // ======================================== + // CAPTIVITY STATE + // ======================================== + + public boolean isCaptive() { + return entity.isLeashed(); + } + + public boolean isEnslavable() { + if (entity.isLeashed()) return false; + return equipment.isTiedUp(); + } + + public ICaptor getCaptor() { + return captor; + } + + /** + * Clear the captor reference without triggering free() or untie(). + * Used when player detaches leash -- only clears the internal reference. + */ + public void clearCaptor() { + this.captor = null; + } + + // ======================================== + // CAPTURE LIFECYCLE + // ======================================== + + public boolean getCapturedBy(ICaptor newCaptor) { + if (newCaptor == null || !isEnslavable()) { + return false; + } + + if (!newCaptor.canCapture(this.asFacadeKidnapped())) { + return false; + } + + // Use vanilla leash mechanic + Entity captorEntity = newCaptor.getEntity(); + if (captorEntity != null) { + entity.setLeashedTo(captorEntity, true); + this.captor = newCaptor; + newCaptor.addCaptive(entity); + + TiedUpMod.LOGGER.info( + "[EntityDamsel] {} captured by {}", + host.getNpcName(), + captorEntity.getName().getString() + ); + return true; + } + + return false; + } + + /** + * Force capture for managed camp operations (dogwalk, extract). + * Bypasses canCapture() PrisonerManager state check since the + * caller is responsible for managing state transitions. + */ + public boolean forceCapturedBy(ICaptor newCaptor) { + if (newCaptor == null || !isEnslavable()) { + return false; + } + // Skip canCapture() - caller manages PrisonerManager state + Entity captorEntity = newCaptor.getEntity(); + if (captorEntity != null) { + entity.setLeashedTo(captorEntity, true); + this.captor = newCaptor; + newCaptor.addCaptive(entity); + TiedUpMod.LOGGER.info( + "[EntityDamsel] {} force-captured by {} (managed op)", + host.getNpcName(), + captorEntity.getName().getString() + ); + return true; + } + return false; + } + + public void free() { + free(true); + } + + public void free(boolean transportState) { + if (!isCaptive()) return; + + if (transportState) { + TiedUpMod.LOGGER.info( + "[EntityDamsel] {} freed from captivity", + host.getNpcName() + ); + + // Broadcast freed dialogue + if (!entity.level().isClientSide()) { + host.talkToPlayersInRadius( + com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory.DAMSEL_FREED, + ModConfig.SERVER.dialogueRadius.get() + ); + } + } else { + TiedUpMod.LOGGER.debug( + "[EntityDamsel] {} transferred (leash dropped)", + host.getNpcName() + ); + } + + // Drop leash (only drop item on true freedom, not internal transfers like imprisonment) + entity.dropLeash(true, transportState); + + // Clear captor + if (captor != null) { + captor.removeCaptive(entity, transportState); + captor = null; + } + } + + public void transferCaptivityTo(ICaptor newCaptor) { + if (!isCaptive()) return; + + ICaptor currentCaptor = getCaptor(); + if (currentCaptor == null) return; + + if (!currentCaptor.allowCaptiveTransfer()) { + TiedUpMod.LOGGER.warn( + "[EntityDamsel] {} transfer blocked by current captor", + host.getNpcName() + ); + return; + } + + // Free from current captor (keep transport entity) + free(false); + + // Capture by new captor + getCapturedBy(newCaptor); + + TiedUpMod.LOGGER.info( + "[EntityDamsel] {} transferred to new captor", + host.getNpcName() + ); + } + + // ======================================== + // POLE / LEASH + // ======================================== + + public boolean isTiedToPole() { + Entity leashHolder = entity.getLeashHolder(); + return ( + leashHolder instanceof + net.minecraft.world.entity.decoration.LeashFenceKnotEntity + ); + } + + public boolean tieToClosestPole(int searchRadius) { + return RestraintEffectUtils.tieToClosestPole(entity, searchRadius); + } + + public boolean canBeKidnappedByEvents() { + return !isCaptive() && !equipment.hasCollar(); + } + + // ======================================== + // CAPTOR RESTORATION + // ======================================== + + /** + * Attempt to restore captor from pending UUID. + * Call this from EntityDamsel.tick() until successful. + */ + public void restoreCaptorFromUUID() { + if (pendingCaptorUUID == null) return; + + // Search for entity with matching UUID + for (Entity searchEntity : entity + .level() + .getEntities(entity, entity.getBoundingBox().inflate(64))) { + if (searchEntity.getUUID().equals(pendingCaptorUUID)) { + if (searchEntity instanceof ICaptor kidnapper) { + captor = kidnapper; + kidnapper.addCaptive(entity); + TiedUpMod.LOGGER.info( + "[EntityDamsel] {} restored captor relationship with {}", + host.getNpcName(), + searchEntity.getName().getString() + ); + } + pendingCaptorUUID = null; + return; + } + } + + // Also check players (PlayerBindState.getCaptorManager() is ICaptor) + for (net.minecraft.world.entity.player.Player player : entity + .level() + .players()) { + if (player.getUUID().equals(pendingCaptorUUID)) { + PlayerBindState bindState = + PlayerBindState.getInstance(player); + if (bindState != null) { + ICaptor kidnapper = bindState.getCaptorManager(); + if (kidnapper != null) { + captor = kidnapper; + kidnapper.addCaptive(entity); + TiedUpMod.LOGGER.info( + "[EntityDamsel] {} restored captor relationship with player {}", + host.getNpcName(), + player.getName().getString() + ); + } + } + pendingCaptorUUID = null; + return; + } + } + } + + // ======================================== + // PERSISTENCE + // ======================================== + + /** + * Save captivity-related state to NBT. + */ + public void saveCaptivityToTag(CompoundTag tag) { + if (captor != null && captor.getEntity() != null) { + tag.putUUID("CaptorUUID", captor.getEntity().getUUID()); + } + } + + /** + * Load captivity-related state from NBT. + */ + public void loadCaptivityFromTag(CompoundTag tag) { + if (tag.contains("CaptorUUID")) { + pendingCaptorUUID = tag.getUUID("CaptorUUID"); + } else if (tag.contains("MasterUUID")) { + // Legacy support + pendingCaptorUUID = tag.getUUID("MasterUUID"); + } + } + + // ======================================== + // INTERNAL + // ======================================== + + /** + * Get the facade IRestrainable reference for canCapture() calls. + * This is the entity itself (AbstractTiedUpNpc implements IRestrainable via delegation). + */ + private com.tiedup.remake.state.IRestrainable asFacadeKidnapped() { + return (com.tiedup.remake.state.IRestrainable) entity; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/components/NpcEquipmentManager.java b/src/main/java/com/tiedup/remake/entities/damsel/components/NpcEquipmentManager.java new file mode 100644 index 0000000..db5eda6 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/components/NpcEquipmentManager.java @@ -0,0 +1,940 @@ +package com.tiedup.remake.entities.damsel.components; + +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.AbstractTiedUpNpc; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.state.IRestrainableEntity; +import com.tiedup.remake.util.RestraintEffectUtils; +import com.tiedup.remake.util.TiedUpSounds; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import com.tiedup.remake.v2.bondage.capability.V2BondageEquipment; +import java.util.Map; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.Tag; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * Equipment CRUD, coercion, bulk operations, and permissions for NPC bondage items. + * + *

Extracted from DamselBondageManager (H9 split). Owns all equipment state + * and delegates to V2BondageEquipment for storage. Does NOT own captivity state.

+ * + *

Cross-cutting note: {@code untie()} stays in the facade because it calls + * both equipment and captivity methods.

+ */ +public class NpcEquipmentManager { + + // ======================================== + // FIELDS + // ======================================== + + private final AbstractTiedUpNpc entity; + private final IBondageHost host; + + /** Maps legacy V1 NBT key names to V2 body regions for migration. */ + static final Map V1_TO_V2 = Map.of( + "Bind", BodyRegionV2.ARMS, + "Gag", BodyRegionV2.MOUTH, + "Blindfold", BodyRegionV2.EYES, + "Earplugs", BodyRegionV2.EARS, + "Collar", BodyRegionV2.NECK, + "Clothes", BodyRegionV2.TORSO, + "Mittens", BodyRegionV2.HANDS + ); + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public NpcEquipmentManager(AbstractTiedUpNpc entity, IBondageHost host) { + this.entity = entity; + this.host = host; + } + + // ======================================== + // V2 EQUIPMENT HELPERS + // ======================================== + + /** Get the V2 equipment storage from the parent entity, cast to concrete type. */ + V2BondageEquipment getEquipment() { + return (V2BondageEquipment) entity.getV2Equipment(); + } + + /** Get the item equipped in a V2 body region. */ + public ItemStack getInRegion(BodyRegionV2 region) { + return getEquipment().getInRegion(region); + } + + /** Serialize V2 equipment to synched entity data (copy-on-write). */ + void syncToEntityData() { + entity.syncV2Equipment(); + } + + // ======================================== + // STATE QUERIES (14 methods) + // ======================================== + + public boolean isTiedUp() { + return getEquipment().isRegionOccupied(BodyRegionV2.ARMS); + } + + public boolean isGagged() { + return getEquipment().isRegionOccupied(BodyRegionV2.MOUTH); + } + + public boolean isBlindfolded() { + return getEquipment().isRegionOccupied(BodyRegionV2.EYES); + } + + public boolean hasEarplugs() { + return getEquipment().isRegionOccupied(BodyRegionV2.EARS); + } + + public boolean hasCollar() { + return getEquipment().isRegionOccupied(BodyRegionV2.NECK); + } + + public boolean hasClothes() { + return getEquipment().isRegionOccupied(BodyRegionV2.TORSO); + } + + public boolean hasMittens() { + return getEquipment().isRegionOccupied(BodyRegionV2.HANDS); + } + + public boolean isBoundAndGagged() { + return isTiedUp() && isGagged(); + } + + public boolean hasGaggingEffect() { + ItemStack gag = getCurrentGag(); + if (gag.isEmpty()) return false; + return ( + gag.getItem() instanceof + com.tiedup.remake.items.base.IHasGaggingEffect + ); + } + + public boolean hasBlindingEffect() { + ItemStack blindfold = getCurrentBlindfold(); + if (blindfold.isEmpty()) return false; + return ( + blindfold.getItem() instanceof + com.tiedup.remake.items.base.IHasBlindingEffect + ); + } + + public boolean hasKnives() { + return false; // NPCs don't have inventories for knives + } + + public boolean hasClothesWithSmallArms() { + // For damsels, delegate to appearance component + return entity.hasSlimArms(); + } + + public boolean hasLockedCollar() { + ItemStack collar = getCurrentCollar(); + if (collar.isEmpty()) return false; + if (collar.getItem() instanceof ILockable lockable) { + return lockable.isLocked(collar); + } + return false; + } + + public boolean hasNamedCollar() { + ItemStack collar = getCurrentCollar(); + if (collar.isEmpty()) return false; + return collar.hasCustomHoverName(); + } + + // ======================================== + // EQUIPMENT GETTERS (7 methods) + // ======================================== + + public ItemStack getCurrentBind() { + return getEquipment().getInRegion(BodyRegionV2.ARMS); + } + + public ItemStack getCurrentGag() { + return getEquipment().getInRegion(BodyRegionV2.MOUTH); + } + + public ItemStack getCurrentBlindfold() { + return getEquipment().getInRegion(BodyRegionV2.EYES); + } + + public ItemStack getCurrentEarplugs() { + return getEquipment().getInRegion(BodyRegionV2.EARS); + } + + public ItemStack getCurrentCollar() { + return getEquipment().getInRegion(BodyRegionV2.NECK); + } + + public ItemStack getCurrentClothes() { + return getEquipment().getInRegion(BodyRegionV2.TORSO); + } + + public ItemStack getCurrentMittens() { + return getEquipment().getInRegion(BodyRegionV2.HANDS); + } + + // ======================================== + // EQUIPMENT PUTTERS (7 methods) + // ======================================== + + public void putBindOn(ItemStack bind) { + if (bind.isEmpty()) return; + + boolean wasAlreadyTied = isTiedUp(); + getEquipment().setInRegion(BodyRegionV2.ARMS, bind.copy()); + syncToEntityData(); + + // Call onEquipped hook + if (bind.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onEquipped(bind, entity); + } + + // Apply speed reduction + RestraintEffectUtils.applyBindSpeedReduction(entity); + + // Play sound + TiedUpSounds.playBindSound(entity); + + // Stop movement + entity.getNavigation().stop(); + + // Broadcast captured dialogue if just got tied + if (!wasAlreadyTied && !entity.level().isClientSide()) { + host.talkToPlayersInRadius( + com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory.DAMSEL_CAPTURED, + ModConfig.SERVER.dialogueRadius.get() + ); + } + + TiedUpMod.LOGGER.debug( + "[EntityDamsel] {} tied up", + host.getNpcName() + ); + } + + public void putGagOn(ItemStack gag) { + if (gag.isEmpty()) return; + + getEquipment().setInRegion(BodyRegionV2.MOUTH, gag.copy()); + syncToEntityData(); + + if (gag.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onEquipped(gag, entity); + } + + TiedUpMod.LOGGER.debug( + "[EntityDamsel] {} gagged", + host.getNpcName() + ); + } + + public void putBlindfoldOn(ItemStack blindfold) { + if (blindfold.isEmpty()) return; + + getEquipment().setInRegion(BodyRegionV2.EYES, blindfold.copy()); + syncToEntityData(); + + if (blindfold.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onEquipped(blindfold, entity); + } + + TiedUpMod.LOGGER.debug( + "[EntityDamsel] {} blindfolded", + host.getNpcName() + ); + } + + public void putEarplugsOn(ItemStack earplugs) { + if (earplugs.isEmpty()) return; + + getEquipment().setInRegion(BodyRegionV2.EARS, earplugs.copy()); + syncToEntityData(); + + if (earplugs.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onEquipped(earplugs, entity); + } + + TiedUpMod.LOGGER.debug( + "[EntityDamsel] {} has earplugs", + host.getNpcName() + ); + } + + public void putCollarOn(ItemStack collar) { + if (collar.isEmpty()) return; + + getEquipment().setInRegion(BodyRegionV2.NECK, collar.copy()); + syncToEntityData(); + + if (collar.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onEquipped(collar, entity); + } + + // Play lock sound + TiedUpSounds.playLockSound(entity); + + TiedUpMod.LOGGER.debug( + "[EntityDamsel] {} collared", + host.getNpcName() + ); + } + + public void putClothesOn(ItemStack clothes) { + if (clothes.isEmpty()) return; + + getEquipment().setInRegion(BodyRegionV2.TORSO, clothes.copy()); + syncToEntityData(); + + if (clothes.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onEquipped(clothes, entity); + } + + TiedUpMod.LOGGER.debug( + "[EntityDamsel] {} changed clothes", + host.getNpcName() + ); + } + + public void putMittensOn(ItemStack mittens) { + if (mittens.isEmpty()) return; + + getEquipment().setInRegion(BodyRegionV2.HANDS, mittens.copy()); + syncToEntityData(); + + if (mittens.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onEquipped(mittens, entity); + } + + TiedUpMod.LOGGER.debug( + "[EntityDamsel] {} has mittens", + host.getNpcName() + ); + } + + // ======================================== + // EQUIPMENT REMOVERS (8 methods + 1 force-remove) + // ======================================== + + /** + * Force-remove the item from a region, bypassing ILockable lock checks. + * Runs the same side effects as the per-region takeXOff methods (sync, onUnequipped, etc.). + * Used by {@link DamselBondageManager#forceUnequip(BodyRegionV2)}. + * + * @param region The body region to force-remove from + * @return The removed ItemStack, or {@link ItemStack#EMPTY} if the slot was empty + */ + public ItemStack forceRemoveFromRegion(BodyRegionV2 region) { + // NECK and TORSO already have correct force/no-lock handling + if (region == BodyRegionV2.NECK) return takeCollarOff(true); + if (region == BodyRegionV2.TORSO) return takeClothesOff(); + + ItemStack current = getInRegion(region); + if (current.isEmpty()) return ItemStack.EMPTY; + + // Clear slot + sync (no lock check — that's the point of force) + getEquipment().setInRegion(region, ItemStack.EMPTY); + syncToEntityData(); + + if (current.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onUnequipped(current, entity); + } + + // Region-specific side effects + if (region == BodyRegionV2.ARMS) { + RestraintEffectUtils.removeBindSpeedReduction(entity); + TiedUpMod.LOGGER.debug("[EntityDamsel] {} force-untied", host.getNpcName()); + } + + return current; + } + + public ItemStack takeBindOff() { + ItemStack current = getCurrentBind(); + if (current.isEmpty()) return ItemStack.EMPTY; + + // Check if locked + if ( + current.getItem() instanceof ILockable lockable && + lockable.isLocked(current) + ) { + return ItemStack.EMPTY; + } + + getEquipment().setInRegion(BodyRegionV2.ARMS, ItemStack.EMPTY); + syncToEntityData(); + + if (current.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onUnequipped(current, entity); + } + + // Remove speed reduction + RestraintEffectUtils.removeBindSpeedReduction(entity); + + TiedUpMod.LOGGER.debug( + "[EntityDamsel] {} untied", + host.getNpcName() + ); + + return current; + } + + public ItemStack takeGagOff() { + ItemStack current = getCurrentGag(); + if (current.isEmpty()) return ItemStack.EMPTY; + + if ( + current.getItem() instanceof ILockable lockable && + lockable.isLocked(current) + ) { + return ItemStack.EMPTY; + } + + getEquipment().setInRegion(BodyRegionV2.MOUTH, ItemStack.EMPTY); + syncToEntityData(); + + if (current.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onUnequipped(current, entity); + } + + return current; + } + + public ItemStack takeBlindfoldOff() { + ItemStack current = getCurrentBlindfold(); + if (current.isEmpty()) return ItemStack.EMPTY; + + if ( + current.getItem() instanceof ILockable lockable && + lockable.isLocked(current) + ) { + return ItemStack.EMPTY; + } + + getEquipment().setInRegion(BodyRegionV2.EYES, ItemStack.EMPTY); + syncToEntityData(); + + if (current.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onUnequipped(current, entity); + } + + return current; + } + + public ItemStack takeEarplugsOff() { + ItemStack current = getCurrentEarplugs(); + if (current.isEmpty()) return ItemStack.EMPTY; + + if ( + current.getItem() instanceof ILockable lockable && + lockable.isLocked(current) + ) { + return ItemStack.EMPTY; + } + + getEquipment().setInRegion(BodyRegionV2.EARS, ItemStack.EMPTY); + syncToEntityData(); + + if (current.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onUnequipped(current, entity); + } + + return current; + } + + public ItemStack takeCollarOff() { + return takeCollarOff(false); + } + + public ItemStack takeCollarOff(boolean force) { + ItemStack current = getCurrentCollar(); + if (current.isEmpty()) return ItemStack.EMPTY; + + if ( + !force && + current.getItem() instanceof ILockable lockable && + lockable.isLocked(current) + ) { + return ItemStack.EMPTY; + } + + getEquipment().setInRegion(BodyRegionV2.NECK, ItemStack.EMPTY); + syncToEntityData(); + + if (current.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onUnequipped(current, entity); + } + + // Play unlock sound + TiedUpSounds.playUnlockSound(entity); + + return current; + } + + public ItemStack takeClothesOff() { + ItemStack current = getCurrentClothes(); + if (current.isEmpty()) return ItemStack.EMPTY; + + getEquipment().setInRegion(BodyRegionV2.TORSO, ItemStack.EMPTY); + syncToEntityData(); + + if (current.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onUnequipped(current, entity); + } + + return current; + } + + public ItemStack takeMittensOff() { + ItemStack current = getCurrentMittens(); + if (current.isEmpty()) return ItemStack.EMPTY; + + if ( + current.getItem() instanceof ILockable lockable && + lockable.isLocked(current) + ) { + return ItemStack.EMPTY; + } + + getEquipment().setInRegion(BodyRegionV2.HANDS, ItemStack.EMPTY); + syncToEntityData(); + + if (current.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onUnequipped(current, entity); + } + + return current; + } + + // ======================================== + // EQUIPMENT REPLACERS (14 methods) + // ======================================== + + public ItemStack replaceBind(ItemStack newBind) { + ItemStack oldBind = takeBindOff(); + if (!oldBind.isEmpty() || !newBind.isEmpty()) { + putBindOn(newBind); + } + return oldBind; + } + + public ItemStack replaceBind(ItemStack newBind, boolean force) { + ItemStack oldBind = force ? getCurrentBind() : takeBindOff(); + if (!oldBind.isEmpty() || !newBind.isEmpty()) { + if (force && !oldBind.isEmpty()) { + getEquipment().setInRegion(BodyRegionV2.ARMS, ItemStack.EMPTY); + // Don't sync here -- putBindOn will sync + if (oldBind.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onUnequipped(oldBind, entity); + } + } + putBindOn(newBind); + } + return oldBind; + } + + public ItemStack replaceGag(ItemStack newGag) { + return replaceGag(newGag, false); + } + + public ItemStack replaceGag(ItemStack newGag, boolean force) { + ItemStack oldGag = force ? getCurrentGag() : takeGagOff(); + if (!oldGag.isEmpty() || !newGag.isEmpty()) { + if (force && !oldGag.isEmpty()) { + getEquipment().setInRegion(BodyRegionV2.MOUTH, ItemStack.EMPTY); + if (oldGag.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onUnequipped(oldGag, entity); + } + } + putGagOn(newGag); + } + return oldGag; + } + + public ItemStack replaceBlindfold(ItemStack newBlindfold) { + return replaceBlindfold(newBlindfold, false); + } + + public ItemStack replaceBlindfold(ItemStack newBlindfold, boolean force) { + ItemStack oldBlindfold = force + ? getCurrentBlindfold() + : takeBlindfoldOff(); + if (!oldBlindfold.isEmpty() || !newBlindfold.isEmpty()) { + if (force && !oldBlindfold.isEmpty()) { + getEquipment().setInRegion(BodyRegionV2.EYES, ItemStack.EMPTY); + if (oldBlindfold.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onUnequipped(oldBlindfold, entity); + } + } + putBlindfoldOn(newBlindfold); + } + return oldBlindfold; + } + + public ItemStack replaceEarplugs(ItemStack newEarplugs) { + return replaceEarplugs(newEarplugs, false); + } + + public ItemStack replaceEarplugs(ItemStack newEarplugs, boolean force) { + ItemStack oldEarplugs = force + ? getCurrentEarplugs() + : takeEarplugsOff(); + if (!oldEarplugs.isEmpty() || !newEarplugs.isEmpty()) { + if (force && !oldEarplugs.isEmpty()) { + getEquipment().setInRegion(BodyRegionV2.EARS, ItemStack.EMPTY); + if (oldEarplugs.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onUnequipped(oldEarplugs, entity); + } + } + putEarplugsOn(newEarplugs); + } + return oldEarplugs; + } + + public ItemStack replaceCollar(ItemStack newCollar) { + return replaceCollar(newCollar, false); + } + + public ItemStack replaceCollar(ItemStack newCollar, boolean force) { + ItemStack oldCollar = takeCollarOff(force); + if (!oldCollar.isEmpty() || !newCollar.isEmpty()) { + putCollarOn(newCollar); + } + return oldCollar; + } + + public ItemStack replaceClothes(ItemStack newClothes) { + return replaceClothes(newClothes, false); + } + + public ItemStack replaceClothes(ItemStack newClothes, boolean force) { + ItemStack oldClothes = takeClothesOff(); + if (!oldClothes.isEmpty() || !newClothes.isEmpty()) { + putClothesOn(newClothes); + } + return oldClothes; + } + + public ItemStack replaceMittens(ItemStack newMittens) { + return replaceMittens(newMittens, false); + } + + public ItemStack replaceMittens(ItemStack newMittens, boolean force) { + ItemStack oldMittens = force ? getCurrentMittens() : takeMittensOff(); + if (!oldMittens.isEmpty() || !newMittens.isEmpty()) { + if (force && !oldMittens.isEmpty()) { + getEquipment().setInRegion(BodyRegionV2.HANDS, ItemStack.EMPTY); + if (oldMittens.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onUnequipped(oldMittens, entity); + } + } + putMittensOn(newMittens); + } + return oldMittens; + } + + // ======================================== + // BULK OPERATIONS + // ======================================== + + public void applyBondage( + ItemStack bind, + ItemStack gag, + ItemStack blindfold, + ItemStack earplugs, + ItemStack collar, + ItemStack clothes + ) { + if (!bind.isEmpty()) putBindOn(bind); + if (!gag.isEmpty()) putGagOn(gag); + if (!blindfold.isEmpty()) putBlindfoldOn(blindfold); + if (!earplugs.isEmpty()) putEarplugsOn(earplugs); + if (!collar.isEmpty()) putCollarOn(collar); + if (!clothes.isEmpty()) putClothesOn(clothes); + + TiedUpMod.LOGGER.info( + "[EntityDamsel] {} fully restrained", + host.getNpcName() + ); + } + + /** + * Drop bondage items on the ground. Convenience overload. + */ + public void dropBondageItems(boolean drop) { + dropBondageItems(drop, true, true, true, true, true, true); + } + + /** + * Drop bondage items with optional bind control. Convenience overload. + */ + public void dropBondageItems(boolean drop, boolean dropBind) { + dropBondageItems(drop, dropBind, true, true, true, true, true); + } + + /** + * Drop bondage items with full granular control. + * + * @param drop master switch -- if false, nothing is dropped + */ + public void dropBondageItems( + boolean drop, + boolean dropBind, + boolean dropGag, + boolean dropBlindfold, + boolean dropEarplugs, + boolean dropCollar, + boolean dropClothes + ) { + if (!drop) return; + + if (dropBind) { + ItemStack bind = takeBindOff(); + if (!bind.isEmpty()) host.dropItemStack(bind); + } + if (dropGag) { + ItemStack gag = takeGagOff(); + if (!gag.isEmpty()) host.dropItemStack(gag); + } + if (dropBlindfold) { + ItemStack blindfold = takeBlindfoldOff(); + if (!blindfold.isEmpty()) host.dropItemStack(blindfold); + } + if (dropEarplugs) { + ItemStack earplugs = takeEarplugsOff(); + if (!earplugs.isEmpty()) host.dropItemStack(earplugs); + } + if (dropCollar) { + ItemStack collar = takeCollarOff(); + if (!collar.isEmpty()) host.dropItemStack(collar); + } + if (dropClothes) { + ItemStack clothes = takeClothesOff(); + if (!clothes.isEmpty()) host.dropItemStack(clothes); + } + // Always drop mittens if dropping + ItemStack mittens = takeMittensOff(); + if (!mittens.isEmpty()) host.dropItemStack(mittens); + } + + public void dropClothes() { + ItemStack clothes = takeClothesOff(); + if (!clothes.isEmpty()) { + host.dropItemStack(clothes); + } + } + + public int getBondageItemsWhichCanBeRemovedCount() { + int count = 0; + if (!getCurrentBind().isEmpty() && !isLocked(getCurrentBind())) count++; + if (!getCurrentGag().isEmpty() && !isLocked(getCurrentGag())) count++; + if (!getCurrentBlindfold().isEmpty() && !isLocked(getCurrentBlindfold())) count++; + if (!getCurrentEarplugs().isEmpty() && !isLocked(getCurrentEarplugs())) count++; + if (!getCurrentCollar().isEmpty() && !isLocked(getCurrentCollar())) count++; + if (!getCurrentClothes().isEmpty() && !isLocked(getCurrentClothes())) count++; + return count; + } + + // ======================================== + // ADVANCED QUERIES + // ======================================== + + public boolean canBeTiedUp() { + return !isTiedUp(); + } + + // ======================================== + // PERMISSIONS + // ======================================== + + public boolean canTakeOffClothes(Player player) { + return true; // NPCs don't have permission restrictions + } + + public boolean canChangeClothes(Player player) { + return true; + } + + public boolean canChangeClothes() { + return true; + } + + // ======================================== + // COLLAR OWNER CHECK + // ======================================== + + /** + * Check if a player is an owner of this NPC's collar. + * Returns true if no collar (anyone can interact with uncolored NPCs). + */ + public boolean isCollarOwner(Player player) { + if (!hasCollar()) return true; + ItemStack collar = getCurrentCollar(); + if (!(collar.getItem() instanceof ItemCollar collarItem)) return true; + return collarItem.isOwner(collar, player); + } + + // ======================================== + // COERCION + // ======================================== + + public void tighten(Player tightener) { + if (!isTiedUp()) return; + + TiedUpSounds.playSound( + entity, + net.minecraft.sounds.SoundEvents.PLAYER_ATTACK_NODAMAGE, + 1.0f + ); + TiedUpMod.LOGGER.debug( + "[EntityDamsel] {} tightened by {}", + host.getNpcName(), + tightener.getName().getString() + ); + } + + public void applyChloroform(int duration) { + RestraintEffectUtils.applyChloroformEffects(entity, duration); + } + + public void shockKidnapped() { + shockKidnapped("", 1.0f); + } + + public void shockKidnapped(String messageAddon, float damage) { + // Play shock sound + TiedUpSounds.playSound( + entity, + net.minecraft.sounds.SoundEvents.LIGHTNING_BOLT_IMPACT, + 0.5f + ); + + // Apply damage + entity.hurt(entity.damageSources().magic(), damage); + + TiedUpMod.LOGGER.debug( + "[EntityDamsel] {} shocked ({} damage)", + host.getNpcName(), + damage + ); + } + + public void takeBondageItemBy(IRestrainableEntity taker, int slotIndex) { + ItemStack taken = ItemStack.EMPTY; + + switch (slotIndex) { + case 0 -> taken = takeBindOff(); + case 1 -> taken = takeGagOff(); + case 2 -> taken = takeBlindfoldOff(); + case 3 -> taken = takeEarplugsOff(); + case 4 -> taken = takeCollarOff(); + case 5 -> taken = takeClothesOff(); + } + + if (!taken.isEmpty()) { + taker.kidnappedDropItem(taken); + TiedUpMod.LOGGER.debug( + "[EntityDamsel] {} item taken by {}", + host.getNpcName(), + taker.getKidnappedName() + ); + } + } + + // ======================================== + // INTERNAL - UNLOCK ALL + // ======================================== + + /** + * Unlock all locked bondage items on this entity. + * Used by the facade in onDeathKidnapped. + * Epic 4B: Uses V2 regions, syncs once at end. + */ + public void unlockAllItems() { + BodyRegionV2[] regions = { + BodyRegionV2.ARMS, BodyRegionV2.MOUTH, BodyRegionV2.EYES, + BodyRegionV2.EARS, BodyRegionV2.NECK + }; + boolean changed = false; + for (BodyRegionV2 region : regions) { + ItemStack stack = getEquipment().getInRegion(region); + if ( + !stack.isEmpty() && + stack.getItem() instanceof ILockable lockable && + lockable.isLocked(stack) + ) { + lockable.setLocked(stack, false); + changed = true; + } + } + if (changed) { + syncToEntityData(); + } + } + + /** + * Clear all V2 regions at once and sync. Used by untie(drop=false) in facade. + */ + public void clearAllAndSync() { + getEquipment().clearAll(); + syncToEntityData(); + } + + // ======================================== + // PERSISTENCE + // ======================================== + + /** + * Save equipment-related state to NBT. + * Epic 4B: Serializes V2BondageEquipment as a single CompoundTag. + */ + public void saveEquipmentToTag(CompoundTag tag) { + tag.put("V2Equipment", getEquipment().serializeNBT()); + } + + /** + * Load equipment-related state from NBT. + * Supports both V2 format and legacy V1 migration. + */ + public void loadEquipmentFromTag(CompoundTag tag) { + if (tag.contains("V2Equipment", Tag.TAG_COMPOUND)) { + // V2 format: deserialize directly + getEquipment().deserializeNBT(tag.getCompound("V2Equipment")); + } else { + // Legacy V1 migration: load individual item keys into V2 regions + for (Map.Entry entry : V1_TO_V2.entrySet()) { + if (tag.contains(entry.getKey(), Tag.TAG_COMPOUND)) { + ItemStack stack = ItemStack.of(tag.getCompound(entry.getKey())); + if (!stack.isEmpty()) { + getEquipment().setInRegion(entry.getValue(), stack); + } + } + } + } + syncToEntityData(); + } + + // ======================================== + // PRIVATE HELPERS + // ======================================== + + /** + * Check if an item is locked (convenience -- no force parameter). + */ + private boolean isLocked(ItemStack stack) { + return ( + stack.getItem() instanceof ILockable lockable && + lockable.isLocked(stack) + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/hosts/AIHost.java b/src/main/java/com/tiedup/remake/entities/damsel/hosts/AIHost.java new file mode 100644 index 0000000..26e5856 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/hosts/AIHost.java @@ -0,0 +1,148 @@ +package com.tiedup.remake.entities.damsel.hosts; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.entities.damsel.components.DamselBondageManager; +import com.tiedup.remake.entities.damsel.components.DamselPersonalitySystem; +import com.tiedup.remake.entities.damsel.components.IAIHost; +import java.util.UUID; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; + +/** + * Host implementation for AIController callbacks. + * Extracted from EntityDamsel inner class for better organization. + * + * Phase 9: Extracted from EntityDamsel.AIHostImpl + */ +public class AIHost implements IAIHost { + + private final EntityDamsel entity; + + public AIHost(EntityDamsel entity) { + this.entity = entity; + } + + // ======================================== + // BASIC ENTITY ACCESS + // ======================================== + + @Override + public Level level() { + return entity.level(); + } + + @Override + public UUID getUUID() { + return entity.getUUID(); + } + + @Override + public String getNpcName() { + return entity.getNpcName(); + } + + @Override + public RandomSource getRandom() { + return entity.getRandom(); + } + + @Override + public AABB getBoundingBox() { + return entity.getBoundingBox(); + } + + // ======================================== + // LEASH SYSTEM ACCESS + // ======================================== + + @Override + public boolean isLeashed() { + return entity.isLeashed(); + } + + @Override + public Entity getLeashHolder() { + return entity.getLeashHolder(); + } + + @Override + public Vec3 getDeltaMovement() { + return entity.getDeltaMovement(); + } + + @Override + public void setDeltaMovement(Vec3 motion) { + entity.setDeltaMovement(motion); + } + + @Override + public float maxUpStep() { + return entity.maxUpStep(); + } + + @Override + public void setMaxUpStep(float height) { + entity.setMaxUpStep(height); + } + + @Override + public void teleportTo(double x, double y, double z) { + entity.teleportTo(x, y, z); + } + + @Override + public float distanceTo(Entity other) { + return entity.distanceTo(other); + } + + @Override + public double getX() { + return entity.getX(); + } + + @Override + public double getY() { + return entity.getY(); + } + + @Override + public double getZ() { + return entity.getZ(); + } + + // ======================================== + // COMPONENT ACCESS + // ======================================== + + @Override + public DamselBondageManager getBondageManager() { + return entity.getBondageManager(); + } + + @Override + public DamselPersonalitySystem getPersonalitySystem() { + return entity.getPersonalitySystem(); + } + + // ======================================== + // BONDAGE STATE QUERIES (delegated) + // ======================================== + + @Override + public boolean isTiedUp() { + return entity.isTiedUp(); + } + + @Override + public boolean isGagged() { + return entity.isGagged(); + } + + @Override + public boolean isCaptive() { + return entity.isCaptive(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/hosts/AnimationHost.java b/src/main/java/com/tiedup/remake/entities/damsel/hosts/AnimationHost.java new file mode 100644 index 0000000..705ee73 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/hosts/AnimationHost.java @@ -0,0 +1,81 @@ +package com.tiedup.remake.entities.damsel.hosts; + +import com.tiedup.remake.entities.AbstractTiedUpNpc; +import com.tiedup.remake.entities.damsel.components.IAnimationHost; +import net.minecraft.world.level.Level; + +/** + * Host implementation for AnimationController callbacks. + * Extracted from EntityDamsel inner class for better organization. + * + * Phase 9: Extracted from EntityDamsel.AnimationHostImpl + * Phase 3 audit: Updated to use AbstractTiedUpNpc + */ +public class AnimationHost implements IAnimationHost { + + private final AbstractTiedUpNpc entity; + + public AnimationHost(AbstractTiedUpNpc entity) { + this.entity = entity; + } + + @Override + public Level level() { + return entity.level(); + } + + @Override + public float getYBodyRot() { + return entity.yBodyRot; + } + + @Override + public void setYBodyRot(float rot) { + entity.yBodyRot = rot; + } + + @Override + public float getYBodyRotO() { + return entity.yBodyRotO; + } + + @Override + public void setYBodyRotO(float rot) { + entity.yBodyRotO = rot; + } + + @Override + public boolean isDogPose() { + return entity.isDogPose(); + } + + @Override + public boolean isSittingFromData() { + return entity.getEntityData().get(AbstractTiedUpNpc.DATA_SITTING); + } + + @Override + public void setSittingToData(boolean sitting) { + entity.getEntityData().set(AbstractTiedUpNpc.DATA_SITTING, sitting); + } + + @Override + public boolean isKneelingFromData() { + return entity.getEntityData().get(AbstractTiedUpNpc.DATA_KNEELING); + } + + @Override + public void setKneelingToData(boolean kneeling) { + entity.getEntityData().set(AbstractTiedUpNpc.DATA_KNEELING, kneeling); + } + + @Override + public boolean isStrugglingFromData() { + return entity.getEntityData().get(AbstractTiedUpNpc.DATA_STRUGGLING); + } + + @Override + public void setStrugglingToData(boolean struggling) { + entity.getEntityData().set(AbstractTiedUpNpc.DATA_STRUGGLING, struggling); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/hosts/BondageHost.java b/src/main/java/com/tiedup/remake/entities/damsel/hosts/BondageHost.java new file mode 100644 index 0000000..eb67ca2 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/hosts/BondageHost.java @@ -0,0 +1,88 @@ +package com.tiedup.remake.entities.damsel.hosts; + +import com.tiedup.remake.dialogue.EntityDialogueManager; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.entities.damsel.components.DamselInventoryManager; +import com.tiedup.remake.entities.damsel.components.IBondageHost; +import com.tiedup.remake.personality.PersonalityState; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * Host implementation for BondageManager callbacks. + * Extracted from EntityDamsel inner class for better organization. + * + * Phase 9: Extracted from EntityDamsel.BondageHostImpl + */ +public class BondageHost implements IBondageHost { + + private final EntityDamsel entity; + + public BondageHost(EntityDamsel entity) { + this.entity = entity; + } + + @Override + public PersonalityState getPersonalityState() { + return entity.getPersonalitySystem() != null + ? entity.getPersonalitySystem().getPersonalityState() + : null; + } + + @Override + public DamselInventoryManager getInventory() { + return entity.getInventoryManager(); + } + + @Override + public void dropItemStack(ItemStack stack) { + entity.spawnAtLocation(stack); + } + + @Override + public void playSound(SoundEvent sound) { + entity.playSound(sound, 1.0f, 1.0f); + } + + @Override + public void setHealth(float health) { + entity.setHealth(health); + } + + @Override + public void remove(Entity.RemovalReason reason) { + entity.remove(reason); + } + + @Override + public Level level() { + return entity.level(); + } + + @Override + public BlockPos blockPosition() { + return entity.blockPosition(); + } + + @Override + public UUID getUUID() { + return entity.getUUID(); + } + + @Override + public String getNpcName() { + return entity.getNpcName(); + } + + @Override + public void talkToPlayersInRadius( + EntityDialogueManager.DialogueCategory category, + int radius + ) { + entity.talkToPlayersInRadius(category, radius); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/hosts/DialogueHost.java b/src/main/java/com/tiedup/remake/entities/damsel/hosts/DialogueHost.java new file mode 100644 index 0000000..5fd2bc6 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/hosts/DialogueHost.java @@ -0,0 +1,53 @@ +package com.tiedup.remake.entities.damsel.hosts; + +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.entities.damsel.components.IDialogueHost; +import java.util.List; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; + +/** + * Host implementation for DialogueHandler callbacks. + * Extracted from EntityDamsel inner class for better organization. + * + * Phase 9: Extracted from EntityDamsel.DialogueHostImpl + */ +public class DialogueHost implements IDialogueHost { + + private final EntityDamsel entity; + + public DialogueHost(EntityDamsel entity) { + this.entity = entity; + } + + @Override + public Level level() { + return entity.level(); + } + + @Override + public AABB getBoundingBox() { + return entity.getBoundingBox(); + } + + @Override + public EntityDamsel getEntity() { + return entity; + } + + @Override + public long getGameTime() { + return entity.level().getGameTime(); + } + + @Override + public List findNearbyPlayers(double radius) { + return entity + .level() + .getEntitiesOfClass( + Player.class, + entity.getBoundingBox().inflate(radius) + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/damsel/hosts/PersonalityTickContextHost.java b/src/main/java/com/tiedup/remake/entities/damsel/hosts/PersonalityTickContextHost.java new file mode 100644 index 0000000..86bb395 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/damsel/hosts/PersonalityTickContextHost.java @@ -0,0 +1,109 @@ +package com.tiedup.remake.entities.damsel.hosts; + +import com.tiedup.remake.dialogue.EntityDialogueManager; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.entities.damsel.components.DamselAppearance; +import com.tiedup.remake.entities.damsel.components.DamselBondageManager; +import com.tiedup.remake.entities.damsel.components.DamselInventoryManager; +import com.tiedup.remake.entities.damsel.components.IPersonalityTickContext; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +/** + * Host implementation for PersonalitySystem callbacks. + * Extracted from EntityDamsel inner class for better organization. + * + * Phase 9: Extracted from EntityDamsel.PersonalityTickContextImpl + */ +public class PersonalityTickContextHost implements IPersonalityTickContext { + + private final EntityDamsel entity; + + public PersonalityTickContextHost(EntityDamsel entity) { + this.entity = entity; + } + + @Override + public Level level() { + return entity.level(); + } + + @Override + public int getTickCount() { + return entity.tickCount; + } + + @Override + public boolean isLeashed() { + return entity.isLeashed(); + } + + @Override + public Vec3 getDeltaMovement() { + return entity.getDeltaMovement(); + } + + @Override + public boolean hasCollar() { + return entity.getBondageManager().hasCollar(); + } + + @Override + public ItemStack getEquipment(BodyRegionV2 region) { + return entity.getBondageManager().getEquipment(region); + } + + @Override + public DamselBondageManager getBondageManager() { + return entity.getBondageManager(); + } + + @Override + public DamselInventoryManager getInventoryManager() { + return entity.getInventoryManager(); + } + + @Override + public DamselAppearance getAppearance() { + return entity.getAppearance(); + } + + @Override + public UUID getUUID() { + return entity.getUUID(); + } + + @Override + public BlockPos blockPosition() { + return entity.blockPosition(); + } + + @Override + public void stopNavigation() { + entity.getNavigation().stop(); + } + + @Override + public Entity getLeashHolder() { + return entity.getLeashHolder(); + } + + @Override + public void talkToPlayersInRadius( + EntityDialogueManager.DialogueCategory category, + int radius + ) { + entity.talkToPlayersInRadius(category, radius); + } + + @Override + public void talkTo(Player player, String message) { + entity.talkTo(player, message); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/IAIHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/IAIHost.java new file mode 100644 index 0000000..1aeda5d --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/IAIHost.java @@ -0,0 +1,226 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.entities.ai.kidnapper.KidnapperState; +import com.tiedup.remake.state.IBondageState; +import java.util.List; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.GoalSelector; +import net.minecraft.world.entity.ai.navigation.PathNavigation; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * Host interface for KidnapperAIManager callbacks. + * Provides access to entity properties and components needed for AI goal registration. + */ +public interface IAIHost { + // ======================================== + // NAVIGATION & BASIC + // ======================================== + + /** + * Get the entity's navigation system. + */ + PathNavigation getNavigation(); + + /** + * Get the goal selector for registering AI goals. + */ + GoalSelector getGoalSelector(); + + /** + * Get the entity's level/world. + */ + Level getLevel(); + + // ======================================== + // STATE MANAGEMENT + // ======================================== + + /** + * Get the current behavioral state. + */ + KidnapperState getCurrentState(); + + /** + * Set the current behavioral state. + */ + void setCurrentState(KidnapperState state); + + // ======================================== + // TARGET MANAGEMENT + // ======================================== + + /** + * Get the current hunt target. + */ + @Nullable + LivingEntity getTarget(); + + /** + * Set the current hunt target. + */ + void setTarget(@Nullable LivingEntity target); + + /** + * Check if an entity is a suitable target for capture. + */ + boolean isSuitableTarget(LivingEntity entity); + + /** + * Check if target is still valid during an active chase. + */ + boolean isTargetStillValidForChase(LivingEntity entity); + + /** + * Check if kidnapper is close enough to capture target. + */ + boolean isCloseToTarget(); + + /** + * Find closest suitable target within radius. + */ + @Nullable + LivingEntity getClosestSuitableTarget(int radius); + + // ======================================== + // CAPTIVE MANAGEMENT + // ======================================== + + /** + * Get the current captive. + */ + @Nullable + IBondageState getCaptive(); + + /** + * Check if kidnapper has captives. + */ + boolean hasCaptives(); + + // ======================================== + // AGGRESSION SYSTEM + // ======================================== + + /** + * Get the last entity that attacked this kidnapper. + */ + @Nullable + LivingEntity getLastAttacker(); + + /** + * Get the escaped target if still within memory time. + */ + @Nullable + LivingEntity getEscapedTarget(); + + // ======================================== + // ALERT SYSTEM + // ======================================== + + /** + * Get the alert target (escapee being searched for). + */ + @Nullable + LivingEntity getAlertTarget(); + + /** + * Set the alert target. + */ + void setAlertTarget(@Nullable LivingEntity target); + + // ======================================== + // CAMP SYSTEM + // ======================================== + + /** + * Get the associated structure UUID for this kidnapper. + */ + @Nullable + UUID getAssociatedStructure(); + + /** + * Check if this kidnapper is a "hunter" that patrols far from camp. + */ + boolean isHunter(); + + // ======================================== + // CELL SYSTEM + // ======================================== + + /** + * Get all nearby cells that have prisoners in them. + */ + List getNearbyCellsWithPrisoners(); + + /** + * Get the PATROL markers near this kidnapper. + */ + List getNearbyPatrolMarkers(int radius); + + // ======================================== + // SALE & JOB SYSTEM + // ======================================== + + /** + * Check if currently selling captive. + */ + boolean isSellingCaptive(); + + /** + * Check if waiting for worker to complete job. + */ + boolean isWaitingForJobToBeCompleted(); + + // ======================================== + // COLLAR CONFIG + // ======================================== + + /** + * Check if kidnapping mode is fully ready (enabled + prison set). + */ + boolean isKidnappingModeReady(); + + /** + * Get cell ID from collar. + */ + @Nullable + UUID getCellIdFromCollar(); + + // ======================================== + // CAPTURE EQUIPMENT + // ======================================== + + /** + * Equip themed bind and gag items before capture. + */ + void setUpHeldItems(); + + /** + * Get bind item to use for capture. + */ + ItemStack getBindItem(); + + /** + * Get gag item to use for capture. + */ + ItemStack getGagItem(); + + // ======================================== + // STATE FLAGS + // ======================================== + + /** + * Check if in "get out" state (flee behavior). + */ + boolean isGetOutState(); + + /** + * Check if currently dogwalking a prisoner. + */ + boolean isDogwalking(); +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/IAggressionHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/IAggressionHost.java new file mode 100644 index 0000000..85849fd --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/IAggressionHost.java @@ -0,0 +1,29 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import org.jetbrains.annotations.Nullable; +import net.minecraft.world.level.Level; + +/** + * Host interface for KidnapperAggressionSystem callbacks. + * Provides access to entity information needed for aggression tracking. + */ +public interface IAggressionHost { + /** + * Get the world/level for tick timing. + */ + @Nullable + Level level(); + + /** + * Get the entity's display name for dialogue. + */ + String getNpcName(); + + /** + * Trigger alert dialogue when captive escapes. + */ + void talkToPlayersInRadius( + com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory category, + int radius + ); +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/IAlertHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/IAlertHost.java new file mode 100644 index 0000000..d54f3c2 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/IAlertHost.java @@ -0,0 +1,46 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import com.tiedup.remake.entities.ai.kidnapper.KidnapperState; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; + +/** + * Host interface for KidnapperAlertManager callbacks. + * Provides access to entity properties needed for the alert system. + */ +public interface IAlertHost { + /** + * Get the entity's level/world. + */ + Level level(); + + /** + * Get the entity's bounding box for area searches. + */ + AABB getBoundingBox(); + + /** + * Get the current AI state. + */ + KidnapperState getCurrentState(); + + /** + * Set the current AI state. + */ + void setCurrentState(KidnapperState state); + + /** + * Check if this kidnapper has captives. + */ + boolean hasCaptives(); + + /** + * Check if this kidnapper is tied up. + */ + boolean isTiedUp(); + + /** + * Get the kidnapper's name for logging. + */ + String getNpcName(); +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/IAppearanceHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/IAppearanceHost.java new file mode 100644 index 0000000..da46cab --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/IAppearanceHost.java @@ -0,0 +1,60 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import com.tiedup.remake.entities.skins.Gender; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.Level; + +/** + * Host interface for KidnapperAppearance callbacks. + * Provides access to entity data and configuration needed for appearance management. + */ +public interface IAppearanceHost { + /** + * Get the entity's UUID for deterministic skin selection. + */ + UUID getUUID(); + + /** + * Get the entity's random source. + */ + RandomSource getRandom(); + + /** + * Get the world/level. + */ + @Nullable + Level level(); + + /** + * Set whether the kidnapper uses slim arms model. + */ + void setSlimArms(boolean slimArms); + + /** + * Set the kidnapper's gender. + */ + void setGender(Gender gender); + + /** + * Set the kidnapper's display name. + */ + void setNpcName(String name); + + /** + * Get the kidnapper's display name. + */ + String getNpcName(); + + /** + * Set synced entity data string value. + */ + void setEntityDataString(EntityDataAccessor accessor, String value); + + /** + * Get synced entity data string value. + */ + String getEntityDataString(EntityDataAccessor accessor); +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/ICampHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/ICampHost.java new file mode 100644 index 0000000..b896c87 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/ICampHost.java @@ -0,0 +1,19 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import net.minecraft.util.RandomSource; + +/** + * Host interface for KidnapperCampManager callbacks. + * Provides access to random source and name for camp assignment logic. + */ +public interface ICampHost { + /** + * Get random source for hunter role assignment. + */ + RandomSource getRandom(); + + /** + * Get the kidnapper's name for logging. + */ + String getNpcName(); +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/ICaptiveHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/ICaptiveHost.java new file mode 100644 index 0000000..91913fa --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/ICaptiveHost.java @@ -0,0 +1,112 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import com.tiedup.remake.entities.ai.kidnapper.KidnapperState; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +/** + * Host interface for KidnapperCaptiveManager callbacks. + * Provides access to entity properties needed for captive management. + */ +public interface ICaptiveHost { + /** + * Get the entity's level/world. + */ + Level level(); + + /** + * Get the entity's UUID. + */ + UUID getUUID(); + + /** + * Get the entity's position. + */ + Vec3 position(); + + /** + * Get the entity's block position. + */ + BlockPos blockPosition(); + + /** + * Get the kidnapper's name for logging. + */ + String getNpcName(); + + /** + * Set entity data value. + */ + void setEntityData(EntityDataAccessor accessor, T value); + + /** + * Check if this kidnapper is tied up. + */ + boolean isTiedUp(); + + /** + * Get the current AI state. + */ + KidnapperState getCurrentState(); + + /** + * Set the current AI state. + */ + void setCurrentState(KidnapperState state); + + /** + * Set the "get out" / flee state. + */ + void setGetOutState(boolean state); + + /** + * Trigger dialogue with nearby players. + */ + void talkToPlayersInRadius( + com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory category, + int radius + ); + + /** + * Get the aggression system for escape tracking. + */ + KidnapperAggressionSystem getAggressionSystem(); + + /** + * Get the alert manager for broadcasting alerts. + */ + KidnapperAlertManager getAlertManager(); + + /** + * Broadcast alert about escaped captive. + */ + void broadcastAlert(LivingEntity escapee); + + /** + * Check if has line of sight to entity. + */ + boolean hasLineOfSight(LivingEntity entity); + + /** + * Get distance to entity. + */ + float distanceTo(LivingEntity entity); + + /** + * Find cell containing prisoner. + */ + @Nullable + com.tiedup.remake.cells.CellDataV2 findCellContainingPrisoner( + UUID prisonerId + ); + + /** + * Get entity as LivingEntity. + */ + LivingEntity asLivingEntity(); +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/ICellHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/ICellHost.java new file mode 100644 index 0000000..f0e6a6d --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/ICellHost.java @@ -0,0 +1,53 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import com.tiedup.remake.entities.ai.kidnapper.KidnapperState; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.ai.navigation.PathNavigation; +import net.minecraft.world.level.Level; + +/** + * Host interface for KidnapperCellManager callbacks. + * Provides access to entity properties needed for cell management. + */ +public interface ICellHost { + /** + * Get the entity's level/world. + */ + Level level(); + + /** + * Get the entity's current block position. + */ + BlockPos blockPosition(); + + /** + * Get the associated structure UUID for camp-linked kidnappers. + */ + @Nullable + UUID getAssociatedStructure(); + + /** + * Get the entity's navigation component. + */ + PathNavigation getNavigation(); + + /** + * Set the current AI state. + */ + void setCurrentState(KidnapperState state); + + /** + * Trigger dialogue with nearby players. + */ + void talkToPlayersInRadius( + com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory category, + int radius + ); + + /** + * Get the kidnapper's name for logging. + */ + String getNpcName(); +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/IDataSerializerHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/IDataSerializerHost.java new file mode 100644 index 0000000..8afa708 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/IDataSerializerHost.java @@ -0,0 +1,83 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.KidnapperJobManager; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.syncher.EntityDataAccessor; + +/** + * Host interface for KidnapperDataSerializer callbacks. + * Provides access to entity data and components for NBT persistence. + */ +public interface IDataSerializerHost { + // ======================================== + // ENTITY ACCESS + // ======================================== + + /** + * Get the entity instance. + */ + EntityKidnapper getEntity(); + + // ======================================== + // ENTITY DATA ACCESS + // ======================================== + + /** + * Get entity data value. + */ + T getEntityData(EntityDataAccessor accessor); + + /** + * Set entity data value. + */ + void setEntityData(EntityDataAccessor accessor, T value); + + // ======================================== + // STATE FLAGS + // ======================================== + + /** + * Check if in "get out" state. + */ + boolean isGetOutState(); + + /** + * Set "get out" state. + */ + void setGetOutState(boolean state); + + // ======================================== + // COMPONENT ACCESS + // ======================================== + + /** + * Get appearance manager. + */ + KidnapperAppearance getAppearance(); + + /** + * Get aggression system. + */ + KidnapperAggressionSystem getAggressionSystem(); + + /** + * Get captive manager. + */ + KidnapperCaptiveManager getCaptiveManager(); + + /** + * Get state manager. + */ + KidnapperStateManager getStateManager(); + + /** + * Get camp manager. + */ + KidnapperCampManager getCampManager(); + + /** + * Get job manager. + */ + KidnapperJobManager getJobManager(); +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/ISaleHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/ISaleHost.java new file mode 100644 index 0000000..e562923 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/ISaleHost.java @@ -0,0 +1,47 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import com.tiedup.remake.state.IRestrainable; +import org.jetbrains.annotations.Nullable; +import net.minecraft.world.level.Level; + +/** + * Host interface for KidnapperSaleManager callbacks. + * Provides access to entity properties needed for sale management. + */ +public interface ISaleHost { + /** + * Get the entity's level/world. + */ + Level level(); + + /** + * Check if this kidnapper has captives. + */ + boolean hasCaptives(); + + /** + * Get the current captive. + */ + @Nullable + IRestrainable getCaptive(); + + /** + * Get the kidnapper's name for logging. + */ + String getNpcName(); + + /** + * Set the "get out" / flee state after completing a sale. + */ + void setGetOutState(boolean state); + + /** + * Get the captive transfer flag. + */ + boolean getAllowCaptiveTransferFlag(); + + /** + * Set the captive transfer flag. + */ + void setAllowCaptiveTransferFlag(boolean flag); +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/IStateHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/IStateHost.java new file mode 100644 index 0000000..b8e35ab --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/IStateHost.java @@ -0,0 +1,14 @@ +package com.tiedup.remake.entities.kidnapper.components; + +/** + * Host interface for KidnapperStateManager callbacks. + * Provides access to entity information needed for state management logging. + */ +public interface IStateHost { + /** + * Get the name of this kidnapper for logging purposes. + * + * @return The kidnapper's name + */ + String getNpcName(); +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/ITargetHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/ITargetHost.java new file mode 100644 index 0000000..7f0f831 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/ITargetHost.java @@ -0,0 +1,93 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import com.tiedup.remake.entities.KidnapperCollarConfig; +import com.tiedup.remake.entities.KidnapperJobManager; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.navigation.PathNavigation; +import net.minecraft.world.entity.ai.sensing.Sensing; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; + +/** + * Host interface for KidnapperTargetSelector callbacks. + * Provides access to entity properties needed for target selection and validation. + */ +public interface ITargetHost { + /** + * Get the entity's level/world. + */ + Level level(); + + /** + * Get the entity's sensing component for line-of-sight checks. + */ + Sensing getSensing(); + + /** + * Get the entity's bounding box for area searches. + */ + AABB getBoundingBox(); + + /** + * Calculate distance to another entity. + */ + float distanceTo(LivingEntity entity); + + /** + * Get the entity's UUID. + */ + UUID getUUID(); + + /** + * Check if this kidnapper is tied up. + */ + boolean isTiedUp(); + + /** + * Check if this kidnapper has captives. + */ + boolean hasCaptives(); + + /** + * Check if waiting for a job to be completed. + */ + boolean isWaitingForJobToBeCompleted(); + + /** + * Check if in "get out" / fleeing state. + */ + boolean isGetOutState(); + + /** + * Check if kidnapping mode is enabled. + */ + boolean isKidnappingModeEnabled(); + + /** + * Get the associated structure UUID for camp-linked kidnappers. + */ + @Nullable + UUID getAssociatedStructure(); + + /** + * Get the entity's navigation component. + */ + PathNavigation getNavigation(); + + /** + * Get the aggression system for robbery immunity checks. + */ + KidnapperAggressionSystem getAggressionSystem(); + + /** + * Get the job manager for worker UUID checks. + */ + KidnapperJobManager getJobManager(); + + /** + * Get the collar config for kidnapping mode validation. + */ + KidnapperCollarConfig getCollarConfig(); +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperAIManager.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperAIManager.java new file mode 100644 index 0000000..c21db91 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperAIManager.java @@ -0,0 +1,167 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.ai.kidnapper.*; +import net.minecraft.world.entity.ai.goal.*; +import net.minecraft.world.entity.player.Player; + +/** + * KidnapperAIManager - Manages AI goal registration. + * Phase 3.2: AI management component (~79 lines). + * + * Handles: + * - Registering all 20 AI goals with priorities + * - Does NOT call super() - kidnappers have completely different AI than damsels + * + *

Low complexity - Straightforward goal registration.

+ */ +public class KidnapperAIManager { + + // ======================================== + // FIELDS + // ======================================== + + /** Entity reference (needed for goal constructors) */ + private final EntityKidnapper entity; + + /** Host callbacks */ + private final IAIHost host; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public KidnapperAIManager(EntityKidnapper entity, IAIHost host) { + this.entity = entity; + this.host = host; + } + + // ======================================== + // AI GOAL REGISTRATION + // ======================================== + + /** + * Register all AI goals for the kidnapper. + * CRITICAL: Does NOT call super() - kidnappers have completely different AI than damsels. + */ + public void registerGoals() { + // Don't call super - kidnapper has completely different AI than damsel + + // Priority 0: Always swim + host.getGoalSelector().addGoal(0, new FloatGoal(this.entity)); + + // Priority 1: Fight back if attacked while holding captive (MEDIUM FIX: higher priority - self defense) + host + .getGoalSelector() + .addGoal(1, new KidnapperFightBackGoal(this.entity)); + + // Priority 1: Self-defense when attacked without captives (prevents PROTECTED exploit) + host + .getGoalSelector() + .addGoal(1, new KidnapperSelfDefenseGoal(this.entity)); + + // Priority 2: Hunt monsters (camp kidnappers only - protect captives) + host + .getGoalSelector() + .addGoal(2, new KidnapperHuntMonstersGoal(this.entity)); + + // Priority 3: ALERT - Search for escaped prisoner (high priority) (MEDIUM FIX: bumped from 2 to 3) + host.getGoalSelector().addGoal(3, new KidnapperAlertGoal(this.entity)); + + // Priority 3: Find and capture targets + host + .getGoalSelector() + .addGoal(3, new KidnapperFindTargetGoal(this.entity, 25)); + + // Priority 3: PUNISH - Punish recaptured prisoner (after Alert, before Capture) + host.getGoalSelector().addGoal(3, new KidnapperPunishGoal(this.entity)); + + // Priority 4: Capture target + host + .getGoalSelector() + .addGoal(4, new KidnapperCaptureGoal(this.entity)); + + // Priority 4: Recapture escaped captives (same priority as capture, but checked after) + host + .getGoalSelector() + .addGoal(4, new KidnapperRecaptureGoal(this.entity)); + + // Priority 5: Bring slave to cell (unified goal handles both camp and player cells) + host + .getGoalSelector() + .addGoal(5, new KidnapperBringToCellGoal(this.entity)); + + // Priority 6: Decide what to do with slave (sell or job) + host + .getGoalSelector() + .addGoal(6, new KidnapperDecideNextActionGoal(this.entity)); + + // Priority 6: Dispersal - Force movement away when too many near camp center + host + .getGoalSelector() + .addGoal(6, new KidnapperDispersalGoal(this.entity)); + + // Priority 7: GUARD - Watch over occupied cells + host.getGoalSelector().addGoal(7, new KidnapperGuardGoal(this.entity)); + + // Priority 7: Thief behavior for wild kidnappers (steal items and flee) + // BUG FIX (Phase 2): DISABLED - Integrated into DecideNextActionGoal + // Problem: ThiefGoal (P7) never executed because DecideNextActionGoal (P6) + // finished first and changed state (sell/job), blocking ThiefGoal activation. + // Solution: Integrated theft as a 3rd option (30% chance) in DecideNextActionGoal. + // The standalone ThiefGoal is now DEAD CODE and has been disabled. + // host.getGoalSelector().addGoal(7, new KidnapperThiefGoal(this.entity)); + + // Priority 7: Walk prisoner (DISABLED - chunk unload loses goal state, + // causing prisoners to be sold/stolen instead of returned to cell) + // host.getGoalSelector().addGoal(7, new KidnapperWalkPrisonerGoal(this.entity)); + + // Priority 8: Patrol when no slave (close to camp) + host.getGoalSelector().addGoal(8, new KidnapperPatrolGoal(this.entity)); + + // Priority 8: Hunt far from camp (only for hunters) + host.getGoalSelector().addGoal(8, new KidnapperHuntGoal(this.entity)); + + // Priority 9-10: Flee behavior (getOutState) + host + .getGoalSelector() + .addGoal(9, new KidnapperFleeWithCaptiveGoal(this.entity)); + host + .getGoalSelector() + .addGoal(10, new KidnapperFleeSafeGoal(this.entity)); + + // Priority 11-12: Sale and Job systems + host + .getGoalSelector() + .addGoal(11, new KidnapperWaitForBuyerGoal(this.entity)); + host + .getGoalSelector() + .addGoal(12, new KidnapperWaitForJobGoal(this.entity)); + + // Priority 13-15: Basic movement (lower priority) + host + .getGoalSelector() + .addGoal(13, new WaterAvoidingRandomStrollGoal(this.entity, 1.0D)); + // Use custom LookAtPlayerGoal that ignores players in cells + host + .getGoalSelector() + .addGoal( + 14, + new KidnapperLookAtPlayerGoal(this.entity, Player.class, 8.0F) + ); + host + .getGoalSelector() + .addGoal(15, new RandomLookAroundGoal(this.entity)); + + // Priority 16: Open doors + host + .getGoalSelector() + .addGoal(16, new OpenDoorGoal(this.entity, false)); + + TiedUpMod.LOGGER.debug( + "[EntityKidnapper] Registered {} AI goals (ThiefGoal disabled)", + 20 + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperAggressionSystem.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperAggressionSystem.java new file mode 100644 index 0000000..3ff3c49 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperAggressionSystem.java @@ -0,0 +1,308 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.EntityDialogueManager; +import com.tiedup.remake.state.IBondageState; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.world.entity.LivingEntity; + +/** + * KidnapperAggressionSystem - Manages aggression tracking and immunity. + * Phase 1.3: Aggression management component (151 lines). + * + * Handles three aggression subsystems: + * 1. **Escaped Target Memory** - Remember escapees for pursuit (30 seconds) + * 2. **Fight Back System** - Track last attacker for retaliation (5 seconds) + * 3. **Robbery Immunity** - Prevent repeated targeting after robbery (2 minutes) + * + *

Low complexity - Simple state tracking with time-based expiration.

+ */ +public class KidnapperAggressionSystem { + + // ======================================== + // CONSTANTS + // ======================================== + + /** How long to remember an escaped target (30 seconds = 600 ticks) */ + private static final long ESCAPED_TARGET_MEMORY = 600; + + /** Duration of robbed immunity (2 minutes = 2400 ticks) */ + private static final long ROBBED_IMMUNITY_DURATION = 2400L; + + /** How long to remember last attacker for fight-back (5 seconds = 100 ticks) */ + private static final int FIGHT_BACK_MEMORY = 100; + + // ======================================== + // FIELDS + // ======================================== + + /** Host callbacks */ + private final IAggressionHost host; + + // Escaped Target Tracking + /** Entity that escaped from this kidnapper */ + @Nullable + private LivingEntity escapedTarget = null; + + /** Tick count when captive escaped */ + private long escapedTargetTime = 0; + + // Fight Back System + /** Last entity that attacked this kidnapper */ + @Nullable + private LivingEntity lastAttacker = null; + + /** Tick count when last attacked */ + private int lastAttackTime = 0; + + // Robbery Immunity System + /** + * Map of player UUIDs to game time when immunity expires. + * Players robbed by this kidnapper cannot be targeted again for 2 minutes. + */ + private final Map robbedImmunity = new HashMap<>(); + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public KidnapperAggressionSystem(IAggressionHost host) { + this.host = host; + } + + // ======================================== + // ESCAPED TARGET SYSTEM + // ======================================== + + /** + * Called when a captive escapes from this kidnapper. + * Records the escaped entity for potential recapture. + * + * @param escaped The entity that escaped (IBondageState or LivingEntity) + */ + public void onCaptiveEscaped(@Nullable Object escaped) { + LivingEntity escapee = null; + + if (escaped instanceof IBondageState kidnapped) { + if (kidnapped instanceof LivingEntity living) { + escapee = living; + } + } else if (escaped instanceof LivingEntity living) { + escapee = living; + } + + if (escapee != null) { + this.escapedTarget = escapee; + this.escapedTargetTime = getCurrentTick(); + + TiedUpMod.LOGGER.debug( + "[KidnapperAggressionSystem] {} remembering escapee: {}", + host.getNpcName(), + escapee.getName().getString() + ); + + // Trigger alert dialogue + host.talkToPlayersInRadius( + EntityDialogueManager.DialogueCategory.CAPTURE_ESCAPE, + 20 + ); + } + } + + /** + * Get the escaped target if still remembered. + * Returns null if memory expired or target is dead/removed. + * + * @return The escaped entity, or null if forgotten/dead + */ + @Nullable + public LivingEntity getEscapedTarget() { + if (this.escapedTarget == null) { + return null; + } + + // Check if memory expired (30 seconds) + long currentTick = getCurrentTick(); + if (currentTick - this.escapedTargetTime > ESCAPED_TARGET_MEMORY) { + this.escapedTarget = null; + this.escapedTargetTime = 0; + return null; + } + + // Check if target is still valid + if (!this.escapedTarget.isAlive() || this.escapedTarget.isRemoved()) { + this.escapedTarget = null; + this.escapedTargetTime = 0; + return null; + } + + return this.escapedTarget; + } + + /** + * Clear the escaped target memory. + */ + public void clearEscapedTarget() { + this.escapedTarget = null; + this.escapedTargetTime = 0; + } + + // ======================================== + // FIGHT BACK SYSTEM + // ======================================== + + /** + * Get the last entity that attacked this kidnapper. + * Returns null if attack was more than 5 seconds ago. + * + * @return The attacker if recent, null otherwise + */ + @Nullable + public LivingEntity getLastAttacker() { + if (this.lastAttacker == null) { + return null; + } + + // Check if attack was recent (5 seconds = 100 ticks) + int currentTick = (int) getCurrentTick(); + if (currentTick - this.lastAttackTime > FIGHT_BACK_MEMORY) { + return null; + } + + return this.lastAttacker; + } + + /** + * Set the last attacker for fight-back mechanics. + * + * @param attacker The entity that attacked + */ + public void setLastAttacker(LivingEntity attacker) { + this.lastAttacker = attacker; + this.lastAttackTime = (int) getCurrentTick(); + } + + // ======================================== + // ROBBERY IMMUNITY SYSTEM + // ======================================== + + /** + * Grant robbery immunity to a player for 2 minutes. + * Players with immunity cannot be targeted by this kidnapper. + * + * @param playerUUID The player's UUID + */ + public void grantRobbedImmunity(UUID playerUUID) { + long expirationTime = getCurrentTick() + ROBBED_IMMUNITY_DURATION; + this.robbedImmunity.put(playerUUID, expirationTime); + + TiedUpMod.LOGGER.debug( + "[KidnapperAggressionSystem] {} granted robbery immunity to player {}", + host.getNpcName(), + playerUUID + ); + } + + /** + * Check if a player has active robbery immunity. + * Lazily removes expired immunity during check. + * + * @param playerUUID The player's UUID + * @return true if player has active immunity + */ + public boolean hasRobbedImmunity(UUID playerUUID) { + Long expirationTime = this.robbedImmunity.get(playerUUID); + if (expirationTime == null) { + return false; + } + + // Check if immunity expired (lazy cleanup) + long currentTick = getCurrentTick(); + if (currentTick >= expirationTime) { + this.robbedImmunity.remove(playerUUID); + return false; + } + + return true; + } + + /** + * Clear robbery immunity for a specific player. + * + * @param playerUUID The player's UUID + */ + public void clearRobbedImmunity(UUID playerUUID) { + this.robbedImmunity.remove(playerUUID); + } + + /** + * Clear all robbery immunities (e.g., on death/removal). + */ + public void clearAllRobbedImmunities() { + this.robbedImmunity.clear(); + } + + // ======================================== + // HELPER METHODS + // ======================================== + + /** + * Get current game tick from world. + */ + private long getCurrentTick() { + if (host.level() != null) { + return host.level().getGameTime(); + } + return 0; + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + /** + * Save aggression data to NBT. + * Note: Only saves robbery immunity map, not transient tracking fields. + */ + public void saveToNBT(net.minecraft.nbt.CompoundTag tag) { + // Save robbery immunity map + if (!this.robbedImmunity.isEmpty()) { + net.minecraft.nbt.CompoundTag immunityTag = + new net.minecraft.nbt.CompoundTag(); + for (Map.Entry entry : this.robbedImmunity.entrySet()) { + immunityTag.putLong( + entry.getKey().toString(), + entry.getValue() + ); + } + tag.put("RobbedImmunity", immunityTag); + } + } + + /** + * Load aggression data from NBT. + */ + public void loadFromNBT(net.minecraft.nbt.CompoundTag tag) { + // Load robbery immunity map + if (tag.contains("RobbedImmunity")) { + net.minecraft.nbt.CompoundTag immunityTag = tag.getCompound( + "RobbedImmunity" + ); + for (String key : immunityTag.getAllKeys()) { + try { + UUID playerUUID = UUID.fromString(key); + long expirationTime = immunityTag.getLong(key); + this.robbedImmunity.put(playerUUID, expirationTime); + } catch (IllegalArgumentException e) { + TiedUpMod.LOGGER.warn( + "[KidnapperAggressionSystem] Invalid UUID in robbery immunity: {}", + key + ); + } + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperAlertManager.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperAlertManager.java new file mode 100644 index 0000000..e61bcb3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperAlertManager.java @@ -0,0 +1,211 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.ai.kidnapper.KidnapperState; +import java.util.List; +import org.jetbrains.annotations.Nullable; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.LivingEntity; + +/** + * KidnapperAlertManager - Manages alert system between kidnappers. + * Phase 2.1: Alert management component (~75 lines). + * + * Handles: + * - Broadcasting alerts about escaped captives to nearby kidnappers + * - Receiving alert broadcasts from other kidnappers + * - Alert cooldown to prevent spam (5 second cooldown = 100 ticks) + * - Alert target tracking for ALERT state + * + *

Low complexity - Simple broadcast/receive pattern with cooldown.

+ */ +public class KidnapperAlertManager { + + // ======================================== + // CONSTANTS + // ======================================== + + /** Cooldown duration before receiving another alert (5 seconds = 100 ticks) */ + private static final int ALERT_COOLDOWN_DURATION = 100; + + /** Broadcast radius for alerts (50 blocks horizontal, 20 blocks vertical) */ + private static final double BROADCAST_RADIUS_HORIZONTAL = 50.0; + private static final double BROADCAST_RADIUS_VERTICAL = 20.0; + + // ======================================== + // FIELDS + // ======================================== + + /** Host callbacks */ + private final IAlertHost host; + + /** Entity reference (needed for getEntitiesOfClass filtering) */ + private final EntityKidnapper entity; + + /** Target entity being searched for during ALERT state */ + @Nullable + private LivingEntity alertTarget; + + /** Cooldown before receiving another alert broadcast (in ticks) */ + private int alertCooldown = 0; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public KidnapperAlertManager(EntityKidnapper entity, IAlertHost host) { + this.entity = entity; + this.host = host; + } + + // ======================================== + // ALERT TARGET TRACKING + // ======================================== + + /** + * Get the current alert target. + * + * @return The target entity being searched for, or null if none + */ + @Nullable + public LivingEntity getAlertTarget() { + return this.alertTarget; + } + + /** + * Set the alert target. + * + * @param target The target to search for + */ + public void setAlertTarget(@Nullable LivingEntity target) { + this.alertTarget = target; + } + + // ======================================== + // ALERT BROADCASTING + // ======================================== + + /** + * Broadcast an alert about an escaped captive to nearby kidnappers. + * Searches for kidnappers within 50 blocks horizontally and 20 blocks vertically. + * Only kidnappers in states that can receive alerts will be notified. + * + * @param escapee The entity that escaped + */ + public void broadcastAlert(LivingEntity escapee) { + if (!(host.level() instanceof ServerLevel serverLevel)) { + return; + } + if (escapee == null) { + return; + } + + // Find nearby kidnappers that can receive alerts + List nearby = serverLevel.getEntitiesOfClass( + EntityKidnapper.class, + host + .getBoundingBox() + .inflate( + BROADCAST_RADIUS_HORIZONTAL, + BROADCAST_RADIUS_VERTICAL, + BROADCAST_RADIUS_HORIZONTAL + ), + k -> k != this.entity && k.getCurrentState().canReceiveAlerts() + ); + + TiedUpMod.LOGGER.info( + "[KidnapperAlertManager] {} broadcasting alert about {} to {} kidnappers", + host.getNpcName(), + escapee.getName().getString(), + nearby.size() + ); + + // Send alert to each nearby kidnapper + for (EntityKidnapper other : nearby) { + other.receiveAlertBroadcast(escapee, this.entity); + } + } + + // ======================================== + // ALERT RECEIVING + // ======================================== + + /** + * Receive an alert broadcast from another kidnapper. + * Ignores alerts if: + * - On cooldown (prevents spam) + * - Already has captives + * - Is tied up + * + * If accepted, sets alert target and switches to ALERT state. + * + * @param escapee The entity that escaped + * @param source The kidnapper that sent the alert + */ + public void receiveAlertBroadcast( + LivingEntity escapee, + EntityKidnapper source + ) { + // On cooldown, ignore alert + if (this.alertCooldown > 0) { + return; + } + + // Don't respond if we already have a captive or are tied up + if (host.hasCaptives() || host.isTiedUp()) { + return; + } + + // Set alert target and state + this.setAlertTarget(escapee); + host.setCurrentState(KidnapperState.ALERT); + this.alertCooldown = ALERT_COOLDOWN_DURATION; // 5 second cooldown + + TiedUpMod.LOGGER.info( + "[KidnapperAlertManager] {} received alert from {} about {}", + host.getNpcName(), + source.getNpcName(), + escapee.getName().getString() + ); + } + + // ======================================== + // TICK PROCESSING + // ======================================== + + /** + * Called each tick to update alert system state. + * Decrements the alert cooldown timer. + */ + public void tick() { + // Decrement alert cooldown + if (this.alertCooldown > 0) { + this.alertCooldown--; + } + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + /** + * Save alert data to NBT. + * Note: alertTarget is transient and not saved (recalculated on load). + * Note: alertCooldown is transient and not saved (resets on load). + */ + public void saveToNBT(net.minecraft.nbt.CompoundTag tag) { + // Alert system uses only transient runtime state + // No persistent data needs to be saved + } + + /** + * Load alert data from NBT. + * Note: alertTarget is transient and not loaded. + * Note: alertCooldown is transient and not loaded. + */ + public void loadFromNBT(net.minecraft.nbt.CompoundTag tag) { + // Alert system uses only transient runtime state + // No persistent data needs to be loaded + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperAppearance.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperAppearance.java new file mode 100644 index 0000000..4470d45 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperAppearance.java @@ -0,0 +1,396 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.KidnapperItemSelector; +import com.tiedup.remake.entities.KidnapperTheme; +import com.tiedup.remake.entities.KidnapperVariant; +import com.tiedup.remake.entities.skins.Gender; +import com.tiedup.remake.entities.skins.KidnapperSkinManager; +import com.tiedup.remake.items.base.ItemColor; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; + +/** + * KidnapperAppearance - Manages visual appearance and item selection. + * Phase 1.2: Appearance management component (~220 lines). + * + * Handles: + * - Kidnapper skin variants (texture selection) + * - Item themes (bind/gag sets) + * - Theme colors + * - Item selection results + * - Initialization and persistence + * + *

Medium complexity - Lazy loading, synced data integration.

+ */ +public class KidnapperAppearance { + + // ======================================== + // FIELDS + // ======================================== + + /** Parent entity for accessing static data accessors */ + private final EntityKidnapper entity; + + /** Host callbacks */ + private final IAppearanceHost host; + + /** DATA accessors passed in (cannot be moved due to Minecraft static requirements) */ + private final EntityDataAccessor dataVariantId; + private final EntityDataAccessor dataTheme; + private final EntityDataAccessor dataThemeColor; + + /** Current kidnapper skin variant (volatile for thread-safe render access) */ + @Nullable + private volatile KidnapperVariant kidnapperVariant; + + /** Current item theme */ + @Nullable + private KidnapperTheme currentTheme; + + /** Current theme color */ + @Nullable + private ItemColor themeColor; + + /** Selected items for this kidnapper */ + @Nullable + private KidnapperItemSelector.SelectionResult itemSelection; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public KidnapperAppearance( + EntityKidnapper entity, + IAppearanceHost host, + EntityDataAccessor dataVariantId, + EntityDataAccessor dataTheme, + EntityDataAccessor dataThemeColor + ) { + this.entity = entity; + this.host = host; + this.dataVariantId = dataVariantId; + this.dataTheme = dataTheme; + this.dataThemeColor = dataThemeColor; + } + + // ======================================== + // VARIANT SYSTEM + // ======================================== + + /** + * Set kidnapper variant and name. + */ + public void setKidnapperVariant(KidnapperVariant variant) { + if (variant == null) { + TiedUpMod.LOGGER.warn( + "[KidnapperAppearance] Attempted to set null kidnapper variant" + ); + return; + } + + this.kidnapperVariant = variant; + host.setEntityDataString(dataVariantId, variant.id()); + host.setSlimArms(variant.hasSlimArms()); + host.setGender(variant.gender()); + applyVariantName(variant); + } + + /** + * Get current kidnapper variant. + */ + @Nullable + public KidnapperVariant getKidnapperVariant() { + // Try to load from synced variant ID first + if ( + this.kidnapperVariant == null && !getKidnapperVariantId().isEmpty() + ) { + this.kidnapperVariant = lookupVariantById(getKidnapperVariantId()); + } + // Fallback: compute from UUID if not yet synced (client-side race condition fix) + if (this.kidnapperVariant == null) { + this.kidnapperVariant = computeVariantForEntity(host.getUUID()); + } + return this.kidnapperVariant; + } + + /** + * Lookup a variant by ID from the skin manager. + * Delegates to entity's virtual method to allow subclass overrides. + */ + protected KidnapperVariant lookupVariantById(String variantId) { + return entity.lookupVariantById(variantId); + } + + /** + * Compute which variant to use based on UUID. + * Delegates to entity's virtual method to allow subclass overrides. + */ + protected KidnapperVariant computeVariantForEntity(UUID entityUUID) { + return entity.computeVariantForEntity(entityUUID); + } + + /** + * Get the texture folder for this kidnapper type. + * Delegates to entity's virtual method to allow subclass overrides. + */ + protected String getVariantTextureFolder() { + return entity.getVariantTextureFolder(); + } + + /** + * Get the default variant ID for fallback. + * Delegates to entity's virtual method to allow subclass overrides. + */ + protected String getDefaultVariantId() { + return entity.getDefaultVariantId(); + } + + /** + * Apply the name from variant to this entity. + * Delegates to entity's virtual method to allow subclass overrides. + */ + protected void applyVariantName(KidnapperVariant variant) { + entity.applyVariantName(variant); + } + + /** + * Get the NBT key for saving the variant. + * Delegates to entity's virtual method to allow subclass overrides. + */ + protected String getVariantNBTKey() { + return entity.getVariantNBTKey(); + } + + /** + * Get kidnapper variant ID (synced data). + */ + public String getKidnapperVariantId() { + return host.getEntityDataString(dataVariantId); + } + + /** + * Get the skin texture resource location. + * Reads variant ID directly from synced entityData to work on LAN clients + * where SkinManager is not populated. + */ + public ResourceLocation getSkinTexture() { + String variantId = getKidnapperVariantId(); + if (!variantId.isEmpty()) { + return ResourceLocation.fromNamespaceAndPath( + "tiedup", + getVariantTextureFolder() + variantId + ".png" + ); + } + // Fallback to default + return ResourceLocation.fromNamespaceAndPath( + "tiedup", + getVariantTextureFolder() + getDefaultVariantId() + ".png" + ); + } + + /** + * Invalidate variant cache (called when synced data updates from server). + */ + public void invalidateVariantCache() { + this.kidnapperVariant = null; + } + + // ======================================== + // INITIALIZATION + // ======================================== + + /** + * Initialize variant and theme when entity is first added to world. + * Should only be called on server side. + */ + public void initializeAppearance() { + initializeVariantOnSpawn(); + initializeTheme(); + } + + /** + * Initialize variant on spawn (server-side only). + */ + protected void initializeVariantOnSpawn() { + if (host.level() == null || host.level().isClientSide) { + return; + } + + // Only set variant if not already set + if (this.kidnapperVariant == null) { + KidnapperVariant variant = computeVariantForEntity(host.getUUID()); + setKidnapperVariant(variant); + } + } + + /** + * Initialize theme (server-side only). + */ + protected void initializeTheme() { + if (host.level() == null || host.level().isClientSide) { + return; + } + + // Only initialize if not already set + if (this.currentTheme == null) { + this.itemSelection = selectItemsForKidnapper(); + this.currentTheme = this.itemSelection.theme; + this.themeColor = this.itemSelection.color; + + // Sync to client + host.setEntityDataString(dataTheme, this.currentTheme.name()); + if (this.themeColor != null) { + host.setEntityDataString( + dataThemeColor, + this.themeColor.getName() + ); + } + } + } + + /** + * Select themed items for this kidnapper. + * Delegates to entity's virtual method to allow subclass overrides. + */ + protected KidnapperItemSelector.SelectionResult selectItemsForKidnapper() { + return entity.selectItemsForKidnapper(); + } + + // ======================================== + // THEME & ITEM SELECTION + // ======================================== + + /** + * Get current theme (lazy load from synced data if needed). + */ + public KidnapperTheme getTheme() { + if (this.currentTheme == null) { + String themeName = host.getEntityDataString(dataTheme); + if (!themeName.isEmpty()) { + try { + this.currentTheme = KidnapperTheme.valueOf(themeName); + } catch (IllegalArgumentException e) { + TiedUpMod.LOGGER.debug( + "[KidnapperAppearance] Invalid theme from synced data: {}", + themeName + ); + } + } + } + return this.currentTheme; + } + + /** + * Get current theme color (lazy load from synced data if needed). + */ + @Nullable + public ItemColor getThemeColor() { + if (this.themeColor == null) { + String colorName = host.getEntityDataString(dataThemeColor); + if (!colorName.isEmpty()) { + this.themeColor = ItemColor.fromName(colorName); + } + } + return this.themeColor; + } + + /** + * Get item selection result. + */ + @Nullable + public KidnapperItemSelector.SelectionResult getItemSelection() { + return this.itemSelection; + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + /** + * Save appearance data to NBT. + */ + public void saveToNBT(CompoundTag tag) { + // Save kidnapper variant using NBT key + if (this.kidnapperVariant != null) { + tag.putString(getVariantNBTKey(), this.kidnapperVariant.id()); + } + + // Save theme and color + if (this.currentTheme != null) { + tag.putString("Theme", this.currentTheme.name()); + } + if (this.themeColor != null) { + tag.putString("ThemeColor", this.themeColor.getName()); + } + } + + /** + * Load appearance data from NBT. + */ + public void loadFromNBT(CompoundTag tag) { + // Restore kidnapper variant using NBT key and lookup + String nbtKey = getVariantNBTKey(); + if (tag.contains(nbtKey)) { + String variantId = tag.getString(nbtKey); + KidnapperVariant variant = lookupVariantById(variantId); + if (variant != null) { + this.kidnapperVariant = variant; + host.setEntityDataString(dataVariantId, variantId); + } + } + + // Restore theme and color + if (tag.contains("Theme")) { + try { + this.currentTheme = KidnapperTheme.valueOf( + tag.getString("Theme") + ); + host.setEntityDataString(dataTheme, this.currentTheme.name()); + } catch (IllegalArgumentException e) { + TiedUpMod.LOGGER.debug( + "[KidnapperAppearance] Invalid theme from NBT: {}", + tag.getString("Theme") + ); + } + } + if (tag.contains("ThemeColor")) { + this.themeColor = ItemColor.fromName(tag.getString("ThemeColor")); + if (this.themeColor != null) { + host.setEntityDataString( + dataThemeColor, + this.themeColor.getName() + ); + } + } + + // Recreate item selection if theme was loaded + if (this.currentTheme != null) { + this.itemSelection = new KidnapperItemSelector.SelectionResult( + this.currentTheme, + this.themeColor, + KidnapperItemSelector.createBind( + this.currentTheme.getBind(), + this.themeColor + ), + KidnapperItemSelector.createGag( + this.currentTheme.getPrimaryGag(), + this.themeColor + ), + KidnapperItemSelector.createMittens(), + KidnapperItemSelector.createEarplugs(), + this.currentTheme.hasBlindfolds() + ? KidnapperItemSelector.createBlindfold( + this.currentTheme.getPrimaryBlindfold(), + this.themeColor + ) + : ItemStack.EMPTY + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperCampManager.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperCampManager.java new file mode 100644 index 0000000..3da432e --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperCampManager.java @@ -0,0 +1,127 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; + +/** + * KidnapperCampManager - Manages camp/structure association. + * Phase 1.4: Camp management component (67 lines). + * + * Handles: + * - Structure UUID tracking (which camp this kidnapper belongs to) + * - Hunter role assignment (patrols far vs stays close to camp) + * + *

Low complexity - Simple state tracking with smart initialization.

+ */ +public class KidnapperCampManager { + + // ======================================== + // FIELDS + // ======================================== + + /** Host callbacks */ + private final ICampHost host; + + /** UUID of the structure this kidnapper belongs to (for marker queries) */ + @Nullable + private UUID associatedStructure; + + /** + * Whether this kidnapper is a "hunter" that patrols far from camp. + * Hunters patrol 80-150 blocks from camp looking for prey. + * Non-hunters stay closer (GUARD/PATROL within 40 blocks). + * Decided randomly when associated with a camp (50/50 chance). + */ + private boolean isHunter = false; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public KidnapperCampManager(ICampHost host) { + this.host = host; + } + + // ======================================== + // STRUCTURE ASSOCIATION + // ======================================== + + /** + * Get the associated structure UUID for this kidnapper. + * + * @return The structure UUID, or null if not associated + */ + @Nullable + public UUID getAssociatedStructure() { + return this.associatedStructure; + } + + /** + * Set the associated structure UUID. + * When first associated with a camp, randomly decides if this kidnapper + * becomes a "hunter" (50% chance) that patrols far from camp. + * + * @param structureId The structure this kidnapper belongs to + */ + public void setAssociatedStructure(@Nullable UUID structureId) { + // If being newly associated with a camp, randomly assign hunter role + if (structureId != null && this.associatedStructure == null) { + this.isHunter = host.getRandom().nextBoolean(); // 50% chance + TiedUpMod.LOGGER.debug( + "[KidnapperCampManager] {} assigned to camp, isHunter: {}", + host.getNpcName(), + this.isHunter + ); + } + this.associatedStructure = structureId; + } + + // ======================================== + // HUNTER ROLE + // ======================================== + + /** + * Check if this kidnapper is a "hunter" that patrols far from camp. + * + * @return true if this kidnapper hunts in the wilderness + */ + public boolean isHunter() { + return this.isHunter; + } + + /** + * Set whether this kidnapper is a hunter. + * + * @param hunter true to make this kidnapper a hunter + */ + public void setHunter(boolean hunter) { + this.isHunter = hunter; + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + /** + * Save camp data to NBT. + */ + public void saveToNBT(net.minecraft.nbt.CompoundTag tag) { + if (this.associatedStructure != null) { + tag.putUUID("AssociatedStructure", this.associatedStructure); + } + tag.putBoolean("IsHunter", this.isHunter); + } + + /** + * Load camp data from NBT. + */ + public void loadFromNBT(net.minecraft.nbt.CompoundTag tag) { + if (tag.contains("AssociatedStructure")) { + this.associatedStructure = tag.getUUID("AssociatedStructure"); + } + if (tag.contains("IsHunter")) { + this.isHunter = tag.getBoolean("IsHunter"); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperCaptiveManager.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperCaptiveManager.java new file mode 100644 index 0000000..f9759ac --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperCaptiveManager.java @@ -0,0 +1,988 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.EntityDialogueManager; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.ai.kidnapper.KidnapperState; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerRecord; +import com.tiedup.remake.prison.PrisonerState; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.state.ICaptor; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.util.RestraintApplicator; +import java.util.Random; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.Vec3; + +/** + * KidnapperCaptiveManager - Manages captive lifecycle and ICaptor implementation. + * Phase 3.1: Captive management component (~400 lines) - CRITICAL. + * + * Handles: + * - ICaptor interface implementation (10 methods) + * - Captive tracking (currentCaptive, pendingCaptiveUUID) + * - UUID lazy resolution after chunk reload + * - Lifecycle hooks (putBindOn, die, remove, tick) + * - Abandon/keep captive logic (solo mode fallback) + * - Struggle and attack punishment + * - Leash physics (visual positioning) + * - NBT serialization (backward compatible SlaveUUID → CaptiveUUID) + * + *

VERY HIGH complexity - Critical captive state management with UUID resolution, + * leash physics, and punishment flows. Bugs here cause captive loss or state corruption.

+ */ +public class KidnapperCaptiveManager { + + // ======================================== + // FIELDS + // ======================================== + + /** Host callbacks */ + private final ICaptiveHost host; + + /** Entity reference (needed for leash attachment and UUID lookups) */ + private final EntityKidnapper entity; + + /** Entity data accessor for has-captive sync */ + private final EntityDataAccessor dataHasCaptive; + + /** Current captive being held */ + @Nullable + private IRestrainable currentCaptive; + + /** UUID of captive to restore after chunk/server reload */ + @Nullable + private UUID pendingCaptiveUUID; + + /** Retry counter for pending captive restoration (ticks) */ + private int pendingCaptiveRetryTicks = 0; + + /** Max ticks to retry before giving up (30 seconds = 600 ticks) */ + private static final int MAX_PENDING_CAPTIVE_RETRY_TICKS = 600; + + /** Flag to allow captive transfer (prevents re-capture during transfer) */ + private boolean allowCaptiveTransferFlag = false; + + /** Target for struggle punishment (shock/tighten) */ + @Nullable + private LivingEntity strugglePunishmentTarget; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public KidnapperCaptiveManager( + EntityKidnapper entity, + ICaptiveHost host, + EntityDataAccessor dataHasCaptive + ) { + this.entity = entity; + this.host = host; + this.dataHasCaptive = dataHasCaptive; + } + + // ======================================== + // I_KIDNAPPER IMPLEMENTATION + // ======================================== + + /** + * Add a captive to this kidnapper. + * For NPCs: Attach vanilla leash. + * For Players: Leash already attached by PlayerBindState mixin. + */ + public void addCaptive(IRestrainable captive) { + if (captive == null) return; + + // Guard: don't overwrite an existing captive - prevents state corruption + if (this.currentCaptive != null && this.currentCaptive != captive) { + TiedUpMod.LOGGER.warn( + "[KidnapperCaptiveManager] {} tried to capture {} but already has captive {} - rejecting", + host.getNpcName(), + captive.getKidnappedName(), + this.currentCaptive.getKidnappedName() + ); + return; + } + + // For Players: Leash is already attached by PlayerBindState.getCapturedBy() + // For NPCs: Attach vanilla leash directly + LivingEntity captiveEntity = captive.asLivingEntity(); + if (captiveEntity instanceof Mob mob) { + // NPC captive - use vanilla leash + mob.setLeashedTo(this.entity, true); + } + // Player captives have LeashProxyEntity attached via mixin - no action needed here + + this.currentCaptive = captive; + host.setEntityData(dataHasCaptive, true); + + TiedUpMod.LOGGER.info( + "[KidnapperCaptiveManager] {} captured {}", + host.getNpcName(), + captive.getKidnappedName() + ); + } + + /** + * Remove a captive from this kidnapper. + * For NPCs: Detach vanilla leash. + * For Players: Leash detached by PlayerBindState mixin. + * + * @param captive The captive to remove + * @param transportState true if captive is being transported (no leash drop item) + */ + public void removeCaptive(IRestrainable captive, boolean transportState) { + if (captive == null || captive != this.currentCaptive) return; + + // For Players: Leash detached by PlayerBindState.free() + // For NPCs: Detach vanilla leash without dropping item + LivingEntity captiveEntity = captive.asLivingEntity(); + if (captiveEntity instanceof Mob mob && mob.isLeashed()) { + mob.dropLeash(false, false); // Don't drop leash item when imprisoning + } + // Player captives have LeashProxyEntity removed via mixin free() - no action needed here + + String captiveName = this.currentCaptive.getKidnappedName(); + this.currentCaptive = null; + host.setEntityData(dataHasCaptive, false); + + TiedUpMod.LOGGER.info( + "[KidnapperCaptiveManager] {} released captive {}", + host.getNpcName(), + captiveName + ); + } + + /** + * Check if this kidnapper can capture a target. + * Validates collar ownership if target has collar. + * Also checks PrisonerManager state to prevent capturing already-imprisoned players. + */ + public boolean canCapture(IRestrainable target) { + if (target == null) return false; + + // Check if target is already in any non-FREE state + // Only FREE players can be targeted by kidnappers + if (host.level() instanceof ServerLevel serverLevel) { + PrisonerManager manager = PrisonerManager.get(serverLevel); + PrisonerState state = manager.getState( + target.asLivingEntity().getUUID() + ); + + if (!state.isTargetable()) { + TiedUpMod.LOGGER.debug( + "[KidnapperCaptiveManager] {} can't capture {} - not targetable (state={})", + host.getNpcName(), + target.getKidnappedName(), + state + ); + return false; + } + } + + // If target has collar, verify we own it + if (target.hasCollar()) { + ItemStack collar = target.getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemCollar collarItem) { + // Check if THIS kidnapper is owner + if (!collarItem.getOwners(collar).contains(host.getUUID())) { + TiedUpMod.LOGGER.debug( + "[KidnapperCaptiveManager] {} can't capture {} - collar owned by someone else", + host.getNpcName(), + target.getKidnappedName() + ); + return false; // Not our collar + } + } + } + + return true; + } + + /** + * Check if captive can be released. + */ + public boolean canRelease(IRestrainable captive) { + return captive != null && captive == this.currentCaptive; + } + + /** + * Check if captive transfer is allowed. + * Used to prevent re-capture during transfer between kidnappers. + */ + public boolean allowCaptiveTransfer() { + // Can transfer if explicitly flagged OR if we're tied up (forced release) + return this.allowCaptiveTransferFlag || host.isTiedUp(); + } + + /** + * Get the captive transfer flag. + */ + public boolean getAllowCaptiveTransferFlag() { + return this.allowCaptiveTransferFlag; + } + + /** + * Set the captive transfer flag. + */ + public void setAllowCaptiveTransferFlag(boolean flag) { + this.allowCaptiveTransferFlag = flag; + } + + /** + * Check if multiple captives are allowed. + */ + public boolean allowMultipleCaptives() { + return false; // Kidnappers only hold one captive at a time + } + + /** + * Called when a captive (player) logs out. + * Releases the captive gracefully. + */ + public void onCaptiveLogout(IRestrainable captive) { + if (captive == this.currentCaptive) { + TiedUpMod.LOGGER.info( + "[KidnapperCaptiveManager] {} captive {} logged out - releasing", + host.getNpcName(), + captive.getKidnappedName() + ); + removeCaptive(captive, false); + } + } + + /** + * Called when a captive is released/freed. + * Tracks escape, broadcasts alert, and transitions to IDLE. + */ + public void onCaptiveReleased(IRestrainable releasedCaptive) { + if (releasedCaptive != this.currentCaptive) return; + + TiedUpMod.LOGGER.info( + "[KidnapperCaptiveManager] {} captive {} was released", + host.getNpcName(), + releasedCaptive.getKidnappedName() + ); + + LivingEntity escapedEntity = releasedCaptive.asLivingEntity(); + + // Track escapee for potential recapture (30 seconds) + host.getAggressionSystem().onCaptiveEscaped(escapedEntity); + + // Broadcast alert to nearby kidnappers + host.broadcastAlert(escapedEntity); + + // Clear captive reference + this.currentCaptive = null; + host.setEntityData(dataHasCaptive, false); + + // Return to IDLE state + host.setCurrentState(KidnapperState.IDLE); + } + + /** + * Called when a captive struggles. + * Currently no special handling needed. + */ + public void onCaptiveStruggle(IRestrainable captive) { + // No-op - struggle punishment handled by onStruggleDetected() separately + } + + /** + * Check if this kidnapper has captives. + */ + public boolean hasCaptives() { + return this.currentCaptive != null; + } + + /** + * Get the entity implementing ICaptor. + */ + public LivingEntity getEntity() { + return this.entity; + } + + // ======================================== + // CAPTIVE ACCESS + // ======================================== + + /** + * Get the current captive. + */ + @Nullable + public IRestrainable getCaptive() { + return this.currentCaptive; + } + + /** + * Transfer all captives to another kidnapper. + * Updates PrisonerManager with new captor. + */ + public void transferAllCaptivesTo(ICaptor target) { + if (!this.hasCaptives() || target == null) return; + + // Update captivity state before transfer + if ( + host.level() instanceof ServerLevel serverLevel && + target.getEntity() != null + ) { + PrisonerManager manager = PrisonerManager.get(serverLevel); + UUID captiveId = this.currentCaptive.asLivingEntity().getUUID(); + PrisonerRecord record = manager.getRecord(captiveId); + // Update captor ID + record.setCaptorId(target.getEntity().getUUID()); + } + + // Transfer captive + this.allowCaptiveTransferFlag = true; + this.currentCaptive.transferCaptivityTo(target); + this.allowCaptiveTransferFlag = false; + } + + // ======================================== + // UUID RESTORATION (CRITICAL) + // ======================================== + + /** + * Restore captive reference from UUID after chunk/server reload. + * Called from customServerAiStep() when pendingCaptiveUUID is set but currentCaptive is null. + * This is CRITICAL for maintaining captive state across save/load cycles. + * + * FIX: Now retries for up to 30 seconds before giving up, to handle: + * - Players temporarily offline + * - Chunks not yet loaded + * - Entity loading delays + */ + public void restoreCaptiveFromUUID() { + if (this.pendingCaptiveUUID == null) return; + if (!(host.level() instanceof ServerLevel serverLevel)) return; + + // Find entity by UUID + net.minecraft.world.entity.Entity foundEntity = serverLevel.getEntity( + this.pendingCaptiveUUID + ); + + if (foundEntity == null) { + // Entity not found - increment retry counter + this.pendingCaptiveRetryTicks++; + + if ( + this.pendingCaptiveRetryTicks >= MAX_PENDING_CAPTIVE_RETRY_TICKS + ) { + // Timeout reached - give up + TiedUpMod.LOGGER.warn( + "[KidnapperCaptiveManager] {} giving up on captive UUID {} after {} ticks", + host.getNpcName(), + this.pendingCaptiveUUID, + this.pendingCaptiveRetryTicks + ); + this.pendingCaptiveUUID = null; + this.pendingCaptiveRetryTicks = 0; + } else if (this.pendingCaptiveRetryTicks % 100 == 0) { + // Log every 5 seconds at debug level + TiedUpMod.LOGGER.debug( + "[KidnapperCaptiveManager] {} waiting for captive UUID {} ({}/{})", + host.getNpcName(), + this.pendingCaptiveUUID, + this.pendingCaptiveRetryTicks, + MAX_PENDING_CAPTIVE_RETRY_TICKS + ); + } + return; + } + + if (!(foundEntity instanceof LivingEntity livingEntity)) { + TiedUpMod.LOGGER.warn( + "[KidnapperCaptiveManager] {} found captive entity {} but it's not LivingEntity", + host.getNpcName(), + foundEntity.getName().getString() + ); + this.pendingCaptiveUUID = null; + this.pendingCaptiveRetryTicks = 0; + return; + } + + // Get kidnapped state + IRestrainable kidnappedState = KidnappedHelper.getKidnappedState( + livingEntity + ); + if (kidnappedState == null) { + TiedUpMod.LOGGER.warn( + "[KidnapperCaptiveManager] {} found captive {} but it doesn't have IRestrainable state", + host.getNpcName(), + livingEntity.getName().getString() + ); + this.pendingCaptiveUUID = null; + this.pendingCaptiveRetryTicks = 0; + return; + } + + // Verify captive is still marked as captured by this kidnapper + ICaptor captor = kidnappedState.getCaptor(); + UUID captorUUID = (captor != null && captor.getEntity() != null) + ? captor.getEntity().getUUID() + : null; + if ( + !kidnappedState.isCaptive() || + captorUUID == null || + !captorUUID.equals(host.getUUID()) + ) { + TiedUpMod.LOGGER.warn( + "[KidnapperCaptiveManager] {} found captive {} but it's not captured by us (captive={}, captor={})", + host.getNpcName(), + livingEntity.getName().getString(), + kidnappedState.isCaptive(), + captorUUID + ); + this.pendingCaptiveUUID = null; + this.pendingCaptiveRetryTicks = 0; + return; + } + + // Restore reference - success! + this.currentCaptive = kidnappedState; + host.setEntityData(dataHasCaptive, true); + this.pendingCaptiveUUID = null; + this.pendingCaptiveRetryTicks = 0; + + TiedUpMod.LOGGER.info( + "[KidnapperCaptiveManager] {} restored captive {} from UUID", + host.getNpcName(), + kidnappedState.getKidnappedName() + ); + } + + /** + * Tick processing for UUID restoration and captive validation. + * Call this from EntityKidnapper.customServerAiStep(). + */ + public void tick() { + // FIX: Check if current captive is still alive (died, disconnected, etc.) + // Without this, kidnapper keeps reference to dead captive, blocking all goals + if (this.currentCaptive != null) { + LivingEntity captiveEntity = this.currentCaptive.asLivingEntity(); + if (captiveEntity == null || !captiveEntity.isAlive()) { + TiedUpMod.LOGGER.info( + "[KidnapperCaptiveManager] {} captive died or was removed - clearing reference", + host.getNpcName() + ); + // Clear reference without dropping leash (entity is already gone) + this.currentCaptive = null; + host.setEntityData(dataHasCaptive, false); + host.setCurrentState(KidnapperState.IDLE); + } + } + + // Restore captive from UUID after chunk/server reload + if (this.pendingCaptiveUUID != null && this.currentCaptive == null) { + restoreCaptiveFromUUID(); + } + } + + // ======================================== + // LIFECYCLE HOOKS + // ======================================== + + /** + * Called when kidnapper gets bound/tied up. + * Releases captive and updates PrisonerManager. + */ + public void onPutBindOn() { + if (!this.hasCaptives()) return; + + // Capture local reference - escape() may trigger onCaptiveReleased() which nulls the field + IRestrainable captive = this.currentCaptive; + if (captive == null) return; + + TiedUpMod.LOGGER.info( + "[KidnapperCaptiveManager] {} got tied up - releasing captive {}", + host.getNpcName(), + captive.getKidnappedName() + ); + + // Release prisoner from captivity state + if (host.level() instanceof ServerLevel serverLevel) { + UUID captiveUUID = captive.asLivingEntity().getUUID(); + PrisonerManager manager = PrisonerManager.get(serverLevel); + PrisonerRecord record = manager.getPrisoner(captiveUUID); + if (record != null && record.isImprisoned()) { + // Clear captivity state - prisoner escaped + // Use centralized escape service for complete cleanup + com.tiedup.remake.prison.service.PrisonerService.get().escape( + serverLevel, + captiveUUID, + "kidnapper tied up" + ); + } + } + + // Free the captive + captive.free(); + } + + /** + * Called when kidnapper dies. + * Releases captive and updates PrisonerManager. + */ + public void onDie() { + if (!this.hasCaptives()) return; + + // Capture local reference - escape() may trigger onCaptiveReleased() which nulls the field + IRestrainable captive = this.currentCaptive; + if (captive == null) return; + + TiedUpMod.LOGGER.info( + "[KidnapperCaptiveManager] {} died - releasing captive {}", + host.getNpcName(), + captive.getKidnappedName() + ); + + // Release prisoner from captivity state + if (host.level() instanceof ServerLevel serverLevel) { + UUID captiveUUID = captive.asLivingEntity().getUUID(); + PrisonerManager manager = PrisonerManager.get(serverLevel); + PrisonerRecord record = manager.getPrisoner(captiveUUID); + if (record != null && record.isImprisoned()) { + // Clear captivity state - captor died + // Use centralized escape service for complete cleanup + com.tiedup.remake.prison.service.PrisonerService.get().escape( + serverLevel, + captiveUUID, + "kidnapper died" + ); + } + } + + // Free the captive + captive.free(); + } + + /** + * Called when kidnapper is removed (despawn/chunk unload). + * Conditional leash drop based on removal reason. + */ + public void onRemove( + net.minecraft.world.entity.Entity.RemovalReason reason + ) { + if (!this.hasCaptives()) return; + + // Graceful chunk unload - don't drop leash item + boolean transportState = (reason == + net.minecraft.world.entity.Entity.RemovalReason.UNLOADED_TO_CHUNK); + + TiedUpMod.LOGGER.debug( + "[KidnapperCaptiveManager] {} removed (reason={}), transport={}", + host.getNpcName(), + reason, + transportState + ); + + removeCaptive(this.currentCaptive, transportState); + } + + // ======================================== + // SOLO MODE FALLBACK + // ======================================== + + /** + * Keep captive (solo mode fallback when no buyers available). + * DecideNextActionGoal will handle job assignment or cell transport. + */ + public void keepCaptive() { + if (!this.hasCaptives()) { + TiedUpMod.LOGGER.warn( + "[KidnapperCaptiveManager] {} tried to keep captive but has none", + host.getNpcName() + ); + return; + } + + TiedUpMod.LOGGER.info( + "[KidnapperCaptiveManager] {} keeping captive {} for themselves", + host.getNpcName(), + this.currentCaptive.getKidnappedName() + ); + + // DecideNextActionGoal will take over and assign a job or bring to cell + // No need to do anything else here - just don't set getOutState + } + + /** + * Abandon captive (solo mode fallback when no buyers and no cells). + * Blindfolds, teleports to safe position, optionally removes restraints. + */ + public void abandonCaptive() { + if (!this.hasCaptives()) { + TiedUpMod.LOGGER.warn( + "[KidnapperCaptiveManager] {} tried to abandon captive but has none", + host.getNpcName() + ); + return; + } + + LivingEntity captiveEntity = this.currentCaptive.asLivingEntity(); + + TiedUpMod.LOGGER.info( + "[KidnapperCaptiveManager] {} abandoning captive {}", + host.getNpcName(), + this.currentCaptive.getKidnappedName() + ); + + // Apply blindfold if enabled in config + if ( + com.tiedup.remake.core.ModConfig.SERVER.abandonKeepsBlindfold.get() + ) { + net.minecraft.world.item.ItemStack blindfold = getBlindfoldItem(); + if ( + blindfold != null && + !blindfold.isEmpty() && + !this.currentCaptive.isBlindfolded() + ) { + this.currentCaptive.equip(BodyRegionV2.EYES, blindfold.copy()); + } + } + + // Find safe position and teleport + BlockPos safePos = findRandomSafePosition(); + if (safePos != null) { + captiveEntity.teleportTo( + safePos.getX() + 0.5, + safePos.getY(), + safePos.getZ() + 0.5 + ); + } + + // Remove restraints if NOT configured to keep them + boolean keepBinds = + com.tiedup.remake.core.ModConfig.SERVER.abandonKeepsBinds.get(); + if (!keepBinds) { + // Full release including binds + this.currentCaptive.untie(true); + this.currentCaptive.free(true); + } else { + // Just release from leash, keep binds + this.currentCaptive.free(false); + } + + // Release from captivity state + if (host.level() instanceof ServerLevel serverLevel) { + UUID captiveUUID = captiveEntity.getUUID(); + PrisonerManager manager = PrisonerManager.get(serverLevel); + PrisonerRecord record = manager.getPrisoner(captiveUUID); + if (record != null && record.isImprisoned()) { + // Clear captivity state - prisoner freed + // Use centralized escape service for complete cleanup + com.tiedup.remake.prison.service.PrisonerService.get().escape( + serverLevel, + captiveUUID, + "abandoned by kidnapper" + ); + } + } + + // Enter flee state + host.setGetOutState(true); + } + + /** + * Get blindfold item to use for abandon. + */ + @Nullable + private net.minecraft.world.item.ItemStack getBlindfoldItem() { + return this.entity.getBlindfoldItem(); + } + + /** + * Find a random safe position near kidnapper for abandoning captive. + */ + @Nullable + private BlockPos findRandomSafePosition() { + Random random = new Random(); + for (int i = 0; i < 10; i++) { + int offsetX = random.nextInt(21) - 10; + int offsetZ = random.nextInt(21) - 10; + BlockPos testPos = host.blockPosition().offset(offsetX, 0, offsetZ); + if (host.level().getBlockState(testPos.below()).isSolid()) { + return testPos; + } + } + return host.blockPosition(); + } + + // ======================================== + // PUNISHMENT SYSTEM + // ======================================== + + /** Maximum distance to HEAR struggle (through bars, no line of sight needed) */ + private static final double STRUGGLE_HEARING_RANGE = 6.0; + + /** Maximum distance to SEE struggle (requires line of sight) */ + private static final double STRUGGLE_VISION_RANGE = 15.0; + + /** + * Called when struggle noise is detected from a prisoner. + * Uses HYBRID detection: HEARING (close range) + VISION (far range). + * + * Detection modes: + * - Within 6 blocks: Can HEAR through bars/fences (no line of sight needed) + * - Within 15 blocks: Can SEE if line of sight is clear + * + * Validates prisoner belongs to us, then applies shock + tighten punishment. + */ + public void onStruggleDetected(LivingEntity source, BlockPos strugglePos) { + // Ignore if not in GUARD state or already has punishment target + if ( + host.getCurrentState() != KidnapperState.GUARD || + this.strugglePunishmentTarget != null + ) { + return; + } + + // HYBRID DETECTION: Hearing (close) + Vision (far) + double distance = host.distanceTo(source); + + // HEARING: Close range - can hear through bars/fences (no LOS needed) + boolean canHear = distance <= STRUGGLE_HEARING_RANGE; + + // VISION: Longer range - requires clear line of sight + boolean canSee = + distance <= STRUGGLE_VISION_RANGE && host.hasLineOfSight(source); + + if (!canHear && !canSee) { + return; // Can't detect the struggle + } + + // Verify this prisoner belongs to a cell we're guarding + IRestrainable sourceState = KidnappedHelper.getKidnappedState(source); + if (sourceState == null || !sourceState.isCaptive()) { + return; + } + + // Check if prisoner is in a cell + CellDataV2 cell = host.findCellContainingPrisoner(source.getUUID()); + if (cell == null) { + return; + } + + String detectionMethod = canHear ? "heard" : "saw"; + TiedUpMod.LOGGER.info( + "[KidnapperCaptiveManager] {} {} struggle from prisoner {} (distance: {})", + host.getNpcName(), + detectionMethod, + source.getName().getString(), + distance + ); + + // Apply shock punishment + sourceState.shockKidnapped(" Stop struggling!", 2.0f); + + // Tighten binds + tightenBinds(source); + + // Set punishment target + this.strugglePunishmentTarget = source; + } + + /** + * Punish a prisoner for attacking. + * Used when prisoner attacks while in cell/camp. + * Stronger punishment: shock (4.0f) + tighten. + */ + public boolean punishAttackingPrisoner(Player player) { + if (player == null) return false; + + IRestrainable playerState = KidnappedHelper.getKidnappedState(player); + if (playerState == null) return false; + + // Check if captive (leashed) OR imprisoned/working in PrisonerManager + if (!playerState.isCaptive()) { + if ( + !(host.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel) + ) { + return false; + } + PrisonerManager pm = PrisonerManager.get(serverLevel); + PrisonerState pmState = pm.getState(player.getUUID()); + if ( + pmState != PrisonerState.IMPRISONED && + pmState != PrisonerState.WORKING + ) { + return false; + } + } + + boolean shouldPunish = false; + + // Check if prisoner is in one of our cells + CellDataV2 cell = host.findCellContainingPrisoner(player.getUUID()); + if (cell != null) { + shouldPunish = true; + } + + // Also check camp ownership for camp kidnappers + if (!shouldPunish && host.level() instanceof ServerLevel serverLevel) { + UUID campId = entity.getAssociatedStructure(); + if (campId != null) { + // Check if this prisoner is in a cell owned by our camp + com.tiedup.remake.cells.CellRegistryV2 cellRegistry = + com.tiedup.remake.cells.CellRegistryV2.get(serverLevel); + java.util.List campCells = + cellRegistry.getCellsByCamp(campId); + + for (CellDataV2 campCell : campCells) { + if (campCell.hasPrisoner(player.getUUID())) { + shouldPunish = true; + break; + } + } + } + } + + if (shouldPunish) { + TiedUpMod.LOGGER.info( + "[KidnapperCaptiveManager] {} punishing attacking prisoner {}", + host.getNpcName(), + player.getName().getString() + ); + + // Stronger shock for attacking + playerState.shockKidnapped(" How DARE you!", 4.0f); + + // Tighten binds (also re-applies if player struggled free) + tightenBinds(player); + + // Re-apply gag and blindfold if missing + RestraintApplicator.applyGagIfMissing(player, entity.getGagItem()); + RestraintApplicator.applyBlindfoldIfMissing( + player, + entity.getBlindfoldItem() + ); + + // Trigger dialogue + host.talkToPlayersInRadius( + EntityDialogueManager.DialogueCategory.PUNISH, + 20 + ); + + return true; + } + + return false; + } + + /** + * Tighten binds on a target. + */ + private void tightenBinds(LivingEntity target) { + IRestrainable targetState = KidnappedHelper.getKidnappedState(target); + if (targetState != null) { + RestraintApplicator.tightenBind(targetState, target); + } + } + + /** + * Set struggle punishment target. + */ + public void setStrugglePunishmentTarget(LivingEntity target) { + this.strugglePunishmentTarget = target; + } + + /** + * Get struggle punishment target. + */ + @Nullable + public LivingEntity getStrugglePunishmentTarget() { + return this.strugglePunishmentTarget; + } + + /** + * Clear struggle punishment target. + */ + public void clearStrugglePunishmentTarget() { + this.strugglePunishmentTarget = null; + } + + // ======================================== + // LEASH PHYSICS (VISUAL) + // ======================================== + + /** + * Get the position where the rope/leash is held. + * Overrides default chest position to use right hand. + * Custom visual physics - positions leash at right hand with body rotation. + */ + public Vec3 getRopeHoldPosition(float partialTicks) { + // Interpolate position for smooth rendering + double x = entity.xo + (entity.getX() - entity.xo) * partialTicks; + double y = entity.yo + (entity.getY() - entity.yo) * partialTicks; + double z = entity.zo + (entity.getZ() - entity.zo) * partialTicks; + + // Get body yaw rotation + float bodyYaw = + entity.yBodyRotO + + (entity.yBodyRot - entity.yBodyRotO) * partialTicks; + double yawRad = Math.toRadians(bodyYaw); + + // Calculate offset in kidnapper's local space (right hand position) + double lateralOffset = 0.4; // Right side + double heightOffset = 1.3; // Shoulder height + double forwardOffset = 0.2; // Slightly forward + + // Rotate offset by body yaw + double offsetX = + -Math.sin(yawRad) * forwardOffset + + Math.cos(yawRad) * lateralOffset; + double offsetZ = + Math.cos(yawRad) * forwardOffset + Math.sin(yawRad) * lateralOffset; + + return new Vec3(x + offsetX, y + heightOffset, z + offsetZ); + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + /** + * Save captive manager data to NBT. + * Saves captive UUID for lazy restoration. + * Backward compatible: saves as "CaptiveUUID" (old: "SlaveUUID"). + */ + public void saveToNBT(net.minecraft.nbt.CompoundTag tag) { + // Save captive UUID if we have one + if (this.currentCaptive != null) { + tag.putUUID( + "CaptiveUUID", + this.currentCaptive.getKidnappedUniqueId() + ); + } + } + + /** + * Load captive manager data from NBT. + * Loads captive UUID for lazy restoration. + * Backward compatible: reads both "CaptiveUUID" and old "SlaveUUID". + */ + public void loadFromNBT(net.minecraft.nbt.CompoundTag tag) { + // Load captive UUID (will be resolved later in tick) + // Support both old "SlaveUUID" and new "CaptiveUUID" for backward compatibility + if (tag.contains("CaptiveUUID")) { + this.pendingCaptiveUUID = tag.getUUID("CaptiveUUID"); + } else if (tag.contains("SlaveUUID")) { + this.pendingCaptiveUUID = tag.getUUID("SlaveUUID"); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperCellManager.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperCellManager.java new file mode 100644 index 0000000..2e331c9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperCellManager.java @@ -0,0 +1,269 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import com.tiedup.remake.blocks.entity.MarkerBlockEntity; +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.cells.MarkerType; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.ai.kidnapper.KidnapperState; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.entity.BlockEntity; + +/** + * KidnapperCellManager - Manages cell-related queries and interactions. + * Phase 2.3: Cell management component (~125 lines). + * + * Handles: + * - Finding cells containing specific prisoners + * - Querying nearby cells with occupants + * - Cell boundary detection (isInsideCell) + * - Patrol marker discovery + * - Cell breach response + * + *

Medium complexity - Cell registry integration and boundary calculations.

+ */ +public class KidnapperCellManager { + + // ======================================== + // FIELDS + // ======================================== + + /** Host callbacks */ + private final ICellHost host; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public KidnapperCellManager(ICellHost host) { + this.host = host; + } + + // ======================================== + // CELL QUERIES + // ======================================== + + /** + * Find the cell containing a specific prisoner. + * + * @param prisonerId UUID of the prisoner + * @return The cell data, or null if not found + */ + @Nullable + public CellDataV2 findCellContainingPrisoner(UUID prisonerId) { + if (!(host.level() instanceof ServerLevel serverLevel)) return null; + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + return registry.findCellByPrisoner(prisonerId); + } + + /** + * Get all nearby cells (within 32 blocks) that have prisoners. + * + * @return List of occupied cells near this kidnapper + */ + public List getNearbyCellsWithPrisoners() { + List result = new ArrayList<>(); + if (!(host.level() instanceof ServerLevel serverLevel)) { + return result; + } + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + List nearbyCells = registry.findCellsNear( + host.blockPosition(), + 32 + ); + for (CellDataV2 cell : nearbyCells) { + if (cell.isOccupied()) { + result.add(cell); + } + } + return result; + } + + // ======================================== + // CELL BOUNDARY DETECTION + // ======================================== + + /** + * Check if a position is inside a cell's boundaries. + * Uses wall markers to calculate bounds, or defaults to 5-block radius from spawn point. + * + * @param pos Position to check + * @param cell Cell data + * @return true if position is inside the cell + */ + public boolean isInsideCell(BlockPos pos, CellDataV2 cell) { + java.util.Set walls = cell.getWallBlocks(); + + if (walls.isEmpty()) { + // No wall markers - use default radius from core position + BlockPos spawn = cell.getCorePos(); + double distSq = pos.distSqr(spawn); + return distSq <= 25; // 5 block radius default + } + + // Calculate bounding box from wall markers + int minX = Integer.MAX_VALUE, + minY = Integer.MAX_VALUE, + minZ = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE, + maxY = Integer.MIN_VALUE, + maxZ = Integer.MIN_VALUE; + + for (BlockPos wall : walls) { + minX = Math.min(minX, wall.getX()); + minY = Math.min(minY, wall.getY()); + minZ = Math.min(minZ, wall.getZ()); + maxX = Math.max(maxX, wall.getX()); + maxY = Math.max(maxY, wall.getY()); + maxZ = Math.max(maxZ, wall.getZ()); + } + + // Check if position is within bounds (with 1-block tolerance on sides, 3-block tolerance on top) + return ( + pos.getX() >= minX - 1 && + pos.getX() <= maxX + 1 && + pos.getY() >= minY - 1 && + pos.getY() <= maxY + 3 && + pos.getZ() >= minZ - 1 && + pos.getZ() <= maxZ + 1 + ); + } + + // ======================================== + // PATROL MARKERS + // ======================================== + + /** + * Get all patrol markers from nearby cells. + * + * @param radius Search radius in blocks + * @return List of patrol marker positions + */ + public List getNearbyPatrolMarkers(int radius) { + List result = new ArrayList<>(); + if (!(host.level() instanceof ServerLevel serverLevel)) return result; + + BlockPos center = host.blockPosition(); + int chunkRadius = (radius + 15) >> 4; + int centerCX = center.getX() >> 4; + int centerCZ = center.getZ() >> 4; + + for ( + int cx = centerCX - chunkRadius; + cx <= centerCX + chunkRadius; + cx++ + ) { + for ( + int cz = centerCZ - chunkRadius; + cz <= centerCZ + chunkRadius; + cz++ + ) { + net.minecraft.world.level.chunk.LevelChunk chunk = serverLevel + .getChunkSource() + .getChunkNow(cx, cz); + if (chunk == null) continue; + + for (BlockEntity be : chunk.getBlockEntities().values()) { + if (!(be instanceof MarkerBlockEntity marker)) continue; + if (marker.getMarkerType() != MarkerType.PATROL) continue; + + BlockPos pos = be.getBlockPos(); + if (center.distSqr(pos) <= (double) radius * radius) { + result.add(pos); + } + } + } + } + return result; + } + + // ======================================== + // CELL BREACH RESPONSE + // ======================================== + + /** + * Called when a cell wall is breached (broken). + * Kidnappers react to breaches in occupied cells or their own camp cells. + * + * @param breachPos Position where wall was broken + * @param cellId UUID of the cell that was breached + */ + public void onCellBreach(BlockPos breachPos, UUID cellId) { + if (cellId == null) return; + + boolean shouldReact = false; + CellDataV2 cell = null; + + // Check if this is an occupied cell + if (host.level() instanceof ServerLevel serverLevel) { + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + cell = registry.getCell(cellId); + + if (cell != null && cell.isOccupied()) { + shouldReact = true; + } + } + + // Also react if it's our associated structure + if ( + host.getAssociatedStructure() != null && + host.getAssociatedStructure().equals(cellId) + ) { + shouldReact = true; + } + + if (shouldReact) { + TiedUpMod.LOGGER.info( + "[KidnapperCellManager] {} detected wall breach at {}, investigating", + host.getNpcName(), + breachPos.toShortString() + ); + + // Navigate to breach location + host + .getNavigation() + .moveTo( + breachPos.getX() + 0.5, + breachPos.getY(), + breachPos.getZ() + 0.5, + 1.2 + ); + + // Enter alert state + host.setCurrentState(KidnapperState.ALERT); + + // Trigger dialogue + host.talkToPlayersInRadius( + com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory.CAPTURE_ESCAPE, + 20 + ); + } + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + /** + * Save cell manager data to NBT. + * Note: Cell manager uses only transient runtime state. + */ + public void saveToNBT(net.minecraft.nbt.CompoundTag tag) { + // Cell manager uses only transient runtime state + // No persistent data needs to be saved + } + + /** + * Load cell manager data from NBT. + * Note: Cell manager uses only transient runtime state. + */ + public void loadFromNBT(net.minecraft.nbt.CompoundTag tag) { + // Cell manager uses only transient runtime state + // No persistent data needs to be loaded + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperDataSerializer.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperDataSerializer.java new file mode 100644 index 0000000..9cf214b --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperDataSerializer.java @@ -0,0 +1,123 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.syncher.EntityDataAccessor; + +/** + * KidnapperDataSerializer - Manages NBT serialization and deserialization. + * Phase 4.1: Data serialization component (~65 lines). + * + * Handles: + * - Orchestrating component save/load (appearance, aggression, captive, state, camp, job) + * - Direct entity data (KidnappingMode, GetOutState) + * - Backward compatibility (AIState → KidnapperState) + * + *

Low complexity - Already component-based, minimal orchestration needed.

+ */ +public class KidnapperDataSerializer { + + // ======================================== + // FIELDS + // ======================================== + + /** Host callbacks */ + private final IDataSerializerHost host; + + /** Entity data accessor for kidnapping mode */ + private final EntityDataAccessor dataKidnappingMode; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public KidnapperDataSerializer( + IDataSerializerHost host, + EntityDataAccessor dataKidnappingMode + ) { + this.host = host; + this.dataKidnappingMode = dataKidnappingMode; + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + /** + * Save all data to NBT. + * Orchestrates component saves and direct entity data. + * + * @param tag The NBT tag to save to + */ + public void saveToNBT(CompoundTag tag) { + // Save appearance data + host.getAppearance().saveToNBT(tag); + + // Save aggression system data + host.getAggressionSystem().saveToNBT(tag); + + // Save captive manager data + host.getCaptiveManager().saveToNBT(tag); + + // Save direct entity data + tag.putBoolean( + "KidnappingMode", + host.getEntityData(dataKidnappingMode) + ); + tag.putBoolean("GetOutState", host.isGetOutState()); + + // Save AI state + tag.putString( + host.getStateManager().getNBTKey(), + host.getStateManager().serializeState() + ); + + // Save camp manager state + host.getCampManager().saveToNBT(tag); + + // Save job manager state + host.getJobManager().save(tag); + } + + /** + * Load all data from NBT. + * Orchestrates component loads and direct entity data with backward compatibility. + * + * @param tag The NBT tag to load from + */ + public void loadFromNBT(CompoundTag tag) { + // Restore appearance data + host.getAppearance().loadFromNBT(tag); + + // Restore aggression system data + host.getAggressionSystem().loadFromNBT(tag); + + // Restore captive manager data + host.getCaptiveManager().loadFromNBT(tag); + + // Restore direct entity data with null checks + if (tag.contains("KidnappingMode")) { + host.setEntityData( + dataKidnappingMode, + tag.getBoolean("KidnappingMode") + ); + } + if (tag.contains("GetOutState")) { + host.setGetOutState(tag.getBoolean("GetOutState")); + } + + // Load AI state with backward compatibility + String stateKey = host.getStateManager().getNBTKey(); + if (tag.contains(stateKey)) { + host.getStateManager().deserializeState(tag.getString(stateKey)); + } else if (tag.contains("AIState")) { + // Backward compatibility: old saves used "AIState" instead of "KidnapperState" + host.getStateManager().deserializeState(tag.getString("AIState")); + } + + // Load camp manager state + host.getCampManager().loadFromNBT(tag); + + // Load job manager state + host.getJobManager().load(tag); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperSaleManager.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperSaleManager.java new file mode 100644 index 0000000..184944e --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperSaleManager.java @@ -0,0 +1,230 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityDamselShiny; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerRecord; +import com.tiedup.remake.state.ICaptor; +import com.tiedup.remake.util.tasks.ItemTask; +import com.tiedup.remake.util.tasks.SaleLoader; +import java.util.UUID; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; + +/** + * KidnapperSaleManager - Manages NPC sale transactions. + * Phase 2.4: Sale management component (~115 lines). + * + * Handles: + * - Initiating sales with price calculation (player/shiny multipliers) + * - Checking sale status + * - Canceling sales + * - Completing sales with captive transfer + * - PrisonerManager integration + * + *

Low complexity - Straightforward state transitions and captive transfer.

+ */ +public class KidnapperSaleManager { + + // ======================================== + // FIELDS + // ======================================== + + /** Host callbacks */ + private final ISaleHost host; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public KidnapperSaleManager(ISaleHost host) { + this.host = host; + } + + // ======================================== + // SALE STATUS + // ======================================== + + /** + * Check if currently selling a captive. + * + * @return true if has captive and captive is for sale + */ + public boolean isSellingCaptive() { + return host.hasCaptives() && host.getCaptive().isForSell(); + } + + // ======================================== + // START SALE + // ======================================== + + /** + * Start selling the current captive with auto-calculated price. + * Price is based on SaleLoader random price with multipliers: + * - Players: 2x multiplier + * - Shiny Damsels: 3x multiplier + * - Regular NPCs: 1x multiplier + * + * @return true if sale started successfully + */ + public boolean startSale() { + if (!host.hasCaptives()) { + TiedUpMod.LOGGER.warn( + "[KidnapperSaleManager] {} can't start sale - no captive", + host.getNpcName() + ); + return false; + } + + if (host.getCaptive().isForSell()) { + TiedUpMod.LOGGER.warn( + "[KidnapperSaleManager] {} captive already for sale", + host.getNpcName() + ); + return false; + } + + // Get base price + ItemTask basePrice = SaleLoader.getRandomSale(); + + // Calculate price multiplier based on captive type + int multiplier = 1; + LivingEntity captiveEntity = host.getCaptive().asLivingEntity(); + + if (captiveEntity instanceof Player) { + multiplier = 2; // Players cost 2x more + } else if (captiveEntity instanceof EntityDamselShiny) { + multiplier = 3; // Shiny damsels cost 3x more + } + + // Apply multiplier + ItemTask finalPrice = + multiplier > 1 + ? new ItemTask( + basePrice.getItemId(), + basePrice.getAmount() * multiplier + ) + : basePrice; + + host.getCaptive().putForSale(finalPrice); + + TiedUpMod.LOGGER.info( + "[KidnapperSaleManager] {} started selling {} for {} ({}x multiplier)", + host.getNpcName(), + host.getCaptive().getKidnappedName(), + finalPrice.toDisplayString(), + multiplier + ); + + return true; + } + + /** + * Start selling the current captive with a specific price. + * + * @param price The price to sell for + * @return true if sale started successfully + */ + public boolean startSale(ItemTask price) { + if (!host.hasCaptives() || price == null) { + return false; + } + + if (host.getCaptive().isForSell()) { + return false; + } + + host.getCaptive().putForSale(price); + return true; + } + + // ======================================== + // CANCEL SALE + // ======================================== + + /** + * Cancel the current sale. + * Removes the for-sale flag from the captive. + */ + public void cancelSale() { + if (host.hasCaptives() && host.getCaptive().isForSell()) { + host.getCaptive().cancelSale(); + } + } + + // ======================================== + // COMPLETE SALE + // ======================================== + + /** + * Complete a sale by transferring the captive to the buyer. + * Also updates PrisonerManager, removes sale flag, and triggers flee state. + * + * @param buyer The kidnapper purchasing the captive + * @return true if sale completed successfully + */ + public boolean completeSale(ICaptor buyer) { + if (!host.hasCaptives() || buyer == null) { + return false; + } + + if (!host.getCaptive().isForSell()) { + return false; + } + + // Cancel sale flag + host.getCaptive().cancelSale(); + + // Update PrisonerManager with new captor (the buyer) + if ( + host.level() instanceof ServerLevel serverLevel && + buyer.getEntity() != null + ) { + PrisonerManager manager = PrisonerManager.get(serverLevel); + UUID captiveId = host.getCaptive().asLivingEntity().getUUID(); + PrisonerRecord record = manager.getRecord(captiveId); + record.setCaptorId(buyer.getEntity().getUUID()); + } + + // Transfer captive to buyer + host.setAllowCaptiveTransferFlag(true); + host.getCaptive().transferCaptivityTo(buyer); + host.setAllowCaptiveTransferFlag(false); + + TiedUpMod.LOGGER.info( + "[KidnapperSaleManager] {} sold captive to {}", + host.getNpcName(), + buyer.getEntity().getName().getString() + ); + + // Enter flee state after sale + host.setGetOutState(true); + + return true; + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + /** + * Save sale manager data to NBT. + * Note: Sale state is stored in captive (IRestrainable), not in this manager. + */ + public void saveToNBT(net.minecraft.nbt.CompoundTag tag) { + // Sale manager uses only transient runtime state + // Sale flag is stored in captive entity + // No persistent data needs to be saved + } + + /** + * Load sale manager data from NBT. + * Note: Sale state is loaded from captive (IRestrainable). + */ + public void loadFromNBT(net.minecraft.nbt.CompoundTag tag) { + // Sale manager uses only transient runtime state + // Sale flag is loaded from captive entity + // No persistent data needs to be loaded + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperStateManager.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperStateManager.java new file mode 100644 index 0000000..4e6db76 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperStateManager.java @@ -0,0 +1,103 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.ai.kidnapper.KidnapperState; + +/** + * KidnapperStateManager - Manages the behavioral state of a kidnapper. + * Phase 1.1: Simple state management component (26 lines). + * + * Handles the current KidnapperState enum (IDLE, HUNT, CAPTURE, etc.) + * with debug logging for state transitions. + * + *

No dependencies - Foundation component.

+ */ +public class KidnapperStateManager { + + // ======================================== + // FIELDS + // ======================================== + + /** Host callbacks for logging */ + private final IStateHost host; + + /** Current behavioral state */ + private KidnapperState currentState = KidnapperState.IDLE; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public KidnapperStateManager(IStateHost host) { + this.host = host; + } + + // ======================================== + // STATE MANAGEMENT + // ======================================== + + /** + * Get the current behavioral state. + * + * @return The current state + */ + public KidnapperState getCurrentState() { + return this.currentState; + } + + /** + * Set the current behavioral state with debug logging. + * + * @param state The new state + */ + public void setCurrentState(KidnapperState state) { + if (this.currentState != state) { + TiedUpMod.LOGGER.debug( + "[KidnapperStateManager] {} state change: {} -> {}", + host.getNpcName(), + this.currentState, + state + ); + this.currentState = state; + } + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + /** + * Get the NBT key for this component. + * + * @return The NBT key + */ + public String getNBTKey() { + return "CurrentState"; + } + + /** + * Serialize state to NBT. + * + * @return The state name as a string + */ + public String serializeState() { + return currentState.name(); + } + + /** + * Deserialize state from NBT. + * + * @param stateName The state name string + */ + public void deserializeState(String stateName) { + try { + this.currentState = KidnapperState.valueOf(stateName); + } catch (IllegalArgumentException e) { + TiedUpMod.LOGGER.warn( + "[KidnapperStateManager] Invalid state name: {}, defaulting to IDLE", + stateName + ); + this.currentState = KidnapperState.IDLE; + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperTargetSelector.java b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperTargetSelector.java new file mode 100644 index 0000000..06d1dd0 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/components/KidnapperTargetSelector.java @@ -0,0 +1,543 @@ +package com.tiedup.remake.entities.kidnapper.components; + +import com.tiedup.remake.cells.CampOwnership; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.compat.mca.MCACompat; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.EntityKidnapperElite; +import com.tiedup.remake.entities.ai.kidnapper.KidnapperState; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerRecord; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.List; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.AABB; + +/** + * KidnapperTargetSelector - Manages target selection and validation logic. + * Phase 2.2: Target selection component (~286 lines) - CRITICAL. + * + * Handles: + * - isSuitableTarget() - Complex targeting logic with 8 categories of checks + * - isTargetStillValidForChase() - Mid-chase validation (no line-of-sight) + * - isTargetBeingPursuedByOther() - Competition detection + * - getClosestSuitableTarget() - Proximity-based targeting + * - Target tracking (currentTarget field) + * + *

VERY HIGH complexity - Critical logic controlling kidnapper behavior. + * Bugs here cause kidnappers to be passive (ignore everyone) or too aggressive + * (target protected players). Preserve all conditions exactly.

+ */ +public class KidnapperTargetSelector { + + // ======================================== + // FIELDS + // ======================================== + + /** Host callbacks */ + private final ITargetHost host; + + /** Entity reference (needed for type checks and competition detection) */ + private final EntityKidnapper entity; + + /** Current target being pursued */ + @Nullable + private LivingEntity currentTarget; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public KidnapperTargetSelector(EntityKidnapper entity, ITargetHost host) { + this.entity = entity; + this.host = host; + } + + // ======================================== + // TARGET TRACKING + // ======================================== + + /** + * Get the current target being pursued. + * + * @return The target entity, or null if none + */ + @Nullable + public LivingEntity getTarget() { + return this.currentTarget; + } + + /** + * Set the current target. + * + * @param target The target to pursue + */ + public void setTarget(@Nullable LivingEntity target) { + this.currentTarget = target; + } + + // ======================================== + // TARGET SUITABILITY (CRITICAL METHOD) + // ======================================== + + /** + * Check if an entity is a suitable target for capture. + * This is the CRITICAL method controlling kidnapper behavior. + * + * Validation categories: + * 1. Basic validity (null, alive, self, type) + * 2. Kidnapper state (has captive, tied up, waiting for job, fleeing) + * 3. Job worker protection + * 4. Player-specific (creative/spectator, robbed immunity, grace period, ransom/labor, token) + * 5. Kidnapped state (already captive, collar ownership) + * 6. Kidnapping mode (whitelist/blacklist) + * 7. Competition check (other kidnappers pursuing) + * 8. Line of sight (expensive, checked last) + * + * @param entity The entity to evaluate + * @return true if entity is a valid target, false otherwise + */ + public boolean isSuitableTarget(LivingEntity entity) { + if (entity == null) return false; + if (!entity.isAlive()) return false; + if (entity.isInvisible()) return false; + // Note: hasLineOfSight check moved to end (expensive raycast) + + // Can't target self + if (entity == this.entity) return false; + + // Can't target other kidnappers + if (entity instanceof EntityKidnapper) return false; + + // Can't target labor guards (they're allies, not capture targets) + if ( + entity instanceof com.tiedup.remake.entities.EntityLaborGuard + ) return false; + + // Can't target if we already have a captive + if (host.hasCaptives()) return false; + + // Can't target if we're tied up + if (host.isTiedUp()) return false; + + // Can't target if we're waiting for a job to be completed + if (host.isWaitingForJobToBeCompleted()) return false; + + // Can't target if we're fleeing (get out state) + if (host.isGetOutState()) return false; + + // Can't target the current job worker + UUID workerUUID = host.getJobManager().getJobWorkerUUID(); + if (workerUUID != null && entity.getUUID().equals(workerUUID)) { + return false; + } + + // FIX: Can't target job workers of OTHER kidnappers (wild jobs protection) + // Prevents kidnapper B from capturing a player doing a job for kidnapper A + if (host.level() instanceof ServerLevel serverLevel) { + AABB searchBox = new AABB(entity.blockPosition()).inflate(64); + List nearbyKidnappers = + serverLevel.getEntitiesOfClass( + EntityKidnapper.class, + searchBox + ); + for (EntityKidnapper otherKidnapper : nearbyKidnappers) { + if (otherKidnapper == this.entity) continue; + if ( + otherKidnapper.getJobManager() != null && + entity + .getUUID() + .equals( + otherKidnapper.getJobManager().getJobWorkerUUID() + ) + ) { + return false; // Protected - active job worker for another kidnapper + } + } + } + + // Player-specific checks + if (entity instanceof Player player) { + if (player.isCreative() || player.isSpectator()) return false; + + // Robbed immunity check - this kidnapper recently robbed this player + if ( + host.getAggressionSystem().hasRobbedImmunity(player.getUUID()) + ) { + return false; + } + + // UNIFIED CAPTIVITY CHECK - check if player is detained in a camp/cell + // This prevents kidnappers from targeting players who are: + // 1. Being transported (CAPTURED, BEING_TRANSPORTED) + // 2. Imprisoned (in cell, working, returning, resting) + // 3. Under post-release protection (grace period) + if (host.level() instanceof ServerLevel serverLevel) { + PrisonerManager manager = PrisonerManager.get(serverLevel); + long currentTime = serverLevel.getGameTime(); + + // Can't target if captive (transported/imprisoned) OR protected (grace period) + if (!manager.isTargetable(player.getUUID(), currentTime)) { + return false; // Player is captive or protected + } + } + + // Token check - only protects from kidnappers LINKED to a camp + // Non-linked kidnappers (wild spawns) ignore the token + if ( + hasTokenInInventory(player) && + host.getAssociatedStructure() != null + ) { + return false; // Token protects from camp-linked kidnappers + } + } + + // Get entity's kidnapped state + IBondageState state = KidnappedHelper.getKidnappedState(entity); + if (state == null) return false; + + // Can't target if entity is already someone's captive (unless on pole) + if (state.isCaptive() && !state.isTiedToPole()) { + return false; + } + + // Can't target if entity has a collar (is a slave) - UNLESS we own that collar + // OR the collared player escaped from our camp (same camp kidnappers can recapture) + // Self-collared entities (exploit) are treated as uncolllared — kidnappers ignore self-collars + if (state.hasCollar()) { + ItemStack collar = state.getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemCollar collarItem) { + java.util.List owners = collarItem.getOwners(collar); + // Filter out self-collar (owner == wearer = exploit) + java.util.List realOwners = owners + .stream() + .filter(id -> !id.equals(entity.getUUID())) + .toList(); + if ( + !realOwners.isEmpty() && + !realOwners.contains(host.getUUID()) + ) { + // Not our collar directly - check if escaped prisoner from our camp + boolean sameCampEscapee = false; + if ( + entity instanceof Player collarPlayer && + host.level() instanceof ServerLevel sl + ) { + PrisonerRecord record = PrisonerManager.get( + sl + ).getRecord(collarPlayer.getUUID()); + UUID prisonerCampId = record.getCampId(); + UUID ourCampId = host.getAssociatedStructure(); + if ( + prisonerCampId != null && + ourCampId != null && + prisonerCampId.equals(ourCampId) + ) { + sameCampEscapee = true; // Escaped from our camp - recapture + } + } + if (!sameCampEscapee) { + return false; // Not our collar and not from our camp + } + } + // Our collar, self-collar, or same-camp escapee - continue targeting + } else { + return false; // Unknown collar type, don't target + } + } + + // If kidnapping mode is enabled, check blacklist/whitelist (players only) + if (entity instanceof Player player && host.isKidnappingModeEnabled()) { + if (!host.getCollarConfig().isValidKidnappingTarget(player)) { + return false; + } + } + + // Check if another kidnapper is already pursuing this target (elite kidnappers bypass this) + if (isTargetBeingPursuedByOther(entity)) { + return false; + } + + // Expensive raycast check - do this last after all cheap checks pass + if (!host.getSensing().hasLineOfSight(entity)) return false; + + return true; + } + + // ======================================== + // CHASE VALIDATION + // ======================================== + + /** + * Check if target is still valid during active chase. + * More lenient than isSuitableTarget - no line-of-sight requirement. + * Allows chasing through forests and around obstacles. + * + * @param entity The target being chased + * @return true if chase should continue, false otherwise + */ + public boolean isTargetStillValidForChase(LivingEntity entity) { + if (entity == null) return false; + if (!entity.isAlive()) return false; + if (entity.isInvisible()) return false; + + // Can't continue if we got tied up + if (host.isTiedUp()) return false; + + // Can't continue if we're fleeing + if (host.isGetOutState()) return false; + + // Player-specific checks + if (entity instanceof Player player) { + if (player.isCreative() || player.isSpectator()) return false; + + // UNIFIED CAPTIVITY CHECK - same as isSuitableTarget + if (host.level() instanceof ServerLevel serverLevel) { + PrisonerManager manager = PrisonerManager.get(serverLevel); + long currentTime = serverLevel.getGameTime(); + + // Can't chase if captive (transported/imprisoned) OR protected (grace period) + if (!manager.isTargetable(player.getUUID(), currentTime)) { + return false; // Player is captive or protected + } + } + } + + // Check if captured by someone else + IBondageState state = KidnappedHelper.getKidnappedState(entity); + if (state != null && state.isCaptive() && !state.isTiedToPole()) { + // Captured by another - stop chase + return false; + } + + // Check if collared by a PLAYER (player may have collared during chase) + // Other kidnappers' collars are fair game — kidnapper can steal from kidnapper + if (state != null && state.hasCollar()) { + ItemStack collar = state.getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemCollar collarItem) { + java.util.List owners = collarItem.getOwners(collar); + if (!owners.isEmpty() && !owners.contains(host.getUUID())) { + // Check if any owner is a DIFFERENT player (not self-collared, not a kidnapper) + if (host.level() instanceof ServerLevel sl) { + for (UUID ownerId : owners) { + // Skip self-collared (exploit prevention: player puts collar on themselves) + if (ownerId.equals(entity.getUUID())) continue; + if ( + sl + .getServer() + .getPlayerList() + .getPlayer(ownerId) != + null + ) { + return false; // Owned by another player — stop chase + } + } + } + // All owners are NPCs/kidnappers — continue chase + } + } + } + + // NO LINE-OF-SIGHT CHECK - allow chasing through forests + return true; + } + + // ======================================== + // COMPETITION DETECTION + // ======================================== + + /** + * Check if another kidnapper is already effectively pursuing this target. + * Elite kidnappers bypass this check (always return false). + * + * Considers a target "pursued" only if: + * - Pursuer is in active pursuit state (HUNT/CHASE) + * - Pursuer is within 40 blocks + * - Pursuer has active navigation OR is very close (< 5 blocks) + * + * @param target The target entity + * @return true if another kidnapper is effectively pursuing, false otherwise + */ + public boolean isTargetBeingPursuedByOther(LivingEntity target) { + if (target == null) return false; + + // Elite kidnappers can always pursue, regardless of other kidnappers + if (this.entity instanceof EntityKidnapperElite) { + return false; + } + + if (!(host.level() instanceof ServerLevel serverLevel)) { + return false; + } + + // Check nearby kidnappers (within 50 blocks) + List nearbyKidnappers = serverLevel.getEntitiesOfClass( + EntityKidnapper.class, + target.getBoundingBox().inflate(50, 20, 50), + k -> k != this.entity && k.isAlive() + ); + + for (EntityKidnapper other : nearbyKidnappers) { + LivingEntity otherTarget = other.getTarget(); + if (otherTarget != null && otherTarget.equals(target)) { + // Check if the other kidnapper is actually pursuing effectively + KidnapperState otherState = other.getCurrentState(); + + // Only consider "pursued" if kidnapper is in active pursuit state + if (!otherState.isPursuit()) { + continue; // IDLE/GUARD/PATROL with stale target - ignore + } + + // Check distance - if pursuer is too far (> 40 blocks), they're not effective + double distSq = other.distanceToSqr(target); + if (distSq > 1600) { + // > 40 blocks squared + continue; + } + + // Check if pursuer has valid navigation path (can reach target) + if (!other.getNavigation().isInProgress() && distSq > 25) { + // Not moving and far away - probably stuck + continue; + } + + // This kidnapper is effectively pursuing the target + return true; + } + } + + return false; + } + + // ======================================== + // PROXIMITY TARGETING + // ======================================== + + /** + * Find the closest suitable target within a given radius. + * Search order: Players → Damsels → MCA villagers (if loaded) + * + * @param radius Search radius in blocks + * @return The closest suitable target, or null if none found + */ + @Nullable + public LivingEntity getClosestSuitableTarget(int radius) { + AABB searchBox = host.getBoundingBox().inflate(radius); + + // Search for players first (highest priority) + List players = host + .level() + .getEntitiesOfClass(Player.class, searchBox); + for (Player player : players) { + if (this.isSuitableTarget(player)) { + return player; + } + } + + // Then search for damsels + List damsels = host + .level() + .getEntitiesOfClass(EntityDamsel.class, searchBox); + for (EntityDamsel damsel : damsels) { + // EntityDamsel no longer includes kidnappers (separate hierarchies) + if (this.isSuitableTarget(damsel)) { + return damsel; + } + } + + // MCA Compatibility: Search for MCA villagers + if (MCACompat.isMCALoaded()) { + List entities = host + .level() + .getEntitiesOfClass( + LivingEntity.class, + searchBox, + MCACompat::isMCAVillager // Filter only MCA villagers + ); + for (LivingEntity mcaVillager : entities) { + if (this.isSuitableTarget(mcaVillager)) { + return mcaVillager; + } + } + } + + return null; + } + + // ======================================== + // DISTANCE HELPERS + // ======================================== + + /** + * Check if close to current target (within 2 blocks). + * + * @return true if close to target, false otherwise + */ + public boolean isCloseToTarget() { + if (this.currentTarget == null) return false; + return host.distanceTo(this.currentTarget) <= 2.0f; + } + + /** + * Check if too far from current target. + * + * @param radius Maximum acceptable distance + * @return true if beyond radius, false otherwise + */ + public boolean isTooFarFromTarget(int radius) { + if (this.currentTarget == null) return false; + return host.distanceTo(this.currentTarget) > radius; + } + + // ======================================== + // HELPER METHODS + // ======================================== + + /** + * Check if a player has a protection token in their inventory. + * Tokens protect from camp-linked kidnappers only. + * + * @param player The player to check + * @return true if player has token, false otherwise + */ + public static boolean hasTokenInInventory(Player player) { + for (int i = 0; i < player.getInventory().getContainerSize(); i++) { + ItemStack stack = player.getInventory().getItem(i); + if (!stack.isEmpty() && stack.getItem() == ModItems.TOKEN.get()) { + return true; + } + } + return false; + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + /** + * Save target selector data to NBT. + * Note: currentTarget is transient and not saved (recalculated on load). + */ + public void saveToNBT(net.minecraft.nbt.CompoundTag tag) { + // Target selector uses only transient runtime state + // No persistent data needs to be saved + } + + /** + * Load target selector data from NBT. + * Note: currentTarget is transient and not loaded. + */ + public void loadFromNBT(net.minecraft.nbt.CompoundTag tag) { + // Target selector uses only transient runtime state + // No persistent data needs to be loaded + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/AIHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/AIHost.java new file mode 100644 index 0000000..ae5b2ac --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/AIHost.java @@ -0,0 +1,236 @@ +package com.tiedup.remake.entities.kidnapper.hosts; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.ai.kidnapper.KidnapperState; +import com.tiedup.remake.entities.kidnapper.components.IAIHost; +import com.tiedup.remake.state.IBondageState; +import java.util.List; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.goal.GoalSelector; +import net.minecraft.world.entity.ai.navigation.PathNavigation; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * Host implementation for KidnapperAIManager callbacks. + * Provides access to entity properties and components needed for AI goal registration. + */ +public class AIHost implements IAIHost { + + private final EntityKidnapper entity; + + public AIHost(EntityKidnapper entity) { + this.entity = entity; + } + + // ======================================== + // NAVIGATION & BASIC + // ======================================== + + @Override + public PathNavigation getNavigation() { + return entity.getNavigation(); + } + + @Override + public GoalSelector getGoalSelector() { + return entity.goalSelector; + } + + @Override + public Level getLevel() { + return entity.level(); + } + + // ======================================== + // STATE MANAGEMENT + // ======================================== + + @Override + public KidnapperState getCurrentState() { + return entity.getCurrentState(); + } + + @Override + public void setCurrentState(KidnapperState state) { + entity.setCurrentState(state); + } + + // ======================================== + // TARGET MANAGEMENT + // ======================================== + + @Override + @Nullable + public LivingEntity getTarget() { + return entity.getTarget(); + } + + @Override + public void setTarget(@Nullable LivingEntity target) { + entity.setTarget(target); + } + + @Override + public boolean isSuitableTarget(LivingEntity entity) { + return this.entity.isSuitableTarget(entity); + } + + @Override + public boolean isTargetStillValidForChase(LivingEntity entity) { + return this.entity.isTargetStillValidForChase(entity); + } + + @Override + public boolean isCloseToTarget() { + return entity.isCloseToTarget(); + } + + @Override + @Nullable + public LivingEntity getClosestSuitableTarget(int radius) { + return entity.getClosestSuitableTarget(radius); + } + + // ======================================== + // CAPTIVE MANAGEMENT + // ======================================== + + @Override + @Nullable + public IBondageState getCaptive() { + // C6-V2: EntityKidnapper.getCaptive() returns IRestrainable which IS-A IBondageState + return entity.getCaptive(); + } + + @Override + public boolean hasCaptives() { + return entity.hasCaptives(); + } + + // ======================================== + // AGGRESSION SYSTEM + // ======================================== + + @Override + @Nullable + public LivingEntity getLastAttacker() { + return entity.getLastAttacker(); + } + + @Override + @Nullable + public LivingEntity getEscapedTarget() { + return entity.getEscapedTarget(); + } + + // ======================================== + // ALERT SYSTEM + // ======================================== + + @Override + @Nullable + public LivingEntity getAlertTarget() { + return entity.getAlertTarget(); + } + + @Override + public void setAlertTarget(@Nullable LivingEntity target) { + entity.setAlertTarget(target); + } + + // ======================================== + // CAMP SYSTEM + // ======================================== + + @Override + @Nullable + public UUID getAssociatedStructure() { + return entity.getAssociatedStructure(); + } + + @Override + public boolean isHunter() { + return entity.isHunter(); + } + + // ======================================== + // CELL SYSTEM + // ======================================== + + @Override + public List getNearbyCellsWithPrisoners() { + return entity.getNearbyCellsWithPrisoners(); + } + + @Override + public List getNearbyPatrolMarkers(int radius) { + return entity.getNearbyPatrolMarkers(radius); + } + + // ======================================== + // SALE & JOB SYSTEM + // ======================================== + + @Override + public boolean isSellingCaptive() { + return entity.isSellingCaptive(); + } + + @Override + public boolean isWaitingForJobToBeCompleted() { + return entity.isWaitingForJobToBeCompleted(); + } + + // ======================================== + // COLLAR CONFIG + // ======================================== + + @Override + public boolean isKidnappingModeReady() { + return entity.isKidnappingModeReady(); + } + + @Override + @Nullable + public UUID getCellIdFromCollar() { + return entity.getCellIdFromCollar(); + } + + // ======================================== + // CAPTURE EQUIPMENT + // ======================================== + + @Override + public void setUpHeldItems() { + entity.setUpHeldItems(); + } + + @Override + public ItemStack getBindItem() { + return entity.getBindItem(); + } + + @Override + public ItemStack getGagItem() { + return entity.getGagItem(); + } + + // ======================================== + // STATE FLAGS + // ======================================== + + @Override + public boolean isGetOutState() { + return entity.isGetOutState(); + } + + @Override + public boolean isDogwalking() { + return entity.isDogwalking(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/AggressionHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/AggressionHost.java new file mode 100644 index 0000000..973eebe --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/AggressionHost.java @@ -0,0 +1,39 @@ +package com.tiedup.remake.entities.kidnapper.hosts; + +import com.tiedup.remake.dialogue.EntityDialogueManager; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.kidnapper.components.IAggressionHost; +import org.jetbrains.annotations.Nullable; +import net.minecraft.world.level.Level; + +/** + * Host implementation for AggressionSystem callbacks. + * Phase 1.3: Provides access to entity properties for aggression tracking. + */ +public class AggressionHost implements IAggressionHost { + + private final EntityKidnapper entity; + + public AggressionHost(EntityKidnapper entity) { + this.entity = entity; + } + + @Override + @Nullable + public Level level() { + return entity.level(); + } + + @Override + public String getNpcName() { + return entity.getNpcName(); + } + + @Override + public void talkToPlayersInRadius( + EntityDialogueManager.DialogueCategory category, + int radius + ) { + entity.talkToPlayersInRadius(category, radius); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/AlertHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/AlertHost.java new file mode 100644 index 0000000..e56f982 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/AlertHost.java @@ -0,0 +1,55 @@ +package com.tiedup.remake.entities.kidnapper.hosts; + +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.ai.kidnapper.KidnapperState; +import com.tiedup.remake.entities.kidnapper.components.IAlertHost; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; + +/** + * Host implementation for KidnapperAlertManager callbacks. + * Provides access to entity properties needed for the alert system. + */ +public class AlertHost implements IAlertHost { + + private final EntityKidnapper entity; + + public AlertHost(EntityKidnapper entity) { + this.entity = entity; + } + + @Override + public Level level() { + return entity.level(); + } + + @Override + public AABB getBoundingBox() { + return entity.getBoundingBox(); + } + + @Override + public KidnapperState getCurrentState() { + return entity.getCurrentState(); + } + + @Override + public void setCurrentState(KidnapperState state) { + entity.setCurrentState(state); + } + + @Override + public boolean hasCaptives() { + return entity.hasCaptives(); + } + + @Override + public boolean isTiedUp() { + return entity.isTiedUp(); + } + + @Override + public String getNpcName() { + return entity.getNpcName(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/AppearanceHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/AppearanceHost.java new file mode 100644 index 0000000..494b83c --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/AppearanceHost.java @@ -0,0 +1,72 @@ +package com.tiedup.remake.entities.kidnapper.hosts; + +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.kidnapper.components.IAppearanceHost; +import com.tiedup.remake.entities.skins.Gender; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.Level; + +/** + * Host implementation for AppearanceManager callbacks. + * Phase 1.2: Provides access to entity properties needed for appearance management. + */ +public class AppearanceHost implements IAppearanceHost { + + private final EntityKidnapper entity; + + public AppearanceHost(EntityKidnapper entity) { + this.entity = entity; + } + + @Override + public UUID getUUID() { + return entity.getUUID(); + } + + @Override + public RandomSource getRandom() { + return entity.getRandom(); + } + + @Override + @Nullable + public Level level() { + return entity.level(); + } + + @Override + public void setSlimArms(boolean slimArms) { + entity.setSlimArms(slimArms); + } + + @Override + public void setGender(Gender gender) { + entity.setGender(gender); + } + + @Override + public void setNpcName(String name) { + entity.setNpcName(name); + } + + @Override + public String getNpcName() { + return entity.getNpcName(); + } + + @Override + public void setEntityDataString( + EntityDataAccessor accessor, + String value + ) { + entity.getEntityData().set(accessor, value); + } + + @Override + public String getEntityDataString(EntityDataAccessor accessor) { + return entity.getEntityData().get(accessor); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/BondageHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/BondageHost.java new file mode 100644 index 0000000..688ab07 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/BondageHost.java @@ -0,0 +1,96 @@ +package com.tiedup.remake.entities.kidnapper.hosts; + +import com.tiedup.remake.dialogue.EntityDialogueManager; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.damsel.components.DamselInventoryManager; +import com.tiedup.remake.entities.damsel.components.IBondageHost; +import com.tiedup.remake.personality.PersonalityState; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * Host implementation for BondageManager callbacks specific to EntityKidnapper. + * + * Phase 3 (AbstractTiedUpNpc migration): Created so EntityKidnapper can provide + * its own IBondageHost instead of inheriting EntityDamsel's BondageHost. + * + * Key differences from the Damsel BondageHost: + * - getPersonalityState() returns null (kidnappers have no personality system) + * - talkToPlayersInRadius() is a no-op (kidnapper dialogue is separate) + * + * NOTE: This host is created during super() constructor (AbstractTiedUpNpc). + * It must only store the entity reference, NOT access any EntityKidnapper fields + * that haven't been initialized yet. + */ +public class BondageHost implements IBondageHost { + + private final EntityKidnapper entity; + + public BondageHost(EntityKidnapper entity) { + this.entity = entity; + } + + @Override + public PersonalityState getPersonalityState() { + // Kidnappers don't have a personality system + return null; + } + + @Override + public DamselInventoryManager getInventory() { + return entity.getInventoryManager(); + } + + @Override + public void dropItemStack(ItemStack stack) { + entity.spawnAtLocation(stack); + } + + @Override + public void playSound(SoundEvent sound) { + entity.playSound(sound, 1.0f, 1.0f); + } + + @Override + public void setHealth(float health) { + entity.setHealth(health); + } + + @Override + public void remove(Entity.RemovalReason reason) { + entity.remove(reason); + } + + @Override + public Level level() { + return entity.level(); + } + + @Override + public BlockPos blockPosition() { + return entity.blockPosition(); + } + + @Override + public UUID getUUID() { + return entity.getUUID(); + } + + @Override + public String getNpcName() { + return entity.getNpcName(); + } + + @Override + public void talkToPlayersInRadius( + EntityDialogueManager.DialogueCategory category, + int radius + ) { + // Kidnappers use their own dialogue system (KidnapperDialogueTriggerSystem), + // not the damsel dialogue categories. No-op here. + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/CampHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/CampHost.java new file mode 100644 index 0000000..9e329fd --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/CampHost.java @@ -0,0 +1,28 @@ +package com.tiedup.remake.entities.kidnapper.hosts; + +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.kidnapper.components.ICampHost; +import net.minecraft.util.RandomSource; + +/** + * Host implementation for CampManager callbacks. + * Phase 1.4: Provides random source and name for camp assignment. + */ +public class CampHost implements ICampHost { + + private final EntityKidnapper entity; + + public CampHost(EntityKidnapper entity) { + this.entity = entity; + } + + @Override + public RandomSource getRandom() { + return entity.getRandom(); + } + + @Override + public String getNpcName() { + return entity.getNpcName(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/CaptiveHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/CaptiveHost.java new file mode 100644 index 0000000..35e225c --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/CaptiveHost.java @@ -0,0 +1,123 @@ +package com.tiedup.remake.entities.kidnapper.hosts; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.dialogue.EntityDialogueManager; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.ai.kidnapper.KidnapperState; +import com.tiedup.remake.entities.kidnapper.components.ICaptiveHost; +import com.tiedup.remake.entities.kidnapper.components.KidnapperAggressionSystem; +import com.tiedup.remake.entities.kidnapper.components.KidnapperAlertManager; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +/** + * Host implementation for KidnapperCaptiveManager callbacks. + * Provides access to entity properties needed for captive management. + */ +public class CaptiveHost implements ICaptiveHost { + + private final EntityKidnapper entity; + + public CaptiveHost(EntityKidnapper entity) { + this.entity = entity; + } + + @Override + public Level level() { + return entity.level(); + } + + @Override + public UUID getUUID() { + return entity.getUUID(); + } + + @Override + public Vec3 position() { + return entity.position(); + } + + @Override + public BlockPos blockPosition() { + return entity.blockPosition(); + } + + @Override + public String getNpcName() { + return entity.getNpcName(); + } + + @Override + public void setEntityData(EntityDataAccessor accessor, T value) { + entity.getEntityData().set(accessor, value); + } + + @Override + public boolean isTiedUp() { + return entity.isTiedUp(); + } + + @Override + public KidnapperState getCurrentState() { + return entity.getCurrentState(); + } + + @Override + public void setCurrentState(KidnapperState state) { + entity.setCurrentState(state); + } + + @Override + public void setGetOutState(boolean state) { + entity.setGetOutState(state); + } + + @Override + public void talkToPlayersInRadius( + EntityDialogueManager.DialogueCategory category, + int radius + ) { + entity.talkToPlayersInRadius(category, radius); + } + + @Override + public KidnapperAggressionSystem getAggressionSystem() { + return entity.getAggressionSystem(); + } + + @Override + public KidnapperAlertManager getAlertManager() { + return entity.getAlertManager(); + } + + @Override + public void broadcastAlert(LivingEntity escapee) { + entity.broadcastAlert(escapee); + } + + @Override + public boolean hasLineOfSight(LivingEntity entity) { + return this.entity.hasLineOfSight(entity); + } + + @Override + public float distanceTo(LivingEntity entity) { + return this.entity.distanceTo(entity); + } + + @Override + @Nullable + public CellDataV2 findCellContainingPrisoner(UUID prisonerId) { + return entity.findCellContainingPrisoner(prisonerId); + } + + @Override + public LivingEntity asLivingEntity() { + return entity; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/CellHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/CellHost.java new file mode 100644 index 0000000..fe3cdf2 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/CellHost.java @@ -0,0 +1,62 @@ +package com.tiedup.remake.entities.kidnapper.hosts; + +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.ai.kidnapper.KidnapperState; +import com.tiedup.remake.entities.kidnapper.components.ICellHost; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.ai.navigation.PathNavigation; +import net.minecraft.world.level.Level; + +/** + * Host implementation for KidnapperCellManager callbacks. + * Provides access to entity properties needed for cell management. + */ +public class CellHost implements ICellHost { + + private final EntityKidnapper entity; + + public CellHost(EntityKidnapper entity) { + this.entity = entity; + } + + @Override + public Level level() { + return entity.level(); + } + + @Override + public BlockPos blockPosition() { + return entity.blockPosition(); + } + + @Override + @Nullable + public UUID getAssociatedStructure() { + return entity.getAssociatedStructure(); + } + + @Override + public PathNavigation getNavigation() { + return entity.getNavigation(); + } + + @Override + public void setCurrentState(KidnapperState state) { + entity.setCurrentState(state); + } + + @Override + public void talkToPlayersInRadius( + com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory category, + int radius + ) { + entity.talkToPlayersInRadius(category, radius); + } + + @Override + public String getNpcName() { + return entity.getNpcName(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/DataSerializerHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/DataSerializerHost.java new file mode 100644 index 0000000..c3e41ad --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/DataSerializerHost.java @@ -0,0 +1,90 @@ +package com.tiedup.remake.entities.kidnapper.hosts; + +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.KidnapperJobManager; +import com.tiedup.remake.entities.kidnapper.components.*; +import net.minecraft.network.syncher.EntityDataAccessor; + +/** + * Host implementation for KidnapperDataSerializer callbacks. + * Provides access to entity data and components for NBT persistence. + */ +public class DataSerializerHost implements IDataSerializerHost { + + private final EntityKidnapper entity; + + public DataSerializerHost(EntityKidnapper entity) { + this.entity = entity; + } + + // ======================================== + // ENTITY ACCESS + // ======================================== + + @Override + public EntityKidnapper getEntity() { + return entity; + } + + // ======================================== + // ENTITY DATA ACCESS + // ======================================== + + @Override + public T getEntityData(EntityDataAccessor accessor) { + return entity.getEntityData().get(accessor); + } + + @Override + public void setEntityData(EntityDataAccessor accessor, T value) { + entity.getEntityData().set(accessor, value); + } + + // ======================================== + // STATE FLAGS + // ======================================== + + @Override + public boolean isGetOutState() { + return entity.isGetOutState(); + } + + @Override + public void setGetOutState(boolean state) { + entity.setGetOutState(state); + } + + // ======================================== + // COMPONENT ACCESS + // ======================================== + + @Override + public KidnapperAppearance getAppearance() { + return entity.getAppearanceComponent(); + } + + @Override + public KidnapperAggressionSystem getAggressionSystem() { + return entity.getAggressionSystem(); + } + + @Override + public KidnapperCaptiveManager getCaptiveManager() { + return entity.getCaptiveManagerComponent(); + } + + @Override + public KidnapperStateManager getStateManager() { + return entity.getStateManagerComponent(); + } + + @Override + public KidnapperCampManager getCampManager() { + return entity.getCampManagerComponent(); + } + + @Override + public KidnapperJobManager getJobManager() { + return entity.getJobManager(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/SaleHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/SaleHost.java new file mode 100644 index 0000000..c2118e0 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/SaleHost.java @@ -0,0 +1,56 @@ +package com.tiedup.remake.entities.kidnapper.hosts; + +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.kidnapper.components.ISaleHost; +import com.tiedup.remake.state.IRestrainable; +import org.jetbrains.annotations.Nullable; +import net.minecraft.world.level.Level; + +/** + * Host implementation for KidnapperSaleManager callbacks. + * Provides access to entity properties needed for sale management. + */ +public class SaleHost implements ISaleHost { + + private final EntityKidnapper entity; + + public SaleHost(EntityKidnapper entity) { + this.entity = entity; + } + + @Override + public Level level() { + return entity.level(); + } + + @Override + public boolean hasCaptives() { + return entity.hasCaptives(); + } + + @Override + @Nullable + public IRestrainable getCaptive() { + return entity.getCaptive(); + } + + @Override + public String getNpcName() { + return entity.getNpcName(); + } + + @Override + public void setGetOutState(boolean state) { + entity.setGetOutState(state); + } + + @Override + public boolean getAllowCaptiveTransferFlag() { + return entity.getAllowCaptiveTransferFlag(); + } + + @Override + public void setAllowCaptiveTransferFlag(boolean flag) { + entity.setAllowCaptiveTransferFlag(flag); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/StateHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/StateHost.java new file mode 100644 index 0000000..528d2e9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/StateHost.java @@ -0,0 +1,22 @@ +package com.tiedup.remake.entities.kidnapper.hosts; + +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.kidnapper.components.IStateHost; + +/** + * Host implementation for StateManager callbacks. + * Phase 1.1: Simple host providing name access for logging. + */ +public class StateHost implements IStateHost { + + private final EntityKidnapper entity; + + public StateHost(EntityKidnapper entity) { + this.entity = entity; + } + + @Override + public String getNpcName() { + return entity.getNpcName(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/TargetHost.java b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/TargetHost.java new file mode 100644 index 0000000..c873dc9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/kidnapper/hosts/TargetHost.java @@ -0,0 +1,103 @@ +package com.tiedup.remake.entities.kidnapper.hosts; + +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.KidnapperCollarConfig; +import com.tiedup.remake.entities.KidnapperJobManager; +import com.tiedup.remake.entities.kidnapper.components.ITargetHost; +import com.tiedup.remake.entities.kidnapper.components.KidnapperAggressionSystem; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.navigation.PathNavigation; +import net.minecraft.world.entity.ai.sensing.Sensing; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; + +/** + * Host implementation for KidnapperTargetSelector callbacks. + * Provides access to entity properties needed for target selection and validation. + */ +public class TargetHost implements ITargetHost { + + private final EntityKidnapper entity; + + public TargetHost(EntityKidnapper entity) { + this.entity = entity; + } + + @Override + public Level level() { + return entity.level(); + } + + @Override + public Sensing getSensing() { + return entity.getSensing(); + } + + @Override + public AABB getBoundingBox() { + return entity.getBoundingBox(); + } + + @Override + public float distanceTo(LivingEntity other) { + return entity.distanceTo(other); + } + + @Override + public UUID getUUID() { + return entity.getUUID(); + } + + @Override + public boolean isTiedUp() { + return entity.isTiedUp(); + } + + @Override + public boolean hasCaptives() { + return entity.hasCaptives(); + } + + @Override + public boolean isWaitingForJobToBeCompleted() { + return entity.isWaitingForJobToBeCompleted(); + } + + @Override + public boolean isGetOutState() { + return entity.isGetOutState(); + } + + @Override + public boolean isKidnappingModeEnabled() { + return entity.isKidnappingModeEnabled(); + } + + @Override + @Nullable + public UUID getAssociatedStructure() { + return entity.getAssociatedStructure(); + } + + @Override + public PathNavigation getNavigation() { + return entity.getNavigation(); + } + + @Override + public KidnapperAggressionSystem getAggressionSystem() { + return entity.getAggressionSystem(); + } + + @Override + public KidnapperJobManager getJobManager() { + return entity.getJobManager(); + } + + @Override + public KidnapperCollarConfig getCollarConfig() { + return entity.getCollarConfig(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/maid/components/MaidPrisonInteraction.java b/src/main/java/com/tiedup/remake/entities/maid/components/MaidPrisonInteraction.java new file mode 100644 index 0000000..2a97c8a --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/maid/components/MaidPrisonInteraction.java @@ -0,0 +1,492 @@ +package com.tiedup.remake.entities.maid.components; + +import com.tiedup.remake.cells.CampOwnership; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityMaid; +import com.tiedup.remake.labor.LaborTask; +import com.tiedup.remake.prison.LaborRecord; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerRecord; +import com.tiedup.remake.prison.PrisonerState; +import com.tiedup.remake.prison.RansomRecord; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.util.MessageDispatcher; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.ChatFormatting; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; + +/** + * Manages all prison-related interactions for an EntityMaid. + * + * Responsibilities: + * - Manual prisoner check-in (task completion) + * - Manual prisoner extraction (stuck PENDING state) + * - Labor status display + * - Prisoner record lookups + * - Struggle detection and punishment + * + * This is a plain Java component (not a Forge capability). + * Lifecycle is owned by EntityMaid. + */ +public class MaidPrisonInteraction { + + // ======================================== + // CONSTANTS + // ======================================== + + /** Maximum distance to HEAR struggle (through bars, no line of sight needed) */ + private static final double STRUGGLE_HEARING_RANGE = 6.0; + + /** Maximum distance to SEE struggle (requires line of sight) */ + private static final double STRUGGLE_VISION_RANGE = 15.0; + + // ======================================== + // STATE + // ======================================== + + private final EntityMaid maid; + + /** Target prisoner to approach after catching them struggling */ + @Nullable + private ServerPlayer strugglePunishmentTarget; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public MaidPrisonInteraction(EntityMaid maid) { + this.maid = maid; + } + + // ======================================== + // PLAYER INTERACTION DISPATCH + // ======================================== + + /** + * Handle prisoner-specific interactions when a player right-clicks the maid. + * Called from EntityMaid.mobInteract() after verifying the player is a prisoner of this camp. + * + * @param serverPlayer The interacting player (server-side) + * @param manager The PrisonerManager instance + * @param record The player's prisoner record + * @param laborRecord The player's labor record (may be null) + * @return InteractionResult.SUCCESS if handled, null if not a prison interaction + */ + @Nullable + public InteractionResult handlePrisonerInteract( + ServerPlayer serverPlayer, + PrisonerManager manager, + PrisonerRecord record, + @Nullable LaborRecord laborRecord + ) { + LaborTask task = laborRecord != null ? laborRecord.getTask() : null; + PrisonerState state = record.getState(); + + // CASE 1: Stuck in IMPRISONED with pending task (maid failed to extract) + if ( + state == PrisonerState.IMPRISONED && + laborRecord != null && + laborRecord.getPhase() == LaborRecord.WorkPhase.PENDING_EXTRACTION && + serverPlayer.isShiftKeyDown() + ) { + handleManualExtraction(serverPlayer, manager, laborRecord); + return InteractionResult.SUCCESS; + } + + // CASE 2: Manual turn-in (task complete) + if ( + state == PrisonerState.WORKING && + task != null && + serverPlayer.isShiftKeyDown() + ) { + // Check current progress first + if (maid.level() instanceof ServerLevel serverLevel) { + task.checkProgress(serverPlayer, serverLevel); + } + + if (task.isComplete()) { + handleManualCheckIn(serverPlayer, manager, laborRecord); + return InteractionResult.SUCCESS; + } + } + + // CASE 3: Show status (normal click, not shift) + if (!serverPlayer.isShiftKeyDown()) { + showLaborStatus(serverPlayer, record, laborRecord); + return InteractionResult.SUCCESS; + } + + return null; // Not handled + } + + // ======================================== + // MANUAL EXTRACTION + // ======================================== + + /** + * Manually extract prisoner from stuck PENDING state. + */ + private void handleManualExtraction( + ServerPlayer player, + PrisonerManager manager, + LaborRecord laborRecord + ) { + if (!(maid.level() instanceof ServerLevel serverLevel)) { + return; + } + + IRestrainable cap = KidnappedHelper.getKidnappedState(player); + if (cap == null) return; + + LaborTask task = laborRecord.getTask(); + if (task == null) return; + + // Save bondage state to laborRecord and remove restraints for labor + CompoundTag bondageSnapshot = + com.tiedup.remake.prison.service.BondageService.get().saveSnapshot(cap); + laborRecord.setBondageSnapshot(bondageSnapshot); + manager.setLaborRecord(player.getUUID(), laborRecord); + com.tiedup.remake.prison.service.BondageService.get().removeForLabor(cap); + + task.giveEquipment(player); + + // Transition to WORKING state + manager.transitionToWorking( + player.getUUID(), + serverLevel.getGameTime() + ); + + sayToPlayer(player, "Very well. Here are your tools."); + player.sendSystemMessage( + Component.literal( + "The maid manually assigns you to: " + task.getDescription() + ).withStyle(ChatFormatting.YELLOW) + ); + + TiedUpMod.LOGGER.info( + "[EntityMaid] Manual extraction: {} -> WORKING", + player.getName().getString() + ); + } + + // ======================================== + // MANUAL CHECK-IN + // ======================================== + + /** + * Handle manual check-in when a working player completes their task early. + * NOTE: Does NOT add payment - MaidReturnGoal.collectAndStartEscort() handles payment. + * This just signals task completion and sets the phase for pickup. + */ + private void handleManualCheckIn( + ServerPlayer worker, + PrisonerManager manager, + LaborRecord laborRecord + ) { + if (!(maid.level() instanceof ServerLevel serverLevel)) { + return; + } + + LaborTask task = laborRecord.getTask(); + if (task == null) { + return; + } + + // Signal that task is complete - maid will pick up, collect items, and process payment + laborRecord.setPhase( + LaborRecord.WorkPhase.PENDING_RETURN, + serverLevel.getGameTime() + ); + manager.setLaborRecord(worker.getUUID(), laborRecord); + + sayToPlayer(worker, "Good. You've completed your task."); + worker.sendSystemMessage( + Component.literal( + "A Maid will come to collect you shortly." + ).withStyle(ChatFormatting.YELLOW) + ); + + TiedUpMod.LOGGER.info( + "[EntityMaid] {} manual check-in: {} task complete, awaiting pickup", + maid.getNpcName(), + worker.getName().getString() + ); + } + + // ======================================== + // STATUS DISPLAY + // ======================================== + + /** + * Show labor status to prisoner. + */ + private void showLaborStatus( + ServerPlayer player, + PrisonerRecord record, + @Nullable LaborRecord laborRecord + ) { + player.sendSystemMessage( + Component.literal("=== Labor Status ===").withStyle( + ChatFormatting.GOLD + ) + ); + + player.sendSystemMessage( + Component.literal("State: " + record.getState().name()).withStyle( + ChatFormatting.GRAY + ) + ); + + if (laborRecord != null && laborRecord.getTask() != null) { + LaborTask task = laborRecord.getTask(); + player.sendSystemMessage( + Component.literal("Task: " + task.getDescription()).withStyle( + ChatFormatting.GRAY + ) + ); + player.sendSystemMessage( + Component.literal( + "Progress: " + task.getProgress() + "/" + task.getQuota() + ).withStyle(ChatFormatting.GRAY) + ); + } + + if (!(maid.level() instanceof ServerLevel serverLevel)) { + return; + } + + PrisonerManager manager = PrisonerManager.get(serverLevel); + RansomRecord ransom = manager.getRansomRecord(player.getUUID()); + int remaining = ransom != null ? ransom.getRemainingDebt() : 0; + player.sendSystemMessage( + Component.literal( + "Remaining debt: " + remaining + " emeralds" + ).withStyle(ChatFormatting.GRAY) + ); + + // Show action hints + if ( + laborRecord != null && + laborRecord.getPhase() == LaborRecord.WorkPhase.PENDING_EXTRACTION + ) { + player.sendSystemMessage( + Component.literal( + "Shift+Right-Click to manually start task" + ).withStyle(ChatFormatting.YELLOW) + ); + } else if ( + laborRecord != null && + laborRecord.getTask() != null && + laborRecord.getTask().isComplete() + ) { + player.sendSystemMessage( + Component.literal( + "Shift+Right-Click to manually turn in task" + ).withStyle(ChatFormatting.GREEN) + ); + } + } + + // ======================================== + // PRISONER RECORD ACCESS + // ======================================== + + /** + * Get the prisoner records for prisoners in this maid's camp. + * Returns a map of UUID -> PrisonerRecord for all prisoners managed by this camp. + */ + public Map getPrisonerRecords() { + if (!(maid.level() instanceof ServerLevel serverLevel)) { + return Collections.emptyMap(); + } + UUID campId = maid.getCampUUID(); + if (campId == null) { + return Collections.emptyMap(); + } + + PrisonerManager manager = PrisonerManager.get(serverLevel); + Map result = new HashMap<>(); + + // Find all prisoners in this camp + for (UUID prisonerId : manager.getPrisonersInCamp(campId)) { + PrisonerRecord record = manager.getPrisoner(prisonerId); + if (record != null) { + result.put(prisonerId, record); + } + } + + return result; + } + + /** + * Get the processed prisoners set. + * Delegates to CampOwnership for persistence. + */ + public Set getProcessedPrisoners() { + if (!(maid.level() instanceof ServerLevel serverLevel)) { + return Collections.emptySet(); + } + return CampOwnership.get(serverLevel).getProcessedPrisoners(); + } + + /** + * Get the CampOwnership registry for direct access. + * Returns null on client side. + */ + @Nullable + public CampOwnership getCampOwnership() { + if (!(maid.level() instanceof ServerLevel serverLevel)) { + return null; + } + return CampOwnership.get(serverLevel); + } + + /** + * Get the PrisonerManager for direct access. + * Returns null on client side. + */ + @Nullable + public PrisonerManager getPrisonerManager() { + if (!(maid.level() instanceof ServerLevel serverLevel)) { + return null; + } + return PrisonerManager.get(serverLevel); + } + + // ======================================== + // STRUGGLE DETECTION + // ======================================== + + /** + * Called when a prisoner is detected struggling nearby. + * Uses HYBRID detection: HEARING (close range) + VISION (far range). + * + * Detection modes: + * - Within 6 blocks: Can HEAR through bars/fences (no line of sight needed) + * - Within 15 blocks: Can SEE if line of sight is clear + * + * If detected: + * 1. Shock the prisoner (punishment) + * 2. Approach to tighten their binds (reset resistance) + * + * @param prisoner The player who is struggling + */ + public void onStruggleDetected(ServerPlayer prisoner) { + // Don't react if busy, tied, or freed + if (maid.getMaidState().isBusy() || maid.isTiedUp() || maid.isFreed()) { + return; + } + + // Must have a master trader + if (maid.getMasterTrader() == null) { + return; + } + + // HYBRID DETECTION: Hearing (close) + Vision (far) + double distance = maid.distanceTo(prisoner); + + // HEARING: Close range - can hear through bars/fences (no LOS needed) + boolean canHear = distance <= STRUGGLE_HEARING_RANGE; + + // VISION: Longer range - requires clear line of sight + boolean canSee = + distance <= STRUGGLE_VISION_RANGE && + maid.getSensing().hasLineOfSight(prisoner); + + if (!canHear && !canSee) { + return; // Can't detect the struggle + } + + // Check if this player is a prisoner of our camp + UUID campId = maid.getCampUUID(); + if (campId == null) { + return; + } + + if (!(maid.level() instanceof ServerLevel serverLevel)) { + return; + } + + PrisonerManager manager = PrisonerManager.get(serverLevel); + PrisonerRecord record = manager.getPrisoner(prisoner.getUUID()); + if (record == null || !campId.equals(record.getCampId())) { + return; // Not our prisoner + } + + IRestrainable state = KidnappedHelper.getKidnappedState(prisoner); + if (state == null || !state.isTiedUp()) { + return; + } + + String detectionMethod = canHear ? "heard" : "saw"; + TiedUpMod.LOGGER.info( + "[EntityMaid] {} {} {} struggling! Punishing... (distance: {})", + maid.getNpcName(), + detectionMethod, + prisoner.getName().getString(), + distance + ); + + // PUNISHMENT: Shock the prisoner + state.shockKidnapped(" Stop struggling!", 2.0f); + + // TIGHTEN BINDS: Reset resistance to maximum + tightenBinds(state, prisoner); + + // Look at the prisoner menacingly + maid.getLookControl().setLookAt(prisoner, 30.0f, 30.0f); + + // Set as target to approach + this.strugglePunishmentTarget = prisoner; + } + + /** + * Tighten the prisoner's binds by resetting their resistance to maximum. + * This happens when a guard catches someone struggling. + * + * @param state The prisoner's kidnapped state + * @param prisoner The prisoner entity + */ + private void tightenBinds(IRestrainable state, LivingEntity prisoner) { + com.tiedup.remake.util.RestraintApplicator.tightenBind(state, prisoner); + } + + /** + * Get the current struggle punishment target. + * @return The prisoner to approach, or null if none + */ + @Nullable + public ServerPlayer getStrugglePunishmentTarget() { + return this.strugglePunishmentTarget; + } + + /** + * Clear the struggle punishment target (after approaching or timeout). + */ + public void clearStrugglePunishmentTarget() { + this.strugglePunishmentTarget = null; + } + + // ======================================== + // HELPERS + // ======================================== + + /** + * Say a message to a specific player via the maid. + */ + private void sayToPlayer(ServerPlayer player, String message) { + MessageDispatcher.talkTo(maid, player, message); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/maid/components/MaidTraderLink.java b/src/main/java/com/tiedup/remake/entities/maid/components/MaidTraderLink.java new file mode 100644 index 0000000..3140b96 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/maid/components/MaidTraderLink.java @@ -0,0 +1,419 @@ +package com.tiedup.remake.entities.maid.components; + +import com.tiedup.remake.cells.CampOwnership; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityMaid; +import com.tiedup.remake.entities.EntitySlaveTrader; +import com.tiedup.remake.entities.ai.maid.MaidState; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.phys.AABB; + +/** + * Manages the bidirectional link between an EntityMaid and her master EntitySlaveTrader. + * + * Responsibilities: + * - Storing and persisting the master trader UUID + * - Multi-level trader lookup (cache, UUID, camp ownership, area search) + * - Periodic link establishment for maids spawned before their trader + * - Camp UUID lookup via CampOwnership registry + * + * This is a plain Java component (not a Forge capability or system). + * Lifecycle is owned by EntityMaid. + */ +public class MaidTraderLink { + + // ======================================== + // CONSTANTS + // ======================================== + + /** Search interval in ticks (2 seconds) */ + private static final int TRADER_SEARCH_INTERVAL = 40; + + /** Interval for link establishment retry (5 seconds) */ + private static final int LINK_ESTABLISHMENT_INTERVAL = 100; + + /** Maximum attempts to establish link before giving up */ + private static final int MAX_LINK_ATTEMPTS = 12; // 1 minute total + + // ======================================== + // STATE + // ======================================== + + private final EntityMaid maid; + + /** UUID of the trader this maid serves */ + @Nullable + private UUID masterTraderUUID; + + /** Cached trader reference for performance */ + @Nullable + private transient EntitySlaveTrader cachedTrader; + + /** Tick counter for periodic trader search */ + private transient int traderSearchCooldown = 0; + + /** Tick counter for periodic link establishment (when masterTraderUUID is null) */ + private transient int linkEstablishmentCounter = 0; + + /** Current link attempt count */ + private transient int linkAttemptCount = 0; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public MaidTraderLink(EntityMaid maid) { + this.maid = maid; + } + + // ======================================== + // ACCESSORS + // ======================================== + + @Nullable + public UUID getMasterTraderUUID() { + return masterTraderUUID; + } + + public void setMasterTraderUUID(@Nullable UUID masterTraderUUID) { + this.masterTraderUUID = masterTraderUUID; + } + + /** + * Clear the trader UUID and cached reference. + * Used when the trader dies or the maid is captured and removed from camp. + */ + public void clearTraderUUID() { + this.masterTraderUUID = null; + this.cachedTrader = null; + } + + // ======================================== + // TRADER LOOKUP + // ======================================== + + /** + * Get the master trader entity. + * Uses multi-level lookup strategy: + * 1. Cached reference (if still valid) + * 2. Direct entity lookup by UUID + * 3. CampOwnership registry lookup + * 4. Area search around camp center + * 5. Area search around maid position + */ + @Nullable + public EntitySlaveTrader getMasterTrader() { + if (!(maid.level() instanceof ServerLevel serverLevel)) { + return null; + } + + // Check cached trader first + if ( + cachedTrader != null && + cachedTrader.isAlive() && + !cachedTrader.isRemoved() + ) { + // Update masterTraderUUID if cache is valid but UUID was null + if (masterTraderUUID == null) { + masterTraderUUID = cachedTrader.getUUID(); + } + return cachedTrader; + } + cachedTrader = null; + + // If no UUID and no cache, search via camp ownership + if (masterTraderUUID == null) { + return searchTraderViaCampOwnership(serverLevel); + } + + // Try direct entity lookup (fast path - works if same chunk) + var entity = serverLevel.getEntity(masterTraderUUID); + if (entity instanceof EntitySlaveTrader trader && trader.isAlive()) { + cachedTrader = trader; + return trader; + } + + // Fallback: search via CampOwnership and area search + return searchTraderViaCampOwnership(serverLevel); + } + + // ======================================== + // CAMP UUID + // ======================================== + + /** + * Get the camp UUID this maid belongs to. + * Looks up via CampOwnership registry using maid's UUID. + */ + @Nullable + public UUID getCampUUID() { + if (!(maid.level() instanceof ServerLevel serverLevel)) { + return null; + } + CampOwnership ownership = CampOwnership.get(serverLevel); + CampOwnership.CampData camp = ownership.getCampByMaid(maid.getUUID()); + return camp != null ? camp.getCampId() : null; + } + + // ======================================== + // SEARCH STRATEGIES + // ======================================== + + /** + * Search for trader using CampOwnership registry and area search. + */ + @Nullable + private EntitySlaveTrader searchTraderViaCampOwnership( + ServerLevel serverLevel + ) { + // Only search periodically to avoid performance issues + if (traderSearchCooldown > 0) { + traderSearchCooldown--; + return null; + } + traderSearchCooldown = TRADER_SEARCH_INTERVAL; + + CampOwnership ownership = CampOwnership.get(serverLevel); + + // Try to find camp by this maid's UUID + CampOwnership.CampData camp = ownership.getCampByMaid(maid.getUUID()); + + if (camp != null && camp.isAlive()) { + UUID traderUUID = camp.getTraderUUID(); + if (traderUUID != null) { + // Update our stored UUID + this.masterTraderUUID = traderUUID; + + // Search around camp center (most likely location) + BlockPos campCenter = camp.getCenter(); + if (campCenter != null) { + EntitySlaveTrader trader = searchTraderInArea( + serverLevel, + traderUUID, + campCenter, + EntityMaid.MAID_FOLLOW_RANGE + ); + if (trader != null) { + cachedTrader = trader; + return trader; + } + } + } + } + + // Last fallback: search around maid's position + if (masterTraderUUID != null) { + EntitySlaveTrader trader = searchTraderInArea( + serverLevel, + masterTraderUUID, + maid.blockPosition(), + EntityMaid.MAID_FOLLOW_RANGE + ); + if (trader != null) { + cachedTrader = trader; + return trader; + } + } + + return null; + } + + /** + * Search for a specific trader in an area around a position. + */ + @Nullable + private EntitySlaveTrader searchTraderInArea( + ServerLevel level, + UUID targetUUID, + BlockPos center, + double radius + ) { + AABB searchBox = new AABB(center).inflate(radius); + + for (EntitySlaveTrader trader : level.getEntitiesOfClass( + EntitySlaveTrader.class, + searchBox + )) { + if (trader.getUUID().equals(targetUUID) && trader.isAlive()) { + return trader; + } + } + return null; + } + + // ======================================== + // LINK ESTABLISHMENT + // ======================================== + + /** + * Periodically attempt to establish link to master trader if not linked. + * This handles the case where maid spawns before trader in structure generation. + */ + public void tickLinkEstablishment(ServerLevel serverLevel) { + // Skip if already linked + if (masterTraderUUID != null) { + return; + } + + // Skip if freed (trader died) + if (maid.getMaidState() == MaidState.FREE) { + return; + } + + // Skip if max attempts exceeded (prevents infinite searching) + if (linkAttemptCount >= MAX_LINK_ATTEMPTS) { + return; + } + + // Increment counter and check interval + linkEstablishmentCounter++; + if (linkEstablishmentCounter < LINK_ESTABLISHMENT_INTERVAL) { + return; + } + linkEstablishmentCounter = 0; + linkAttemptCount++; + + TiedUpMod.LOGGER.debug( + "[EntityMaid] {} attempting to establish trader link (attempt {}/{})", + maid.getNpcName(), + linkAttemptCount, + MAX_LINK_ATTEMPTS + ); + + // Strategy 1: Search via CampOwnership for camps near our position + CampOwnership ownership = CampOwnership.get(serverLevel); + CampOwnership.CampData camp = ownership.findNearestAliveCamp( + maid.blockPosition(), + 50 + ); + + if (camp != null && camp.getTraderUUID() != null) { + // Found a camp with a trader - try to link + UUID traderUUID = camp.getTraderUUID(); + + // Search for the trader entity + EntitySlaveTrader trader = findTraderEntity( + serverLevel, + traderUUID + ); + if (trader != null) { + establishBidirectionalLink(trader, camp, ownership); + return; + } + } + + // Strategy 2: Search for any nearby trader without a maid + EntitySlaveTrader unlinkedTrader = findUnlinkedTraderNearby( + serverLevel + ); + if (unlinkedTrader != null) { + // Get or create camp data + CampOwnership.CampData traderCamp = null; + if (unlinkedTrader.getCampUUID() != null) { + traderCamp = ownership.getCamp(unlinkedTrader.getCampUUID()); + } + establishBidirectionalLink(unlinkedTrader, traderCamp, ownership); + } + } + + /** + * Find a trader entity by UUID, searching around our position. + */ + @Nullable + private EntitySlaveTrader findTraderEntity( + ServerLevel level, + UUID traderUUID + ) { + // Direct lookup first + var entity = level.getEntity(traderUUID); + if (entity instanceof EntitySlaveTrader trader && trader.isAlive()) { + return trader; + } + + // Area search around our position + return searchTraderInArea( + level, + traderUUID, + maid.blockPosition(), + EntityMaid.MAID_FOLLOW_RANGE + ); + } + + /** + * Find any nearby trader that doesn't have a maid assigned. + */ + @Nullable + private EntitySlaveTrader findUnlinkedTraderNearby(ServerLevel level) { + AABB searchBox = new AABB(maid.blockPosition()).inflate( + EntityMaid.MAID_FOLLOW_RANGE + ); + + for (EntitySlaveTrader trader : level.getEntitiesOfClass( + EntitySlaveTrader.class, + searchBox + )) { + if (trader.isAlive() && trader.getMaidUUID() == null) { + return trader; + } + } + return null; + } + + /** + * Establish bidirectional link between this maid and a trader. + */ + private void establishBidirectionalLink( + EntitySlaveTrader trader, + @Nullable CampOwnership.CampData camp, + CampOwnership ownership + ) { + // Link maid -> trader + this.masterTraderUUID = trader.getUUID(); + this.cachedTrader = trader; + + // Link trader -> maid + trader.setMaidUUID(maid.getUUID()); + + // Link camp -> maid (if camp exists) + if (camp != null) { + camp.setMaidUUID(maid.getUUID()); + ownership.setDirty(); + } + + // Reset counters + this.linkAttemptCount = 0; + this.linkEstablishmentCounter = 0; + + TiedUpMod.LOGGER.info( + "[EntityMaid] {} successfully linked to trader {} via periodic search", + maid.getNpcName(), + trader.getNpcName() + ); + } + + // ======================================== + // NBT PERSISTENCE + // ======================================== + + /** + * Save trader link data to NBT. + */ + public void save(CompoundTag tag) { + if (masterTraderUUID != null) { + tag.putUUID("MasterTraderUUID", masterTraderUUID); + } + } + + /** + * Load trader link data from NBT. + */ + public void load(CompoundTag tag) { + if (tag.contains("MasterTraderUUID")) { + masterTraderUUID = tag.getUUID("MasterTraderUUID"); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/master/components/IMasterStateHost.java b/src/main/java/com/tiedup/remake/entities/master/components/IMasterStateHost.java new file mode 100644 index 0000000..01c2464 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/master/components/IMasterStateHost.java @@ -0,0 +1,36 @@ +package com.tiedup.remake.entities.master.components; + +/** + * Host interface for MasterStateManager callbacks. + * Provides access to entity information needed for state management and distraction mechanics. + */ +public interface IMasterStateHost { + /** + * Get the name of this master for logging purposes. + * + * @return The master's name + */ + String getNpcName(); + + /** + * Get the current game tick for timing calculations. + * + * @return Current tick count + */ + long getCurrentTick(); + + /** + * Called when the master becomes distracted. + */ + void onDistracted(); + + /** + * Called when the master stops being distracted. + */ + void onDistractionEnd(); + + /** + * Called when the master detects a struggle attempt. + */ + void onStruggleDetected(); +} diff --git a/src/main/java/com/tiedup/remake/entities/master/components/MasterPetManager.java b/src/main/java/com/tiedup/remake/entities/master/components/MasterPetManager.java new file mode 100644 index 0000000..12f9b47 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/master/components/MasterPetManager.java @@ -0,0 +1,321 @@ +package com.tiedup.remake.entities.master.components; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.entities.ai.master.MasterRandomEventGoal; +import com.tiedup.remake.entities.ai.master.MasterState; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.minigame.StruggleSessionManager; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * MasterPetManager - Manages pet player lifecycle for a Master entity. + * + * Responsibilities: + * - Pet player resolution (UUID to ServerPlayer) + * - Pet collar equip/removal + * - Leash attach/detach + * - Collar data sync for disconnect persistence + * - Collar resistance reset (on struggle detection) + * - Pet freedom (on master death/escape) + * + * The pet UUID itself is stored in {@link MasterStateManager} -- this component + * does NOT duplicate that field. It accesses it via the stateManager reference. + */ +public class MasterPetManager { + + /** Pet collar resistance (very high) */ + public static final int PET_COLLAR_RESISTANCE = 250; + + /** Back-reference to the owning Master entity */ + private final EntityMaster master; + + /** State manager holding the pet UUID */ + private final MasterStateManager stateManager; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public MasterPetManager(EntityMaster master, MasterStateManager stateManager) { + this.master = master; + this.stateManager = stateManager; + } + + // ======================================== + // PET PLAYER RESOLUTION + // ======================================== + + /** + * Get the pet player entity from the server. + * + * @return The pet ServerPlayer, or null if offline/not set + */ + @Nullable + public ServerPlayer getPetPlayer() { + UUID petUUID = stateManager.getPetPlayerUUID(); + if (petUUID == null) return null; + + if (master.level() instanceof ServerLevel serverLevel) { + return serverLevel.getServer().getPlayerList().getPlayer(petUUID); + } + return null; + } + + /** + * Set the pet player. Initializes state to FOLLOWING and starts distraction timer. + * + * @param player The player to become the pet + */ + public void setPetPlayer(ServerPlayer player) { + stateManager.setPetPlayerUUID(player.getUUID()); + stateManager.setCurrentState(MasterState.FOLLOWING); + stateManager.initializeDistractionTimer(); + } + + /** + * Check if this master has a pet. + * + * @return true if a pet UUID is set + */ + public boolean hasPet() { + return stateManager.hasPet(); + } + + /** + * Check if a player is the pet of this master. + * + * @param player The player to check + * @return true if the player's UUID matches the pet UUID + */ + public boolean isPetPlayer(Player player) { + if (player == null) return false; + UUID petUUID = stateManager.getPetPlayerUUID(); + return petUUID != null && petUUID.equals(player.getUUID()); + } + + // ======================================== + // PET COLLAR + // ======================================== + + /** + * Put a pet collar on the player. + * Replaces any existing collar (e.g., shock collar from Kidnapper) with a choke collar. + * Configures it for pet play mode. + * + * @param player The player to collar + */ + public void putPetCollar(ServerPlayer player) { + IBondageState state = KidnappedHelper.getKidnappedState(player); + if (state == null) return; + + // Create a choke collar for pet play + ItemStack chokeCollar = new ItemStack( + com.tiedup.remake.items.ModItems.CHOKE_COLLAR.get() + ); + + // Configure for pet play BEFORE equipping + if ( + chokeCollar.getItem() instanceof + com.tiedup.remake.items.ItemChokeCollar collar + ) { + collar.setCurrentResistance(chokeCollar, PET_COLLAR_RESISTANCE); + collar.setLocked(chokeCollar, true); + collar.setLockable(chokeCollar, false); // Cannot be lockpicked + collar.setCanBeStruggledOut(chokeCollar, true); // Can struggle, but very hard + + // Add this Master as owner + collar.addOwner(chokeCollar, master.getUUID(), master.getNpcName()); + + // Set NBT flag for pet play mode + chokeCollar.getOrCreateTag().putBoolean("petPlayMode", true); + chokeCollar.getOrCreateTag().putUUID("masterUUID", master.getUUID()); + } + + // Replace any existing collar (force removal) with the choke collar + state.replaceEquipment(BodyRegionV2.NECK, chokeCollar, true); + + TiedUpMod.LOGGER.info( + "[MasterPetManager] {} put choke collar on {}", + master.getNpcName(), + player.getName().getString() + ); + } + + /** + * Free the pet player (on master death or escape). + * Removes pet play mode from collar, unlocks it, cleans up temp items, + * and sends a message to the player. + * + * @param player The pet player to free + */ + public void freePet(ServerPlayer player) { + TiedUpMod.LOGGER.info( + "[MasterPetManager] {} freed pet {}", + master.getNpcName(), + player.getName().getString() + ); + + stateManager.clearPet(); + + // Remove all temporary master event items (blindfold, gag, mittens, bind) + // Using Long.MAX_VALUE forces all items to expire immediately + MasterRandomEventGoal.cleanupExpiredTempItems(player, Long.MAX_VALUE); + + // Remove pet play mode from collar + ItemStack collarStack = V2EquipmentHelper.getInRegion( + player, + BodyRegionV2.NECK + ); + if (!collarStack.isEmpty()) { + CompoundTag tag = collarStack.getTag(); + if (tag != null) { + tag.remove("petPlayMode"); + tag.remove("masterUUID"); + } + + // Unlock the collar + if (collarStack.getItem() instanceof ItemCollar collar) { + collar.setLocked(collarStack, false); + collar.setLockable(collarStack, true); + } + } + + // Send message to player + player.sendSystemMessage( + Component.literal( + "You are free! Your master " + + master.getNpcName() + + " is gone." + ).withStyle(Style.EMPTY.withColor(0x00FF00)) + ); + } + + // ======================================== + // COLLAR SYNC & RESISTANCE + // ======================================== + + /** + * Synchronize Master data to the pet's collar NBT. + * This ensures Master data is preserved even if Master entity is in an unloaded chunk + * when the player disconnects. + * + * Called every second during tick() for persistence safety. + * + * @param pet The pet player + */ + public void syncDataToCollar(ServerPlayer pet) { + ItemStack collarStack = V2EquipmentHelper.getInRegion( + pet, + BodyRegionV2.NECK + ); + if (collarStack.isEmpty()) return; + + CompoundTag tag = collarStack.getOrCreateTag(); + + // Store Master persistence data + tag.putString("masterVariantId", master.getKidnapperVariantId()); + tag.putString("masterState", master.getStateManager().serializeState()); + tag.putString("masterName", master.getNpcName()); + tag.putBoolean("masterDataValid", true); + + // masterUUID and petPlayMode already set by putPetCollar() + } + + /** + * Reset the pet collar resistance to maximum (Master "repairs" the lock). + * Also ends any active struggle session. + * + * @param pet The pet player whose collar should be reset + */ + public void resetCollarResistance(ServerPlayer pet) { + ItemStack collarStack = V2EquipmentHelper.getInRegion( + pet, + BodyRegionV2.NECK + ); + if (collarStack.isEmpty()) return; + + if (collarStack.getItem() instanceof ItemCollar collar) { + collar.setCurrentResistance(collarStack, PET_COLLAR_RESISTANCE); + TiedUpMod.LOGGER.debug( + "[MasterPetManager] Reset collar resistance for {}", + pet.getName().getString() + ); + } + + // End the struggle session + StruggleSessionManager.getInstance() + .endContinuousStruggleSession(pet.getUUID(), false); + } + + // ======================================== + // LEASH MANAGEMENT + // ======================================== + + /** + * Attach a leash from the pet player to this Master. + * Used during walks and other activities. + */ + public void attachLeashToPet() { + ServerPlayer pet = getPetPlayer(); + if (pet == null) return; + + if (pet instanceof com.tiedup.remake.state.IPlayerLeashAccess access) { + access.tiedup$attachLeash(master); + TiedUpMod.LOGGER.debug( + "[MasterPetManager] {} attached leash to {}", + master.getNpcName(), + pet.getName().getString() + ); + } + } + + /** + * Detach the leash from the pet player. + */ + public void detachLeashFromPet() { + ServerPlayer pet = getPetPlayer(); + if (pet == null) return; + + if (pet instanceof com.tiedup.remake.state.IPlayerLeashAccess access) { + if (access.tiedup$isLeashed()) { + access.tiedup$detachLeash(); + TiedUpMod.LOGGER.debug( + "[MasterPetManager] {} detached leash from {}", + master.getNpcName(), + pet.getName().getString() + ); + } + } + } + + /** + * Check if the pet player is currently leashed to this Master. + * + * @return true if pet is leashed to this master + */ + public boolean isPetLeashed() { + ServerPlayer pet = getPetPlayer(); + if (pet == null) return false; + + if (pet instanceof com.tiedup.remake.state.IPlayerLeashAccess access) { + return ( + access.tiedup$isLeashed() && + access.tiedup$getLeashHolder() == master + ); + } + return false; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/master/components/MasterStateManager.java b/src/main/java/com/tiedup/remake/entities/master/components/MasterStateManager.java new file mode 100644 index 0000000..78a1be2 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/master/components/MasterStateManager.java @@ -0,0 +1,393 @@ +package com.tiedup.remake.entities.master.components; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.ai.master.MasterState; +import java.util.Random; +import java.util.UUID; +import net.minecraft.server.level.ServerPlayer; + +/** + * MasterStateManager - Manages the behavioral state of a Master entity. + * + * Features: + * - State transitions with debug logging + * - Distraction timer system (30-60 sec every 2-5 min) + * - Struggle detection during watching states + * - Pet player tracking + */ +public class MasterStateManager { + + // ======================================== + // CONSTANTS + // ======================================== + + /** Minimum time between distractions (ticks) - 2 minutes */ + private static final int MIN_DISTRACTION_INTERVAL = 2400; + + /** Maximum time between distractions (ticks) - 5 minutes */ + private static final int MAX_DISTRACTION_INTERVAL = 6000; + + /** Minimum distraction duration (ticks) - 30 seconds */ + private static final int MIN_DISTRACTION_DURATION = 600; + + /** Maximum distraction duration (ticks) - 60 seconds */ + private static final int MAX_DISTRACTION_DURATION = 1200; + + // ======================================== + // FIELDS + // ======================================== + + /** Host callbacks for logging and events */ + private final IMasterStateHost host; + + /** Current behavioral state */ + private MasterState currentState = MasterState.IDLE; + + /** Previous state (for returning after distraction) */ + private MasterState previousState = MasterState.IDLE; + + /** UUID of the pet player owned by this master */ + private UUID petPlayerUUID = null; + + /** Tick when next distraction will occur */ + private long nextDistractionTick = 0; + + /** Tick when current distraction will end */ + private long distractionEndTick = 0; + + /** Whether distraction timer has been initialized */ + private boolean distractionInitialized = false; + + /** Random for distraction timing */ + private final Random random = new Random(); + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public MasterStateManager(IMasterStateHost host) { + this.host = host; + } + + // ======================================== + // STATE MANAGEMENT + // ======================================== + + /** + * Get the current behavioral state. + * + * @return The current state + */ + public MasterState getCurrentState() { + return this.currentState; + } + + /** + * Set the current behavioral state with debug logging. + * + * @param state The new state + */ + public void setCurrentState(MasterState state) { + if (this.currentState != state) { + TiedUpMod.LOGGER.debug( + "[MasterStateManager] {} state change: {} -> {}", + host.getNpcName(), + this.currentState, + state + ); + this.previousState = this.currentState; + this.currentState = state; + } + } + + /** + * Check if master is currently watching the pet. + * In watching states, struggle attempts will be detected. + * + * @return true if master can see struggle attempts + */ + public boolean isWatching() { + return currentState.isWatching() || currentState.canDetectStruggle(); + } + + /** + * Check if pet can safely struggle without detection. + * + * @return true if master is distracted or idle + */ + public boolean canPetStruggleSafely() { + return currentState.allowsSafeStruggle(); + } + + // ======================================== + // PET PLAYER MANAGEMENT + // ======================================== + + /** + * Set the UUID of the pet player owned by this master. + * + * @param playerUUID The pet player's UUID + */ + public void setPetPlayerUUID(UUID playerUUID) { + this.petPlayerUUID = playerUUID; + TiedUpMod.LOGGER.info( + "[MasterStateManager] {} now owns pet {}", + host.getNpcName(), + playerUUID + ); + } + + /** + * Get the UUID of the pet player. + * + * @return Pet player UUID or null if none + */ + public UUID getPetPlayerUUID() { + return this.petPlayerUUID; + } + + /** + * Check if this master has a pet. + * + * @return true if master has a pet player + */ + public boolean hasPet() { + return this.petPlayerUUID != null; + } + + /** + * Clear the pet player (on death, escape, etc.) + */ + public void clearPet() { + TiedUpMod.LOGGER.info( + "[MasterStateManager] {} lost pet {}", + host.getNpcName(), + this.petPlayerUUID + ); + this.petPlayerUUID = null; + } + + // ======================================== + // DISTRACTION SYSTEM + // ======================================== + + /** + * Initialize or reset the distraction timer. + * Should be called when master starts following pet. + */ + public void initializeDistractionTimer() { + long currentTick = host.getCurrentTick(); + int interval = + MIN_DISTRACTION_INTERVAL + + random.nextInt(MAX_DISTRACTION_INTERVAL - MIN_DISTRACTION_INTERVAL); + this.nextDistractionTick = currentTick + interval; + this.distractionInitialized = true; + + TiedUpMod.LOGGER.debug( + "[MasterStateManager] {} distraction timer set for {} ticks ({}s)", + host.getNpcName(), + interval, + interval / 20 + ); + } + + /** + * Tick the distraction system. + * Should be called every server tick. + * + * @return true if a state change occurred + */ + public boolean tickDistraction() { + if (!distractionInitialized || petPlayerUUID == null) { + return false; + } + + long currentTick = host.getCurrentTick(); + + // Check if currently distracted + if (currentState == MasterState.DISTRACTED) { + // Check if distraction should end + if (currentTick >= distractionEndTick) { + endDistraction(); + return true; + } + return false; + } + + // Check if should become distracted + if ( + currentTick >= nextDistractionTick && + currentState.canTransitionToPunish() + ) { + startDistraction(); + return true; + } + + return false; + } + + /** + * Start a distraction period. + */ + private void startDistraction() { + long currentTick = host.getCurrentTick(); + int duration = + MIN_DISTRACTION_DURATION + + random.nextInt(MAX_DISTRACTION_DURATION - MIN_DISTRACTION_DURATION); + + this.previousState = this.currentState; + this.distractionEndTick = currentTick + duration; + setCurrentState(MasterState.DISTRACTED); + + TiedUpMod.LOGGER.info( + "[MasterStateManager] {} became distracted for {}s", + host.getNpcName(), + duration / 20 + ); + + host.onDistracted(); + } + + /** + * End the current distraction and return to previous state. + */ + private void endDistraction() { + TiedUpMod.LOGGER.info( + "[MasterStateManager] {} distraction ended, returning to {}", + host.getNpcName(), + previousState + ); + + // Schedule next distraction + long currentTick = host.getCurrentTick(); + int interval = + MIN_DISTRACTION_INTERVAL + + random.nextInt(MAX_DISTRACTION_INTERVAL - MIN_DISTRACTION_INTERVAL); + this.nextDistractionTick = currentTick + interval; + + // Return to previous state (or FOLLOWING if previous was IDLE) + MasterState returnState = + previousState == MasterState.IDLE + ? MasterState.FOLLOWING + : previousState; + setCurrentState(returnState); + + host.onDistractionEnd(); + } + + /** + * Get remaining ticks until distraction ends. + * Returns 0 if not currently distracted. + * + * @return Remaining ticks or 0 + */ + public long getRemainingDistractionTicks() { + if (currentState != MasterState.DISTRACTED) { + return 0; + } + long remaining = distractionEndTick - host.getCurrentTick(); + return Math.max(0, remaining); + } + + /** + * Force distraction to end early (e.g., if attacked). + */ + public void interruptDistraction() { + if (currentState == MasterState.DISTRACTED) { + TiedUpMod.LOGGER.info( + "[MasterStateManager] {} distraction interrupted!", + host.getNpcName() + ); + endDistraction(); + } + } + + // ======================================== + // STRUGGLE DETECTION + // ======================================== + + /** + * Called when a struggle attempt is detected. + * Will transition to PUNISH state if master was watching. + * + * @return true if struggle was detected (master was watching) + */ + public boolean onStruggleAttempt() { + if (!isWatching()) { + return false; // Struggle not detected + } + + TiedUpMod.LOGGER.info( + "[MasterStateManager] {} detected struggle attempt!", + host.getNpcName() + ); + + // Transition to punish state + if (currentState.canTransitionToPunish()) { + setCurrentState(MasterState.PUNISH); + } + + host.onStruggleDetected(); + return true; + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + /** + * Get the state name for NBT serialization. + * + * @return The state name as a string + */ + public String serializeState() { + return currentState.name(); + } + + /** + * Deserialize state from NBT. + * + * @param stateName The state name string + */ + public void deserializeState(String stateName) { + try { + this.currentState = MasterState.valueOf(stateName); + } catch (IllegalArgumentException e) { + TiedUpMod.LOGGER.warn( + "[MasterStateManager] Invalid state name: {}, defaulting to IDLE", + stateName + ); + this.currentState = MasterState.IDLE; + } + } + + /** + * Serialize pet UUID for NBT. + * + * @return UUID string or null + */ + public String serializePetUUID() { + return petPlayerUUID != null ? petPlayerUUID.toString() : null; + } + + /** + * Deserialize pet UUID from NBT. + * + * @param uuidString UUID string or null + */ + public void deserializePetUUID(String uuidString) { + if (uuidString != null && !uuidString.isEmpty()) { + try { + this.petPlayerUUID = UUID.fromString(uuidString); + } catch (IllegalArgumentException e) { + TiedUpMod.LOGGER.warn( + "[MasterStateManager] Invalid pet UUID: {}", + uuidString + ); + this.petPlayerUUID = null; + } + } else { + this.petPlayerUUID = null; + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/master/components/MasterTaskManager.java b/src/main/java/com/tiedup/remake/entities/master/components/MasterTaskManager.java new file mode 100644 index 0000000..02e632f --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/master/components/MasterTaskManager.java @@ -0,0 +1,318 @@ +package com.tiedup.remake.entities.master.components; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.entities.ai.master.MasterState; +import com.tiedup.remake.entities.ai.master.PetTask; +import javax.annotation.Nullable; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.Item; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.registries.ForgeRegistries; + +/** + * MasterTaskManager - Manages pet tasks, engagement cadence, and cold shoulder. + * + * Extracted from EntityMaster to reduce class size and isolate task-tracking + * concerns from the entity lifecycle. + * + * Features: + * - Task lifecycle: assign, watch, complete, fail, clear + * - Fetch item give handling (item validation + dialogue) + * - Engagement cadence: anti-spam gap + anti-drought boost + * - Cold shoulder: timed ignore of pet interactions + * - Full NBT persistence + */ +public class MasterTaskManager { + + // ======================================== + // CONSTANTS + // ======================================== + + /** Engagement cadence: minimum gap between actions (30s) */ + private static final int MIN_ENGAGEMENT_GAP = 600; + + /** Engagement cadence: force action if idle too long (90s) */ + private static final int MAX_IDLE_BEFORE_BOOST = 1800; + + /** NBT keys */ + private static final String NBT_CURRENT_TASK = "CurrentTask"; + private static final String NBT_TASK_START_POS = "TaskStartPos"; + private static final String NBT_REQUESTED_ITEM = "RequestedItem"; + private static final String NBT_LAST_ENGAGEMENT = "LastEngagementTime"; + private static final String NBT_COLD_SHOULDER_UNTIL = "ColdShoulderUntil"; + + // ======================================== + // FIELDS + // ======================================== + + /** Back-reference to the owning entity */ + private final EntityMaster master; + + /** Currently active task (null if no task) */ + @Nullable + private PetTask currentTask = null; + + /** Start position for WAIT_HERE task */ + @Nullable + private Vec3 taskStartPosition = null; + + /** Requested item for FETCH_ITEM task */ + @Nullable + private Item requestedItem = null; + + /** Last time the master performed an engagement action */ + private long lastEngagementTime = 0; + + /** Game time until cold shoulder ends (0 = not active) */ + private long coldShoulderUntil = 0; + + // ======================================== + // CONSTRUCTOR + // ======================================== + + public MasterTaskManager(EntityMaster master) { + this.master = master; + } + + // ======================================== + // TASK MANAGEMENT + // ======================================== + + /** + * Check if there is an active task. + */ + public boolean hasActiveTask() { + return currentTask != null; + } + + /** + * Get the current active task. + */ + @Nullable + public PetTask getCurrentTask() { + return currentTask; + } + + /** + * Set the active task. + */ + public void setActiveTask(PetTask task) { + this.currentTask = task; + TiedUpMod.LOGGER.debug( + "[MasterTaskManager] {} set active task: {}", + master.getDamselName(), + task + ); + } + + /** + * Clear the active task and associated data. + */ + public void clearActiveTask() { + TiedUpMod.LOGGER.debug( + "[MasterTaskManager] {} cleared task: {}", + master.getDamselName(), + currentTask + ); + this.currentTask = null; + this.taskStartPosition = null; + this.requestedItem = null; + } + + /** + * Get the task start position (for WAIT_HERE task). + */ + @Nullable + public Vec3 getTaskStartPosition() { + return taskStartPosition; + } + + /** + * Set the task start position (for WAIT_HERE task). + */ + public void setTaskStartPosition(Vec3 position) { + this.taskStartPosition = position; + } + + /** + * Get the requested item (for FETCH_ITEM task). + */ + @Nullable + public Item getRequestedItem() { + return requestedItem; + } + + /** + * Set the requested item (for FETCH_ITEM task). + */ + public void setRequestedItem(Item item) { + this.requestedItem = item; + } + + /** + * Handle item being given by pet for FETCH_ITEM task. + * Called from interaction handling. + * + * @param item The item being given + * @return true if the item was accepted and task completed + */ + public boolean handleFetchItemGive(Item item) { + if ( + currentTask != PetTask.FETCH_ITEM && currentTask != PetTask.DEMAND + ) { + return false; + } + + if (requestedItem == null) { + return false; + } + + if (item == requestedItem) { + ServerPlayer pet = master.getPetPlayer(); + if (pet != null) { + // Task completed successfully - use appropriate dialogue + String successDialogue = + currentTask == PetTask.DEMAND + ? "petplay.demand_success" + : "petplay.fetch_success"; + com.tiedup.remake.dialogue.DialogueBridge.talkTo( + master, + pet, + successDialogue + ); + TiedUpMod.LOGGER.info( + "[MasterTaskManager] {} received requested item from {}", + master.getDamselName(), + pet.getName().getString() + ); + } + + // Clear task and return to following + clearActiveTask(); + master.setMasterState(MasterState.FOLLOWING); + return true; + } + + // Wrong item + ServerPlayer pet = master.getPetPlayer(); + if (pet != null) { + com.tiedup.remake.dialogue.DialogueBridge.talkTo( + master, + pet, + "petplay.fetch_wrong" + ); + } + return false; + } + + // ======================================== + // ENGAGEMENT CADENCE SYSTEM + // ======================================== + + /** + * Mark that an engagement action just occurred. + * Called by goals when they start an action (task, event, idle behavior, etc.) + */ + public void markEngagement() { + lastEngagementTime = master.level().getGameTime(); + } + + /** + * Get a multiplier for engagement chance based on time since last action. + * Returns 0.0 if too soon (anti-spam), 5.0 if idle too long (anti-drought), 1.0 normally. + */ + public float getEngagementMultiplier() { + long idle = master.level().getGameTime() - lastEngagementTime; + if (idle < MIN_ENGAGEMENT_GAP) return 0.0f; // Suppress if recent + if (idle > MAX_IDLE_BEFORE_BOOST) return 5.0f; // Boost if idle too long + return 1.0f; + } + + // ======================================== + // COLD SHOULDER + // ======================================== + + /** + * Check if the master is giving the cold shoulder (ignoring pet). + */ + public boolean isGivingColdShoulder() { + return ( + coldShoulderUntil > 0 && + master.level().getGameTime() < coldShoulderUntil + ); + } + + /** + * Start cold shoulder punishment - ignore pet interactions for duration. + * @param durationTicks How long to ignore the pet + */ + public void startColdShoulder(int durationTicks) { + coldShoulderUntil = master.level().getGameTime() + durationTicks; + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + /** + * Save task manager state to NBT. + */ + public void save(CompoundTag tag) { + if (currentTask != null) { + tag.putString(NBT_CURRENT_TASK, currentTask.name()); + } + if (taskStartPosition != null) { + tag.putDouble(NBT_TASK_START_POS + "X", taskStartPosition.x); + tag.putDouble(NBT_TASK_START_POS + "Y", taskStartPosition.y); + tag.putDouble(NBT_TASK_START_POS + "Z", taskStartPosition.z); + } + if (requestedItem != null) { + ResourceLocation requestedItemKey = ForgeRegistries.ITEMS.getKey(requestedItem); + if (requestedItemKey != null) { + tag.putString(NBT_REQUESTED_ITEM, requestedItemKey.toString()); + } else { + TiedUpMod.LOGGER.warn( + "[MasterTaskManager] Unregistered requestedItem {}, falling back to iron_ingot", + requestedItem + ); + tag.putString(NBT_REQUESTED_ITEM, "minecraft:iron_ingot"); + } + } + tag.putLong(NBT_LAST_ENGAGEMENT, lastEngagementTime); + tag.putLong(NBT_COLD_SHOULDER_UNTIL, coldShoulderUntil); + } + + /** + * Load task manager state from NBT. + */ + public void load(CompoundTag tag) { + if (tag.contains(NBT_CURRENT_TASK)) { + try { + currentTask = PetTask.valueOf(tag.getString(NBT_CURRENT_TASK)); + } catch (IllegalArgumentException e) { + currentTask = null; + } + } + if (tag.contains(NBT_TASK_START_POS + "X")) { + taskStartPosition = new Vec3( + tag.getDouble(NBT_TASK_START_POS + "X"), + tag.getDouble(NBT_TASK_START_POS + "Y"), + tag.getDouble(NBT_TASK_START_POS + "Z") + ); + } + if (tag.contains(NBT_REQUESTED_ITEM)) { + requestedItem = ForgeRegistries.ITEMS.getValue( + ResourceLocation.tryParse(tag.getString(NBT_REQUESTED_ITEM)) + ); + } + if (tag.contains(NBT_LAST_ENGAGEMENT)) { + lastEngagementTime = tag.getLong(NBT_LAST_ENGAGEMENT); + } + if (tag.contains(NBT_COLD_SHOULDER_UNTIL)) { + coldShoulderUntil = tag.getLong(NBT_COLD_SHOULDER_UNTIL); + } + } +} diff --git a/src/main/java/com/tiedup/remake/entities/skins/ArcherKidnapperSkinManager.java b/src/main/java/com/tiedup/remake/entities/skins/ArcherKidnapperSkinManager.java new file mode 100644 index 0000000..45afb8b --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/skins/ArcherKidnapperSkinManager.java @@ -0,0 +1,46 @@ +package com.tiedup.remake.entities.skins; + +import com.tiedup.remake.entities.KidnapperVariant; +import net.minecraft.resources.ResourceLocation; + +/** + * Registry and manager for archer kidnapper skin variants. + * + * Archer kidnappers attack from range with rope arrows. + * They prefer to tie up targets from a distance before approaching. + */ +public class ArcherKidnapperSkinManager { + + public static final SkinManagerCore CORE = + new SkinManagerCore<>( + "ArcherKidnapperSkinManager", + "kidnapper_archer", + SkinDefinition::toKidnapperVariant, + ArcherKidnapperSkinManager::loadHardcodedDefaults + ); + + /** + * Initialize and register all archer kidnapper variants. + */ + public static void registerDefaults() { + CORE.registerDefaults(); + } + + /** + * Load hardcoded default archer skin variants. + */ + private static void loadHardcodedDefaults() { + CORE.registerVariant( + new KidnapperVariant( + "bowy", + ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/kidnapper/archer/bowy.png" + ), + true, + "Bowy", + Gender.FEMALE + ) + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/skins/DamselSkinManager.java b/src/main/java/com/tiedup/remake/entities/skins/DamselSkinManager.java new file mode 100644 index 0000000..5c50bef --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/skins/DamselSkinManager.java @@ -0,0 +1,80 @@ +package com.tiedup.remake.entities.skins; + +import com.tiedup.remake.entities.DamselVariant; + +/** + * Registry and manager for damsel skin variants. + * + * Simplified: All skins from damsel folder, random selection. + * 39 total skins (19 numbered + 20 named). + */ +public class DamselSkinManager { + + public static final SkinManagerCore CORE = + new SkinManagerCore<>( + "DamselSkinManager", + "damsel", + SkinDefinition::toDamselVariant, + DamselSkinManager::loadHardcodedDefaults + ); + + /** + * Initialize and register all default damsel variants. + */ + public static void registerDefaults() { + CORE.registerDefaults(); + } + + /** + * Load hardcoded default skin variants. + */ + private static void loadHardcodedDefaults() { + // Numbered skins (dam_mob_1 to dam_mob_19) + // Slim arms: variants 10, 11, 15 + CORE.registerVariant(DamselVariant.create("dam_mob_1", false)); + CORE.registerVariant(DamselVariant.create("dam_mob_2", false)); + CORE.registerVariant(DamselVariant.create("dam_mob_3", false)); + CORE.registerVariant(DamselVariant.create("dam_mob_4", false)); + CORE.registerVariant(DamselVariant.create("dam_mob_5", false)); + CORE.registerVariant(DamselVariant.create("dam_mob_6", false)); + CORE.registerVariant(DamselVariant.create("dam_mob_7", false)); + CORE.registerVariant(DamselVariant.create("dam_mob_8", false)); + CORE.registerVariant(DamselVariant.create("dam_mob_9", false)); + CORE.registerVariant(DamselVariant.create("dam_mob_10", true)); // SLIM + CORE.registerVariant(DamselVariant.create("dam_mob_11", true)); // SLIM + CORE.registerVariant(DamselVariant.create("dam_mob_12", false)); + CORE.registerVariant(DamselVariant.create("dam_mob_13", false)); + CORE.registerVariant(DamselVariant.create("dam_mob_14", false)); + CORE.registerVariant(DamselVariant.create("dam_mob_15", true)); // SLIM + CORE.registerVariant(DamselVariant.create("dam_mob_16", false)); + CORE.registerVariant(DamselVariant.create("dam_mob_17", false)); + CORE.registerVariant(DamselVariant.create("dam_mob_18", false)); + CORE.registerVariant(DamselVariant.create("dam_mob_19", false)); + + // Named skins (community/special) + CORE.registerVariant( + DamselVariant.create("anastasia", true, "Anastasia") + ); + CORE.registerVariant(DamselVariant.create("blizz", true, "Blizz")); + CORE.registerVariant(DamselVariant.create("ceras", true, "Ceras")); + CORE.registerVariant(DamselVariant.create("creamy", true, "Creamy")); + CORE.registerVariant(DamselVariant.create("el", true, "El")); + CORE.registerVariant( + DamselVariant.create("fuya_kitty", true, "Fuya Kitty") + ); + CORE.registerVariant(DamselVariant.create("glacie", true, "Glacie")); + CORE.registerVariant(DamselVariant.create("hazey", true, "Hazey")); + CORE.registerVariant(DamselVariant.create("junichi", true, "Junichi")); + CORE.registerVariant(DamselVariant.create("kitty", true, "Kitty")); + CORE.registerVariant(DamselVariant.create("kyky", true, "Kyky")); + CORE.registerVariant(DamselVariant.create("laureen", true, "Laureen")); + CORE.registerVariant(DamselVariant.create("mani", true, "Mani")); + CORE.registerVariant(DamselVariant.create("nobu", true, "Nobu")); + CORE.registerVariant(DamselVariant.create("pika", true, "Pika")); + CORE.registerVariant(DamselVariant.create("raph", true, "Raph")); + CORE.registerVariant(DamselVariant.create("risette", true, "Risette")); + CORE.registerVariant(DamselVariant.create("roxy", true, "Roxy")); + CORE.registerVariant(DamselVariant.create("sayari", true, "Sayari")); + CORE.registerVariant(DamselVariant.create("sui", true, "Sui")); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/skins/EliteKidnapperSkinManager.java b/src/main/java/com/tiedup/remake/entities/skins/EliteKidnapperSkinManager.java new file mode 100644 index 0000000..ae1df58 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/skins/EliteKidnapperSkinManager.java @@ -0,0 +1,83 @@ +package com.tiedup.remake.entities.skins; + +import com.tiedup.remake.entities.KidnapperVariant; +import net.minecraft.resources.ResourceLocation; + +/** + * Registry and manager for elite kidnapper skin variants. + * + * Elite kidnappers are rarer, faster, and tie up targets faster. + * Each elite has a unique name and dedicated skin texture. + */ +public class EliteKidnapperSkinManager { + + public static final SkinManagerCore CORE = + new SkinManagerCore<>( + "EliteKidnapperSkinManager", + "kidnapper_elite", + SkinDefinition::toKidnapperVariant, + EliteKidnapperSkinManager::loadHardcodedDefaults + ); + + /** + * Initialize and register all elite kidnapper variants. + */ + public static void registerDefaults() { + CORE.registerDefaults(); + } + + /** + * Load hardcoded default elite skin variants. + */ + private static void loadHardcodedDefaults() { + // Elite skins with custom texture path (elite subfolder) + CORE.registerVariant( + new KidnapperVariant( + "suki", + ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/kidnapper/elite/suki.png" + ), + false, + "Suki", + Gender.FEMALE + ) + ); + CORE.registerVariant( + new KidnapperVariant( + "carol", + ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/kidnapper/elite/carol.png" + ), + false, + "Carol", + Gender.FEMALE + ) + ); + CORE.registerVariant( + new KidnapperVariant( + "athena", + ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/kidnapper/elite/athena.png" + ), + false, + "Athena", + Gender.FEMALE + ) + ); + CORE.registerVariant( + new KidnapperVariant( + "evelyn", + ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/kidnapper/elite/evelyn.png" + ), + false, + "Evelyn", + Gender.FEMALE + ) + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/skins/EntitySkinRegistry.java b/src/main/java/com/tiedup/remake/entities/skins/EntitySkinRegistry.java new file mode 100644 index 0000000..9886053 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/skins/EntitySkinRegistry.java @@ -0,0 +1,131 @@ +package com.tiedup.remake.entities.skins; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.*; +import net.minecraft.util.RandomSource; + +/** + * Registry for entity skins of a specific entity type. + * + * Each entity type (kidnapper, damsel, etc.) has its own registry instance. + * Skins are loaded from JSON files and stored here for quick access. + * + * Phase 1: Data-Driven Skin System + */ +public class EntitySkinRegistry { + + private final String entityType; + private final Map skins = new LinkedHashMap<>(); + private final List skinsList = new ArrayList<>(); + private final RandomSource random = RandomSource.create(); + + /** + * Create a new skin registry for an entity type. + * + * @param entityType Entity type identifier (e.g., "kidnapper", "damsel") + */ + public EntitySkinRegistry(String entityType) { + this.entityType = entityType; + } + + /** + * Register a skin definition. + * + * If a skin with the same ID already exists, it will be skipped + * (datapacks can override by having higher priority). + * + * @param skin Skin definition to register + */ + public void register(SkinDefinition skin) { + if (skin == null) { + TiedUpMod.LOGGER.warn( + "[EntitySkinRegistry] Attempted to register null skin for {}", + entityType + ); + return; + } + + if (skins.containsKey(skin.id())) { + TiedUpMod.LOGGER.debug( + "[EntitySkinRegistry] Skin {} already registered for {}, skipping", + skin.id(), + entityType + ); + return; + } + + skins.put(skin.id(), skin); + skinsList.add(skin); + + TiedUpMod.LOGGER.debug( + "[EntitySkinRegistry] Registered skin {} for {}", + skin.id(), + entityType + ); + } + + /** + * Get a skin by ID. + * + * @param id Skin ID + * @return SkinDefinition or null if not found + */ + public SkinDefinition get(String id) { + return skins.get(id); + } + + /** + * Get a random skin from this registry. + * + * @return Random SkinDefinition + * @throws IllegalStateException if no skins are registered + */ + public SkinDefinition getRandom() { + if (skinsList.isEmpty()) { + throw new IllegalStateException( + "No skins registered for entity type: " + entityType + ); + } + return skinsList.get(random.nextInt(skinsList.size())); + } + + /** + * Get all registered skins. + * + * @return Unmodifiable collection of all skins + */ + public Collection getAllSkins() { + return Collections.unmodifiableCollection(skins.values()); + } + + /** + * Get the number of registered skins. + * + * @return Number of skins + */ + public int count() { + return skins.size(); + } + + /** + * Clear all registered skins. + * Used before reloading from datapacks. + */ + public void clear() { + skins.clear(); + skinsList.clear(); + TiedUpMod.LOGGER.debug( + "[EntitySkinRegistry] Cleared all skins for {}", + entityType + ); + } + + /** + * Get the entity type identifier. + * + * @return Entity type string + */ + public String getEntityType() { + return entityType; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/skins/Gender.java b/src/main/java/com/tiedup/remake/entities/skins/Gender.java new file mode 100644 index 0000000..235e16c --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/skins/Gender.java @@ -0,0 +1,28 @@ +package com.tiedup.remake.entities.skins; + +import net.minecraft.util.StringRepresentable; + +/** + * Gender of the entity skin. + * Used for model selection, voice lines, and text formatting. + */ +public enum Gender implements StringRepresentable { + FEMALE("female"), + MALE("male"); + + private final String name; + + Gender(String name) { + this.name = name; + } + + @Override + public String getSerializedName() { + return this.name; + } + + public static Gender fromName(String name) { + if (name == null) return FEMALE; + return name.equalsIgnoreCase("male") ? MALE : FEMALE; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/skins/KidnapperSkinManager.java b/src/main/java/com/tiedup/remake/entities/skins/KidnapperSkinManager.java new file mode 100644 index 0000000..1b7261e --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/skins/KidnapperSkinManager.java @@ -0,0 +1,85 @@ +package com.tiedup.remake.entities.skins; + +import com.tiedup.remake.entities.KidnapperVariant; + +/** + * Registry and manager for kidnapper skin variants. + * + * All skins from kidnapper folder, random selection. + * 41 total skins (18 numbered + 23 named). + */ +public class KidnapperSkinManager { + + public static final SkinManagerCore CORE = + new SkinManagerCore<>( + "KidnapperSkinManager", + "kidnapper", + SkinDefinition::toKidnapperVariant, + KidnapperSkinManager::loadHardcodedDefaults + ); + + /** + * Initialize and register all default kidnapper variants. + */ + public static void registerDefaults() { + CORE.registerDefaults(); + } + + /** + * Load hardcoded default skin variants. + */ + private static void loadHardcodedDefaults() { + // Numbered skins (knp_mob_1 to knp_mob_18) + CORE.registerVariant(KidnapperVariant.create("knp_mob_1", false)); + CORE.registerVariant(KidnapperVariant.create("knp_mob_2", false)); + CORE.registerVariant(KidnapperVariant.create("knp_mob_3", false)); + CORE.registerVariant(KidnapperVariant.create("knp_mob_4", false)); + CORE.registerVariant(KidnapperVariant.create("knp_mob_5", false)); + CORE.registerVariant(KidnapperVariant.create("knp_mob_6", false)); + CORE.registerVariant(KidnapperVariant.create("knp_mob_7", false)); + CORE.registerVariant(KidnapperVariant.create("knp_mob_8", false)); + CORE.registerVariant(KidnapperVariant.create("knp_mob_9", false)); + CORE.registerVariant(KidnapperVariant.create("knp_mob_10", false)); + CORE.registerVariant(KidnapperVariant.create("knp_mob_11", false)); + CORE.registerVariant(KidnapperVariant.create("knp_mob_12", false)); + CORE.registerVariant(KidnapperVariant.create("knp_mob_13", false)); + CORE.registerVariant(KidnapperVariant.create("knp_mob_14", false)); + CORE.registerVariant(KidnapperVariant.create("knp_mob_15", false)); + CORE.registerVariant(KidnapperVariant.create("knp_mob_16", false)); + CORE.registerVariant(KidnapperVariant.create("knp_mob_17", false)); + CORE.registerVariant(KidnapperVariant.create("knp_mob_18", false)); + + // Named skins (community/special) + CORE.registerVariant(KidnapperVariant.create("blake", true, "Blake")); + CORE.registerVariant(KidnapperVariant.create("darkie", true, "Darkie")); + CORE.registerVariant(KidnapperVariant.create("dean", false, "Dean")); + CORE.registerVariant( + KidnapperVariant.create("eleanor", true, "Eleanor") + ); + CORE.registerVariant(KidnapperVariant.create("esther", true, "Esther")); + CORE.registerVariant( + KidnapperVariant.create("fleur_dianthus", true, "Fleur Dianthus") + ); + CORE.registerVariant(KidnapperVariant.create("fuya", true, "Fuya")); + CORE.registerVariant(KidnapperVariant.create("jack", false, "Jack")); + CORE.registerVariant(KidnapperVariant.create("jass", true, "Jass")); + CORE.registerVariant(KidnapperVariant.create("ketulu", true, "Ketulu")); + CORE.registerVariant(KidnapperVariant.create("kitty", true, "Kitty")); + CORE.registerVariant(KidnapperVariant.create("kyra", true, "Kyra")); + CORE.registerVariant(KidnapperVariant.create("lucina", true, "Lucina")); + CORE.registerVariant(KidnapperVariant.create("misty", true, "Misty")); + CORE.registerVariant( + KidnapperVariant.create("nataleigh", true, "Nataleigh") + ); + CORE.registerVariant(KidnapperVariant.create("nico", true, "Nico")); + CORE.registerVariant(KidnapperVariant.create("ruby", true, "Ruby")); + CORE.registerVariant(KidnapperVariant.create("ryuko", true, "Ryuko")); + CORE.registerVariant(KidnapperVariant.create("teemo", false, "Teemo")); + CORE.registerVariant( + KidnapperVariant.create("welphia", true, "Welphia") + ); + CORE.registerVariant(KidnapperVariant.create("wynter", true, "Wynter")); + CORE.registerVariant(KidnapperVariant.create("yuti", true, "Yuti")); + CORE.registerVariant(KidnapperVariant.create("zephyr", true, "Zephyr")); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/skins/LaborGuardSkinManager.java b/src/main/java/com/tiedup/remake/entities/skins/LaborGuardSkinManager.java new file mode 100644 index 0000000..d44f7ba --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/skins/LaborGuardSkinManager.java @@ -0,0 +1,48 @@ +package com.tiedup.remake.entities.skins; + +import com.tiedup.remake.entities.DamselVariant; + +/** + * Registry and manager for labor guard skin variants. + * + * Uses DamselVariant since EntityLaborGuard extends EntityDamsel. + * Guard skins are stored in textures/entity/guard/ folder. + */ +public class LaborGuardSkinManager { + + public static final SkinManagerCore CORE = + new SkinManagerCore<>( + "LaborGuardSkinManager", + "labor_guard", + SkinDefinition::toDamselVariant, + LaborGuardSkinManager::loadHardcodedDefaults + ); + + public static void registerDefaults() { + CORE.registerDefaults(); + } + + private static void loadHardcodedDefaults() { + CORE.registerVariant(createGuardVariant("feifei", true, "Feifei")); + } + + /** + * Create a guard variant with textures in the guard/ folder. + */ + private static DamselVariant createGuardVariant( + String textureName, + boolean hasSlimArms, + String displayName + ) { + return new DamselVariant( + textureName, + net.minecraft.resources.ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/guard/" + textureName + ".png" + ), + hasSlimArms, + displayName, + Gender.FEMALE + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/skins/MaidSkinManager.java b/src/main/java/com/tiedup/remake/entities/skins/MaidSkinManager.java new file mode 100644 index 0000000..4e0fd12 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/skins/MaidSkinManager.java @@ -0,0 +1,70 @@ +package com.tiedup.remake.entities.skins; + +import com.tiedup.remake.entities.KidnapperVariant; + +/** + * Registry and manager for Maid skin variants. + * + * Maids are servants of Slave Traders who execute their orders. + * They wear classic maid outfits. + */ +public class MaidSkinManager { + + public static final SkinManagerCore CORE = + new SkinManagerCore<>( + "MaidSkinManager", + "maid", + SkinDefinition::toKidnapperVariant, + MaidSkinManager::loadHardcodedDefaults + ); + + /** + * Initialize and register all maid variants. + */ + public static void registerDefaults() { + CORE.registerDefaults(); + } + + /** + * Load hardcoded default maid skin variants. + */ + private static void loadHardcodedDefaults() { + // Generic numbered skins (maid_mob_1 to maid_mob_10) - use random names + for (int i = 1; i <= 10; i++) { + CORE.registerVariant( + createMaidVariant("maid_mob_" + i, false, null) + ); + } + + // Named skins (specific characters) + CORE.registerVariant(createMaidVariant("maid_default", true, "Maid")); + CORE.registerVariant(createMaidVariant("maid_classic", true, "Sakura")); + + // FUTURE: Add more named maid variants here (requires artist skin assets) + // CORE.registerVariant(createMaidVariant("maid_gothic", true, "Gothic Maid")); + // CORE.registerVariant(createMaidVariant("maid_french", true, "Marie")); + } + + /** + * Create a maid variant with proper texture path. + * Uses KidnapperVariant.createWithSubfolder() helper. + * + * @param textureName Texture file name without extension + * @param hasSlimArms True if this variant uses slim arms + * @param displayName Display name, or null for generic "Maid" + * @return Maid variant + */ + private static KidnapperVariant createMaidVariant( + String textureName, + boolean hasSlimArms, + String displayName + ) { + return KidnapperVariant.createWithSubfolder( + textureName, + "maid", + hasSlimArms, + displayName != null ? displayName : "Maid", + Gender.FEMALE + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/skins/MasterSkinManager.java b/src/main/java/com/tiedup/remake/entities/skins/MasterSkinManager.java new file mode 100644 index 0000000..e64bc4a --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/skins/MasterSkinManager.java @@ -0,0 +1,47 @@ +package com.tiedup.remake.entities.skins; + +import com.tiedup.remake.entities.KidnapperVariant; +import net.minecraft.resources.ResourceLocation; + +/** + * Registry and manager for Master NPC skin variants. + * + * Masters are rare NPCs that buy solo players from Kidnappers + * and implement pet play mechanics. + */ +public class MasterSkinManager { + + public static final SkinManagerCore CORE = + new SkinManagerCore<>( + "MasterSkinManager", + "master", + SkinDefinition::toKidnapperVariant, + MasterSkinManager::loadHardcodedDefaults + ); + + /** + * Initialize and register all master variants. + */ + public static void registerDefaults() { + CORE.registerDefaults(); + } + + /** + * Load hardcoded default master skin variants. + */ + private static void loadHardcodedDefaults() { + // Amy - default Master skin + CORE.registerVariant( + new KidnapperVariant( + "amy", + ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/master/amy.png" + ), + false, // Slim arms + "Amy", + Gender.FEMALE + ) + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/skins/MerchantKidnapperSkinManager.java b/src/main/java/com/tiedup/remake/entities/skins/MerchantKidnapperSkinManager.java new file mode 100644 index 0000000..60552e4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/skins/MerchantKidnapperSkinManager.java @@ -0,0 +1,46 @@ +package com.tiedup.remake.entities.skins; + +import com.tiedup.remake.entities.KidnapperVariant; +import net.minecraft.resources.ResourceLocation; + +/** + * Registry and manager for merchant kidnapper skin variants. + * + * Merchant kidnappers are elite kidnappers who trade mod items for gold. + * They become hostile when attacked but revert to merchant mode after conditions are met. + */ +public class MerchantKidnapperSkinManager { + + public static final SkinManagerCore CORE = + new SkinManagerCore<>( + "MerchantKidnapperSkinManager", + "kidnapper_merchant", + SkinDefinition::toKidnapperVariant, + MerchantKidnapperSkinManager::loadHardcodedDefaults + ); + + /** + * Initialize and register all merchant kidnapper variants. + */ + public static void registerDefaults() { + CORE.registerDefaults(); + } + + /** + * Load hardcoded default merchant skin variants. + */ + private static void loadHardcodedDefaults() { + CORE.registerVariant( + new KidnapperVariant( + "goldy", + ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/kidnapper/merchant/goldy.png" + ), + true, + "Goldy", + Gender.FEMALE + ) + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/skins/ShinyDamselSkinManager.java b/src/main/java/com/tiedup/remake/entities/skins/ShinyDamselSkinManager.java new file mode 100644 index 0000000..9e10fb4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/skins/ShinyDamselSkinManager.java @@ -0,0 +1,49 @@ +package com.tiedup.remake.entities.skins; + +import com.tiedup.remake.entities.DamselVariant; +import net.minecraft.resources.ResourceLocation; + +/** + * Registry and manager for shiny damsel skin variants. + * + * Shiny damsels are rare variants with: + * - Faster movement speed + * - Golden sparkle particles + * - Rare spawn rate + * - Dedicated shiny skins + */ +public class ShinyDamselSkinManager { + + public static final SkinManagerCore CORE = + new SkinManagerCore<>( + "ShinyDamselSkinManager", + "damsel_shiny", + SkinDefinition::toDamselVariant, + ShinyDamselSkinManager::loadHardcodedDefaults + ); + + /** + * Initialize and register all shiny damsel variants. + */ + public static void registerDefaults() { + CORE.registerDefaults(); + } + + /** + * Load hardcoded default shiny damsel skin variants. + */ + private static void loadHardcodedDefaults() { + CORE.registerVariant( + new DamselVariant( + "ellen", + ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/damsel/shiny/ellen.png" + ), + true, + "Ellen", + Gender.FEMALE + ) + ); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/skins/SkinDefinition.java b/src/main/java/com/tiedup/remake/entities/skins/SkinDefinition.java new file mode 100644 index 0000000..58858d7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/skins/SkinDefinition.java @@ -0,0 +1,115 @@ +package com.tiedup.remake.entities.skins; + +import com.google.gson.JsonObject; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.DamselVariant; +import com.tiedup.remake.entities.KidnapperVariant; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.GsonHelper; + +/** + * Data-driven skin definition for entity types. + * + * This record holds the skin metadata loaded from JSON files in datapacks. + * Each entity type (kidnapper, damsel, etc.) has its own skin definitions. + * + * Phase 1: Data-Driven Skin System + */ +public record SkinDefinition( + String id, + boolean hasSlimArms, + String defaultName, + ResourceLocation texture, + Gender gender +) { + /** + * Parse a SkinDefinition from JSON and auto-generate texture path. + * + * @param json JSON object from datapack file + * @param entityType Entity type (e.g., "kidnapper", "damsel", "kidnapper_elite") + * @return SkinDefinition with auto-generated texture path + */ + public static SkinDefinition fromJson(JsonObject json, String entityType) { + String id = GsonHelper.getAsString(json, "id"); + boolean hasSlimArms = GsonHelper.getAsBoolean( + json, + "hasSlimArms", + false + ); + String defaultName = GsonHelper.getAsString(json, "defaultName", "NPC"); + String genderStr = GsonHelper.getAsString(json, "gender", "female"); + Gender gender = Gender.fromName(genderStr); + + // Auto-generate texture path: tiedup:textures/entity/{entity_type}/{id}.png + // Map special entity types to correct texture folders: + // kidnapper_elite → kidnapper/elite + // kidnapper_archer → kidnapper/archer + // kidnapper_merchant → kidnapper/merchant + String texturePath = mapEntityTypeToTexturePath(entityType); + ResourceLocation texture = ResourceLocation.fromNamespaceAndPath( + "tiedup", + "textures/entity/" + texturePath + "/" + id + ".png" + ); + + TiedUpMod.LOGGER.debug( + "[SkinDefinition] Parsed {} skin: {} (slimArms={}, name={}, gender={})", + entityType, + id, + hasSlimArms, + defaultName, + gender + ); + + return new SkinDefinition( + id, + hasSlimArms, + defaultName, + texture, + gender + ); + } + + /** + * Map entity type to texture path. + * Handles special cases like elite/archer/merchant variants. + * + * @param entityType Entity type from registry (e.g., "kidnapper_elite") + * @return Texture path segment (e.g., "kidnapper/elite") + */ + private static String mapEntityTypeToTexturePath(String entityType) { + return switch (entityType) { + case "kidnapper_elite" -> "kidnapper/elite"; + case "kidnapper_archer" -> "kidnapper/archer"; + case "kidnapper_merchant" -> "kidnapper/merchant"; + case "maid" -> "kidnapper/maid"; + case "slave_trader" -> "kidnapper/trader"; + case "damsel_shiny" -> "damsel/shiny"; + case "labor_guard" -> "guard"; + default -> entityType; + }; + } + + /** + * Convert to KidnapperVariant for backward compatibility. + * + * @return KidnapperVariant with same data + */ + public KidnapperVariant toKidnapperVariant() { + return new KidnapperVariant( + id, + texture, + hasSlimArms, + defaultName, + gender + ); + } + + /** + * Convert to DamselVariant for backward compatibility. + * + * @return DamselVariant with same data + */ + public DamselVariant toDamselVariant() { + return new DamselVariant(id, texture, hasSlimArms, defaultName, gender); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/skins/SkinLoader.java b/src/main/java/com/tiedup/remake/entities/skins/SkinLoader.java new file mode 100644 index 0000000..719f420 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/skins/SkinLoader.java @@ -0,0 +1,184 @@ +package com.tiedup.remake.entities.skins; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.tiedup.remake.core.TiedUpMod; +import java.io.Reader; +import java.util.HashMap; +import java.util.Map; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; + +/** + * Main loader for entity skins from datapacks. + * + * Loads skin definitions from JSON files in data/tiedup/skins/{entity_type}/*.json + * and populates EntitySkinRegistry instances for each entity type. + * + * Called on server startup and /reload command via SkinReloadListener. + * + * Phase 1: Data-Driven Skin System + */ +public class SkinLoader { + + private static final Gson GSON = new GsonBuilder() + .setPrettyPrinting() + .create(); + private static final Map REGISTRIES = + new HashMap<>(); + + /** + * Entity types to load skins for. + * Each type maps to a folder: data/tiedup/skins/{type}/*.json + */ + private static final String[] ENTITY_TYPES = { + "kidnapper", + "damsel", + "damsel_shiny", + "kidnapper_elite", + "kidnapper_archer", + "kidnapper_merchant", + "maid", + "slave_trader", + "labor_guard", + "master", + }; + + /** + * Load all skin definitions from datapacks (server-side). + * + * This method: + * 1. Clears existing registries + * 2. Scans for JSON files in data/tiedup/skins/{entity_type}/*.json + * 3. Parses each JSON into a SkinDefinition + * 4. Registers skins in the appropriate EntitySkinRegistry + * + * @param resourceManager Minecraft's resource manager (from Forge event) + */ + public static void loadSkins(ResourceManager resourceManager) { + TiedUpMod.LOGGER.info( + "[SkinLoader] Starting skin loading from datapacks..." + ); + clear(); + + int totalLoaded = 0; + + for (String entityType : ENTITY_TYPES) { + int loaded = loadSkinsForEntity(resourceManager, entityType); + totalLoaded += loaded; + } + + TiedUpMod.LOGGER.info( + "[SkinLoader] Loaded {} total skins across {} entity types", + totalLoaded, + ENTITY_TYPES.length + ); + } + + /** + * Load skins for a specific entity type. + * + * @param resourceManager Minecraft's resource manager + * @param entityType Entity type identifier (e.g., "kidnapper") + * @return Number of skins loaded + */ + private static int loadSkinsForEntity( + ResourceManager resourceManager, + String entityType + ) { + EntitySkinRegistry registry = new EntitySkinRegistry(entityType); + + // Load all JSON files from data/*/skins/{entityType}/*.json + // Note: Minecraft searches across ALL datapacks, including tiedup's own + String path = "skins/" + entityType; + + Map resources = + resourceManager.listResources(path, loc -> + loc.getPath().endsWith(".json") + ); + + int loaded = 0; + for (Map.Entry< + ResourceLocation, + Resource + > entry : resources.entrySet()) { + ResourceLocation location = entry.getKey(); + + try (Reader reader = entry.getValue().openAsReader()) { + JsonObject json = GSON.fromJson(reader, JsonObject.class); + SkinDefinition skin = SkinDefinition.fromJson(json, entityType); + registry.register(skin); + loaded++; + } catch (Exception e) { + TiedUpMod.LOGGER.error( + "[SkinLoader] Failed to load skin from {}: {}", + location, + e.getMessage() + ); + TiedUpMod.LOGGER.debug("[SkinLoader] Exception details:", e); + } + } + + REGISTRIES.put(entityType, registry); + + if (loaded > 0) { + TiedUpMod.LOGGER.info( + "[SkinLoader] Loaded {} skins for entity type '{}'", + loaded, + entityType + ); + } else { + TiedUpMod.LOGGER.warn( + "[SkinLoader] No skins found for entity type '{}' (will use hardcoded fallback)", + entityType + ); + } + + return loaded; + } + + /** + * Get the skin registry for a specific entity type. + * + * If no registry exists yet, creates an empty one. + * + * @param entityType Entity type identifier + * @return EntitySkinRegistry for this type + */ + public static EntitySkinRegistry getRegistry(String entityType) { + return REGISTRIES.computeIfAbsent(entityType, EntitySkinRegistry::new); + } + + /** + * Clear all registries. + * + * Called before reloading to ensure fresh data. + */ + public static void clear() { + REGISTRIES.values().forEach(EntitySkinRegistry::clear); + REGISTRIES.clear(); + TiedUpMod.LOGGER.debug("[SkinLoader] Cleared all skin registries"); + } + + /** + * Get the number of registered entity types. + * + * @return Number of entity types with skin registries + */ + public static int getRegistryCount() { + return REGISTRIES.size(); + } + + /** + * Check if any skins are loaded for a given entity type. + * + * @param entityType Entity type identifier + * @return true if at least one skin is loaded + */ + public static boolean hasSkinsForType(String entityType) { + EntitySkinRegistry registry = REGISTRIES.get(entityType); + return registry != null && registry.count() > 0; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/skins/SkinManagerCore.java b/src/main/java/com/tiedup/remake/entities/skins/SkinManagerCore.java new file mode 100644 index 0000000..5b5a924 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/skins/SkinManagerCore.java @@ -0,0 +1,210 @@ +package com.tiedup.remake.entities.skins; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import net.minecraft.util.RandomSource; + +/** + * Core skin manager logic shared across all skin managers. + * + * This class provides the common functionality for variant registration, + * retrieval, and management. Each specific skin manager uses this via + * composition to avoid code duplication. + * + * @param The variant type (must implement SkinVariant) + */ +public class SkinManagerCore { + + private final Map variants = new LinkedHashMap<>(); + private final List variantsList = new ArrayList<>(); + private final RandomSource random = RandomSource.create(); + + private final String managerName; + private final String registryKey; + private final Function variantConverter; + private final Runnable hardcodedDefaultsLoader; + + /** + * Create a new skin manager core. + * + * @param managerName Name for logging (e.g., "DamselSkinManager") + * @param registryKey Key for SkinLoader registry (e.g., "damsel") + * @param variantConverter Function to convert SkinDefinition to variant type + * @param hardcodedDefaultsLoader Runnable to load hardcoded defaults + */ + public SkinManagerCore( + String managerName, + String registryKey, + Function variantConverter, + Runnable hardcodedDefaultsLoader + ) { + this.managerName = managerName; + this.registryKey = registryKey; + this.variantConverter = variantConverter; + this.hardcodedDefaultsLoader = hardcodedDefaultsLoader; + } + + /** + * Initialize and register all variants. + * Loads from data files first, falls back to hardcoded defaults. + */ + public void registerDefaults() { + TiedUpMod.LOGGER.info("[{}] Registering variants...", managerName); + + clear(); + + EntitySkinRegistry registry = SkinLoader.getRegistry(registryKey); + + if (registry.count() == 0) { + TiedUpMod.LOGGER.warn( + "[{}] No JSON data found, using hardcoded defaults", + managerName + ); + hardcodedDefaultsLoader.run(); + } else { + for (SkinDefinition def : registry.getAllSkins()) { + registerVariant(variantConverter.apply(def)); + } + TiedUpMod.LOGGER.info( + "[{}] Loaded {} variants from data files", + managerName, + variants.size() + ); + } + + TiedUpMod.LOGGER.info( + "[{}] Registered {} variants total", + managerName, + variants.size() + ); + } + + /** + * Register a variant. + * + * @param variant The variant to register + */ + public void registerVariant(V variant) { + if (variant == null) { + TiedUpMod.LOGGER.warn( + "[{}] Attempted to register null variant", + managerName + ); + return; + } + + if (variants.containsKey(variant.id())) { + TiedUpMod.LOGGER.warn( + "[{}] Variant {} already registered, skipping", + managerName, + variant.id() + ); + return; + } + + variants.put(variant.id(), variant); + variantsList.add(variant); + } + + /** + * Get a variant by ID. + * + * @param id The variant ID + * @return The variant, or null if not found + */ + public V getVariant(String id) { + return variants.get(id); + } + + /** + * Get a random variant. + * + * @return A random variant + * @throws IllegalStateException if no variants are registered + */ + public V getRandomVariant() { + if (variantsList.isEmpty()) { + throw new IllegalStateException( + "No " + managerName + " variants registered!" + ); + } + return variantsList.get(random.nextInt(variantsList.size())); + } + + /** + * Get a deterministic variant based on entity UUID. + * This ensures all clients see the same variant for the same entity. + * + * @param entityUUID The entity's UUID + * @return A variant determined by the UUID, or null if none registered + */ + public V getVariantForEntity(UUID entityUUID) { + return getVariantForEntity(entityUUID, null); + } + + /** + * Get a deterministic variant based on entity UUID, optionally filtering by gender. + * + * @param entityUUID The entity's UUID + * @param preferredGender The preferred gender, or null for any + * @return A variant determined by the UUID + */ + public V getVariantForEntity(UUID entityUUID, Gender preferredGender) { + if (variantsList.isEmpty()) { + return null; + } + + List candidates; + if (preferredGender == null) { + candidates = variantsList; + } else { + candidates = new ArrayList<>(); + for (V v : variantsList) { + if (v.gender() == preferredGender) { + candidates.add(v); + } + } + // Fallback if no match (e.g., no males registered) + if (candidates.isEmpty()) { + candidates = variantsList; + } + } + + int index = Math.abs(entityUUID.hashCode()) % candidates.size(); + return candidates.get(index); + } + + /** + * Get all registered variants. + * + * @return Unmodifiable collection of all variants + */ + public Collection getAllVariants() { + return Collections.unmodifiableCollection(variants.values()); + } + + /** + * Get total count of registered variants. + * + * @return The variant count + */ + public int getVariantCount() { + return variants.size(); + } + + /** + * Clear all registered variants. + */ + public void clear() { + variants.clear(); + variantsList.clear(); + TiedUpMod.LOGGER.debug("[{}] Cleared all variants", managerName); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/skins/SkinManagers.java b/src/main/java/com/tiedup/remake/entities/skins/SkinManagers.java new file mode 100644 index 0000000..fc5d866 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/skins/SkinManagers.java @@ -0,0 +1,41 @@ +package com.tiedup.remake.entities.skins; + +import java.util.List; + +/** + * Central registry of all SkinManagerCore instances. + * Provides reloadAll() for SkinReloadListener. + */ +public final class SkinManagers { + + private SkinManagers() {} + + private static final List> ALL_CORES = List.of( + DamselSkinManager.CORE, + ShinyDamselSkinManager.CORE, + KidnapperSkinManager.CORE, + EliteKidnapperSkinManager.CORE, + ArcherKidnapperSkinManager.CORE, + MerchantKidnapperSkinManager.CORE, + MasterSkinManager.CORE, + MaidSkinManager.CORE, + TraderSkinManager.CORE, + LaborGuardSkinManager.CORE + ); + + /** + * Reload all skin managers. Called by SkinReloadListener. + */ + public static void reloadAll() { + for (SkinManagerCore core : ALL_CORES) { + core.registerDefaults(); + } + } + + /** + * Get all registered cores for iteration. + */ + public static List> getAllCores() { + return ALL_CORES; + } +} diff --git a/src/main/java/com/tiedup/remake/entities/skins/SkinReloadListener.java b/src/main/java/com/tiedup/remake/entities/skins/SkinReloadListener.java new file mode 100644 index 0000000..8ed51d8 --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/skins/SkinReloadListener.java @@ -0,0 +1,72 @@ +package com.tiedup.remake.entities.skins; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import net.minecraft.server.packs.resources.PreparableReloadListener; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.util.profiling.ProfilerFiller; + +/** + * Forge reload listener for entity skins. + * + * Integrates with Minecraft's resource reload system to: + * 1. Load skin definitions from datapacks on server start + * 2. Reload skins when /reload command is executed + * + * Registered via AddReloadListenerEvent in TiedUpMod. + * + * Phase 1: Data-Driven Skin System + */ +public class SkinReloadListener implements PreparableReloadListener { + + @Override + public CompletableFuture reload( + PreparationBarrier barrier, + ResourceManager resourceManager, + ProfilerFiller preparationsProfiler, + ProfilerFiller reloadProfiler, + Executor backgroundExecutor, + Executor gameExecutor + ) { + // Load skins in background thread (non-blocking) + return CompletableFuture.runAsync( + () -> { + preparationsProfiler.startTick(); + preparationsProfiler.push("tiedup_skin_loading"); + + try { + TiedUpMod.LOGGER.info( + "[SkinReloadListener] Starting skin reload..." + ); + + // Load skins from datapacks + SkinLoader.loadSkins(resourceManager); + + // Refresh existing managers to use new data + refreshManagers(); + + TiedUpMod.LOGGER.info( + "[SkinReloadListener] Skin reload complete" + ); + } catch (Exception e) { + TiedUpMod.LOGGER.error( + "[SkinReloadListener] Failed to reload skins", + e + ); + } + + preparationsProfiler.pop(); + preparationsProfiler.endTick(); + }, + backgroundExecutor + ).thenCompose(barrier::wait); + } + + /** + * Refresh existing skin managers to use newly loaded data. + */ + private void refreshManagers() { + SkinManagers.reloadAll(); + } +} diff --git a/src/main/java/com/tiedup/remake/entities/skins/SkinVariant.java b/src/main/java/com/tiedup/remake/entities/skins/SkinVariant.java new file mode 100644 index 0000000..a03c1ec --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/skins/SkinVariant.java @@ -0,0 +1,41 @@ +package com.tiedup.remake.entities.skins; + +import net.minecraft.resources.ResourceLocation; + +/** + * Common interface for skin variants (Damsel, Kidnapper, etc.). + * + * This interface allows for generic handling of skin variants + * across different entity types while maintaining type safety. + */ +public interface SkinVariant { + /** + * Get the unique identifier for this variant. + * @return The variant ID (e.g., "dam_mob_1", "blake") + */ + String id(); + + /** + * Get the texture resource location for this variant. + * @return The texture location + */ + ResourceLocation texture(); + + /** + * Check if this variant uses slim arms (Alex model). + * @return true for slim/Alex model, false for normal/Steve model + */ + boolean hasSlimArms(); + + /** + * Get the gender of this variant. + * @return The gender (MALE/FEMALE) + */ + Gender gender(); + + /** + * Get the default display name for this variant. + * @return The display name + */ + String defaultName(); +} diff --git a/src/main/java/com/tiedup/remake/entities/skins/TraderSkinManager.java b/src/main/java/com/tiedup/remake/entities/skins/TraderSkinManager.java new file mode 100644 index 0000000..19c8ddb --- /dev/null +++ b/src/main/java/com/tiedup/remake/entities/skins/TraderSkinManager.java @@ -0,0 +1,74 @@ +package com.tiedup.remake.entities.skins; + +import com.tiedup.remake.entities.KidnapperVariant; + +/** + * Registry and manager for Slave Trader skin variants. + * + * Slave Traders are the bosses of camps who sell captives. + * They wear fancy/noble outfits to distinguish them from regular kidnappers. + */ +public class TraderSkinManager { + + public static final SkinManagerCore CORE = + new SkinManagerCore<>( + "TraderSkinManager", + "slave_trader", + SkinDefinition::toKidnapperVariant, + TraderSkinManager::loadHardcodedDefaults + ); + + /** + * Initialize and register all trader variants. + */ + public static void registerDefaults() { + CORE.registerDefaults(); + } + + /** + * Load hardcoded default trader skin variants. + */ + private static void loadHardcodedDefaults() { + // Generic numbered skins (trader_mob_1 to trader_mob_10) - use random names + for (int i = 1; i <= 10; i++) { + CORE.registerVariant( + createTraderVariant("trader_mob_" + i, true, null) + ); + } + + // Named skins (specific characters) + CORE.registerVariant( + createTraderVariant("trader_default", true, "Madame") + ); + CORE.registerVariant( + createTraderVariant("trader_noble", true, "Lady Noble") + ); + + // FUTURE: Add more named trader variants here (requires artist skin assets) + // CORE.registerVariant(createTraderVariant("trader_merchant", true, "The Merchant")); + // CORE.registerVariant(createTraderVariant("trader_queen", true, "Queen of Chains")); + } + + /** + * Create a trader variant with proper texture path. + * Uses KidnapperVariant.createWithSubfolder() helper. + * + * @param textureName Texture file name without extension + * @param hasSlimArms True if this variant uses slim arms + * @param displayName Display name, or null for generic "Trader" + * @return Trader variant + */ + private static KidnapperVariant createTraderVariant( + String textureName, + boolean hasSlimArms, + String displayName + ) { + return KidnapperVariant.createWithSubfolder( + textureName, + "trader", + hasSlimArms, + displayName != null ? displayName : "Trader", + Gender.FEMALE + ); + } +} diff --git a/src/main/java/com/tiedup/remake/events/camp/CampChestHandler.java b/src/main/java/com/tiedup/remake/events/camp/CampChestHandler.java new file mode 100644 index 0000000..fd57829 --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/camp/CampChestHandler.java @@ -0,0 +1,133 @@ +package com.tiedup.remake.events.camp; + +import com.tiedup.remake.blocks.BlockMarker; +import com.tiedup.remake.blocks.entity.MarkerBlockEntity; +import com.tiedup.remake.cells.CampOwnership; +import com.tiedup.remake.cells.CampOwnership.CampData; +import com.tiedup.remake.cells.MarkerType; +import com.tiedup.remake.core.TiedUpMod; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.block.ChestBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraftforge.event.entity.player.PlayerInteractEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Event handler for camp chest access control. + * + * Chests with a LOOT marker above them are locked while the camp trader is alive. + * Only camp members (trader, maid, kidnappers) can access them. + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class CampChestHandler { + + @SubscribeEvent + public static void onChestOpen(PlayerInteractEvent.RightClickBlock event) { + if (event.getLevel().isClientSide()) { + return; + } + + BlockPos pos = event.getPos(); + ServerLevel level = (ServerLevel) event.getLevel(); + + // Only check if clicking a chest + if (!(level.getBlockState(pos).getBlock() instanceof ChestBlock)) { + return; + } + + // Check if there's a LOOT marker above this chest + BlockPos markerPos = pos.above(); + if ( + !(level.getBlockState(markerPos).getBlock() instanceof BlockMarker) + ) { + return; // No marker above, allow normal access + } + + BlockEntity be = level.getBlockEntity(markerPos); + if (!(be instanceof MarkerBlockEntity marker)) { + return; + } + + // Check if it's a LOOT marker + if (marker.getMarkerType() != MarkerType.LOOT) { + return; // Not a LOOT marker, allow normal access + } + + // Find the nearest camp + CampOwnership registry = CampOwnership.get(level); + CampData camp = registry.findNearestAliveCamp(pos, 100); + + if (camp == null) { + return; // No camp nearby, allow access + } + + // Camp dead = chest accessible + if (!camp.isAlive()) { + return; // Trader dead, chest unlocked + } + + // Check if the entity has camp access + Entity entity = event.getEntity(); + if (hasCampAccess(camp, entity)) { + return; // Camp member, allow access + } + + // Deny access + event.setCanceled(true); + event.setCancellationResult(InteractionResult.FAIL); + + if (entity instanceof Player player) { + player.displayClientMessage( + Component.literal( + "This chest is locked by the camp!" + ).withStyle(ChatFormatting.RED), + true + ); + } + + TiedUpMod.LOGGER.debug( + "[CampChestHandler] Denied access to LOOT chest at {} for {}", + pos.toShortString(), + entity.getName().getString() + ); + } + + /** + * Check if an entity has access to the camp's resources. + * + * @param camp The camp data + * @param entity The entity trying to access + * @return true if the entity is allowed access + */ + private static boolean hasCampAccess(CampData camp, Entity entity) { + UUID entityId = entity.getUUID(); + + // Trader has access + if (entityId.equals(camp.getTraderUUID())) { + return true; + } + + // Maid has access + if (entityId.equals(camp.getMaidUUID())) { + return true; + } + + // Kidnapper linked to this camp has access + if (camp.hasKidnapper(entityId)) { + return true; + } + + return false; + } +} diff --git a/src/main/java/com/tiedup/remake/events/camp/CampManagementHandler.java b/src/main/java/com/tiedup/remake/events/camp/CampManagementHandler.java new file mode 100644 index 0000000..3466f2b --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/camp/CampManagementHandler.java @@ -0,0 +1,299 @@ +package com.tiedup.remake.events.camp; + +import com.tiedup.remake.cells.CampMaidManager; +import com.tiedup.remake.cells.CampOwnership; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityMaid; +import com.tiedup.remake.entities.EntitySlaveTrader; +import com.tiedup.remake.entities.ModEntities; +// Prison system v2 +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.service.PrisonerService; +import java.util.List; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Handles camp management tasks including maid respawn. + * + * When a maid dies: + * - Camp remains alive (trader still exists) + * - Prisoners are paused (no new tasks) + * - After 5 minutes (6000 ticks), a new maid spawns + * - Prisoners are notified and labor resumes + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class CampManagementHandler { + + // Check every 100 ticks (5 seconds) - optimized for performance + private static final int CHECK_INTERVAL_TICKS = 100; + + private static int tickCounter = 0; + + /** + * Periodically check for camps needing maid respawn. + */ + @SubscribeEvent + public static void onServerTick(TickEvent.ServerTickEvent event) { + if (event.phase != TickEvent.Phase.END) return; + + tickCounter++; + if (tickCounter < CHECK_INTERVAL_TICKS) return; + tickCounter = 0; + + // Process all worlds + for (ServerLevel level : event.getServer().getAllLevels()) { + processCampManagement(level); + } + } + + /** + * Process camp management for a specific level. + */ + private static void processCampManagement(ServerLevel level) { + // IMPORTANT: Always use server-level registry to avoid dimension fragmentation + CampOwnership ownership = CampOwnership.get(level.getServer()); + long currentTime = level.getGameTime(); + + // Get camps that need maid respawn + List campsNeedingMaid = CampMaidManager.getCampsNeedingMaidRespawn( + currentTime, level + ); + + for (UUID campId : campsNeedingMaid) { + spawnNewMaidForCamp(level, ownership, campId); + } + + // Prison system v2 - tick escape service (handles escape detection) + PrisonerService.get().tick(level.getServer(), currentTime); + + // Prison system v2 - tick protection expiry + PrisonerManager.get(level).tickProtectionExpiry(currentTime); + } + + /** + * Spawn a new maid for a camp that needs one. + * + * @param level The server level + * @param ownership The camp ownership data + * @param campId The camp UUID + */ + private static void spawnNewMaidForCamp( + ServerLevel level, + CampOwnership ownership, + UUID campId + ) { + CampOwnership.CampData campData = ownership.getCamp(campId); + if (campData == null || !campData.isAlive()) { + return; + } + + BlockPos center = campData.getCenter(); + if (center == null) { + TiedUpMod.LOGGER.warn( + "[CampManagementHandler] Cannot spawn maid - camp {} has no center position", + campId.toString().substring(0, 8) + ); + return; + } + + UUID traderUUID = campData.getTraderUUID(); + if (traderUUID == null) { + TiedUpMod.LOGGER.warn( + "[CampManagementHandler] Cannot spawn maid - camp {} has no trader", + campId.toString().substring(0, 8) + ); + return; + } + + // Find the trader entity + Entity traderEntity = level.getEntity(traderUUID); + EntitySlaveTrader trader = null; + if (traderEntity instanceof EntitySlaveTrader t) { + trader = t; + } else { + // Trader not loaded - search near camp center + trader = findTraderNearPosition(level, center, 50); + } + + if (trader == null) { + TiedUpMod.LOGGER.warn( + "[CampManagementHandler] Cannot spawn maid - trader not found for camp {}", + campId.toString().substring(0, 8) + ); + return; + } + + // Create new maid + EntityMaid maid = ModEntities.MAID.get().create(level); + if (maid == null) { + TiedUpMod.LOGGER.error( + "[CampManagementHandler] Failed to create maid entity for camp {}", + campId.toString().substring(0, 8) + ); + return; + } + + // Find spawn position near trader (slightly offset) + BlockPos spawnPos = findSafeSpawnPosition( + level, + trader.blockPosition() + ); + + // Position the maid + maid.moveTo( + spawnPos.getX() + 0.5, + spawnPos.getY(), + spawnPos.getZ() + 0.5, + trader.getYRot() + 180, // Face opposite direction from trader + 0 + ); + + // Link maid to trader + maid.setMasterTraderUUID(trader.getUUID()); + + // Add to world + level.addFreshEntity(maid); + + // Update camp ownership with new maid + CampMaidManager.assignNewMaid(campId, maid.getUUID(), level); + + // Update trader's maid reference + trader.setMaidUUID(maid.getUUID()); + + TiedUpMod.LOGGER.info( + "[CampManagementHandler] Spawned replacement maid {} for camp {} at {}", + maid.getNpcName(), + campId.toString().substring(0, 8), + spawnPos.toShortString() + ); + + // Notify prisoners + notifyPrisonersOfNewMaid(level, campId, maid.getNpcName()); + } + + /** + * Find a trader near the given position. + */ + private static EntitySlaveTrader findTraderNearPosition( + ServerLevel level, + BlockPos pos, + int radius + ) { + List traders = level.getEntitiesOfClass( + EntitySlaveTrader.class, + new net.minecraft.world.phys.AABB(pos).inflate(radius) + ); + return traders.isEmpty() ? null : traders.get(0); + } + + /** + * Find a safe position to spawn the maid. + * Tries positions around the target, prioritizing nearby valid spots. + */ + private static BlockPos findSafeSpawnPosition( + ServerLevel level, + BlockPos targetPos + ) { + // Try offsets in a spiral pattern + int[][] offsets = { + { 1, 0 }, + { 0, 1 }, + { -1, 0 }, + { 0, -1 }, + { 1, 1 }, + { -1, 1 }, + { -1, -1 }, + { 1, -1 }, + { 2, 0 }, + { 0, 2 }, + { -2, 0 }, + { 0, -2 }, + }; + + for (int[] offset : offsets) { + BlockPos testPos = targetPos.offset(offset[0], 0, offset[1]); + + // Check if position is safe (solid ground, air above) + if (isValidSpawnPosition(level, testPos)) { + return testPos; + } + + // Try one block down + BlockPos downPos = testPos.below(); + if (isValidSpawnPosition(level, downPos)) { + return downPos; + } + + // Try one block up + BlockPos upPos = testPos.above(); + if (isValidSpawnPosition(level, upPos)) { + return upPos; + } + } + + // Fallback: use target position + return targetPos; + } + + /** + * Check if a position is valid for spawning. + */ + private static boolean isValidSpawnPosition( + ServerLevel level, + BlockPos pos + ) { + // Ground must be solid + if (!level.getBlockState(pos.below()).isSolid()) { + return false; + } + + // Position and above must be passable (air or similar) + if (!level.getBlockState(pos).isAir()) { + return false; + } + + if (!level.getBlockState(pos.above()).isAir()) { + return false; + } + + return true; + } + + /** + * Notify all prisoners of this camp that a new maid has arrived. + */ + private static void notifyPrisonersOfNewMaid( + ServerLevel level, + UUID campId, + String maidName + ) { + PrisonerManager manager = PrisonerManager.get(level); + for (UUID prisonerId : manager.getPrisonersInCamp(campId)) { + ServerPlayer player = level + .getServer() + .getPlayerList() + .getPlayer(prisonerId); + if (player != null) { + player.sendSystemMessage( + Component.literal( + "A new maid, " + + maidName + + ", has arrived. Work resumes." + ).withStyle(ChatFormatting.GOLD) + ); + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/events/camp/CampNpcProtectionHandler.java b/src/main/java/com/tiedup/remake/events/camp/CampNpcProtectionHandler.java new file mode 100644 index 0000000..adee72d --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/camp/CampNpcProtectionHandler.java @@ -0,0 +1,85 @@ +package com.tiedup.remake.events.camp; + +import com.tiedup.remake.cells.CampLifecycleManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityMaid; +import com.tiedup.remake.entities.EntitySlaveTrader; +import com.tiedup.remake.items.base.ItemBind; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.event.entity.player.PlayerInteractEvent; +import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Protects camp NPCs (Trader and Maid) from being captured by players. + * When a player attempts to restrain a camp NPC: + * 1. The attempt is cancelled + * 2. The entire camp (trader, maid, all kidnappers) is alerted to attack + * 3. The player receives a warning message + */ +@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID) +public class CampNpcProtectionHandler { + + @SubscribeEvent(priority = EventPriority.HIGHEST) + public static void onPlayerInteractEntity( + PlayerInteractEvent.EntityInteract event + ) { + Player player = event.getEntity(); + Entity target = event.getTarget(); + + // Server-side only + if (player.level().isClientSide) return; + if (!(player.level() instanceof ServerLevel serverLevel)) return; + + // Check if player is holding restraint item + ItemStack heldItem = player.getItemInHand(event.getHand()); + if (!(heldItem.getItem() instanceof ItemBind)) return; + + // Check if target is trader or maid with active camp + UUID campId = null; + boolean isTrader = false; + + if (target instanceof EntitySlaveTrader trader) { + if (trader.isTiedUp()) return; // Already captured + campId = trader.getCampUUID(); + isTrader = true; + } else if (target instanceof EntityMaid maid) { + if (maid.isTiedUp() || maid.isFreed()) return; // Already captured or freed + campId = maid.getCampUUID(); + isTrader = false; + } else { + return; // Not a protected NPC + } + + if (campId == null) return; // No active camp + + // CANCEL THE ATTEMPT + event.setCanceled(true); + + // ALERT THE CAMP + CampLifecycleManager.alertCampToDefend(campId, player, serverLevel); + + // Log the attempt + TiedUpMod.LOGGER.warn( + "[CampNpcProtection] {} attempted to restrain {} - camp alerted!", + player.getName().getString(), + isTrader ? "trader" : "maid" + ); + + // Send warning message to player + player.sendSystemMessage( + Component.literal( + isTrader + ? "The camp defends their leader! You are now under attack!" + : "The camp defends their servant! You are now under attack!" + ).withStyle(ChatFormatting.RED, ChatFormatting.BOLD) + ); + } +} diff --git a/src/main/java/com/tiedup/remake/events/captivity/CaptivityTickHandler.java b/src/main/java/com/tiedup/remake/events/captivity/CaptivityTickHandler.java new file mode 100644 index 0000000..f0836d3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/captivity/CaptivityTickHandler.java @@ -0,0 +1,61 @@ +package com.tiedup.remake.events.captivity; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.prison.PrisonerManager; +import net.minecraft.server.level.ServerLevel; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Handles periodic captivity system updates. + * + * Responsibilities: + * - Expire protection periods for released prisoners + * - Periodic validation (optional, for debugging) + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class CaptivityTickHandler { + + private static final int EXPIRY_CHECK_INTERVAL = 100; // Check every 5 seconds (100 ticks) + private static int tickCounter = 0; + + /** + * Handle server ticks for captivity system updates. + */ + @SubscribeEvent + public static void onServerTick(TickEvent.ServerTickEvent event) { + // Only run at end of tick to avoid interfering with game logic + if (event.phase != TickEvent.Phase.END) { + return; + } + + tickCounter++; + + // Run expiry check every 5 seconds + if (tickCounter >= EXPIRY_CHECK_INTERVAL) { + tickCounter = 0; + + // Check all loaded worlds + for (ServerLevel level : event.getServer().getAllLevels()) { + try { + PrisonerManager manager = PrisonerManager.get(level); + long currentTime = level.getGameTime(); + + // Expire protection for players whose grace period has ended + manager.tickProtectionExpiry(currentTime); + } catch (Exception e) { + TiedUpMod.LOGGER.error( + "[CaptivityTickHandler] Error processing captivity expiry in {}: {}", + level.dimension().location(), + e.getMessage(), + e + ); + } + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/events/captivity/ForcedSeatingHandler.java b/src/main/java/com/tiedup/remake/events/captivity/ForcedSeatingHandler.java new file mode 100644 index 0000000..104f542 --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/captivity/ForcedSeatingHandler.java @@ -0,0 +1,316 @@ +package com.tiedup.remake.events.captivity; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.SystemMessageManager.MessageCategory; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.LeashProxyEntity; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.state.PlayerCaptorManager; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.event.entity.EntityMountEvent; +import net.minecraftforge.event.entity.player.PlayerInteractEvent; +import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Handles forced seating for captives. + * + * Flow: + * 1. [ALT] + right-click empty vehicle → free captive + mount on vehicle + * 2. [ALT] + right-click vehicle with tied player → dismount + re-capture + * 3. Owner enters vehicle with tied player → swap (owner = driver) + * 4. Tied player Shift → blocked + * 5. Vehicle destroyed → tied player dismounts, stays free + * + * Note: In the vehicle, the captive has NO leash. The "tied up" status + * (isTiedUp) is preserved, which blocks vehicle control and dismount. + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class ForcedSeatingHandler { + + /** Track which players have the Force Seat keybind pressed (server-side) */ + private static final Map forceSeatPressed = + new ConcurrentHashMap<>(); + + /** Track forced dismounts in progress to avoid blocking our own operations */ + private static final Map forcedDismountInProgress = + new ConcurrentHashMap<>(); + + /** + * Update the Force Seat keybind state for a player. + * Called from PacketForceSeatModifier. + */ + public static void setForceSeatPressed(UUID playerId, boolean pressed) { + if (pressed) { + forceSeatPressed.put(playerId, true); + } else { + forceSeatPressed.remove(playerId); + } + } + + /** + * Check if a player has the Force Seat keybind pressed. + */ + public static boolean isForceSeatPressed(Player player) { + return forceSeatPressed.getOrDefault(player.getUUID(), false); + } + + /** + * Clean up keybind state when player disconnects. + */ + public static void clearPlayer(UUID playerId) { + forceSeatPressed.remove(playerId); + forcedDismountInProgress.remove(playerId); + } + + // ========== ALT + CLICK ON ENTITY ========== + + /** + * Handle ALT + right-click on entity. + * - If vehicle has tied player → dismount + re-capture + * - If vehicle is empty → free captive + mount + */ + @SubscribeEvent(priority = EventPriority.HIGHEST) + public static void onPlayerInteractEntity( + PlayerInteractEvent.EntityInteract event + ) { + if (event.getLevel().isClientSide) return; + + Player owner = event.getEntity(); + Entity target = event.getTarget(); + + // Furniture handles its own seating — skip forced seating logic + if (target instanceof com.tiedup.remake.v2.furniture.ISeatProvider) return; + + // Only process if Force Seat keybind is pressed + if (!isForceSeatPressed(owner)) return; + + // Skip proxies and players + if (target instanceof LeashProxyEntity) return; + if (target instanceof Player) return; + + // Get owner's state + PlayerBindState ownerState = PlayerBindState.getInstance(owner); + if (ownerState == null) return; + + PlayerCaptorManager captorManager = ownerState.getCaptorManager(); + + // === CASE 1: Vehicle with tied player → dismount and re-capture === + for (Entity passenger : target.getPassengers()) { + if (passenger instanceof Player tiedPlayer && tiedPlayer != owner) { + IBondageState tiedState = KidnappedHelper.getKidnappedState( + tiedPlayer + ); + + if (tiedState != null && tiedState.isTiedUp()) { + // Cancel the event first + event.setCanceled(true); + + // Mark forced dismount in progress + forcedDismountInProgress.put(tiedPlayer.getUUID(), true); + + // Schedule dismount for next tick + target + .getServer() + .execute(() -> { + try { + // Dismount the tied player + tiedPlayer.stopRiding(); + + // Teleport slightly away from vehicle + double offsetX = + (target.level().random.nextDouble() - 0.5) * + 2; + double offsetZ = + (target.level().random.nextDouble() - 0.5) * + 2; + tiedPlayer.teleportTo( + target.getX() + offsetX, + target.getY() + 0.5, + target.getZ() + offsetZ + ); + + // Re-capture (creates proxy + leash) + if (captorManager != null) { + tiedState.getCapturedBy(captorManager); + } + + TiedUpMod.LOGGER.debug( + "[ForcedSeating] {} dismounted {} from vehicle", + owner.getName().getString(), + tiedPlayer.getName().getString() + ); + } finally { + forcedDismountInProgress.remove( + tiedPlayer.getUUID() + ); + } + }); + return; + } + } + } + + // Need captives for Case 2 + if (captorManager == null || !captorManager.hasCaptives()) { + return; + } + + // === CASE 2: Empty vehicle → free captive and mount === + for (IBondageState captive : captorManager.getCaptives()) { + LivingEntity captiveEntity = captive.asLivingEntity(); + + // Skip if captive is the target itself + if (captiveEntity == target) continue; + + // Skip if captive already riding something + if (captiveEntity.isPassenger()) continue; + + // Skip if captive not tied + if (!captive.isTiedUp()) continue; + + // Free the captive (detaches leash, removes relationship) + captive.free(true); + + // Mount captive on vehicle (still tied up, can't control) + boolean success = captiveEntity.startRiding(target, true); + + if (success) { + TiedUpMod.LOGGER.debug( + "[ForcedSeating] {} mounted {} on vehicle", + owner.getName().getString(), + captive.getKidnappedName() + ); + } + + event.setCanceled(true); + return; + } + } + + // ========== MOUNT/DISMOUNT EVENTS ========== + + /** + * Handle mounting/dismounting events. + * - Block dismount for tied players (unless vehicle is dead) + * - Swap when owner enters vehicle with tied player + */ + @SubscribeEvent(priority = EventPriority.HIGHEST) + public static void onEntityMount(EntityMountEvent event) { + if (event.getLevel().isClientSide) return; + + Entity rider = event.getEntityMounting(); + Entity vehicle = event.getEntityBeingMounted(); + + // === DISMOUNTING === + if (event.isDismounting()) { + if (rider instanceof Player player) { + // Skip if this is a forced dismount (ALT+click or swap) + if ( + forcedDismountInProgress.getOrDefault( + player.getUUID(), + false + ) + ) { + return; + } + + // Furniture manages its own dismount via removePassenger() + if (vehicle instanceof com.tiedup.remake.v2.furniture.ISeatProvider) { + return; + } + + IBondageState state = KidnappedHelper.getKidnappedState(player); + if (state != null && state.isTiedUp()) { + if (vehicle.isAlive()) { + // Block voluntary dismount (Shift key) + event.setCanceled(true); + SystemMessageManager.sendRestriction( + player, + MessageCategory.CANT_MOVE + ); + return; + } + // Vehicle destroyed → captive dismounts, stays free + } + } + return; + } + + // === MOUNTING: Swap if tied player already in vehicle === + if (rider instanceof Player newDriver) { + // Skip if swap already in progress for this driver + if ( + forcedDismountInProgress.getOrDefault( + newDriver.getUUID(), + false + ) + ) { + return; + } + + for (Entity passenger : vehicle.getPassengers()) { + if ( + passenger instanceof Player tiedPlayer && + tiedPlayer != newDriver + ) { + IBondageState state = KidnappedHelper.getKidnappedState( + tiedPlayer + ); + if (state != null && state.isTiedUp()) { + // Mark swap in progress for both players + forcedDismountInProgress.put(newDriver.getUUID(), true); + forcedDismountInProgress.put( + tiedPlayer.getUUID(), + true + ); + + // Cancel this mount event - we'll do it manually + event.setCanceled(true); + + // Schedule the swap for next tick + vehicle + .getServer() + .execute(() -> { + try { + // 1. Dismount tied player + tiedPlayer.stopRiding(); + + // 2. Mount new driver (becomes controller) + newDriver.startRiding(vehicle, true); + + // 3. Remount tied player (becomes passenger) + tiedPlayer.startRiding(vehicle, true); + + TiedUpMod.LOGGER.debug( + "[ForcedSeating] Swapped: {} driving, {} passenger", + newDriver.getName().getString(), + tiedPlayer.getName().getString() + ); + } finally { + forcedDismountInProgress.remove( + newDriver.getUUID() + ); + forcedDismountInProgress.remove( + tiedPlayer.getUUID() + ); + } + }); + return; + } + } + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/events/captivity/LeashTickHandler.java b/src/main/java/com/tiedup/remake/events/captivity/LeashTickHandler.java new file mode 100644 index 0000000..1b9c4e5 --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/captivity/LeashTickHandler.java @@ -0,0 +1,40 @@ +package com.tiedup.remake.events.captivity; + +import com.tiedup.remake.state.IPlayerLeashAccess; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Handles leash ticking for players via Forge events. + * This replaces the mixin @Inject approach which had refmap issues. + * + * Security fix: Uses per-player tick count instead of global counter + * to prevent erratic physics behavior with multiple players. + */ +@Mod.EventBusSubscriber( + modid = "tiedup", + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class LeashTickHandler { + + @SubscribeEvent + public static void onPlayerTick(TickEvent.PlayerTickEvent event) { + // Only on server side, at end of tick + if (event.phase != TickEvent.Phase.END) return; + if (!(event.player instanceof ServerPlayer serverPlayer)) return; + + // FIX: Removed throttling (was: tickCount % 2 != 0) + // Throttling caused rubber-banding and jerky movement because: + // 1. Client predicts movement between ticks + // 2. Server sends velocity corrections every 2 ticks + // 3. This creates visible "snapping" effect + // Now physics runs every tick (20 Hz) for smooth movement. + + // Call the leash tick method via the mixin interface + if (serverPlayer instanceof IPlayerLeashAccess access) { + access.tiedup$tickLeash(); + } + } +} diff --git a/src/main/java/com/tiedup/remake/events/captivity/PlayerEnslavementHandler.java b/src/main/java/com/tiedup/remake/events/captivity/PlayerEnslavementHandler.java new file mode 100644 index 0000000..5faf9f1 --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/captivity/PlayerEnslavementHandler.java @@ -0,0 +1,243 @@ +package com.tiedup.remake.events.captivity; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.entities.LeashProxyEntity; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.state.IPlayerLeashAccess; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.core.SettingsAccessor; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.event.entity.player.PlayerInteractEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Event handler for master-slave (enslavement) interactions. + * + *

Key Mechanisms

+ *
    + *
  • Enslavement: Right-click with Lead on tied player → enslaves them
  • + *
  • Freeing: Right-click with empty hand on your slave → frees them
  • + *
+ * + *

Leash System

+ * Uses a proxy-based leash system where a {@link LeashProxyEntity} follows the player + * and holds the leash. The player does NOT mount anything - traction is applied via push(). + * + * @see PlayerBindState#free() + * @see LeashProxyEntity + * @see IPlayerLeashAccess + */ +@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID) +public class PlayerEnslavementHandler { + + /** + * Prevent enslaved players from attacking/breaking their constraints. + * Handles Left-Click (Attack) events. + */ + @SubscribeEvent + public static void onPlayerAttackEntity( + net.minecraftforge.event.entity.player.AttackEntityEvent event + ) { + Player player = event.getEntity(); + Entity target = event.getTarget(); + + // Check if player is leashed (enslaved) via proxy system + if ( + player instanceof IPlayerLeashAccess access && + access.tiedup$isLeashed() + ) { + // Prevent breaking the LeashKnot or the LeashProxy itself + if ( + target instanceof + net.minecraft.world.entity.decoration.LeashFenceKnotEntity || + target instanceof LeashProxyEntity + ) { + event.setCanceled(true); + } + } + } + + /** + * Handle player interactions with other players for enslavement/freeing. + * + * Uses IBondageState for condition checks (isEnslavable) + * Note: Enslavement system (getEnslavedBy, free) remains PlayerBindState-specific + */ + @SubscribeEvent + public static void onPlayerInteractEntity( + PlayerInteractEvent.EntityInteract event + ) { + Player master = event.getEntity(); + Entity target = event.getTarget(); + + // 1. Prevention Logic: Slaves cannot interact with leash knots or proxies + if ( + master instanceof IPlayerLeashAccess access && + access.tiedup$isLeashed() + ) { + if ( + target instanceof + net.minecraft.world.entity.decoration.LeashFenceKnotEntity || + target instanceof LeashProxyEntity + ) { + event.setCanceled(true); + return; + } + } + + // Only handle player-to-player interactions for enslavement logic + if (!(target instanceof Player)) { + return; + } + Player slave = (Player) target; + + // Server-side only + if (master.level().isClientSide) { + return; + } + + // Only handle MAIN_HAND to avoid double processing + if (event.getHand() != net.minecraft.world.InteractionHand.MAIN_HAND) { + return; + } + + // Check if enslavement is enabled + if (!SettingsAccessor.isEnslavementEnabled(master.level().getGameRules())) { + return; + } + + // Get IBondageState for condition checks + IBondageState slaveKidnappedState = KidnappedHelper.getKidnappedState( + slave + ); + if (slaveKidnappedState == null) { + return; + } + + // Get PlayerBindState for enslavement operations (Player-only system) + PlayerBindState masterState = PlayerBindState.getInstance(master); + PlayerBindState slaveState = PlayerBindState.getInstance(slave); + + if (masterState == null || slaveState == null) { + return; + } + + ItemStack heldItem = master.getItemInHand(event.getHand()); + + // ======================================== + // Scenario 1: Enslavement with Lead + // ======================================== + if (heldItem.is(Items.LEAD)) { + // Check if target is enslavable (using IBondageState) + if (!slaveKidnappedState.isEnslavable()) { + // Exception: collar owner can capture even if not tied + boolean canCapture = false; + if (slaveKidnappedState.hasCollar()) { + ItemStack collar = slaveKidnappedState.getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemCollar collarItem) { + if ( + collarItem + .getOwners(collar) + .contains(master.getUUID()) + ) { + canCapture = true; + } + } + } + if (!canCapture) { + TiedUpMod.LOGGER.debug( + "[PlayerEnslavementHandler] {} cannot be enslaved - not tied and not collar owner", + slave.getName().getString() + ); + return; + } + } + + // Phase 17: Check if not already captured (Player-specific check) + if (slaveState.isCaptive()) { + TiedUpMod.LOGGER.debug( + "[PlayerEnslavementHandler] {} is already captured", + slave.getName().getString() + ); + return; + } + + // Attempt capture + boolean success = slaveState.getCapturedBy( + masterState.getCaptorManager() + ); + + if (success) { + heldItem.shrink(1); + TiedUpMod.LOGGER.info( + "[PlayerEnslavementHandler] {} enslaved {} (lead consumed)", + master.getName().getString(), + slave.getName().getString() + ); + event.setCanceled(true); + } else { + TiedUpMod.LOGGER.warn( + "[PlayerEnslavementHandler] Failed to enslave {} by {}", + slave.getName().getString(), + master.getName().getString() + ); + } + } + // ======================================== + // Scenario 2: Freeing with Empty Hand + // ======================================== + else if (heldItem.isEmpty()) { + // Phase 17: isSlave → isCaptive + if (!slaveState.isCaptive()) return; + + // Phase 17: getMaster → getCaptor, getSlaveHolderManager → getCaptorManager + if (slaveState.getCaptor() != masterState.getCaptorManager()) { + TiedUpMod.LOGGER.debug( + "[PlayerEnslavementHandler] {} tried to free {} but is not the captor", + master.getName().getString(), + slave.getName().getString() + ); + return; + } + + slaveState.free(); + TiedUpMod.LOGGER.info( + "[PlayerEnslavementHandler] {} freed {}", + master.getName().getString(), + slave.getName().getString() + ); + event.setCanceled(true); + } + } + + /** + * Periodic check for enslaved players. + * + * Phase 14.1.5: Remains PlayerBindState-specific (enslavement system is Player-only) + */ + @SubscribeEvent + public static void onPlayerTick(TickEvent.PlayerTickEvent event) { + if (event.phase != TickEvent.Phase.END) return; + Player player = event.player; + if (player.level().isClientSide) return; + if (player.tickCount % 20 != 0) return; + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) return; + + // Phase 17: isSlave → isCaptive, checkStillSlave → checkStillCaptive + if (state.isCaptive()) { + state.checkStillCaptive(); + } + // Phase 17: getSlaveHolderManager → getCaptorManager, cleanupInvalidSlaves → cleanupInvalidCaptives + state.getCaptorManager().cleanupInvalidCaptives(); + } +} diff --git a/src/main/java/com/tiedup/remake/events/combat/GraceEventHandler.java b/src/main/java/com/tiedup/remake/events/combat/GraceEventHandler.java new file mode 100644 index 0000000..4a9879f --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/combat/GraceEventHandler.java @@ -0,0 +1,65 @@ +package com.tiedup.remake.events.combat; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerRecord; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.event.entity.player.AttackEntityEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Event handler for grace period management. + * + * Revokes grace period when a player attacks a kidnapper. + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class GraceEventHandler { + + @SubscribeEvent + public static void onAttackEntity(AttackEntityEvent event) { + if (event.getEntity().level().isClientSide()) { + return; + } + + Entity attacker = event.getEntity(); + Entity target = event.getTarget(); + + // Check if a player is attacking a kidnapper + if ( + attacker instanceof Player player && + target instanceof EntityKidnapper + ) { + ServerLevel level = (ServerLevel) player.level(); + PrisonerManager manager = PrisonerManager.get(level); + PrisonerRecord record = manager.getRecord(player.getUUID()); + + // Check if player has grace + if (record.isProtected(level.getGameTime())) { + // Revoke grace by clearing protection expiry + record.setProtectionExpiry(0); + + player.displayClientMessage( + Component.literal( + "You attacked a kidnapper - protection lost!" + ).withStyle(ChatFormatting.RED), + true + ); + + TiedUpMod.LOGGER.info( + "[GraceEventHandler] Player {} lost grace period by attacking kidnapper {}", + player.getName().getString(), + target.getName().getString() + ); + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/events/combat/LaborAttackPunishmentHandler.java b/src/main/java/com/tiedup/remake/events/combat/LaborAttackPunishmentHandler.java new file mode 100644 index 0000000..901c57d --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/combat/LaborAttackPunishmentHandler.java @@ -0,0 +1,423 @@ +package com.tiedup.remake.events.combat; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.EntityMaid; +import com.tiedup.remake.entities.EntitySlaveTrader; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.prison.LaborRecord; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerRecord; +import com.tiedup.remake.prison.PrisonerState; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.util.KidnappedHelper; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.event.entity.player.AttackEntityEvent; +import net.minecraftforge.event.entity.player.PlayerInteractEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Punishes prisoners who attack or attempt to restrain kidnappers, traders, or maids while working. + * + * Triggers: + * - Attacking a protected entity (kidnappers, traders, maids) + * - Using rope/restraint items on a protected entity + * + * Punishments (applied immediately): + * - Shock (damage + message) + * - Debt increase (+25 emeralds) + * - Task marked as failed (no payment) + * - Equipment reclaimed + * - Immediate teleportation back to cell + * - State changed to IMPRISONED (resting) + * - Attack/interaction cancelled (no damage to target) + * + * This prevents prisoners from attacking or restraining their captors during labor tasks. + */ +@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID) +public class LaborAttackPunishmentHandler { + + /** Debt increase per attack */ + private static final int DEBT_INCREASE_PER_ATTACK = 25; + + /** Base shock damage */ + private static final float BASE_SHOCK_DAMAGE = 3.0f; + + /** + * Detect when a prisoner attacks a kidnapper/trader/maid during labor. + */ + @SubscribeEvent + public static void onPlayerAttackEntity(AttackEntityEvent event) { + Player player = event.getEntity(); + Entity target = event.getTarget(); + + // Server-side only + if (player.level().isClientSide) { + return; + } + + // Only handle ServerPlayer + if (!(player instanceof ServerPlayer serverPlayer)) { + return; + } + + // Only handle ServerLevel + if (!(player.level() instanceof ServerLevel serverLevel)) { + return; + } + + // Check if target is a kidnapper, trader, or maid + boolean isProtectedEntity = + target instanceof EntityKidnapper || + target instanceof EntitySlaveTrader || + target instanceof EntityMaid; + + if (!isProtectedEntity) { + return; + } + + // Check if player is a prisoner in WORKING state + PrisonerManager manager = PrisonerManager.get(serverLevel); + PrisonerRecord record = manager.getRecord(serverPlayer.getUUID()); + + if (record.getState() != PrisonerState.WORKING) { + return; // Not working, no punishment + } + + // PUNISH THE PRISONER + punishPrisonerForAttack(serverPlayer, serverLevel, record, target); + + // Cancel the attack + event.setCanceled(true); + } + + /** + * Detect when a prisoner tries to use rope/restraint items on kidnapper/trader/maid during labor. + */ + @SubscribeEvent + public static void onPlayerInteractEntity( + PlayerInteractEvent.EntityInteract event + ) { + Player player = event.getEntity(); + Entity target = event.getTarget(); + InteractionHand hand = event.getHand(); + + // Server-side only + if (player.level().isClientSide) { + return; + } + + // Only handle ServerPlayer + if (!(player instanceof ServerPlayer serverPlayer)) { + return; + } + + // Only handle ServerLevel + if (!(player.level() instanceof ServerLevel serverLevel)) { + return; + } + + // Check if target is a kidnapper, trader, or maid + boolean isProtectedEntity = + target instanceof EntityKidnapper || + target instanceof EntitySlaveTrader || + target instanceof EntityMaid; + + if (!isProtectedEntity) { + return; + } + + // Check if player is a prisoner in WORKING state + PrisonerManager manager = PrisonerManager.get(serverLevel); + PrisonerRecord record = manager.getRecord(serverPlayer.getUUID()); + + if (record.getState() != PrisonerState.WORKING) { + return; // Not working, no punishment + } + + // Check if player is holding a restraint item (rope, chain, etc.) + ItemStack heldItem = serverPlayer.getItemInHand(hand); + if (!(heldItem.getItem() instanceof ItemBind)) { + return; // Not a restraint item + } + + // PUNISH THE PRISONER + punishPrisonerForAttack(serverPlayer, serverLevel, record, target); + + // Cancel the interaction + event.setCanceled(true); + + TiedUpMod.LOGGER.info( + "[LaborAttackPunishmentHandler] Prisoner {} attempted to restrain {} during labor - punished", + serverPlayer.getName().getString(), + target instanceof EntityKidnapper kidnapper + ? kidnapper.getNpcName() + : target instanceof EntitySlaveTrader trader + ? trader.getNpcName() + : target instanceof EntityMaid maid + ? maid.getNpcName() + : "captor" + ); + } + + /** + * Apply punishment to prisoner for attacking protected entity. + */ + private static void punishPrisonerForAttack( + ServerPlayer prisoner, + ServerLevel level, + PrisonerRecord record, + Entity target + ) { + IRestrainable kidnappedState = KidnappedHelper.getKidnappedState( + prisoner + ); + if (kidnappedState == null) { + return; + } + + // 1. Shock the prisoner + String targetName = "your captor"; + if (target instanceof EntityKidnapper kidnapper) { + targetName = kidnapper.getNpcName(); + } else if (target instanceof EntitySlaveTrader trader) { + targetName = trader.getNpcName(); + } else if (target instanceof EntityMaid maid) { + targetName = maid.getNpcName(); + } + + String shockMessage = String.format( + "You DARE attack %s? You'll pay for this insolence!", + targetName + ); + + kidnappedState.shockKidnapped(shockMessage, BASE_SHOCK_DAMAGE); + + // 2. Increase debt + PrisonerManager manager = PrisonerManager.get(level); + com.tiedup.remake.prison.RansomRecord ransomRecord = + manager.getRansomRecord(prisoner.getUUID()); + if (ransomRecord != null) { + manager.increaseDebt(prisoner.getUUID(), DEBT_INCREASE_PER_ATTACK); + + prisoner.sendSystemMessage( + Component.literal( + String.format( + "Your debt has increased by %d emeralds for attacking %s!", + DEBT_INCREASE_PER_ATTACK, + targetName + ) + ).withStyle(ChatFormatting.RED, ChatFormatting.BOLD) + ); + } + + // 3. CRITICAL FIX: Collect task items, reclaim equipment, and confiscate everything + LaborRecord laborRecord = manager.getLaborRecord(prisoner.getUUID()); + com.tiedup.remake.labor.LaborTask task = laborRecord.getTask(); + + // Find cell via PrisonerRecord.getCellId() — CellRegistryV2 won't have the prisoner + // during WORKING state because extractFromCell() removes them from the registry. + com.tiedup.remake.cells.CellRegistryV2 cellRegistry = + com.tiedup.remake.cells.CellRegistryV2.get(level); + java.util.UUID cellId = record.getCellId(); + com.tiedup.remake.cells.CellDataV2 cell = + cellId != null ? cellRegistry.getCell(cellId) : null; + + if (task != null) { + // Collect task items (prevent player keeping valuable items like diamonds) + java.util.List collectedItems = + task.collectItems(prisoner); + TiedUpMod.LOGGER.debug( + "[LaborAttackPunishmentHandler] Collected {} task items from {} before punishment", + collectedItems.size(), + prisoner.getName().getString() + ); + + // Reclaim labor tools + java.util.List reclaimedTools = + task.reclaimEquipment(prisoner); + TiedUpMod.LOGGER.debug( + "[LaborAttackPunishmentHandler] Reclaimed {} tools from {} before punishment", + reclaimedTools.size(), + prisoner.getName().getString() + ); + + if (cell != null) { + java.util.List allItems = + new java.util.ArrayList<>(); + allItems.addAll(collectedItems); + allItems.addAll(reclaimedTools); + + if (!allItems.isEmpty()) { + depositPunishmentItems(level, cell, allItems); + } + } + } + + // Confiscate remaining contraband (anything picked up during labor) + if (cell != null && !prisoner.getInventory().isEmpty()) { + com.tiedup.remake.cells.ConfiscatedInventoryRegistry registry = + com.tiedup.remake.cells.ConfiscatedInventoryRegistry.get(level); + + java.util.List campChests = + findCampLootChests(level, cell); + if (!campChests.isEmpty()) { + registry.dumpInventoryToChest(prisoner, campChests.get(0)); + TiedUpMod.LOGGER.debug( + "[LaborAttackPunishmentHandler] Confiscated remaining contraband from {}", + prisoner.getName().getString() + ); + } + } + + laborRecord.setTaskFailed(true); + + // 4. Warning message + prisoner.sendSystemMessage( + Component.literal( + "Your task has been marked as failed. You will not be paid for your work." + ).withStyle(ChatFormatting.DARK_RED) + ); + + // 5. Return prisoner to cell immediately (punishment) + + if (cell != null) { + // Teleport to cell + net.minecraft.core.BlockPos spawnPoint = cell + .getSpawnPoint() + .above(); + com.tiedup.remake.util.teleport.Position teleportTarget = + new com.tiedup.remake.util.teleport.Position( + spawnPoint, + level.dimension() + ); + com.tiedup.remake.util.teleport.TeleportHelper.teleportEntity( + prisoner, + teleportTarget + ); + + // Reassign prisoner to cell registry (was removed during extract) + cellRegistry.assignPrisoner(cell.getId(), prisoner.getUUID()); + + // Transition to IMPRISONED state and start rest + long currentTime = level.getGameTime(); + record.setState(PrisonerState.IMPRISONED, currentTime); + laborRecord.setTask(null); // CRITICAL: Clear task to prevent reuse after punishment + laborRecord.startRest(currentTime); + laborRecord.setEscortMaidId(null); + + prisoner.sendSystemMessage( + Component.literal( + "You have been returned to your cell for your insolence!" + ).withStyle(ChatFormatting.RED, ChatFormatting.BOLD) + ); + + TiedUpMod.LOGGER.info( + "[LaborAttackPunishmentHandler] Prisoner {} attacked {} during labor - punished (shocked, +{} debt, task failed, items confiscated, returned to cell)", + prisoner.getName().getString(), + targetName, + DEBT_INCREASE_PER_ATTACK + ); + } else { + TiedUpMod.LOGGER.warn( + "[LaborAttackPunishmentHandler] Could not return {} to cell - no cell found! (cellId={}, record={})", + prisoner.getName().getString(), + cellId != null ? cellId.toString().substring(0, 8) : "null", + record + ); + } + } + + /** + * Deposit punishment items (task items + tools) in camp chests. + * Items are confiscated as punishment and stored in camp. + */ + private static void depositPunishmentItems( + ServerLevel level, + com.tiedup.remake.cells.CellDataV2 cell, + java.util.List items + ) { + if (items.isEmpty()) { + return; + } + + java.util.UUID campId = cell.getOwnerId(); + if (campId == null) { + TiedUpMod.LOGGER.warn( + "[LaborAttackPunishmentHandler] Cell has no camp owner - cannot deposit punishment items" + ); + return; + } + + java.util.List campChests = + findCampLootChests(level, cell); + + if (campChests.isEmpty()) { + // Fallback: drop at camp center + com.tiedup.remake.cells.CampOwnership campOwnership = + com.tiedup.remake.cells.CampOwnership.get(level); + com.tiedup.remake.cells.CampOwnership.CampData camp = + campOwnership.getCamp(campId); + if (camp != null && camp.getCenter() != null) { + net.minecraft.core.BlockPos center = camp.getCenter(); + TiedUpMod.LOGGER.warn( + "[LaborAttackPunishmentHandler] No LOOT chests found - dropping punishment items at camp center {}", + center.toShortString() + ); + + for (net.minecraft.world.item.ItemStack stack : items) { + if (stack.isEmpty()) continue; + net.minecraft.world.entity.item.ItemEntity itemEntity = + new net.minecraft.world.entity.item.ItemEntity( + level, + center.getX() + 0.5, + center.getY() + 1.0, + center.getZ() + 0.5, + stack + ); + level.addFreshEntity(itemEntity); + } + } + return; + } + + // Use unified inventory system for smart chest rotation + com.tiedup.remake.cells.ConfiscatedInventoryRegistry registry = + com.tiedup.remake.cells.ConfiscatedInventoryRegistry.get(level); + + int deposited = registry.depositItemsInChests(items, campChests, level); + + TiedUpMod.LOGGER.info( + "[LaborAttackPunishmentHandler] Deposited {} punishment items in {} camp LOOT chests", + deposited, + campChests.size() + ); + } + + /** + * Find all LOOT chests for a camp. + * Delegates to ItemService which handles fast-path + lazy discovery. + */ + private static java.util.List< + net.minecraft.core.BlockPos + > findCampLootChests( + ServerLevel level, + com.tiedup.remake.cells.CellDataV2 cell + ) { + java.util.UUID campId = cell.getOwnerId(); + if (campId == null) return java.util.List.of(); + + return com.tiedup.remake.prison.service.ItemService.get().findAllCampChests( + level, + campId + ); + } +} diff --git a/src/main/java/com/tiedup/remake/events/combat/MonsterTargetingHandler.java b/src/main/java/com/tiedup/remake/events/combat/MonsterTargetingHandler.java new file mode 100644 index 0000000..c8bc356 --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/combat/MonsterTargetingHandler.java @@ -0,0 +1,65 @@ +package com.tiedup.remake.events.combat; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.PlayerBindState; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.monster.Monster; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.event.entity.living.LivingChangeTargetEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Event handler to prevent monsters from targeting tied-up players. + * + * When a player is bound (has bind item), monsters will ignore them. + * This makes sense because: + * 1. Tied-up players are helpless and can't fight back + * 2. Camp kidnappers protect their captives from monsters + * 3. It reduces frustration of being killed while unable to defend + * + * The kidnapper's HuntMonstersGoal will handle killing nearby monsters instead. + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class MonsterTargetingHandler { + + /** + * Called when any living entity changes its target. + * Cancel the event if a monster tries to target a tied-up player. + */ + @SubscribeEvent + public static void onLivingChangeTarget(LivingChangeTargetEvent event) { + // Only care about monsters + if (!(event.getEntity() instanceof Monster monster)) { + return; + } + + // Check if new target is a player + LivingEntity newTarget = event.getNewTarget(); + if (!(newTarget instanceof Player player)) { + return; + } + + // Server-side only for ServerPlayer state check + if (!(player instanceof ServerPlayer serverPlayer)) { + return; + } + + // Check if player is tied up + PlayerBindState state = PlayerBindState.getInstance(serverPlayer); + if (state != null && state.isTiedUp()) { + // Cancel targeting - monster ignores tied-up player + event.setCanceled(true); + + TiedUpMod.LOGGER.debug( + "[MonsterTargetingHandler] {} ignored tied-up player {}", + monster.getName().getString(), + player.getName().getString() + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/events/interaction/DialogueTickHandler.java b/src/main/java/com/tiedup/remake/events/interaction/DialogueTickHandler.java new file mode 100644 index 0000000..efaad72 --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/interaction/DialogueTickHandler.java @@ -0,0 +1,137 @@ +package com.tiedup.remake.events.interaction; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.conversation.ConversationManager; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.conversation.PacketEndConversationS2C; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Server tick handler for dialogue and conversation system maintenance. + * + * Phase 14: Conversation System + * + * Handles: + * - Cleanup of stale/abandoned conversations + * - Distance checks for active conversations + * - Entity validity checks (NPC died, etc.) + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class DialogueTickHandler { + + /** + * How often to run cleanup (in ticks). + * 1200 ticks = 1 minute + */ + private static final int CLEANUP_INTERVAL = 1200; + + /** + * How often to check active conversations (in ticks). + * 20 ticks = 1 second + */ + private static final int CHECK_INTERVAL = 20; + + /** + * Maximum distance for conversation before auto-ending. + */ + private static final double MAX_CONVERSATION_DISTANCE = 8.0; + + /** + * Handle server tick events for dialogue system maintenance. + */ + @SubscribeEvent + public static void onServerTick(TickEvent.ServerTickEvent event) { + if (event.phase != TickEvent.Phase.END) return; + + long currentTick = event.getServer().getTickCount(); + + // Check active conversations every second + if (currentTick % CHECK_INTERVAL == 0) { + checkActiveConversations(event.getServer()); + } + + // Cleanup stale conversations every minute + if (currentTick % CLEANUP_INTERVAL == 0) { + ConversationManager.cleanupStaleConversations(); + } + } + + /** + * Check all active conversations for validity. + * Ends conversations where player/NPC is too far, dead, or disconnected. + */ + private static void checkActiveConversations( + net.minecraft.server.MinecraftServer server + ) { + List playersToEnd = new ArrayList<>(); + + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + ConversationManager.ConversationState state = + ConversationManager.getConversationState(player); + if (state == null) continue; + + UUID speakerId = state.getSpeakerEntityId(); + boolean shouldEnd = false; + int entityId = -1; + + // O(1) lookup: get the specific ServerLevel, then getEntity by UUID + ServerLevel speakerLevel = server.getLevel( + state.getSpeakerDimension() + ); + Entity speakerEntity = + speakerLevel != null ? speakerLevel.getEntity(speakerId) : null; + + if (speakerEntity == null) { + // Entity no longer exists + shouldEnd = true; + TiedUpMod.LOGGER.debug( + "[DialogueTickHandler] Ending conversation: NPC no longer exists" + ); + } else if (!speakerEntity.isAlive()) { + // Entity died + shouldEnd = true; + entityId = speakerEntity.getId(); + TiedUpMod.LOGGER.debug( + "[DialogueTickHandler] Ending conversation: NPC died" + ); + } else if ( + speakerEntity.distanceTo(player) > MAX_CONVERSATION_DISTANCE + ) { + // Player moved too far + shouldEnd = true; + entityId = speakerEntity.getId(); + TiedUpMod.LOGGER.debug( + "[DialogueTickHandler] Ending conversation: Player too far ({})", + speakerEntity.distanceTo(player) + ); + } + + if (shouldEnd) { + playersToEnd.add(player); + // Send packet to close client GUI + if (entityId != -1) { + ModNetwork.sendToPlayer( + new PacketEndConversationS2C(entityId), + player + ); + } + } + } + + // End conversations outside the iteration + for (ServerPlayer player : playersToEnd) { + ConversationManager.endConversation(player); + } + } +} diff --git a/src/main/java/com/tiedup/remake/events/interaction/KidnapperInteractionEventHandler.java b/src/main/java/com/tiedup/remake/events/interaction/KidnapperInteractionEventHandler.java new file mode 100644 index 0000000..11bdd43 --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/interaction/KidnapperInteractionEventHandler.java @@ -0,0 +1,167 @@ +package com.tiedup.remake.events.interaction; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.state.ICaptor; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.util.tasks.ItemTask; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.event.entity.player.PlayerInteractEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Event handler for player interactions with EntityKidnapper. + * + * Phase 14.3.5: Sale system interactions + * + * Handles: + * - Right-click on kidnapper selling slave to buy + * - Payment validation + * - Slave transfer to buyer + */ +@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID) +public class KidnapperInteractionEventHandler { + + /** + * Handle right-click interaction with EntityKidnapper. + */ + @SubscribeEvent + public static void onEntityInteract( + PlayerInteractEvent.EntityInteract event + ) { + // Server-side only + if (event.getLevel().isClientSide()) return; + + // Must be interacting with a kidnapper + if (!(event.getTarget() instanceof EntityKidnapper kidnapper)) return; + + // Must use main hand + if (event.getHand() != InteractionHand.MAIN_HAND) return; + + Player player = event.getEntity(); + + // Skip enslaved kidnappers - let item/entity interactions handle them + if (kidnapper.isTiedUp()) return; + + // Check if kidnapper is selling a captive + if (kidnapper.isSellingCaptive()) { + handleSalePurchase(player, kidnapper, event); + return; + } + + // Other interactions can be added here later + } + + /** + * Handle a player attempting to purchase a captive from a kidnapper. + */ + private static void handleSalePurchase( + Player player, + EntityKidnapper kidnapper, + PlayerInteractEvent.EntityInteract event + ) { + IRestrainable captive = kidnapper.getCaptive(); + if (captive == null) return; + + ItemTask price = captive.getSalePrice(); + if (price == null) return; + + ItemStack heldItem = player.getMainHandItem(); + + // Check if player is holding the required payment + if (!price.matchesItem(heldItem)) { + // Wrong item - show what's needed + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + Component.translatable( + "tiedup.sale.wrong_item", + price.toDisplayString() + ).getString() + ); + return; + } + + if (!price.isCompletedBy(heldItem)) { + // Not enough items + int have = heldItem.getCount(); + int need = price.getAmount(); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + Component.translatable( + "tiedup.sale.not_enough", + have, + need, + price.getDisplayName() + ).getString() + ); + return; + } + + // Player has the required payment! + // Consume payment + price.consumeFrom(heldItem); + + // Get player's kidnapper interface for slave transfer + PlayerBindState bindState = PlayerBindState.getInstance(player); + if (bindState == null) { + TiedUpMod.LOGGER.error( + "[KidnapperInteractionEventHandler] Player {} has no bind state!", + player.getName().getString() + ); + return; + } + + // Phase 17: getSlaveHolderManager → getCaptorManager + ICaptor buyerKidnapper = bindState.getCaptorManager(); + if (buyerKidnapper == null) { + TiedUpMod.LOGGER.error( + "[KidnapperInteractionEventHandler] Player {} has no kidnapper interface!", + player.getName().getString() + ); + return; + } + + // Complete the sale + if (kidnapper.completeSale(buyerKidnapper)) { + // Success! + SystemMessageManager.sendChatToPlayer( + player, + Component.translatable( + "tiedup.sale.success", + captive.getKidnappedName() + ).getString(), + ChatFormatting.GREEN + ); + + // Kidnapper talks about completed sale + kidnapper.talkTo(player, DialogueCategory.SALE_COMPLETE); + + TiedUpMod.LOGGER.info( + "[KidnapperInteractionEventHandler] {} purchased {} from {} for {}", + player.getName().getString(), + captive.getKidnappedName(), + kidnapper.getNpcName(), + price.toDisplayString() + ); + + event.setCanceled(true); + } else { + // Sale failed for some reason + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + Component.translatable("tiedup.sale.failed").getString() + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/events/interaction/MerchantInteractionEventHandler.java b/src/main/java/com/tiedup/remake/events/interaction/MerchantInteractionEventHandler.java new file mode 100644 index 0000000..21cdcb9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/interaction/MerchantInteractionEventHandler.java @@ -0,0 +1,92 @@ +package com.tiedup.remake.events.interaction; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityKidnapperMerchant; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.merchant.PacketOpenMerchantScreen; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.event.entity.player.PlayerInteractEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Event handler for player interactions with EntityKidnapperMerchant. + * + * Handles: + * - Right-click on merchant to open trading screen + * - Validation checks (merchant mode, no captives) + */ +@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID) +public class MerchantInteractionEventHandler { + + /** + * Handle right-click interaction with EntityKidnapperMerchant. + */ + @SubscribeEvent + public static void onEntityInteract( + PlayerInteractEvent.EntityInteract event + ) { + // Server-side only + if (event.getLevel().isClientSide()) return; + + // Must be interacting with a merchant + if ( + !(event.getTarget() instanceof EntityKidnapperMerchant merchant) + ) return; + + // Must use main hand + if (event.getHand() != InteractionHand.MAIN_HAND) return; + + // Skip enslaved merchants - allow normal NPC interactions (command wand, conversation) + if (merchant.isTiedUp()) return; + + Player player = event.getEntity(); + + // Validation 1: Merchant must be in MERCHANT mode (not hostile) + if (merchant.isHostile()) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "The merchant is too angry to trade!" + ); + event.setCanceled(true); + return; + } + + // Validation 2: Merchant must not have captives + if (merchant.hasCaptives()) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "The merchant is busy with a captive!" + ); + event.setCanceled(true); + return; + } + + // Open trading screen + if (player instanceof ServerPlayer serverPlayer) { + // Mark merchant as trading with this player + merchant.startTrading(player.getUUID()); + + ModNetwork.sendToPlayer( + new PacketOpenMerchantScreen( + merchant.getUUID(), + merchant.getTrades() + ), + serverPlayer + ); + + TiedUpMod.LOGGER.debug( + "[MerchantInteractionEventHandler] {} opened merchant screen for {}", + player.getName().getString(), + merchant.getNpcName() + ); + } + + event.setCanceled(true); + } +} diff --git a/src/main/java/com/tiedup/remake/events/lifecycle/CapabilityEventHandler.java b/src/main/java/com/tiedup/remake/events/lifecycle/CapabilityEventHandler.java new file mode 100644 index 0000000..68e562b --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/lifecycle/CapabilityEventHandler.java @@ -0,0 +1,148 @@ +package com.tiedup.remake.events.lifecycle; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import com.tiedup.remake.v2.bondage.capability.V2BondageEquipmentProvider; +import java.util.Map; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.GameRules; +import net.minecraftforge.event.AttachCapabilitiesEvent; +import net.minecraftforge.event.entity.player.PlayerEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Event handler for capability attachment and persistence. + * Handles attaching V2 bondage equipment to players and preserving it across death/respawn. + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class CapabilityEventHandler { + + private static final ResourceLocation V2_BONDAGE_EQUIPMENT_ID = + ResourceLocation.fromNamespaceAndPath( + TiedUpMod.MOD_ID, + "v2_bondage_equipment" + ); + + /** + * Attach V2 bondage equipment capability to all players. + */ + @SubscribeEvent + public static void onAttachCapabilities( + AttachCapabilitiesEvent event + ) { + if (!(event.getObject() instanceof Player)) { + return; + } + + // V2 bondage equipment capability + V2BondageEquipmentProvider v2Provider = new V2BondageEquipmentProvider(); + event.addCapability(V2_BONDAGE_EQUIPMENT_ID, v2Provider); + event.addListener(v2Provider::invalidate); + } + + /** + * Handle bondage equipment on player clone (death/respawn or dimension change). + * + *

Behavior: + *

    + *
  • Death without keepInventory: Fire onUnequipped for all items, don't copy
  • + *
  • Death with keepInventory: Preserve via serialize/deserialize round-trip
  • + *
  • Dimension change (End portal): Preserve via serialize/deserialize round-trip
  • + *
+ * + * IMPORTANT: Must call reviveCaps() on original player to access capabilities + * after the entity has been marked as removed. + */ + @SubscribeEvent + public static void onPlayerClone(PlayerEvent.Clone event) { + // Revive capabilities on old player (required after entity removal) + event.getOriginal().reviveCaps(); + + Player oldPlayer = event.getOriginal(); + Player newPlayer = event.getEntity(); + + if (event.isWasDeath()) { + boolean keepInventory = oldPlayer.level().getGameRules() + .getBoolean(GameRules.RULE_KEEPINVENTORY); + + // === V2 Death Handling === + oldPlayer + .getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT) + .ifPresent(oldV2 -> { + if (keepInventory) { + // keepInventory: serialize/deserialize round-trip + newPlayer + .getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT) + .ifPresent(newV2 -> { + CompoundTag saved = oldV2.serializeNBT(); + newV2.deserializeNBT(saved); + // Fire onEquipped for each item on the new player + for (Map.Entry entry + : newV2.getAllEquipped().entrySet()) { + ItemStack stack = entry.getValue(); + if (stack.getItem() instanceof IV2BondageItem v2Item) { + v2Item.onEquipped(stack, newPlayer); + } + } + TiedUpMod.LOGGER.debug( + "[CapabilityEventHandler] V2 preserved on death (keepInventory)" + ); + }); + } else { + // No keepInventory: fire onUnequipped for each V2 item + for (Map.Entry entry + : oldV2.getAllEquipped().entrySet()) { + ItemStack stack = entry.getValue(); + if (stack.getItem() instanceof IV2BondageItem v2Item) { + v2Item.onUnequipped(stack, oldPlayer); + } + } + TiedUpMod.LOGGER.debug( + "[CapabilityEventHandler] V2 cleared on death (no keepInventory)" + ); + } + }); + + // Invalidate old player caps to prevent memory leak + event.getOriginal().invalidateCaps(); + return; + } + + // DIMENSION CHANGE (End portal, etc.): Preserve all equipment + + // === V2 Dimension Change === + oldPlayer + .getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT) + .ifPresent(oldV2 -> { + newPlayer + .getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT) + .ifPresent(newV2 -> { + CompoundTag saved = oldV2.serializeNBT(); + newV2.deserializeNBT(saved); + // Fire onEquipped for each item on the new player + for (Map.Entry entry + : newV2.getAllEquipped().entrySet()) { + ItemStack stack = entry.getValue(); + if (stack.getItem() instanceof IV2BondageItem v2Item) { + v2Item.onEquipped(stack, newPlayer); + } + } + TiedUpMod.LOGGER.debug( + "[CapabilityEventHandler] Preserved V2 equipment on dimension change" + ); + }); + }); + + // Invalidate old player caps to prevent memory leak + event.getOriginal().invalidateCaps(); + } +} diff --git a/src/main/java/com/tiedup/remake/events/lifecycle/EntitySpawnHandler.java b/src/main/java/com/tiedup/remake/events/lifecycle/EntitySpawnHandler.java new file mode 100644 index 0000000..f8064ac --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/lifecycle/EntitySpawnHandler.java @@ -0,0 +1,118 @@ +package com.tiedup.remake.events.lifecycle; + +import com.tiedup.remake.core.SettingsAccessor; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.EntityKidnapperArcher; +import com.tiedup.remake.entities.EntityKidnapperElite; +import com.tiedup.remake.entities.EntityKidnapperMerchant; +import com.tiedup.remake.entities.EntityMaster; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.level.GameRules; +import net.minecraft.world.level.Level; +import net.minecraftforge.event.entity.living.MobSpawnEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Event handler for controlling entity spawning via gamerules. + * + * Controls both on/off spawning and spawn rates: + * - damselsSpawn + damselSpawnRate: Controls EntityDamsel + * - kidnappersSpawn + variant rates: Controls EntityKidnapper variants + * - masterSpawnRate: Controls EntityMaster + * + * Spawn rates are 0-100 percentage. For example, 50 = 50% chance to allow spawn. + * Note: Command spawns (/tiedup npc spawn) are not affected. + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class EntitySpawnHandler { + + /** + * Check if entity spawn should be allowed based on gamerules. + * + * @param event The spawn check event + */ + @SubscribeEvent + public static void onCheckSpawn(MobSpawnEvent.FinalizeSpawn event) { + LivingEntity entity = event.getEntity(); + Level level = entity.level(); + GameRules gameRules = level.getGameRules(); + RandomSource random = level.getRandom(); + + // Check Master spawn (separate entity, not related to Damsel) + if (entity instanceof EntityMaster) { + int spawnRate = SettingsAccessor.getMasterSpawnRate(gameRules); + if (!checkSpawnRate(random, spawnRate)) { + event.setSpawnCancelled(true); + return; + } + } + // Check Kidnapper spawn (includes Elite, Archer, Merchant) + else if (entity instanceof EntityKidnapper kidnapper) { + if (!SettingsAccessor.doKidnappersSpawn(gameRules)) { + event.setSpawnCancelled(true); + return; + } + + // Check variant-specific spawn rate + int spawnRate = getKidnapperVariantSpawnRate(kidnapper, gameRules); + if (!checkSpawnRate(random, spawnRate)) { + event.setSpawnCancelled(true); + return; + } + } + // Check Damsel spawn + else if (entity instanceof EntityDamsel) { + if (!SettingsAccessor.doDamselsSpawn(gameRules)) { + event.setSpawnCancelled(true); + return; + } + + // Check damsel spawn rate + int spawnRate = SettingsAccessor.getDamselSpawnRate(gameRules); + if (!checkSpawnRate(random, spawnRate)) { + event.setSpawnCancelled(true); + return; + } + } + } + + /** + * Check if spawn should be allowed based on rate (0-100). + * + * @param random Random source + * @param rate Spawn rate percentage (0-100) + * @return true if spawn should be allowed + */ + private static boolean checkSpawnRate(RandomSource random, int rate) { + if (rate <= 0) return false; + if (rate >= 100) return true; + return random.nextInt(100) < rate; + } + + /** + * Get spawn rate for a specific kidnapper variant. + */ + private static int getKidnapperVariantSpawnRate( + EntityKidnapper kidnapper, + GameRules gameRules + ) { + // Check class type to determine spawn rate + if (kidnapper instanceof EntityKidnapperArcher) { + return SettingsAccessor.getKidnapperArcherSpawnRate(gameRules); + } else if (kidnapper instanceof EntityKidnapperElite) { + return SettingsAccessor.getKidnapperEliteSpawnRate(gameRules); + } else if (kidnapper instanceof EntityKidnapperMerchant) { + return SettingsAccessor.getKidnapperMerchantSpawnRate(gameRules); + } else { + // Base kidnapper + return SettingsAccessor.getKidnapperSpawnRate(gameRules); + } + } +} diff --git a/src/main/java/com/tiedup/remake/events/lifecycle/PlayerDisconnectHandler.java b/src/main/java/com/tiedup/remake/events/lifecycle/PlayerDisconnectHandler.java new file mode 100644 index 0000000..fe500e6 --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/lifecycle/PlayerDisconnectHandler.java @@ -0,0 +1,163 @@ +package com.tiedup.remake.events.lifecycle; + +import com.mojang.logging.LogUtils; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.events.captivity.ForcedSeatingHandler; +import com.tiedup.remake.events.restriction.LaborToolProtectionHandler; +import com.tiedup.remake.events.restriction.PetPlayRestrictionHandler; +import com.tiedup.remake.network.PacketRateLimiter; +import net.minecraftforge.event.entity.player.PlayerEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; +import org.slf4j.Logger; + +/** + * Handler for player disconnect events to clean up server-side resources. + * + *

This handler ensures that resources associated with disconnected players + * are properly cleaned up to prevent memory leaks and unbounded map growth. + * + *

Phase: Server Resource Management + * + *

Cleanup includes: + *

    + *
  • Rate limiter token buckets ({@link PacketRateLimiter})
  • + *
  • Future: Session data, pending tasks, etc.
  • + *
+ */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class PlayerDisconnectHandler { + + private static final Logger LOGGER = LogUtils.getLogger(); + + /** + * Clean up resources when a player logs out. + * + *

This event fires when a player disconnects from the server, + * either by logging out normally or being kicked/timing out. + * + * @param event The player logout event + */ + @SubscribeEvent + public static void onPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) { + java.util.UUID playerId = event.getEntity().getUUID(); + + // NOTE: PlayerBindState.removeInstance() is called by PlayerLifecycleHandler (EventPriority.HIGH) + // We don't duplicate it here to avoid redundant cleanup + + // Clean up rate limiter state + PacketRateLimiter.cleanup(playerId); + + // Clean up static cooldown maps to prevent memory leaks + com.tiedup.remake.commands.SocialCommand.cleanupPlayer(playerId); + LaborToolProtectionHandler.cleanupPlayer(playerId); + + // BUG FIX: Memory leak cleanup for event handlers + // Clean up ForcedSeatingHandler maps + ForcedSeatingHandler.clearPlayer(playerId); + + // Clean up PetPlayRestrictionHandler timestamp map + PetPlayRestrictionHandler.clearPlayer(playerId); + + // Clean up minigame sessions + com.tiedup.remake.minigame.MiniGameSessionManager.getInstance().cleanupPlayer( + playerId + ); + + // Clean up pet cage state + com.tiedup.remake.v2.blocks.PetCageManager.onPlayerDisconnect(playerId); + + // Clean up pet bed state (has onPlayerDisconnect but was never wired) + com.tiedup.remake.v2.blocks.PetBedManager.onPlayerDisconnect(playerId); + + // Clean up active conversations + com.tiedup.remake.dialogue.conversation.ConversationManager.cleanupPlayer(playerId); + + // Clean up cell selection mode + com.tiedup.remake.cells.CellSelectionManager.cleanup(playerId); + + // BUG FIX: Security - Remove labor tools from disconnecting player + // This prevents players from keeping unbreakable tools by disconnecting + if ( + event.getEntity() instanceof + net.minecraft.server.level.ServerPlayer player + ) { + removeLaborTools(player); + } + + // BUG FIX: Memory leak cleanup for entities + // Clean up EntityKidnapperMerchant tradingPlayers set (O(1) reverse-lookup) + if ( + event.getEntity().level() instanceof + net.minecraft.server.level.ServerLevel serverLevel + ) { + java.util.UUID merchantUUID = + com.tiedup.remake.entities.EntityKidnapperMerchant.getMerchantForPlayer( + playerId + ); + if (merchantUUID != null) { + net.minecraft.world.entity.Entity merchantEntity = + serverLevel.getEntity(merchantUUID); + if ( + merchantEntity instanceof + com.tiedup.remake.entities.EntityKidnapperMerchant merchant + ) { + merchant.cleanupTradingPlayer(playerId); + } + } + + // Kidnapper robbery immunity: cheap per-entity Map.remove(), disconnect-only — acceptable scan + // Uses getAllEntities since there's no UUID index for this reverse lookup + for (net.minecraft.world.entity.Entity entity : serverLevel.getAllEntities()) { + if ( + entity instanceof + com.tiedup.remake.entities.EntityKidnapper kidnapper + ) { + kidnapper.clearRobbedImmunity(playerId); + } + } + } + + LOGGER.debug( + "Cleaned up server resources for player: {} ({})", + event.getEntity().getName().getString(), + playerId + ); + } + + /** + * SECURITY: Remove all labor tools from player inventory on disconnect. + * Prevents exploit where players disconnect to keep unbreakable tools. + */ + private static void removeLaborTools( + net.minecraft.server.level.ServerPlayer player + ) { + var inventory = player.getInventory(); + int removedCount = 0; + + for (int i = 0; i < inventory.getContainerSize(); i++) { + net.minecraft.world.item.ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty() && stack.hasTag()) { + net.minecraft.nbt.CompoundTag tag = stack.getTag(); + if (tag != null && tag.getBoolean("LaborTool")) { + inventory.setItem( + i, + net.minecraft.world.item.ItemStack.EMPTY + ); + removedCount++; + } + } + } + + if (removedCount > 0) { + LOGGER.info( + "[PlayerDisconnectHandler] Removed {} labor tools from {} on disconnect", + removedCount, + player.getName().getString() + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/events/lifecycle/PlayerLifecycleHandler.java b/src/main/java/com/tiedup/remake/events/lifecycle/PlayerLifecycleHandler.java new file mode 100644 index 0000000..3bdc07f --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/lifecycle/PlayerLifecycleHandler.java @@ -0,0 +1,384 @@ +package com.tiedup.remake.events.lifecycle; + +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2BondageEquipmentProvider; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.entities.ModEntities; +import com.tiedup.remake.state.IPlayerLeashAccess; +import com.tiedup.remake.state.PlayerBindState; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.decoration.LeashFenceKnotEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.event.entity.player.PlayerEvent; +import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Handles clean player lifecycle transitions. + * - Master/Slave links are cleaned up on disconnect. + * - Pole leash positions are saved for restoration on reconnect. + */ +@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID) +public class PlayerLifecycleHandler { + + /** + * Triggered when a player leaves the server. + * Cleans up enslavement links and saves pole leash state. + * + * Priority HIGH ensures this runs BEFORE other logout handlers that may read PlayerBindState. + */ + @SubscribeEvent(priority = EventPriority.HIGH) + public static void onPlayerLoggedOut( + PlayerEvent.PlayerLoggedOutEvent event + ) { + Player player = event.getEntity(); + if (player.level().isClientSide) return; + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) return; + + // Phase 17: CASE 1: This player is a Captor - free all their captives + if (state.getCaptorManager().hasCaptives()) { + TiedUpMod.LOGGER.info( + "[Lifecycle] Captor {} disconnected. Freeing all captives.", + player.getName().getString() + ); + state.getCaptorManager().freeAllCaptives(true); + } + + // CASE 2: This player is leashed + if ( + player instanceof IPlayerLeashAccess access && + access.tiedup$isLeashed() + ) { + Entity holder = access.tiedup$getLeashHolder(); + + // CASE 2a: Leashed to a pole - SAVE for restoration + if (holder instanceof LeashFenceKnotEntity fenceKnot) { + BlockPos polePos = fenceKnot.getPos(); + player + .getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT) + .ifPresent(cap -> { + cap.savePoleLeash(polePos, player.level().dimension()); + TiedUpMod.LOGGER.info( + "[Lifecycle] {} disconnected while leashed to pole at {}. Saved for restoration.", + player.getName().getString(), + polePos + ); + }); + // Detach without dropping (will restore on reconnect) + access.tiedup$detachLeash(); + } + // MEDIUM FIX: CASE 2b: Leashed to a captor (player or NPC) - SAVE for restoration + else if (state.isCaptive() && state.getCaptor() != null) { + java.util.UUID captorUUID = state + .getCaptor() + .getEntity() + .getUUID(); + player + .getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT) + .ifPresent(cap -> { + cap.saveCaptorUUID(captorUUID); + TiedUpMod.LOGGER.info( + "[Lifecycle] {} disconnected while captive to {}. Saved for restoration.", + player.getName().getString(), + captorUUID.toString().substring(0, 8) + ); + }); + + // Remove from captor's tracking list (will be re-added on reconnect) + state.getCaptor().removeCaptive(state, false); + + // Detach leash without dropping (will restore on reconnect) + access.tiedup$detachLeash(); + + // Clear captor reference (will be restored on reconnect) + state.setCaptor(null); + } + } + + // CASE 3: Pet play - handle Master entity + handleMasterOnDisconnect(player); + + // Canonical removal point — this is the ONLY place removeInstance should be called for server-side logout + PlayerBindState.removeInstance(player.getUUID(), false); + } + + /** + * Triggered when a player joins the server. + * Restores Master entity if the player was a pet. + */ + @SubscribeEvent(priority = EventPriority.LOW) + public static void onPlayerLoggedIn(PlayerEvent.PlayerLoggedInEvent event) { + Player player = event.getEntity(); + if (player.level().isClientSide) return; + + if (player instanceof ServerPlayer serverPlayer) { + // Clean up any leftover pet bed/cage speed modifiers from previous session + com.tiedup.remake.v2.blocks.PetBedManager.onPlayerLogin( + serverPlayer + ); + com.tiedup.remake.v2.blocks.PetCageManager.onPlayerLogin( + serverPlayer + ); + + // Delay to ensure player is fully loaded and in world + serverPlayer + .getServer() + .execute(() -> { + // Extra tick delay to ensure world is ready + serverPlayer + .level() + .getServer() + .execute(() -> { + handleMasterOnReconnect(serverPlayer); + }); + }); + } + } + + // ======================================== + // MASTER PERSISTENCE CONSTANTS + // ======================================== + + private static final String NBT_MASTER_DATA = "TiedUp_MasterPersistence"; + private static final String NBT_MASTER_HAS_MASTER = "HasMaster"; + private static final String NBT_MASTER_VARIANT_ID = "MasterVariantId"; + private static final String NBT_MASTER_STATE = "MasterState"; + private static final String NBT_MASTER_NAME = "MasterName"; + + // ======================================== + // MASTER DISCONNECT HANDLING + // ======================================== + + /** + * Handle Master entity when pet player disconnects. + * Saves Master data to player and removes Master entity. + * + * If Master entity is not found (in unloaded chunk), falls back to reading + * data from the collar NBT which is continuously synchronized. + */ + private static void handleMasterOnDisconnect(Player player) { + // Check if player has a Master (pet play mode) + UUID masterUUID = EntityMaster.getMasterUUID(player); + if (masterUUID == null) return; + + if (!(player.level() instanceof ServerLevel serverLevel)) return; + + // O(1) lookup by UUID instead of scanning all entities + EntityMaster master = null; + Entity masterEntity = serverLevel.getEntity(masterUUID); + if (masterEntity instanceof EntityMaster m) { + master = m; + } + + CompoundTag masterData = new CompoundTag(); + + if (master != null) { + // Master found - save from entity (most up-to-date) + masterData.putBoolean(NBT_MASTER_HAS_MASTER, true); + masterData.putString( + NBT_MASTER_VARIANT_ID, + master.getKidnapperVariantId() + ); + masterData.putString( + NBT_MASTER_STATE, + master.getStateManager().serializeState() + ); + masterData.putString(NBT_MASTER_NAME, master.getNpcName()); + + TiedUpMod.LOGGER.info( + "[Lifecycle] {} disconnected - saving Master {} (entity found)", + player.getName().getString(), + master.getNpcName() + ); + + // Despawn the Master entity + master.discard(); + } else { + // Master not found - fallback to collar data + ItemStack collar = + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getInRegion( + player, + com.tiedup.remake.v2.BodyRegionV2.NECK + ); + + if (!collar.isEmpty() && collar.hasTag()) { + CompoundTag collarTag = collar.getTag(); + if (collarTag.getBoolean("masterDataValid")) { + masterData.putBoolean(NBT_MASTER_HAS_MASTER, true); + masterData.putString( + NBT_MASTER_VARIANT_ID, + collarTag.getString("masterVariantId") + ); + masterData.putString( + NBT_MASTER_STATE, + collarTag.getString("masterState") + ); + masterData.putString( + NBT_MASTER_NAME, + collarTag.getString("masterName") + ); + + TiedUpMod.LOGGER.warn( + "[Lifecycle] {} disconnected - Master entity not found in loaded chunks, " + + "using collar data. Master may remain in unloaded chunk.", + player.getName().getString() + ); + } else { + TiedUpMod.LOGGER.error( + "[Lifecycle] {} has Master UUID but no valid collar data!", + player.getName().getString() + ); + return; + } + } else { + TiedUpMod.LOGGER.error( + "[Lifecycle] {} has Master UUID but no collar found!", + player.getName().getString() + ); + return; + } + } + + // Store in player's persistent data + CompoundTag persistentData = player.getPersistentData(); + persistentData.put(NBT_MASTER_DATA, masterData); + } + + // ======================================== + // MASTER RECONNECT HANDLING + // ======================================== + + /** + * Handle Master restoration when pet player reconnects. + * Spawns a new Master entity with saved data. + * + * First cleans up any orphaned Master entities that may remain from previous sessions + * (e.g., if Master was in unloaded chunk during disconnect). + */ + private static void handleMasterOnReconnect(ServerPlayer player) { + // Check if player has saved Master data + CompoundTag persistentData = player.getPersistentData(); + if (!persistentData.contains(NBT_MASTER_DATA)) return; + + CompoundTag masterData = persistentData.getCompound(NBT_MASTER_DATA); + if (!masterData.getBoolean(NBT_MASTER_HAS_MASTER)) return; + + // Check if player still has pet collar + if (!EntityMaster.hasPetCollar(player)) { + // Player no longer has collar - clear saved data + persistentData.remove(NBT_MASTER_DATA); + TiedUpMod.LOGGER.info( + "[Lifecycle] {} reconnected but no pet collar - clearing Master data", + player.getName().getString() + ); + return; + } + + ServerLevel level = player.serverLevel(); + + // CLEANUP: Remove orphaned Master entity that may remain from previous session + // (e.g., if Master was in unloaded chunk during disconnect) + // O(1) lookup by UUID from collar instead of scanning all entities + UUID existingMasterUUID = EntityMaster.getMasterUUID(player); + if (existingMasterUUID != null) { + Entity orphanEntity = level.getEntity(existingMasterUUID); + if ( + orphanEntity instanceof EntityMaster m && m.isPetPlayer(player) + ) { + TiedUpMod.LOGGER.warn( + "[Lifecycle] Found orphaned Master {} claiming {}, despawning before restoration", + m.getNpcName(), + player.getName().getString() + ); + m.discard(); + } + } + + // Spawn new Master entity near player + EntityMaster master = ModEntities.MASTER.get().create(level); + if (master == null) { + TiedUpMod.LOGGER.error( + "[Lifecycle] Failed to create Master entity for {}", + player.getName().getString() + ); + return; + } + + // Restore Master data + String variantId = masterData.getString(NBT_MASTER_VARIANT_ID); + String stateSerialized = masterData.getString(NBT_MASTER_STATE); + String masterName = masterData.getString(NBT_MASTER_NAME); + + if (!variantId.isEmpty()) { + // Lookup and set the variant + com.tiedup.remake.entities.KidnapperVariant variant = + master.lookupVariantById(variantId); + if (variant != null) { + master.setKidnapperVariant(variant); + } + } + + // Position Master near player + double angle = master.getRandom().nextDouble() * Math.PI * 2; + double dist = 2.0 + master.getRandom().nextDouble() * 2; + double x = player.getX() + Math.cos(angle) * dist; + double z = player.getZ() + Math.sin(angle) * dist; + + master.setPos(x, player.getY(), z); + + // Set the pet player + master.setPetPlayer(player); + + // Restore state if available + if (!stateSerialized.isEmpty()) { + master.getStateManager().deserializeState(stateSerialized); + } + + // Spawn the Master + level.addFreshEntity(master); + + // Update the player's collar with new Master UUID + updateCollarMasterUUID(player, master.getUUID()); + + TiedUpMod.LOGGER.info( + "[Lifecycle] {} reconnected - restored Master {} at ({}, {}, {})", + player.getName().getString(), + master.getNpcName(), + (int) x, + (int) player.getY(), + (int) z + ); + + // Clear saved Master data + persistentData.remove(NBT_MASTER_DATA); + } + + /** + * Update the player's collar with the new Master UUID. + */ + private static void updateCollarMasterUUID( + ServerPlayer player, + UUID masterUUID + ) { + net.minecraft.world.item.ItemStack collarStack = + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getInRegion( + player, + com.tiedup.remake.v2.BodyRegionV2.NECK + ); + + if (!collarStack.isEmpty()) { + CompoundTag tag = collarStack.getOrCreateTag(); + tag.putUUID("masterUUID", masterUUID); + } + } +} diff --git a/src/main/java/com/tiedup/remake/events/lifecycle/PlayerStateEventHandler.java b/src/main/java/com/tiedup/remake/events/lifecycle/PlayerStateEventHandler.java new file mode 100644 index 0000000..6d6bec8 --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/lifecycle/PlayerStateEventHandler.java @@ -0,0 +1,437 @@ +package com.tiedup.remake.events.lifecycle; + +import com.tiedup.remake.events.restriction.BondageItemRestrictionHandler; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2BondageEquipmentProvider; +import com.tiedup.remake.cells.CampOwnership; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.labor.LaborTask; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.labor.PacketSyncLaborProgress; +import com.tiedup.remake.network.sync.PacketSyncBindState; +import com.tiedup.remake.prison.LaborRecord; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerRecord; +import com.tiedup.remake.prison.PrisonerState; +import com.tiedup.remake.state.IPlayerLeashAccess; +import com.tiedup.remake.state.PlayerBindState; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.decoration.LeashFenceKnotEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraftforge.event.entity.living.LivingDeathEvent; +import net.minecraftforge.event.entity.player.PlayerEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Event handler for PlayerBindState lifecycle management. + * Handles player connection, disconnection, death, and respawn. + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class PlayerStateEventHandler { + + /** UUIDs of players who died while imprisoned — message sent on respawn */ + private static final Set pendingDeathEscapeMessage = + ConcurrentHashMap.newKeySet(); + + /** + * Called when a player logs in to the server. + * Initialize or reset their PlayerBindState, restore pole leash, and sync to client. + */ + @SubscribeEvent + public static void onPlayerLoggedIn(PlayerEvent.PlayerLoggedInEvent event) { + if (!(event.getEntity() instanceof ServerPlayer player)) { + return; + } + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state != null) { + state.resetNewConnection(player); + } + + // Restore pole leash if player was leashed to one when they disconnected + restorePoleLeashIfNeeded(player); + + // MEDIUM FIX: Restore captor if player was captive when they disconnected + restoreCaptorIfNeeded(player); + + // Sync V2 equipment + bind state to the logging-in player + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.sync(player); + + PacketSyncBindState statePacket = PacketSyncBindState.fromPlayer( + player + ); + if (statePacket != null) { + ModNetwork.sendToPlayer(statePacket, player); + } + + // Sync Labor HUD on login + syncLaborState(player); + } + + /** + * Restore leash to pole if player was leashed when they disconnected. + */ + private static void restorePoleLeashIfNeeded(ServerPlayer player) { + player + .getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT) + .ifPresent(cap -> { + if (!cap.wasLeashedToPole()) { + return; + } + + BlockPos polePos = cap.getSavedPolePosition(); + ResourceKey poleDimension = cap.getSavedPoleDimension(); + + if (polePos == null || poleDimension == null) { + cap.clearSavedPoleLeash(); + return; + } + + // Check if player is in the same dimension + if (!player.level().dimension().equals(poleDimension)) { + TiedUpMod.LOGGER.info( + "[Lifecycle] {} reconnected but is in different dimension. Clearing saved pole leash.", + player.getName().getString() + ); + cap.clearSavedPoleLeash(); + return; + } + + ServerLevel level = player.serverLevel(); + + // Try to find or create the LeashFenceKnotEntity at that position + LeashFenceKnotEntity fenceKnot = + LeashFenceKnotEntity.getOrCreateKnot(level, polePos); + + if (fenceKnot == null) { + TiedUpMod.LOGGER.info( + "[Lifecycle] {} reconnected but pole at {} no longer exists.", + player.getName().getString(), + polePos + ); + cap.clearSavedPoleLeash(); + return; + } + + // Attach leash to the pole + if (player instanceof IPlayerLeashAccess access) { + access.tiedup$attachLeash(fenceKnot); + TiedUpMod.LOGGER.info( + "[Lifecycle] {} reconnected. Restored leash to pole at {}.", + player.getName().getString(), + polePos + ); + } + + // Clear saved data (no longer needed) + cap.clearSavedPoleLeash(); + }); + } + + /** + * MEDIUM FIX: Restore captor if player was captive when they disconnected. + */ + private static void restoreCaptorIfNeeded(ServerPlayer player) { + player + .getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT) + .ifPresent(cap -> { + if (!cap.hasSavedCaptor()) { + return; + } + + java.util.UUID captorUUID = cap.getSavedCaptorUUID(); + if (captorUUID == null) { + cap.clearSavedCaptor(); + return; + } + + ServerLevel level = player.serverLevel(); + + // Try to find the captor entity + net.minecraft.world.entity.Entity captorEntity = + level.getEntity(captorUUID); + + if (captorEntity == null) { + TiedUpMod.LOGGER.info( + "[Lifecycle] {} reconnected but captor {} no longer exists. Clearing captivity.", + player.getName().getString(), + captorUUID.toString().substring(0, 8) + ); + cap.clearSavedCaptor(); + return; + } + + // Check if captor is still a valid ICaptor + if ( + !(captorEntity instanceof + net.minecraft.world.entity.LivingEntity livingCaptor) + ) { + TiedUpMod.LOGGER.info( + "[Lifecycle] {} reconnected but captor is not a LivingEntity. Clearing captivity.", + player.getName().getString() + ); + cap.clearSavedCaptor(); + return; + } + + // Get the ICaptor interface + com.tiedup.remake.state.ICaptor captor = null; + if ( + livingCaptor instanceof + com.tiedup.remake.state.ICaptor kidnapper + ) { + captor = kidnapper; + } else { + TiedUpMod.LOGGER.info( + "[Lifecycle] {} reconnected but captor {} does not implement ICaptor. Clearing captivity.", + player.getName().getString(), + captorUUID.toString().substring(0, 8) + ); + cap.clearSavedCaptor(); + return; + } + + // Check if player is still enslavable (still tied up) + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null || !state.isTiedUp()) { + TiedUpMod.LOGGER.info( + "[Lifecycle] {} reconnected but is no longer tied up. Clearing saved captor.", + player.getName().getString() + ); + cap.clearSavedCaptor(); + return; + } + + // Restore captivity using the PlayerCaptivity component + boolean success = state.getCapturedBy(captor); + + if (success) { + TiedUpMod.LOGGER.info( + "[Lifecycle] {} reconnected. Restored captivity to {}.", + player.getName().getString(), + captorEntity.getName().getString() + ); + } else { + TiedUpMod.LOGGER.warn( + "[Lifecycle] {} reconnected but failed to restore captivity to {}.", + player.getName().getString(), + captorEntity.getName().getString() + ); + } + + // Clear saved data (no longer needed) + cap.clearSavedCaptor(); + }); + } + + /** + * Called when a player logs out of the server. + * Clean up their PlayerBindState instance. + */ + @SubscribeEvent + public static void onPlayerLoggedOut( + PlayerEvent.PlayerLoggedOutEvent event + ) { + if (!(event.getEntity() instanceof ServerPlayer player)) { + return; + } + + // NOTE: PlayerBindState.removeInstance() is NOT called here. + // The canonical removal point is PlayerLifecycleHandler.onPlayerLoggedOut (EventPriority.HIGH). + // Calling it here at NORMAL priority would be a redundant double-removal. + + // Clean up message cooldowns to prevent memory leak + BondageItemRestrictionHandler.clearCooldowns(player.getUUID()); + + // Clean up pending death escape message flag (player disconnected between death and respawn) + pendingDeathEscapeMessage.remove(player.getUUID()); + } + + /** + * Called when a player dies. + * Handle bondage state cleanup based on game rules. + */ + @SubscribeEvent + public static void onPlayerDeath(LivingDeathEvent event) { + if (!(event.getEntity() instanceof ServerPlayer player)) { + return; + } + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state != null) { + state.onDeathKidnapped(player.level()); + } + + // Clean up captivity state - prisoner can't continue task after death + PrisonerManager manager = PrisonerManager.get(player.serverLevel()); + PrisonerRecord record = manager.getRecord(player.getUUID()); + LaborRecord laborRecord = manager.getLaborRecord(player.getUUID()); + + if (record.isImprisoned() || laborRecord.hasTask()) { + // SECURITY: Remove labor tools before death to prevent item duplication + // (Tools would otherwise drop on death) + removeLaborToolsOnDeath(player); + + // Transition to FREE state via escape (clears all data) + long currentTime = player.serverLevel().getGameTime(); + // Use centralized escape service for complete cleanup + com.tiedup.remake.prison.service.PrisonerService.get().escape( + player.serverLevel(), + player.getUUID(), + "death" + ); + + // Flag for respawn message + pendingDeathEscapeMessage.add(player.getUUID()); + + TiedUpMod.LOGGER.debug( + "[PlayerStateEventHandler] Transitioned {} to FREE state on death", + player.getName().getString() + ); + } else if (record.isProtected(player.serverLevel().getGameTime())) { + // Revoke grace period on death - player can be targeted again after respawn + // Death is considered "payment" for any remaining grace + record.setProtectionExpiry(0); + } + + // Release prisoner from any cells to prevent "ghost prisoner" blocking cells + // Without this, the cell still counts this player as prisoner, making it "full" + // and preventing new kidnappers from using it + CellRegistryV2 cellRegistry = CellRegistryV2.get(player.server); + if (cellRegistry != null) { + int released = cellRegistry.releasePrisonerFromAllCells( + player.getUUID() + ); + if (released > 0) { + TiedUpMod.LOGGER.debug( + "[PlayerStateEventHandler] Released {} from {} cell(s) on death", + player.getName().getString(), + released + ); + } + } + } + + /** + * Called when a player respawns after death. + * Handled by PlayerEvent.Clone in CapabilityEventHandler (capability copying) + * and PlayerLoggedIn (state reset). + * + * The respawn process: + * 1. PlayerEvent.Clone -> Copy capability data from old player to new player + * 2. PlayerLoggedIn -> Reset PlayerBindState with new player entity + * 3. Sync to client + */ + @SubscribeEvent + public static void onPlayerRespawn(PlayerEvent.PlayerRespawnEvent event) { + if (!(event.getEntity() instanceof ServerPlayer player)) { + return; + } + + // Get or create state for the respawned player + PlayerBindState state = PlayerBindState.getInstance(player); + if (state != null) { + state.resetNewConnection(player); + } + + // Sync V2 equipment + bind state to the respawned player + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.sync(player); + + PacketSyncBindState statePacket = PacketSyncBindState.fromPlayer( + player + ); + if (statePacket != null) { + ModNetwork.sendToPlayer(statePacket, player); + } + + // Sync Labor HUD on respawn (restores task OR clears it if executed) + syncLaborState(player); + + // Send death escape message if applicable + if (pendingDeathEscapeMessage.remove(player.getUUID())) { + player.sendSystemMessage( + net.minecraft.network.chat.Component.literal( + "You died and escaped captivity. Your items remain in the camp chest." + ).withStyle(net.minecraft.ChatFormatting.YELLOW) + ); + } + } + + /** + * Helper to sync labor state to client. + * Sends current progress if active, or a clear packet if not. + */ + private static void syncLaborState(ServerPlayer player) { + PrisonerManager manager = PrisonerManager.get(player.serverLevel()); + PrisonerRecord record = manager.getRecord(player.getUUID()); + LaborRecord laborRecord = manager.getLaborRecord(player.getUUID()); + + if (record.isImprisoned() && laborRecord.hasTask()) { + LaborTask task = laborRecord.getTask(); + ModNetwork.sendToPlayer( + new PacketSyncLaborProgress( + task.getDescription(), + task.getProgress(), + task.getQuota(), + task.getValue() + ), + player + ); + TiedUpMod.LOGGER.debug( + "[PlayerStateEventHandler] Synced labor HUD for {}: {}/{} {}", + player.getName().getString(), + task.getProgress(), + task.getQuota(), + task.getDescription() + ); + } else { + // No active captivity state - ensure HUD is cleared (important after execution/death) + ModNetwork.sendToPlayer(new PacketSyncLaborProgress(), player); + TiedUpMod.LOGGER.debug( + "[PlayerStateEventHandler] Cleared labor HUD for {}", + player.getName().getString() + ); + } + } + + /** + * SECURITY: Remove all labor tools from player inventory before death. + * Prevents tools from dropping and being recoverable. + */ + private static void removeLaborToolsOnDeath(ServerPlayer player) { + var inventory = player.getInventory(); + int removedCount = 0; + + for (int i = 0; i < inventory.getContainerSize(); i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty() && stack.hasTag()) { + CompoundTag tag = stack.getTag(); + if (tag != null && tag.getBoolean("LaborTool")) { + inventory.setItem(i, ItemStack.EMPTY); + removedCount++; + } + } + } + + if (removedCount > 0) { + TiedUpMod.LOGGER.debug( + "[PlayerStateEventHandler] Removed {} labor tools from {} before death", + removedCount, + player.getName().getString() + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/events/restriction/BondageItemRestrictionHandler.java b/src/main/java/com/tiedup/remake/events/restriction/BondageItemRestrictionHandler.java new file mode 100644 index 0000000..ab9ca70 --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/restriction/BondageItemRestrictionHandler.java @@ -0,0 +1,548 @@ +package com.tiedup.remake.events.restriction; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.items.ItemLockpick; +import com.tiedup.remake.items.base.IKnife; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.dialogue.EntityDialogueManager; +import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.SystemMessageManager.MessageCategory; +import com.tiedup.remake.util.GameConstants; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.ButtonBlock; +import net.minecraft.world.level.block.DoorBlock; +import net.minecraft.world.level.block.FenceGateBlock; +import net.minecraft.world.level.block.LeverBlock; +import net.minecraft.world.level.block.TrapDoorBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.event.entity.living.LivingEvent; +import net.minecraftforge.event.entity.living.LivingHurtEvent; +import net.minecraftforge.event.entity.living.LivingEntityUseItemEvent; +import net.minecraftforge.event.entity.player.AttackEntityEvent; +import net.minecraftforge.event.entity.player.PlayerEvent; +import net.minecraftforge.event.entity.player.PlayerInteractEvent; +import net.minecraftforge.event.level.BlockEvent; +import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Unified event handler for all bondage item restrictions. + * + * Phase 14.4+: Centralized restriction system + * Leg Binding: Separate arm/leg restrictions + * + * This handler manages restrictions based on equipped bondage items: + * + * === LEGS BOUND (hasLegsBound) === + * - No sprinting + * - No climbing ladders (can descend) + * - No elytra flying + * - Reduced swim speed (50%) + * + * === ARMS BOUND (hasArmsBound) === + * - No block breaking + * - No block placing + * - No attacking + * - No item usage + * - No block interaction (except allowed blocks) + * + * === MITTENS === + * - Additional hand restrictions + * - Allowed: buttons, levers, doors, trapdoors, fence gates (when arms bound only) + * + * === BLINDFOLDED === + * - Vision effects (handled client-side) + * + * === GAGGED === + * - Chat muffling (handled in ChatEventHandler) + * + * @see RestraintTaskTickHandler for task tick progression (untying, tying, force feeding) + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class BondageItemRestrictionHandler { + + /** Cooldown between restriction messages (in milliseconds) */ + private static final long MESSAGE_COOLDOWN_MS = 2000; // 2 seconds + + /** Swim speed multiplier when tied (from config) */ + + /** Per-player, per-category message cooldowns */ + private static final Map< + UUID, + Map + > messageCooldowns = new HashMap<>(); + + // ======================================== + // MOVEMENT RESTRICTIONS + // ======================================== + + /** + * Movement restrictions per tick (throttled to every 5 ticks). + * - Prevent sprinting when tied + * - Prevent ladder climbing when tied (can descend) + * - Reduce swim speed when tied + */ + @SubscribeEvent + public static void onPlayerTick(TickEvent.PlayerTickEvent event) { + if (event.side.isClient() || event.phase != TickEvent.Phase.END) { + return; + } + + Player player = event.player; + + // Throttle: only check every 5 ticks (0.25 seconds) - per-player timing + if (player.tickCount % 5 != 0) { + return; + } + IRestrainable state = KidnappedHelper.getKidnappedState(player); + if (state == null) return; + + // Movement restrictions only apply when LEGS are bound + if (!state.hasLegsBound()) return; + + // === SPRINT RESTRICTION === + if (player.isSprinting()) { + player.setSprinting(false); + } + + // === LADDER RESTRICTION === + // Can descend but not climb + if (player.onClimbable()) { + Vec3 motion = player.getDeltaMovement(); + if (motion.y > 0) { + player.setDeltaMovement(motion.x, 0, motion.z); + } + } + + // === SWIM SPEED RESTRICTION === + if (player.isInWater() && player.isSwimming()) { + Vec3 motion = player.getDeltaMovement(); + player.setDeltaMovement(motion.scale( + com.tiedup.remake.core.ModConfig.SERVER.tiedSwimSpeedMultiplier.get() + )); + } + } + + /** + * Prevent elytra flying when tied. + */ + @SubscribeEvent + public static void onLivingTick(LivingEvent.LivingTickEvent event) { + if (!(event.getEntity() instanceof Player player)) return; + if (player.level().isClientSide) return; + + // Cheap check FIRST: only proceed if player is flying + if (!player.isFallFlying()) return; + + IRestrainable state = KidnappedHelper.getKidnappedState(player); + if (state == null || !state.hasLegsBound()) return; + + // Elytra restriction only applies when LEGS are bound + player.stopFallFlying(); + sendRestrictionMessage(player, MessageCategory.NO_ELYTRA); + } + + // ======================================== + // INTERACTION RESTRICTIONS + // ======================================== + + /** + * Block breaking restrictions. + * - Tied: Cannot break blocks + * - Mittens: Cannot break blocks (hands covered) + */ + @SubscribeEvent(priority = EventPriority.HIGH) + public static void onBlockBreak(BlockEvent.BreakEvent event) { + Player player = event.getPlayer(); + if (player == null) return; + + IRestrainable state = KidnappedHelper.getKidnappedState(player); + if (state == null) return; + + // Block breaking requires ARMS to be free + if (state.hasArmsBound()) { + event.setCanceled(true); + sendRestrictionMessage(player, MessageCategory.CANT_BREAK_TIED); + } else if (state.hasMittens()) { + event.setCanceled(true); + sendRestrictionMessage(player, MessageCategory.CANT_BREAK_MITTENS); + } + } + + /** + * Block placing restrictions. + * - Arms bound: Cannot place blocks + * - Mittens: Cannot place blocks (hands covered) + */ + @SubscribeEvent(priority = EventPriority.HIGH) + public static void onBlockPlace(BlockEvent.EntityPlaceEvent event) { + if (!(event.getEntity() instanceof Player player)) return; + + IRestrainable state = KidnappedHelper.getKidnappedState(player); + if (state == null) return; + + // Block placing requires ARMS to be free + if (state.hasArmsBound()) { + event.setCanceled(true); + sendRestrictionMessage(player, MessageCategory.CANT_PLACE_TIED); + } else if (state.hasMittens()) { + event.setCanceled(true); + sendRestrictionMessage(player, MessageCategory.CANT_PLACE_MITTENS); + } + } + + /** + * Block interaction restrictions. + * - Arms bound: Cannot interact with most blocks + * - Mittens: Cannot interact (hands covered) + * - Exception: Buttons, levers, doors (can be pressed/opened with body when arms bound, but not with mittens) + */ + @SubscribeEvent(priority = EventPriority.HIGH) + public static void onRightClickBlock( + PlayerInteractEvent.RightClickBlock event + ) { + Player player = event.getEntity(); + + IRestrainable state = KidnappedHelper.getKidnappedState(player); + if (state == null) return; + + boolean hasArmsBound = state.hasArmsBound(); + boolean hasMittens = state.hasMittens(); + + if (!hasArmsBound && !hasMittens) return; + + // Get the block being interacted with + BlockState blockState = event.getLevel().getBlockState(event.getPos()); + + // Allow specific block interactions (can press with body/head) - only when arms bound WITHOUT mittens + // Mittens block ALL interactions since you can't use buttons with covered hands + if ( + hasArmsBound && !hasMittens && isAllowedTiedInteraction(blockState) + ) { + return; // Allow this interaction + } + + // Block all other interactions + event.setCanceled(true); + if (hasArmsBound) { + sendRestrictionMessage(player, MessageCategory.CANT_INTERACT_TIED); + } else { + sendRestrictionMessage( + player, + MessageCategory.CANT_INTERACT_MITTENS + ); + } + } + + /** + * Item usage restrictions. + * - Arms bound: Cannot use items (except knife/lockpick without mittens) + * - Mittens: Cannot use items (hands covered) + * + * v2.5: Allow knife and lockpick usage when tied (but NOT with mittens). + * This enables the player to cut/lockpick their binds while restrained. + */ + @SubscribeEvent(priority = EventPriority.HIGH) + public static void onRightClickItem( + PlayerInteractEvent.RightClickItem event + ) { + Player player = event.getEntity(); + ItemStack heldItem = event.getItemStack(); + + IRestrainable state = KidnappedHelper.getKidnappedState(player); + if (state == null) return; + + // v2.5: Allow knife and lockpick usage when tied (but NOT with mittens) + if (state.hasArmsBound() && !state.hasMittens()) { + // Allow knives - they have special cutting logic + if (heldItem.getItem() instanceof IKnife) { + return; // Don't block + } + // Allow lockpicks - they open the struggle choice screen + if (heldItem.getItem() instanceof ItemLockpick) { + return; // Don't block + } + } + + // Item usage requires ARMS to be free (except above exceptions) + if (state.hasArmsBound()) { + event.setCanceled(true); + sendRestrictionMessage(player, MessageCategory.CANT_USE_ITEM_TIED); + } else if (state.hasMittens()) { + event.setCanceled(true); + sendRestrictionMessage( + player, + MessageCategory.CANT_USE_ITEM_MITTENS + ); + } + } + + /** + * Attack restrictions. + * - Captive/slave attacking their kidnapper: Cannot attack, gets shocked + * - Arms bound: Cannot attack at all + * - Mittens only: Can punch but damage is zeroed in onLivingHurt() + */ + @SubscribeEvent(priority = EventPriority.HIGH) + public static void onAttack(AttackEntityEvent event) { + Player player = event.getEntity(); + Entity target = event.getTarget(); + + // Check if player is attacking a kidnapper + if (target instanceof EntityKidnapper kidnapper) { + // Check if the attacker is this kidnapper's captive or job worker + boolean isThisCaptive = false; + boolean isThisJobWorker = false; + + // Check if player is the current captive + IRestrainable captive = kidnapper.getCaptive(); + if (captive != null) { + UUID captiveUUID = captive.getKidnappedUniqueId(); + if ( + captiveUUID != null && captiveUUID.equals(player.getUUID()) + ) { + isThisCaptive = true; + } + } + + // Check if player is the job worker + UUID workerUUID = kidnapper.getJobWorkerUUID(); + if (workerUUID != null && workerUUID.equals(player.getUUID())) { + isThisJobWorker = true; + } + + // Only block if player is THIS kidnapper's captive or job worker + if (isThisCaptive || isThisJobWorker) { + event.setCanceled(true); + + // Kidnapper responds with dialogue + kidnapper.talkTo( + player, + DialogueCategory.ATTACK_SLAVE + ); + + // Shock the captive/worker + IRestrainable playerState = KidnappedHelper.getKidnappedState( + player + ); + if (playerState != null) { + playerState.shockKidnapped( + " (You cannot attack your master!)", + 2.0f + ); + } + + TiedUpMod.LOGGER.debug( + "[RESTRICTION] {} tried to attack master {} - shocked (captive={}, worker={})", + player.getName().getString(), + kidnapper.getName().getString(), + isThisCaptive, + isThisJobWorker + ); + return; + } + // Other players CAN attack this kidnapper - don't block + } + + IRestrainable state = KidnappedHelper.getKidnappedState(player); + if (state == null) return; + + // Arms bound: cannot attack at all + if (state.hasArmsBound()) { + event.setCanceled(true); + return; + } + + // Mittens only (not arms bound): allow punch animation + // Damage will be zeroed in onLivingHurt() + } + + /** + * Damage reduction for mittens. + * When a player with mittens (but not arms bound) attacks, damage is reduced to 0. + * The punch animation still plays, but no damage is dealt. + */ + @SubscribeEvent(priority = EventPriority.HIGHEST) + public static void onLivingHurt(LivingHurtEvent event) { + // Check if the attacker is a player with mittens + if (!(event.getSource().getEntity() instanceof Player player)) return; + + IRestrainable state = KidnappedHelper.getKidnappedState(player); + if (state == null) return; + + // Mittens only (not arms bound): zero damage + if (state.hasMittens() && !state.hasArmsBound()) { + event.setAmount(0); + } + } + + /** + * Fall damage protection for captive players on a leash. + * When being led by a kidnapper/master, the player has no control over movement + * and should not take fall damage from terrain the captor drags them through. + */ + @SubscribeEvent(priority = EventPriority.HIGHEST) + public static void onCaptiveFallDamage(LivingHurtEvent event) { + if (!(event.getEntity() instanceof Player player)) return; + if ( + !event + .getSource() + .is(net.minecraft.world.damagesource.DamageTypes.FALL) + ) return; + + IRestrainable state = KidnappedHelper.getKidnappedState(player); + if (state == null) return; + + if (state.isCaptive()) { + event.setCanceled(true); + } + } + + /** + * Notify the Master when their pet takes damage. + * This allows the Master to react (e.g., get up from human chair). + */ + @SubscribeEvent + public static void onPetHurt(LivingHurtEvent event) { + if (!(event.getEntity() instanceof ServerPlayer pet)) return; + if (event.getAmount() <= 0) return; + + // Find if this player has a nearby Master who owns them + var masters = pet.level().getEntitiesOfClass( + com.tiedup.remake.entities.EntityMaster.class, + pet.getBoundingBox().inflate(32.0), + m -> m.isAlive() && m.hasPet() + && pet.getUUID().equals(m.getStateManager().getPetPlayerUUID()) + ); + + for (var master : masters) { + master.onPetHurt(event.getSource(), event.getAmount()); + } + } + + // ========== BREAK SPEED REDUCTION ========== + + /** + * Apply break speed reduction when tied up. + * This makes mining extremely slow (10% speed) when restrained. + */ + @SubscribeEvent + public static void onCalculateSpeed(PlayerEvent.BreakSpeed event) { + Player player = event.getEntity(); + IBondageState state = KidnappedHelper.getKidnappedState(player); + + if (state != null && state.isTiedUp()) { + event.setNewSpeed( + event.getNewSpeed() * GameConstants.TIED_BREAK_SPEED_MULTIPLIER + ); + } + } + + // ========== BLOCK SELF-EATING WHEN GAGGED ========== + + /** + * Block gagged players from eating food themselves. + */ + @SubscribeEvent + public static void onGaggedPlayerEat(LivingEntityUseItemEvent.Start event) { + if (!(event.getEntity() instanceof Player player)) { + return; + } + + if (!event.getItem().getItem().isEdible()) { + return; + } + + IBondageState state = KidnappedHelper.getKidnappedState(player); + if (state != null && state.isGagged()) { + event.setCanceled(true); + player.displayClientMessage( + Component.literal("You can't eat with a gag on.").withStyle( + ChatFormatting.RED + ), + true + ); + } + } + + // ======================================== + // HELPER METHODS + // ======================================== + + /** + * Check if a block interaction should be allowed when tied. + * These blocks can be activated with body/head, not hands. + */ + private static boolean isAllowedTiedInteraction(BlockState blockState) { + // Buttons - can press with head/body + if (blockState.getBlock() instanceof ButtonBlock) return true; + + // Levers - can push with body + if (blockState.getBlock() instanceof LeverBlock) return true; + + // Doors - can push open with body + if (blockState.getBlock() instanceof DoorBlock) return true; + + // Trapdoors - debatable, but allow for now + if (blockState.getBlock() instanceof TrapDoorBlock) return true; + + // Fence gates - can push open + if (blockState.getBlock() instanceof FenceGateBlock) return true; + + return false; + } + + /** + * Send a restriction message to the player. + * Uses per-category cooldown to prevent spam. + */ + private static void sendRestrictionMessage( + Player player, + MessageCategory category + ) { + // Only send on server side + if (player.level().isClientSide) return; + + UUID playerId = player.getUUID(); + long now = System.currentTimeMillis(); + + // Get or create player's cooldown map + Map playerCooldowns = + messageCooldowns.computeIfAbsent(playerId, k -> new HashMap<>()); + + // Check cooldown for this category + Long lastSent = playerCooldowns.get(category); + if (lastSent != null && (now - lastSent) < MESSAGE_COOLDOWN_MS) { + return; // Still on cooldown + } + + // Update cooldown and send message + playerCooldowns.put(category, now); + SystemMessageManager.sendRestriction(player, category); + } + + /** + * Clean up cooldowns for a player (call when they disconnect). + */ + public static void clearCooldowns(UUID playerId) { + messageCooldowns.remove(playerId); + } +} diff --git a/src/main/java/com/tiedup/remake/events/restriction/LaborToolProtectionHandler.java b/src/main/java/com/tiedup/remake/events/restriction/LaborToolProtectionHandler.java new file mode 100644 index 0000000..e4e4e8a --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/restriction/LaborToolProtectionHandler.java @@ -0,0 +1,154 @@ +package com.tiedup.remake.events.restriction; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityLaborGuard; +import com.tiedup.remake.prison.LaborRecord; +import com.tiedup.remake.prison.PrisonerManager; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.ChestBlock; +import net.minecraft.world.level.block.ShulkerBoxBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.event.entity.item.ItemTossEvent; +import net.minecraftforge.event.entity.player.PlayerInteractEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Event handler for labor tool protection. + * + * Prevents prisoners from: + * - Dropping labor tools + * - Storing labor tools in containers + * + * Note: Death handling removed - if prisoner dies, they're no longer working anyway. + * + * This is extracted from CampLaborEventHandler after the refactoring to remove event-driven task tracking. + * Only the essential protection mechanisms remain. + */ +@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID) +public class LaborToolProtectionHandler { + + private static final Map lastDropWarningTick = new HashMap<>(); + private static final long DROP_WARNING_COOLDOWN = 60; // 3 seconds + + /** Remove player entry on disconnect to prevent memory leak. */ + public static void cleanupPlayer(java.util.UUID playerId) { + lastDropWarningTick.remove(playerId); + } + + /** + * Prevent dropping labor tools. + * Forge removes the item from inventory before firing ItemTossEvent, + * so we must restore it after canceling. + */ + @SubscribeEvent + public static void onItemToss(ItemTossEvent event) { + ItemStack stack = event.getEntity().getItem(); + if (!isLaborTool(stack)) return; + + // Cancel + restore (Forge removes from inventory before event fires) + event.setCanceled(true); + event.getPlayer().getInventory().add(stack); + + Player player = event.getPlayer(); + if (!(player instanceof ServerPlayer serverPlayer)) return; + if (!(player.level() instanceof ServerLevel level)) return; + + // Cooldown + long tick = level.getGameTime(); + Long last = lastDropWarningTick.get(player.getUUID()); + if (last != null && (tick - last) < DROP_WARNING_COOLDOWN) return; + lastDropWarningTick.put(player.getUUID(), tick); + + // Guard reaction + EntityLaborGuard guard = findGuardForPlayer(level, player.getUUID()); + if (guard != null && guard.isAlive()) { + guard.getLookControl().setLookAt(player); + guard.guardSay( + serverPlayer, + "guard.labor.drop_tool", + "Stupid! Don't drop your tools!" + ); + } else { + player.displayClientMessage( + Component.literal("You cannot drop labor tools!"), + true + ); + } + } + + /** + * Prevent storing labor tools in containers. + */ + @SubscribeEvent + public static void onRightClickBlock( + PlayerInteractEvent.RightClickBlock event + ) { + Player player = event.getEntity(); + ItemStack held = player.getMainHandItem(); + if (!isLaborTool(held)) return; + + BlockState state = event.getLevel().getBlockState(event.getPos()); + if ( + !(state.getBlock() instanceof ChestBlock) && + !(state.getBlock() instanceof ShulkerBoxBlock) + ) return; + + event.setCanceled(true); + + if ( + player instanceof ServerPlayer sp && + player.level() instanceof ServerLevel sl + ) { + // Reuse same cooldown + long tick = sl.getGameTime(); + Long last = lastDropWarningTick.get(player.getUUID()); + if (last != null && (tick - last) < DROP_WARNING_COOLDOWN) return; + lastDropWarningTick.put(player.getUUID(), tick); + + EntityLaborGuard guard = findGuardForPlayer(sl, player.getUUID()); + if (guard != null && guard.isAlive()) { + guard.getLookControl().setLookAt(player); + guard.guardSay( + sp, + "guard.labor.hide_tool", + "Don't try to hide your tools!" + ); + } else { + player.displayClientMessage( + Component.literal("You cannot store labor tools!"), + true + ); + } + } + } + + @Nullable + private static EntityLaborGuard findGuardForPlayer( + ServerLevel level, + UUID playerUUID + ) { + PrisonerManager manager = PrisonerManager.get(level); + LaborRecord labor = manager.getLaborRecord(playerUUID); + UUID guardId = labor.getGuardId(); + if (guardId == null) return null; + net.minecraft.world.entity.Entity entity = level.getEntity(guardId); + if (entity instanceof EntityLaborGuard guard) return guard; + return null; + } + + /** + * Check if an item is a labor tool (tagged as LaborTool). + */ + private static boolean isLaborTool(ItemStack stack) { + return stack.hasTag() && stack.getTag().getBoolean("LaborTool"); + } +} diff --git a/src/main/java/com/tiedup/remake/events/restriction/PetPlayRestrictionHandler.java b/src/main/java/com/tiedup/remake/events/restriction/PetPlayRestrictionHandler.java new file mode 100644 index 0000000..b864ffd --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/restriction/PetPlayRestrictionHandler.java @@ -0,0 +1,349 @@ +package com.tiedup.remake.events.restriction; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.items.ItemChokeCollar; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.v2.blocks.PetBedManager; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.BedBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.event.entity.player.PlayerInteractEvent; +import net.minecraftforge.event.entity.player.PlayerSleepInBedEvent; +import net.minecraftforge.eventbus.api.Event; +import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Event handler for pet play restrictions. + * + * When a player has a pet play collar (from EntityMaster): + * - Cannot eat food from hand (must use Bowl block) + * - Cannot sleep in normal beds (must use Pet Bed block) + * + * Placeholder blocks: + * - Bowl: Cauldron (shift+right-click with food to eat) + * - Pet Bed: White carpet (shift+right-click to sleep) + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class PetPlayRestrictionHandler { + + /** Message cooldown in milliseconds */ + private static final long MESSAGE_COOLDOWN_MS = 3000; + + /** Last message time per player */ + private static final java.util.Map lastMessageTime = + new java.util.HashMap<>(); + + // ======================================== + // EATING RESTRICTION + // ======================================== + + /** + * Prevent pet play players from eating food from hand. + * They must use a Bowl block (cauldron placeholder). + */ + @SubscribeEvent(priority = EventPriority.HIGH) + public static void onRightClickItem( + PlayerInteractEvent.RightClickItem event + ) { + Player player = event.getEntity(); + if (player.level().isClientSide) return; + + // Check for pet play collar + if (!EntityMaster.hasPetCollar(player)) return; + + ItemStack heldItem = event.getItemStack(); + + // Check if trying to eat food + if (heldItem.isEdible()) { + event.setCanceled(true); + event.setCancellationResult( + net.minecraft.world.InteractionResult.FAIL + ); + + sendThrottledMessage( + player, + "You cannot eat from your hand! Use a bowl." + ); + + TiedUpMod.LOGGER.debug( + "[PetPlayRestrictionHandler] Blocked {} from eating food directly", + player.getName().getString() + ); + } + } + + /** + * Allow eating from Bowl block (cauldron placeholder). + * Shift+right-click with food on cauldron to eat. + */ + @SubscribeEvent(priority = EventPriority.HIGH) + public static void onRightClickBlock( + PlayerInteractEvent.RightClickBlock event + ) { + Player player = event.getEntity(); + if (player.level().isClientSide) return; + + // Check for pet play collar + if (!EntityMaster.hasPetCollar(player)) return; + + BlockPos pos = event.getPos(); + BlockState state = player.level().getBlockState(pos); + ItemStack heldItem = event.getItemStack(); + + // Bowl interaction (cauldron placeholder) + if ( + state.getBlock() == Blocks.CAULDRON && + player.isShiftKeyDown() && + heldItem.isEdible() + ) { + // Allow eating from bowl + // Consume the food + if (player instanceof ServerPlayer serverPlayer) { + net.minecraft.world.food.FoodProperties food = heldItem + .getItem() + .getFoodProperties(); + if (food != null) { + serverPlayer + .getFoodData() + .eat(food.getNutrition(), food.getSaturationModifier()); + + // Shrink the item + heldItem.shrink(1); + + // Play eating sound + player + .level() + .playSound( + null, + pos, + net.minecraft.sounds.SoundEvents.GENERIC_EAT, + net.minecraft.sounds.SoundSource.PLAYERS, + 1.0f, + 1.0f + ); + + TiedUpMod.LOGGER.debug( + "[PetPlayRestrictionHandler] {} ate from bowl", + player.getName().getString() + ); + + // Cancel to prevent normal cauldron interaction + event.setCanceled(true); + event.setCancellationResult( + net.minecraft.world.InteractionResult.SUCCESS + ); + } + } + } + + // Pet bed interaction (carpet placeholder) - handled in sleep event + } + + // ======================================== + // SLEEPING RESTRICTION + // ======================================== + + /** + * Prevent pet play players from sleeping in normal beds. + * They must use a Pet Bed block (carpet placeholder). + */ + @SubscribeEvent(priority = EventPriority.HIGH) + public static void onSleepInBed(PlayerSleepInBedEvent event) { + Player player = event.getEntity(); + if (player.level().isClientSide) return; + + // Check for pet play collar + if (!EntityMaster.hasPetCollar(player)) return; + + // Block sleeping in beds + BlockState state = player.level().getBlockState(event.getPos()); + if (state.getBlock() instanceof BedBlock) { + event.setResult(Player.BedSleepingProblem.OTHER_PROBLEM); + + sendThrottledMessage( + player, + "You cannot sleep in a bed! Use your pet bed." + ); + + TiedUpMod.LOGGER.debug( + "[PetPlayRestrictionHandler] Blocked {} from sleeping in bed", + player.getName().getString() + ); + } + } + + /** + * Allow sleeping on Pet Bed block (carpet placeholder). + * Shift+right-click on white carpet to sleep. + */ + @SubscribeEvent(priority = EventPriority.NORMAL) + public static void onInteractForSleep( + PlayerInteractEvent.RightClickBlock event + ) { + Player player = event.getEntity(); + if (player.level().isClientSide) return; + + // Check for pet play collar + if (!EntityMaster.hasPetCollar(player)) return; + + BlockPos pos = event.getPos(); + BlockState state = player.level().getBlockState(pos); + + // Pet bed interaction (white carpet placeholder) + if ( + state.getBlock() == Blocks.WHITE_CARPET && player.isShiftKeyDown() + ) { + if (player instanceof ServerPlayer serverPlayer) { + // Try to sleep + Player.BedSleepingProblem problem = canSleepNow(serverPlayer); + + if (problem == null) { + // Set spawn point to pet bed location + serverPlayer.setRespawnPosition( + serverPlayer.level().dimension(), + pos, + serverPlayer.getYRot(), + false, + true + ); + + // Start sleeping (simplified - real implementation would need proper sleep mechanics) + serverPlayer.sendSystemMessage( + Component.literal( + "You curl up in your pet bed..." + ).withStyle(Style.EMPTY.withColor(0x888888)) + ); + + // Apply sleep effects (heal, skip night handled elsewhere) + serverPlayer.heal(2.0f); + + TiedUpMod.LOGGER.debug( + "[PetPlayRestrictionHandler] {} slept in pet bed", + player.getName().getString() + ); + + event.setCanceled(true); + event.setCancellationResult( + net.minecraft.world.InteractionResult.SUCCESS + ); + } else { + sendThrottledMessage( + player, + "You can only sleep at night or during thunderstorms." + ); + } + } + } + } + + /** + * Check if player can sleep now (time/weather check). + */ + private static Player.BedSleepingProblem canSleepNow(ServerPlayer player) { + // Check time of day + if (player.level().isDay() && !player.level().isThundering()) { + return Player.BedSleepingProblem.NOT_POSSIBLE_NOW; + } + return null; + } + + // ======================================== + // CHOKE COLLAR EFFECT + // ======================================== + + /** + * Apply choke collar effect on player tick. + * When choking is active, rapidly reduces air supply to cause drowning damage. + */ + @SubscribeEvent + public static void onPlayerTick(TickEvent.PlayerTickEvent event) { + // Only process on server side, at END phase + if (event.phase != TickEvent.Phase.END) return; + if (event.player.level().isClientSide) return; + if (!(event.player instanceof ServerPlayer player)) return; + + // Check pet bed sit cancellation (movement detection) + PetBedManager.tickPlayer(player); + + // Check pet cage validity + com.tiedup.remake.v2.blocks.PetCageManager.tickPlayer(player); + + // Get player's collar + PlayerBindState bindState = PlayerBindState.getInstance(player); + if (bindState == null || !bindState.hasCollar()) return; + + ItemStack collar = bindState.getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemChokeCollar chokeCollar) { + if (chokeCollar.isChoking(collar)) { + // Apply ChokeEffect (short duration, re-applied each active tick) + if ( + !player.hasEffect( + com.tiedup.remake.core.ModEffects.CHOKE.get() + ) + ) { + player.addEffect( + new MobEffectInstance( + com.tiedup.remake.core.ModEffects.CHOKE.get(), + 40, + 0, + false, + false, + false + ) + ); + } + } else { + // Remove effect if choke is deactivated + player.removeEffect( + com.tiedup.remake.core.ModEffects.CHOKE.get() + ); + } + } + } + + // ======================================== + // UTILITY + // ======================================== + + /** + * BUG FIX: Clean up player data to prevent memory leak. + * Called on player logout. + */ + public static void clearPlayer(java.util.UUID playerId) { + lastMessageTime.remove(playerId); + } + + /** + * Send a message with cooldown to prevent spam. + */ + private static void sendThrottledMessage(Player player, String message) { + long now = System.currentTimeMillis(); + Long lastTime = lastMessageTime.get(player.getUUID()); + + if (lastTime == null || now - lastTime > MESSAGE_COOLDOWN_MS) { + lastMessageTime.put(player.getUUID(), now); + player.sendSystemMessage( + Component.literal(message).withStyle( + Style.EMPTY.withColor(EntityMaster.MASTER_NAME_COLOR) + ) + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/events/restriction/RestraintTaskTickHandler.java b/src/main/java/com/tiedup/remake/events/restriction/RestraintTaskTickHandler.java new file mode 100644 index 0000000..d80a568 --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/restriction/RestraintTaskTickHandler.java @@ -0,0 +1,672 @@ +package com.tiedup.remake.events.restriction; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.minigame.StruggleSessionManager; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.personality.PacketSlaveBeingFreed; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.tasks.ForceFeedingTask; +import com.tiedup.remake.tasks.TimedInteractTask; +import com.tiedup.remake.tasks.UntyingPlayerTask; +import com.tiedup.remake.tasks.UntyingTask; +import com.tiedup.remake.util.GameConstants; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.core.SettingsAccessor; +import java.util.List; +import java.util.UUID; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.event.entity.player.PlayerInteractEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Tick handler for restraint-related tasks (untying, tying, force feeding). + * + * Manages progress-based interaction tasks that span multiple ticks: + * - Untying mechanic (empty hand right-click on tied entity) + * - Tying mechanic (tick progression) + * - Force feeding mechanic (food right-click on gagged entity) + * - Auto-shock collar checks + * - Struggle auto-stop (legacy QTE fallback) + * + * @see BondageItemRestrictionHandler for movement, interaction, and eating restrictions + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class RestraintTaskTickHandler { + + // ========== PLAYER-SPECIFIC TICK ========== + + /** + * Handle player tick event for player-specific features. + * - Auto-shock collar check (throttled to every N ticks) + */ + @SubscribeEvent + public static void onPlayerTick(TickEvent.PlayerTickEvent event) { + if (event.side.isClient() || event.phase != TickEvent.Phase.END) { + return; + } + + Player player = event.player; + PlayerBindState playerState = PlayerBindState.getInstance(player); + + // Check if struggle animation should stop + // For continuous struggle: animation is managed by MiniGameSessionManager + // Only auto-stop if NO active continuous session (legacy QTE fallback) + if (playerState != null && playerState.isStruggling()) { + // Don't auto-stop if there's an active continuous struggle session + StruggleSessionManager mgr = StruggleSessionManager.getInstance(); + if (mgr.getContinuousStruggleSession(player.getUUID()) == null) { + // Legacy behavior: stop after 80 ticks (no active continuous session) + if ( + playerState.shouldStopStruggling( + player.level().getGameTime() + ) + ) { + playerState.setStruggling(false, 0); + com.tiedup.remake.network.sync.SyncManager.syncStruggleState( + player + ); + } + } + } + + // Process untying task tick (progress-based system) + // tick() increments/decrements progress based on whether update() was called this tick + // sendProgressPackets() updates the UI for both players + if (playerState != null) { + com.tiedup.remake.tasks.UntyingTask currentUntyingTask = + playerState.getCurrentUntyingTask(); + if (currentUntyingTask != null && !currentUntyingTask.isStopped()) { + // AUTO-UPDATE: Check if player is still targeting the same entity + // This allows "hold click" behavior without needing repeated interactLivingEntity calls + if ( + currentUntyingTask instanceof + com.tiedup.remake.tasks.UntyingPlayerTask untyingPlayerTask + ) { + net.minecraft.world.entity.LivingEntity target = + untyingPlayerTask.getTargetEntity(); + if (target != null && target.isAlive()) { + // Check if player is looking at target and close enough + double distance = player.distanceTo(target); + boolean isLookingAtTarget = isPlayerLookingAtEntity( + player, + target, + 4.0 + ); + + if ( + distance <= 4.0 && + isLookingAtTarget && + player.hasLineOfSight(target) + ) { + // Player is still targeting - auto-update the task + currentUntyingTask.update(); + } + } + } + + // Process tick (increment if active, decrement if not) + currentUntyingTask.tick(); + // Send progress packets to update UI + currentUntyingTask.sendProgressPackets(); + + // Check if task stopped (completed or cancelled due to no progress) + if (currentUntyingTask.isStopped()) { + playerState.setCurrentUntyingTask(null); + TiedUpMod.LOGGER.debug( + "[RESTRAINT] {} untying task ended (tick update)", + player.getName().getString() + ); + } + } + + // Process tying task tick (same progress-based system) + com.tiedup.remake.tasks.TyingTask currentTyingTask = + playerState.getCurrentTyingTask(); + if (currentTyingTask != null && !currentTyingTask.isStopped()) { + // AUTO-UPDATE: Check if player is still targeting the same entity + // This allows "hold click" behavior without needing repeated interactLivingEntity calls + if ( + currentTyingTask instanceof + com.tiedup.remake.tasks.TyingPlayerTask tyingPlayerTask + ) { + net.minecraft.world.entity.LivingEntity target = + tyingPlayerTask.getTargetEntity(); + boolean isSelfTying = + target != null && target.equals(player); + + if (isSelfTying) { + // Self-tying: skip look-at/distance checks (player can't raycast to own hitbox) + // Progress is driven by continuous PacketSelfBondage packets from client + currentTyingTask.update(); + } else if (target != null && target.isAlive()) { + // Tying another player: check distance + line of sight + double distance = player.distanceTo(target); + boolean isLookingAtTarget = isPlayerLookingAtEntity( + player, + target, + 4.0 + ); + + if ( + distance <= 4.0 && + isLookingAtTarget && + player.hasLineOfSight(target) + ) { + currentTyingTask.update(); + } + } + } + + // Process tick (increment if active, decrement if not) + currentTyingTask.tick(); + // Send progress packets to update UI + currentTyingTask.sendProgressPackets(); + + // Check if task stopped (completed or cancelled due to no progress) + if (currentTyingTask.isStopped()) { + playerState.setCurrentTyingTask(null); + TiedUpMod.LOGGER.debug( + "[RESTRAINT] {} tying task ended (tick update)", + player.getName().getString() + ); + } + } + + // Process force feeding task tick + TimedInteractTask feedingTask = playerState.getCurrentFeedingTask(); + if (feedingTask != null && !feedingTask.isStopped()) { + LivingEntity target = feedingTask.getTargetEntity(); + if (target != null && target.isAlive()) { + double distance = player.distanceTo(target); + boolean isLookingAtTarget = isPlayerLookingAtEntity( + player, + target, + 4.0 + ); + + if ( + distance <= 4.0 && + isLookingAtTarget && + player.hasLineOfSight(target) + ) { + feedingTask.update(); + } + } + + feedingTask.tick(); + feedingTask.sendProgressPackets(); + + if (feedingTask.isStopped()) { + playerState.setCurrentFeedingTask(null); + TiedUpMod.LOGGER.debug( + "[RESTRAINT] {} feeding task ended (tick update)", + player.getName().getString() + ); + } + } + } + + // Throttle: only check every N ticks (configurable via GameConstants) - per-player timing + if (player.tickCount % GameConstants.SHOCK_COLLAR_CHECK_INTERVAL != 0) { + return; + } + + // Phase 13: Auto-shock collar logic (Player-specific feature) + if (playerState != null) { + playerState.checkAutoShockCollar(); + } + } + + // ========== UNTYING MECHANIC ========== + + /** + * Handle untying a tied entity (right-click with empty hand). + * + * Based on original PlayerKidnapActionsHandler.onUntyingTarget() (1.12.2) + * + * When a player right-clicks a tied entity (player or NPC) with an empty hand, + * starts or continues an untying task to free them. + */ + @SubscribeEvent + public static void onUntyingTarget( + PlayerInteractEvent.EntityInteract event + ) { + // Only run on server side + if (event.getLevel().isClientSide) { + return; + } + + Entity target = event.getTarget(); + Player helper = event.getEntity(); + + // Must be targeting a LivingEntity, using main hand, and have empty hand + if ( + !(target instanceof LivingEntity targetEntity) || + event.getHand() != InteractionHand.MAIN_HAND || + !helper.getMainHandItem().isEmpty() + ) { + return; + } + + // MCA villagers require Shift+click to untie (prevents conflict with MCA menu) + if ( + com.tiedup.remake.compat.mca.MCACompat.isMCALoaded() && + com.tiedup.remake.compat.mca.MCACompat.isMCAVillager( + targetEntity + ) && + !helper.isShiftKeyDown() + ) { + return; + } + + // Check if target is tied using IBondageState interface + IBondageState targetState = KidnappedHelper.getKidnappedState( + targetEntity + ); + if (targetState == null || !targetState.isTiedUp()) { + return; + } + + // ======================================== + // SECURITY: Distance and line-of-sight validation + // ======================================== + double maxUntieDistance = 4.0; // Max distance to untie (blocks) + double distance = helper.distanceTo(targetEntity); + if (distance > maxUntieDistance) { + TiedUpMod.LOGGER.warn( + "[RESTRAINT] {} tried to untie {} from too far away ({} blocks)", + helper.getName().getString(), + targetEntity.getName().getString(), + String.format("%.1f", distance) + ); + return; + } + + // Check line-of-sight (helper must be able to see target) + if (!helper.hasLineOfSight(targetEntity)) { + TiedUpMod.LOGGER.warn( + "[RESTRAINT] {} tried to untie {} without line of sight", + helper.getName().getString(), + targetEntity.getName().getString() + ); + return; + } + + // Check for Kidnapper fight back - block untying if Kidnapper is nearby + if (targetEntity instanceof com.tiedup.remake.entities.AbstractTiedUpNpc npc) { + if ( + npc.getCaptor() instanceof EntityKidnapper kidnapper && + kidnapper.isAlive() + ) { + double distanceToKidnapper = helper.distanceTo(kidnapper); + double fightBackRange = 16.0; // Kidnapper notices within 16 blocks + + if (distanceToKidnapper <= fightBackRange) { + // Trigger Kidnapper fight back by setting helper as "attacker" + // This activates KidnapperFightBackGoal which handles pursuit and attack + kidnapper.setLastAttacker(helper); + + TiedUpMod.LOGGER.debug( + "[RESTRAINT] {} tried to untie {}, {} fights back!", + helper.getName().getString(), + npc.getName().getString(), + kidnapper.getName().getString() + ); + + // Block untying - send message to player + helper.displayClientMessage( + Component.translatable( + "tiedup.message.kidnapper_guards_captive" + ), + true + ); + return; + } + } + } + + // Check for Kidnapper fight back - block untying if player is captive or job worker + if (targetEntity instanceof Player targetPlayer) { + List nearbyKidnappers = helper + .level() + .getEntitiesOfClass( + EntityKidnapper.class, + helper.getBoundingBox().inflate(16.0) + ); + + for (EntityKidnapper kidnapper : nearbyKidnappers) { + if (!kidnapper.isAlive()) continue; + + // Check if player is kidnapper's current captive (held by leash) + IBondageState captive = kidnapper.getCaptive(); + boolean isCaptive = + captive != null && captive.asLivingEntity() == targetPlayer; + + // Check if player is kidnapper's job worker + UUID workerUUID = kidnapper.getJobWorkerUUID(); + boolean isJobWorker = + workerUUID != null && + workerUUID.equals(targetPlayer.getUUID()); + + if (isCaptive || isJobWorker) { + // Trigger Kidnapper fight back + kidnapper.setLastAttacker(helper); + + TiedUpMod.LOGGER.debug( + "[RESTRAINT] {} tried to untie {} (captive={}, worker={}), {} fights back!", + helper.getName().getString(), + targetPlayer.getName().getString(), + isCaptive, + isJobWorker, + kidnapper.getNpcName() + ); + + // Block untying - send message to player + helper.displayClientMessage( + Component.translatable( + "tiedup.message.kidnapper_guards_captive" + ), + true + ); + return; + } + } + } + + // Check if helper is tied using IBondageState interface + IBondageState helperKidnappedState = KidnappedHelper.getKidnappedState( + helper + ); + if (helperKidnappedState == null || helperKidnappedState.isTiedUp()) { + return; + } + + // Get PlayerBindState for task management (helper only) + PlayerBindState helperState = PlayerBindState.getInstance(helper); + if (helperState == null) { + return; + } + + // Block untying while force feeding + TimedInteractTask activeFeedTask = helperState.getCurrentFeedingTask(); + if (activeFeedTask != null && !activeFeedTask.isStopped()) { + return; + } + + // Get untying duration (default: 10 seconds) + int untyingSeconds = getUntyingDuration(helper); + + // Phase 11: Check collar ownership for TiedUp NPCs + // Non-owners take 3x longer and trigger alert to owners + if (targetEntity instanceof com.tiedup.remake.entities.AbstractTiedUpNpc npc) { + if (!npc.isCollarOwner(helper)) { + // Non-owner: triple the untying time + untyingSeconds *= 3; + + // Alert all collar owners + alertCollarOwners(npc, helper); + + TiedUpMod.LOGGER.debug( + "[RESTRAINT] Non-owner {} trying to free {} ({}s)", + helper.getName().getString(), + npc.getNpcName(), + untyingSeconds + ); + } + } + + // Get current untying task (if any) + UntyingTask currentTask = helperState.getCurrentUntyingTask(); + + // Check if we should start a new task or continue existing one + if ( + currentTask == null || + !currentTask.isSameTarget(targetEntity) || + currentTask.isStopped() + ) { + // Create new untying task (unified for Players and NPCs) + UntyingPlayerTask newTask = new UntyingPlayerTask( + targetState, + targetEntity, + untyingSeconds, + helper.level(), + helper + ); + + // Start new task + helperState.setCurrentUntyingTask(newTask); + newTask.setUpTargetState(); // Initialize target's restraint state + newTask.start(); + currentTask = newTask; + + TiedUpMod.LOGGER.debug( + "[RESTRAINT] {} started untying {} ({} seconds)", + helper.getName().getString(), + targetEntity.getName().getString(), + untyingSeconds + ); + } else { + // Continue existing task - ensure helper is set + if (currentTask instanceof UntyingPlayerTask playerTask) { + playerTask.setHelper(helper); + } + } + + // Mark this tick as active (progress will increase in onPlayerTick) + // The tick() method in onPlayerTick handles progress increment/decrement + currentTask.update(); + } + + /** + * Check if a player is looking at a specific entity (raycast). + * + * @param player The player + * @param target The target entity + * @param maxDistance Maximum distance to check + * @return true if player is looking at the target entity + */ + private static boolean isPlayerLookingAtEntity( + Player player, + net.minecraft.world.entity.LivingEntity target, + double maxDistance + ) { + // Get player's look vector + net.minecraft.world.phys.Vec3 eyePos = player.getEyePosition(1.0F); + net.minecraft.world.phys.Vec3 lookVec = player.getLookAngle(); + net.minecraft.world.phys.Vec3 endPos = eyePos.add( + lookVec.x * maxDistance, + lookVec.y * maxDistance, + lookVec.z * maxDistance + ); + + // Check if raycast hits the target entity + net.minecraft.world.phys.AABB targetBounds = target + .getBoundingBox() + .inflate(0.3); + java.util.Optional hit = + targetBounds.clip(eyePos, endPos); + + return hit.isPresent(); + } + + /** + * Get the untying duration in seconds from GameRule. + * + * @param player The player (for accessing world/GameRules) + * @return Duration in seconds (default: 10) + */ + private static int getUntyingDuration(Player player) { + return SettingsAccessor.getUntyingPlayerTime(player.level().getGameRules()); + } + + // ========== FORCE FEEDING MECHANIC ========== + + /** + * Handle force feeding a gagged entity (right-click with food). + * + * When a player right-clicks a gagged entity (player or NPC) while holding food, + * starts or continues a force feeding task. + */ + @SubscribeEvent + public static void onForceFeedingTarget( + PlayerInteractEvent.EntityInteract event + ) { + if (event.getLevel().isClientSide) { + return; + } + + Entity target = event.getTarget(); + Player feeder = event.getEntity(); + + if ( + !(target instanceof LivingEntity targetEntity) || + event.getHand() != InteractionHand.MAIN_HAND + ) { + return; + } + + ItemStack heldItem = feeder.getMainHandItem(); + if (heldItem.isEmpty() || !heldItem.getItem().isEdible()) { + return; + } + + // Target must have IBondageState state and be gagged + IBondageState targetState = KidnappedHelper.getKidnappedState( + targetEntity + ); + if (targetState == null || !targetState.isGagged()) { + return; + } + + // Feeder must not be tied up + IBondageState feederState = KidnappedHelper.getKidnappedState(feeder); + if (feederState != null && feederState.isTiedUp()) { + return; + } + + // Distance and line-of-sight validation + double distance = feeder.distanceTo(targetEntity); + if (distance > 4.0) { + return; + } + if (!feeder.hasLineOfSight(targetEntity)) { + return; + } + + // Get feeder's PlayerBindState for task management + PlayerBindState feederBindState = PlayerBindState.getInstance(feeder); + if (feederBindState == null) { + return; + } + + // Block feeding while untying + UntyingTask activeUntieTask = feederBindState.getCurrentUntyingTask(); + if (activeUntieTask != null && !activeUntieTask.isStopped()) { + return; + } + + // Get current feeding task (if any) + TimedInteractTask currentTask = feederBindState.getCurrentFeedingTask(); + + if ( + currentTask == null || + !currentTask.isSameTarget(targetEntity) || + currentTask.isStopped() + ) { + // Create new force feeding task (5 seconds) + ForceFeedingTask newTask = new ForceFeedingTask( + targetState, + targetEntity, + 5, + feeder.level(), + feeder, + heldItem, + feeder.getInventory().selected + ); + + feederBindState.setCurrentFeedingTask(newTask); + newTask.setUpTargetState(); + newTask.start(); + currentTask = newTask; + + TiedUpMod.LOGGER.debug( + "[RESTRAINT] {} started force feeding {} (5 seconds)", + feeder.getName().getString(), + targetEntity.getName().getString() + ); + } else { + // Continue existing task - ensure feeder is set + if (currentTask instanceof ForceFeedingTask feedTask) { + feedTask.setFeeder(feeder); + } + } + + currentTask.update(); + + // Cancel to prevent mobInteract (avoids instant NPC feed) + event.setCancellationResult(InteractionResult.SUCCESS); + event.setCanceled(true); + } + + /** + * Alert all collar owners that someone is trying to free their slave. + * Phase 11: Multiplayer protection system + * + * @param slave The slave being freed + * @param liberator The player trying to free them + */ + private static void alertCollarOwners( + com.tiedup.remake.entities.AbstractTiedUpNpc slave, + Player liberator + ) { + if (!(slave.level() instanceof ServerLevel serverLevel)) return; + + ItemStack collar = slave.getEquipment(BodyRegionV2.NECK); + if ( + collar.isEmpty() || + !(collar.getItem() instanceof ItemCollar collarItem) + ) { + return; + } + + List owners = collarItem.getOwners(collar); + if (owners.isEmpty()) return; + + // Create alert packet + PacketSlaveBeingFreed alertPacket = new PacketSlaveBeingFreed( + slave.getNpcName(), + liberator.getName().getString(), + slave.blockPosition().getX(), + slave.blockPosition().getY(), + slave.blockPosition().getZ() + ); + + // Send to all online owners + for (UUID ownerUUID : owners) { + ServerPlayer owner = serverLevel + .getServer() + .getPlayerList() + .getPlayer(ownerUUID); + if (owner != null && owner != liberator) { + ModNetwork.sendToPlayer(alertPacket, owner); + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/events/system/AnvilEventHandler.java b/src/main/java/com/tiedup/remake/events/system/AnvilEventHandler.java new file mode 100644 index 0000000..d8cfa13 --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/system/AnvilEventHandler.java @@ -0,0 +1,66 @@ +package com.tiedup.remake.events.system; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.ItemPadlock; +import com.tiedup.remake.items.base.ILockable; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.event.AnvilUpdateEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Event handler for anvil-based padlock attachment. + * + * Phase 20: Padlock via Anvil + * + * Allows combining a bondage item (ILockable) with a Padlock in the anvil + * to make the item "lockable". A lockable item can then be locked with a Key. + * + * Flow: + * - Place ILockable item in left slot + * - Place Padlock in right slot + * - Output: Same item with lockable=true + * - Cost: 1 XP level, consumes 1 padlock + */ +@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID) +public class AnvilEventHandler { + + @SubscribeEvent + public static void onAnvilUpdate(AnvilUpdateEvent event) { + ItemStack left = event.getLeft(); // Bondage item + ItemStack right = event.getRight(); // Padlock + + // Skip if either slot is empty + if (left.isEmpty() || right.isEmpty()) return; + + // Right slot must be a Padlock + if (!(right.getItem() instanceof ItemPadlock)) return; + + // Left slot must be ILockable + if (!(left.getItem() instanceof ILockable lockable)) return; + + // Check if item can have a padlock attached (tape, slime, vine, web cannot) + if (!lockable.canAttachPadlock()) { + return; // Item type cannot have padlock + } + + // Item must not already have a padlock attached + if (lockable.isLockable(left)) { + return; // Already has padlock + } + + // Create result: copy of left with lockable=true + ItemStack result = left.copy(); + lockable.setLockable(result, true); + + // Set anvil output + event.setOutput(result); + event.setCost(1); // 1 XP level cost + event.setMaterialCost(1); // Consume 1 padlock + + TiedUpMod.LOGGER.debug( + "[AnvilEventHandler] Padlock attachment preview: {} + Padlock", + left.getDisplayName().getString() + ); + } +} diff --git a/src/main/java/com/tiedup/remake/events/system/BountyDeliveryHandler.java b/src/main/java/com/tiedup/remake/events/system/BountyDeliveryHandler.java new file mode 100644 index 0000000..5e08442 --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/system/BountyDeliveryHandler.java @@ -0,0 +1,147 @@ +package com.tiedup.remake.events.system; + +import com.tiedup.remake.bounty.Bounty; +import com.tiedup.remake.bounty.BountyManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.state.PlayerCaptorManager; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.core.SettingsAccessor; +import java.util.List; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.event.entity.player.PlayerEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Handles bounty delivery detection and player login events. + * + * Phase 17: Bounty System + * + * Detects when: + * - A hunter brings a captive near the bounty client + * - A player logs in (to return expired bounty rewards) + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class BountyDeliveryHandler { + + // Check every 30 ticks (1.5 seconds) - optimized for performance + private static final int CHECK_INTERVAL = 30; + private static int tickCounter = 0; + + /** + * Periodically check for bounty deliveries. + */ + @SubscribeEvent + public static void onServerTick(TickEvent.ServerTickEvent event) { + if (event.phase != TickEvent.Phase.END) return; + + tickCounter++; + if (tickCounter < CHECK_INTERVAL) return; + tickCounter = 0; + + // Check all players + for (ServerPlayer player : event + .getServer() + .getPlayerList() + .getPlayers()) { + checkBountyDelivery(player); + } + } + + /** + * Check if this player can deliver any captives to nearby bounty clients. + */ + private static void checkBountyDelivery(ServerPlayer hunter) { + // Get hunter's captor manager + PlayerBindState hunterState = PlayerBindState.getInstance(hunter); + if (hunterState == null) return; + + PlayerCaptorManager captorManager = hunterState.getCaptorManager(); + if (captorManager == null || !captorManager.hasCaptives()) return; + + // Get bounty manager + BountyManager bountyManager = BountyManager.get(hunter.serverLevel()); + List bounties = bountyManager.getBounties(hunter.serverLevel()); + if (bounties.isEmpty()) return; + + // Get delivery radius + int radius = SettingsAccessor.getBountyDeliveryRadius( + hunter.serverLevel().getGameRules() + ); + double radiusSq = (double) radius * radius; + + // Find nearby players using pre-maintained player list (faster than AABB query) + List nearbyPlayers = new java.util.ArrayList<>(); + for (Player p : hunter.level().players()) { + if ( + p != hunter && + p instanceof ServerPlayer sp && + hunter.distanceToSqr(p) <= radiusSq + ) { + nearbyPlayers.add(sp); + } + } + + // Pre-filter captives: only ServerPlayers that are tied (O(m) instead of O(n*m)) + List validCaptives = new java.util.ArrayList<>(); + for (IBondageState captive : captorManager.getCaptives()) { + LivingEntity captiveEntity = captive.asLivingEntity(); + if (captiveEntity == null) continue; + if ( + !(captiveEntity instanceof ServerPlayer captivePlayer) + ) continue; + IBondageState captiveState = KidnappedHelper.getKidnappedState( + captivePlayer + ); + if (captiveState == null || !captiveState.isTiedUp()) continue; + validCaptives.add(captivePlayer); + } + + if (validCaptives.isEmpty()) return; + + // Check each nearby player for potential delivery + for (ServerPlayer potentialClient : nearbyPlayers) { + for (ServerPlayer captivePlayer : validCaptives) { + // Captive must be near the client + if ( + captivePlayer.distanceTo(potentialClient) > radius + ) continue; + + // Try to deliver + boolean delivered = bountyManager.tryDeliverCaptive( + hunter, + potentialClient, + captivePlayer + ); + + if (delivered) { + TiedUpMod.LOGGER.info( + "[BOUNTY] Delivery detected: {} delivered {} to {}", + hunter.getName().getString(), + captivePlayer.getName().getString(), + potentialClient.getName().getString() + ); + } + } + } + } + + /** + * Handle player login - return pending bounty rewards. + */ + @SubscribeEvent + public static void onPlayerLogin(PlayerEvent.PlayerLoggedInEvent event) { + if (event.getEntity() instanceof ServerPlayer player) { + BountyManager manager = BountyManager.get(player.serverLevel()); + manager.onPlayerJoin(player); + } + } +} diff --git a/src/main/java/com/tiedup/remake/events/system/CellV2EventHandler.java b/src/main/java/com/tiedup/remake/events/system/CellV2EventHandler.java new file mode 100644 index 0000000..b70dac6 --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/system/CellV2EventHandler.java @@ -0,0 +1,449 @@ +package com.tiedup.remake.events.system; + +import com.tiedup.remake.blocks.BlockCellCore; +import com.tiedup.remake.blocks.entity.CellCoreBlockEntity; +import com.tiedup.remake.cells.*; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.event.entity.player.PlayerInteractEvent; +import net.minecraftforge.event.level.BlockEvent; +import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.registries.ForgeRegistries; + +/** + * Central event handler for Cell System V2 Phase 2. + * + * Handles: + * - Selection mode (block click capture for Set Spawn/Delivery/Disguise) + * - Door control (block prisoners/non-owners from cell doors) + * - Breach detection (wall break → BREACHED/COMPROMISED state) + * - Breach repair (block placed at breached position → repair) + * - Selection timeout/cancel (player tick) + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class CellV2EventHandler { + + // ==================== RIGHT-CLICK BLOCK ==================== + + @SubscribeEvent(priority = EventPriority.HIGH) + public static void onRightClickBlock( + PlayerInteractEvent.RightClickBlock event + ) { + if (event.getLevel().isClientSide()) return; + if (!(event.getEntity() instanceof ServerPlayer player)) return; + + UUID playerId = player.getUUID(); + BlockPos clickedPos = event.getPos(); + ServerLevel level = player.serverLevel(); + + // 1. Check selection mode first + CellSelectionManager.SelectionContext selection = + CellSelectionManager.getSelection(playerId); + if (selection != null) { + event.setCanceled(true); + handleSelectionClick(player, level, clickedPos, selection); + return; + } + + // 2. Check door control + handleDoorControl(event, player, level, clickedPos); + } + + // ==================== BLOCK BREAK ==================== + + @SubscribeEvent(priority = EventPriority.HIGH) + public static void onBlockBreak(BlockEvent.BreakEvent event) { + if (!(event.getLevel() instanceof ServerLevel level)) return; + + BlockPos pos = event.getPos(); + Player breaker = event.getPlayer(); + CellRegistryV2 registry = CellRegistryV2.get(level); + + // Check if broken block is a wall of a V2 cell + UUID cellId = registry.getCellIdAtWall(pos); + if (cellId == null) return; + + CellDataV2 cell = registry.getCell(cellId); + if (cell == null) return; + + // Prisoners cannot break the Cell Core + if (level.getBlockState(pos).getBlock() instanceof BlockCellCore) { + if (cell.hasPrisoner(breaker.getUUID())) { + event.setCanceled(true); + breaker.displayClientMessage( + Component.translatable( + "msg.tiedup.cell_core.cant_break_core" + ).withStyle(ChatFormatting.RED), + true + ); + return; + } + } + + // Record breach + registry.addBreach(cellId, pos); + + // State transitions + float breachPct = cell.getBreachPercentage(); + if (breachPct > 0.30f && cell.getState() != CellState.COMPROMISED) { + cell.setState(CellState.COMPROMISED); + TiedUpMod.LOGGER.info( + "[CellV2EventHandler] Cell {} COMPROMISED ({}% breached)", + cellId.toString().substring(0, 8), + (int) (breachPct * 100) + ); + } else if (cell.getState() == CellState.INTACT) { + cell.setState(CellState.BREACHED); + TiedUpMod.LOGGER.info( + "[CellV2EventHandler] Cell {} BREACHED at {}", + cellId.toString().substring(0, 8), + pos.toShortString() + ); + } + + // Notify owner (skip if breaker is the owner) + if (cell.getOwnerId() != null && !cell.isOwnedBy(breaker.getUUID())) { + ServerPlayer owner = level + .getServer() + .getPlayerList() + .getPlayer(cell.getOwnerId()); + if (owner != null) { + String cellName = + cell.getName() != null + ? cell.getName() + : "Cell " + cellId.toString().substring(0, 8); + SystemMessageManager.sendToPlayer( + owner, + SystemMessageManager.MessageCategory.CELL_BREACH + ); + } + } + } + + // ==================== BLOCK PLACE ==================== + + @SubscribeEvent + public static void onBlockPlace(BlockEvent.EntityPlaceEvent event) { + if (!(event.getLevel() instanceof ServerLevel level)) return; + + BlockPos pos = event.getPos(); + CellRegistryV2 registry = CellRegistryV2.get(level); + + // Check if placed position is a breached wall + UUID cellId = registry.getCellIdAtBreach(pos); + if (cellId == null) return; + + CellDataV2 cell = registry.getCell(cellId); + if (cell == null) return; + + // Only repair if the placed block is solid + BlockState placedState = event.getPlacedBlock(); + if (!placedState.isSolid()) return; + + registry.repairBreach(cellId, pos); + + TiedUpMod.LOGGER.debug( + "[CellV2EventHandler] Breach repaired at {} in cell {}", + pos.toShortString(), + cellId.toString().substring(0, 8) + ); + + // Check if all breaches are repaired + if ( + cell.getBreachedPositions().isEmpty() && + cell.getState() == CellState.BREACHED + ) { + cell.setState(CellState.INTACT); + + // Notify owner + if (cell.getOwnerId() != null) { + ServerPlayer owner = level + .getServer() + .getPlayerList() + .getPlayer(cell.getOwnerId()); + if (owner != null) { + owner.displayClientMessage( + Component.translatable( + "msg.tiedup.cell_core.breach_repaired" + ).withStyle(ChatFormatting.GREEN), + true + ); + } + } + + TiedUpMod.LOGGER.info( + "[CellV2EventHandler] Cell {} fully repaired → INTACT", + cellId.toString().substring(0, 8) + ); + } + } + + // ==================== PLAYER TICK ==================== + + @SubscribeEvent + public static void onPlayerTick(TickEvent.PlayerTickEvent event) { + if (event.phase != TickEvent.Phase.END) return; + if (!(event.player instanceof ServerPlayer player)) return; + + UUID playerId = player.getUUID(); + if (!CellSelectionManager.isInSelectionMode(playerId)) return; + + // Cancel on sneak + if (player.isShiftKeyDown()) { + CellSelectionManager.clearSelection(playerId); + player.displayClientMessage( + Component.translatable( + "msg.tiedup.cell_core.selection.cancelled" + ).withStyle(ChatFormatting.YELLOW), + true + ); + return; + } + + // Cancel on timeout or distance + if ( + CellSelectionManager.shouldCancel(playerId, player.blockPosition()) + ) { + CellSelectionManager.clearSelection(playerId); + player.displayClientMessage( + Component.translatable( + "msg.tiedup.cell_core.selection.cancelled" + ).withStyle(ChatFormatting.YELLOW), + true + ); + } + } + + // ==================== SELECTION HANDLING ==================== + + private static void handleSelectionClick( + ServerPlayer player, + ServerLevel level, + BlockPos clickedPos, + CellSelectionManager.SelectionContext selection + ) { + CellRegistryV2 registry = CellRegistryV2.get(level); + CellDataV2 cell = registry.getCell(selection.cellId); + if (cell == null) { + CellSelectionManager.clearSelection(player.getUUID()); + return; + } + + switch (selection.mode) { + case SET_SPAWN -> handleSetSpawn( + player, + level, + clickedPos, + cell, + selection + ); + case SET_DELIVERY -> handleSetDelivery( + player, + level, + clickedPos, + cell, + selection + ); + case SET_DISGUISE -> handleSetDisguise( + player, + level, + clickedPos, + cell, + selection + ); + } + } + + private static void handleSetSpawn( + ServerPlayer player, + ServerLevel level, + BlockPos clickedPos, + CellDataV2 cell, + CellSelectionManager.SelectionContext selection + ) { + // Spawn must be inside the cell or on a wall block (e.g. floor) + boolean isInterior = cell.isContainedInCell(clickedPos); + boolean isWall = cell.isWallBlock(clickedPos); + + if (!isInterior && !isWall) { + player.displayClientMessage( + Component.translatable( + "msg.tiedup.cell_core.not_inside_cell" + ).withStyle(ChatFormatting.RED), + true + ); + return; + } + + // If clicked on a wall (floor/ceiling/wall), spawn above it + BlockPos actualSpawn = isWall ? clickedPos.above() : clickedPos; + + // Verify the actual spawn position is inside the cell + if (isWall && !cell.isContainedInCell(actualSpawn)) { + player.displayClientMessage( + Component.translatable( + "msg.tiedup.cell_core.not_inside_cell" + ).withStyle(ChatFormatting.RED), + true + ); + return; + } + + // Update spawn in both Core BE and CellDataV2 + BlockEntity be = level.getBlockEntity(selection.corePos); + if (be instanceof CellCoreBlockEntity core) { + core.setSpawnPoint(actualSpawn); + } + cell.setSpawnPoint(actualSpawn); + + CellSelectionManager.clearSelection(player.getUUID()); + player.displayClientMessage( + Component.translatable("msg.tiedup.cell_core.spawn_set").withStyle( + ChatFormatting.GREEN + ), + true + ); + } + + private static void handleSetDelivery( + ServerPlayer player, + ServerLevel level, + BlockPos clickedPos, + CellDataV2 cell, + CellSelectionManager.SelectionContext selection + ) { + // Delivery must be outside the cell + if (cell.isContainedInCell(clickedPos)) { + player.displayClientMessage( + Component.translatable( + "msg.tiedup.cell_core.must_be_outside" + ).withStyle(ChatFormatting.RED), + true + ); + return; + } + + BlockEntity be = level.getBlockEntity(selection.corePos); + if (be instanceof CellCoreBlockEntity core) { + core.setDeliveryPoint(clickedPos); + } + cell.setDeliveryPoint(clickedPos); + + CellSelectionManager.clearSelection(player.getUUID()); + player.displayClientMessage( + Component.translatable( + "msg.tiedup.cell_core.delivery_set" + ).withStyle(ChatFormatting.GREEN), + true + ); + } + + private static void handleSetDisguise( + ServerPlayer player, + ServerLevel level, + BlockPos clickedPos, + CellDataV2 cell, + CellSelectionManager.SelectionContext selection + ) { + BlockState clickedState = level.getBlockState(clickedPos); + + // Must be a solid block + if (!clickedState.isSolid()) { + player.displayClientMessage( + Component.translatable( + "msg.tiedup.cell_core.must_be_solid" + ).withStyle(ChatFormatting.RED), + true + ); + return; + } + + BlockEntity be = level.getBlockEntity(selection.corePos); + if (be instanceof CellCoreBlockEntity core) { + core.setDisguiseState(clickedState); + } + + CellSelectionManager.clearSelection(player.getUUID()); + String blockName = clickedState.getBlock().getName().getString(); + player.displayClientMessage( + Component.translatable( + "msg.tiedup.cell_core.disguise_set", + blockName + ).withStyle(ChatFormatting.GREEN), + true + ); + } + + // ==================== DOOR CONTROL ==================== + + private static void handleDoorControl( + PlayerInteractEvent.RightClickBlock event, + ServerPlayer player, + ServerLevel level, + BlockPos clickedPos + ) { + CellRegistryV2 registry = CellRegistryV2.get(level); + + // Check if the clicked position is a cell door (doors are in wall set) + // or an interior linked redstone (buttons/levers) + CellDataV2 cellByWall = registry.getCellByWall(clickedPos); + CellDataV2 cellByInterior = registry.getCellContaining(clickedPos); + + CellDataV2 cell = null; + boolean isDoor = false; + boolean isRedstone = false; + + if (cellByWall != null) { + // Check if it's in the cell's doors list + if (cellByWall.getDoors().contains(clickedPos)) { + cell = cellByWall; + isDoor = true; + } + // Check linked redstone in walls + if (cellByWall.getLinkedRedstone().contains(clickedPos)) { + cell = cellByWall; + isRedstone = true; + } + } + + if (cellByInterior != null && !isDoor && !isRedstone) { + // Check linked redstone in interior + if (cellByInterior.getLinkedRedstone().contains(clickedPos)) { + cell = cellByInterior; + isRedstone = true; + } + } + + if (cell == null || (!isDoor && !isRedstone)) return; + + // Owner, camp cell, or OP can always interact + if (cell.canPlayerManage(player.getUUID(), player.hasPermissions(2))) { + return; + } + + // Prisoner or non-owner → block interaction + event.setCanceled(true); + player.displayClientMessage( + Component.translatable( + "msg.tiedup.cell_core.door_locked" + ).withStyle(ChatFormatting.RED), + true + ); + } +} diff --git a/src/main/java/com/tiedup/remake/events/system/ChatEventHandler.java b/src/main/java/com/tiedup/remake/events/system/ChatEventHandler.java new file mode 100644 index 0000000..989e205 --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/system/ChatEventHandler.java @@ -0,0 +1,198 @@ +package com.tiedup.remake.events.system; + +import com.tiedup.remake.core.SettingsAccessor; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.GagTalkManager; +import com.tiedup.remake.items.base.ItemGag; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.GagMaterial; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.util.TiedUpUtils; +import java.util.List; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.event.CommandEvent; +import net.minecraftforge.event.ServerChatEvent; +import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * ChatEventHandler - Intercepts chat messages to apply gag effects. + * Evolution: Implements proximity-based chat for gagged players. + * + * Phase 14.1.5: Refactored to use IBondageState interface + * + * Security fix: Now blocks communication commands (/msg, /tell, etc.) when gagged + * to prevent gag bypass exploit + */ +@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID) +public class ChatEventHandler { + + /** List of communication commands that should be blocked when gagged */ + private static final String[] BLOCKED_COMMANDS = { + "msg", + "tell", + "w", + "whisper", + "r", + "reply", + "me", + }; + + @SubscribeEvent + public static void onPlayerChat(ServerChatEvent event) { + ServerPlayer player = event.getPlayer(); + IBondageState state = KidnappedHelper.getKidnappedState(player); + + if (state != null && state.isGagged()) { + ItemStack gagStack = V2EquipmentHelper.getInRegion( + player, + BodyRegionV2.MOUTH + ); + + if ( + !gagStack.isEmpty() && + gagStack.getItem() instanceof ItemGag gagItem + ) { + String originalMessage = event.getRawText(); + GagMaterial material = gagItem.getGagMaterial(); + + // 1. Process the message through our GagTalkManager V2 + Component muffledMessage = GagTalkManager.processGagMessage( + state, + gagStack, + originalMessage + ); + + // 2. Proximity Chat Logic + boolean useProximity = SettingsAccessor.isGagTalkProximityEnabled( + player.level().getGameRules()); + + if (useProximity) { + // Cancel global and send to nearby + event.setCanceled(true); + + Component finalChat = Component.literal("<") + .append(player.getDisplayName()) + .append("> ") + .append(muffledMessage); + + double range = material.getTalkRange(); + + // Phase 14.2: Use TiedUpUtils for proximity and earplugs filtering + List nearbyPlayers = + TiedUpUtils.getPlayersAround( + player.level(), + player.blockPosition(), + range + ); + + int listeners = 0; + for (ServerPlayer other : nearbyPlayers) { + // Check if receiver has earplugs (they can't hear) + IBondageState receiverState = + KidnappedHelper.getKidnappedState(other); + if ( + receiverState != null && receiverState.hasEarplugs() + ) { + // Can't hear - skip this player + continue; + } + + other.sendSystemMessage(finalChat); + if (other != player) listeners++; + } + + if (listeners == 0) { + player.displayClientMessage( + Component.translatable( + "chat.tiedup.gag.no_one_heard" + ).withStyle( + net.minecraft.ChatFormatting.ITALIC, + net.minecraft.ChatFormatting.GRAY + ), + true + ); + } + } else { + // Just replace message but keep it global + event.setMessage(muffledMessage); + } + + TiedUpMod.LOGGER.debug( + "[Chat] {} muffled message processed (Proximity: {})", + player.getName().getString(), + useProximity + ); + } + } + } + + /** + * Intercept commands to prevent gagged players from using communication commands. + * Blocks /msg, /tell, /w, /whisper, /r, /reply, /me when player is gagged. + * + * Security fix: Prevents gag bypass exploit via private messages + */ + @SubscribeEvent(priority = EventPriority.HIGHEST) + public static void onCommand(CommandEvent event) { + // Only check if sender is a ServerPlayer + if ( + !(event + .getParseResults() + .getContext() + .getSource() + .getEntity() instanceof + ServerPlayer player) + ) { + return; + } + + // Check if player is gagged + IBondageState state = KidnappedHelper.getKidnappedState(player); + if (state == null || !state.isGagged()) { + return; // Not gagged, allow all commands + } + + // Get the command name (first part of the command string) + String commandInput = event.getParseResults().getReader().getString(); + if (commandInput.isEmpty()) { + return; + } + + // Remove leading slash if present + String commandName = commandInput.startsWith("/") + ? commandInput.substring(1) + : commandInput; + // Get only the first word (command name) + commandName = commandName.split(" ")[0].toLowerCase(); + + // Check if this is a blocked communication command + for (String blockedCmd : BLOCKED_COMMANDS) { + if (commandName.equals(blockedCmd)) { + // Block the command + event.setCanceled(true); + + // Send muffled message to player + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "Mmmph! You can't use that command while gagged!" + ); + + TiedUpMod.LOGGER.debug( + "[Chat] Blocked command '{}' from gagged player {}", + commandName, + player.getName().getString() + ); + + return; + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/events/system/MiniGameTickHandler.java b/src/main/java/com/tiedup/remake/events/system/MiniGameTickHandler.java new file mode 100644 index 0000000..8ee2e00 --- /dev/null +++ b/src/main/java/com/tiedup/remake/events/system/MiniGameTickHandler.java @@ -0,0 +1,39 @@ +package com.tiedup.remake.events.system; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.minigame.LockpickSessionManager; +import com.tiedup.remake.minigame.StruggleSessionManager; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Server tick handler for mini-game session management. + * + * Handles: + * - Continuous struggle session ticking (direction changes, resistance updates, shock checks) + * - Session cleanup for expired/disconnected sessions + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class MiniGameTickHandler { + + /** + * Tick continuous struggle sessions every server tick. + */ + @SubscribeEvent + public static void onServerTick(TickEvent.ServerTickEvent event) { + if (event.phase != TickEvent.Phase.END) return; + + long currentTick = event.getServer().getTickCount(); + + // Tick continuous struggle sessions every tick + StruggleSessionManager.getInstance().tickContinuousSessions(event.getServer(), currentTick); + + // Cleanup expired sessions periodically + StruggleSessionManager.getInstance().tickCleanup(currentTick); + LockpickSessionManager.getInstance().tickCleanup(currentTick); + } +} diff --git a/src/main/java/com/tiedup/remake/items/GenericBind.java b/src/main/java/com/tiedup/remake/items/GenericBind.java new file mode 100644 index 0000000..360658e --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/GenericBind.java @@ -0,0 +1,68 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.items.base.BindVariant; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.items.base.PoseType; +import net.minecraft.world.item.Item; + +/** + * Generic bind item created from BindVariant enum. + * Replaces individual bind classes (ItemRopes, ItemChain, ItemStraitjacket, etc.) + * + * Factory pattern: All bind variants are created using this single class. + */ +public class GenericBind extends ItemBind { + + private final BindVariant variant; + + public GenericBind(BindVariant variant) { + super(new Item.Properties().stacksTo(16)); + this.variant = variant; + } + + @Override + public String getItemName() { + return variant.getItemName(); + } + + @Override + public PoseType getPoseType() { + return variant.getPoseType(); + } + + /** + * Get the variant this bind was created from. + */ + public BindVariant getVariant() { + return variant; + } + + /** + * Get the default resistance value for this bind variant. + * Note: Actual resistance is managed by GameRules, this is just the configured default. + */ + public int getDefaultResistance() { + return variant.getResistance(); + } + + /** + * Check if this bind can have a padlock attached via anvil. + * Adhesive (tape) and organic (slime, vine, web) binds cannot have padlocks. + */ + @Override + public boolean canAttachPadlock() { + return switch (variant) { + case DUCT_TAPE, SLIME, VINE_SEED, WEB_BIND -> false; + default -> true; + }; + } + + /** + * Get the texture subfolder for this bind variant. + * Issue #12 fix: Eliminates string checks in renderers. + */ + @Override + public String getTextureSubfolder() { + return variant.getTextureSubfolder(); + } +} diff --git a/src/main/java/com/tiedup/remake/items/GenericBlindfold.java b/src/main/java/com/tiedup/remake/items/GenericBlindfold.java new file mode 100644 index 0000000..b030aae --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/GenericBlindfold.java @@ -0,0 +1,37 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.items.base.BlindfoldVariant; +import com.tiedup.remake.items.base.ItemBlindfold; +import net.minecraft.world.item.Item; + +/** + * Generic blindfold item created from BlindfoldVariant enum. + * Replaces individual blindfold classes (ItemClassicBlindfold, ItemBlindfoldMask). + * + * Factory pattern: All blindfold variants are created using this single class. + */ +public class GenericBlindfold extends ItemBlindfold { + + private final BlindfoldVariant variant; + + public GenericBlindfold(BlindfoldVariant variant) { + super(new Item.Properties().stacksTo(16)); + this.variant = variant; + } + + /** + * Get the variant this blindfold was created from. + */ + public BlindfoldVariant getVariant() { + return variant; + } + + /** + * Get the texture subfolder for this blindfold variant. + * Issue #12 fix: Eliminates string checks in renderers. + */ + @Override + public String getTextureSubfolder() { + return variant.getTextureSubfolder(); + } +} diff --git a/src/main/java/com/tiedup/remake/items/GenericEarplugs.java b/src/main/java/com/tiedup/remake/items/GenericEarplugs.java new file mode 100644 index 0000000..8d6d065 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/GenericEarplugs.java @@ -0,0 +1,37 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.items.base.EarplugsVariant; +import com.tiedup.remake.items.base.ItemEarplugs; +import net.minecraft.world.item.Item; + +/** + * Generic earplugs item created from EarplugsVariant enum. + * Replaces individual earplugs classes (ItemClassicEarplugs). + * + * Factory pattern: All earplugs variants are created using this single class. + */ +public class GenericEarplugs extends ItemEarplugs { + + private final EarplugsVariant variant; + + public GenericEarplugs(EarplugsVariant variant) { + super(new Item.Properties().stacksTo(16)); + this.variant = variant; + } + + /** + * Get the variant this earplugs was created from. + */ + public EarplugsVariant getVariant() { + return variant; + } + + /** + * Get the texture subfolder for this earplugs variant. + * Issue #12 fix: Eliminates string checks in renderers. + */ + @Override + public String getTextureSubfolder() { + return variant.getTextureSubfolder(); + } +} diff --git a/src/main/java/com/tiedup/remake/items/GenericGag.java b/src/main/java/com/tiedup/remake/items/GenericGag.java new file mode 100644 index 0000000..824f96e --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/GenericGag.java @@ -0,0 +1,72 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.items.base.GagVariant; +import com.tiedup.remake.items.base.ItemGag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.Item; +import org.jetbrains.annotations.Nullable; + +/** + * Generic gag item created from GagVariant enum. + * Replaces individual gag classes (ItemBallGag, ItemTapeGag, etc.) + * + * Factory pattern: All gag variants are created using this single class. + * + * Note: ItemMedicalGag is NOT handled by this class because it implements + * IHasBlindingEffect (combo item with special behavior). + */ +public class GenericGag extends ItemGag { + + private final GagVariant variant; + + public GenericGag(GagVariant variant) { + super(new Item.Properties().stacksTo(16), variant.getMaterial()); + this.variant = variant; + } + + /** + * Get the variant this gag was created from. + */ + public GagVariant getVariant() { + return variant; + } + + /** + * Check if this gag can have a padlock attached via anvil. + * Adhesive (tape) and organic (slime, vine, web) gags cannot have padlocks. + */ + @Override + public boolean canAttachPadlock() { + return switch (variant) { + case TAPE_GAG, SLIME_GAG, VINE_GAG, WEB_GAG -> false; + default -> true; + }; + } + + /** + * Get the texture subfolder for this gag variant. + * Issue #12 fix: Eliminates string checks in renderers. + */ + @Override + public String getTextureSubfolder() { + return variant.getTextureSubfolder(); + } + + /** + * Check if this gag uses a 3D OBJ model. + */ + @Override + public boolean uses3DModel() { + return variant.uses3DModel(); + } + + /** + * Get the 3D model location for this gag. + */ + @Override + @Nullable + public ResourceLocation get3DModelLocation() { + String path = variant.getModelPath(); + return path != null ? ResourceLocation.tryParse(path) : null; + } +} diff --git a/src/main/java/com/tiedup/remake/items/GenericKnife.java b/src/main/java/com/tiedup/remake/items/GenericKnife.java new file mode 100644 index 0000000..0dff818 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/GenericKnife.java @@ -0,0 +1,504 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.base.IKnife; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.items.base.KnifeVariant; +import com.tiedup.remake.network.sync.SyncManager; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.List; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.UseAnim; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +/** + * Generic knife item created from KnifeVariant enum. + * Replaces individual knife classes (ItemStoneKnife, ItemIronKnife, ItemGoldenKnife). + * + * v2.5 Changes: + * - Added active cutting mechanic (hold right-click) + * - Per-tier cutting speed: Stone=5, Iron=8, Golden=12 resistance/second + * - Durability consumed per second = cutting speed (1 dura = 1 resistance) + * - Can cut binds directly or locked accessories + */ +public class GenericKnife extends Item implements IKnife { + + private final KnifeVariant variant; + + public GenericKnife(KnifeVariant variant) { + super( + new Item.Properties() + .stacksTo(1) + .durability(variant.getDurability()) + ); + this.variant = variant; + } + + /** + * Get the variant this knife was created from. + */ + public KnifeVariant getVariant() { + return variant; + } + + // ==================== TOOLTIP ==================== + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + super.appendHoverText(stack, level, tooltip, flag); + + int remaining = stack.getMaxDamage() - stack.getDamageValue(); + int speed = variant.getCuttingSpeed(); + int cuttingSeconds = remaining / speed; + + // Show cutting speed + tooltip.add( + Component.literal("Cutting speed: " + speed + " res/s").withStyle( + ChatFormatting.GRAY + ) + ); + + // Show cutting time remaining + tooltip.add( + Component.literal( + "Cutting time: " + + cuttingSeconds + + "s (" + + remaining + + " total res)" + ).withStyle(ChatFormatting.DARK_GRAY) + ); + } + + // ==================== USE MECHANICS ==================== + + @Override + public int getUseDuration(ItemStack stack) { + // Max use time: 5 minutes (very long, will stop naturally when bind breaks) + return 20 * 60 * 5; + } + + @Override + public UseAnim getUseAnimation(ItemStack stack) { + return UseAnim.BOW; // Shows a "using" animation + } + + /** + * Called when player right-clicks with knife. + * Starts cutting if: + * - Player is tied up (cuts bind) + * - Player has a knife cut target set (cuts accessory lock) + */ + @Override + public InteractionResultHolder use( + Level level, + Player player, + InteractionHand hand + ) { + ItemStack stack = player.getItemInHand(hand); + + // Only check on server side for actual state + if (!level.isClientSide) { + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + return InteractionResultHolder.pass(stack); + } + + // v2.5: Block knife usage if wearing mittens + if (state.hasMittens()) { + TiedUpMod.LOGGER.debug( + "[GenericKnife] {} cannot use knife - wearing mittens", + player.getName().getString() + ); + return InteractionResultHolder.fail(stack); + } + + // Priority 1: If tied up, cut the bind + if (state.isTiedUp()) { + player.startUsingItem(hand); + return InteractionResultHolder.consume(stack); + } + + // Priority 2: If accessory target selected (via StruggleChoiceScreen) + if (state.getKnifeCutTarget() != null) { + player.startUsingItem(hand); + return InteractionResultHolder.consume(stack); + } + + // Priority 3: If wearing a collar (not tied), auto-target the collar + if (state.hasCollar()) { + state.setKnifeCutTarget(BodyRegionV2.NECK); + player.startUsingItem(hand); + return InteractionResultHolder.consume(stack); + } + + // Priority 4: Check other accessories (gag, blindfold, etc.) + if (state.isGagged()) { + state.setKnifeCutTarget(BodyRegionV2.MOUTH); + player.startUsingItem(hand); + return InteractionResultHolder.consume(stack); + } + if (state.isBlindfolded()) { + state.setKnifeCutTarget(BodyRegionV2.EYES); + player.startUsingItem(hand); + return InteractionResultHolder.consume(stack); + } + if (state.hasEarplugs()) { + state.setKnifeCutTarget(BodyRegionV2.EARS); + player.startUsingItem(hand); + return InteractionResultHolder.consume(stack); + } + // Note: Don't auto-target mittens since you need hands to use knife + + // Nothing to cut + return InteractionResultHolder.pass(stack); + } + + // Client side - check mittens and allow use if valid target or has accessories + PlayerBindState state = PlayerBindState.getInstance(player); + if ( + state != null && + !state.hasMittens() && + (state.isTiedUp() || + state.getKnifeCutTarget() != null || + state.hasCollar() || + state.isGagged() || + state.isBlindfolded() || + state.hasEarplugs()) + ) { + player.startUsingItem(hand); + return InteractionResultHolder.consume(stack); + } + + return InteractionResultHolder.pass(stack); + } + + /** + * Called every tick while player holds right-click. + * Performs the actual cutting logic. + */ + @Override + public void onUseTick( + Level level, + LivingEntity entity, + ItemStack stack, + int remainingTicks + ) { + if (level.isClientSide || !(entity instanceof ServerPlayer player)) { + return; + } + + // Calculate how many ticks have been used + int usedTicks = getUseDuration(stack) - remainingTicks; + + // Only process every 20 ticks (1 second) + if (usedTicks > 0 && usedTicks % 20 == 0) { + performCutTick(player, stack); + } + } + + /** + * Called when player releases right-click or item breaks. + */ + @Override + public void releaseUsing( + ItemStack stack, + Level level, + LivingEntity entity, + int remainingTicks + ) { + if (!level.isClientSide && entity instanceof ServerPlayer player) { + TiedUpMod.LOGGER.debug( + "[GenericKnife] {} stopped cutting", + player.getName().getString() + ); + } + } + + /** + * Perform one "tick" of cutting (called every second while held). + * Consumes durability and removes resistance based on variant's cutting speed. + */ + private void performCutTick(ServerPlayer player, ItemStack stack) { + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + player.stopUsingItem(); + return; + } + + int speed = variant.getCuttingSpeed(); + + // Determine what to cut + if (state.isTiedUp()) { + // Cut BIND + cutBind(player, state, stack, speed); + } else if (state.getKnifeCutTarget() != null) { + // Cut ACCESSORY + cutAccessory(player, state, stack, speed); + } else { + // Nothing to cut + player.stopUsingItem(); + return; + } + + // Play cutting sound + player + .level() + .playSound( + null, + player.blockPosition(), + SoundEvents.SHEEP_SHEAR, + SoundSource.PLAYERS, + 0.5f, + 1.2f + ); + + // Consume durability equal to cutting speed + stack.hurtAndBreak(speed, player, p -> + p.broadcastBreakEvent(p.getUsedItemHand()) + ); + + // Notify nearby guards (Kidnappers, Maids, Traders) about cutting noise + com.tiedup.remake.minigame.GuardNotificationHelper.notifyNearbyGuards( + player + ); + + // Force inventory sync so durability bar updates in real-time + player.inventoryMenu.broadcastChanges(); + + // Sync state to clients + SyncManager.syncBindState(player); + } + + /** + * Cut the bind directly. + */ + private void cutBind( + ServerPlayer player, + PlayerBindState state, + ItemStack knifeStack, + int speed + ) { + // Get bind stack for ILockable check + ItemStack bindStack = V2EquipmentHelper.getInRegion( + player, + BodyRegionV2.ARMS + ); + if ( + bindStack.isEmpty() || + !(bindStack.getItem() instanceof ItemBind bind) + ) { + player.stopUsingItem(); + return; + } + + // Reduce resistance by cutting speed + int currentRes = state.getCurrentBindResistance(); + int newRes = Math.max(0, currentRes - speed); + state.setCurrentBindResistance(newRes); + + TiedUpMod.LOGGER.debug( + "[GenericKnife] {} cutting bind: resistance {} -> {}", + player.getName().getString(), + currentRes, + newRes + ); + + // Check if escaped + if (newRes <= 0) { + state.getStruggleBinds().successActionExternal(state); + player.stopUsingItem(); + TiedUpMod.LOGGER.info( + "[GenericKnife] {} escaped by cutting bind!", + player.getName().getString() + ); + } + } + + /** + * Cut an accessory - either removes lock resistance (if locked) or removes the item directly (if unlocked). + */ + private void cutAccessory( + ServerPlayer player, + PlayerBindState state, + ItemStack knifeStack, + int speed + ) { + BodyRegionV2 target = state.getKnifeCutTarget(); + if (target == null) { + player.stopUsingItem(); + return; + } + + ItemStack accessory = V2EquipmentHelper.getInRegion( + player, + target + ); + if (accessory.isEmpty()) { + // Target doesn't exist + state.clearKnifeCutTarget(); + player.stopUsingItem(); + return; + } + + // Check if the accessory is locked + boolean isLocked = false; + if (accessory.getItem() instanceof ILockable lockable) { + isLocked = lockable.isLocked(accessory); + } + + if (!isLocked) { + // NOT locked - directly cut and remove the accessory + IBondageState kidnapped = KidnappedHelper.getKidnappedState(player); + if (kidnapped != null) { + ItemStack removed = removeAccessory(kidnapped, target); + if (!removed.isEmpty()) { + // Drop the removed accessory + kidnapped.kidnappedDropItem(removed); + TiedUpMod.LOGGER.info( + "[GenericKnife] {} cut off unlocked {}", + player.getName().getString(), + target + ); + } + } + + state.clearKnifeCutTarget(); + player.stopUsingItem(); + return; + } + + // Accessory IS locked - reduce lock resistance + ILockable lockable = (ILockable) accessory.getItem(); + int currentRes = lockable.getCurrentLockResistance(accessory); + int newRes = Math.max(0, currentRes - speed); + lockable.setCurrentLockResistance(accessory, newRes); + + TiedUpMod.LOGGER.debug( + "[GenericKnife] {} cutting {} lock: resistance {} -> {}", + player.getName().getString(), + target, + currentRes, + newRes + ); + + // Check if lock is destroyed + if (newRes <= 0) { + // Destroy the lock (remove padlock, clear lock state) + lockable.setLockedByKeyUUID(accessory, null); // Unlocks and clears locked state + lockable.setLockable(accessory, false); // Remove padlock entirely + lockable.clearLockResistance(accessory); + lockable.setJammed(accessory, false); + + state.clearKnifeCutTarget(); + player.stopUsingItem(); + + TiedUpMod.LOGGER.info( + "[GenericKnife] {} cut through {} lock!", + player.getName().getString(), + target + ); + } + } + + /** + * Remove an accessory from the player and return it. + */ + private ItemStack removeAccessory( + IBondageState kidnapped, + BodyRegionV2 target + ) { + switch (target) { + case NECK -> { + ItemStack collar = kidnapped.getEquipment(BodyRegionV2.NECK); + if (collar != null && !collar.isEmpty()) { + kidnapped.unequip(BodyRegionV2.NECK); + return collar; + } + } + case MOUTH -> { + ItemStack gag = kidnapped.getEquipment(BodyRegionV2.MOUTH); + if (gag != null && !gag.isEmpty()) { + kidnapped.unequip(BodyRegionV2.MOUTH); + return gag; + } + } + case EYES -> { + ItemStack blindfold = kidnapped.getEquipment(BodyRegionV2.EYES); + if (blindfold != null && !blindfold.isEmpty()) { + kidnapped.unequip(BodyRegionV2.EYES); + return blindfold; + } + } + case EARS -> { + ItemStack earplugs = kidnapped.getEquipment(BodyRegionV2.EARS); + if (earplugs != null && !earplugs.isEmpty()) { + kidnapped.unequip(BodyRegionV2.EARS); + return earplugs; + } + } + case HANDS -> { + ItemStack mittens = kidnapped.getEquipment(BodyRegionV2.HANDS); + if (mittens != null && !mittens.isEmpty()) { + kidnapped.unequip(BodyRegionV2.HANDS); + return mittens; + } + } + default -> { + } + } + return ItemStack.EMPTY; + } + + /** + * Find a knife in the player's inventory. + * + * @param player The player to search + * @return The knife ItemStack, or empty if not found + */ + public static ItemStack findKnifeInInventory(Player player) { + // Check main hand first + ItemStack mainHand = player.getMainHandItem(); + if (mainHand.getItem() instanceof IKnife) { + return mainHand; + } + + // Check off hand + ItemStack offHand = player.getOffhandItem(); + if (offHand.getItem() instanceof IKnife) { + return offHand; + } + + // Check inventory + for (int i = 0; i < player.getInventory().getContainerSize(); i++) { + ItemStack stack = player.getInventory().getItem(i); + if (stack.getItem() instanceof IKnife) { + return stack; + } + } + + return ItemStack.EMPTY; + } +} diff --git a/src/main/java/com/tiedup/remake/items/GenericMittens.java b/src/main/java/com/tiedup/remake/items/GenericMittens.java new file mode 100644 index 0000000..f101e71 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/GenericMittens.java @@ -0,0 +1,38 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.items.base.ItemMittens; +import com.tiedup.remake.items.base.MittensVariant; +import net.minecraft.world.item.Item; + +/** + * Generic mittens item created from MittensVariant enum. + * + * Factory pattern: All mittens variants are created using this single class. + * + * Phase 14.4: Mittens system - blocks hand interactions when equipped. + */ +public class GenericMittens extends ItemMittens { + + private final MittensVariant variant; + + public GenericMittens(MittensVariant variant) { + super(new Item.Properties().stacksTo(16)); + this.variant = variant; + } + + /** + * Get the variant this mittens was created from. + */ + public MittensVariant getVariant() { + return variant; + } + + /** + * Get the texture subfolder for this mittens variant. + * Issue #12 fix: Eliminates string checks in renderers. + */ + @Override + public String getTextureSubfolder() { + return variant.getTextureSubfolder(); + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemAdminWand.java b/src/main/java/com/tiedup/remake/items/ItemAdminWand.java new file mode 100644 index 0000000..d9b6cf0 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemAdminWand.java @@ -0,0 +1,768 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.blocks.BlockCellCore; +import com.tiedup.remake.blocks.BlockMarker; +import com.tiedup.remake.blocks.ModBlocks; +import com.tiedup.remake.blocks.entity.CellCoreBlockEntity; +import com.tiedup.remake.blocks.entity.MarkerBlockEntity; +import com.tiedup.remake.cells.*; +import com.tiedup.remake.core.SystemMessageManager; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.context.UseOnContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +/** + * Admin Wand - Structure marker placement and Cell Core management. + * + * Features: + * - Right-click CellCore: rescan cell (flood-fill) + * - Shift+Right-click CellCore: display cell info + * - Right-click elsewhere: cycle structure marker type + * - Left-click: place/remove/info structure markers + */ +public class ItemAdminWand extends Item { + + private static final String TAG_ACTIVE_CELL_ID = "ActiveCellId"; + private static final String TAG_CURRENT_TYPE = "CurrentType"; + private static final String TAG_WAYPOINT_MODE = "WaypointMode"; + + public ItemAdminWand() { + super(new Item.Properties().stacksTo(1)); + } + + // ==================== NBT ACCESS ==================== + + @Nullable + public static UUID getActiveCellId(ItemStack stack) { + if (!stack.hasTag() || !stack.getTag().contains(TAG_ACTIVE_CELL_ID)) { + return null; + } + return stack.getTag().getUUID(TAG_ACTIVE_CELL_ID); + } + + public static void setActiveCellId(ItemStack stack, @Nullable UUID cellId) { + if (cellId != null) { + stack.getOrCreateTag().putUUID(TAG_ACTIVE_CELL_ID, cellId); + } else if (stack.hasTag()) { + stack.getTag().remove(TAG_ACTIVE_CELL_ID); + } + } + + public static MarkerType getCurrentType(ItemStack stack) { + if (!stack.hasTag() || !stack.getTag().contains(TAG_CURRENT_TYPE)) { + return MarkerType.ENTRANCE; // Default to structure marker + } + return MarkerType.fromString( + stack.getTag().getString(TAG_CURRENT_TYPE) + ); + } + + public static void setCurrentType(ItemStack stack, MarkerType type) { + stack + .getOrCreateTag() + .putString(TAG_CURRENT_TYPE, type.getSerializedName()); + } + + // ==================== WAYPOINT MODE ==================== + + public static boolean isInWaypointMode(ItemStack stack) { + return stack.hasTag() && stack.getTag().getBoolean(TAG_WAYPOINT_MODE); + } + + private static void enterWaypointMode(ItemStack stack, UUID cellId) { + stack.getOrCreateTag().putBoolean(TAG_WAYPOINT_MODE, true); + setActiveCellId(stack, cellId); + // Clear any previous waypoints + stack.getTag().remove("Waypoints"); + } + + private static void exitWaypointMode(ItemStack stack) { + if (stack.hasTag()) { + stack.getTag().remove(TAG_WAYPOINT_MODE); + stack.getTag().remove("Waypoints"); + setActiveCellId(stack, null); + } + } + + private static void addWaypointToStack(ItemStack stack, BlockPos pos) { + CompoundTag tag = stack.getOrCreateTag(); + ListTag list = tag.contains("Waypoints") + ? tag.getList("Waypoints", Tag.TAG_COMPOUND) + : new ListTag(); + CompoundTag wp = new CompoundTag(); + wp.putInt("X", pos.getX()); + wp.putInt("Y", pos.getY()); + wp.putInt("Z", pos.getZ()); + list.add(wp); + tag.put("Waypoints", list); + } + + private static List getWaypointsFromStack(ItemStack stack) { + List result = new ArrayList<>(); + if ( + !stack.hasTag() || !stack.getTag().contains("Waypoints") + ) return result; + ListTag list = stack.getTag().getList("Waypoints", Tag.TAG_COMPOUND); + for (int i = 0; i < list.size(); i++) { + CompoundTag wp = list.getCompound(i); + result.add( + new BlockPos(wp.getInt("X"), wp.getInt("Y"), wp.getInt("Z")) + ); + } + return result; + } + + private static void removeLastWaypointFromStack(ItemStack stack) { + if (!stack.hasTag() || !stack.getTag().contains("Waypoints")) return; + ListTag list = stack.getTag().getList("Waypoints", Tag.TAG_COMPOUND); + if (!list.isEmpty()) list.remove(list.size() - 1); + } + + private void saveWaypoints( + ItemStack stack, + Player player, + ServerLevel level + ) { + UUID cellId = getActiveCellId(stack); + if (cellId == null) return; + + CellRegistryV2 registry = CellRegistryV2.get(level); + CellDataV2 cell = registry.getCell(cellId); + if (cell == null) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "Cell not found" + ); + return; + } + + List waypoints = getWaypointsFromStack(stack); + cell.setPathWaypoints(waypoints); + registry.setDirty(); + + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + waypoints.size() + + " waypoints saved to cell " + + cellId.toString().substring(0, 8) + ); + } + + // ==================== BLOCK INTERACTION ==================== + + @Override + public InteractionResult useOn(UseOnContext context) { + Level level = context.getLevel(); + Player player = context.getPlayer(); + ItemStack stack = context.getItemInHand(); + BlockPos pos = context.getClickedPos(); + + if (player == null) return InteractionResult.PASS; + + // Check if clicked block is a Cell Core + BlockState clickedState = level.getBlockState(pos); + if (clickedState.getBlock() instanceof BlockCellCore) { + if ( + !level.isClientSide && level instanceof ServerLevel serverLevel + ) { + if (player.isShiftKeyDown()) { + if (isInWaypointMode(stack)) { + // Save waypoints and exit waypoint mode + saveWaypoints(stack, player, serverLevel); + exitWaypointMode(stack); + } else { + // Enter waypoint mode if cell core has a cell + BlockEntity be = level.getBlockEntity(pos); + if ( + be instanceof CellCoreBlockEntity coreBE && + coreBE.getCellId() != null + ) { + enterWaypointMode(stack, coreBE.getCellId()); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "Waypoint mode: click blocks to add waypoints, Shift+RC Cell Core to save" + ); + } else { + showCellCoreInfo(player, serverLevel, pos); + } + } + } else { + rescanCellCore(player, serverLevel, pos); + } + } + return InteractionResult.sidedSuccess(level.isClientSide); + } + + // Right-click on block: cycle marker type + return cycleMarkerType(stack, player, level); + } + + @Override + public InteractionResultHolder use( + Level level, + Player player, + InteractionHand hand + ) { + ItemStack stack = player.getItemInHand(hand); + + if (player.isShiftKeyDown()) { + if ( + !level.isClientSide && level instanceof ServerLevel serverLevel + ) { + if (isInWaypointMode(stack)) { + // Shift+Right-click in air while in waypoint mode: cancel + exitWaypointMode(stack); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.WARNING, + "Waypoint edit cancelled" + ); + } else { + // Shift+Right-click in air: delete selected cell + deleteSelectedCell(stack, player, serverLevel); + } + } + } else { + // Right-click in air: cycle marker type + cycleMarkerType(stack, player, level); + } + return InteractionResultHolder.sidedSuccess(stack, level.isClientSide); + } + + // ==================== CELL CORE ACTIONS ==================== + + /** + * Right-click Cell Core: rescan cell via flood-fill. + */ + private void rescanCellCore( + Player player, + ServerLevel level, + BlockPos corePos + ) { + BlockEntity be = level.getBlockEntity(corePos); + if (!(be instanceof CellCoreBlockEntity coreBE)) return; + + FloodFillResult result = FloodFillAlgorithm.tryFill(level, corePos); + + if (!result.isSuccess()) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "Rescan failed: " + + (result.getErrorKey() != null + ? result.getErrorKey() + : "unknown error") + ); + return; + } + + CellRegistryV2 registry = CellRegistryV2.get(level); + UUID cellId = coreBE.getCellId(); + + if (cellId != null) { + CellDataV2 existing = registry.getCell(cellId); + if (existing != null) { + registry.rescanCell(cellId, result); + + // Update interior face on block entity + if (result.getInteriorFace() != null) { + coreBE.setInteriorFace(result.getInteriorFace()); + } + + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "Cell rescanned: " + + result.getInterior().size() + + " interior, " + + result.getWalls().size() + + " walls, " + + result.getBeds().size() + + " beds, " + + result.getAnchors().size() + + " anchors, " + + result.getDoors().size() + + " doors" + ); + return; + } + } + + // No existing cell — create new one + CellDataV2 newCell = registry.createCell(corePos, result, null); + coreBE.setCellId(newCell.getId()); + if (result.getInteriorFace() != null) { + coreBE.setInteriorFace(result.getInteriorFace()); + } + + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "New cell created: " + + result.getInterior().size() + + " interior, " + + result.getWalls().size() + + " walls" + ); + } + + /** + * Shift+Right-click Cell Core: display cell info. + */ + private void showCellCoreInfo( + Player player, + ServerLevel level, + BlockPos corePos + ) { + BlockEntity be = level.getBlockEntity(corePos); + if (!(be instanceof CellCoreBlockEntity coreBE)) return; + + UUID cellId = coreBE.getCellId(); + if (cellId == null) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.WARNING, + "Cell Core has no linked cell (right-click to scan)" + ); + return; + } + + CellRegistryV2 registry = CellRegistryV2.get(level); + CellDataV2 cell = registry.getCell(cellId); + + if (cell == null) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.WARNING, + "Cell " + + cellId.toString().substring(0, 8) + + "... not found in registry" + ); + return; + } + + // Build info message + StringBuilder info = new StringBuilder(); + info.append("--- Cell Info ---\n"); + info + .append("ID: ") + .append(cellId.toString().substring(0, 8)) + .append("...\n"); + if (cell.getName() != null) { + info.append("Name: ").append(cell.getName()).append("\n"); + } + info + .append("State: ") + .append(cell.getState().getSerializedName()) + .append("\n"); + info + .append("Volume: ") + .append(cell.getInteriorBlocks().size()) + .append(" blocks\n"); + info.append("Walls: ").append(cell.getWallBlocks().size()); + if (!cell.getBreachedPositions().isEmpty()) { + info + .append(" (") + .append(cell.getBreachedPositions().size()) + .append(" breached)"); + } + info.append("\n"); + info + .append("Beds: ") + .append(cell.getBeds().size()) + .append(", Anchors: ") + .append(cell.getAnchors().size()) + .append(", Doors: ") + .append(cell.getDoors().size()) + .append("\n"); + info + .append("Prisoners: ") + .append(cell.getPrisonerCount()) + .append("/4\n"); + + if (cell.getOwnerId() != null) { + info + .append("Owner: ") + .append(cell.getOwnerId().toString().substring(0, 8)) + .append("... (") + .append(cell.getOwnerType().getSerializedName()) + .append(")"); + } else { + info.append("Owner: none"); + } + + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + info.toString() + ); + } + + // ==================== ATTACK (LEFT-CLICK) ==================== + + @Override + public boolean onBlockStartBreak( + ItemStack stack, + BlockPos pos, + Player player + ) { + Level level = player.level(); + BlockState state = level.getBlockState(pos); + + // Left-click on marker: show info about the marker (always, even in waypoint mode) + if ( + state.getBlock() instanceof BlockMarker && !isInWaypointMode(stack) + ) { + if (!level.isClientSide) { + showMarkerInfo(stack, player, level, pos); + } + return true; + } + + if (isInWaypointMode(stack)) { + if (!level.isClientSide) { + if (player.isShiftKeyDown()) { + // Shift+Left-click in waypoint mode: remove last waypoint + removeLastWaypointFromStack(stack); + int remaining = getWaypointsFromStack(stack).size(); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "Last waypoint removed (" + remaining + " remaining)" + ); + } else { + // Left-click in waypoint mode: add waypoint + BlockPos waypointPos = pos.above(); + addWaypointToStack(stack, waypointPos); + int count = getWaypointsFromStack(stack).size(); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "Waypoint " + + count + + " added at " + + waypointPos.toShortString() + ); + } + } + return true; + } + + // Shift + Left-click: remove marker above this block (if exists) + if (player.isShiftKeyDown()) { + if (!level.isClientSide) { + removeStructureMarker(stack, player, (ServerLevel) level, pos); + } + return true; + } + + // Left-click on block: place structure marker above it + if (!level.isClientSide) { + placeStructureMarker(stack, player, (ServerLevel) level, pos); + } + return true; + } + + /** + * Place a structure marker (no cell needed). + */ + private void placeStructureMarker( + ItemStack stack, + Player player, + ServerLevel level, + BlockPos pos + ) { + MarkerType type = getCurrentType(stack); + + // Structure markers don't need a cell + BlockPos markerPos = pos.above(); + BlockState currentState = level.getBlockState(markerPos); + + if ( + !currentState.isAir() && + !(currentState.getBlock() instanceof BlockMarker) + ) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "Cannot place marker - block is occupied" + ); + return; + } + + // Place the marker block + level.setBlock( + markerPos, + ModBlocks.MARKER.get().defaultBlockState(), + 3 + ); + + // Set the marker type (no cell ID for structure markers) + BlockEntity be = level.getBlockEntity(markerPos); + if (be instanceof MarkerBlockEntity marker) { + marker.setMarkerType(type); + + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + type.name() + " marker placed at " + markerPos.toShortString() + ); + } + } + + /** + * Show info about a marker block. + */ + private void showMarkerInfo( + ItemStack stack, + Player player, + Level level, + BlockPos pos + ) { + BlockEntity be = level.getBlockEntity(pos); + if (be instanceof MarkerBlockEntity marker) { + MarkerType type = marker.getMarkerType(); + UUID cellId = marker.getCellId(); + + if (cellId != null) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "Cell marker: " + + type.name() + + " (Cell: " + + cellId.toString().substring(0, 8) + + "...)" + ); + } else { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "Structure marker: " + type.name() + " (no cell)" + ); + } + } + } + + /** + * Remove a structure marker above the clicked position. + */ + private void removeStructureMarker( + ItemStack stack, + Player player, + ServerLevel level, + BlockPos pos + ) { + BlockPos markerPos = pos.above(); + BlockState state = level.getBlockState(markerPos); + + if (state.getBlock() instanceof BlockMarker) { + BlockEntity be = level.getBlockEntity(markerPos); + if (be instanceof MarkerBlockEntity marker) { + // Only remove if it's a structure marker (no cell ID) + if (marker.getCellId() == null) { + level.destroyBlock(markerPos, false); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "Structure marker removed" + ); + } else { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "This is a cell marker - use Cell Wand to manage cells" + ); + } + } + } else { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.WARNING, + "No marker above this position" + ); + } + } + + // ==================== ACTIONS ==================== + + /** + * Cycle through STRUCTURE marker types (ENTRANCE, PATROL, LOOT, SPAWNER, ...). + */ + private InteractionResult cycleMarkerType( + ItemStack stack, + Player player, + Level level + ) { + if (!level.isClientSide) { + MarkerType current = getCurrentType(stack); + MarkerType next = current.nextStructureType(); + setCurrentType(stack, next); + + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "Structure marker: " + next.name() + ); + } + return InteractionResult.sidedSuccess(level.isClientSide); + } + + private void deleteSelectedCell( + ItemStack stack, + Player player, + ServerLevel level + ) { + UUID cellId = getActiveCellId(stack); + if (cellId == null) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "No cell selected" + ); + return; + } + + CellRegistryV2 registry = CellRegistryV2.get(level); + CellDataV2 cell = registry.getCell(cellId); + + if (cell == null) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "Cell not found" + ); + setActiveCellId(stack, null); + return; + } + + BlockPos spawnPoint = cell.getSpawnPoint(); + BlockState state = level.getBlockState(spawnPoint); + if (state.getBlock() instanceof BlockMarker) { + level.destroyBlock(spawnPoint, false); + } + + setActiveCellId(stack, null); + + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "Cell deleted" + ); + } + + // ==================== TOOLTIP ==================== + + private ChatFormatting getTypeColor(MarkerType type) { + return switch (type) { + case WALL -> ChatFormatting.BLUE; + case ANCHOR -> ChatFormatting.RED; + case BED -> ChatFormatting.LIGHT_PURPLE; + case DOOR -> ChatFormatting.AQUA; + case DELIVERY -> ChatFormatting.YELLOW; + case ENTRANCE -> ChatFormatting.GREEN; + case PATROL -> ChatFormatting.YELLOW; + case LOOT -> ChatFormatting.GOLD; + case SPAWNER -> ChatFormatting.DARK_RED; + case TRADER_SPAWN -> ChatFormatting.LIGHT_PURPLE; + case MAID_SPAWN -> ChatFormatting.LIGHT_PURPLE; + case MERCHANT_SPAWN -> ChatFormatting.AQUA; + }; + } + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + tooltip.add( + Component.literal("ADMIN WAND").withStyle( + ChatFormatting.LIGHT_PURPLE, + ChatFormatting.BOLD + ) + ); + tooltip.add( + Component.literal( + "Structure markers + Cell Core management" + ).withStyle(ChatFormatting.GRAY) + ); + + MarkerType type = getCurrentType(stack); + tooltip.add( + Component.literal("Type: " + type.name()).withStyle( + getTypeColor(type) + ) + ); + + tooltip.add(Component.literal("")); + tooltip.add( + Component.literal("Left-click: Place marker").withStyle( + ChatFormatting.YELLOW + ) + ); + tooltip.add( + Component.literal("Shift+Left-click: Remove marker").withStyle( + ChatFormatting.RED + ) + ); + tooltip.add( + Component.literal("Left-click on marker: Show info").withStyle( + ChatFormatting.DARK_GRAY + ) + ); + tooltip.add( + Component.literal("Right-click: Cycle type").withStyle( + ChatFormatting.DARK_GRAY + ) + ); + tooltip.add( + Component.literal("Right-click Cell Core: Rescan cell").withStyle( + ChatFormatting.AQUA + ) + ); + tooltip.add( + Component.literal( + "Shift+Right-click Cell Core: Cell info / Waypoint mode" + ).withStyle(ChatFormatting.AQUA) + ); + + if (isInWaypointMode(stack)) { + int count = getWaypointsFromStack(stack).size(); + tooltip.add(Component.literal("")); + tooltip.add( + Component.literal( + "WAYPOINT MODE (" + count + " points)" + ).withStyle(ChatFormatting.YELLOW, ChatFormatting.BOLD) + ); + } + } + + @Override + public boolean isFoil(ItemStack stack) { + return true; // Always glowing to indicate admin item + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemCellKey.java b/src/main/java/com/tiedup/remake/items/ItemCellKey.java new file mode 100644 index 0000000..7e6157b --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemCellKey.java @@ -0,0 +1,55 @@ +package com.tiedup.remake.items; + +import java.util.List; +import javax.annotation.Nullable; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; + +/** + * Cell Key - Universal key for iron bar doors. + * + * Phase: Kidnapper Revamp - Cell System + * + * Unlike regular keys which have UUIDs and can only unlock matching locks, + * the Cell Key can unlock any iron bar door regardless of which key locked it. + * + * This is a convenience item for kidnappers to manage their cells without + * needing to track individual keys. + * + * Does NOT unlock regular bondage items (collars, cuffs, etc.) - only iron bar doors. + */ +public class ItemCellKey extends Item { + + public ItemCellKey() { + super(new Item.Properties().stacksTo(1)); + } + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + tooltip.add( + Component.literal("Unlocks any Iron Bar Door").withStyle( + ChatFormatting.GRAY + ) + ); + tooltip.add( + Component.literal("Does not work on bondage items").withStyle( + ChatFormatting.DARK_GRAY + ) + ); + } + + @Override + public boolean isFoil(ItemStack stack) { + // Slight shimmer to indicate it's a special key + return true; + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemChloroformBottle.java b/src/main/java/com/tiedup/remake/items/ItemChloroformBottle.java new file mode 100644 index 0000000..b103a48 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemChloroformBottle.java @@ -0,0 +1,110 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import net.minecraft.network.chat.Component; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * Chloroform Bottle - Used to soak rags for knocking out targets + * Has limited durability. + * + * Phase 15: Full chloroform system implementation + * + * Usage: + * - Hold chloroform bottle in main hand, rag in offhand + * - Right-click to soak the rag with chloroform + * - Consumes 1 durability from the bottle + */ +public class ItemChloroformBottle extends Item { + + public ItemChloroformBottle() { + super( + new Item.Properties().stacksTo(1).durability(9) // Default durability (ModConfig not loaded yet during item registration) + ); + } + + /** + * Called when player right-clicks with the chloroform bottle. + * If holding a rag in offhand, soaks it with chloroform. + */ + @Override + public InteractionResultHolder use( + Level level, + Player player, + InteractionHand hand + ) { + ItemStack bottle = player.getItemInHand(hand); + + // Only works from main hand + if (hand != InteractionHand.MAIN_HAND) { + return InteractionResultHolder.pass(bottle); + } + + // Check for rag in offhand + ItemStack offhandItem = player.getItemInHand(InteractionHand.OFF_HAND); + if ( + offhandItem.isEmpty() || !(offhandItem.getItem() instanceof ItemRag) + ) { + if (!level.isClientSide) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "Hold a rag in your offhand to soak it" + ); + } + return InteractionResultHolder.pass(bottle); + } + + // Server side only + if (!level.isClientSide) { + // Check if rag is already wet + if (ItemRag.isWet(offhandItem)) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "The rag is already soaked with chloroform" + ); + return InteractionResultHolder.pass(bottle); + } + + // Soak the rag + ItemRag.soak(offhandItem, ItemRag.getDefaultWetTime()); + + // Damage the bottle (consume 1 use) + bottle.hurtAndBreak(1, player, p -> { + p.broadcastBreakEvent(InteractionHand.MAIN_HAND); + }); + + // Play bottle pour sound + level.playSound( + null, + player.blockPosition(), + SoundEvents.BOTTLE_EMPTY, + SoundSource.PLAYERS, + 1.0f, + 1.0f + ); + + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.RAG_SOAKED + ); + + TiedUpMod.LOGGER.info( + "[ItemChloroformBottle] {} soaked rag with chloroform", + player.getName().getString() + ); + } + + return InteractionResultHolder.success(bottle); + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemChokeCollar.java b/src/main/java/com/tiedup/remake/items/ItemChokeCollar.java new file mode 100644 index 0000000..95d2943 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemChokeCollar.java @@ -0,0 +1,156 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.items.bondage3d.IHas3DModelConfig; +import com.tiedup.remake.items.bondage3d.Model3DConfig; +import java.util.List; +import java.util.Set; +import net.minecraft.ChatFormatting; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +/** + * Choke Collar - Pet play collar used by Masters. + * + *

Special feature: Can be put in "choke mode" which applies a drowning effect.

+ *

Used by Masters for punishment. The effect simulates choking by reducing air supply, + * which triggers drowning damage if left active for too long.

+ * + *

Mechanics:

+ *
    + *
  • When choking is active, the wearer's air supply decreases rapidly
  • + *
  • This creates the drowning effect (damage and bubble particles)
  • + *
  • Masters should deactivate the choke before the pet dies
  • + *
  • The choke is controlled by the Master's punishment system
  • + *
+ * + * @see com.tiedup.remake.entities.ai.master.MasterPunishGoal + * @see com.tiedup.remake.events.restriction.PetPlayRestrictionHandler + */ +public class ItemChokeCollar extends ItemCollar implements IHas3DModelConfig { + + private static final String NBT_CHOKING = "choking"; + + private static final Model3DConfig CONFIG = new Model3DConfig( + "tiedup:models/obj/choke_collar_leather/model.obj", + "tiedup:models/obj/choke_collar_leather/texture.png", + 0.0f, + 1.47f, + 0.0f, // Collar band centered at neck level + 1.0f, + 0.0f, + 0.0f, + 180.0f, // Flip Y to match rendering convention + Set.of() + ); + + public ItemChokeCollar() { + super(new Item.Properties()); + } + + @Override + public String getItemName() { + return "choke_collar"; + } + + /** + * Check if choke mode is active. + * + * @param stack The collar ItemStack + * @return true if choking is active + */ + public boolean isChoking(ItemStack stack) { + CompoundTag tag = stack.getTag(); + return tag != null && tag.getBoolean(NBT_CHOKING); + } + + /** + * Set choke mode on/off. + * When active, applies drowning effect to wearer (handled by PetPlayRestrictionHandler). + * + * @param stack The collar ItemStack + * @param choking true to activate choking, false to deactivate + */ + public void setChoking(ItemStack stack, boolean choking) { + stack.getOrCreateTag().putBoolean(NBT_CHOKING, choking); + } + + /** + * Check if collar is in pet play mode (from Master). + * + * @param stack The collar ItemStack + * @return true if this is a pet play collar + */ + public boolean isPetPlayMode(ItemStack stack) { + CompoundTag tag = stack.getTag(); + return tag != null && tag.getBoolean("petPlayMode"); + } + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + super.appendHoverText(stack, level, tooltip, flag); + + // Show choke status + if (isChoking(stack)) { + tooltip.add( + Component.literal("CHOKING ACTIVE!") + .withStyle(ChatFormatting.DARK_RED) + .withStyle(ChatFormatting.BOLD) + ); + } + + // Show pet play mode status + if (isPetPlayMode(stack)) { + tooltip.add( + Component.literal("Pet Play Mode").withStyle( + ChatFormatting.LIGHT_PURPLE + ) + ); + } + + // Description + tooltip.add( + Component.literal("A special collar used for pet play punishment") + .withStyle(ChatFormatting.DARK_GRAY) + .withStyle(ChatFormatting.ITALIC) + ); + } + + /** + * Choke collar cannot shock like shock collar. + */ + @Override + public boolean canShock() { + return false; + } + + // ======================================== + // 3D Model Support + // ======================================== + + @Override + public boolean uses3DModel() { + return true; + } + + @Override + public ResourceLocation get3DModelLocation() { + return ResourceLocation.tryParse(CONFIG.objPath()); + } + + @Override + public Model3DConfig getModelConfig() { + return CONFIG; + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemClassicCollar.java b/src/main/java/com/tiedup/remake/items/ItemClassicCollar.java new file mode 100644 index 0000000..c80a778 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemClassicCollar.java @@ -0,0 +1,22 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.items.base.ItemCollar; +import net.minecraft.world.item.Item; + +/** + * Classic Collar - Basic collar item + * Standard collar for marking ownership. + * + * Based on original ItemCollar from 1.12.2 + * Phase 1: No ownership system yet, just a basic wearable collar + * Note: Collars have maxStackSize of 1 (unique items) + */ +public class ItemClassicCollar extends ItemCollar { + + public ItemClassicCollar() { + super( + new Item.Properties() + // stacksTo(1) is set by ItemCollar base class + ); + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemCommandWand.java b/src/main/java/com/tiedup/remake/items/ItemCommandWand.java new file mode 100644 index 0000000..0abc6f7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemCommandWand.java @@ -0,0 +1,537 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.personality.PacketOpenCommandWandScreen; +import com.tiedup.remake.personality.HomeType; +import com.tiedup.remake.personality.JobExperience; +import com.tiedup.remake.personality.NpcCommand; +import com.tiedup.remake.personality.NpcNeeds; +import com.tiedup.remake.personality.PersonalityState; +import java.util.List; +import javax.annotation.Nullable; +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.context.UseOnContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.ChestBlock; +import net.minecraft.world.level.block.state.BlockState; + +/** + * Command Wand - Used to give commands to collared NPCs. + * + * Personality System Phase E: Command Wand Item + * + *

Usage:

+ *
    + *
  • Right-click on a collared NPC you own to open command GUI
  • + *
  • Shows personality info, needs, and available commands
  • + *
  • Only works on NPCs wearing a collar you own
  • + *
+ */ +public class ItemCommandWand extends Item { + + // NBT tags for selection mode + private static final String TAG_SELECTING_FOR = "SelectingFor"; + private static final String TAG_JOB_TYPE = "JobType"; + // Two-click selection for TRANSFER + private static final String TAG_SELECTING_STEP = "SelectingStep"; // 1 = chest A, 2 = chest B + private static final String TAG_FIRST_CHEST_POS = "FirstChestPos"; // BlockPos of chest A + + public ItemCommandWand() { + super(new Item.Properties().stacksTo(1)); + } + + // ========== Selection Mode Methods ========== + + /** + * Check if wand is in selection mode (waiting for chest click). + */ + public static boolean isInSelectionMode(ItemStack stack) { + return stack.hasTag() && stack.getTag().contains(TAG_SELECTING_FOR); + } + + /** + * Enter selection mode - waiting for player to click a chest. + * + * HIGH FIX: Now uses UUID instead of entity ID for persistence across restarts. + * + * @param stack The wand item stack + * @param entityUUID The NPC entity UUID (persistent) + * @param job The job command to assign + */ + public static void enterSelectionMode( + ItemStack stack, + java.util.UUID entityUUID, + NpcCommand job + ) { + stack.getOrCreateTag().putUUID(TAG_SELECTING_FOR, entityUUID); + stack.getOrCreateTag().putString(TAG_JOB_TYPE, job.name()); + // For TRANSFER, we need two clicks + if (job == NpcCommand.TRANSFER) { + stack.getOrCreateTag().putInt(TAG_SELECTING_STEP, 1); + } + } + + /** + * Exit selection mode. + */ + public static void exitSelectionMode(ItemStack stack) { + if (stack.hasTag()) { + stack.getTag().remove(TAG_SELECTING_FOR); + stack.getTag().remove(TAG_JOB_TYPE); + stack.getTag().remove(TAG_SELECTING_STEP); + stack.getTag().remove(TAG_FIRST_CHEST_POS); + } + } + + /** + * Get the current selection step (1 = first chest, 2 = second chest). + * Returns 0 if not in multi-step selection. + */ + public static int getSelectingStep(ItemStack stack) { + return stack.hasTag() ? stack.getTag().getInt(TAG_SELECTING_STEP) : 0; + } + + /** + * Get the first chest position (for TRANSFER). + */ + @Nullable + public static BlockPos getFirstChestPos(ItemStack stack) { + if (!stack.hasTag() || !stack.getTag().contains(TAG_FIRST_CHEST_POS)) { + return null; + } + return BlockPos.of(stack.getTag().getLong(TAG_FIRST_CHEST_POS)); + } + + /** + * Set the first chest position and advance to step 2. + */ + public static void setFirstChestAndAdvance(ItemStack stack, BlockPos pos) { + stack.getOrCreateTag().putLong(TAG_FIRST_CHEST_POS, pos.asLong()); + stack.getOrCreateTag().putInt(TAG_SELECTING_STEP, 2); + } + + /** + * Get the entity UUID being selected for. + * + * HIGH FIX: Returns UUID instead of entity ID for persistence. + */ + @Nullable + public static java.util.UUID getSelectingForEntity(ItemStack stack) { + if (!stack.hasTag() || !stack.getTag().hasUUID(TAG_SELECTING_FOR)) { + return null; + } + return stack.getTag().getUUID(TAG_SELECTING_FOR); + } + + /** + * Get the job type being assigned. + */ + @Nullable + public static NpcCommand getSelectingJobType(ItemStack stack) { + if (!stack.hasTag() || !stack.getTag().contains(TAG_JOB_TYPE)) { + return null; + } + return NpcCommand.fromString(stack.getTag().getString(TAG_JOB_TYPE)); + } + + // ========== Block Interaction (Chest Selection) ========== + + @Override + public InteractionResult useOn(UseOnContext context) { + ItemStack stack = context.getItemInHand(); + Player player = context.getPlayer(); + + if (player == null) { + return InteractionResult.PASS; + } + + BlockPos clickedPos = context.getClickedPos(); + Level level = context.getLevel(); + BlockState blockState = level.getBlockState(clickedPos); + + // Selection mode for job commands + if (!isInSelectionMode(stack)) { + return InteractionResult.PASS; + } + + // Must click on a chest + if (!(blockState.getBlock() instanceof ChestBlock)) { + if (!level.isClientSide) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "You must click on a chest to set the work zone!" + ); + } + return InteractionResult.FAIL; + } + + // Server-side: directly apply the command (we're already on server) + if (!level.isClientSide) { + // HIGH FIX: Use UUID instead of entity ID for persistence + java.util.UUID entityUUID = getSelectingForEntity(stack); + NpcCommand command = getSelectingJobType(stack); + int selectingStep = getSelectingStep(stack); + + if (command != null && entityUUID != null) { + // TRANSFER requires two chests + if (command == NpcCommand.TRANSFER) { + if (selectingStep == 1) { + // First click: store source chest A + setFirstChestAndAdvance(stack, clickedPos); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "Source chest set at " + + clickedPos.toShortString() + + ". Now click on the DESTINATION chest." + ); + return InteractionResult.SUCCESS; + } else if (selectingStep == 2) { + // Second click: apply command with both chests + BlockPos sourceChest = getFirstChestPos(stack); + if (sourceChest == null) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "Error: Source chest not set. Try again." + ); + exitSelectionMode(stack); + return InteractionResult.FAIL; + } + + // Prevent selecting same chest twice + if (sourceChest.equals(clickedPos)) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "Destination must be different from source chest!" + ); + return InteractionResult.FAIL; + } + + // Find the NPC entity (HIGH FIX: lookup by UUID) + net.minecraft.world.entity.Entity entity = ( + (net.minecraft.server.level.ServerLevel) level + ).getEntity(entityUUID); + if (entity instanceof EntityDamsel damsel) { + // Give TRANSFER command with source (commandTarget) and dest (commandTarget2) + boolean success = damsel.giveCommandWithTwoTargets( + player, + command, + sourceChest, // Source chest A + clickedPos // Destination chest B + ); + + if (success) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + damsel.getNpcName() + + " will transfer items from " + + sourceChest.toShortString() + + " to " + + clickedPos.toShortString() + ); + + TiedUpMod.LOGGER.debug( + "[ItemCommandWand] {} set TRANSFER for {} from {} to {}", + player.getName().getString(), + damsel.getNpcName(), + sourceChest, + clickedPos + ); + } else { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + damsel.getNpcName() + " refused the job!" + ); + } + } + exitSelectionMode(stack); + return InteractionResult.SUCCESS; + } + } + + // Standard single-chest job commands (FARM, COOK, SHEAR, etc.) (HIGH FIX: lookup by UUID) + net.minecraft.world.entity.Entity entity = ( + (net.minecraft.server.level.ServerLevel) level + ).getEntity(entityUUID); + if (entity instanceof EntityDamsel damsel) { + // Give command directly (already validated acceptance in PacketNpcCommand) + boolean success = damsel.giveCommand( + player, + command, + clickedPos + ); + + if (success) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "Work zone set! " + + damsel.getNpcName() + + " will " + + command.name() + + " at " + + clickedPos.toShortString() + ); + + TiedUpMod.LOGGER.debug( + "[ItemCommandWand] {} set work zone for {} at {}", + player.getName().getString(), + damsel.getNpcName(), + clickedPos + ); + } else { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + damsel.getNpcName() + " refused the job!" + ); + } + } + + // Exit selection mode + exitSelectionMode(stack); + } + } + + return InteractionResult.SUCCESS; + } + + // ========== Entity Interaction ========== + + @Override + public InteractionResult interactLivingEntity( + ItemStack stack, + Player player, + LivingEntity target, + InteractionHand hand + ) { + // Only works on EntityDamsel (and subclasses like EntityKidnapper) + if (!(target instanceof EntityDamsel damsel)) { + return InteractionResult.PASS; + } + + // Server-side only + if (player.level().isClientSide) { + return InteractionResult.SUCCESS; + } + + // Must have collar + if (!damsel.hasCollar()) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + damsel.getNpcName() + " is not wearing a collar!" + ); + return InteractionResult.FAIL; + } + + // Get collar and verify ownership + ItemStack collar = damsel.getEquipment(BodyRegionV2.NECK); + if (!(collar.getItem() instanceof ItemCollar collarItem)) { + return InteractionResult.PASS; + } + + if (!collarItem.getOwners(collar).contains(player.getUUID())) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "You don't own " + damsel.getNpcName() + "'s collar!" + ); + return InteractionResult.FAIL; + } + + // Get personality data + PersonalityState state = damsel.getPersonalityState(); + if (state == null) { + TiedUpMod.LOGGER.warn( + "[ItemCommandWand] No personality state for {}", + damsel.getNpcName() + ); + return InteractionResult.FAIL; + } + + // Always show personality (discovery system removed) + String personalityName = state.getPersonality().name(); + + // Get home type + String homeType = state.getHomeType().name(); + + // Cell info for GUI + String cellName = ""; + String cellQualityName = ""; + if ( + state.getCellId() != null && + player.level() instanceof net.minecraft.server.level.ServerLevel sl + ) { + CellDataV2 cell = CellRegistryV2.get(sl).getCell(state.getCellId()); + if (cell != null) { + cellName = + cell.getName() != null + ? cell.getName() + : "Cell " + cell.getId().toString().substring(0, 8); + } + } + cellQualityName = + state.getCellQuality() != null ? state.getCellQuality().name() : ""; + + // Get needs + NpcNeeds needs = state.getNeeds(); + + // Get job experience for active command + JobExperience jobExp = state.getJobExperience(); + NpcCommand activeCmd = state.getActiveCommand(); + String activeJobLevelName = ""; + int activeJobXp = 0; + int activeJobXpMax = 10; + if (activeCmd.isActiveJob()) { + JobExperience.JobLevel level = jobExp.getJobLevel(activeCmd); + activeJobLevelName = level.name(); + activeJobXp = jobExp.getExperience(activeCmd); + activeJobXpMax = level.maxExp; + } + + // Send packet to open GUI (HIGH FIX: pass UUID instead of entity ID) + if (player instanceof ServerPlayer sp) { + ModNetwork.sendToPlayer( + new PacketOpenCommandWandScreen( + damsel.getUUID(), + damsel.getNpcName(), + personalityName, + activeCmd.name(), + needs.getHunger(), + needs.getRest(), + state.getMood(), + state.getFollowDistance().name(), + homeType, + state.isAutoRestEnabled(), + cellName, + cellQualityName, + activeJobLevelName, + activeJobXp, + activeJobXpMax + ), + sp + ); + } + + TiedUpMod.LOGGER.debug( + "[ItemCommandWand] {} opened command wand for {}", + player.getName().getString(), + damsel.getNpcName() + ); + + return InteractionResult.SUCCESS; + } + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + if (isInSelectionMode(stack)) { + NpcCommand job = getSelectingJobType(stack); + int step = getSelectingStep(stack); + tooltip.add( + Component.literal("SELECTION MODE").withStyle( + ChatFormatting.GOLD, + ChatFormatting.BOLD + ) + ); + // Different messages for TRANSFER two-step selection + if (job == NpcCommand.TRANSFER) { + if (step == 1) { + tooltip.add( + Component.literal("Click SOURCE chest (1/2)").withStyle( + ChatFormatting.YELLOW + ) + ); + } else if (step == 2) { + BlockPos source = getFirstChestPos(stack); + tooltip.add( + Component.literal( + "Click DESTINATION chest (2/2)" + ).withStyle(ChatFormatting.YELLOW) + ); + if (source != null) { + tooltip.add( + Component.literal( + "Source: " + source.toShortString() + ).withStyle(ChatFormatting.GRAY) + ); + } + } + } else { + tooltip.add( + Component.literal( + "Click a chest to set work zone" + ).withStyle(ChatFormatting.YELLOW) + ); + } + if (job != null) { + tooltip.add( + Component.literal("Job: " + job.name()).withStyle( + ChatFormatting.AQUA + ) + ); + } + tooltip.add(Component.literal("")); + tooltip.add( + Component.literal("Right-click empty to cancel").withStyle( + ChatFormatting.RED + ) + ); + } else { + tooltip.add( + Component.literal("Right-click a collared NPC").withStyle( + ChatFormatting.GRAY + ) + ); + tooltip.add( + Component.literal("to give commands").withStyle( + ChatFormatting.GRAY + ) + ); + tooltip.add(Component.literal("")); + tooltip.add( + Component.literal("Commands: FOLLOW, STAY, HEEL...").withStyle( + ChatFormatting.DARK_PURPLE + ) + ); + tooltip.add( + Component.literal("Jobs: FARM, COOK, STORE").withStyle( + ChatFormatting.GREEN + ) + ); + } + } + + @Override + public boolean isFoil(ItemStack stack) { + // Enchantment glint when in selection mode + return isInSelectionMode(stack); + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemDebugWand.java b/src/main/java/com/tiedup/remake/items/ItemDebugWand.java new file mode 100644 index 0000000..e45113b --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemDebugWand.java @@ -0,0 +1,240 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.PersonalityState; +import com.tiedup.remake.personality.PersonalityType; +import java.util.List; +import javax.annotation.Nullable; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; + +/** + * Debug Wand for testing the Personality System. + * + *

OP item for developers/testers to manipulate NPC personality state. + * + *

Controls: + *

    + *
  • Right-click on Damsel: Cycle personality type
  • + *
  • Shift + Right-click on Damsel: Show status
  • + *
+ */ +public class ItemDebugWand extends Item { + + public ItemDebugWand() { + super(new Properties().stacksTo(1)); + } + + @Override + public InteractionResult interactLivingEntity( + ItemStack stack, + Player player, + LivingEntity target, + InteractionHand hand + ) { + if (player.level().isClientSide()) { + return InteractionResult.SUCCESS; + } + + if (!(target instanceof EntityDamsel damsel)) { + player.displayClientMessage( + Component.literal("Target must be a Damsel!").withStyle( + ChatFormatting.RED + ), + true + ); + return InteractionResult.FAIL; + } + + PersonalityState state = damsel.getPersonalityState(); + if (state == null) { + player.displayClientMessage( + Component.literal("Damsel has no personality state!").withStyle( + ChatFormatting.RED + ), + true + ); + return InteractionResult.FAIL; + } + + boolean isShift = player.isShiftKeyDown(); + + if (isShift) { + // Show status + showStatus(damsel, state, player); + } else { + // Cycle personality type + cyclePersonality(damsel, state, player); + } + + return InteractionResult.SUCCESS; + } + + private void cyclePersonality( + EntityDamsel damsel, + PersonalityState state, + Player player + ) { + PersonalityType current = state.getPersonality(); + PersonalityType[] types = PersonalityType.values(); + + // Find next type + int currentIndex = current.ordinal(); + int nextIndex = (currentIndex + 1) % types.length; + PersonalityType next = types[nextIndex]; + + // Create new state with new personality + damsel.setPersonalityType(next); + + player.displayClientMessage( + Component.literal("Personality: ") + .withStyle(ChatFormatting.YELLOW) + .append( + Component.literal(current.name()).withStyle( + ChatFormatting.GRAY + ) + ) + .append( + Component.literal(" -> ").withStyle(ChatFormatting.WHITE) + ) + .append( + Component.literal(next.name()).withStyle( + ChatFormatting.GOLD + ) + ), + false + ); + + TiedUpMod.LOGGER.info( + "[DebugWand] {} personality changed: {} -> {}", + damsel.getNpcName(), + current, + next + ); + } + + private void showStatus( + EntityDamsel damsel, + PersonalityState state, + Player player + ) { + player.displayClientMessage(Component.literal(""), false); // Blank line + player.displayClientMessage( + Component.literal( + "=== " + damsel.getNpcName() + " ===" + ).withStyle(ChatFormatting.GOLD, ChatFormatting.BOLD), + false + ); + + // Personality + player.displayClientMessage( + Component.literal("Personality: ") + .withStyle(ChatFormatting.GRAY) + .append( + Component.literal(state.getPersonality().name()).withStyle( + ChatFormatting.YELLOW + ) + ), + false + ); + + // Mood + float mood = state.getMood(); + ChatFormatting moodColor = + mood > 60 + ? ChatFormatting.GREEN + : mood < 40 + ? ChatFormatting.RED + : ChatFormatting.YELLOW; + player.displayClientMessage( + Component.literal("Mood: ") + .withStyle(ChatFormatting.GRAY) + .append( + Component.literal(String.format("%.0f%%", mood)).withStyle( + moodColor + ) + ), + false + ); + + // Needs + var needs = state.getNeeds(); + player.displayClientMessage( + Component.literal("Needs: ") + .withStyle(ChatFormatting.GRAY) + .append( + Component.literal( + String.format( + "Hunger:%.0f Rest:%.0f", + needs.getHunger(), + needs.getRest() + ) + ).withStyle(ChatFormatting.WHITE) + ), + false + ); + + // Bondage state + player.displayClientMessage( + Component.literal("State: ") + .withStyle(ChatFormatting.GRAY) + .append( + Component.literal( + (damsel.isTiedUp() ? "TIED " : "") + + (damsel.isGagged() ? "GAGGED " : "") + + (damsel.isBlindfolded() ? "BLIND " : "") + + (damsel.hasCollar() ? "COLLARED " : "") + + (damsel.isSitting() ? "SIT " : "") + + (damsel.isKneeling() ? "KNEEL " : "") + ).withStyle(ChatFormatting.RED) + ), + false + ); + } + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + tooltip.add( + Component.literal("Debug tool for Personality System").withStyle( + ChatFormatting.GRAY + ) + ); + tooltip.add(Component.literal("")); + tooltip.add( + Component.literal("Right-click: Cycle Personality").withStyle( + ChatFormatting.GREEN + ) + ); + tooltip.add( + Component.literal("Shift + Right-click: Show Status").withStyle( + ChatFormatting.YELLOW + ) + ); + tooltip.add(Component.literal("")); + tooltip.add( + Component.literal("OP Item - Testing Only").withStyle( + ChatFormatting.RED, + ChatFormatting.ITALIC + ) + ); + } + + @Override + public boolean isFoil(ItemStack stack) { + return true; // Enchantment glint to show it's special + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemGpsCollar.java b/src/main/java/com/tiedup/remake/items/ItemGpsCollar.java new file mode 100644 index 0000000..9a01fb8 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemGpsCollar.java @@ -0,0 +1,370 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.ArrayList; +import java.util.List; +import net.minecraft.ChatFormatting; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +/** + + * GPS Collar - Advanced shock collar with tracking and safe zone features. + + * + + *

Mechanics:

+ + *
    + + *
  • Safe Zones: Can store multiple coordinates (SafeSpots) where the wearer is allowed to be.
  • + + *
  • Auto-Shock: If the wearer is outside ALL active safe zones, they are shocked at intervals.
  • + + *
  • Master Warning: Masters receive an alert message when a safe zone violation is detected.
  • + + *
  • Public Tracking: If enabled, allows anyone with a Locator to see distance and direction.
  • + + *
+ + */ + +public class ItemGpsCollar extends ItemShockCollar { + + private static final String NBT_PUBLIC_TRACKING = "publicTracking"; + + private static final String NBT_GPS_ACTIVE = "gpsActive"; + + private static final String NBT_SAFE_SPOTS = "gpsSafeSpots"; + + private static final String NBT_SHOCK_INTERVAL = "gpsShockInterval"; + + private static final String NBT_WARN_MASTERS = "warn_masters"; + + private final int defaultInterval; + + public ItemGpsCollar() { + this(200); // 10 seconds default + } + + public ItemGpsCollar(int defaultInterval) { + super(); + this.defaultInterval = defaultInterval; + } + + @Override + public boolean hasGPS() { + return true; + } + + /** + + * Renders detailed GPS status, safe zone list, and alert settings in the item tooltip. + + */ + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + super.appendHoverText(stack, level, tooltip, flag); + + tooltip.add( + Component.literal("GPS Enabled").withStyle( + ChatFormatting.DARK_GREEN + ) + ); + + if (hasPublicTracking(stack)) { + tooltip.add( + Component.literal("Public Tracking Enabled").withStyle( + ChatFormatting.GREEN + ) + ); + } + + if (shouldWarnMasters(stack)) { + tooltip.add( + Component.literal("Alert Masters on Violation").withStyle( + ChatFormatting.GOLD + ) + ); + } + + List safeSpots = getSafeSpots(stack); + + if (!safeSpots.isEmpty()) { + tooltip.add( + Component.literal("GPS Shocks: ") + .withStyle(ChatFormatting.GREEN) + .append( + Component.literal( + isActive(stack) ? "ENABLED" : "DISABLED" + ).withStyle( + isActive(stack) + ? ChatFormatting.RED + : ChatFormatting.GRAY + ) + ) + ); + + tooltip.add( + Component.literal( + "Safe Spots (" + safeSpots.size() + "):" + ).withStyle(ChatFormatting.GREEN) + ); + + for (int i = 0; i < safeSpots.size(); i++) { + SafeSpot spot = safeSpots.get(i); + + tooltip.add( + Component.literal( + (spot.active ? "[+] " : "[-] ") + + (i + 1) + + ": " + + spot.x + + "," + + spot.y + + "," + + spot.z + + " (Range: " + + spot.distance + + "m)" + ).withStyle(ChatFormatting.GRAY) + ); + } + } + } + + public boolean shouldWarnMasters(ItemStack stack) { + CompoundTag tag = stack.getTag(); + + // Default to true if tag doesn't exist + + return ( + tag == null || + !tag.contains(NBT_WARN_MASTERS) || + tag.getBoolean(NBT_WARN_MASTERS) + ); + } + + public void setWarnMasters(ItemStack stack, boolean warn) { + stack.getOrCreateTag().putBoolean(NBT_WARN_MASTERS, warn); + } + + public boolean hasPublicTracking(ItemStack stack) { + CompoundTag tag = stack.getTag(); + + return tag != null && tag.getBoolean(NBT_PUBLIC_TRACKING); + } + + public void setPublicTracking(ItemStack stack, boolean publicTracking) { + stack.getOrCreateTag().putBoolean(NBT_PUBLIC_TRACKING, publicTracking); + } + + public boolean isActive(ItemStack stack) { + CompoundTag tag = stack.getTag(); + + // Default to active if tag doesn't exist + + return ( + tag == null || + !tag.contains(NBT_GPS_ACTIVE) || + tag.getBoolean(NBT_GPS_ACTIVE) + ); + } + + public void setActive(ItemStack stack, boolean active) { + stack.getOrCreateTag().putBoolean(NBT_GPS_ACTIVE, active); + } + + /** + + * Parses the NBT List into a Java List of SafeSpot objects. + + */ + + public List getSafeSpots(ItemStack stack) { + List list = new ArrayList<>(); + + CompoundTag tag = stack.getTag(); + + if (tag != null && tag.contains(NBT_SAFE_SPOTS)) { + ListTag spotList = tag.getList(NBT_SAFE_SPOTS, 10); + + for (int i = 0; i < spotList.size(); i++) { + list.add(new SafeSpot(spotList.getCompound(i))); + } + } + + return list; + } + + /** + + * Adds a new safe zone to the collar's NBT data. + + */ + + public void addSafeSpot( + ItemStack stack, + int x, + int y, + int z, + String dimension, + int distance + ) { + CompoundTag tag = stack.getOrCreateTag(); + + ListTag spotList = tag.getList(NBT_SAFE_SPOTS, 10); + + SafeSpot spot = new SafeSpot(x, y, z, dimension, distance, true); + + spotList.add(spot.toNBT()); + + tag.put(NBT_SAFE_SPOTS, spotList); + } + + /** + + * Gets frequency of GPS violation shocks. + + */ + + public int getShockInterval(ItemStack stack) { + CompoundTag tag = stack.getTag(); + + if (tag != null && tag.contains(NBT_SHOCK_INTERVAL)) { + return tag.getInt(NBT_SHOCK_INTERVAL); + } + + return defaultInterval; + } + + /** + * Phase 14.1.4: Reset auto-shock timer when GPS collar is removed. + */ + @Override + public void onUnequipped(ItemStack stack, LivingEntity entity) { + // Use IRestrainable interface instead of Player-only + IRestrainable state = KidnappedHelper.getKidnappedState(entity); + if (state != null) { + state.resetAutoShockTimer(); + } + + super.onUnequipped(stack, entity); + } + + /** + + * Represents a defined safe zone in the 3D world. + + */ + + public static class SafeSpot { + + public int x, y, z; + + public String dimension; + + public int distance; + + public boolean active; + + public SafeSpot( + int x, + int y, + int z, + String dimension, + int distance, + boolean active + ) { + this.x = x; + + this.y = y; + + this.z = z; + + this.dimension = dimension; + + this.distance = distance; + + this.active = active; + } + + public SafeSpot(CompoundTag nbt) { + this.x = nbt.getInt("x"); + + this.y = nbt.getInt("y"); + + this.z = nbt.getInt("z"); + + this.dimension = nbt.getString("dim"); + + this.distance = nbt.getInt("dist"); + + this.active = !nbt.contains("active") || nbt.getBoolean("active"); + } + + public CompoundTag toNBT() { + CompoundTag nbt = new CompoundTag(); + + nbt.putInt("x", x); + + nbt.putInt("y", y); + + nbt.putInt("z", z); + + nbt.putString("dim", dimension); + + nbt.putInt("dist", distance); + + nbt.putBoolean("active", active); + + return nbt; + } + + /** + + * Checks if an entity is within the cuboid boundaries of this safe zone. + + * Faithful to original 1.12.2 distance logic. + + */ + + public boolean isInside(Entity entity) { + if (!active) return true; + + // LOW FIX: Cross-dimension GPS fix + // If entity is in a different dimension, consider them as "inside" the zone + // to prevent false positive shocks when traveling between dimensions + if ( + !entity + .level() + .dimension() + .location() + .toString() + .equals(dimension) + ) return true; // Changed from false to true + + // Cuboid distance check + + return ( + Math.abs(entity.getX() - x) < distance && + Math.abs(entity.getY() - y) < distance && + Math.abs(entity.getZ() - z) < distance + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemGpsLocator.java b/src/main/java/com/tiedup/remake/items/ItemGpsLocator.java new file mode 100644 index 0000000..745e325 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemGpsLocator.java @@ -0,0 +1,245 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.items.base.ItemOwnerTarget; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.List; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +public class ItemGpsLocator extends ItemOwnerTarget { + + public ItemGpsLocator() { + super(new net.minecraft.world.item.Item.Properties().stacksTo(1)); + } + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + super.appendHoverText(stack, level, tooltip, flag); + + appendOwnerTooltip(stack, tooltip, "Right-click a player"); + + if (hasTarget(stack)) { + String displayName = resolveTargetDisplayName(stack, level); + tooltip.add( + Component.literal("Target: ") + .withStyle(ChatFormatting.BLUE) + .append( + Component.literal(displayName).withStyle( + ChatFormatting.WHITE + ) + ) + ); + } + } + + @Override + public InteractionResultHolder use( + Level level, + Player player, + InteractionHand hand + ) { + ItemStack stack = player.getItemInHand(hand); + + if (level.isClientSide) return InteractionResultHolder.success(stack); + + if (!hasOwner(stack)) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "You must claim this locator first! (Right-click a player)" + ); + return InteractionResultHolder.fail(stack); + } + + if (!isOwner(stack, player)) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.LOCATOR_NOT_OWNER + ); + return InteractionResultHolder.fail(stack); + } + + if (hasTarget(stack)) { + // Use server player list for cross-dimension tracking + Player target = level + .getServer() + .getPlayerList() + .getPlayer(getTargetId(stack)); + if (target != null) { + IBondageState targetState = KidnappedHelper.getKidnappedState( + target + ); + if (targetState != null && targetState.hasCollar()) { + ItemStack collarStack = targetState.getEquipment(BodyRegionV2.NECK); + if ( + collarStack.getItem() instanceof + ItemGpsCollar collarItem + ) { + if ( + collarItem.isOwner(collarStack, player) || + collarItem.hasPublicTracking(collarStack) + ) { + // Check if same dimension + boolean sameDimension = player + .level() + .dimension() + .equals(target.level().dimension()); + + if (sameDimension) { + double distance = player.distanceTo(target); + String direction = getDirection(player, target); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.LOCATOR_DETECTED, + (int) distance + "m [" + direction + "]" + ); + } else { + // Cross-dimension: show dimension name + String dimName = getDimensionDisplayName( + target + .level() + .dimension() + .location() + .getPath() + ); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.LOCATOR_DETECTED, + "Target is in [" + dimName + "]" + ); + } + + playLocatorSound(player); + } else { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "You are not allowed to access this GPS Collar!" + ); + } + } else { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "Target is not wearing a GPS Collar!" + ); + } + } + } else { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "Unable to locate target! (Offline)" + ); + } + } else { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "No target connected!" + ); + } + + return InteractionResultHolder.success(stack); + } + + /** + * Phase 14.1.5: Refactored to support IBondageState (LivingEntity + NPCs) + */ + @Override + public InteractionResult interactLivingEntity( + ItemStack stack, + Player player, + LivingEntity target, + InteractionHand hand + ) { + if (player.level().isClientSide) return InteractionResult.SUCCESS; + + IBondageState playerState = KidnappedHelper.getKidnappedState(player); + if ( + playerState != null && playerState.isTiedUp() + ) return InteractionResult.FAIL; + + if (!hasOwner(stack)) { + setOwner(stack, player); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.LOCATOR_CLAIMED + ); + } else if (!isOwner(stack, player)) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.LOCATOR_NOT_OWNER + ); + return InteractionResult.FAIL; + } + + IBondageState targetState = KidnappedHelper.getKidnappedState(target); + if (targetState != null) { + setTarget(stack, target); + player.setItemInHand(hand, stack); // Force sync + SystemMessageManager.sendChatToPlayer( + player, + "Connected to " + target.getName().getString(), + ChatFormatting.GREEN + ); + return InteractionResult.SUCCESS; + } + + return InteractionResult.PASS; + } + + private String getDirection(Player source, Player target) { + double dx = target.getX() - source.getX(); + double dz = target.getZ() - source.getZ(); + + if (Math.abs(dx) > Math.abs(dz)) { + return dx > 0 ? "EAST" : "WEST"; + } else { + return dz > 0 ? "SOUTH" : "NORTH"; + } + } + + private void playLocatorSound(Player player) { + player + .level() + .playSound( + null, + player.blockPosition(), + com.tiedup.remake.core.ModSounds.SHOCKER_ACTIVATED.get(), + net.minecraft.sounds.SoundSource.PLAYERS, + 0.5f, + 1.0f + ); + } + + /** + * Get a user-friendly display name for a dimension. + */ + private String getDimensionDisplayName(String dimensionPath) { + return switch (dimensionPath) { + case "overworld" -> "Overworld"; + case "the_nether" -> "The Nether"; + case "the_end" -> "The End"; + default -> dimensionPath.replace("_", " "); + }; + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemHood.java b/src/main/java/com/tiedup/remake/items/ItemHood.java new file mode 100644 index 0000000..42f0b8d --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemHood.java @@ -0,0 +1,36 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.items.base.IHasGaggingEffect; +import com.tiedup.remake.items.base.ItemBlindfold; +import com.tiedup.remake.util.GagMaterial; +import net.minecraft.world.item.Item; + +/** + * Hood - Covers the head completely + * Combines blindfold effect with gagging effect. + * + * Phase 15: Combo item (BLINDFOLD slot + gag effect) + * Extends ItemBlindfold for slot behavior, implements IHasGaggingEffect for speech muffling. + */ +public class ItemHood extends ItemBlindfold implements IHasGaggingEffect { + + private final GagMaterial gagMaterial; + + public ItemHood() { + super(new Item.Properties().stacksTo(16)); + this.gagMaterial = GagMaterial.STUFFED; // Hoods muffle speech like stuffed gags + } + + /** + * Get the gag material type for speech conversion. + * @return The gag material (STUFFED for hoods) + */ + public GagMaterial getGagMaterial() { + return gagMaterial; + } + + @Override + public String getTextureSubfolder() { + return "hoods"; + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemKey.java b/src/main/java/com/tiedup/remake/items/ItemKey.java new file mode 100644 index 0000000..ddd4a54 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemKey.java @@ -0,0 +1,313 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.items.base.ItemOwnerTarget; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.List; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.jetbrains.annotations.Nullable; + +/** + * Collar Key - Used to lock/unlock bondage items via the Slave Management GUI. + * + *

Phase 20: Key-Lock System

+ *
    + *
  • Linking: Right-click a player wearing a collar to link (claim) the key to them.
  • + *
  • Management: Opens SlaveItemManagementScreen to lock/unlock individual items.
  • + *
  • Key UUID: Each key has a unique UUID used to identify which locks it created.
  • + *
  • Security: Only items locked with this key can be unlocked by it.
  • + *
+ */ +public class ItemKey extends ItemOwnerTarget { + + private static final String NBT_KEY_UUID = "keyUUID"; + + public ItemKey() { + super(new net.minecraft.world.item.Item.Properties().durability(64)); + } + + // ========== Phase 20: Key UUID System ========== + + /** + * Get the unique UUID for this key. + * Generates one if it doesn't exist yet. + * This UUID is used to identify locks created by this specific key. + * + * @param stack The key ItemStack + * @return The key's unique UUID + */ + public UUID getKeyUUID(ItemStack stack) { + CompoundTag tag = stack.getOrCreateTag(); + if (!tag.hasUUID(NBT_KEY_UUID)) { + // Generate a new UUID for this key + tag.putUUID(NBT_KEY_UUID, UUID.randomUUID()); + } + return tag.getUUID(NBT_KEY_UUID); + } + + /** + * Check if this key matches the given UUID. + * + * @param stack The key ItemStack + * @param lockUUID The lock's UUID to check against + * @return true if this key matches the lock + */ + public boolean matchesLock(ItemStack stack, UUID lockUUID) { + if (lockUUID == null) return false; + return lockUUID.equals(getKeyUUID(stack)); + } + + /** + * Shows ownership and target information when hovering over the item in inventory. + */ + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + super.appendHoverText(stack, level, tooltip, flag); + + if (hasOwner(stack)) { + tooltip.add( + Component.literal("Owner: ") + .withStyle(ChatFormatting.GOLD) + .append( + Component.literal(getOwnerName(stack)).withStyle( + ChatFormatting.WHITE + ) + ) + ); + } else { + tooltip.add( + Component.literal( + "Unclaimed (Right-click a collar wearer to claim)" + ).withStyle(ChatFormatting.GRAY) + ); + } + + if (hasTarget(stack)) { + tooltip.add( + Component.literal("Target: ") + .withStyle(ChatFormatting.BLUE) + .append( + Component.literal(getTargetName(stack)).withStyle( + ChatFormatting.WHITE + ) + ) + ); + } + + tooltip.add( + Component.literal("Right-click a collared player to toggle LOCK") + .withStyle(ChatFormatting.DARK_GRAY) + .withStyle(ChatFormatting.ITALIC) + ); + } + + /** + * Logic for interacting with entities wearing collars. + * Opens the Slave Item Management GUI to lock/unlock individual items. + * + * Phase 14.1.5: Refactored to support IBondageState (LivingEntity + NPCs) + * Phase 20: Opens GUI instead of direct toggle + */ + @Override + public InteractionResult interactLivingEntity( + ItemStack stack, + Player player, + LivingEntity target, + InteractionHand hand + ) { + // Check if target can wear collars (Player, EntityDamsel, EntityKidnapper) + IBondageState targetState = KidnappedHelper.getKidnappedState(target); + + // Target must be wearing a collar + if (targetState == null || !targetState.hasCollar()) { + if (!player.level().isClientSide) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "Target is not wearing a collar!" + ); + } + return InteractionResult.FAIL; + } + + // Server-side: Handle claiming and validation + if (!player.level().isClientSide) { + // 1. Claim logic - first interaction with a collar wearer links the key + if (!hasOwner(stack)) { + setOwner(stack, player); + setTarget(stack, target); + // Ensure key UUID is generated + getKeyUUID(stack); + + // Also link the player to the collar (become collar owner) + linkPlayerToCollar(player, target, targetState); + + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.KEY_CLAIMED, + target + ); + player.setItemInHand(hand, stack); // Sync NBT to client + return InteractionResult.SUCCESS; + } + + // 2. Ownership check - only the person who claimed the key can use it + if (!isOwner(stack, player)) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.KEY_NOT_OWNER + ); + return InteractionResult.FAIL; + } + + // 3. Target check - this key only fits the entity it was first linked to + if ( + target instanceof Player targetPlayer && + !isTarget(stack, targetPlayer) + ) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.KEY_WRONG_TARGET + ); + return InteractionResult.FAIL; + } else if ( + !(target instanceof Player) && + !target.getUUID().equals(getTargetId(stack)) + ) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.KEY_WRONG_TARGET + ); + return InteractionResult.FAIL; + } + + // Server validation passed - client will open GUI + return InteractionResult.SUCCESS; + } + + // Client-side: Open the Slave Item Management GUI + // Only open if key is already claimed and we're the owner (client trusts server validation) + if (hasOwner(stack) && isOwner(stack, player)) { + // Verify target matches (client-side check for responsiveness) + boolean targetMatches = false; + if (target instanceof Player targetPlayer) { + targetMatches = isTarget(stack, targetPlayer); + } else { + targetMatches = target.getUUID().equals(getTargetId(stack)); + } + + if (targetMatches) { + openUnifiedBondageScreen(target); + } + } + + return InteractionResult.SUCCESS; + } + + /** + * Opens the UnifiedBondageScreen in master mode targeting a specific entity. + * Called from client-side code only. + * + * @param target The living entity to manage bondage for + */ + @OnlyIn(Dist.CLIENT) + private void openUnifiedBondageScreen( + net.minecraft.world.entity.LivingEntity target + ) { + net.minecraft.client.Minecraft.getInstance().setScreen( + new com.tiedup.remake.client.gui.screens.UnifiedBondageScreen(target) + ); + } + + /** + * Link a player to a collar - make them an owner. + * + *

When a key is claimed on a collared entity: + *

    + *
  • Add player as owner to the collar item
  • + *
  • Register the relationship in CollarRegistry
  • + *
+ * + * @param player The player claiming the key + * @param target The collared entity + * @param targetState The target's IBondageState state + */ + private void linkPlayerToCollar( + Player player, + LivingEntity target, + IBondageState targetState + ) { + ItemStack collarStack = targetState.getEquipment(BodyRegionV2.NECK); + if (collarStack.isEmpty()) return; + + if ( + collarStack.getItem() instanceof + com.tiedup.remake.items.base.ItemCollar collar + ) { + // Add player as owner to the collar (if not already) + if (!collar.getOwners(collarStack).contains(player.getUUID())) { + collar.addOwner(collarStack, player); + + // Update the collar in the target's inventory + targetState.equip(BodyRegionV2.NECK, collarStack); + } + + // Register in CollarRegistry (if on server) + if ( + player.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel + ) { + com.tiedup.remake.state.CollarRegistry registry = + com.tiedup.remake.state.CollarRegistry.get(serverLevel); + if (registry != null) { + registry.registerCollar(target.getUUID(), player.getUUID()); + + // Sync the registry to the new owner + if ( + player instanceof + net.minecraft.server.level.ServerPlayer serverPlayer + ) { + java.util.Set slaves = registry.getSlaves( + player.getUUID() + ); + com.tiedup.remake.network.ModNetwork.sendToPlayer( + new com.tiedup.remake.network.sync.PacketSyncCollarRegistry( + slaves + ), + serverPlayer + ); + } + } + + // Sync the target's inventory (collar was modified) + if ( + target instanceof + net.minecraft.server.level.ServerPlayer targetPlayer + ) { + com.tiedup.remake.network.sync.SyncManager.syncInventory( + targetPlayer + ); + } + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemLockpick.java b/src/main/java/com/tiedup/remake/items/ItemLockpick.java new file mode 100644 index 0000000..2a182c3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemLockpick.java @@ -0,0 +1,395 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.List; +import java.util.Random; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.jetbrains.annotations.Nullable; + +/** + * Lockpick item for picking locks on bondage restraints. + * + * Phase 21: Revamped Lockpick System + * + * Behavior: + * - 25% chance of success per attempt + * - SUCCESS: Instant unlock, padlock PRESERVED (lockable=true) + * - FAIL: + * - 2.5% chance to JAM the lock (blocks future lockpick attempts) + * - 15% chance to break the lockpick + * - If shock collar equipped: SHOCK + notify owners + * - Cannot be used while wearing mittens + * - Durability: 10 uses + */ +public class ItemLockpick extends Item { + + private static final Random random = new Random(); + + public ItemLockpick() { + super(new Item.Properties().durability(5)); // 5 tentatives max + } + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + super.appendHoverText(stack, level, tooltip, flag); + + tooltip.add( + Component.translatable("item.tiedup.lockpick.tooltip").withStyle( + ChatFormatting.GRAY + ) + ); + + int remaining = stack.getMaxDamage() - stack.getDamageValue(); + tooltip.add( + Component.literal( + "Uses: " + remaining + "/" + stack.getMaxDamage() + ).withStyle(ChatFormatting.DARK_GRAY) + ); + + // LOW FIX: Removed server config access from client tooltip (desync issue) + // Success/break chances depend on server config, not client config + // Displaying client config values here would be misleading in multiplayer + tooltip.add( + Component.literal("Success/break chances: Check server config") + .withStyle(ChatFormatting.GRAY) + .withStyle(ChatFormatting.ITALIC) + ); + } + + /** + * v2.5: Right-click with lockpick opens the struggle choice screen. + * This allows the player to choose which locked item to pick. + */ + @Override + public InteractionResultHolder use( + Level level, + Player player, + InteractionHand hand + ) { + ItemStack stack = player.getItemInHand(hand); + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + return InteractionResultHolder.pass(stack); + } + + // Block mittens + if (state.hasMittens()) { + if (!level.isClientSide) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.CANT_USE_ITEM_MITTENS + ); + } + return InteractionResultHolder.fail(stack); + } + + // Client side: open the unified bondage screen + if (level.isClientSide) { + openUnifiedBondageScreen(); + return InteractionResultHolder.success(stack); + } + + return InteractionResultHolder.consume(stack); + } + + /** + * Client-only method to open the unified bondage screen. + * Separated to avoid classloading issues on server. + * Uses fully qualified names to prevent class loading on server. + */ + @OnlyIn(Dist.CLIENT) + private void openUnifiedBondageScreen() { + net.minecraft.client.Minecraft.getInstance().setScreen( + new com.tiedup.remake.client.gui.screens.UnifiedBondageScreen() + ); + } + + /** + * Check if this lockpick can be used (has durability remaining). + */ + public static boolean canUse(ItemStack stack) { + if (stack.isEmpty() || !(stack.getItem() instanceof ItemLockpick)) { + return false; + } + return stack.getDamageValue() < stack.getMaxDamage(); + } + + /** + * Result of a lockpick attempt. + */ + public enum PickResult { + /** Successfully picked the lock - item unlocked, padlock preserved */ + SUCCESS, + /** Failed but lock still pickable */ + FAIL, + /** Failed and jammed the lock - lockpick no longer usable on this item */ + JAMMED, + /** Lockpick broke during attempt */ + BROKE, + /** Cannot attempt - mittens equipped */ + BLOCKED_MITTENS, + /** Cannot attempt - lock is jammed */ + BLOCKED_JAMMED, + /** Cannot attempt - item not locked */ + NOT_LOCKED, + } + + /** + * Attempt to pick a lock on a target item. + * + * @param player The player attempting to pick + * @param state The player's bind state + * @param lockpickStack The lockpick being used + * @param targetStack The item to pick + * @param targetRegion The V2 body region of the target item + * @return The result of the pick attempt + */ + public static PickResult attemptPick( + Player player, + PlayerBindState state, + ItemStack lockpickStack, + ItemStack targetStack, + BodyRegionV2 targetRegion + ) { + // Check if lockpick is usable + if (!canUse(lockpickStack)) { + return PickResult.BROKE; + } + + // Check if wearing mittens + if (state.hasMittens()) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.CANT_USE_ITEM_MITTENS + ); + return PickResult.BLOCKED_MITTENS; + } + + // Check if target is lockable and locked + if (!(targetStack.getItem() instanceof ILockable lockable)) { + return PickResult.NOT_LOCKED; + } + + if (!lockable.isLocked(targetStack)) { + return PickResult.NOT_LOCKED; + } + + // Check if lock is jammed + if (lockable.isJammed(targetStack)) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "This lock is jammed! Use struggle instead." + ); + return PickResult.BLOCKED_JAMMED; + } + + // Roll for success + boolean success = + random.nextInt(100) < ModConfig.SERVER.lockpickSuccessChance.get(); + + if (success) { + // SUCCESS: Unlock the item, PRESERVE the padlock + lockable.setLockedByKeyUUID(targetStack, null); // Unlock + lockable.clearLockResistance(targetStack); // Clear struggle progress + // lockable stays true - padlock preserved! + + SystemMessageManager.sendToPlayer( + player, + "Lock picked!", + ChatFormatting.GREEN + ); + + // Damage lockpick + damageLockpick(lockpickStack); + + TiedUpMod.LOGGER.info( + "[LOCKPICK] {} successfully picked lock on {} ({})", + player.getName().getString(), + targetStack.getDisplayName().getString(), + targetRegion + ); + + return PickResult.SUCCESS; + } else { + // FAIL: Various bad things can happen + + // 1. Check for shock collar and trigger shock + triggerShockIfCollar(player, state); + + // 2. Check for jam + boolean jammed = + random.nextDouble() * 100 < + ModConfig.SERVER.lockpickJamChance.get(); + if (jammed) { + lockable.setJammed(targetStack, true); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "The lock jammed! Only struggle can open it now." + ); + + TiedUpMod.LOGGER.info( + "[LOCKPICK] {} jammed the lock on {} ({})", + player.getName().getString(), + targetStack.getDisplayName().getString(), + targetRegion + ); + + // Damage lockpick + boolean broke = damageLockpick(lockpickStack); + return broke ? PickResult.BROKE : PickResult.JAMMED; + } + + // 3. Check for break + boolean broke = + random.nextInt(100) < + ModConfig.SERVER.lockpickBreakChance.get(); + if (broke) { + lockpickStack.shrink(1); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "Lockpick broke!" + ); + + TiedUpMod.LOGGER.info( + "[LOCKPICK] {}'s lockpick broke while picking {} ({})", + player.getName().getString(), + targetStack.getDisplayName().getString(), + targetRegion + ); + + return PickResult.BROKE; + } + + // 4. Normal fail - just damage lockpick + damageLockpick(lockpickStack); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.WARNING, + "Lockpick slipped..." + ); + + TiedUpMod.LOGGER.debug( + "[LOCKPICK] {} failed to pick lock on {} ({})", + player.getName().getString(), + targetStack.getDisplayName().getString(), + targetRegion + ); + + return PickResult.FAIL; + } + } + + /** + * Damage the lockpick by 1 use. + * @return true if the lockpick broke (ran out of durability) + */ + private static boolean damageLockpick(ItemStack stack) { + stack.setDamageValue(stack.getDamageValue() + 1); + if (stack.getDamageValue() >= stack.getMaxDamage()) { + stack.shrink(1); + return true; + } + return false; + } + + /** + * Trigger shock collar if player has one equipped. + * Also notifies the collar owners. + */ + private static void triggerShockIfCollar( + Player player, + PlayerBindState state + ) { + ItemStack collar = V2EquipmentHelper.getInRegion( + player, + BodyRegionV2.NECK + ); + if (collar.isEmpty()) return; + + if ( + collar.getItem() instanceof + com.tiedup.remake.items.ItemShockCollar shockCollar + ) { + // Shock the player + state.shockKidnapped(" (Failed lockpick attempt)", 2.0f); + + // Notify owners + notifyOwnersLockpickAttempt(player, collar, shockCollar); + + TiedUpMod.LOGGER.info( + "[LOCKPICK] {} was shocked for failed lockpick attempt", + player.getName().getString() + ); + } + } + + /** + * Notify shock collar owners about the lockpick attempt. + */ + private static void notifyOwnersLockpickAttempt( + Player player, + ItemStack collar, + com.tiedup.remake.items.ItemShockCollar shockCollar + ) { + if (player.getServer() == null) return; + + Component warning = Component.literal("ALERT: ") + .withStyle(ChatFormatting.RED, ChatFormatting.BOLD) + .append( + Component.literal( + player.getName().getString() + " tried to pick a lock!" + ).withStyle(ChatFormatting.GOLD) + ); + + List owners = shockCollar.getOwners(collar); + for (UUID ownerId : owners) { + ServerPlayer owner = player + .getServer() + .getPlayerList() + .getPlayer(ownerId); + if (owner != null) { + owner.sendSystemMessage(warning); + } + } + } + + /** + * Find a lockpick in the player's inventory. + * @return The first usable lockpick found, or EMPTY if none + */ + public static ItemStack findLockpickInInventory(Player player) { + for (ItemStack stack : player.getInventory().items) { + if (canUse(stack)) { + return stack; + } + } + return ItemStack.EMPTY; + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemMasterKey.java b/src/main/java/com/tiedup/remake/items/ItemMasterKey.java new file mode 100644 index 0000000..b15923b --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemMasterKey.java @@ -0,0 +1,244 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.util.TiedUpSounds; +import java.util.UUID; +import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Master Key - Universal key that opens any padlock. + * + * Phase 15: Full master key implementation + * Phase 20: Opens SlaveItemManagementScreen in master mode + * + * Behavior: + * - Right-click: Opens Slave Management GUI (can unlock any lock) + * - Shift+Right-click: Quick unlock all restraints on target + * - Does not consume the key (reusable) + * - Cannot lock items (unlock only) + */ +public class ItemMasterKey extends Item { + + public ItemMasterKey() { + super(new Item.Properties().stacksTo(8)); + } + + /** + * Called when player right-clicks another entity with the master key. + * Opens the Slave Item Management GUI or quick-unlocks all if sneaking. + * + * @param stack The item stack + * @param player The player using the key + * @param target The entity being interacted with + * @param hand The hand holding the key + * @return SUCCESS if action taken, PASS otherwise + */ + @Override + public InteractionResult interactLivingEntity( + ItemStack stack, + Player player, + LivingEntity target, + InteractionHand hand + ) { + // Check if target can be restrained + IBondageState targetState = KidnappedHelper.getKidnappedState(target); + if (targetState == null) { + return InteractionResult.PASS; + } + + // Target must have a collar + if (!targetState.hasCollar()) { + if (!player.level().isClientSide) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "Target is not wearing a collar!" + ); + } + return InteractionResult.FAIL; + } + + // Server-side: Handle Shift+click quick unlock + if (!player.level().isClientSide) { + if (player.isShiftKeyDown()) { + // Quick unlock all - original behavior + int unlocked = unlockAllRestraints(targetState, target); + + if (unlocked > 0) { + TiedUpSounds.playUnlockSound(target); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "Unlocked " + + unlocked + + " restraint(s) on " + + target.getName().getString() + ); + TiedUpMod.LOGGER.info( + "[ItemMasterKey] {} quick-unlocked {} restraints on {}", + player.getName().getString(), + unlocked, + target.getName().getString() + ); + } else { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "No locked restraints found" + ); + } + return InteractionResult.SUCCESS; + } + // Normal click - validation only, client opens GUI + return InteractionResult.SUCCESS; + } + + // Client-side: Open GUI (normal click only) + if (!player.isShiftKeyDown()) { + openUnifiedBondageScreen(target); + } + + return InteractionResult.SUCCESS; + } + + /** + * Opens the UnifiedBondageScreen in master mode targeting a specific entity. + * + * @param target The living entity to manage bondage for + */ + @OnlyIn(Dist.CLIENT) + private void openUnifiedBondageScreen( + net.minecraft.world.entity.LivingEntity target + ) { + net.minecraft.client.Minecraft.getInstance().setScreen( + new com.tiedup.remake.client.gui.screens.UnifiedBondageScreen(target) + ); + } + + /** + * Unlock all locked restraints on the target. + * Uses setLockedByKeyUUID(null) to properly clear the lock. + * Optionally drops padlocks if the item's dropLockOnUnlock() returns true. + * + * @param targetState The target's IBondageState state + * @param target The target entity (for dropping items) + * @return Number of items unlocked + */ + private int unlockAllRestraints( + IBondageState targetState, + LivingEntity target + ) { + int unlocked = 0; + + // Unlock bind + ItemStack bind = targetState.getEquipment(BodyRegionV2.ARMS); + if (!bind.isEmpty() && bind.getItem() instanceof ILockable lockable) { + if (lockable.isLocked(bind)) { + lockable.setLockedByKeyUUID(bind, null); // Clear lock with keyUUID system + unlocked++; + if (lockable.dropLockOnUnlock()) { + dropPadlock(targetState); + } + } + } + + // Unlock gag + ItemStack gag = targetState.getEquipment(BodyRegionV2.MOUTH); + if (!gag.isEmpty() && gag.getItem() instanceof ILockable lockable) { + if (lockable.isLocked(gag)) { + lockable.setLockedByKeyUUID(gag, null); + unlocked++; + if (lockable.dropLockOnUnlock()) { + dropPadlock(targetState); + } + } + } + + // Unlock blindfold + ItemStack blindfold = targetState.getEquipment(BodyRegionV2.EYES); + if ( + !blindfold.isEmpty() && + blindfold.getItem() instanceof ILockable lockable + ) { + if (lockable.isLocked(blindfold)) { + lockable.setLockedByKeyUUID(blindfold, null); + unlocked++; + if (lockable.dropLockOnUnlock()) { + dropPadlock(targetState); + } + } + } + + // Unlock earplugs + ItemStack earplugs = targetState.getEquipment(BodyRegionV2.EARS); + if ( + !earplugs.isEmpty() && + earplugs.getItem() instanceof ILockable lockable + ) { + if (lockable.isLocked(earplugs)) { + lockable.setLockedByKeyUUID(earplugs, null); + unlocked++; + if (lockable.dropLockOnUnlock()) { + dropPadlock(targetState); + } + } + } + + // Unlock collar + ItemStack collar = targetState.getEquipment(BodyRegionV2.NECK); + if ( + !collar.isEmpty() && collar.getItem() instanceof ILockable lockable + ) { + if (lockable.isLocked(collar)) { + lockable.setLockedByKeyUUID(collar, null); + unlocked++; + if (lockable.dropLockOnUnlock()) { + dropPadlock(targetState); + } + } + } + + // Unlock mittens + ItemStack mittens = targetState.getEquipment(BodyRegionV2.HANDS); + if ( + !mittens.isEmpty() && + mittens.getItem() instanceof ILockable lockable + ) { + if (lockable.isLocked(mittens)) { + lockable.setLockedByKeyUUID(mittens, null); + unlocked++; + if (lockable.dropLockOnUnlock()) { + dropPadlock(targetState); + } + } + } + + return unlocked; + } + + /** + * Drop a padlock item near the target. + * + * @param targetState The target's IBondageState state + */ + private void dropPadlock(IBondageState targetState) { + // Create a padlock item to drop + ItemStack padlock = new ItemStack( + com.tiedup.remake.items.ModItems.PADLOCK.get() + ); + targetState.kidnappedDropItem(padlock); + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemMedicalGag.java b/src/main/java/com/tiedup/remake/items/ItemMedicalGag.java new file mode 100644 index 0000000..5cca5a7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemMedicalGag.java @@ -0,0 +1,25 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.items.base.IHasBlindingEffect; +import com.tiedup.remake.items.base.ItemGag; +import com.tiedup.remake.util.GagMaterial; +import net.minecraft.world.item.Item; + +/** + * Medical Gag - Full face medical restraint + * Combines gag effect with blinding effect. + * + * Phase 15: Combo item (GAG slot + blinding effect) + * Extends ItemGag for slot behavior, implements IHasBlindingEffect for vision obstruction. + */ +public class ItemMedicalGag extends ItemGag implements IHasBlindingEffect { + + public ItemMedicalGag() { + super(new Item.Properties().stacksTo(16), GagMaterial.PANEL); + } + + @Override + public String getTextureSubfolder() { + return "straps"; + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemPaddle.java b/src/main/java/com/tiedup/remake/items/ItemPaddle.java new file mode 100644 index 0000000..1528652 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemPaddle.java @@ -0,0 +1,90 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.util.TiedUpSounds; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +/** + * Paddle - Tool for disciplining NPCs (gentle discipline). + * + * Phase 7: Basic paddle + * Refactored: Tighten moved to keybind, paddle now only does discipline on NPCs + */ +public class ItemPaddle extends Item { + + public ItemPaddle() { + super( + new Item.Properties() + .stacksTo(1) // Paddles don't stack (tool) + .durability(64) // 64 uses + ); + } + + /** + * Called when player right-clicks another entity with the paddle. + * Applies gentle discipline to NPCs. + * + * Note: Tighten functionality moved to keybind (key.tiedup.tighten) + * + * @param stack The item stack + * @param player The player using the paddle + * @param target The entity being interacted with + * @param hand The hand holding the paddle + * @return SUCCESS if discipline applied, PASS otherwise + */ + @Override + public InteractionResult interactLivingEntity( + ItemStack stack, + Player player, + LivingEntity target, + InteractionHand hand + ) { + // Only run on server side + if (player.level().isClientSide) { + return InteractionResult.SUCCESS; + } + + // NPC discipline - visual/sound feedback only (no personality effect) + if (target instanceof com.tiedup.remake.entities.AbstractTiedUpNpc npc) { + // Visual feedback - gentler than whip + TiedUpSounds.playSlapSound(target); + if (player.level() instanceof ServerLevel serverLevel) { + serverLevel.sendParticles( + ParticleTypes.SMOKE, + target.getX(), + target.getY() + target.getBbHeight() / 2.0, + target.getZ(), + 5, + 0.3, + 0.3, + 0.3, + 0.05 + ); + } + + // Consume durability + stack.hurtAndBreak(1, player, p -> p.broadcastBreakEvent(hand)); + + TiedUpMod.LOGGER.debug( + "[ItemPaddle] {} disciplined {} with paddle", + player.getName().getString(), + target.getName().getString() + ); + + return InteractionResult.SUCCESS; + } + + // Paddle only works on NPCs now + // Use keybind (T) to tighten binds on any target + return InteractionResult.PASS; + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemPadlock.java b/src/main/java/com/tiedup/remake/items/ItemPadlock.java new file mode 100644 index 0000000..7af9388 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemPadlock.java @@ -0,0 +1,45 @@ +package com.tiedup.remake.items; + +import java.util.List; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +/** + * Padlock - Used to make bondage items lockable. + * + * Phase 20: Anvil-based padlock attachment + * + * Usage: + * - Combine with a bondage item (ILockable) in an Anvil + * - The item becomes "lockable" (can be locked with a Key) + * - Cost: 1 XP level + * + * @see com.tiedup.remake.events.system.AnvilEventHandler + */ +public class ItemPadlock extends Item { + + public ItemPadlock() { + super(new Item.Properties().stacksTo(16)); + } + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + super.appendHoverText(stack, level, tooltip, flag); + + tooltip.add( + Component.translatable("item.tiedup.padlock.tooltip").withStyle( + ChatFormatting.GRAY + ) + ); + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemRag.java b/src/main/java/com/tiedup/remake/items/ItemRag.java new file mode 100644 index 0000000..1bca9b5 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemRag.java @@ -0,0 +1,289 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.core.SettingsAccessor; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.List; +import net.minecraft.ChatFormatting; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +/** + * Rag - Can be soaked in chloroform to knock out targets + * Has wet/dry state managed via NBT. + * + * Phase 15: Full chloroform system implementation + * + * Usage: + * 1. Hold chloroform bottle, rag in offhand, right-click → rag becomes wet + * 2. Hold wet rag, right-click target → apply chloroform effect + * 3. Wet rag evaporates over time (configurable) + * + * Effects on target: + * - Slowness 127 (cannot move) + * - Blindness + * - Nausea + * - UNCONSCIOUS pose + */ +public class ItemRag extends Item { + + private static final String NBT_WET = "wet"; + private static final String NBT_WET_TIME = "wetTime"; // Ticks remaining + + public ItemRag() { + super(new Item.Properties().stacksTo(16)); + } + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + if (isWet(stack)) { + int ticksRemaining = getWetTime(stack); + int secondsRemaining = ticksRemaining / 20; + tooltip.add( + Component.literal("Soaked with chloroform").withStyle( + ChatFormatting.GREEN + ) + ); + tooltip.add( + Component.literal( + "Evaporates in: " + secondsRemaining + "s" + ).withStyle(ChatFormatting.GRAY) + ); + } else { + tooltip.add( + Component.literal("Dry - needs chloroform").withStyle( + ChatFormatting.GRAY + ) + ); + } + } + + /** + * Called when player right-clicks another entity with the rag. + * If wet, applies chloroform effect to the target. + */ + @Override + public InteractionResult interactLivingEntity( + ItemStack stack, + Player player, + LivingEntity target, + InteractionHand hand + ) { + // Only run on server side + if (player.level().isClientSide) { + return InteractionResult.SUCCESS; + } + + // Must be wet to apply chloroform + if (!isWet(stack)) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.RAG_DRY + ); + return InteractionResult.PASS; + } + + // Apply chloroform to target + applyChloroformToTarget(target, player); + + // The rag stays wet (can be used multiple times until it evaporates) + + TiedUpMod.LOGGER.info( + "[ItemRag] {} applied chloroform to {}", + player.getName().getString(), + target.getName().getString() + ); + + return InteractionResult.SUCCESS; + } + + /** + * Tick the rag to handle evaporation of wet state. + */ + @Override + public void inventoryTick( + ItemStack stack, + Level level, + Entity entity, + int slot, + boolean selected + ) { + if (level.isClientSide) return; + + if (isWet(stack)) { + int wetTime = getWetTime(stack); + if (wetTime > 0) { + setWetTime(stack, wetTime - 1); + } else { + // Evaporated + setWet(stack, false); + if (entity instanceof Player player) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.RAG_EVAPORATED + ); + } + TiedUpMod.LOGGER.debug("[ItemRag] Chloroform evaporated"); + } + } + } + + /** + * Apply chloroform effect to a target. + * Effects: Slowness 127, Blindness, Nausea for configured duration. + * + * @param target The target entity + * @param player The player applying chloroform + */ + private void applyChloroformToTarget(LivingEntity target, Player player) { + // Get duration from config via SettingsAccessor (single source of truth) + int duration = SettingsAccessor.getChloroformDuration(); + + // Apply effects + // Slowness 127 = cannot move at all + target.addEffect( + new MobEffectInstance( + MobEffects.MOVEMENT_SLOWDOWN, + duration, + 127, + false, + false + ) + ); + // Blindness + target.addEffect( + new MobEffectInstance( + MobEffects.BLINDNESS, + duration, + 0, + false, + false + ) + ); + // Nausea (confusion) + target.addEffect( + new MobEffectInstance( + MobEffects.CONFUSION, + duration, + 0, + false, + false + ) + ); + // Weakness (cannot fight back) + target.addEffect( + new MobEffectInstance( + MobEffects.WEAKNESS, + duration, + 127, + false, + false + ) + ); + + // If target is IRestrainable, call applyChloroform to apply effects + IRestrainable kidnapped = KidnappedHelper.getKidnappedState(target); + if (kidnapped != null) { + kidnapped.applyChloroform(duration); + } + + TiedUpMod.LOGGER.debug( + "[ItemRag] Applied chloroform to target for {} seconds", + duration + ); + } + + // ========== Wet/Dry State Management ========== + + /** + * Check if this rag is soaked with chloroform. + * @param stack The item stack + * @return true if wet with chloroform + */ + public static boolean isWet(ItemStack stack) { + if (stack.isEmpty()) return false; + CompoundTag tag = stack.getTag(); + return tag != null && tag.getBoolean(NBT_WET); + } + + /** + * Set the wet state of this rag. + * @param stack The item stack + * @param wet true to make wet, false for dry + */ + public static void setWet(ItemStack stack, boolean wet) { + if (stack.isEmpty()) return; + stack.getOrCreateTag().putBoolean(NBT_WET, wet); + if (!wet) { + // Clear wet time when drying + stack.getOrCreateTag().remove(NBT_WET_TIME); + } + } + + /** + * Get the remaining wet time in ticks. + * @param stack The item stack + * @return Ticks remaining, or 0 if not wet + */ + public static int getWetTime(ItemStack stack) { + if (stack.isEmpty()) return 0; + CompoundTag tag = stack.getTag(); + return tag != null ? tag.getInt(NBT_WET_TIME) : 0; + } + + /** + * Set the remaining wet time in ticks. + * @param stack The item stack + * @param ticks Ticks remaining + */ + public static void setWetTime(ItemStack stack, int ticks) { + if (stack.isEmpty()) return; + stack.getOrCreateTag().putInt(NBT_WET_TIME, ticks); + } + + /** + * Soak this rag with chloroform. + * Sets wet = true and initializes the evaporation timer. + * + * @param stack The item stack + * @param wetTime Time in ticks before evaporation + */ + public static void soak(ItemStack stack, int wetTime) { + if (stack.isEmpty()) return; + setWet(stack, true); + setWetTime(stack, wetTime); + TiedUpMod.LOGGER.debug( + "[ItemRag] Soaked with chloroform ({} ticks)", + wetTime + ); + } + + /** + * Get the default wet time for soaking. + * @return Default wet time in ticks + */ + public static int getDefaultWetTime() { + return ModConfig.SERVER.ragWetTime.get(); + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemRopeArrow.java b/src/main/java/com/tiedup/remake/items/ItemRopeArrow.java new file mode 100644 index 0000000..1e76de6 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemRopeArrow.java @@ -0,0 +1,44 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.entities.EntityRopeArrow; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.projectile.AbstractArrow; +import net.minecraft.world.item.ArrowItem; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * Rope Arrow - Arrow that ties up targets on hit + * When fired from a bow and hits an entity, it has 75% chance to bind them. + * + * Phase 15: Full rope arrow implementation + * + * Behavior: + * - Works like regular arrows for firing + * - On hit: 75% chance to bind the target with rope + * - Target must be IRestrainable (Player, Damsel, Kidnapper) + */ +public class ItemRopeArrow extends ArrowItem { + + public ItemRopeArrow() { + super(new Properties().stacksTo(64)); + } + + /** + * Create the arrow entity when fired from a bow. + * Returns EntityRopeArrow for special binding behavior on hit. + * + * @param level The world + * @param stack The arrow item stack + * @param shooter The entity firing the bow + * @return EntityRopeArrow instance + */ + @Override + public AbstractArrow createArrow( + Level level, + ItemStack stack, + LivingEntity shooter + ) { + return new EntityRopeArrow(level, shooter); + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemShockCollar.java b/src/main/java/com/tiedup/remake/items/ItemShockCollar.java new file mode 100644 index 0000000..3a22f36 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemShockCollar.java @@ -0,0 +1,133 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.List; +import net.minecraft.ChatFormatting; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +/** + * Shock Collar - Advanced collar that can be remotely triggered. + * + *

Mechanics:

+ *
    + *
  • Remote Shocking: Can be triggered by anyone holding a linked Shocker Controller.
  • + *
  • Struggle Penalty: If locked, has a chance to shock the wearer during struggle attempts, interrupting them.
  • + *
  • Public Mode: Can be set to public mode, allowing anyone to shock the wearer even if they aren't the owner.
  • + *
+ */ +public class ItemShockCollar extends ItemCollar { + + private static final String NBT_PUBLIC_MODE = "public_mode"; + + public ItemShockCollar() { + super(new Item.Properties()); + } + + @Override + public boolean canShock() { + return true; + } + + /** + * Shows current mode (PUBLIC/PRIVATE) and usage instructions in tooltip. + */ + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + super.appendHoverText(stack, level, tooltip, flag); + + tooltip.add( + Component.literal("Shock Feature: ") + .withStyle(ChatFormatting.YELLOW) + .append( + Component.literal( + isPublic(stack) ? "PUBLIC" : "PRIVATE" + ).withStyle( + isPublic(stack) + ? ChatFormatting.GREEN + : ChatFormatting.RED + ) + ) + ); + + tooltip.add( + Component.literal("Shift + Right-click to toggle public mode") + .withStyle(ChatFormatting.DARK_GRAY) + .withStyle(ChatFormatting.ITALIC) + ); + } + + /** + * Toggles Public mode when shift-right-clicking in air. + */ + @Override + public InteractionResultHolder use( + Level level, + Player player, + InteractionHand hand + ) { + ItemStack stack = player.getItemInHand(hand); + + if (player.isShiftKeyDown()) { + if (!level.isClientSide) { + boolean newState = !isPublic(stack); + setPublic(stack, newState); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.SHOCKER_MODE_SET, + (newState ? "PUBLIC" : "PRIVATE") + ); + } + return InteractionResultHolder.sidedSuccess( + stack, + level.isClientSide() + ); + } + + return super.use(level, player, hand); + } + + /** + * Handles the risk of shocking the wearer during a struggle attempt. + * + * NOTE: For the new continuous struggle mini-game, shock logic is handled + * directly in MiniGameSessionManager.tickContinuousSessions(). This method + * is now a no-op that always returns true, kept for API compatibility. + * + * @param entity The wearer of the collar + * @param stack The collar instance + * @return Always true (shock logic moved to MiniGameSessionManager) + */ + public boolean notifyStruggle(LivingEntity entity, ItemStack stack) { + // Shock collar checks during continuous struggle are now handled by + // MiniGameSessionManager.shouldTriggerShock() with 10% chance every 5 seconds. + // This method is kept for backwards compatibility but no longer performs the check. + return true; + } + + public boolean isPublic(ItemStack stack) { + CompoundTag tag = stack.getTag(); + return tag != null && tag.getBoolean(NBT_PUBLIC_MODE); + } + + public void setPublic(ItemStack stack, boolean publicMode) { + stack.getOrCreateTag().putBoolean(NBT_PUBLIC_MODE, publicMode); + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemShockCollarAuto.java b/src/main/java/com/tiedup/remake/items/ItemShockCollarAuto.java new file mode 100644 index 0000000..d43c8ec --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemShockCollarAuto.java @@ -0,0 +1,60 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.util.KidnappedHelper; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; + +/** + * Automatic Shock Collar - Shocks the wearer at regular intervals. + * + * Phase 14.1.5: Refactored to support IRestrainable (LivingEntity + NPCs) + * + *

Mechanics:

+ *
    + *
  • Self-Triggering: Has an internal timer stored in NBT that shocks the entity when it reaches 0.
  • + *
  • Unstruggable: By default, cannot be escaped via struggle mechanics (requires key).
  • + *
+ */ +public class ItemShockCollarAuto extends ItemShockCollar { + + private final int interval; + + /** + * @param interval Frequency of shocks in TICKS (20 ticks = 1 second). + */ + public ItemShockCollarAuto() { + this(600); // 30 seconds default + } + + public ItemShockCollarAuto(int interval) { + super(); + this.interval = interval; + } + + public int getInterval() { + return interval; + } + + /** + * Ensures the internal shock timer is cleaned up when the item is removed. + * + * Phase 14.1.5: Refactored to support IRestrainable (LivingEntity + NPCs) + */ + @Override + public void onUnequipped(ItemStack stack, LivingEntity entity) { + IRestrainable state = KidnappedHelper.getKidnappedState(entity); + if (state != null) { + state.resetAutoShockTimer(); + } + super.onUnequipped(stack, entity); + } + + /** + * Prevents escaping through struggle mechanics for this specific collar type. + */ + @Override + public boolean canBeStruggledOut(ItemStack stack) { + return false; + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemShockerController.java b/src/main/java/com/tiedup/remake/items/ItemShockerController.java new file mode 100644 index 0000000..a39e677 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemShockerController.java @@ -0,0 +1,398 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.core.ModSounds; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.items.base.ItemOwnerTarget; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +public class ItemShockerController extends ItemOwnerTarget { + + private static final String NBT_BROADCAST = "broadcast"; + private static final String NBT_RADIUS = "radius"; + + public ItemShockerController() { + super(new net.minecraft.world.item.Item.Properties().stacksTo(1)); + } + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + super.appendHoverText(stack, level, tooltip, flag); + + appendOwnerTooltip(stack, tooltip, "Right-click a player"); + + if (isBroadcastEnabled(stack)) { + tooltip.add( + Component.literal("MODE: BROADCAST").withStyle( + ChatFormatting.DARK_RED + ) + ); + tooltip.add( + Component.literal("(Affects ALL your slaves in radius)") + .withStyle(ChatFormatting.GRAY) + .withStyle(ChatFormatting.ITALIC) + ); + } else { + tooltip.add( + Component.literal("MODE: TARGETED").withStyle( + ChatFormatting.BLUE + ) + ); + if (hasTarget(stack)) { + String displayName = getTargetName(stack); + boolean isDisconnected = true; + + if (level != null) { + Player target = level.getPlayerByUUID(getTargetId(stack)); + if (target != null) { + IRestrainable targetState = + KidnappedHelper.getKidnappedState(target); + if (targetState != null && targetState.hasCollar()) { + isDisconnected = false; + ItemStack collar = targetState.getEquipment(BodyRegionV2.NECK); + if ( + collar.getItem() instanceof + ItemCollar collarItem && + collarItem.hasNickname(collar) + ) { + displayName = + collarItem.getNickname(collar) + + " (" + + displayName + + ")"; + } + } + } + } + + MutableComponent targetComp = Component.literal(" > ") + .withStyle(ChatFormatting.BLUE) + .append( + Component.literal(displayName).withStyle( + isDisconnected + ? ChatFormatting.STRIKETHROUGH + : ChatFormatting.WHITE + ) + ); + + if (isDisconnected) { + targetComp.append( + Component.literal(" [FREED]") + .withStyle(ChatFormatting.RED) + .withStyle(ChatFormatting.BOLD) + ); + } + tooltip.add(targetComp); + } else { + tooltip.add( + Component.literal(" > No target connected").withStyle( + ChatFormatting.GRAY + ) + ); + } + } + + tooltip.add( + Component.literal("Radius: " + getRadius(stack) + "m").withStyle( + ChatFormatting.GREEN + ) + ); + tooltip.add( + Component.literal("Shift + Right-click to toggle Broadcast mode") + .withStyle(ChatFormatting.DARK_GRAY) + .withStyle(ChatFormatting.ITALIC) + ); + } + + @Override + public InteractionResultHolder use( + Level level, + Player player, + InteractionHand hand + ) { + ItemStack stack = player.getItemInHand(hand); + + if (player.isShiftKeyDown()) { + if (!level.isClientSide) { + if (hasOwner(stack) && !isOwner(stack, player)) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.SHOCKER_NOT_OWNER + ); + return InteractionResultHolder.fail(stack); + } + + boolean newState = !isBroadcastEnabled(stack); + setBroadcastEnabled(stack, newState); + player.setItemInHand(hand, stack); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.SHOCKER_MODE_SET, + (newState ? "BROADCAST" : "TARGETED") + ); + } + return InteractionResultHolder.sidedSuccess( + stack, + level.isClientSide() + ); + } + + if (level.isClientSide) return InteractionResultHolder.success(stack); + + IRestrainable playerState = KidnappedHelper.getKidnappedState(player); + if ( + playerState != null && playerState.isTiedUp() + ) return InteractionResultHolder.fail(stack); + + if (!hasOwner(stack)) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "You must claim this shocker first! (Right-click a player)" + ); + return InteractionResultHolder.fail(stack); + } + + if (!isOwner(stack, player)) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.SHOCKER_NOT_OWNER + ); + return InteractionResultHolder.fail(stack); + } + + List nearbyTargets = getNearbyKidnappedTargets( + level, + player, + stack + ); + + if (isBroadcastEnabled(stack)) { + for (LivingEntity target : nearbyTargets) { + IRestrainable targetState = KidnappedHelper.getKidnappedState( + target + ); + if (targetState != null) targetState.shockKidnapped(); + } + + if (nearbyTargets.isEmpty()) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "No valid targets in range!" + ); + } else { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.INFO, + "Broadcast shock triggered! (" + + nearbyTargets.size() + + " targets)" + ); + playTriggerSound(player); + } + } else if (hasTarget(stack)) { + Player target = level.getPlayerByUUID(getTargetId(stack)); + IRestrainable targetState = + target != null + ? KidnappedHelper.getKidnappedState(target) + : null; + + if ( + target != null && + targetState != null && + targetState.hasCollar() && + nearbyTargets.contains(target) + ) { + targetState.shockKidnapped(); + String name = target.getName().getString(); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.SHOCKER_TRIGGERED, + name + ); + playTriggerSound(player); + } else { + String error = (target == null) + ? "Target is out of range or in another dimension!" + : (!targetState.hasCollar() + ? "Target is no longer wearing a collar!" + : "Target is out of range!"); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + error + ); + } + } else { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "No target set and broadcast is disabled!" + ); + } + + return InteractionResultHolder.success(stack); + } + + /** + * Phase 14.1.5: Refactored to support IRestrainable (LivingEntity + NPCs) + */ + @Override + public InteractionResult interactLivingEntity( + ItemStack stack, + Player player, + LivingEntity target, + InteractionHand hand + ) { + if (player.level().isClientSide) return InteractionResult.SUCCESS; + + IRestrainable playerState = KidnappedHelper.getKidnappedState(player); + if ( + playerState != null && playerState.isTiedUp() + ) return InteractionResult.FAIL; + + // Claim shocker if unclaimed + if (!hasOwner(stack)) { + setOwner(stack, player); + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.SHOCKER_CLAIMED + ); + } else if (!isOwner(stack, player)) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.SHOCKER_NOT_OWNER + ); + return InteractionResult.FAIL; + } + + // Connect to target (works with any LivingEntity that can be kidnapped) + IRestrainable targetState = KidnappedHelper.getKidnappedState(target); + if (targetState != null) { + setTarget(stack, target); + player.setItemInHand(hand, stack); + SystemMessageManager.sendChatToPlayer( + player, + "Connected to " + target.getName().getString(), + ChatFormatting.GREEN + ); + return InteractionResult.SUCCESS; + } + + return InteractionResult.PASS; + } + + /** + * Phase 14.1.5: New method to support LivingEntity (Players + NPCs) + * Returns all kidnappable entities in range wearing shock collars owned by the shocker owner or in public mode. + */ + private List getNearbyKidnappedTargets( + Level level, + Player source, + ItemStack stack + ) { + double radius = getRadius(stack); + UUID ownerId = getOwnerId(stack); + List targets = new ArrayList<>(); + + // Check all living entities in range + for (LivingEntity entity : level.getEntitiesOfClass( + LivingEntity.class, + source.getBoundingBox().inflate(radius) + )) { + if (entity == source) continue; + + IRestrainable state = KidnappedHelper.getKidnappedState(entity); + if (state != null && state.hasCollar()) { + ItemStack collarStack = state.getEquipment(BodyRegionV2.NECK); + if ( + collarStack.getItem() instanceof ItemShockCollar collarItem + ) { + if ( + collarItem.getOwners(collarStack).contains(ownerId) || + collarItem.isPublic(collarStack) + ) { + targets.add(entity); + } + } + } + } + return targets; + } + + private void playTriggerSound(Player player) { + player + .level() + .playSound( + null, + player.blockPosition(), + ModSounds.SHOCKER_ACTIVATED.get(), + net.minecraft.sounds.SoundSource.PLAYERS, + 0.5f, + 1.0f + ); + } + + public boolean isBroadcastEnabled(ItemStack stack) { + CompoundTag tag = stack.getTag(); + return tag != null && tag.getBoolean(NBT_BROADCAST); + } + + public void setBroadcastEnabled(ItemStack stack, boolean enabled) { + stack.getOrCreateTag().putBoolean(NBT_BROADCAST, enabled); + } + + public int getRadius(ItemStack stack) { + CompoundTag tag = stack.getTag(); + return (tag != null && tag.contains(NBT_RADIUS)) + ? tag.getInt(NBT_RADIUS) + : com.tiedup.remake.core.SettingsAccessor.getShockerControllerRadius(null); + } + + public void setRadius(ItemStack stack, int radius) { + stack.getOrCreateTag().putInt(NBT_RADIUS, radius); + } + + public static ItemStack mergeShockers(List stacks) { + if (stacks == null || stacks.size() <= 1) return ItemStack.EMPTY; + + int totalRadius = 0; + for (ItemStack s : stacks) { + if (s.getItem() instanceof ItemShockerController sc) { + totalRadius += sc.getRadius(s); + } + } + + ItemStack result = new ItemStack(stacks.get(0).getItem()); + ((ItemShockerController) result.getItem()).setRadius( + result, + totalRadius + ); + return result; + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemTaser.java b/src/main/java/com/tiedup/remake/items/ItemTaser.java new file mode 100644 index 0000000..6c48c79 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemTaser.java @@ -0,0 +1,116 @@ +package com.tiedup.remake.items; + +import com.google.common.collect.ImmutableMultimap; +import com.google.common.collect.Multimap; +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.core.ModSounds; +import java.util.UUID; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.attributes.Attribute; +import net.minecraft.world.entity.ai.attributes.AttributeModifier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +/** + * Taser - Kidnapper's defensive weapon + * + * Used by kidnappers when attacked while holding a captive. + * On hit: + * - Plays electric shock sound + * - Applies Slowness I + Weakness I for 5 seconds + * - Deals 3 hearts of damage + */ +public class ItemTaser extends Item { + + /** UUID for the attack damage modifier */ + private static final UUID ATTACK_DAMAGE_UUID = UUID.fromString( + "CB3F55D3-645C-4F38-A497-9C13A33DB5CF" + ); + + private final Multimap defaultModifiers; + + public ItemTaser() { + super(new Item.Properties().stacksTo(1).durability(64)); + // Build attribute modifiers for attack damage + // NOTE: Using default value 5.0 (ModConfig not loaded yet during item registration) + ImmutableMultimap.Builder builder = + ImmutableMultimap.builder(); + builder.put( + Attributes.ATTACK_DAMAGE, + new AttributeModifier( + ATTACK_DAMAGE_UUID, + "Weapon modifier", + 5.0, // Default damage (matches ModConfig default) + AttributeModifier.Operation.ADDITION + ) + ); + this.defaultModifiers = builder.build(); + } + + /** + * Called when this item is used to attack an entity. + * Applies shock effects on successful hit. + */ + @Override + public boolean hurtEnemy( + ItemStack stack, + LivingEntity target, + LivingEntity attacker + ) { + // Play electric shock sound + target + .level() + .playSound( + null, + target.blockPosition(), + ModSounds.ELECTRIC_SHOCK.get(), + SoundSource.HOSTILE, + 1.0f, + 1.0f + ); + + int duration = ModConfig.SERVER.taserStunDuration.get(); + + // Apply Slowness I + target.addEffect( + new MobEffectInstance( + MobEffects.MOVEMENT_SLOWDOWN, + duration, + 0 // Amplifier 0 = level I + ) + ); + + // Apply Weakness I + target.addEffect( + new MobEffectInstance( + MobEffects.WEAKNESS, + duration, + 0 // Amplifier 0 = level I + ) + ); + + // Consume durability + stack.hurtAndBreak(1, attacker, e -> + e.broadcastBreakEvent(EquipmentSlot.MAINHAND) + ); + + return true; + } + + /** + * Get attribute modifiers for this item when equipped. + */ + @Override + public Multimap getDefaultAttributeModifiers( + EquipmentSlot slot + ) { + return slot == EquipmentSlot.MAINHAND + ? this.defaultModifiers + : super.getDefaultAttributeModifiers(slot); + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemTiedUpGuide.java b/src/main/java/com/tiedup/remake/items/ItemTiedUpGuide.java new file mode 100644 index 0000000..245b920 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemTiedUpGuide.java @@ -0,0 +1,86 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.core.TiedUpMod; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraftforge.fml.ModList; +import net.minecraftforge.registries.ForgeRegistries; +import org.jetbrains.annotations.NotNull; + +/** + * TiedUp! Guide Book Item + * + * When used, gives the player the Patchouli guide_book item with the correct NBT. + * If Patchouli is not installed, displays a message. + */ +public class ItemTiedUpGuide extends Item { + + public ItemTiedUpGuide() { + super(new Item.Properties().stacksTo(1)); + } + + @Override + public @NotNull InteractionResultHolder use( + @NotNull Level level, + @NotNull Player player, + @NotNull InteractionHand hand + ) { + ItemStack stack = player.getItemInHand(hand); + + if (!level.isClientSide()) { + // Check if Patchouli is installed + if (!ModList.get().isLoaded("patchouli")) { + player.displayClientMessage( + Component.literal( + "§cPatchouli is not installed! Install it to use this guide." + ), + false + ); + return InteractionResultHolder.fail(stack); + } + + // Get the Patchouli guide_book item + Item guideBookItem = ForgeRegistries.ITEMS.getValue( + ResourceLocation.fromNamespaceAndPath("patchouli", "guide_book") + ); + if (guideBookItem == null) { + player.displayClientMessage( + Component.literal( + "§cFailed to find Patchouli guide_book item." + ), + false + ); + return InteractionResultHolder.fail(stack); + } + + // Create the guide book with NBT pointing to our book + ItemStack guideBook = new ItemStack(guideBookItem); + CompoundTag nbt = new CompoundTag(); + nbt.putString("patchouli:book", TiedUpMod.MOD_ID + ":guide"); + guideBook.setTag(nbt); + + // Give the player the guide book + if (!player.getInventory().add(guideBook)) { + // Drop if inventory is full + player.drop(guideBook, false); + } + + // Consume this item + stack.shrink(1); + + player.displayClientMessage( + Component.literal("§aReceived TiedUp! Guide Book!"), + true + ); + } + + return InteractionResultHolder.consume(stack); + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemToken.java b/src/main/java/com/tiedup/remake/items/ItemToken.java new file mode 100644 index 0000000..2219cd0 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemToken.java @@ -0,0 +1,71 @@ +package com.tiedup.remake.items; + +import java.util.List; +import javax.annotation.Nullable; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Rarity; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; + +/** + * ItemToken - Access pass for kidnapper camps. + * + * Slave Trader & Maid System + * + * Behavior: + * - Reusable (no durability, permanent) + * - Drop: 5% chance from killed kidnappers + * - Effect: Kidnappers won't target the holder + * - Effect: Allows peaceful interaction with SlaveTrader + * + * When a player has a token in their inventory: + * - EntityKidnapper.canTarget() returns false + * - EntitySlaveTrader opens trade menu instead of attacking + */ +public class ItemToken extends Item { + + public ItemToken() { + super(new Item.Properties().stacksTo(1).rarity(Rarity.RARE)); + } + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + tooltip.add( + Component.literal("Camp Access Token").withStyle( + ChatFormatting.GOLD, + ChatFormatting.BOLD + ) + ); + tooltip.add(Component.literal("")); + tooltip.add( + Component.literal("Kidnappers won't target you").withStyle( + ChatFormatting.GREEN + ) + ); + tooltip.add( + Component.literal("Allows trading with Slave Traders").withStyle( + ChatFormatting.GREEN + ) + ); + tooltip.add(Component.literal("")); + tooltip.add( + Component.literal("Keep in your inventory for effect").withStyle( + ChatFormatting.GRAY, + ChatFormatting.ITALIC + ) + ); + } + + @Override + public boolean isFoil(ItemStack stack) { + return true; // Always glowing to indicate special item + } +} diff --git a/src/main/java/com/tiedup/remake/items/ItemWhip.java b/src/main/java/com/tiedup/remake/items/ItemWhip.java new file mode 100644 index 0000000..54b2cf4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ItemWhip.java @@ -0,0 +1,181 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.core.ModConfig; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.util.TiedUpSounds; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +/** + * Whip - Tool for discipline + * Right-click a tied entity to deal damage and decrease their resistance. + * + * Phase 15: Full whip mechanics implementation + * + * Effects: + * - Deals damage (configurable) + * - Decreases bind resistance (configurable) + * - Plays whip crack sound + * - Shows damage particles + * - Consumes durability + * + * Opposite of paddle (which increases resistance). + */ +public class ItemWhip extends Item { + + public ItemWhip() { + super(new Item.Properties().stacksTo(1).durability(256)); + } + + /** + * Called when player right-clicks another entity with the whip. + * Deals damage and decreases resistance if target is restrained. + * + * @param stack The item stack + * @param player The player using the whip + * @param target The entity being interacted with + * @param hand The hand holding the whip + * @return SUCCESS if whipping happened, PASS otherwise + */ + @Override + public InteractionResult interactLivingEntity( + ItemStack stack, + Player player, + LivingEntity target, + InteractionHand hand + ) { + // Only run on server side + if (player.level().isClientSide) { + return InteractionResult.SUCCESS; + } + + // NPC whip - visual/sound feedback only (no personality effect) + if (target instanceof EntityDamsel damsel) { + // Set whip time for anti-flee system (stops fleeing for ~10 seconds) + damsel.setLastWhipTime(player.level().getGameTime()); + + // Visual feedback + TiedUpSounds.playWhipSound(target); + if (player.level() instanceof ServerLevel serverLevel) { + serverLevel.sendParticles( + ParticleTypes.CRIT, + target.getX(), + target.getY() + target.getBbHeight() / 2.0, + target.getZ(), + 10, + 0.5, + 0.5, + 0.5, + 0.1 + ); + } + + // Consume durability + stack.hurtAndBreak(1, player, p -> p.broadcastBreakEvent(hand)); + return InteractionResult.SUCCESS; + } + + // Check if target can be restrained (Player, EntityDamsel, EntityKidnapper) + IBondageState targetState = KidnappedHelper.getKidnappedState(target); + if (targetState == null || !targetState.isTiedUp()) { + return InteractionResult.PASS; + } + + float damage = ModConfig.SERVER.whipDamage.get().floatValue(); + int resistanceDecrease = ModConfig.SERVER.whipResistanceDecrease.get(); + + // 1. Play whip sound + TiedUpSounds.playWhipSound(target); + + // 2. Deal damage + DamageSource damageSource = player.damageSources().playerAttack(player); + target.hurt(damageSource, damage); + + // 3. Show damage particles (critical hit particles) + if (player.level() instanceof ServerLevel serverLevel) { + serverLevel.sendParticles( + ParticleTypes.CRIT, + target.getX(), + target.getY() + target.getBbHeight() / 2.0, + target.getZ(), + 10, // count + 0.5, + 0.5, + 0.5, // spread + 0.1 // speed + ); + } + + // 4. Decrease resistance + decreaseResistance(targetState, target, resistanceDecrease); + + // 5. Damage the whip (consume durability) + stack.hurtAndBreak(1, player, p -> { + p.broadcastBreakEvent(hand); + }); + + TiedUpMod.LOGGER.debug( + "[ItemWhip] {} whipped {} (damage: {}, resistance -{})", + player.getName().getString(), + target.getName().getString(), + damage, + resistanceDecrease + ); + + return InteractionResult.SUCCESS; + } + + /** + * Decrease the target's bind resistance. + * Works for both players (via PlayerBindState) and NPCs. + * + * @param targetState The target's IBondageState state + * @param target The target entity + * @param amount The amount to decrease + */ + private void decreaseResistance( + IBondageState targetState, + LivingEntity target, + int amount + ) { + if (target instanceof Player player) { + // For players, use PlayerBindState + PlayerBindState bindState = PlayerBindState.getInstance(player); + int currentResistance = bindState.getCurrentBindResistance(); + int newResistance = Math.max(0, currentResistance - amount); + bindState.setCurrentBindResistance(newResistance); + + // MEDIUM FIX: Sync resistance change to client + // Resistance is stored in bind item NBT, so we must sync inventory + // Without this, client still shows old resistance value in UI + // Sync V2 equipment (resistance NBT changed on the stored ItemStack) + if (player instanceof net.minecraft.server.level.ServerPlayer serverPlayer) { + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.sync(serverPlayer); + } + + TiedUpMod.LOGGER.debug( + "[ItemWhip] Player resistance: {} -> {}", + currentResistance, + newResistance + ); + } else { + // For NPCs, resistance is not tracked the same way + // Just log the whip action (NPC doesn't struggle, so resistance is less relevant) + TiedUpMod.LOGGER.debug( + "[ItemWhip] Whipped NPC (resistance not tracked for NPCs)" + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/items/ModCreativeTabs.java b/src/main/java/com/tiedup/remake/items/ModCreativeTabs.java new file mode 100644 index 0000000..1b6ead0 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ModCreativeTabs.java @@ -0,0 +1,215 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.blocks.ModBlocks; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.KidnapperItemSelector; +import com.tiedup.remake.items.base.*; +import com.tiedup.remake.v2.V2Items; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.CreativeModeTab; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.RegistryObject; + +/** + * Creative Mode Tabs Registration + * Defines the creative inventory tabs where TiedUp items will appear. + * + * Updated to use factory pattern with enum-based item registration. + */ +@SuppressWarnings("null") // Minecraft API guarantees non-null returns +public class ModCreativeTabs { + + public static final DeferredRegister CREATIVE_MODE_TABS = + DeferredRegister.create(Registries.CREATIVE_MODE_TAB, TiedUpMod.MOD_ID); + + public static final RegistryObject TIEDUP_TAB = + CREATIVE_MODE_TABS.register("tiedup_tab", () -> + CreativeModeTab.builder() + .title(Component.translatable("itemGroup.tiedup")) + .icon(() -> new ItemStack(ModItems.getBind(BindVariant.ROPES))) + .displayItems((parameters, output) -> { + // ========== BINDS (from enum) ========== + for (BindVariant variant : BindVariant.values()) { + // Add base item + output.accept(ModItems.getBind(variant)); + + // Add colored variants if supported + if (variant.supportsColor()) { + for (ItemColor color : ItemColor.values()) { + // Skip special colors (caution, clear) except for duct tape + if ( + color.isSpecial() && + variant != BindVariant.DUCT_TAPE + ) continue; + // Use validation method to check if color has texture + if ( + KidnapperItemSelector.isColorValidForBind( + color, + variant + ) + ) { + output.accept( + KidnapperItemSelector.createBind( + variant, + color + ) + ); + } + } + } + } + + // ========== GAGS (from enum) ========== + for (GagVariant variant : GagVariant.values()) { + // Add base item + output.accept(ModItems.getGag(variant)); + + // Add colored variants if supported + if (variant.supportsColor()) { + for (ItemColor color : ItemColor.values()) { + // Skip special colors (caution, clear) except for tape gag + if ( + color.isSpecial() && + variant != GagVariant.TAPE_GAG + ) continue; + // Use validation method to check if color has texture + if ( + KidnapperItemSelector.isColorValidForGag( + color, + variant + ) + ) { + output.accept( + KidnapperItemSelector.createGag( + variant, + color + ) + ); + } + } + } + } + + // ========== BLINDFOLDS (from enum) ========== + for (BlindfoldVariant variant : BlindfoldVariant.values()) { + // Add base item + output.accept(ModItems.getBlindfold(variant)); + + // Add colored variants if supported + if (variant.supportsColor()) { + for (ItemColor color : ItemColor.values()) { + // Skip special colors for blindfolds + if (color.isSpecial()) continue; + // Use validation method to check if color has texture + if ( + KidnapperItemSelector.isColorValidForBlindfold( + color, + variant + ) + ) { + output.accept( + KidnapperItemSelector.createBlindfold( + variant, + color + ) + ); + } + } + } + } + + // Hood (combo item, not in enum) + output.accept(ModItems.HOOD.get()); + + // ========== 3D ITEMS ========== + output.accept(ModItems.BALL_GAG_3D.get()); + + // ========== COMBO ITEMS ========== + output.accept(ModItems.MEDICAL_GAG.get()); + + // ========== CLOTHES ========== + output.accept(ModItems.CLOTHES.get()); + + // ========== COLLARS ========== + output.accept(ModItems.CLASSIC_COLLAR.get()); + output.accept(ModItems.CHOKE_COLLAR.get()); + output.accept(ModItems.SHOCK_COLLAR.get()); + output.accept(ModItems.GPS_COLLAR.get()); + + // ========== EARPLUGS (from enum) ========== + for (EarplugsVariant variant : EarplugsVariant.values()) { + output.accept(ModItems.getEarplugs(variant)); + } + + // ========== MITTENS (from enum) ========== + for (MittensVariant variant : MittensVariant.values()) { + output.accept(ModItems.getMittens(variant)); + } + + // ========== KNIVES (from enum) ========== + for (KnifeVariant variant : KnifeVariant.values()) { + output.accept(ModItems.getKnife(variant)); + } + + // ========== OTHER TOOLS ========== + output.accept(ModItems.WHIP.get()); + output.accept(ModItems.PADDLE.get()); + output.accept(ModItems.SHOCKER_CONTROLLER.get()); + output.accept(ModItems.GPS_LOCATOR.get()); + output.accept(ModItems.COLLAR_KEY.get()); + output.accept(ModItems.LOCKPICK.get()); + output.accept(ModItems.COMMAND_WAND.get()); + + // ========== SPECIAL ITEMS ========== + output.accept(ModItems.CHLOROFORM_BOTTLE.get()); + output.accept(ModItems.RAG.get()); + output.accept(ModItems.PADLOCK.get()); + output.accept(ModItems.MASTER_KEY.get()); + output.accept(ModItems.ROPE_ARROW.get()); + output.accept(ModItems.TOKEN.get()); + + // ========== SPAWN EGGS ========== + output.accept(ModItems.DAMSEL_SPAWN_EGG.get()); + + // ========== GUIDE BOOK ========== + output.accept(ModItems.TIEDUP_GUIDE.get()); + + // ========== BLOCKS ========== + output.accept(ModBlocks.PADDED_BLOCK.get()); + output.accept(ModBlocks.PADDED_SLAB.get()); + output.accept(ModBlocks.PADDED_STAIRS.get()); + output.accept(ModBlocks.ROPE_TRAP.get()); + output.accept(ModBlocks.KIDNAP_BOMB.get()); + output.accept(ModBlocks.TRAPPED_CHEST.get()); + output.accept(ModBlocks.CELL_DOOR.get()); + output.accept(ModBlocks.CELL_CORE.get()); + + // ========== V2 PET FURNITURE ========== + output.accept(V2Items.PET_BOWL.get()); + output.accept(V2Items.PET_BED.get()); + output.accept(V2Items.PET_CAGE.get()); + + // ========== V2 BONDAGE ITEMS ========== + output.accept(com.tiedup.remake.v2.bondage.V2BondageItems.V2_HANDCUFFS.get()); + + // ========== DATA-DRIVEN BONDAGE ITEMS ========== + for (com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition def : + com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry.getAll()) { + output.accept( + com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem.createStack(def.id()) + ); + } + + // ========== FURNITURE PLACER ITEMS ========== + for (com.tiedup.remake.v2.furniture.FurnitureDefinition def : + com.tiedup.remake.v2.furniture.FurnitureRegistry.getAll()) { + output.accept( + com.tiedup.remake.v2.furniture.FurniturePlacerItem.createStack(def.id()) + ); + } + }) + .build() + ); +} diff --git a/src/main/java/com/tiedup/remake/items/ModItems.java b/src/main/java/com/tiedup/remake/items/ModItems.java new file mode 100644 index 0000000..9fdc040 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/ModItems.java @@ -0,0 +1,411 @@ +package com.tiedup.remake.items; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.ModEntities; +import com.tiedup.remake.items.base.*; +import com.tiedup.remake.items.bondage3d.gags.ItemBallGag3D; +import com.tiedup.remake.items.clothes.GenericClothes; +import java.util.EnumMap; +import java.util.Map; +import net.minecraft.world.item.Item; +import net.minecraftforge.common.ForgeSpawnEggItem; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegistryObject; + +/** + * Mod Items Registration + * Handles registration of all TiedUp items using DeferredRegister. + * + * Refactored with Factory Pattern: + * - Binds, Gags, Blindfolds, Earplugs, Knives use EnumMaps and factory methods + * - Complex items (collars, whip, chloroform, etc.) remain individual registrations + * + * Usage: + * - ModItems.getBind(BindVariant.ROPES) - Get a specific bind item + * - ModItems.getGag(GagVariant.BALL_GAG) - Get a specific gag item + * - ModItems.WHIP.get() - Get complex items directly + */ +public class ModItems { + + // DeferredRegister for items + public static final DeferredRegister ITEMS = DeferredRegister.create( + ForgeRegistries.ITEMS, + TiedUpMod.MOD_ID + ); + + // ========== FACTORY-BASED ITEMS ========== + + /** + * All bind items (15 variants via BindVariant enum) + */ + public static final Map> BINDS = + registerAllBinds(); + + /** + * All gag items (via GagVariant enum) + * Note: ItemMedicalGag is registered separately as it has special behavior + * Note: BALL_GAG_3D is a separate 3D item (not in enum) + */ + public static final Map> GAGS = + registerAllGags(); + + /** + * Ball Gag 3D - Uses 3D OBJ model rendering via dedicated class. + * This is a separate item from BALL_GAG (which uses 2D textures). + */ + public static final RegistryObject BALL_GAG_3D = ITEMS.register( + "ball_gag_3d", + ItemBallGag3D::new + ); + + /** + * All blindfold items (2 variants via BlindfoldVariant enum) + */ + public static final Map> BLINDFOLDS = + registerAllBlindfolds(); + + /** + * All earplugs items (1 variant via EarplugsVariant enum) + */ + public static final Map> EARPLUGS = + registerAllEarplugs(); + + /** + * All knife items (3 variants via KnifeVariant enum) + */ + public static final Map> KNIVES = + registerAllKnives(); + + /** + * All mittens items (1 variant via MittensVariant enum) + * Phase 14.4: Blocks hand interactions when equipped + */ + public static final Map> MITTENS = + registerAllMittens(); + + /** + * Clothes item - uses dynamic textures from URLs. + * Users can create presets via anvil naming. + */ + public static final RegistryObject CLOTHES = ITEMS.register( + "clothes", + GenericClothes::new + ); + + // ========== COMPLEX ITEMS (individual registrations) ========== + + // Medical gag - combo item with IHasBlindingEffect + public static final RegistryObject MEDICAL_GAG = ITEMS.register( + "medical_gag", + ItemMedicalGag::new + ); + + // Hood - combo item + public static final RegistryObject HOOD = ITEMS.register( + "hood", + ItemHood::new + ); + + // Collars - complex logic + public static final RegistryObject CLASSIC_COLLAR = ITEMS.register( + "classic_collar", + ItemClassicCollar::new + ); + + public static final RegistryObject SHOCK_COLLAR = ITEMS.register( + "shock_collar", + ItemShockCollar::new + ); + + public static final RegistryObject SHOCK_COLLAR_AUTO = ITEMS.register( + "shock_collar_auto", + ItemShockCollarAuto::new + ); + + public static final RegistryObject GPS_COLLAR = ITEMS.register( + "gps_collar", + ItemGpsCollar::new + ); + + // Choke Collar - Pet play collar used by Masters + public static final RegistryObject CHOKE_COLLAR = ITEMS.register( + "choke_collar", + ItemChokeCollar::new + ); + + // Tools with complex behavior + public static final RegistryObject WHIP = ITEMS.register( + "whip", + ItemWhip::new + ); + + public static final RegistryObject CHLOROFORM_BOTTLE = ITEMS.register( + "chloroform_bottle", + ItemChloroformBottle::new + ); + + public static final RegistryObject RAG = ITEMS.register( + "rag", + ItemRag::new + ); + + public static final RegistryObject PADLOCK = ITEMS.register( + "padlock", + ItemPadlock::new + ); + + public static final RegistryObject MASTER_KEY = ITEMS.register( + "master_key", + ItemMasterKey::new + ); + + public static final RegistryObject ROPE_ARROW = ITEMS.register( + "rope_arrow", + ItemRopeArrow::new + ); + + public static final RegistryObject PADDLE = ITEMS.register( + "paddle", + ItemPaddle::new + ); + + public static final RegistryObject SHOCKER_CONTROLLER = + ITEMS.register("shocker_controller", ItemShockerController::new); + + public static final RegistryObject GPS_LOCATOR = ITEMS.register( + "gps_locator", + ItemGpsLocator::new + ); + + public static final RegistryObject COLLAR_KEY = ITEMS.register( + "collar_key", + ItemKey::new + ); + + // Phase 20: Lockpick for picking locks without keys + public static final RegistryObject LOCKPICK = ITEMS.register( + "lockpick", + ItemLockpick::new + ); + + // Taser - Kidnapper's defensive weapon (Fight Back system) + public static final RegistryObject TASER = ITEMS.register( + "taser", + ItemTaser::new + ); + + // TiedUp! Guide Book - Opens Patchouli documentation + public static final RegistryObject TIEDUP_GUIDE = ITEMS.register( + "tiedup_guide", + ItemTiedUpGuide::new + ); + + // Command Wand - Gives commands to collared NPCs (Personality System) + public static final RegistryObject COMMAND_WAND = ITEMS.register( + "command_wand", + ItemCommandWand::new + ); + + // Debug Wand - Testing tool for Personality System (OP item) + public static final RegistryObject DEBUG_WAND = ITEMS.register( + "debug_wand", + ItemDebugWand::new + ); + + // ========== CELL SYSTEM ITEMS ========== + + // Admin Wand - Structure marker placement and Cell Core management + public static final RegistryObject ADMIN_WAND = ITEMS.register( + "admin_wand", + ItemAdminWand::new + ); + + // Cell Key - Universal key for iron bar doors + public static final RegistryObject CELL_KEY = ITEMS.register( + "cell_key", + ItemCellKey::new + ); + + // ========== SLAVE TRADER SYSTEM ========== + + // Token - Access pass for kidnapper camps + public static final RegistryObject TOKEN = ITEMS.register( + "token", + ItemToken::new + ); + + // ========== SPAWN EGGS ========== + + /** + * Damsel Spawn Egg + * Colors: Light Pink (0xFFB6C1) / Hot Pink (0xFF69B4) + */ + public static final RegistryObject DAMSEL_SPAWN_EGG = ITEMS.register( + "damsel_spawn_egg", + () -> + new ForgeSpawnEggItem( + ModEntities.DAMSEL, + 0xFFB6C1, // Light pink (primary) + 0xFF69B4, // Hot pink (secondary) + new Item.Properties() + ) + ); + + // ========== FACTORY METHODS ========== + + private static Map> registerAllBinds() { + Map> map = new EnumMap<>( + BindVariant.class + ); + for (BindVariant variant : BindVariant.values()) { + map.put( + variant, + ITEMS.register(variant.getRegistryName(), () -> + new GenericBind(variant) + ) + ); + } + return map; + } + + private static Map> registerAllGags() { + Map> map = new EnumMap<>( + GagVariant.class + ); + for (GagVariant variant : GagVariant.values()) { + map.put( + variant, + ITEMS.register(variant.getRegistryName(), () -> + new GenericGag(variant) + ) + ); + } + return map; + } + + private static Map< + BlindfoldVariant, + RegistryObject + > registerAllBlindfolds() { + Map> map = new EnumMap<>( + BlindfoldVariant.class + ); + for (BlindfoldVariant variant : BlindfoldVariant.values()) { + map.put( + variant, + ITEMS.register(variant.getRegistryName(), () -> + new GenericBlindfold(variant) + ) + ); + } + return map; + } + + private static Map< + EarplugsVariant, + RegistryObject + > registerAllEarplugs() { + Map> map = new EnumMap<>( + EarplugsVariant.class + ); + for (EarplugsVariant variant : EarplugsVariant.values()) { + map.put( + variant, + ITEMS.register(variant.getRegistryName(), () -> + new GenericEarplugs(variant) + ) + ); + } + return map; + } + + private static Map> registerAllKnives() { + Map> map = new EnumMap<>( + KnifeVariant.class + ); + for (KnifeVariant variant : KnifeVariant.values()) { + map.put( + variant, + ITEMS.register(variant.getRegistryName(), () -> + new GenericKnife(variant) + ) + ); + } + return map; + } + + private static Map< + MittensVariant, + RegistryObject + > registerAllMittens() { + Map> map = new EnumMap<>( + MittensVariant.class + ); + for (MittensVariant variant : MittensVariant.values()) { + map.put( + variant, + ITEMS.register(variant.getRegistryName(), () -> + new GenericMittens(variant) + ) + ); + } + return map; + } + + // ========== HELPER ACCESSORS ========== + + /** + * Get a bind item by variant. + * @param variant The bind variant + * @return The bind item + */ + public static Item getBind(BindVariant variant) { + return BINDS.get(variant).get(); + } + + /** + * Get a gag item by variant. + * @param variant The gag variant + * @return The gag item + */ + public static Item getGag(GagVariant variant) { + return GAGS.get(variant).get(); + } + + /** + * Get a blindfold item by variant. + * @param variant The blindfold variant + * @return The blindfold item + */ + public static Item getBlindfold(BlindfoldVariant variant) { + return BLINDFOLDS.get(variant).get(); + } + + /** + * Get an earplugs item by variant. + * @param variant The earplugs variant + * @return The earplugs item + */ + public static Item getEarplugs(EarplugsVariant variant) { + return EARPLUGS.get(variant).get(); + } + + /** + * Get a knife item by variant. + * @param variant The knife variant + * @return The knife item + */ + public static Item getKnife(KnifeVariant variant) { + return KNIVES.get(variant).get(); + } + + /** + * Get a mittens item by variant. + * @param variant The mittens variant + * @return The mittens item + */ + public static Item getMittens(MittensVariant variant) { + return MITTENS.get(variant).get(); + } +} diff --git a/src/main/java/com/tiedup/remake/items/base/AdjustmentHelper.java b/src/main/java/com/tiedup/remake/items/base/AdjustmentHelper.java new file mode 100644 index 0000000..e7d6727 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/AdjustmentHelper.java @@ -0,0 +1,173 @@ +package com.tiedup.remake.items.base; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.util.Mth; +import net.minecraft.world.item.ItemStack; + +/** + * Helper class for reading/writing adjustment values to ItemStack NBT. + * + * Adjustment values represent vertical offset in pixels (-4.0 to +4.0). + * These are stored in the ItemStack's NBT and automatically synced to clients + * via the equipment sync system (PacketSyncV2Equipment). + */ +public class AdjustmentHelper { + + /** NBT key for Y adjustment value */ + public static final String NBT_ADJUSTMENT_Y = "AdjustY"; + + /** NBT key for scale adjustment value */ + public static final String NBT_ADJUSTMENT_SCALE = "AdjustScale"; + + /** Default adjustment value (no offset) */ + public static final float DEFAULT_VALUE = 0.0f; + + /** Minimum allowed adjustment value */ + public static final float MIN_VALUE = -4.0f; + + /** Maximum allowed adjustment value */ + public static final float MAX_VALUE = 4.0f; + + /** Minimum allowed scale value */ + public static final float MIN_SCALE = 0.5f; + + /** Maximum allowed scale value */ + public static final float MAX_SCALE = 2.0f; + + /** Default scale value (no scaling) */ + public static final float DEFAULT_SCALE = 1.0f; + + /** Scale adjustment step */ + public static final float SCALE_STEP = 0.1f; + + /** + * Get the Y adjustment value from an ItemStack. + * + * @param stack The ItemStack to read from + * @return adjustment in pixels (-4.0 to +4.0), or default if not set + */ + public static float getAdjustment(ItemStack stack) { + if (stack.isEmpty()) { + return DEFAULT_VALUE; + } + + CompoundTag tag = stack.getTag(); + if (tag != null && tag.contains(NBT_ADJUSTMENT_Y)) { + return tag.getFloat(NBT_ADJUSTMENT_Y); + } + + // Fallback to item's default adjustment + if (stack.getItem() instanceof IAdjustable adj) { + return adj.getDefaultAdjustment(); + } + + return DEFAULT_VALUE; + } + + /** + * Set the Y adjustment value on an ItemStack. + * Value is clamped to the valid range. + * + * @param stack The ItemStack to modify + * @param value The adjustment value in pixels + */ + public static void setAdjustment(ItemStack stack, float value) { + if (stack.isEmpty()) { + return; + } + + float clamped = Mth.clamp(value, MIN_VALUE, MAX_VALUE); + stack.getOrCreateTag().putFloat(NBT_ADJUSTMENT_Y, clamped); + } + + /** + * Check if an ItemStack has a custom adjustment set. + * + * @param stack The ItemStack to check + * @return true if a custom adjustment is stored in NBT + */ + public static boolean hasAdjustment(ItemStack stack) { + if (stack.isEmpty()) { + return false; + } + CompoundTag tag = stack.getTag(); + return tag != null && tag.contains(NBT_ADJUSTMENT_Y); + } + + /** + * Remove custom adjustment from an ItemStack, reverting to item default. + * + * @param stack The ItemStack to modify + */ + public static void clearAdjustment(ItemStack stack) { + if (stack.isEmpty()) { + return; + } + CompoundTag tag = stack.getTag(); + if (tag != null) { + tag.remove(NBT_ADJUSTMENT_Y); + } + } + + /** + * Convert pixel adjustment to Minecraft units for PoseStack.translate(). + * 1 pixel = 1/16 block in Minecraft's coordinate system. + * + * Note: The result is negated because positive adjustment values should + * move the item UP (negative Y in model space). + * + * @param pixels Adjustment value in pixels + * @return Offset in Minecraft units for PoseStack.translate() + */ + public static double toMinecraftUnits(float pixels) { + return -pixels / 16.0; + } + + /** + * Check if an ItemStack's item supports adjustment. + * + * @param stack The ItemStack to check + * @return true if the item implements IAdjustable and canBeAdjusted() returns true + */ + public static boolean isAdjustable(ItemStack stack) { + if (stack.isEmpty()) { + return false; + } + if (stack.getItem() instanceof IAdjustable adj) { + return adj.canBeAdjusted(); + } + return false; + } + + /** + * Get the scale adjustment value from an ItemStack. + * + * @param stack The ItemStack to read from + * @return scale factor (0.5 to 2.0), or 1.0 if not set + */ + public static float getScale(ItemStack stack) { + if (stack.isEmpty()) { + return DEFAULT_SCALE; + } + CompoundTag tag = stack.getTag(); + if (tag != null && tag.contains(NBT_ADJUSTMENT_SCALE)) { + return tag.getFloat(NBT_ADJUSTMENT_SCALE); + } + return DEFAULT_SCALE; + } + + /** + * Set the scale adjustment value on an ItemStack. + * Value is clamped to the valid range. + * + * @param stack The ItemStack to modify + * @param value The scale value (0.5 to 2.0) + */ + public static void setScale(ItemStack stack, float value) { + if (stack.isEmpty()) { + return; + } + float clamped = Mth.clamp(value, MIN_SCALE, MAX_SCALE); + stack.getOrCreateTag().putFloat(NBT_ADJUSTMENT_SCALE, clamped); + } +} diff --git a/src/main/java/com/tiedup/remake/items/base/BindVariant.java b/src/main/java/com/tiedup/remake/items/base/BindVariant.java new file mode 100644 index 0000000..12dee31 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/BindVariant.java @@ -0,0 +1,88 @@ +package com.tiedup.remake.items.base; + +/** + * Enum defining all bind variants with their properties. + * Used by GenericBind to create bind items via factory pattern. + * + *

Issue #12 fix: Added textureSubfolder to eliminate 40+ string checks in renderers. + */ +public enum BindVariant { + // Standard binds (PoseType.STANDARD) + ROPES("ropes", PoseType.STANDARD, true, "ropes"), + ARMBINDER("armbinder", PoseType.STANDARD, false, "armbinder"), + DOGBINDER("dogbinder", PoseType.DOG, false, "armbinder"), + CHAIN("chain", PoseType.STANDARD, false, "chain"), + RIBBON("ribbon", PoseType.STANDARD, false, "ribbon"), + SLIME("slime", PoseType.STANDARD, false, "slime"), + VINE_SEED("vine_seed", PoseType.STANDARD, false, "vine"), + WEB_BIND("web_bind", PoseType.STANDARD, false, "web"), + SHIBARI("shibari", PoseType.STANDARD, true, "shibari"), + LEATHER_STRAPS("leather_straps", PoseType.STANDARD, false, "straps"), + MEDICAL_STRAPS("medical_straps", PoseType.STANDARD, false, "straps"), + BEAM_CUFFS("beam_cuffs", PoseType.STANDARD, false, "beam"), + DUCT_TAPE("duct_tape", PoseType.STANDARD, true, "tape"), + + // Pose items (special PoseType) + STRAITJACKET("straitjacket", PoseType.STRAITJACKET, false, "straitjacket"), + WRAP("wrap", PoseType.WRAP, false, "wrap"), + LATEX_SACK("latex_sack", PoseType.LATEX_SACK, false, "latex"); + + private final String registryName; + private final PoseType poseType; + private final boolean supportsColor; + private final String textureSubfolder; + + BindVariant( + String registryName, + PoseType poseType, + boolean supportsColor, + String textureSubfolder + ) { + this.registryName = registryName; + this.poseType = poseType; + this.supportsColor = supportsColor; + this.textureSubfolder = textureSubfolder; + } + + public String getRegistryName() { + return registryName; + } + + /** + * Get the configured resistance for this bind variant. + * Delegates to {@link com.tiedup.remake.core.SettingsAccessor#getBindResistance(String)}. + */ + public int getResistance() { + return com.tiedup.remake.core.SettingsAccessor.getBindResistance(registryName); + } + + public PoseType getPoseType() { + return poseType; + } + + /** + * Check if this bind variant supports color variations. + * Items with colors: ropes, shibari, duct_tape + */ + public boolean supportsColor() { + return supportsColor; + } + + /** + * Get the texture subfolder for this bind variant. + * Used by renderers to locate texture files. + * + * @return Subfolder path under textures/entity/bondage/ (e.g., "ropes", "straps") + */ + public String getTextureSubfolder() { + return textureSubfolder; + } + + /** + * Get the item name used for textures and translations. + * For most variants this is the same as registryName. + */ + public String getItemName() { + return registryName; + } +} diff --git a/src/main/java/com/tiedup/remake/items/base/BlindfoldVariant.java b/src/main/java/com/tiedup/remake/items/base/BlindfoldVariant.java new file mode 100644 index 0000000..41cbc36 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/BlindfoldVariant.java @@ -0,0 +1,48 @@ +package com.tiedup.remake.items.base; + +/** + * Enum defining all blindfold variants. + * Used by GenericBlindfold to create blindfold items via factory pattern. + * + *

Issue #12 fix: Added textureSubfolder to eliminate string checks in renderers. + */ +public enum BlindfoldVariant { + CLASSIC("classic_blindfold", true, "blindfolds"), + MASK("blindfold_mask", true, "blindfolds/mask"); + + private final String registryName; + private final boolean supportsColor; + private final String textureSubfolder; + + BlindfoldVariant( + String registryName, + boolean supportsColor, + String textureSubfolder + ) { + this.registryName = registryName; + this.supportsColor = supportsColor; + this.textureSubfolder = textureSubfolder; + } + + public String getRegistryName() { + return registryName; + } + + /** + * Check if this blindfold variant supports color variations. + * Both variants support colors in the original mod. + */ + public boolean supportsColor() { + return supportsColor; + } + + /** + * Get the texture subfolder for this blindfold variant. + * Used by renderers to locate texture files. + * + * @return Subfolder path under textures/entity/bondage/ (e.g., "blindfolds", "blindfolds/mask") + */ + public String getTextureSubfolder() { + return textureSubfolder; + } +} diff --git a/src/main/java/com/tiedup/remake/items/base/EarplugsVariant.java b/src/main/java/com/tiedup/remake/items/base/EarplugsVariant.java new file mode 100644 index 0000000..97368f9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/EarplugsVariant.java @@ -0,0 +1,33 @@ +package com.tiedup.remake.items.base; + +/** + * Enum defining all earplugs variants. + * Used by GenericEarplugs to create earplugs items via factory pattern. + * + *

Issue #12 fix: Added textureSubfolder to eliminate string checks in renderers. + */ +public enum EarplugsVariant { + CLASSIC("classic_earplugs", "earplugs"); + + private final String registryName; + private final String textureSubfolder; + + EarplugsVariant(String registryName, String textureSubfolder) { + this.registryName = registryName; + this.textureSubfolder = textureSubfolder; + } + + public String getRegistryName() { + return registryName; + } + + /** + * Get the texture subfolder for this earplugs variant. + * Used by renderers to locate texture files. + * + * @return Subfolder path under textures/entity/bondage/ (e.g., "earplugs") + */ + public String getTextureSubfolder() { + return textureSubfolder; + } +} diff --git a/src/main/java/com/tiedup/remake/items/base/GagVariant.java b/src/main/java/com/tiedup/remake/items/base/GagVariant.java new file mode 100644 index 0000000..4b3e2eb --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/GagVariant.java @@ -0,0 +1,163 @@ +package com.tiedup.remake.items.base; + +import com.tiedup.remake.util.GagMaterial; + +/** + * Enum defining all gag variants with their properties. + * Used by GenericGag to create gag items via factory pattern. + * + *

Note: ItemMedicalGag is NOT included here because it implements + * IHasBlindingEffect (combo item with special behavior). + * + *

Issue #12 fix: Added textureSubfolder to eliminate 40+ string checks in renderers. + */ +public enum GagVariant { + // Cloth-based gags + CLOTH_GAG("cloth_gag", GagMaterial.CLOTH, true, "cloth", false, null), + ROPES_GAG("ropes_gag", GagMaterial.CLOTH, true, "shibari", false, null), + CLEAVE_GAG("cleave_gag", GagMaterial.CLOTH, true, "cleave", false, null), + RIBBON_GAG("ribbon_gag", GagMaterial.CLOTH, false, "ribbon", false, null), + + // Ball gags - standard 2D texture rendering + BALL_GAG( + "ball_gag", + GagMaterial.BALL, + true, + "ballgags/normal", + false, + null + ), + BALL_GAG_STRAP( + "ball_gag_strap", + GagMaterial.BALL, + true, + "ballgags/harness", + false, + null + ), + + // Tape gags + TAPE_GAG("tape_gag", GagMaterial.TAPE, true, "tape", false, null), + + // Stuffed/filling gags (no colors) + WRAP_GAG("wrap_gag", GagMaterial.STUFFED, false, "wrap", false, null), + SLIME_GAG("slime_gag", GagMaterial.STUFFED, false, "slime", false, null), + VINE_GAG("vine_gag", GagMaterial.STUFFED, false, "vine", false, null), + WEB_GAG("web_gag", GagMaterial.STUFFED, false, "web", false, null), + + // Panel gags (no colors) + PANEL_GAG( + "panel_gag", + GagMaterial.PANEL, + false, + "straitjacket", + false, + null + ), + BEAM_PANEL_GAG( + "beam_panel_gag", + GagMaterial.PANEL, + false, + "beam", + false, + null + ), + CHAIN_PANEL_GAG( + "chain_panel_gag", + GagMaterial.PANEL, + false, + "chain", + false, + null + ), + + // Latex gags (no colors) + LATEX_GAG("latex_gag", GagMaterial.LATEX, false, "latex", false, null), + + // Ring/tube gags (no colors) + TUBE_GAG("tube_gag", GagMaterial.RING, false, "tube", false, null), + + // Bite gags (no colors) + BITE_GAG("bite_gag", GagMaterial.BITE, false, "armbinder", false, null), + + // Sponge gags (no colors) + SPONGE_GAG("sponge_gag", GagMaterial.SPONGE, false, "sponge", false, null), + + // Baguette gags (no colors) + BAGUETTE_GAG( + "baguette_gag", + GagMaterial.BAGUETTE, + false, + "baguette", + false, + null + ); + + private final String registryName; + private final GagMaterial material; + private final boolean supportsColor; + private final String textureSubfolder; + private final boolean uses3DModel; + private final String modelPath; + + GagVariant( + String registryName, + GagMaterial material, + boolean supportsColor, + String textureSubfolder, + boolean uses3DModel, + String modelPath + ) { + this.registryName = registryName; + this.material = material; + this.supportsColor = supportsColor; + this.textureSubfolder = textureSubfolder; + this.uses3DModel = uses3DModel; + this.modelPath = modelPath; + } + + public String getRegistryName() { + return registryName; + } + + public GagMaterial getMaterial() { + return material; + } + + /** + * Check if this gag variant supports color variations. + * Items with colors: cloth_gag, ropes_gag, cleave_gag, ribbon_gag, + * ball_gag, ball_gag_strap, tape_gag + */ + public boolean supportsColor() { + return supportsColor; + } + + /** + * Get the texture subfolder for this gag variant. + * Used by renderers to locate texture files. + * + * @return Subfolder path under textures/entity/bondage/ (e.g., "cloth", "ballgags/normal") + */ + public String getTextureSubfolder() { + return textureSubfolder; + } + + /** + * Check if this gag variant uses a 3D OBJ model. + * + * @return true if this variant uses a 3D model + */ + public boolean uses3DModel() { + return uses3DModel; + } + + /** + * Get the model path for 3D rendering. + * + * @return ResourceLocation string path (e.g., "tiedup:models/obj/ball_gag.obj"), or null if no 3D model + */ + public String getModelPath() { + return modelPath; + } +} diff --git a/src/main/java/com/tiedup/remake/items/base/IAdjustable.java b/src/main/java/com/tiedup/remake/items/base/IAdjustable.java new file mode 100644 index 0000000..b5b592a --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/IAdjustable.java @@ -0,0 +1,49 @@ +package com.tiedup.remake.items.base; + +/** + * Interface for items that can have their render position adjusted. + * Typically gags and blindfolds that render on the player's head. + * + * Players can adjust the Y position of these items to better fit their skin. + * Adjustment values are stored in the ItemStack's NBT via AdjustmentHelper. + */ +public interface IAdjustable { + /** + * Whether this item supports position adjustment. + * @return true if adjustable + */ + boolean canBeAdjusted(); + + /** + * Default Y offset for this item type (in pixels, 1 pixel = 1/16 block). + * Override for items that need a non-zero default position. + * @return default adjustment value + */ + default float getDefaultAdjustment() { + return 0.0f; + } + + /** + * Minimum allowed adjustment value (pixels). + * @return minimum value (typically -4.0) + */ + default float getMinAdjustment() { + return -4.0f; + } + + /** + * Maximum allowed adjustment value (pixels). + * @return maximum value (typically +4.0) + */ + default float getMaxAdjustment() { + return 4.0f; + } + + /** + * Step size for GUI slider (smaller = more precise). + * @return step size (typically 0.25) + */ + default float getAdjustmentStep() { + return 0.25f; + } +} diff --git a/src/main/java/com/tiedup/remake/items/base/IBondageItem.java b/src/main/java/com/tiedup/remake/items/base/IBondageItem.java new file mode 100644 index 0000000..8a099de --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/IBondageItem.java @@ -0,0 +1,102 @@ +package com.tiedup.remake.items.base; + +import com.tiedup.remake.v2.BodyRegionV2; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; + +/** + * Interface for all bondage equipment items. + * Defines the core behavior for items that can be equipped in custom bondage slots. + * + * Based on original IExtraBondageItem from 1.12.2 + */ +public interface IBondageItem { + /** + * Get the body region this item occupies when equipped. + * @return The body region + */ + BodyRegionV2 getBodyRegion(); + + /** + * Called every tick while this item is equipped on an entity. + * @param stack The equipped item stack + * @param entity The entity wearing the item + */ + default void onWornTick(ItemStack stack, LivingEntity entity) { + // Default: do nothing + } + + /** + * Called when this item is equipped on an entity. + * @param stack The equipped item stack + * @param entity The entity wearing the item + */ + default void onEquipped(ItemStack stack, LivingEntity entity) { + // Default: do nothing + } + + /** + * Called when this item is unequipped from an entity. + * @param stack The unequipped item stack + * @param entity The entity that was wearing the item + */ + default void onUnequipped(ItemStack stack, LivingEntity entity) { + // Default: do nothing + } + + /** + * Check if this item can be equipped on the given entity. + * @param stack The item stack to equip + * @param entity The target entity + * @return true if the item can be equipped, false otherwise + */ + default boolean canEquip(ItemStack stack, LivingEntity entity) { + return true; + } + + /** + * Check if this item can be unequipped from the given entity. + * @param stack The equipped item stack + * @param entity The entity wearing the item + * @return true if the item can be unequipped, false otherwise + */ + default boolean canUnequip(ItemStack stack, LivingEntity entity) { + return true; + } + + /** + * Get the texture subfolder for this bondage item. + * Used by renderers to locate texture files. + * + *

Issue #12 fix: Eliminates 40+ string checks in renderers by letting + * each item type declare its own texture subfolder. + * + * @return Subfolder path under textures/entity/bondage/ (e.g., "ropes", "ballgags/normal") + */ + default String getTextureSubfolder() { + return "misc"; // Fallback for items without explicit subfolder + } + + /** + * Check if this bondage item uses a 3D OBJ model instead of a flat texture. + * Items with 3D models will be rendered using ObjModelRenderer. + * + * @return true if this item uses a 3D model, false for standard texture rendering + */ + default boolean uses3DModel() { + return false; + } + + /** + * Get the ResourceLocation of the 3D model for this item. + * Only called if uses3DModel() returns true. + * + * @return ResourceLocation pointing to the .obj file, or null if no 3D model + */ + @Nullable + default ResourceLocation get3DModelLocation() { + return null; + } +} diff --git a/src/main/java/com/tiedup/remake/items/base/IHasBlindingEffect.java b/src/main/java/com/tiedup/remake/items/base/IHasBlindingEffect.java new file mode 100644 index 0000000..cf5890e --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/IHasBlindingEffect.java @@ -0,0 +1,33 @@ +package com.tiedup.remake.items.base; + +/** + * Marker interface for items that have a blinding visual effect. + * + *

Items implementing this interface will: + *

    + *
  • Apply a screen overlay when worn (client-side)
  • + *
  • Reduce the player's visibility
  • + *
  • Potentially disable certain UI elements
  • + *
+ * + *

Usage

+ *
{@code
+ * if (blindfold.getItem() instanceof IHasBlindingEffect) {
+ *     // Apply blinding overlay
+ *     renderBlindingOverlay();
+ * }
+ * }
+ * + *

Implementations

+ *
    + *
  • {@link ItemBlindfold} - All blindfold items
  • + *
+ * + *

Based on original IHasBlindingEffect.java from 1.12.2 + * + * @see ItemBlindfold + */ +public interface IHasBlindingEffect { + // Marker interface - no methods required + // Presence of this interface indicates the item has a blinding effect +} diff --git a/src/main/java/com/tiedup/remake/items/base/IHasGaggingEffect.java b/src/main/java/com/tiedup/remake/items/base/IHasGaggingEffect.java new file mode 100644 index 0000000..e16ca51 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/IHasGaggingEffect.java @@ -0,0 +1,33 @@ +package com.tiedup.remake.items.base; + +/** + * Marker interface for items that have a gagging (speech muffling) effect. + * + *

Items implementing this interface will: + *

    + *
  • Convert chat messages to "mmpphh" sounds
  • + *
  • Play gagged speech sounds
  • + *
  • Potentially block certain chat commands
  • + *
+ * + *

Usage

+ *
{@code
+ * if (gag.getItem() instanceof IHasGaggingEffect) {
+ *     // Convert chat message to gagged speech
+ *     message = GagTalkConverter.convert(message);
+ * }
+ * }
+ * + *

Implementations

+ *
    + *
  • {@link ItemGag} - Ball gags, tape gags, cloth gags, etc.
  • + *
+ * + *

Based on original ItemGaggingEffect.java from 1.12.2 + * + * @see ItemGag + */ +public interface IHasGaggingEffect { + // Marker interface - no methods required + // Presence of this interface indicates the item has a gagging effect +} diff --git a/src/main/java/com/tiedup/remake/items/base/IHasResistance.java b/src/main/java/com/tiedup/remake/items/base/IHasResistance.java new file mode 100644 index 0000000..133eb86 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/IHasResistance.java @@ -0,0 +1,235 @@ +package com.tiedup.remake.items.base; + +import com.tiedup.remake.core.SettingsAccessor; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; + +/** + * Interface for bondage items that have a resistance value. + * + *

The resistance system allows players to "struggle" out of restraints. + * Higher resistance = more struggle attempts needed to escape. + * + *

How Resistance Works

+ *
    + *
  1. Item has a base resistance from config (via SettingsAccessor)
  2. + *
  3. When equipped, current resistance = base resistance
  4. + *
  5. Each struggle attempt decreases current resistance
  6. + *
  7. When current resistance reaches 0, player escapes
  8. + *
  9. Resistance resets when item is unequipped
  10. + *
+ * + *

Implementations

+ *
    + *
  • {@link ItemBind} - Ropes, chains, straitjackets
  • + *
  • {@link ItemGag} - Ball gags, tape gags, cloth gags
  • + *
  • {@link ItemBlindfold} - Blindfolds
  • + *
  • {@link ItemCollar} - Collars (special: may not be struggleable)
  • + *
+ * + *

Based on original IHasResistance.java from 1.12.2 + * + * @see SettingsAccessor + */ +public interface IHasResistance { + // ======================================== + // NBT KEYS + // ======================================== + + /** NBT key for storing current resistance value (camelCase standard) */ + String NBT_CURRENT_RESISTANCE = "currentResistance"; + + /** Legacy NBT key for migration from older versions */ + String NBT_CURRENT_RESISTANCE_LEGACY = "currentresistance"; + + /** NBT key for storing whether item can be struggled out of */ + String NBT_CAN_STRUGGLE = "canBeStruggledOut"; + + // ======================================== + // ABSTRACT METHODS (must implement) + // ======================================== + + /** + * Get the item name/ID for resistance config lookup. + * + *

This is used to look up the base resistance from ModConfig + * via {@link SettingsAccessor#getBindResistance(String)}. + * + * @return Item identifier for resistance lookup + */ + String getResistanceId(); + + /** + * Called when the entity struggles against this item. + * + *

Implementations should: + *

    + *
  • Play struggle sound
  • + *
  • Show message to player
  • + *
  • Potentially notify nearby players
  • + *
+ * + * @param entity The entity struggling + */ + void notifyStruggle(LivingEntity entity); + + // ======================================== + // DEFAULT METHODS (NBT handling) + // ======================================== + + /** + * Get the base resistance from config via SettingsAccessor. + * + * @param entity The entity (kept for API compatibility) + * @return Base resistance value + */ + default int getBaseResistance(LivingEntity entity) { + return SettingsAccessor.getBindResistance(getResistanceId()); + } + + /** + * Get the current resistance from ItemStack NBT. + * + *

If no current resistance is stored (or <= 0), returns base resistance. + *

Handles migration from legacy lowercase key to camelCase. + * + * @param stack The item stack + * @param entity The entity (for accessing base resistance) + * @return Current resistance value + */ + default int getCurrentResistance(ItemStack stack, LivingEntity entity) { + if (stack.isEmpty()) { + return 0; + } + + CompoundTag tag = stack.getTag(); + if (tag != null) { + // Check new camelCase key first + if (tag.contains(NBT_CURRENT_RESISTANCE)) { + int resistance = tag.getInt(NBT_CURRENT_RESISTANCE); + if (resistance > 0) { + return resistance; + } + } + // Migration: check legacy lowercase key + else if (tag.contains(NBT_CURRENT_RESISTANCE_LEGACY)) { + int resistance = tag.getInt(NBT_CURRENT_RESISTANCE_LEGACY); + // Migrate to new key + tag.remove(NBT_CURRENT_RESISTANCE_LEGACY); + if (resistance > 0) { + tag.putInt(NBT_CURRENT_RESISTANCE, resistance); + return resistance; + } + } + } + + // Default to base resistance + return getBaseResistance(entity); + } + + /** + * Set the current resistance in ItemStack NBT. + * + * @param stack The item stack + * @param resistance The resistance value to set + * @return The modified item stack + */ + default ItemStack setCurrentResistance(ItemStack stack, int resistance) { + if (!stack.isEmpty()) { + stack.getOrCreateTag().putInt(NBT_CURRENT_RESISTANCE, resistance); + } + return stack; + } + + /** + * Reset the current resistance (remove from NBT). + * + *

Called when the item is unequipped. Next time it's equipped, + * it will start fresh with base resistance. + * + * @param stack The item stack + * @return The modified item stack + */ + default ItemStack resetCurrentResistance(ItemStack stack) { + if (!stack.isEmpty()) { + CompoundTag tag = stack.getTag(); + if (tag != null && tag.contains(NBT_CURRENT_RESISTANCE)) { + tag.remove(NBT_CURRENT_RESISTANCE); + // Clean up empty tag + if (tag.isEmpty()) { + stack.setTag(null); + } + } + } + return stack; + } + + /** + * Decrease the current resistance by one struggle attempt. + * + * @param stack The item stack + * @param entity The entity struggling + * @return The new resistance value after decreasing + */ + default int decreaseResistance(ItemStack stack, LivingEntity entity) { + int current = getCurrentResistance(stack, entity); + int newResistance = Math.max(0, current - 1); + setCurrentResistance(stack, newResistance); + return newResistance; + } + + /** + * Check if this item can be struggled out of. + * + *

Some items (like locked collars) cannot be escaped via struggling. + * Default is true (can be struggled). + * + * @param stack The item stack + * @return True if struggling is allowed + */ + default boolean canBeStruggledOut(ItemStack stack) { + if (stack.isEmpty()) { + return true; + } + + CompoundTag tag = stack.getTag(); + if (tag != null && tag.contains(NBT_CAN_STRUGGLE)) { + return tag.getBoolean(NBT_CAN_STRUGGLE); + } + + return true; // Default: can be struggled + } + + /** + * Set whether this item can be struggled out of. + * + * @param stack The item stack + * @param canStruggle True to allow struggling + * @return The modified item stack + */ + default ItemStack setCanBeStruggledOut( + ItemStack stack, + boolean canStruggle + ) { + if (!stack.isEmpty()) { + stack.getOrCreateTag().putBoolean(NBT_CAN_STRUGGLE, canStruggle); + } + return stack; + } + + /** + * Check if the entity can escape from this item. + * + *

Combines struggle permission with current resistance check. + * + * @param stack The item stack + * @param entity The entity trying to escape + * @return True if escape is possible (resistance = 0 and struggling allowed) + */ + default boolean canEscape(ItemStack stack, LivingEntity entity) { + return ( + canBeStruggledOut(stack) && getCurrentResistance(stack, entity) <= 0 + ); + } +} diff --git a/src/main/java/com/tiedup/remake/items/base/IKnife.java b/src/main/java/com/tiedup/remake/items/base/IKnife.java new file mode 100644 index 0000000..e445973 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/IKnife.java @@ -0,0 +1,15 @@ +package com.tiedup.remake.items.base; + +/** + * Marker interface for knife items. + * + * v2.5: Knives now work by active cutting (hold right-click). + * - Consumes 5 durability/second + * - Removes 5 resistance/second from bind or locked accessory + * + * See GenericKnife for the active cutting implementation. + */ +public interface IKnife { + // Marker interface - no methods required + // Implementation provides: use(), onUseTick() for active cutting +} diff --git a/src/main/java/com/tiedup/remake/items/base/ILockable.java b/src/main/java/com/tiedup/remake/items/base/ILockable.java new file mode 100644 index 0000000..f089d86 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/ILockable.java @@ -0,0 +1,350 @@ +package com.tiedup.remake.items.base; + +import com.tiedup.remake.util.ItemNBTHelper; +import java.util.List; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; + +/** + * Interface for bondage items that can be locked with padlocks. + * + *

Items implementing this interface can be locked to prevent removal + * without a key or force action. + * + *

Lock Safety Pattern

+ * When an item is locked: + *
    + *
  • Cannot be removed by normal means
  • + *
  • Cannot be replaced unless forced
  • + *
  • Must be unlocked with a key or master key
  • + *
+ * + *

Implementations

+ *
    + *
  • {@link ItemGag} - Gags can be locked
  • + *
  • {@link ItemBlindfold} - Blindfolds can be locked
  • + *
  • {@link ItemCollar} - Collars can be locked
  • + *
  • {@link ItemEarplugs} - Earplugs can be locked
  • + *
  • ItemBind - Binds can be locked (future)
  • + *
+ * + *

NBT Storage

+ * Lock state is stored in NBT: + *
{@code
+ * {
+ *   "locked": true,
+ *   "lockable": true,
+ *   "lockedByKeyUUID": "uuid-string"  // UUID of the key that locked this item
+ * }
+ * }
+ * + *

Key-Lock System

+ * Each lock is tied to a specific key via UUID: + *
    + *
  • When locked with a key, the key's UUID is stored
  • + *
  • Only the matching key (or master key) can unlock
  • + *
  • Master key bypasses UUID check
  • + *
+ * + * @see ItemPadlock + * @see ItemKey + */ +public interface ILockable { + // ========== NBT CONSTANTS ========== + + /** NBT key for locked state */ + String NBT_LOCKED = "locked"; + + /** NBT key for lockable state (can accept padlock) */ + String NBT_LOCKABLE = "lockable"; + + /** NBT key for the UUID of the key that locked this item */ + String NBT_LOCKED_BY_KEY_UUID = "lockedByKeyUUID"; + + // ========== LOCK STATE METHODS ========== + + /** + * Set the locked state of this item. + * + *

CRITICAL: When unlocking (state = false), some items may reset + * their resistance to base value to prevent exploits.

+ * + * @param stack The ItemStack to modify + * @param state true to lock, false to unlock + * @return The modified ItemStack (for chaining) + */ + default ItemStack setLocked(ItemStack stack, boolean state) { + ItemNBTHelper.setBoolean(stack, NBT_LOCKED, state); + return stack; + } + + /** + * Check if this item is currently locked. + * + * @param stack The ItemStack to check + * @return true if locked + */ + default boolean isLocked(ItemStack stack) { + return ItemNBTHelper.getBoolean(stack, NBT_LOCKED); + } + + /** + * Set whether this item can be locked (lockable state). + * + *

This is different from locked state: + *

    + *
  • lockable = true: Item can accept a padlock
  • + *
  • locked = true: Item currently has a padlock on it
  • + *
+ * + * @param stack The ItemStack to modify + * @param state true to make lockable, false to prevent locking + * @return The modified ItemStack (for chaining) + */ + default ItemStack setLockable(ItemStack stack, boolean state) { + ItemNBTHelper.setBoolean(stack, NBT_LOCKABLE, state); + return stack; + } + + /** + * Check if this item can be locked. + * + * @param stack The ItemStack to check + * @return true if lockable (can accept a padlock) + */ + default boolean isLockable(ItemStack stack) { + return ItemNBTHelper.getBoolean(stack, NBT_LOCKABLE); + } + + // ========== TOOLTIP HELPER ========== + + /** + * Append lock status tooltip to item hover text. + * Provides consistent lock/lockable display across all lockable items. + * + * @param stack The ItemStack being displayed + * @param tooltip The tooltip list to append to + */ + default void appendLockTooltip(ItemStack stack, List tooltip) { + if (isLockable(stack)) { + if (isLocked(stack)) { + tooltip.add( + Component.translatable( + "item.tiedup.tooltip.locked" + ).withStyle(ChatFormatting.RED) + ); + } else { + tooltip.add( + Component.translatable( + "item.tiedup.tooltip.lockable" + ).withStyle(ChatFormatting.GOLD) + ); + } + } + } + + /** + * Check if the padlock should be dropped when unlocking. + * + *

Default implementation returns true (drop padlock on unlock).

+ *

Some items may override this to consume the padlock permanently.

+ * + * @return true if padlock should be dropped + */ + default boolean dropLockOnUnlock() { + return true; + } + + /** + * Check if this item can have a padlock attached via anvil. + * + *

Some items cannot have padlocks attached due to their nature:

+ *
    + *
  • Adhesive items (tape) - stick to themselves, no attachment point
  • + *
  • Organic items (slime, vine, web) - living/organic matter
  • + *
+ * + *

Default implementation returns true (can attach padlock).

+ * + * @return true if padlock can be attached + */ + default boolean canAttachPadlock() { + return true; + } + + // ========== KEY-LOCK SYSTEM ========== + + /** + * Get the UUID of the key that locked this item. + * + * @param stack The ItemStack to check + * @return The UUID of the locking key, or null if not locked or locked without key + */ + @Nullable + default UUID getLockedByKeyUUID(ItemStack stack) { + return ItemNBTHelper.getUUID(stack, NBT_LOCKED_BY_KEY_UUID); + } + + /** + * Set the key UUID that locks this item. + * + *

Setting a non-null keyUUID will also set locked=true. + * Setting null will unlock the item (locked=false).

+ * + * @param stack The ItemStack to modify + * @param keyUUID The UUID of the key, or null to unlock + */ + default void setLockedByKeyUUID(ItemStack stack, @Nullable UUID keyUUID) { + if (stack.isEmpty()) return; + ItemNBTHelper.setUUID(stack, NBT_LOCKED_BY_KEY_UUID, keyUUID); + ItemNBTHelper.setBoolean(stack, NBT_LOCKED, keyUUID != null); + } + + /** + * Check if a key matches this item's lock. + * + *

Default implementation compares the stored keyUUID with the provided one.

+ * + * @param stack The ItemStack to check + * @param keyUUID The key UUID to test + * @return true if the key matches this lock + */ + default boolean matchesKey(ItemStack stack, UUID keyUUID) { + if (keyUUID == null) return false; + UUID lockedBy = getLockedByKeyUUID(stack); + return lockedBy != null && lockedBy.equals(keyUUID); + } + + // ========== STRUGGLE/LOCKPICK SYSTEM ========== + + /** + * NBT key for jammed state. + */ + String NBT_JAMMED = "jammed"; + + /** + * Get the resistance added by the lock for struggle mechanics. + * + *

When locked, this value is added to the item's base resistance. + * Configurable via server config and GameRule.

+ * + * @return Lock resistance value (default: 250, configurable) + */ + default int getLockResistance() { + return com.tiedup.remake.core.SettingsAccessor.getPadlockResistance(null); + } + + /** + * Check if the lock is jammed (lockpick failed critically). + * + *

When jammed, only struggle can unlock the item, lockpick is blocked. + * Jam state is set when lockpick has a 2.5% critical failure.

+ * + * @param stack The ItemStack to check + * @return true if the lock is jammed + */ + default boolean isJammed(ItemStack stack) { + return ItemNBTHelper.getBoolean(stack, NBT_JAMMED); + } + + /** + * Set the jammed state of this item's lock. + * + *

When jammed, lockpick cannot be used on this item. + * Only struggle can unlock a jammed lock.

+ * + * @param stack The ItemStack to modify + * @param jammed true to jam the lock, false to clear jam + */ + default void setJammed(ItemStack stack, boolean jammed) { + if (jammed) { + ItemNBTHelper.setBoolean(stack, NBT_JAMMED, true); + } else { + ItemNBTHelper.remove(stack, NBT_JAMMED); + } + } + + // ========== LOCK RESISTANCE (for struggle) ========== + + /** + * NBT key for current lock resistance during struggle. + */ + String NBT_LOCK_RESISTANCE = "lockResistance"; + + /** + * Get the current lock resistance remaining for struggle. + * Initialized to getLockResistance() (configurable, default 250) when first locked. + * + * @param stack The ItemStack to check + * @return Current lock resistance (0 if not locked or fully struggled) + */ + default int getCurrentLockResistance(ItemStack stack) { + if (stack.isEmpty()) return 0; + + // If locked but no resistance stored yet, initialize it + if ( + isLocked(stack) && + !ItemNBTHelper.contains(stack, NBT_LOCK_RESISTANCE) + ) { + return getLockResistance(); // Configurable via ModConfig + } + + return ItemNBTHelper.getInt(stack, NBT_LOCK_RESISTANCE); + } + + /** + * Set the current lock resistance remaining for struggle. + * + * @param stack The ItemStack to modify + * @param resistance The new resistance value + */ + default void setCurrentLockResistance(ItemStack stack, int resistance) { + ItemNBTHelper.setInt(stack, NBT_LOCK_RESISTANCE, resistance); + } + + /** + * Initialize lock resistance when item is locked. + * Called when setLockedByKeyUUID is called with a non-null UUID. + * + * @param stack The ItemStack to initialize + */ + default void initializeLockResistance(ItemStack stack) { + setCurrentLockResistance(stack, getLockResistance()); + } + + /** + * Clear lock resistance when item is unlocked. + * + * @param stack The ItemStack to clear + */ + default void clearLockResistance(ItemStack stack) { + ItemNBTHelper.remove(stack, NBT_LOCK_RESISTANCE); + } + + // ========== LOCK BREAKING (struggle/force) ========== + + /** + * Completely break/destroy the lock on an item. + * + *

Used when a padlock is destroyed through struggle or force. + * This removes all lock-related state from the item: + *

    + *
  • Unlocks the item (lockedByKeyUUID = null)
  • + *
  • Removes the lockable flag (no more padlock slot)
  • + *
  • Clears any jam state
  • + *
  • Clears stored lock resistance
  • + *
+ * + * @param stack The ItemStack to break the lock on + */ + default void breakLock(ItemStack stack) { + if (stack.isEmpty()) return; + setLockedByKeyUUID(stack, null); + setLockable(stack, false); + setJammed(stack, false); + clearLockResistance(stack); + } +} diff --git a/src/main/java/com/tiedup/remake/items/base/ItemBind.java b/src/main/java/com/tiedup/remake/items/base/ItemBind.java new file mode 100644 index 0000000..0df1f3a --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/ItemBind.java @@ -0,0 +1,623 @@ +package com.tiedup.remake.items.base; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.action.PacketTying; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.tasks.TyingPlayerTask; +import com.tiedup.remake.tasks.TyingTask; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.core.SettingsAccessor; +import com.tiedup.remake.util.RestraintEffectUtils; +import com.tiedup.remake.util.TiedUpSounds; +import java.util.List; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.context.UseOnContext; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +/** + * Base class for binding/restraint items (ropes, chains, straitjacket, etc.) + * These items restrain a player's movement and actions when equipped. + * + *

Implements {@link IHasResistance} for the struggle/escape system. + *

Implements {@link ILockable} for the padlock system (Phase 15). + * + * Based on original ItemBind from 1.12.2 + * + * Phase 5: Movement speed reduction implemented + * Phase 7: Resistance system implemented via IHasResistance + * Phase 15: Added ILockable interface for padlock support + */ +public abstract class ItemBind + extends Item + implements IBondageItem, IHasResistance, ILockable +{ + + // ========== Leg Binding: Bind Mode NBT Key ========== + private static final String NBT_BIND_MODE = "bindMode"; + + public ItemBind(Properties properties) { + super(properties); + } + + @Override + public BodyRegionV2 getBodyRegion() { + return BodyRegionV2.ARMS; + } + + // ========== Leg Binding: Bind Mode Methods ========== + + // String constants matching NBT values + public static final String BIND_MODE_FULL = "full"; + private static final String MODE_FULL = BIND_MODE_FULL; + private static final String MODE_ARMS = "arms"; + private static final String MODE_LEGS = "legs"; + private static final String[] MODE_CYCLE = {MODE_FULL, MODE_ARMS, MODE_LEGS}; + private static final java.util.Map MODE_TRANSLATION_KEYS = java.util.Map.of( + MODE_FULL, "tiedup.bindmode.full", + MODE_ARMS, "tiedup.bindmode.arms", + MODE_LEGS, "tiedup.bindmode.legs" + ); + + /** + * Get the bind mode ID string from the stack's NBT. + * @param stack The bind ItemStack + * @return "full", "arms", or "legs" (defaults to "full" if absent) + */ + public static String getBindModeId(ItemStack stack) { + if (stack.isEmpty()) return MODE_FULL; + CompoundTag tag = stack.getTag(); + if (tag == null || !tag.contains(NBT_BIND_MODE)) return MODE_FULL; + String value = tag.getString(NBT_BIND_MODE); + if (MODE_FULL.equals(value) || MODE_ARMS.equals(value) || MODE_LEGS.equals(value)) { + return value; + } + return MODE_FULL; + } + + /** + * Check if arms are bound (mode is "arms" or "full"). + * @param stack The bind ItemStack + * @return true if arms are restrained + */ + public static boolean hasArmsBound(ItemStack stack) { + String mode = getBindModeId(stack); + return MODE_ARMS.equals(mode) || MODE_FULL.equals(mode); + } + + /** + * Check if legs are bound (mode is "legs" or "full"). + * @param stack The bind ItemStack + * @return true if legs are restrained + */ + public static boolean hasLegsBound(ItemStack stack) { + String mode = getBindModeId(stack); + return MODE_LEGS.equals(mode) || MODE_FULL.equals(mode); + } + + /** + * Cycle bind mode: full -> arms -> legs -> full. + * @param stack The bind ItemStack + * @return the new mode ID string + */ + public static String cycleBindModeId(ItemStack stack) { + String current = getBindModeId(stack); + String next = MODE_FULL; + for (int i = 0; i < MODE_CYCLE.length; i++) { + if (MODE_CYCLE[i].equals(current)) { + next = MODE_CYCLE[(i + 1) % MODE_CYCLE.length]; + break; + } + } + stack.getOrCreateTag().putString(NBT_BIND_MODE, next); + return next; + } + + /** + * Get the translation key for the current bind mode. + * @param stack The bind ItemStack + * @return the i18n key for the mode + */ + public static String getBindModeTranslationKey(ItemStack stack) { + return MODE_TRANSLATION_KEYS.getOrDefault(getBindModeId(stack), "tiedup.bindmode.full"); + } + + /** + * Called when player right-clicks in air with bind item. + * Sneak+click cycles the bind mode. + */ + @Override + public InteractionResultHolder use( + Level level, + Player player, + InteractionHand hand + ) { + ItemStack stack = player.getItemInHand(hand); + + // Sneak+click in air cycles bind mode + if (player.isShiftKeyDown()) { + if (!level.isClientSide) { + String newModeId = cycleBindModeId(stack); + + // Play feedback sound + player.playSound(SoundEvents.CHAIN_STEP, 0.5f, 1.2f); + + // Show action bar message + player.displayClientMessage( + Component.translatable( + "tiedup.message.bindmode_changed", + Component.translatable(getBindModeTranslationKey(stack)) + ), + true + ); + + TiedUpMod.LOGGER.debug( + "[ItemBind] {} cycled bind mode to {}", + player.getName().getString(), + newModeId + ); + } + return InteractionResultHolder.sidedSuccess( + stack, + level.isClientSide + ); + } + + return super.use(level, player, hand); + } + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + super.appendHoverText(stack, level, tooltip, flag); + + // Show bind mode + tooltip.add( + Component.translatable( + "item.tiedup.tooltip.bindmode", + Component.translatable(getBindModeTranslationKey(stack)) + ).withStyle(ChatFormatting.GRAY) + ); + + // Show lock status + if (isLockable(stack)) { + if (isLocked(stack)) { + tooltip.add( + Component.translatable( + "item.tiedup.tooltip.locked" + ).withStyle(ChatFormatting.RED) + ); + } else { + tooltip.add( + Component.translatable( + "item.tiedup.tooltip.lockable" + ).withStyle(ChatFormatting.GOLD) + ); + } + } + } + + /** + * Called when the bind is equipped on an entity. + * Applies movement speed reduction only if legs are bound. + * + * Phase 14.1.5: Refactored to support IBondageState (LivingEntity + NPCs) + * Leg Binding: Speed reduction conditional on mode + * Based on original ItemBind.onEquipped() (1.12.2) + */ + @Override + public void onEquipped(ItemStack stack, LivingEntity entity) { + String modeId = getBindModeId(stack); + + // Only apply speed reduction if legs are bound + if (hasLegsBound(stack)) { + // H6 fix: For players, speed is handled exclusively by MovementStyleManager + // (V2 tick-based system) via MovementStyleResolver V1 fallback. + // Applying V1 RestraintEffectUtils here would cause double stacking (different + // UUIDs, ADDITION vs MULTIPLY_BASE) leading to quasi-immobility. + if (entity instanceof Player) { + TiedUpMod.LOGGER.debug( + "[ItemBind] Applied bind (mode={}, pose={}) to player {} - speed delegated to MovementStyleManager", + modeId, + getPoseType().getAnimationId(), + entity.getName().getString() + ); + } else { + // NPCs: MovementStyleManager only handles ServerPlayer, so NPCs + // still need the legacy RestraintEffectUtils speed modifier. + PoseType poseType = getPoseType(); + boolean fullImmobilization = + poseType == PoseType.WRAP || poseType == PoseType.LATEX_SACK; + + RestraintEffectUtils.applyBindSpeedReduction(entity, fullImmobilization); + TiedUpMod.LOGGER.debug( + "[ItemBind] Applied bind (mode={}, pose={}) to NPC {} - speed reduced (full={})", + modeId, + poseType.getAnimationId(), + entity.getName().getString(), + fullImmobilization + ); + } + } else { + TiedUpMod.LOGGER.debug( + "[ItemBind] Applied bind (mode={}) to {} - no speed reduction", + modeId, + entity.getName().getString() + ); + } + } + + /** + * Called when the bind is unequipped from an entity. + * Restores normal movement speed for all entities. + * Phase 7: Resets resistance for next use. + * + * Phase 14.1.5: Refactored to support IBondageState (LivingEntity + NPCs) + * Based on original ItemBind.onUnequipped() (1.12.2) + */ + @Override + public void onUnequipped(ItemStack stack, LivingEntity entity) { + // H6 fix: For players, speed cleanup is handled by MovementStyleManager + // (V2 tick-based system). On the next tick, the resolver will see the item + // is gone, deactivate the style, and remove the modifier automatically. + // NPCs still need the legacy RestraintEffectUtils cleanup. + if (!(entity instanceof Player)) { + RestraintEffectUtils.removeBindSpeedReduction(entity); + } + + // Phase 7: Reset resistance for next use (uses IHasResistance default method) + IHasResistance.super.resetCurrentResistance(stack); + + TiedUpMod.LOGGER.debug( + "[ItemBind] Removed bind from {} - speed {} resistance reset", + entity.getName().getString(), + entity instanceof Player ? "delegated to MovementStyleManager," : "restored," + ); + } + + // ========== Phase 6: Tying Interaction ========== + + /** + * Called when player right-clicks another entity with this bind item. + * Starts or continues a tying task to tie up the target entity. + * + * Phase 14.2: Unified to support IBondageState (Player + NPCs) + * - Players: Uses tying task with progress bar + * - NPCs: Instant bind (no tying mini-game) + * + * Based on original ItemBind.itemInteractionForEntity() (1.12.2) + * + * @param stack The item stack + * @param player The player using the item (kidnapper) + * @param target The entity being interacted with + * @param hand The hand holding the item + * @return SUCCESS if tying started/continued, PASS otherwise + */ + @Override + public InteractionResult interactLivingEntity( + ItemStack stack, + Player player, + LivingEntity target, + InteractionHand hand + ) { + // Only run on server side + if (player.level().isClientSide) { + return InteractionResult.SUCCESS; + } + + // Phase 14.2: Use KidnappedHelper to support both Players and NPCs + IBondageState targetState = KidnappedHelper.getKidnappedState(target); + if (targetState == null) { + return InteractionResult.PASS; // Target cannot be restrained + } + + // Get kidnapper state (player using the item) + IBondageState kidnapperState = KidnappedHelper.getKidnappedState(player); + if (kidnapperState == null) { + return InteractionResult.FAIL; + } + + // Already tied - try to swap binds (if not locked) + // Check stack.isEmpty() first to prevent accidental unbinding when + // the original stack was consumed (e.g., rapid clicks after tying completes) + if (targetState.isTiedUp()) { + if (stack.isEmpty()) { + // No bind in hand - can't swap, just pass + return InteractionResult.PASS; + } + ItemStack oldBind = targetState.replaceEquipment(BodyRegionV2.ARMS, stack.copy(), false); + if (!oldBind.isEmpty()) { + stack.shrink(1); + targetState.kidnappedDropItem(oldBind); + TiedUpMod.LOGGER.debug( + "[ItemBind] Swapped bind on {} - dropped old bind", + target.getName().getString() + ); + return InteractionResult.SUCCESS; + } + // Locked or failed - can't swap + return InteractionResult.PASS; + } + + // Phase 7 FIX: Can't tie others if you're tied yourself + if (kidnapperState.isTiedUp()) { + TiedUpMod.LOGGER.debug( + "[ItemBind] {} tried to tie but is tied themselves", + player.getName().getString() + ); + return InteractionResult.PASS; + } + + // ======================================== + // SECURITY: Distance and line-of-sight validation (skip for self-tying) + // ======================================== + boolean isSelfTying = player.equals(target); + if (!isSelfTying) { + double maxTieDistance = 4.0; // Max distance to tie (blocks) + double distance = player.distanceTo(target); + if (distance > maxTieDistance) { + TiedUpMod.LOGGER.warn( + "[ItemBind] {} tried to tie {} from too far away ({} blocks)", + player.getName().getString(), + target.getName().getString(), + String.format("%.1f", distance) + ); + return InteractionResult.PASS; + } + + // Check line-of-sight (must be able to see target) + if (!player.hasLineOfSight(target)) { + TiedUpMod.LOGGER.warn( + "[ItemBind] {} tried to tie {} without line of sight", + player.getName().getString(), + target.getName().getString() + ); + return InteractionResult.PASS; + } + } + + // Phase 14.2.6: Unified tying for both Players and NPCs + return handleTying(stack, player, target, targetState); + } + + /** + * Handle tying any target entity (Player or NPC). + * Phase 14.2.6: Unified tying system for all IBondageState entities. + * + * Uses progress-based system: + * - update() marks the tick as active + * - tick() in RestraintTaskTickHandler.onPlayerTick() handles progress increment/decrement + */ + private InteractionResult handleTying( + ItemStack stack, + Player player, + LivingEntity target, + IBondageState targetState + ) { + // Get kidnapper's state to track the tying task + PlayerBindState kidnapperState = PlayerBindState.getInstance(player); + if (kidnapperState == null) { + return InteractionResult.FAIL; + } + + // Get tying duration from GameRule (default: 5 seconds) + int tyingSeconds = getTyingDuration(player); + + // Get current tying task (if any) + TyingTask currentTask = kidnapperState.getCurrentTyingTask(); + + // Check if we should start a new task or continue existing one + if ( + currentTask == null || + !currentTask.isSameTarget(target) || + currentTask.isStopped() || + !ItemStack.matches(currentTask.getBind(), stack) + ) { + // Create new tying task (works for both Players and NPCs) + TyingPlayerTask newTask = new TyingPlayerTask( + stack.copy(), + targetState, + target, + tyingSeconds, + player.level(), + player // Pass kidnapper for SystemMessage + ); + + // FIX: Store the inventory slot for consumption when task completes + // This prevents duplication AND allows refund if task is cancelled + int sourceSlot = player.getInventory().selected; + newTask.setSourceSlot(sourceSlot); + newTask.setSourcePlayer(player); + + // Start new task + kidnapperState.setCurrentTyingTask(newTask); + newTask.setUpTargetState(); // Initialize target's restraint state (only for players) + newTask.start(); + currentTask = newTask; + + TiedUpMod.LOGGER.debug( + "[ItemBind] {} started tying {} ({} seconds, slot={})", + player.getName().getString(), + target.getName().getString(), + tyingSeconds, + sourceSlot + ); + } else { + // Continue existing task - ensure kidnapper is set + if (currentTask instanceof TyingPlayerTask playerTask) { + playerTask.setKidnapper(player); + } + } + + // Mark this tick as active (progress will increase in onPlayerTick) + // The tick() method in RestraintTaskTickHandler.onPlayerTick handles progress increment/decrement + currentTask.update(); + + return InteractionResult.SUCCESS; + } + + /** + * Called when player right-clicks with the bind item (not targeting an entity). + * Cancels any ongoing tying task. + * + * Based on original ItemBind.onItemRightClick() (1.12.2) + * + * @param context The use context + * @return FAIL to cancel the action + */ + @Override + public InteractionResult useOn(UseOnContext context) { + // Only run on server side + if (context.getLevel().isClientSide) { + return InteractionResult.SUCCESS; + } + + Player player = context.getPlayer(); + if (player == null) { + return InteractionResult.FAIL; + } + + // Cancel any ongoing tying task + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + return InteractionResult.FAIL; + } + + // Check for active tying task (unified for both players and NPCs) + TyingTask task = state.getCurrentTyingTask(); + if (task != null) { + task.stop(); + state.setCurrentTyingTask(null); + + LivingEntity target = task.getTargetEntity(); + String targetName = + target != null ? target.getName().getString() : "???"; + String kidnapperName = player.getName().getString(); + + // Send cancellation packet to kidnapper + if (player instanceof ServerPlayer serverPlayer) { + PacketTying packet = new PacketTying( + -1, + task.getMaxSeconds(), + true, + targetName + ); + ModNetwork.sendToPlayer(packet, serverPlayer); + } + + // Send cancellation packet to target (if it's a player) + if (target instanceof ServerPlayer serverTarget) { + PacketTying packet = new PacketTying( + -1, + task.getMaxSeconds(), + false, + kidnapperName + ); + ModNetwork.sendToPlayer(packet, serverTarget); + } + + TiedUpMod.LOGGER.debug( + "[ItemBind] {} cancelled tying task", + player.getName().getString() + ); + } + + return InteractionResult.FAIL; + } + + /** + * Get the tying duration in seconds from GameRule. + * Phase 6: Reads from custom GameRule "tyingPlayerTime" + * + * @param player The player (for accessing world/GameRules) + * @return Duration in seconds (default: 5) + */ + private int getTyingDuration(Player player) { + return SettingsAccessor.getTyingPlayerTime(player.level().getGameRules()); + } + + // ========== Phase 7: Resistance System (via IHasResistance) ========== + + /** + * Get the item name for GameRule lookup. + * Each subclass must implement this to return its identifier (e.g., "rope", "chain", etc.) + * + * @return Item name for resistance GameRule lookup + */ + public abstract String getItemName(); + + // ========== Phase 15: Pose System ========== + + /** + * Get the pose type for this bind item. + * Determines which animation/pose is applied when this item is equipped. + * + * Override in subclasses for special poses (straitjacket, wrap, latex_sack). + * + * @return PoseType for this bind (default: STANDARD) + */ + public PoseType getPoseType() { + return PoseType.STANDARD; + } + + /** + * Implementation of IHasResistance.getResistanceId(). + * Delegates to getItemName() for backward compatibility with subclasses. + * + * @return Item identifier for resistance lookup + */ + @Override + public String getResistanceId() { + return getItemName(); + } + + /** + * Called when the entity struggles against this bind. + * Plays struggle sound and shows message. + * + * Based on original ItemBind struggle notification (1.12.2) + * + * @param entity The entity struggling + */ + @Override + public void notifyStruggle(LivingEntity entity) { + // Play struggle sound + TiedUpSounds.playStruggleSound(entity); + + // Log the struggle attempt + TiedUpMod.LOGGER.debug( + "[ItemBind] {} is struggling against bind", + entity.getName().getString() + ); + + // Notify nearby players if the entity is a player + if (entity instanceof ServerPlayer serverPlayer) { + serverPlayer.displayClientMessage( + Component.translatable("tiedup.message.struggling"), + true // Action bar + ); + } + } + + // ILockable implementation inherited from interface default methods +} diff --git a/src/main/java/com/tiedup/remake/items/base/ItemBlindfold.java b/src/main/java/com/tiedup/remake/items/base/ItemBlindfold.java new file mode 100644 index 0000000..627f02b --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/ItemBlindfold.java @@ -0,0 +1,90 @@ +package com.tiedup.remake.items.base; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.util.EquipmentInteractionHelper; +import java.util.List; +import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +/** + * Base class for blindfold items (classic blindfold, mask, hood, etc.) + * These items obstruct a player's vision when equipped. + * + * Based on original ItemBlindfold from 1.12.2 + * + * Phase 1: Basic implementation without rendering effects (added in Phase 5) + * Phase 8.5: Added interactLivingEntity for equipment on tied players + */ +public abstract class ItemBlindfold + extends Item + implements IBondageItem, IHasBlindingEffect, IAdjustable, ILockable +{ + + public ItemBlindfold(Properties properties) { + super(properties); + } + + @Override + public BodyRegionV2 getBodyRegion() { + return BodyRegionV2.EYES; + } + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + super.appendHoverText(stack, level, tooltip, flag); + appendLockTooltip(stack, tooltip); + } + + /** + * All blindfolds can be adjusted to better fit player skins. + * @return true - blindfolds support position adjustment + */ + @Override + public boolean canBeAdjusted() { + return true; + } + + /** + * Called when player right-clicks another entity with this blindfold. + * Allows putting blindfold on tied-up entities (Players and NPCs). + */ + @Override + public InteractionResult interactLivingEntity( + ItemStack stack, + Player player, + LivingEntity target, + InteractionHand hand + ) { + return EquipmentInteractionHelper.equipOnTarget( + stack, + player, + target, + state -> state.isBlindfolded(), + (state, item) -> state.equip(BodyRegionV2.EYES, item), + (state, item) -> state.replaceEquipment(BodyRegionV2.EYES, item, false), + (p, t) -> + SystemMessageManager.sendToTarget( + p, + t, + SystemMessageManager.MessageCategory.BLINDFOLDED + ), + "ItemBlindfold" + ); + } + + // ILockable implementation inherited from interface default methods +} diff --git a/src/main/java/com/tiedup/remake/items/base/ItemCollar.java b/src/main/java/com/tiedup/remake/items/base/ItemCollar.java new file mode 100644 index 0000000..8505c67 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/ItemCollar.java @@ -0,0 +1,1459 @@ +package com.tiedup.remake.items.base; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.sync.SyncManager; +import com.tiedup.remake.state.CollarRegistry; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.core.SettingsAccessor; +import com.tiedup.remake.util.teleport.Position; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +/** + * Base class for collar items (classic collar, shock collar, GPS collar, etc.) + * These items mark ownership and can have various special effects. + * + * Based on original ItemCollar from 1.12.2 + * + * Phase 1: Basic implementation + * Phase 8: Add ownership system, locking, and resistance + * Phase 14: Add GPS/shock features for special collars + * Phase 14.1.6: Implements ILockable interface for lock safety + * + * Note: Collars have maxStackSize of 1 (unique items) + */ +public abstract class ItemCollar + extends Item + implements IBondageItem, ILockable +{ + + // NBT Keys - Basic + private static final String NBT_OWNERS = "owners"; + private static final String NBT_LOCKED = "locked"; + private static final String NBT_LOCKED_BY_KEY_UUID = "lockedByKeyUUID"; + private static final String NBT_NICKNAME = "nickname"; + private static final String NBT_CURRENT_RESISTANCE = "currentResistance"; + private static final String NBT_CURRENT_RESISTANCE_LEGACY = + "currentresistance"; + private static final String NBT_CAN_BE_STRUGGLED_OUT = "canBeStruggledOut"; + + // NBT Keys - Kidnapping Mode + private static final String NBT_KIDNAPPING_MODE = "kidnappingMode"; + private static final String NBT_TIE_TO_POLE = "tieToPole"; + private static final String NBT_WARN_MASTERS = "warnMasters"; + private static final String NBT_BONDAGE_SERVICE = "bondageservice"; + private static final String NBT_SERVICE_SENTENCE = "servicesentence"; + + // NBT Keys - Blacklist/Whitelist (Phase 14.4) + private static final String NBT_BLACKLIST = "blacklist"; + private static final String NBT_WHITELIST = "whitelist"; + + // NBT Keys - Cell System + private static final String NBT_CELL_ID = "cellId"; + + /** + * ThreadLocal flag to suppress alert on legitimate collar removals. + * Set this to true before removing a collar legitimately (camp death, ransom paid, etc.) + * and reset to false afterward. + * + * CRITICAL FIX: Use runWithSuppressedAlert() wrapper to ensure proper cleanup. + */ + private static final ThreadLocal SUPPRESS_REMOVAL_ALERT = + ThreadLocal.withInitial(() -> false); + + /** + * CRITICAL FIX: Safe wrapper for legitimate collar removals. + * Automatically handles ThreadLocal lifecycle to prevent memory leaks. + * + * Use this instead of manual beginLegitimateRemoval() / endLegitimateRemoval(). + * + * @param action The action to perform with suppressed alerts + */ + public static void runWithSuppressedAlert(Runnable action) { + SUPPRESS_REMOVAL_ALERT.set(true); + try { + action.run(); + } finally { + SUPPRESS_REMOVAL_ALERT.set(false); + } + } + + /** + * DEPRECATED: Use runWithSuppressedAlert() instead. + * Begin a legitimate collar removal that should NOT alert kidnappers. + * @deprecated Use {@link #runWithSuppressedAlert(Runnable)} to prevent ThreadLocal leaks + */ + @Deprecated + public static void beginLegitimateRemoval() { + SUPPRESS_REMOVAL_ALERT.set(true); + } + + /** + * DEPRECATED: Use runWithSuppressedAlert() instead. + * End a legitimate collar removal sequence. + * @deprecated Use {@link #runWithSuppressedAlert(Runnable)} to prevent ThreadLocal leaks + */ + @Deprecated + public static void endLegitimateRemoval() { + SUPPRESS_REMOVAL_ALERT.set(false); + } + + /** + * Check if removal alerts are currently suppressed. + */ + public static boolean isRemovalAlertSuppressed() { + return SUPPRESS_REMOVAL_ALERT.get(); + } + + public ItemCollar(Properties properties) { + super(properties.stacksTo(1)); // Collars are unique items + } + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + // Nickname + if (hasNickname(stack)) { + tooltip.add( + Component.literal("Nickname: ") + .withStyle(ChatFormatting.AQUA) + .append( + Component.literal(getNickname(stack)).withStyle( + ChatFormatting.WHITE + ) + ) + ); + } + + // Locked status + if (isLocked(stack)) { + tooltip.add( + Component.literal("Locked").withStyle(ChatFormatting.RED) + ); + } + + // Kidnapping mode + if (isKidnappingModeEnabled(stack)) { + tooltip.add( + Component.literal("Kidnapping Mode: ON").withStyle( + ChatFormatting.DARK_RED + ) + ); + } + + // Additional flags + if (shouldTieToPole(stack)) { + tooltip.add( + Component.literal("Tie to Pole: ON").withStyle( + ChatFormatting.GRAY + ) + ); + } + if (isBondageServiceEnabled(stack)) { + tooltip.add( + Component.literal("Bondage Service: ON").withStyle( + ChatFormatting.LIGHT_PURPLE + ) + ); + } + + // Cell assignment info + UUID cellId = getCellId(stack); + if (cellId != null) { + tooltip.add( + Component.literal("Cell: ") + .withStyle(ChatFormatting.DARK_PURPLE) + .append( + Component.literal( + cellId.toString().substring(0, 8) + "..." + ).withStyle(ChatFormatting.LIGHT_PURPLE) + ) + ); + } + } + + public String getNickname(ItemStack stack) { + CompoundTag tag = stack.getTag(); + if (tag != null && tag.contains(NBT_NICKNAME)) { + return tag.getString(NBT_NICKNAME); + } + return null; + } + + public void setNickname(ItemStack stack, String nickname) { + stack.getOrCreateTag().putString(NBT_NICKNAME, nickname); + } + + public boolean hasNickname(ItemStack stack) { + return stack.hasTag() && stack.getTag().contains(NBT_NICKNAME); + } + + @Override + public BodyRegionV2 getBodyRegion() { + return BodyRegionV2.NECK; + } + + /** + * Get the item name for GameRule lookup. + * Used by SettingsAccessor to find resistance value. + * + * @return "collar" + */ + public String getItemName() { + return "collar"; + } + + /** + * Check if this collar can shock the wearer. + * Override in shock collar subclasses. + * + * @return true if collar has shock capability + */ + public boolean canShock() { + return false; + } + + /** + * Check if this collar has GPS tracking. + * Override in GPS collar subclasses. + * + * @return true if collar has GPS capability + */ + public boolean hasGPS() { + return false; + } + + // ======================================== + // Phase 8.5: Interactive Equipment + // ======================================== + + /** + * Called when player right-clicks another entity with this collar. + * Allows putting collar on tied-up entities (Players and NPCs) and adds the player as owner. + * + * Phase 14.1.5: Refactored to support IBondageState (LivingEntity + NPCs) + * Based on original ItemCollar.itemInteractionForEntity() + * + * @param stack The item stack + * @param player The player using the item + * @param target The entity being interacted with + * @param hand The hand holding the item + * @return SUCCESS if collar equipped/replaced, PASS otherwise + */ + @Override + public InteractionResult interactLivingEntity( + ItemStack stack, + Player player, + LivingEntity target, + InteractionHand hand + ) { + // Server-side only + if (player.level().isClientSide) { + return InteractionResult.SUCCESS; + } + + // Check if target can be collared (Player, EntityDamsel, EntityKidnapper) + IBondageState targetState = KidnappedHelper.getKidnappedState(target); + if (targetState == null) { + return InteractionResult.PASS; // Entity cannot be collared + } + + // Must be tied up + if (!targetState.isTiedUp()) { + return InteractionResult.PASS; + } + + // Phase 8: Add player as owner if not already + ItemStack newCollar = stack.copy(); + if (!isOwner(newCollar, player)) { + addOwner(newCollar, player); + } + + // Case 1: No collar yet - equip new one + if (!targetState.hasCollar()) { + targetState.equip(BodyRegionV2.NECK, newCollar); + stack.shrink(1); + + // Phase 17: Register in CollarRegistry + registerCollarInRegistry(target, newCollar, player); + + // Send screen message to target + SystemMessageManager.sendToTarget( + player, + target, + SystemMessageManager.MessageCategory.COLLARED + ); + + // Sync equipment to all tracking clients + if (target instanceof ServerPlayer serverPlayer) { + SyncManager.syncInventory(serverPlayer); + } + + TiedUpMod.LOGGER.info( + "[ItemCollar] {} put collar on {}", + player.getName().getString(), + target.getName().getString() + ); + + // Play custom put sound + player + .level() + .playSound( + null, + player.blockPosition(), + com.tiedup.remake.core.ModSounds.COLLAR_PUT.get(), + net.minecraft.sounds.SoundSource.PLAYERS, + 1.0f, + 1.0f + ); + + return InteractionResult.SUCCESS; + } + // Case 2: Already has collar - replace it + else { + ItemStack oldCollar = targetState.getEquipment(BodyRegionV2.NECK); + if ( + oldCollar != null && oldCollar.getItem() instanceof ItemCollar + ) { + ItemCollar oldCollarItem = (ItemCollar) oldCollar.getItem(); + + // Check if old collar is locked + if (oldCollarItem.isLocked(oldCollar)) { + // Cannot replace locked collar + TiedUpMod.LOGGER.info( + "[ItemCollar] {} tried to replace locked collar on {}", + player.getName().getString(), + target.getName().getString() + ); + + // Send error message to player (kidnapper) + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "Target's collar is locked! You cannot replace it." + ); + return InteractionResult.FAIL; + } + + // Old collar not locked - replace it + ItemStack replacedCollar = targetState.replaceEquipment(BodyRegionV2.NECK, newCollar, false); + if (replacedCollar != null) { + stack.shrink(1); + targetState.kidnappedDropItem(replacedCollar); + + // Phase 17: Update CollarRegistry (unregister old, register new) + unregisterCollarFromRegistry(target); + registerCollarInRegistry(target, newCollar, player); + + // Send screen message to target + SystemMessageManager.sendToTarget( + player, + target, + SystemMessageManager.MessageCategory.COLLARED + ); + + // Sync equipment to all tracking clients + if (target instanceof ServerPlayer serverPlayer) { + SyncManager.syncInventory(serverPlayer); + } + + TiedUpMod.LOGGER.info( + "[ItemCollar] {} replaced collar on {}", + player.getName().getString(), + target.getName().getString() + ); + + return InteractionResult.SUCCESS; + } + } + } + + return InteractionResult.PASS; + } + + // ======================================== + // Phase 8: Ownership System + // ======================================== + + /** + * Add an owner to this collar. + * Owners are stored as UUID + name pairs in NBT. + * + * From original ItemCollar.addOwner() + * + * @param stack The collar ItemStack + * @param ownerUUID The owner's UUID + * @param ownerName The owner's display name + */ + public void addOwner(ItemStack stack, UUID ownerUUID, String ownerName) { + if (stack.isEmpty() || ownerUUID == null) { + return; + } + + CompoundTag tag = stack.getOrCreateTag(); + ListTag owners = tag.contains(NBT_OWNERS) + ? tag.getList(NBT_OWNERS, Tag.TAG_COMPOUND) + : new ListTag(); + + // Check if already owner + for (int i = 0; i < owners.size(); i++) { + CompoundTag ownerTag = owners.getCompound(i); + if (ownerTag.getUUID("uuid").equals(ownerUUID)) { + TiedUpMod.LOGGER.debug( + "[ItemCollar] {} is already an owner", + ownerName + ); + return; + } + } + + // Add new owner + CompoundTag ownerTag = new CompoundTag(); + ownerTag.putUUID("uuid", ownerUUID); + ownerTag.putString("name", ownerName != null ? ownerName : "Unknown"); + owners.add(ownerTag); + tag.put(NBT_OWNERS, owners); + + TiedUpMod.LOGGER.info("[ItemCollar] Added {} as owner", ownerName); + } + + /** + * Add an owner to this collar (player version). + * + * @param stack The collar ItemStack + * @param owner The owner player + */ + public void addOwner(ItemStack stack, Player owner) { + if (owner == null) { + return; + } + addOwner(stack, owner.getUUID(), owner.getName().getString()); + } + + /** + * Remove an owner from this collar. + * + * @param stack The collar ItemStack + * @param ownerUUID The owner's UUID to remove + */ + public void removeOwner(ItemStack stack, UUID ownerUUID) { + if (stack.isEmpty() || ownerUUID == null) { + return; + } + + CompoundTag tag = stack.getTag(); + if (tag == null || !tag.contains(NBT_OWNERS)) { + return; + } + + ListTag owners = tag.getList(NBT_OWNERS, Tag.TAG_COMPOUND); + ListTag newOwners = new ListTag(); + + for (int i = 0; i < owners.size(); i++) { + CompoundTag ownerTag = owners.getCompound(i); + if (!ownerTag.getUUID("uuid").equals(ownerUUID)) { + newOwners.add(ownerTag); + } + } + + tag.put(NBT_OWNERS, newOwners); + TiedUpMod.LOGGER.info("[ItemCollar] Removed owner {}", ownerUUID); + } + + /** + * Get the list of all owners. + * + * @param stack The collar ItemStack + * @return List of owner UUIDs + */ + public List getOwners(ItemStack stack) { + if (stack.isEmpty()) { + return new ArrayList<>(); + } + + CompoundTag tag = stack.getTag(); + if (tag == null || !tag.contains(NBT_OWNERS)) { + return new ArrayList<>(); + } + + ListTag owners = tag.getList(NBT_OWNERS, Tag.TAG_COMPOUND); + List result = new ArrayList<>(); + + for (int i = 0; i < owners.size(); i++) { + result.add(owners.getCompound(i).getUUID("uuid")); + } + + return result; + } + + /** + * Check if the given player is an owner of this collar. + * Optimized: uses direct NBT lookup with early-return instead of building list. + * + * @param stack The collar ItemStack + * @param player The player to check + * @return true if player is an owner + */ + public boolean isOwner(ItemStack stack, Player player) { + if (player == null) { + return false; + } + return hasUUIDInList(stack, NBT_OWNERS, player.getUUID()); + } + + /** + * Check if this collar has any owners. + * + * @param stack The collar ItemStack + * @return true if has at least one owner + */ + public boolean hasOwner(ItemStack stack) { + return !getOwners(stack).isEmpty(); + } + + // ======================================== + // Phase 14.4: Blacklist/Whitelist System + // ======================================== + + /** + * Add a player to this collar's blacklist. + * Blacklisted players will not be targeted when kidnapping mode is active. + * + * @param stack The collar ItemStack + * @param uuid The player's UUID + * @param name The player's display name + */ + public void addToBlacklist(ItemStack stack, UUID uuid, String name) { + addToList(stack, NBT_BLACKLIST, uuid, name); + } + + /** + * Add a player to this collar's blacklist (player version). + */ + public void addToBlacklist(ItemStack stack, Player player) { + if (player == null) return; + addToBlacklist(stack, player.getUUID(), player.getName().getString()); + } + + /** + * Remove a player from this collar's blacklist. + */ + public void removeFromBlacklist(ItemStack stack, UUID uuid) { + removeFromList(stack, NBT_BLACKLIST, uuid); + } + + /** + * Get all blacklisted player UUIDs. + */ + public List getBlacklist(ItemStack stack) { + return getListUUIDs(stack, NBT_BLACKLIST); + } + + /** + * Check if a player is blacklisted. + * Optimized: uses direct NBT lookup with early-return instead of building list. + */ + public boolean isBlacklisted(ItemStack stack, UUID uuid) { + return hasUUIDInList(stack, NBT_BLACKLIST, uuid); + } + + /** + * Check if a player is blacklisted (player version). + */ + public boolean isBlacklisted(ItemStack stack, Player player) { + return player != null && isBlacklisted(stack, player.getUUID()); + } + + /** + * Add a player to this collar's whitelist. + * When whitelist is not empty, ONLY whitelisted players will be targeted. + * + * @param stack The collar ItemStack + * @param uuid The player's UUID + * @param name The player's display name + */ + public void addToWhitelist(ItemStack stack, UUID uuid, String name) { + addToList(stack, NBT_WHITELIST, uuid, name); + } + + /** + * Add a player to this collar's whitelist (player version). + */ + public void addToWhitelist(ItemStack stack, Player player) { + if (player == null) return; + addToWhitelist(stack, player.getUUID(), player.getName().getString()); + } + + /** + * Remove a player from this collar's whitelist. + */ + public void removeFromWhitelist(ItemStack stack, UUID uuid) { + removeFromList(stack, NBT_WHITELIST, uuid); + } + + /** + * Get all whitelisted player UUIDs. + */ + public List getWhitelist(ItemStack stack) { + return getListUUIDs(stack, NBT_WHITELIST); + } + + /** + * Check if a player is whitelisted. + * Optimized: uses direct NBT lookup with early-return instead of building list. + */ + public boolean isWhitelisted(ItemStack stack, UUID uuid) { + return hasUUIDInList(stack, NBT_WHITELIST, uuid); + } + + /** + * Check if a player is whitelisted (player version). + */ + public boolean isWhitelisted(ItemStack stack, Player player) { + return player != null && isWhitelisted(stack, player.getUUID()); + } + + // ========== Helper methods for list management ========== + + /** + * Check if a UUID exists in a specific list (owners, blacklist, whitelist). + * Optimized O(n) with early-return instead of O(2n) from building list + contains. + * + * @param stack The collar ItemStack + * @param listKey The NBT key for the list + * @param uuid The UUID to check + * @return true if UUID is in the list + */ + private boolean hasUUIDInList(ItemStack stack, String listKey, UUID uuid) { + if (stack.isEmpty() || uuid == null) return false; + + CompoundTag tag = stack.getTag(); + if (tag == null || !tag.contains(listKey)) return false; + + ListTag list = tag.getList(listKey, Tag.TAG_COMPOUND); + for (int i = 0; i < list.size(); i++) { + if (list.getCompound(i).getUUID("uuid").equals(uuid)) { + return true; // Early return - no need to build full list + } + } + return false; + } + + private void addToList( + ItemStack stack, + String listKey, + UUID uuid, + String name + ) { + if (stack.isEmpty() || uuid == null) return; + + CompoundTag tag = stack.getOrCreateTag(); + ListTag list = tag.contains(listKey) + ? tag.getList(listKey, Tag.TAG_COMPOUND) + : new ListTag(); + + // Check if already in list + for (int i = 0; i < list.size(); i++) { + CompoundTag entry = list.getCompound(i); + if (entry.getUUID("uuid").equals(uuid)) { + return; // Already in list + } + } + + // Add new entry + CompoundTag entry = new CompoundTag(); + entry.putUUID("uuid", uuid); + entry.putString("name", name != null ? name : "Unknown"); + list.add(entry); + tag.put(listKey, list); + } + + private void removeFromList(ItemStack stack, String listKey, UUID uuid) { + if (stack.isEmpty() || uuid == null) return; + + CompoundTag tag = stack.getTag(); + if (tag == null || !tag.contains(listKey)) return; + + ListTag list = tag.getList(listKey, Tag.TAG_COMPOUND); + ListTag newList = new ListTag(); + + for (int i = 0; i < list.size(); i++) { + CompoundTag entry = list.getCompound(i); + if (!entry.getUUID("uuid").equals(uuid)) { + newList.add(entry); + } + } + + tag.put(listKey, newList); + } + + private List getListUUIDs(ItemStack stack, String listKey) { + if (stack.isEmpty()) return new ArrayList<>(); + + CompoundTag tag = stack.getTag(); + if (tag == null || !tag.contains(listKey)) return new ArrayList<>(); + + ListTag list = tag.getList(listKey, Tag.TAG_COMPOUND); + List result = new ArrayList<>(); + + for (int i = 0; i < list.size(); i++) { + result.add(list.getCompound(i).getUUID("uuid")); + } + + return result; + } + + // ======================================== + // Phase 8: Locking System + // ======================================== + + /** + * Check if this collar is locked. + * Locked collars cannot be removed normally. + * + * Phase 14.1.6: Now part of ILockable interface + * + * @param stack The collar ItemStack + * @return true if locked + */ + @Override + public boolean isLocked(ItemStack stack) { + if (stack.isEmpty()) { + return false; + } + CompoundTag tag = stack.getTag(); + return tag != null && tag.getBoolean(NBT_LOCKED); + } + + /** + * Set the locked state of this collar. + * + * Phase 14.1.6: Changed return type from void to ItemStack (ILockable interface) + * + * @param stack The collar ItemStack + * @param locked true to lock, false to unlock + * @return The modified ItemStack for chaining + */ + @Override + public ItemStack setLocked(ItemStack stack, boolean locked) { + if (stack.isEmpty()) { + return stack; + } + stack.getOrCreateTag().putBoolean(NBT_LOCKED, locked); + TiedUpMod.LOGGER.debug("[ItemCollar] Set locked={}", locked); + return stack; + } + + /** + * Check if this collar can be locked. + * By default, all collars are lockable. + * + * Phase 14.1.6: Added for ILockable interface + * + * @param stack The collar ItemStack + * @return true if lockable (can accept a padlock) + */ + @Override + public boolean isLockable(ItemStack stack) { + if (stack.isEmpty()) { + return false; + } + CompoundTag tag = stack.getTag(); + // By default, collars are lockable unless explicitly set to false + return ( + tag == null || + !tag.contains("lockable") || + tag.getBoolean("lockable") + ); + } + + /** + * Set whether this collar can be locked (lockable state). + * + * Phase 14.1.6: Added for ILockable interface + * + * @param stack The collar ItemStack + * @param state true to make lockable, false to prevent locking + * @return The modified ItemStack for chaining + */ + @Override + public ItemStack setLockable(ItemStack stack, boolean state) { + if (stack.isEmpty()) { + return stack; + } + stack.getOrCreateTag().putBoolean("lockable", state); + return stack; + } + + /** + * Check if the padlock should be dropped when unlocking. + * Collars have a built-in lock mechanism, so no padlock to drop. + * + * Phase 20: Collars are inherently lockable, no external padlock needed. + * + * @return false (no padlock to drop for collars) + */ + @Override + public boolean dropLockOnUnlock() { + return false; + } + + // ========== Phase 20: Key-Lock System ========== + + /** + * Get the UUID of the key that locked this collar. + * + * @param stack The collar ItemStack + * @return The key UUID or null if not locked with a key + */ + @Override + @Nullable + public UUID getLockedByKeyUUID(ItemStack stack) { + if (stack.isEmpty()) return null; + CompoundTag tag = stack.getTag(); + if (tag == null || !tag.hasUUID(NBT_LOCKED_BY_KEY_UUID)) return null; + return tag.getUUID(NBT_LOCKED_BY_KEY_UUID); + } + + /** + * Set the key UUID that locks this collar. + * Setting a non-null UUID will also set locked=true. + * Setting null will unlock the collar. + * + * @param stack The collar ItemStack + * @param keyUUID The key UUID or null to unlock + */ + @Override + public void setLockedByKeyUUID(ItemStack stack, @Nullable UUID keyUUID) { + if (stack.isEmpty()) return; + CompoundTag tag = stack.getOrCreateTag(); + if (keyUUID == null) { + tag.remove(NBT_LOCKED_BY_KEY_UUID); + tag.putBoolean(NBT_LOCKED, false); + } else { + tag.putUUID(NBT_LOCKED_BY_KEY_UUID, keyUUID); + tag.putBoolean(NBT_LOCKED, true); + } + } + + // ======================================== + // Phase 8: Resistance System (like ItemBind Phase 7) + // ======================================== + + /** + * Get the base resistance for this collar from config. + * + * Phase 14.1.5: Refactored to support LivingEntity + * BUG-003 fix: Now reads from SettingsAccessor (config) instead of GameRules + * + * @param entity The entity (kept for API compatibility) + * @return Base resistance value from config + */ + public int getBaseResistance(LivingEntity entity) { + return SettingsAccessor.getBindResistance(getItemName()); + } + + /** + * Get the current resistance of this collar. + * Returns base resistance if not yet set. + * + * Phase 14.1.5: Refactored to support LivingEntity + * + * @param stack The collar ItemStack + * @param entity The entity (for GameRules lookup) + * @return Current resistance + */ + public int getCurrentResistance(ItemStack stack, LivingEntity entity) { + if (stack.isEmpty()) { + return 0; + } + + CompoundTag tag = stack.getTag(); + if (tag != null) { + // Check new camelCase key first + if (tag.contains(NBT_CURRENT_RESISTANCE)) { + return tag.getInt(NBT_CURRENT_RESISTANCE); + } + // Migration: check legacy lowercase key + else if (tag.contains(NBT_CURRENT_RESISTANCE_LEGACY)) { + int resistance = tag.getInt(NBT_CURRENT_RESISTANCE_LEGACY); + // Migrate to new key + tag.remove(NBT_CURRENT_RESISTANCE_LEGACY); + if (resistance > 0) { + tag.putInt(NBT_CURRENT_RESISTANCE, resistance); + } + return resistance > 0 ? resistance : getBaseResistance(entity); + } + } + + // Not set yet - return base resistance + return getBaseResistance(entity); + } + + /** + * Set the current resistance of this collar. + * + * @param stack The collar ItemStack + * @param resistance The new resistance value + */ + public void setCurrentResistance(ItemStack stack, int resistance) { + if (stack.isEmpty()) { + return; + } + stack.getOrCreateTag().putInt(NBT_CURRENT_RESISTANCE, resistance); + } + + /** + * Reset the current resistance to base value. + * + * Phase 14.1.5: Refactored to support LivingEntity + * + * @param stack The collar ItemStack + * @param entity The entity (for GameRules lookup) + */ + public void resetCurrentResistance(ItemStack stack, LivingEntity entity) { + setCurrentResistance(stack, getBaseResistance(entity)); + } + + /** + * Check if this collar can be struggled out of. + * + * @param stack The collar ItemStack + * @return true if struggle is enabled + */ + public boolean canBeStruggledOut(ItemStack stack) { + if (stack.isEmpty()) { + return false; + } + + CompoundTag tag = stack.getTag(); + if (tag != null && tag.contains(NBT_CAN_BE_STRUGGLED_OUT)) { + return tag.getBoolean(NBT_CAN_BE_STRUGGLED_OUT); + } + + return true; // Default: can struggle + } + + /** + * Set whether this collar can be struggled out of. + * + * @param stack The collar ItemStack + * @param canStruggle true to enable struggle + */ + public void setCanBeStruggledOut(ItemStack stack, boolean canStruggle) { + if (stack.isEmpty()) { + return; + } + stack + .getOrCreateTag() + .putBoolean(NBT_CAN_BE_STRUGGLED_OUT, canStruggle); + } + + // ======================================== + // Cell ID (Assigned Cell) + // ======================================== + + /** + * Get the assigned cell ID from this collar. + * + * @param stack The collar ItemStack + * @return Cell UUID or null if not assigned + */ + @Nullable + public UUID getCellId(ItemStack stack) { + if (stack.isEmpty()) { + return null; + } + + CompoundTag tag = stack.getTag(); + if (tag == null || !tag.hasUUID(NBT_CELL_ID)) { + return null; + } + + return tag.getUUID(NBT_CELL_ID); + } + + /** + * Set the assigned cell ID on this collar. + * + * @param stack The collar ItemStack + * @param cellId The cell UUID, or null to clear + */ + public void setCellId(ItemStack stack, @Nullable UUID cellId) { + if (stack.isEmpty()) { + return; + } + + CompoundTag tag = stack.getOrCreateTag(); + if (cellId == null) { + tag.remove(NBT_CELL_ID); + } else { + tag.putUUID(NBT_CELL_ID, cellId); + } + } + + /** + * Check if this collar has a cell assigned. + * + * @param stack The collar ItemStack + * @return true if a cell is assigned + */ + public boolean hasCellAssigned(ItemStack stack) { + return getCellId(stack) != null; + } + + // ======================================== + // Kidnapping Mode + // ======================================== + + /** + * Check if kidnapping mode is enabled. + * Kidnapping mode allows NPC kidnappers to auto-capture and teleport to prison. + * + * @param stack The collar ItemStack + * @return true if kidnapping mode enabled + */ + public boolean isKidnappingModeEnabled(ItemStack stack) { + if (stack.isEmpty()) { + return false; + } + + CompoundTag tag = stack.getTag(); + return tag != null && tag.getBoolean(NBT_KIDNAPPING_MODE); + } + + /** + * Set kidnapping mode state. + * + * @param stack The collar ItemStack + * @param enabled true to enable kidnapping mode + */ + public void setKidnappingModeEnabled(ItemStack stack, boolean enabled) { + if (stack.isEmpty()) { + return; + } + + stack.getOrCreateTag().putBoolean(NBT_KIDNAPPING_MODE, enabled); + TiedUpMod.LOGGER.info( + "[ItemCollar] Kidnapping mode set to {}", + enabled + ); + } + + /** + * Check if kidnapping mode is fully configured and ready. + * Requires: kidnapping mode ON + cell assigned + * + * @param stack The collar ItemStack + * @return true if ready for automated kidnapping + */ + public boolean isKidnappingModeReady(ItemStack stack) { + if (!isKidnappingModeEnabled(stack)) return false; + return hasCellAssigned(stack); + } + + // ======================================== + // Tie to Pole (Auto-tie slave in cell) + // ======================================== + + /** + * Check if slave should be auto-tied to nearest pole in prison. + * + * @param stack The collar ItemStack + * @return true if should tie to pole + */ + public boolean shouldTieToPole(ItemStack stack) { + if (stack.isEmpty()) { + return false; + } + + CompoundTag tag = stack.getTag(); + return tag != null && tag.getBoolean(NBT_TIE_TO_POLE); + } + + /** + * Set whether slave should be tied to pole in prison. + * + * @param stack The collar ItemStack + * @param enabled true to enable tie to pole + */ + public void setTieToPole(ItemStack stack, boolean enabled) { + if (stack.isEmpty()) { + return; + } + + stack.getOrCreateTag().putBoolean(NBT_TIE_TO_POLE, enabled); + } + + // ======================================== + // Warn Masters + // ======================================== + + /** + * Check if owners should be warned when slave is captured. + * + * @param stack The collar ItemStack + * @return true if should warn masters + */ + public boolean shouldWarnMasters(ItemStack stack) { + if (stack.isEmpty()) { + return true; // Default: warn + } + + CompoundTag tag = stack.getTag(); + if (tag != null && tag.contains(NBT_WARN_MASTERS)) { + return tag.getBoolean(NBT_WARN_MASTERS); + } + + return true; // Default: warn + } + + /** + * Set whether owners should be warned. + * + * @param stack The collar ItemStack + * @param enabled true to enable warnings + */ + public void setWarnMasters(ItemStack stack, boolean enabled) { + if (stack.isEmpty()) { + return; + } + + stack.getOrCreateTag().putBoolean(NBT_WARN_MASTERS, enabled); + } + + // ======================================== + // Bondage Service + // ======================================== + + /** + * Check if bondage service is enabled on this collar. + * Bondage service allows a Damsel to automatically capture attacking players. + * + * @param stack The collar ItemStack + * @return true if bondage service is enabled + */ + public boolean isBondageServiceEnabled(ItemStack stack) { + if (stack.isEmpty()) { + return false; + } + + CompoundTag tag = stack.getTag(); + return tag != null && tag.getBoolean(NBT_BONDAGE_SERVICE); + } + + /** + * Enable or disable bondage service on this collar. + * + * @param stack The collar ItemStack + * @param enabled true to enable bondage service + */ + public void setBondageServiceEnabled(ItemStack stack, boolean enabled) { + if (stack.isEmpty()) { + return; + } + + stack.getOrCreateTag().putBoolean(NBT_BONDAGE_SERVICE, enabled); + } + + /** + * Get the custom service sentence message. + * + * @param stack The collar ItemStack + * @return Custom message or null if not set + */ + @Nullable + public String getServiceSentence(ItemStack stack) { + if (stack.isEmpty()) { + return null; + } + + CompoundTag tag = stack.getTag(); + if (tag != null && tag.contains(NBT_SERVICE_SENTENCE)) { + return tag.getString(NBT_SERVICE_SENTENCE); + } + return null; + } + + /** + * Set a custom service sentence message. + * + * @param stack The collar ItemStack + * @param sentence The custom message + */ + public void setServiceSentence(ItemStack stack, String sentence) { + if (stack.isEmpty()) { + return; + } + + if (sentence == null || sentence.isEmpty()) { + stack.getOrCreateTag().remove(NBT_SERVICE_SENTENCE); + } else { + stack.getOrCreateTag().putString(NBT_SERVICE_SENTENCE, sentence); + } + } + + // ======================================== + // Phase 17: CollarRegistry Integration + // ======================================== + + /** + * Register a collar in the global CollarRegistry. + * Called when a collar is put on an entity. + * + * @param wearer The entity wearing the collar + * @param collarStack The collar ItemStack + * @param primaryOwner The player putting the collar on (primary owner) + */ + private void registerCollarInRegistry( + LivingEntity wearer, + ItemStack collarStack, + Player primaryOwner + ) { + if (wearer == null || wearer.level().isClientSide()) { + return; + } + + // Get server-side registry + if ( + !(wearer.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel) + ) { + return; + } + + CollarRegistry registry = CollarRegistry.get(serverLevel); + if (registry == null) { + return; + } + + // Get all owners from the collar NBT + java.util.List owners = getOwners(collarStack); + if (owners.isEmpty() && primaryOwner != null) { + // If no owners yet, use the player putting the collar on + owners = java.util.List.of(primaryOwner.getUUID()); + } + + // Register all owners + for (UUID ownerUUID : owners) { + registry.registerCollar(wearer.getUUID(), ownerUUID); + } + + // Sync to affected owners + syncRegistryToOwners(serverLevel, owners); + + TiedUpMod.LOGGER.debug( + "[CollarRegistry] Registered {} with {} owners", + wearer.getName().getString(), + owners.size() + ); + } + + /** + * Unregister a collar from the global CollarRegistry. + * Called when a collar is removed from an entity. + * + * @param wearer The entity whose collar is being removed + */ + private void unregisterCollarFromRegistry(LivingEntity wearer) { + if (wearer == null || wearer.level().isClientSide()) { + return; + } + + if ( + !(wearer.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel) + ) { + return; + } + + CollarRegistry registry = CollarRegistry.get(serverLevel); + if (registry == null) { + return; + } + + // Get owners before unregistering (for sync) + java.util.Set owners = registry.getOwners(wearer.getUUID()); + + // Unregister the wearer + registry.unregisterWearer(wearer.getUUID()); + + // Sync to affected owners + syncRegistryToOwners(serverLevel, owners); + + TiedUpMod.LOGGER.debug( + "[CollarRegistry] Unregistered {}", + wearer.getName().getString() + ); + } + + // ======================================== + // ESCAPE DETECTION: Collar Removal Alert + // ======================================== + + /** + * Alert nearby kidnappers when a collar is forcibly removed. + * This indicates a potential escape attempt. + * + * Should be called when: + * - Player struggles out of a collar + * - Collar is removed by another player without permission + * - Collar is broken/picked + * + * @param wearer The entity whose collar was removed + * @param forced Whether the removal was forced (struggle, lockpick, etc.) + */ + public static void onCollarRemoved(LivingEntity wearer, boolean forced) { + if (wearer == null || wearer.level().isClientSide()) { + return; + } + + if (!forced) { + return; // Only alert on forced removals + } + + if ( + !(wearer.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel) + ) { + return; + } + + TiedUpMod.LOGGER.info( + "[ItemCollar] {} collar was forcibly removed - alerting kidnappers", + wearer.getName().getString() + ); + + // Unregister from CollarRegistry + CollarRegistry registry = CollarRegistry.get(serverLevel); + if (registry != null) { + registry.unregisterWearer(wearer.getUUID()); + } + + // Find and alert nearby kidnappers + net.minecraft.world.phys.AABB searchBox = wearer + .getBoundingBox() + .inflate(50, 20, 50); + java.util.List kidnappers = + serverLevel.getEntitiesOfClass( + com.tiedup.remake.entities.EntityKidnapper.class, + searchBox + ); + + for (com.tiedup.remake.entities.EntityKidnapper kidnapper : kidnappers) { + // Check if this kidnapper owned the collar + // For now, alert all kidnappers - they'll check on their own + if (!kidnapper.hasCaptives() && !kidnapper.isTiedUp()) { + kidnapper.setAlertTarget(wearer); + kidnapper.setCurrentState( + com.tiedup.remake.entities.ai.kidnapper.KidnapperState.ALERT + ); + kidnapper.broadcastAlert(wearer); + + TiedUpMod.LOGGER.debug( + "[ItemCollar] Alerted kidnapper {} about collar removal", + kidnapper.getNpcName() + ); + } + } + } + + /** + * Sync the CollarRegistry to specific owners. + * + * @param level The server level + * @param ownerUUIDs The owners to sync to + */ + private void syncRegistryToOwners( + net.minecraft.server.level.ServerLevel level, + java.util.Collection ownerUUIDs + ) { + net.minecraft.server.MinecraftServer server = level.getServer(); + if (server == null) { + return; + } + + CollarRegistry registry = CollarRegistry.get(server); + if (registry == null) { + return; + } + + for (UUID ownerUUID : ownerUUIDs) { + net.minecraft.server.level.ServerPlayer owner = server + .getPlayerList() + .getPlayer(ownerUUID); + if (owner != null) { + // Send full sync to this owner + java.util.Set slaves = registry.getSlaves(ownerUUID); + com.tiedup.remake.network.ModNetwork.sendToPlayer( + new com.tiedup.remake.network.sync.PacketSyncCollarRegistry( + slaves + ), + owner + ); + } + } + } + + // ======================================== + // LIFECYCLE HOOKS + // ======================================== + + /** + * Called when a collar is unequipped from an entity. + * Triggers escape detection for forced removals. + * + * @param stack The unequipped collar + * @param entity The entity that was wearing the collar + */ + @Override + public void onUnequipped(ItemStack stack, LivingEntity entity) { + // Check if this is a legitimate removal (camp death, ransom paid, etc.) + if (isRemovalAlertSuppressed()) { + TiedUpMod.LOGGER.debug( + "[ItemCollar] Collar removal for {} - alert suppressed (legitimate removal)", + entity.getName().getString() + ); + return; + } + + // Alert kidnappers about the collar removal (forced/escape) + onCollarRemoved(entity, true); + } + + // ======================================== + // TEXTURE SUBFOLDER + // ======================================== + + /** + * Get the texture subfolder for collar items. + * Issue #12 fix: Eliminates string checks in renderers. + */ + @Override + public String getTextureSubfolder() { + return "collars"; + } +} diff --git a/src/main/java/com/tiedup/remake/items/base/ItemColor.java b/src/main/java/com/tiedup/remake/items/base/ItemColor.java new file mode 100644 index 0000000..9024030 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/ItemColor.java @@ -0,0 +1,149 @@ +package com.tiedup.remake.items.base; + +import java.util.Random; + +/** + * Standard colors for bondage items. + * Colors are stored in NBT and used for texture selection. + * + * Based on original mod color variants: + * - 16 standard colors (matching Minecraft dye colors) + * - 2 special variants for tape (caution, clear) + */ +public enum ItemColor { + // Standard 16 colors (modelId used for CustomModelData) + BLACK("black", 0x1D1D21, 1), + BLUE("blue", 0x3C44AA, 2), + BROWN("brown", 0x835432, 3), + CYAN("cyan", 0x169C9C, 4), + GRAY("gray", 0x474F52, 5), + GREEN("green", 0x5E7C16, 6), + LIGHT_BLUE("light_blue", 0x3AB3DA, 7), + LIME("lime", 0x80C71F, 8), + MAGENTA("magenta", 0xC74EBD, 9), + ORANGE("orange", 0xF9801D, 10), + PINK("pink", 0xF38BAA, 11), + PURPLE("purple", 0x8932B8, 12), + RED("red", 0xB02E26, 13), + SILVER("silver", 0x9D9D97, 14), // Also known as light_gray + WHITE("white", 0xF9FFFE, 15), + YELLOW("yellow", 0xFED83D, 16), + + // Special variants (for duct_tape/tape_gag) + CAUTION("caution", 0xFFCC00, 17), + CLEAR("clear", 0xCCCCCC, 18); + + private static final Random RANDOM = new Random(); + + /** Standard colors (excludes special variants like caution/clear) */ + private static final ItemColor[] STANDARD_COLORS = { + BLACK, + BLUE, + BROWN, + CYAN, + GRAY, + GREEN, + LIGHT_BLUE, + LIME, + MAGENTA, + ORANGE, + PINK, + PURPLE, + RED, + SILVER, + WHITE, + YELLOW, + }; + + private final String name; + private final int hexColor; + private final int modelId; + + ItemColor(String name, int hexColor, int modelId) { + this.name = name; + this.hexColor = hexColor; + this.modelId = modelId; + } + + /** + * Get the name used in NBT and texture paths. + * Example: "red" -> textures/item/ropes_red.png + */ + public String getName() { + return name; + } + + /** + * Get the hex color value for tinting or display. + */ + public int getHexColor() { + return hexColor; + } + + /** + * Get the model ID used for CustomModelData. + * This allows item models to use overrides for different colors. + */ + public int getModelId() { + return modelId; + } + + /** + * Get a random standard color (excludes caution/clear). + */ + public static ItemColor getRandomStandard() { + return STANDARD_COLORS[RANDOM.nextInt(STANDARD_COLORS.length)]; + } + + /** + * Get a random standard color using a specific Random instance. + */ + public static ItemColor getRandomStandard(Random random) { + return STANDARD_COLORS[random.nextInt(STANDARD_COLORS.length)]; + } + + /** + * Get a color by name (for NBT deserialization). + * @return The color, or null if not found + */ + public static ItemColor fromName(String name) { + if (name == null || name.isEmpty()) { + return null; + } + for (ItemColor color : values()) { + if (color.name.equals(name)) { + return color; + } + } + return null; + } + + /** + * Check if this is a special color (caution/clear). + * Special colors are only used for tape items. + */ + public boolean isSpecial() { + return this == CAUTION || this == CLEAR; + } + + /** + * Get the red component as a float (0-1). + */ + public float getRed() { + return ((hexColor >> 16) & 0xFF) / 255.0f; + } + + /** + * Get the green component as a float (0-1). + */ + public float getGreen() { + return ((hexColor >> 8) & 0xFF) / 255.0f; + } + + /** + * Get the blue component as a float (0-1). + */ + public float getBlue() { + return (hexColor & 0xFF) / 255.0f; + } +} diff --git a/src/main/java/com/tiedup/remake/items/base/ItemEarplugs.java b/src/main/java/com/tiedup/remake/items/base/ItemEarplugs.java new file mode 100644 index 0000000..caf68b5 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/ItemEarplugs.java @@ -0,0 +1,90 @@ +package com.tiedup.remake.items.base; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.util.EquipmentInteractionHelper; +import com.tiedup.remake.util.TiedUpSounds; +import java.util.List; +import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +/** + * Base class for earplug items. + * These items block or reduce sounds when equipped. + * + * Based on original ItemEarplugs from 1.12.2 + * + * Phase 8.5: Basic implementation + equipment mechanics + * Phase future: Sound blocking effect + */ +public abstract class ItemEarplugs + extends Item + implements IBondageItem, ILockable +{ + + public ItemEarplugs(Properties properties) { + super(properties.stacksTo(16)); // Earplugs can stack to 16 + } + + @Override + public BodyRegionV2 getBodyRegion() { + return BodyRegionV2.EARS; + } + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + super.appendHoverText(stack, level, tooltip, flag); + appendLockTooltip(stack, tooltip); + } + + /** + * Called when player right-clicks another entity with earplugs. + * Allows putting earplugs on tied-up entities (Players and NPCs). + */ + @Override + public InteractionResult interactLivingEntity( + ItemStack stack, + Player player, + LivingEntity target, + InteractionHand hand + ) { + return EquipmentInteractionHelper.equipOnTarget( + stack, + player, + target, + state -> state.hasEarplugs(), + (state, item) -> state.equip(BodyRegionV2.EARS, item), + (state, item) -> state.replaceEquipment(BodyRegionV2.EARS, item, false), + (p, t) -> + SystemMessageManager.sendToTarget( + p, + t, + SystemMessageManager.MessageCategory.EARPLUGS_ON + ), + "ItemEarplugs", + null, // No pre-equip hook + (s, p, t, state) -> TiedUpSounds.playEarplugsEquipSound(t), // Post-equip: play sound + null // No replace check + ); + } + + // Sound blocking implemented in: + // - client/events/EarplugSoundHandler.java (event interception) + // - client/MuffledSoundInstance.java (volume/pitch wrapper) + // - Configurable via ModConfig.CLIENT.earplugVolumeMultiplier + + // ILockable implementation inherited from interface default methods +} diff --git a/src/main/java/com/tiedup/remake/items/base/ItemGag.java b/src/main/java/com/tiedup/remake/items/base/ItemGag.java new file mode 100644 index 0000000..b42e485 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/ItemGag.java @@ -0,0 +1,95 @@ +package com.tiedup.remake.items.base; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.util.EquipmentInteractionHelper; +import com.tiedup.remake.util.GagMaterial; +import java.util.List; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +/** + * Base class for gag items (ball gag, cloth gag, tape, etc.) + * These items prevent or muffle a player's speech when equipped. + * + * Based on original ItemGag from 1.12.2 + * + * Phase 1: Basic implementation without gag talk or adjustment (added later) + * Phase 8.5: Added interactLivingEntity for equipment on tied players + * Phase 12: Added GagTalk material system + */ +public abstract class ItemGag + extends Item + implements IBondageItem, IHasGaggingEffect, IAdjustable, ILockable +{ + + private final GagMaterial material; + + public ItemGag(Properties properties, GagMaterial material) { + super(properties); + this.material = material; + } + + public GagMaterial getGagMaterial() { + return this.material; + } + + @Override + public BodyRegionV2 getBodyRegion() { + return BodyRegionV2.MOUTH; + } + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + super.appendHoverText(stack, level, tooltip, flag); + appendLockTooltip(stack, tooltip); + } + + /** + * All gags can be adjusted to better fit player skins. + * @return true - gags support position adjustment + */ + @Override + public boolean canBeAdjusted() { + return true; + } + + /** + * Called when player right-clicks another entity with this gag. + * Allows putting gag on tied-up entities (Players and NPCs). + */ + @Override + public InteractionResult interactLivingEntity( + ItemStack stack, + Player player, + LivingEntity target, + InteractionHand hand + ) { + return EquipmentInteractionHelper.equipOnTarget( + stack, + player, + target, + state -> state.isGagged(), + (state, item) -> state.equip(BodyRegionV2.MOUTH, item), + (state, item) -> state.replaceEquipment(BodyRegionV2.MOUTH, item, false), + SystemMessageManager::sendGagged, + "ItemGag" + ); + } + + // ILockable implementation inherited from interface default methods +} diff --git a/src/main/java/com/tiedup/remake/items/base/ItemMittens.java b/src/main/java/com/tiedup/remake/items/base/ItemMittens.java new file mode 100644 index 0000000..995f579 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/ItemMittens.java @@ -0,0 +1,72 @@ +package com.tiedup.remake.items.base; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.util.EquipmentInteractionHelper; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +/** + * Base class for mittens items. + * These items block hand interactions (mining, placing, using items) when equipped. + * + * Phase 14.4: Mittens system + * + * Restrictions when wearing mittens: + * - Cannot mine/break blocks + * - Cannot place blocks + * - Cannot use items + * - Cannot attack (0 damage punch allowed) + * + * Allowed: + * - Push buttons/levers + * - Open doors + */ +public abstract class ItemMittens + extends Item + implements IBondageItem, ILockable +{ + + public ItemMittens(Properties properties) { + super(properties); + } + + @Override + public BodyRegionV2 getBodyRegion() { + return BodyRegionV2.HANDS; + } + + /** + * Called when player right-clicks another entity with these mittens. + * Allows putting mittens on tied-up entities (Players and NPCs). + */ + @Override + public InteractionResult interactLivingEntity( + ItemStack stack, + Player player, + LivingEntity target, + InteractionHand hand + ) { + return EquipmentInteractionHelper.equipOnTarget( + stack, + player, + target, + state -> state.hasMittens(), + (state, item) -> state.equip(BodyRegionV2.HANDS, item), + (state, item) -> state.replaceEquipment(BodyRegionV2.HANDS, item, false), + (p, t) -> + SystemMessageManager.sendToTarget( + p, + t, + SystemMessageManager.MessageCategory.MITTENS_ON + ), + "ItemMittens" + ); + } + + // ILockable implementation inherited from interface default methods +} diff --git a/src/main/java/com/tiedup/remake/items/base/ItemOwnerTarget.java b/src/main/java/com/tiedup/remake/items/base/ItemOwnerTarget.java new file mode 100644 index 0000000..052650c --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/ItemOwnerTarget.java @@ -0,0 +1,203 @@ +package com.tiedup.remake.items.base; + +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.v2.BodyRegionV2; +import java.util.List; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +/** + * Base class for items that maintain a relationship between an owner and a target. + * This is used for "pairing" mechanics like shocker controllers and GPS locators. + * + *

Data is stored directly in the ItemStack's NBT tag using UUIDs for reliability + * and Names for display in tooltips.

+ */ +public abstract class ItemOwnerTarget extends Item { + + public ItemOwnerTarget(Properties properties) { + super(properties); + } + + /** + * Links this item to a specific owner. + * @param stack The item instance + * @param owner The player who now owns this tool + */ + public void setOwner(ItemStack stack, Player owner) { + if (owner != null) { + CompoundTag nbt = stack.getOrCreateTag(); + nbt.putUUID("ownerId", owner.getUUID()); + nbt.putString("ownerName", owner.getName().getString()); + } + } + + /** + * Directly sets the owner UUID without a player instance. + */ + public void setOwnerId(ItemStack stack, UUID uuid) { + if (uuid != null) { + stack.getOrCreateTag().putUUID("ownerId", uuid); + } + } + + /** + * Clears all ownership data from the item. + */ + public void removeOwner(ItemStack stack) { + CompoundTag nbt = stack.getTag(); + if (nbt != null) { + nbt.remove("ownerId"); + nbt.remove("ownerName"); + } + } + + /** + * Links this tool to a specific target (victim/slave). + * Used for TARGETED mode in shockers and locators. + */ + public void setTarget(ItemStack stack, Entity target) { + if (target != null) { + CompoundTag nbt = stack.getOrCreateTag(); + nbt.putUUID("targetId", target.getUUID()); + nbt.putString("targetName", target.getName().getString()); + } + } + + /** + * Retrieves the stored owner UUID. + * @return UUID or null if unclaimed + */ + public UUID getOwnerId(ItemStack stack) { + CompoundTag nbt = stack.getTag(); + if (nbt != null && nbt.hasUUID("ownerId")) { + return nbt.getUUID("ownerId"); + } + return null; + } + + /** + * Retrieves the stored owner name for display purposes. + */ + public String getOwnerName(ItemStack stack) { + CompoundTag nbt = stack.getTag(); + if (nbt != null && nbt.contains("ownerName")) { + return nbt.getString("ownerName"); + } + return null; + } + + /** + * Retrieves the stored target UUID. + */ + public UUID getTargetId(ItemStack stack) { + CompoundTag nbt = stack.getTag(); + if (nbt != null && nbt.hasUUID("targetId")) { + return nbt.getUUID("targetId"); + } + return null; + } + + /** + * Retrieves the stored target name. + */ + public String getTargetName(ItemStack stack) { + CompoundTag nbt = stack.getTag(); + if (nbt != null && nbt.contains("targetName")) { + return nbt.getString("targetName"); + } + return null; + } + + public boolean hasOwner(ItemStack stack) { + return getOwnerId(stack) != null; + } + + public boolean hasTarget(ItemStack stack) { + return getTargetId(stack) != null; + } + + /** + * Verification if the current player matches the item's stored owner. + */ + public boolean isOwner(ItemStack stack, Player player) { + return player != null && isOwner(stack, player.getUUID()); + } + + public boolean isOwner(ItemStack stack, UUID uuid) { + if (uuid != null && stack != null) { + UUID ownerUUID = getOwnerId(stack); + return uuid.equals(ownerUUID); + } + return false; + } + + /** + * Check if a player instance matches the item's current target. + */ + // ===================================================== + // TOOLTIP HELPERS + // ===================================================== + + /** + * Appends the "Owner: ..." or "Unclaimed (...)" tooltip line. + * @param unclaimedHint text shown when unclaimed (e.g. "Right-click a player") + */ + protected void appendOwnerTooltip(ItemStack stack, List tooltip, String unclaimedHint) { + if (hasOwner(stack)) { + tooltip.add( + Component.literal("Owner: ") + .withStyle(ChatFormatting.GOLD) + .append(Component.literal(getOwnerName(stack)).withStyle(ChatFormatting.WHITE)) + ); + } else { + tooltip.add( + Component.literal("Unclaimed (" + unclaimedHint + ")").withStyle(ChatFormatting.GRAY) + ); + } + } + + /** + * Resolves the display name for the current target, enriching it with + * the collar nickname if available. + * @return display name, possibly "Nickname (RealName)" if collar has a nickname + */ + protected String resolveTargetDisplayName(ItemStack stack, @Nullable Level level) { + String displayName = getTargetName(stack); + if (level != null && hasTarget(stack)) { + Player target = level.getPlayerByUUID(getTargetId(stack)); + if (target != null) { + IBondageState targetState = KidnappedHelper.getKidnappedState(target); + if (targetState != null && targetState.hasCollar()) { + ItemStack collar = targetState.getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemCollar collarItem && collarItem.hasNickname(collar)) { + displayName = collarItem.getNickname(collar) + " (" + displayName + ")"; + } + } + } + } + return displayName; + } + + public boolean isTarget(ItemStack stack, Player potentialTarget) { + if (potentialTarget != null && stack != null) { + UUID playerUUID = potentialTarget.getUUID(); + UUID targetUUID = getTargetId(stack); + return ( + playerUUID != null && + targetUUID != null && + playerUUID.equals(targetUUID) + ); + } + return false; + } +} diff --git a/src/main/java/com/tiedup/remake/items/base/KnifeVariant.java b/src/main/java/com/tiedup/remake/items/base/KnifeVariant.java new file mode 100644 index 0000000..a67fb8f --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/KnifeVariant.java @@ -0,0 +1,61 @@ +package com.tiedup.remake.items.base; + +/** + * Enum defining all knife variants with their properties. + * Used by GenericKnife to create knife items via factory pattern. + * + * Each tier has its own cutting speed and durability: + * - Stone: slow, low capacity (emergency tool) + * - Iron: medium speed, reliable capacity + * - Golden: fast, high capacity (can cut through a padlock) + * + * Durability consumed per second = cuttingSpeed (1 durability = 1 resistance). + */ +public enum KnifeVariant { + STONE("stone_knife", 100, 5), // 100 dura, 5 res/s → 20s, 100 total resistance + IRON("iron_knife", 160, 8), // 160 dura, 8 res/s → 20s, 160 total resistance + GOLDEN("golden_knife", 300, 12); // 300 dura, 12 res/s → 25s, 300 total resistance + + private final String registryName; + private final int durability; + private final int cuttingSpeed; + + KnifeVariant(String registryName, int durability, int cuttingSpeed) { + this.registryName = registryName; + this.durability = durability; + this.cuttingSpeed = cuttingSpeed; + } + + public String getRegistryName() { + return registryName; + } + + /** + * Get the durability (max damage) of this knife. + * Total resistance a knife can cut = durability (1:1 ratio with cuttingSpeed drain). + * + * @return Durability value + */ + public int getDurability() { + return durability; + } + + /** + * Get the cutting speed (resistance removed per second). + * Also the durability consumed per second. + * + * @return Cutting speed in resistance/second + */ + public int getCuttingSpeed() { + return cuttingSpeed; + } + + /** + * Get max cutting time in seconds. + * + * @return Max cutting time (durability / cuttingSpeed) + */ + public int getMaxCuttingTimeSeconds() { + return durability / cuttingSpeed; + } +} diff --git a/src/main/java/com/tiedup/remake/items/base/MittensVariant.java b/src/main/java/com/tiedup/remake/items/base/MittensVariant.java new file mode 100644 index 0000000..1918265 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/MittensVariant.java @@ -0,0 +1,35 @@ +package com.tiedup.remake.items.base; + +/** + * Enum defining all mittens variants. + * Used by GenericMittens to create mittens items via factory pattern. + * + *

Phase 14.4: Mittens system - blocks hand interactions when equipped. + * + *

Issue #12 fix: Added textureSubfolder to eliminate string checks in renderers. + */ +public enum MittensVariant { + LEATHER("mittens", "mittens"); + + private final String registryName; + private final String textureSubfolder; + + MittensVariant(String registryName, String textureSubfolder) { + this.registryName = registryName; + this.textureSubfolder = textureSubfolder; + } + + public String getRegistryName() { + return registryName; + } + + /** + * Get the texture subfolder for this mittens variant. + * Used by renderers to locate texture files. + * + * @return Subfolder path under textures/entity/bondage/ (e.g., "mittens") + */ + public String getTextureSubfolder() { + return textureSubfolder; + } +} diff --git a/src/main/java/com/tiedup/remake/items/base/PoseType.java b/src/main/java/com/tiedup/remake/items/base/PoseType.java new file mode 100644 index 0000000..fa76ad3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/base/PoseType.java @@ -0,0 +1,55 @@ +package com.tiedup.remake.items.base; + +/** + * Enum defining the different pose types for restrained entities. + * Each pose type has a corresponding animation file for players + * and pose method in BondagePoseHelper for NPCs. + * + * Phase 15: Pose system for different bind types + */ +public enum PoseType { + /** Standard tied pose - arms behind back, legs frozen */ + STANDARD("tied_up_basic", "basic"), + + /** Straitjacket pose - arms crossed in front */ + STRAITJACKET("straitjacket", "straitjacket"), + + /** Wrap pose - arms at sides, body wrapped */ + WRAP("wrap", "wrap"), + + /** Latex sack pose - full enclosure, legs together */ + LATEX_SACK("latex_sack", "latex_sack"), + + /** Dog pose - on all fours (crawling) */ + DOG("tied_up_dog", "dog"), + + /** Human chair pose - on all fours with straight limbs (table/furniture) */ + HUMAN_CHAIR("human_chair", "human_chair"); + + private final String animationId; + private final String bindTypeName; + + PoseType(String animationId, String bindTypeName) { + this.animationId = animationId; + this.bindTypeName = bindTypeName; + } + + /** + * Get the animation file ID for this pose type. + * Used by PlayerAnimationManager to load the correct animation. + * + * @return Animation resource name (without path or extension) + */ + public String getAnimationId() { + return animationId; + } + + /** + * Get the bind type name used in SIT/KNEEL animation IDs. + * + * @return Bind type name (e.g., "basic", "straitjacket", "wrap") + */ + public String getBindTypeName() { + return bindTypeName; + } +} diff --git a/src/main/java/com/tiedup/remake/items/bondage3d/IHas3DModelConfig.java b/src/main/java/com/tiedup/remake/items/bondage3d/IHas3DModelConfig.java new file mode 100644 index 0000000..2870d57 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/bondage3d/IHas3DModelConfig.java @@ -0,0 +1,13 @@ +package com.tiedup.remake.items.bondage3d; + +/** + * Interface for items that have a 3D model configuration. + * Implement this to provide custom position, scale, and rotation for 3D rendering. + */ +public interface IHas3DModelConfig { + /** + * Get the 3D model configuration for rendering. + * @return The Model3DConfig with position, scale, and rotation offsets + */ + Model3DConfig getModelConfig(); +} diff --git a/src/main/java/com/tiedup/remake/items/bondage3d/Model3DConfig.java b/src/main/java/com/tiedup/remake/items/bondage3d/Model3DConfig.java new file mode 100644 index 0000000..791ed39 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/bondage3d/Model3DConfig.java @@ -0,0 +1,67 @@ +package com.tiedup.remake.items.bondage3d; + +import java.util.Set; + +/** + * Configuration immutable for a 3D item. + * Contains all parameters necessary for rendering. + */ +public record Model3DConfig( + String objPath, // "tiedup:models/obj/ball_gag/model.obj" + String texturePath, // "tiedup:models/obj/ball_gag/texture.png" (or null = use MTL) + float posOffsetX, // Horizontal offset + float posOffsetY, // Vertical offset (negative = lower) + float posOffsetZ, // Depth offset (positive = forward) + float scale, // Scale (1.0 = normal size) + float rotOffsetX, // Additional X rotation + float rotOffsetY, // Additional Y rotation + float rotOffsetZ, // Additional Z rotation + Set tintMaterials // Material names to apply color tint (e.g., "Ball") +) { + /** Config without tinting */ + public static Model3DConfig simple(String objPath, String texturePath) { + return new Model3DConfig( + objPath, + texturePath, + 0, + 0, + 0, + 1.0f, + 0, + 0, + 0, + Set.of() + ); + } + + /** Config with position/rotation but no tinting */ + public Model3DConfig( + String objPath, + String texturePath, + float posOffsetX, + float posOffsetY, + float posOffsetZ, + float scale, + float rotOffsetX, + float rotOffsetY, + float rotOffsetZ + ) { + this( + objPath, + texturePath, + posOffsetX, + posOffsetY, + posOffsetZ, + scale, + rotOffsetX, + rotOffsetY, + rotOffsetZ, + Set.of() + ); + } + + /** Check if this item supports color tinting */ + public boolean supportsTinting() { + return tintMaterials != null && !tintMaterials.isEmpty(); + } +} diff --git a/src/main/java/com/tiedup/remake/items/bondage3d/gags/ItemBallGag3D.java b/src/main/java/com/tiedup/remake/items/bondage3d/gags/ItemBallGag3D.java new file mode 100644 index 0000000..63f79fb --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/bondage3d/gags/ItemBallGag3D.java @@ -0,0 +1,78 @@ +package com.tiedup.remake.items.bondage3d.gags; + +import com.tiedup.remake.items.base.ItemGag; +import com.tiedup.remake.items.bondage3d.IHas3DModelConfig; +import com.tiedup.remake.items.bondage3d.Model3DConfig; +import com.tiedup.remake.util.GagMaterial; +import java.util.Set; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.Item; +import org.jetbrains.annotations.Nullable; + +/** + * Ball Gag 3D - Extends ItemGag with 3D OBJ model rendering. + * All 3D configuration is defined here. + * Supports color variants via tinting the "Ball" material. + */ +public class ItemBallGag3D extends ItemGag implements IHas3DModelConfig { + + // 3D config with "Ball" material tintable for color variants + private static final Model3DConfig CONFIG = new Model3DConfig( + "tiedup:models/obj/ball_gag/model.obj", // OBJ + "tiedup:models/obj/ball_gag/texture.png", // Explicit texture + 0.0f, // posX + 1.55f, // posY + 0.0f, // posZ + 1.0f, // scale + 0.0f, + 0.0f, + 180.0f, // rotation + Set.of("Ball") // Tintable materials (for color variants) + ); + + public ItemBallGag3D() { + super(new Item.Properties().stacksTo(16), GagMaterial.BALL); + } + + // ===== 3D Model Support ===== + + @Override + public boolean uses3DModel() { + return true; + } + + @Override + @Nullable + public ResourceLocation get3DModelLocation() { + return ResourceLocation.tryParse(CONFIG.objPath()); + } + + /** + * Returns the complete 3D configuration for the renderer. + */ + @Override + public Model3DConfig getModelConfig() { + return CONFIG; + } + + /** + * Explicit texture (if non-null, overrides MTL map_Kd). + */ + @Nullable + public ResourceLocation getExplicitTexture() { + String path = CONFIG.texturePath(); + return path != null ? ResourceLocation.tryParse(path) : null; + } + + // ===== Gag Properties ===== + + @Override + public String getTextureSubfolder() { + return "ballgags/normal"; // Fallback if 3D fails + } + + @Override + public boolean canAttachPadlock() { + return true; + } +} diff --git a/src/main/java/com/tiedup/remake/items/clothes/ClothesProperties.java b/src/main/java/com/tiedup/remake/items/clothes/ClothesProperties.java new file mode 100644 index 0000000..34f2f63 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/clothes/ClothesProperties.java @@ -0,0 +1,152 @@ +package com.tiedup.remake.items.clothes; + +import java.util.EnumSet; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; + +/** + * Immutable snapshot of clothes properties for rendering. + * Extracted from ItemStack NBT for efficient access during render. + * + *

This record is created once per render frame to avoid repeated NBT lookups. + */ +public record ClothesProperties( + @Nullable String dynamicTextureUrl, + boolean fullSkin, + boolean smallArms, + boolean keepHead, + EnumSet visibleLayers +) { + /** + * Enum representing the six body layer parts that can be hidden. + */ + public enum LayerPart { + HEAD, + BODY, + LEFT_ARM, + RIGHT_ARM, + LEFT_LEG, + RIGHT_LEG, + } + + /** + * Create a ClothesProperties from an ItemStack. + * + * @param stack The clothes ItemStack + * @return ClothesProperties, or null if not a GenericClothes item + */ + @Nullable + public static ClothesProperties fromStack(ItemStack stack) { + if ( + stack.isEmpty() || + !(stack.getItem() instanceof GenericClothes clothes) + ) { + return null; + } + + String url = clothes.getDynamicTextureUrl(stack); + boolean fullSkin = clothes.isFullSkinEnabled(stack); + boolean smallArms = clothes.shouldForceSmallArms(stack); + boolean keepHead = clothes.isKeepHeadEnabled(stack); + + EnumSet visible = EnumSet.noneOf(LayerPart.class); + if ( + clothes.isLayerEnabled(stack, GenericClothes.LAYER_HEAD) + ) visible.add(LayerPart.HEAD); + if ( + clothes.isLayerEnabled(stack, GenericClothes.LAYER_BODY) + ) visible.add(LayerPart.BODY); + if ( + clothes.isLayerEnabled(stack, GenericClothes.LAYER_LEFT_ARM) + ) visible.add(LayerPart.LEFT_ARM); + if ( + clothes.isLayerEnabled(stack, GenericClothes.LAYER_RIGHT_ARM) + ) visible.add(LayerPart.RIGHT_ARM); + if ( + clothes.isLayerEnabled(stack, GenericClothes.LAYER_LEFT_LEG) + ) visible.add(LayerPart.LEFT_LEG); + if ( + clothes.isLayerEnabled(stack, GenericClothes.LAYER_RIGHT_LEG) + ) visible.add(LayerPart.RIGHT_LEG); + + return new ClothesProperties( + url, + fullSkin, + smallArms, + keepHead, + visible + ); + } + + /** + * Check if a dynamic texture URL is set. + * + * @return true if a URL is available + */ + public boolean hasUrl() { + return dynamicTextureUrl != null && !dynamicTextureUrl.isEmpty(); + } + + /** + * Check if all layers are visible (default state). + * + * @return true if all 6 layers are visible + */ + public boolean allLayersVisible() { + return visibleLayers.size() == 6; + } + + /** + * Check if a specific layer is visible. + * + * @param part The layer part to check + * @return true if visible + */ + public boolean isLayerVisible(LayerPart part) { + return visibleLayers.contains(part); + } + + /** + * Encode layer visibility as a byte bitfield. + * Used for network packets. + * + *

Bit positions: + *

    + *
  • 0: HEAD
  • + *
  • 1: BODY
  • + *
  • 2: LEFT_ARM
  • + *
  • 3: RIGHT_ARM
  • + *
  • 4: LEFT_LEG
  • + *
  • 5: RIGHT_LEG
  • + *
+ * + * @return Bitfield byte (0b111111 = all visible) + */ + public byte encodeLayerVisibility() { + byte bits = 0; + if (visibleLayers.contains(LayerPart.HEAD)) bits |= 0b000001; + if (visibleLayers.contains(LayerPart.BODY)) bits |= 0b000010; + if (visibleLayers.contains(LayerPart.LEFT_ARM)) bits |= 0b000100; + if (visibleLayers.contains(LayerPart.RIGHT_ARM)) bits |= 0b001000; + if (visibleLayers.contains(LayerPart.LEFT_LEG)) bits |= 0b010000; + if (visibleLayers.contains(LayerPart.RIGHT_LEG)) bits |= 0b100000; + return bits; + } + + /** + * Decode layer visibility from a byte bitfield. + * + * @param bits The bitfield byte + * @return EnumSet of visible layers + */ + public static EnumSet decodeLayerVisibility(byte bits) { + EnumSet visible = EnumSet.noneOf(LayerPart.class); + if ((bits & 0b000001) != 0) visible.add(LayerPart.HEAD); + if ((bits & 0b000010) != 0) visible.add(LayerPart.BODY); + if ((bits & 0b000100) != 0) visible.add(LayerPart.LEFT_ARM); + if ((bits & 0b001000) != 0) visible.add(LayerPart.RIGHT_ARM); + if ((bits & 0b010000) != 0) visible.add(LayerPart.LEFT_LEG); + if ((bits & 0b100000) != 0) visible.add(LayerPart.RIGHT_LEG); + return visible; + } +} diff --git a/src/main/java/com/tiedup/remake/items/clothes/GenericClothes.java b/src/main/java/com/tiedup/remake/items/clothes/GenericClothes.java new file mode 100644 index 0000000..8410c02 --- /dev/null +++ b/src/main/java/com/tiedup/remake/items/clothes/GenericClothes.java @@ -0,0 +1,526 @@ +package com.tiedup.remake.items.clothes; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.network.sync.SyncManager; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +/** + * Generic clothes item with full NBT-based configuration. + * + *

Clothes are cosmetic items that can: + *

    + *
  • Use dynamic textures from URLs
  • + *
  • Replace the entire player skin (full-skin mode)
  • + *
  • Force slim arm model
  • + *
  • Control visibility of wearer's body parts
  • + *
  • Be locked with padlocks
  • + *
+ * + *

Unlike other bondage items, clothes have NO gameplay effects - they are purely visual. + */ +public class GenericClothes extends Item implements ILockable, IV2BondageItem { + + // ========== NBT KEYS ========== + public static final String NBT_DYNAMIC_TEXTURE = "dynamicTexture"; + public static final String NBT_FULL_SKIN = "fullSkin"; + public static final String NBT_SMALL_ARMS = "smallArms"; + public static final String NBT_KEEP_HEAD = "keepHead"; + public static final String NBT_LAYER_VISIBILITY = "layerVisibility"; + public static final String NBT_LOCKED = "locked"; + public static final String NBT_LOCKABLE = "lockable"; + public static final String NBT_LOCKED_BY_KEY_UUID = "lockedByKeyUUID"; + + // Layer visibility keys + public static final String LAYER_HEAD = "head"; + public static final String LAYER_BODY = "body"; + public static final String LAYER_LEFT_ARM = "leftArm"; + public static final String LAYER_RIGHT_ARM = "rightArm"; + public static final String LAYER_LEFT_LEG = "leftLeg"; + public static final String LAYER_RIGHT_LEG = "rightLeg"; + + public GenericClothes() { + super(new Item.Properties().stacksTo(16)); + } + + // ========== Lifecycle Hooks ========== + + @Override + public void onEquipped(ItemStack stack, LivingEntity entity) { + // Clothes have no special equip effects - purely cosmetic + } + + @Override + public void onUnequipped(ItemStack stack, LivingEntity entity) { + // Clothes have no special unequip effects + } + + /** + * Called when player right-clicks another entity with clothes. + * Allows putting clothes on tied-up entities (Players and NPCs). + * + * Unlike other bondage items, clothes can also be put on non-tied players + * if game rules allow it (roleplay scenarios). + * + * @param stack The item stack + * @param player The player using the item + * @param target The entity being interacted with + * @param hand The hand holding the item + * @return SUCCESS if clothes equipped/replaced, PASS otherwise + */ + @Override + public InteractionResult interactLivingEntity( + ItemStack stack, + Player player, + LivingEntity target, + InteractionHand hand + ) { + // Server-side only + if (player.level().isClientSide) { + return InteractionResult.SUCCESS; + } + + // Check if target can wear clothes (Player, EntityDamsel, EntityKidnapper) + IBondageState targetState = KidnappedHelper.getKidnappedState(target); + if (targetState == null) { + return InteractionResult.PASS; // Entity cannot wear clothes + } + + // Unlike gags/blindfolds, clothes can be put on non-tied players too + // But if tied, always allowed. If not tied, check if target allows it. + if (!targetState.isTiedUp() && !targetState.canChangeClothes(player)) { + return InteractionResult.PASS; + } + + // Case 1: No clothes yet - equip new one + if (!targetState.hasClothes()) { + ItemStack clothesCopy = stack.copyWithCount(1); + targetState.equip(BodyRegionV2.TORSO, clothesCopy); + stack.shrink(1); + + // Sync equipment to all tracking clients + if (target instanceof ServerPlayer serverPlayer) { + SyncManager.syncInventory(serverPlayer); + SyncManager.syncClothesConfig(serverPlayer); + } + + TiedUpMod.LOGGER.info( + "[GenericClothes] {} put clothes on {}", + player.getName().getString(), + target.getName().getString() + ); + + return InteractionResult.SUCCESS; + } + // Case 2: Already has clothes - replace them + else { + ItemStack clothesCopy = stack.copyWithCount(1); + ItemStack oldClothes = targetState.replaceEquipment( + BodyRegionV2.TORSO, clothesCopy, false + ); + if (!oldClothes.isEmpty()) { + stack.shrink(1); + targetState.kidnappedDropItem(oldClothes); + + // Sync equipment to all tracking clients + if (target instanceof ServerPlayer serverPlayer) { + SyncManager.syncInventory(serverPlayer); + SyncManager.syncClothesConfig(serverPlayer); + } + + TiedUpMod.LOGGER.info( + "[GenericClothes] {} replaced clothes on {}", + player.getName().getString(), + target.getName().getString() + ); + + return InteractionResult.SUCCESS; + } + } + + return InteractionResult.PASS; + } + + // ========== Dynamic Texture Methods ========== + + /** + * Get the dynamic texture URL from this clothes item. + * + * @param stack The ItemStack to check + * @return The URL string, or null if not set + */ + @Nullable + public String getDynamicTextureUrl(ItemStack stack) { + CompoundTag tag = stack.getTag(); + if (tag != null && tag.contains(NBT_DYNAMIC_TEXTURE)) { + String url = tag.getString(NBT_DYNAMIC_TEXTURE); + return url.isEmpty() ? null : url; + } + return null; + } + + /** + * Set the dynamic texture URL for this clothes item. + * + * @param stack The ItemStack to modify + * @param url The URL to set + */ + public void setDynamicTextureUrl(ItemStack stack, String url) { + if (url != null && !url.isEmpty()) { + stack.getOrCreateTag().putString(NBT_DYNAMIC_TEXTURE, url); + } + } + + /** + * Remove the dynamic texture URL from this clothes item. + * + * @param stack The ItemStack to modify + */ + public void removeDynamicTextureUrl(ItemStack stack) { + CompoundTag tag = stack.getTag(); + if (tag != null) { + tag.remove(NBT_DYNAMIC_TEXTURE); + } + } + + /** + * Check if this clothes item has a dynamic texture URL set. + * + * @param stack The ItemStack to check + * @return true if a URL is set + */ + public boolean hasDynamicTextureUrl(ItemStack stack) { + return getDynamicTextureUrl(stack) != null; + } + + // ========== Full Skin / Small Arms Methods ========== + + /** + * Check if full-skin mode is enabled. + * In full-skin mode, the clothes texture replaces the entire player skin. + * + * @param stack The ItemStack to check + * @return true if full-skin mode is enabled + */ + public boolean isFullSkinEnabled(ItemStack stack) { + CompoundTag tag = stack.getTag(); + return tag != null && tag.getBoolean(NBT_FULL_SKIN); + } + + /** + * Set full-skin mode. + * + * @param stack The ItemStack to modify + * @param enabled true to enable full-skin mode + */ + public void setFullSkinEnabled(ItemStack stack, boolean enabled) { + stack.getOrCreateTag().putBoolean(NBT_FULL_SKIN, enabled); + } + + /** + * Check if small arms (slim model) should be forced. + * + * @param stack The ItemStack to check + * @return true if small arms should be forced + */ + public boolean shouldForceSmallArms(ItemStack stack) { + CompoundTag tag = stack.getTag(); + return tag != null && tag.getBoolean(NBT_SMALL_ARMS); + } + + /** + * Set whether small arms (slim model) should be forced. + * + * @param stack The ItemStack to modify + * @param enabled true to force small arms + */ + public void setForceSmallArms(ItemStack stack, boolean enabled) { + stack.getOrCreateTag().putBoolean(NBT_SMALL_ARMS, enabled); + } + + /** + * Check if keep head mode is enabled. + * When enabled, the wearer's head/hat layers are preserved instead of being + * replaced by the clothes texture. Useful for keeping the original face. + * + * @param stack The ItemStack to check + * @return true if keep head mode is enabled + */ + public boolean isKeepHeadEnabled(ItemStack stack) { + CompoundTag tag = stack.getTag(); + return tag != null && tag.getBoolean(NBT_KEEP_HEAD); + } + + /** + * Set keep head mode. + * When enabled, the wearer's head/hat layers are preserved. + * + * @param stack The ItemStack to modify + * @param enabled true to keep the wearer's head + */ + public void setKeepHeadEnabled(ItemStack stack, boolean enabled) { + stack.getOrCreateTag().putBoolean(NBT_KEEP_HEAD, enabled); + } + + // ========== Layer Visibility Methods ========== + + /** + * Check if a specific body layer is enabled (visible on wearer). + * Defaults to true (visible) if not set. + * + * @param stack The ItemStack to check + * @param layer The layer key (use LAYER_* constants) + * @return true if the layer is visible + */ + public boolean isLayerEnabled(ItemStack stack, String layer) { + CompoundTag tag = stack.getTag(); + if (tag == null || !tag.contains(NBT_LAYER_VISIBILITY)) { + return true; // Default: all layers visible + } + CompoundTag layers = tag.getCompound(NBT_LAYER_VISIBILITY); + // If not specified, default to visible + return !layers.contains(layer) || layers.getBoolean(layer); + } + + /** + * Set the visibility of a specific body layer on the wearer. + * + * @param stack The ItemStack to modify + * @param layer The layer key (use LAYER_* constants) + * @param enabled true to show the layer, false to hide it + */ + public void setLayerEnabled( + ItemStack stack, + String layer, + boolean enabled + ) { + CompoundTag tag = stack.getOrCreateTag(); + CompoundTag layers = tag.contains(NBT_LAYER_VISIBILITY) + ? tag.getCompound(NBT_LAYER_VISIBILITY) + : new CompoundTag(); + layers.putBoolean(layer, enabled); + tag.put(NBT_LAYER_VISIBILITY, layers); + } + + /** + * Get all layer visibility settings as a compound tag. + * + * @param stack The ItemStack to check + * @return The layer visibility compound, or null if not set + */ + @Nullable + public CompoundTag getLayerVisibility(ItemStack stack) { + CompoundTag tag = stack.getTag(); + if (tag != null && tag.contains(NBT_LAYER_VISIBILITY)) { + return tag.getCompound(NBT_LAYER_VISIBILITY); + } + return null; + } + + // ========== IV2BondageItem Implementation ========== + + private static final Set REGIONS = + Collections.unmodifiableSet(EnumSet.of(BodyRegionV2.TORSO)); + + @Override + public Set getOccupiedRegions() { + return REGIONS; + } + + @Override + @Nullable + public ResourceLocation getModelLocation() { + return null; // Clothes use URL-texture rendering, not GLB models + } + + @Override + public int getPosePriority() { + return 0; // Cosmetic item, never forces a pose + } + + @Override + public int getEscapeDifficulty() { + return 0; // Cosmetic, no struggle resistance + } + + @Override + public boolean supportsColor() { + return false; // Color is handled via URL texture, not variant system + } + + @Override + public boolean supportsSlimModel() { + return false; // Slim/wide is handled via NBT smallArms flag, not model variants + } + + @Override + public boolean canEquip(ItemStack stack, LivingEntity entity) { + return true; + } + + @Override + public boolean canUnequip(ItemStack stack, LivingEntity entity) { + return true; + } + + // ========== ILockable Implementation ========== + + @Override + public ItemStack setLocked(ItemStack stack, boolean state) { + stack.getOrCreateTag().putBoolean(NBT_LOCKED, state); + if (!state) { + // When unlocking, clear lock-related data + clearLockResistance(stack); + setJammed(stack, false); + } + return stack; + } + + @Override + public boolean isLocked(ItemStack stack) { + CompoundTag tag = stack.getTag(); + return tag != null && tag.getBoolean(NBT_LOCKED); + } + + @Override + public ItemStack setLockable(ItemStack stack, boolean state) { + stack.getOrCreateTag().putBoolean(NBT_LOCKABLE, state); + return stack; + } + + @Override + public boolean isLockable(ItemStack stack) { + CompoundTag tag = stack.getTag(); + // Default to true if not set + return ( + tag == null || + !tag.contains(NBT_LOCKABLE) || + tag.getBoolean(NBT_LOCKABLE) + ); + } + + @Override + @Nullable + public UUID getLockedByKeyUUID(ItemStack stack) { + CompoundTag tag = stack.getTag(); + if (tag != null && tag.hasUUID(NBT_LOCKED_BY_KEY_UUID)) { + return tag.getUUID(NBT_LOCKED_BY_KEY_UUID); + } + return null; + } + + @Override + public void setLockedByKeyUUID(ItemStack stack, @Nullable UUID keyUUID) { + CompoundTag tag = stack.getOrCreateTag(); + if (keyUUID != null) { + tag.putUUID(NBT_LOCKED_BY_KEY_UUID, keyUUID); + setLocked(stack, true); + initializeLockResistance(stack); + } else { + tag.remove(NBT_LOCKED_BY_KEY_UUID); + setLocked(stack, false); + } + } + + // ========== Tooltip ========== + + @Override + public void appendHoverText( + ItemStack stack, + @Nullable Level level, + List tooltip, + TooltipFlag flag + ) { + super.appendHoverText(stack, level, tooltip, flag); + + // Dynamic texture info + String url = getDynamicTextureUrl(stack); + if (url != null) { + tooltip.add( + Component.translatable( + "item.tiedup.clothes.tooltip.has_url" + ).withStyle(ChatFormatting.GREEN) + ); + if (isFullSkinEnabled(stack)) { + tooltip.add( + Component.translatable( + "item.tiedup.clothes.tooltip.full_skin" + ).withStyle(ChatFormatting.AQUA) + ); + } + if (shouldForceSmallArms(stack)) { + tooltip.add( + Component.translatable( + "item.tiedup.clothes.tooltip.small_arms" + ).withStyle(ChatFormatting.AQUA) + ); + } + } else { + tooltip.add( + Component.translatable( + "item.tiedup.clothes.tooltip.no_url" + ).withStyle(ChatFormatting.GRAY) + ); + } + + // Layer visibility info + CompoundTag layers = getLayerVisibility(stack); + if (layers != null) { + StringBuilder disabled = new StringBuilder(); + if (!isLayerEnabled(stack, LAYER_HEAD)) disabled.append("head "); + if (!isLayerEnabled(stack, LAYER_BODY)) disabled.append("body "); + if (!isLayerEnabled(stack, LAYER_LEFT_ARM)) disabled.append( + "L.arm " + ); + if (!isLayerEnabled(stack, LAYER_RIGHT_ARM)) disabled.append( + "R.arm " + ); + if (!isLayerEnabled(stack, LAYER_LEFT_LEG)) disabled.append( + "L.leg " + ); + if (!isLayerEnabled(stack, LAYER_RIGHT_LEG)) disabled.append( + "R.leg " + ); + + if (!disabled.isEmpty()) { + tooltip.add( + Component.translatable( + "item.tiedup.clothes.tooltip.layers_disabled", + disabled.toString().trim() + ).withStyle(ChatFormatting.YELLOW) + ); + } + } + + // Lock info + if (isLocked(stack)) { + tooltip.add( + Component.translatable("item.tiedup.locked").withStyle( + ChatFormatting.RED + ) + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/labor/LaborTask.java b/src/main/java/com/tiedup/remake/labor/LaborTask.java new file mode 100644 index 0000000..e78d5b4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/labor/LaborTask.java @@ -0,0 +1,474 @@ +package com.tiedup.remake.labor; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraftforge.registries.ForgeRegistries; + +/** + * Unified "Bring X Items" labor task. + * + * DESIGN CHANGE (v2.0): + * All tasks are now simplified to "bring X items" instead of: + * - "Mine X blocks" (event-based tracking) + * - "Kill X mobs" (event-based tracking) + * - "Chop X logs" (event-based tracking) + * - "Collect X items" (inventory-based tracking) + * + * Benefits: + * - Single task class (was 4) + * - Periodic inventory checks (no event dependencies) + * - Works with ANY item source (/give, crafting, trading, mining, looting) + * - Maid collects items and deposits in cell chest + * - Tools are provided and automatically retrieved + */ +public class LaborTask { + + private final UUID id; + private final Item targetItem; // What to bring + private final int quota; // How many + private final int value; // Emerald reward + private final ItemStack toolGiven; // Tool provided (can be null) + private int progress; // Current count (based on inventory) + private UUID campId; + + // ==================== CONSTRUCTORS ==================== + + public LaborTask( + Item targetItem, + int quota, + int value, + @Nullable ItemStack tool + ) { + this.id = UUID.randomUUID(); + this.targetItem = targetItem; + this.quota = quota; + this.value = value; + this.toolGiven = tool != null ? tagAsLaborTool(tool.copy()) : null; + this.progress = 0; + } + + // ==================== GETTERS ==================== + + public UUID getId() { + return id; + } + + public Item getTargetItem() { + return targetItem; + } + + public int getQuota() { + return quota; + } + + public int getProgress() { + return progress; + } + + public int getValue() { + return value; + } + + public UUID getCampId() { + return campId; + } + + public void setCampId(UUID campId) { + this.campId = campId; + } + + @Nullable + public ItemStack getToolGiven() { + return toolGiven; + } + + /** + * Get the tool type (e.g., Items.IRON_AXE). + * Used for retrieving stored tools from cell chest. + */ + @Nullable + public Item getToolType() { + return toolGiven != null ? toolGiven.getItem() : null; + } + + /** + * Check if this is a combat task (requires killing mobs). + * Combat tasks use a sword and depend on mob spawns (time-of-day dependent). + */ + public boolean isCombatTask() { + return toolGiven != null && toolGiven.getItem() == Items.IRON_SWORD; + } + + // ==================== PROGRESS ==================== + + /** + * Check if the task is complete. + */ + public boolean isComplete() { + return progress >= quota; + } + + /** + * Get completion percentage (0-100). + */ + public int getProgressPercent() { + if (quota <= 0) return 100; + return Math.min(100, (progress * 100) / quota); + } + + /** + * Check progress by counting matching items in worker's inventory. + * Called periodically (every ~5 seconds) by MaidManagePrisonersGoal. + * + * @param worker The player doing the task + * @param level The server level (unused but kept for API compatibility) + * @return true if progress was made since last check + */ + public boolean checkProgress(ServerPlayer worker, ServerLevel level) { + int count = 0; + var inventory = worker.getInventory(); + for (int i = 0; i < inventory.getContainerSize(); i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty() && isAcceptableItem(stack.getItem())) { + count += stack.getCount(); + } + } + + int oldProgress = progress; + progress = Math.min(count, quota); + return progress > oldProgress; + } + + /** + * Check if an item is acceptable for this task. + * For logs, accept any type of log (oak, birch, spruce, etc.) + */ + private boolean isAcceptableItem(Item item) { + // Direct match + if (item == targetItem) { + return true; + } + + // If target is any type of log, accept all logs + if (isLogItem(targetItem) && isLogItem(item)) { + return true; + } + + // If target is any type of planks, accept all planks + if (isPlankItem(targetItem) && isPlankItem(item)) { + return true; + } + + return false; + } + + /** + * Check if an item is a log. + */ + private boolean isLogItem(Item item) { + return ( + item == Items.OAK_LOG || + item == Items.BIRCH_LOG || + item == Items.SPRUCE_LOG || + item == Items.DARK_OAK_LOG || + item == Items.JUNGLE_LOG || + item == Items.ACACIA_LOG || + item == Items.MANGROVE_LOG || + item == Items.CHERRY_LOG || + item == Items.CRIMSON_STEM || + item == Items.WARPED_STEM + ); + } + + /** + * Check if an item is a plank. + */ + private boolean isPlankItem(Item item) { + return ( + item == Items.OAK_PLANKS || + item == Items.BIRCH_PLANKS || + item == Items.SPRUCE_PLANKS || + item == Items.DARK_OAK_PLANKS || + item == Items.JUNGLE_PLANKS || + item == Items.ACACIA_PLANKS || + item == Items.MANGROVE_PLANKS || + item == Items.CHERRY_PLANKS || + item == Items.CRIMSON_PLANKS || + item == Items.WARPED_PLANKS || + item == Items.BAMBOO_PLANKS + ); + } + + // ==================== EQUIPMENT ==================== + + /** + * Give equipment to the worker for this task. + * Equipment is tagged as LaborTool to prevent dropping. + * + * @param worker The player to give equipment to + */ + public void giveEquipment(ServerPlayer worker) { + // Give tool if one is specified + if (toolGiven != null) { + ItemStack tool = toolGiven.copy(); + worker.getInventory().add(tool); + } + + // Always give food for sustenance + ItemStack bread = new ItemStack(Items.BREAD, 8); + tagAsLaborTool(bread); + worker.getInventory().add(bread); + + // Give torches if tool is a pickaxe (for mining) + if ( + toolGiven != null && + (toolGiven.getItem() == Items.IRON_PICKAXE || + toolGiven.getItem() == Items.STONE_PICKAXE) + ) { + ItemStack torches = new ItemStack(Items.TORCH, 32); + tagAsLaborTool(torches); + worker.getInventory().add(torches); + } + } + + /** + * Give equipment using a retrieved tool from cell chest. + * Used to reuse tools instead of creating new ones. + * + * @param worker The player to give equipment to + * @param retrievedTool The tool retrieved from cell chest + */ + public void giveRetrievedEquipment( + ServerPlayer worker, + ItemStack retrievedTool + ) { + // Ensure the retrieved tool has LaborTool tag + tagAsLaborTool(retrievedTool); + worker.getInventory().add(retrievedTool); + + // Give consumables (food, torches) + ItemStack bread = new ItemStack(Items.BREAD, 8); + tagAsLaborTool(bread); + worker.getInventory().add(bread); + + if ( + retrievedTool.getItem() == Items.IRON_PICKAXE || + retrievedTool.getItem() == Items.STONE_PICKAXE + ) { + ItemStack torches = new ItemStack(Items.TORCH, 32); + tagAsLaborTool(torches); + worker.getInventory().add(torches); + } + } + + /** + * Reclaim equipment from the worker after task completion. + * Removes any items tagged as LaborTool belonging to this camp. + * + * @param worker The player to reclaim equipment from + * @return List of reclaimed tool items (for storage in cell chest) + */ + public List reclaimEquipment(ServerPlayer worker) { + List reclaimedTools = new ArrayList<>(); + var inventory = worker.getInventory(); + + for (int i = 0; i < inventory.getContainerSize(); i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty() && isLaborTool(stack)) { + // Only store tools (not consumables like bread/torches) + if (isReusableTool(stack)) { + reclaimedTools.add(stack.copy()); + } + inventory.setItem(i, ItemStack.EMPTY); + } + } + + return reclaimedTools; + } + + /** + * Collect task items from worker's inventory. + * Called by maid when task is complete. + * + * @param worker The player to collect items from + * @return List of collected item stacks + */ + public List collectItems(ServerPlayer worker) { + List collected = new ArrayList<>(); + var inventory = worker.getInventory(); + + int remaining = quota; + for ( + int i = 0; + i < inventory.getContainerSize() && remaining > 0; + i++ + ) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty() && isAcceptableItem(stack.getItem())) { + int toTake = Math.min(stack.getCount(), remaining); + ItemStack taken = stack.split(toTake); + collected.add(taken); + remaining -= toTake; + } + } + + return collected; + } + + // ==================== HELPER METHODS ==================== + + /** + * Check if an item is a labor tool from this camp. + */ + private boolean isLaborTool(ItemStack stack) { + if (!stack.hasTag()) return false; + CompoundTag tag = stack.getTag(); + if (tag == null) return false; + if (!tag.getBoolean("LaborTool")) return false; + + // Check camp ID if we have one + if (campId != null && tag.hasUUID("CampId")) { + return campId.equals(tag.getUUID("CampId")); + } + + return true; + } + + /** + * Check if a tool is reusable (should be stored, not discarded). + */ + private boolean isReusableTool(ItemStack stack) { + Item item = stack.getItem(); + return ( + item == Items.IRON_PICKAXE || + item == Items.STONE_PICKAXE || + item == Items.IRON_AXE || + item == Items.IRON_SWORD || + item == Items.IRON_SHOVEL || + item == Items.IRON_HOE + ); + } + + /** + * Tag an item as a labor tool belonging to this camp. + * BUG FIX: Made tools unbreakable to prevent soft-lock when tools break. + * Problem: Tools breaking left prisoners stuck and punished in loop with no recourse. + */ + private ItemStack tagAsLaborTool(ItemStack stack) { + CompoundTag tag = stack.getOrCreateTag(); + tag.putBoolean("LaborTool", true); + tag.putBoolean("Unbreakable", true); // Prevent tool breakage soft-lock + if (campId != null) { + tag.putUUID("CampId", campId); + } + return stack; + } + + // ==================== DESCRIPTION ==================== + + /** + * Get a human-readable description of this task. + */ + public String getDescription() { + return String.format("Bring %d %s", quota, getTargetName()); + } + + /** + * Get the target item name (formatted). + * For logs, return generic "Logs" instead of specific type. + */ + public String getTargetName() { + if (isLogItem(targetItem)) { + return "Logs"; + } + if (isPlankItem(targetItem)) { + return "Planks"; + } + return new ItemStack(targetItem).getHoverName().getString(); + } + + // ==================== SERIALIZATION ==================== + + /** + * Save task data to NBT. + */ + public CompoundTag save() { + CompoundTag tag = new CompoundTag(); + tag.putUUID("id", id); + + // Save target item + ResourceLocation itemKey = ForgeRegistries.ITEMS.getKey(targetItem); + if (itemKey != null) { + tag.putString("targetItem", itemKey.toString()); + } + + tag.putInt("quota", quota); + tag.putInt("progress", progress); + tag.putInt("value", value); + + // Save tool if one was given + if (toolGiven != null) { + CompoundTag toolTag = new CompoundTag(); + toolGiven.save(toolTag); + tag.put("toolGiven", toolTag); + } + + if (campId != null) { + tag.putUUID("campId", campId); + } + + return tag; + } + + /** + * Load task from NBT. Returns null if invalid. + */ + @Nullable + public static LaborTask load(CompoundTag tag) { + if ( + !tag.contains("targetItem") || + !tag.contains("quota") || + !tag.contains("value") + ) { + return null; + } + + // Load target item + String itemKeyStr = tag.getString("targetItem"); + ResourceLocation itemKey = ResourceLocation.tryParse(itemKeyStr); + if (itemKey == null) return null; + + Item item = ForgeRegistries.ITEMS.getValue(itemKey); + if (item == null || item == Items.AIR) return null; + + // Load tool (if present) + ItemStack tool = null; + if (tag.contains("toolGiven")) { + tool = ItemStack.of(tag.getCompound("toolGiven")); + } + + // Create task + int quota = tag.getInt("quota"); + int value = tag.getInt("value"); + LaborTask task = new LaborTask(item, quota, value, tool); + + // Load progress and camp ID + if (tag.contains("progress")) { + task.progress = tag.getInt("progress"); + } + if (tag.contains("campId")) { + task.campId = tag.getUUID("campId"); + } + + return task; + } +} diff --git a/src/main/java/com/tiedup/remake/labor/LaborTaskGenerator.java b/src/main/java/com/tiedup/remake/labor/LaborTaskGenerator.java new file mode 100644 index 0000000..7d8f16c --- /dev/null +++ b/src/main/java/com/tiedup/remake/labor/LaborTaskGenerator.java @@ -0,0 +1,255 @@ +package com.tiedup.remake.labor; + +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.Nullable; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.RandomSource; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +/** + * Generator for random labor tasks. + * + * DESIGN CHANGE (v2.0): + * All tasks simplified to "Bring X Items" with automatic tool assignment. + * + * Value Guidelines: + * - Standard ransom: 100-200 emeralds + * - Target: ~6 tasks average to pay off + * - Per task value: 18-35 emeralds depending on difficulty + */ +public class LaborTaskGenerator { + + private static final RandomSource RANDOM = RandomSource.create(); + + // ==================== TOOL MAPPING ==================== + + /** + * Maps items to the tools needed to collect them. + * If an item is not in this map, no tool is given. + */ + private static final Map TOOL_MAP = Map.ofEntries( + // Logs → Axe + Map.entry(Items.OAK_LOG, new ItemStack(Items.IRON_AXE)), + Map.entry(Items.BIRCH_LOG, new ItemStack(Items.IRON_AXE)), + Map.entry(Items.SPRUCE_LOG, new ItemStack(Items.IRON_AXE)), + Map.entry(Items.DARK_OAK_LOG, new ItemStack(Items.IRON_AXE)), + Map.entry(Items.JUNGLE_LOG, new ItemStack(Items.IRON_AXE)), + Map.entry(Items.ACACIA_LOG, new ItemStack(Items.IRON_AXE)), + Map.entry(Items.MANGROVE_LOG, new ItemStack(Items.IRON_AXE)), + Map.entry(Items.CHERRY_LOG, new ItemStack(Items.IRON_AXE)), + // Gathering → Shovel + Map.entry(Items.SAND, new ItemStack(Items.IRON_SHOVEL)), + Map.entry(Items.GRAVEL, new ItemStack(Items.IRON_SHOVEL)), + Map.entry(Items.DIRT, new ItemStack(Items.IRON_SHOVEL)), + Map.entry(Items.CLAY_BALL, new ItemStack(Items.IRON_SHOVEL)), + // Basic mining → Pickaxe + Map.entry(Items.COBBLESTONE, new ItemStack(Items.STONE_PICKAXE)), + // Ores → Pickaxe + Map.entry(Items.IRON_ORE, new ItemStack(Items.IRON_PICKAXE)), + Map.entry(Items.DEEPSLATE_IRON_ORE, new ItemStack(Items.IRON_PICKAXE)), + Map.entry(Items.RAW_IRON, new ItemStack(Items.IRON_PICKAXE)), + Map.entry(Items.COAL_ORE, new ItemStack(Items.IRON_PICKAXE)), + Map.entry(Items.DEEPSLATE_COAL_ORE, new ItemStack(Items.IRON_PICKAXE)), + Map.entry(Items.COAL, new ItemStack(Items.IRON_PICKAXE)), + Map.entry(Items.DIAMOND_ORE, new ItemStack(Items.IRON_PICKAXE)), + Map.entry( + Items.DEEPSLATE_DIAMOND_ORE, + new ItemStack(Items.IRON_PICKAXE) + ), + Map.entry(Items.DIAMOND, new ItemStack(Items.IRON_PICKAXE)), + Map.entry(Items.GOLD_ORE, new ItemStack(Items.IRON_PICKAXE)), + Map.entry(Items.DEEPSLATE_GOLD_ORE, new ItemStack(Items.IRON_PICKAXE)), + Map.entry(Items.RAW_GOLD, new ItemStack(Items.IRON_PICKAXE)), + Map.entry(Items.COPPER_ORE, new ItemStack(Items.IRON_PICKAXE)), + Map.entry( + Items.DEEPSLATE_COPPER_ORE, + new ItemStack(Items.IRON_PICKAXE) + ), + Map.entry(Items.RAW_COPPER, new ItemStack(Items.IRON_PICKAXE)), + Map.entry(Items.LAPIS_ORE, new ItemStack(Items.IRON_PICKAXE)), + Map.entry(Items.DEEPSLATE_LAPIS_ORE, new ItemStack(Items.IRON_PICKAXE)), + Map.entry(Items.LAPIS_LAZULI, new ItemStack(Items.IRON_PICKAXE)), + Map.entry(Items.REDSTONE_ORE, new ItemStack(Items.IRON_PICKAXE)), + Map.entry( + Items.DEEPSLATE_REDSTONE_ORE, + new ItemStack(Items.IRON_PICKAXE) + ), + Map.entry(Items.REDSTONE, new ItemStack(Items.IRON_PICKAXE)), + Map.entry(Items.EMERALD_ORE, new ItemStack(Items.IRON_PICKAXE)), + Map.entry( + Items.DEEPSLATE_EMERALD_ORE, + new ItemStack(Items.IRON_PICKAXE) + ), + Map.entry(Items.EMERALD, new ItemStack(Items.IRON_PICKAXE)), + // Combat drops → Sword + Map.entry(Items.ROTTEN_FLESH, new ItemStack(Items.IRON_SWORD)), + Map.entry(Items.BONE, new ItemStack(Items.IRON_SWORD)), + Map.entry(Items.STRING, new ItemStack(Items.IRON_SWORD)), + Map.entry(Items.GUNPOWDER, new ItemStack(Items.IRON_SWORD)), + Map.entry(Items.SPIDER_EYE, new ItemStack(Items.IRON_SWORD)), + Map.entry(Items.ARROW, new ItemStack(Items.IRON_SWORD)) + + // Items not in this map (e.g., SWEET_BERRIES, KELP, STICK, TORCH, planks) get no tool + ); + + // ==================== TASK POOL ==================== + + /** + * Pool of all available labor tasks. + * Each entry specifies: item to bring, quota, emerald value. + */ + private static final List TASK_POOL = List.of( + // Logs (axe provided) — generic "Logs", accepts any type + new TaskSpec(Items.OAK_LOG, 32, 18), + new TaskSpec(Items.OAK_LOG, 48, 25), + new TaskSpec(Items.OAK_LOG, 64, 32), + // Gathering (shovel provided) + new TaskSpec(Items.SAND, 32, 10), + new TaskSpec(Items.GRAVEL, 32, 10), + new TaskSpec(Items.DIRT, 64, 8), + new TaskSpec(Items.CLAY_BALL, 16, 14), + // Basic mining (stone pickaxe provided) + new TaskSpec(Items.COBBLESTONE, 64, 10), + new TaskSpec(Items.COBBLESTONE, 32, 8), + // Easy picks (no tool needed) + new TaskSpec(Items.SWEET_BERRIES, 16, 12), + new TaskSpec(Items.KELP, 32, 10), + // Crafted (no tool needed) + new TaskSpec(Items.OAK_PLANKS, 64, 12), + new TaskSpec(Items.STICK, 64, 10), + new TaskSpec(Items.TORCH, 32, 15), + // Mining - Coal (common, pickaxe provided) + new TaskSpec(Items.COAL, 24, 18), + new TaskSpec(Items.COAL, 32, 22), + // Mining - Iron (pickaxe provided) + new TaskSpec(Items.RAW_IRON, 16, 25), + new TaskSpec(Items.RAW_IRON, 24, 32), + // Mining - Copper (pickaxe provided) + new TaskSpec(Items.RAW_COPPER, 20, 20), + // Mining - Gold (pickaxe provided) + new TaskSpec(Items.RAW_GOLD, 8, 30), + // Mining - Lapis (pickaxe provided) + new TaskSpec(Items.LAPIS_LAZULI, 10, 28), + // Mining - Redstone (pickaxe provided) + new TaskSpec(Items.REDSTONE, 12, 22), + // Mining - Diamond (pickaxe provided, rare, high value) + new TaskSpec(Items.DIAMOND, 5, 35), + // Mining - Emerald (pickaxe provided, very rare) + new TaskSpec(Items.EMERALD, 3, 35), + // Combat drops (sword provided) + new TaskSpec(Items.ROTTEN_FLESH, 24, 18), + new TaskSpec(Items.BONE, 16, 20), + new TaskSpec(Items.STRING, 32, 20), + new TaskSpec(Items.GUNPOWDER, 12, 25), + new TaskSpec(Items.SPIDER_EYE, 8, 20), + new TaskSpec(Items.ARROW, 16, 18) + ); + + // ==================== GENERATION ==================== + + /** Combat task items (require mob kills, only assigned at night). */ + private static final List COMBAT_ITEMS = List.of( + Items.ROTTEN_FLESH, + Items.BONE, + Items.STRING, + Items.GUNPOWDER, + Items.SPIDER_EYE, + Items.ARROW + ); + + /** + * Generate a random labor task, filtering combat tasks to nighttime only. + * + * @param level The server level (used to check time of day), or null to skip filtering + */ + public static LaborTask generateRandom(@Nullable ServerLevel level) { + List pool; + if (level != null && isDay(level)) { + // Daytime: exclude combat tasks (mobs don't spawn) + pool = TASK_POOL.stream() + .filter(spec -> !COMBAT_ITEMS.contains(spec.item())) + .toList(); + } else { + pool = TASK_POOL; + } + TaskSpec spec = pickRandom(pool); + ItemStack tool = TOOL_MAP.get(spec.item()); + return new LaborTask(spec.item(), spec.quota(), spec.value(), tool); + } + + /** + * Check if it's daytime in the level. + * Minecraft day cycle: 0-12541 = day, 12542-23999 = night/dusk/dawn. + * We use 13000-23000 as "night enough for mob spawns". + */ + private static boolean isDay(ServerLevel level) { + long timeOfDay = level.getDayTime() % 24000; + return timeOfDay < 13000; + } + + /** + * Generate a task requiring a specific item type. + * Used for testing or custom scenarios. + */ + public static LaborTask generateFor(Item item, int quota, int value) { + ItemStack tool = TOOL_MAP.get(item); + return new LaborTask(item, quota, value, tool); + } + + // ==================== UTILITY ==================== + + private static T pickRandom(List list) { + return list.get(RANDOM.nextInt(list.size())); + } + + /** + * Task specification record. + */ + private record TaskSpec(Item item, int quota, int value) {} + + // ==================== RANSOM CALCULATION ==================== + + /** + * Calculate ransom amount based on player equipment/level. + * + * @param hasIronArmor Player has iron armor equipped + * @param hasDiamondArmor Player has diamond/netherite armor equipped + * @param hasValuables Player has emeralds/diamonds in inventory + * @return Ransom amount in emeralds + */ + public static int calculateRansomAmount( + boolean hasIronArmor, + boolean hasDiamondArmor, + boolean hasValuables + ) { + int base = 100; + + if (hasDiamondArmor) { + base = 200; + } else if (hasIronArmor) { + base = 150; + } + + if (hasValuables) { + base += 25; + } + + // Add some randomness + int variance = RANDOM.nextInt(21) - 10; // -10 to +10 + return base + variance; + } + + /** + * Estimate number of tasks to pay off a ransom. + * + * @param ransomAmount The ransom amount + * @return Estimated number of tasks + */ + public static int estimateTasksToPayoff(int ransomAmount) { + // Average task value is about 25 emeralds + return (int) Math.ceil(ransomAmount / 25.0); + } +} diff --git a/src/main/java/com/tiedup/remake/minigame/ContinuousStruggleMiniGameState.java b/src/main/java/com/tiedup/remake/minigame/ContinuousStruggleMiniGameState.java new file mode 100644 index 0000000..f248347 --- /dev/null +++ b/src/main/java/com/tiedup/remake/minigame/ContinuousStruggleMiniGameState.java @@ -0,0 +1,548 @@ +package com.tiedup.remake.minigame; + +import com.tiedup.remake.v2.BodyRegionV2; +import java.util.Random; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; + +/** + * Server-side state for the continuous Struggle mini-game session. + * + * New system: Player holds a direction key to continuously reduce resistance. + * - Progression: 1 resistance per second when holding correct direction + * - Direction changes randomly every 3-5 seconds + * - Shock collar check: 10% chance every 5 seconds (if locked collar equipped) + * - No cooldown, no penalty for wrong key (just no progress) + */ +public class ContinuousStruggleMiniGameState { + + /** + * Possible directions for struggling. + */ + public enum Direction { + UP(0), // W / Forward + LEFT(1), // A / Strafe Left + DOWN(2), // S / Back + RIGHT(3); // D / Strafe Right + + private final int index; + + Direction(int index) { + this.index = index; + } + + public int getIndex() { + return index; + } + + public static Direction fromIndex(int index) { + return switch (index) { + case 0 -> UP; + case 1 -> LEFT; + case 2 -> DOWN; + case 3 -> RIGHT; + default -> UP; + }; + } + + public static Direction random(Random random) { + return fromIndex(random.nextInt(4)); + } + } + + /** + * State of the continuous struggle session. + */ + public enum State { + /** Actively struggling */ + ACTIVE, + /** Temporarily interrupted by shock collar */ + SHOCKED, + /** Successfully escaped (resistance reached 0) */ + ESCAPED, + /** Cancelled by player or damage */ + CANCELLED, + } + + /** + * Type of state update sent to client. + */ + public enum UpdateType { + START, + DIRECTION_CHANGE, + RESISTANCE_UPDATE, + SHOCK, + ESCAPE, + END, + } + + // ==================== CONSTANTS ==================== + + /** Minimum interval for direction change (3 seconds = 60 ticks) */ + private static final int DIRECTION_CHANGE_MIN_TICKS = 60; + + /** Maximum interval for direction change (5 seconds = 100 ticks) */ + private static final int DIRECTION_CHANGE_MAX_TICKS = 100; + + /** Interval for shock collar check (5 seconds = 100 ticks) */ + private static final int SHOCK_CHECK_INTERVAL_TICKS = 100; + + /** Shock probability (10% = 10 out of 100) */ + private static final int SHOCK_PROBABILITY = 10; + + /** Default ticks per 1 resistance point (20 ticks = 1 second per resistance) */ + private static final int DEFAULT_TICKS_PER_RESISTANCE = 20; + + /** Shock stun duration in ticks (1 second) */ + private static final int SHOCK_STUN_TICKS = 20; + + /** Kidnapper notification interval (2 seconds = 40 ticks) */ + private static final int KIDNAPPER_NOTIFY_INTERVAL_TICKS = 40; + + /** Struggle sound interval (1.5 seconds = 30 ticks) */ + private static final int STRUGGLE_SOUND_INTERVAL_TICKS = 30; + + /** Session timeout in milliseconds (10 minutes) */ + private static final long SESSION_TIMEOUT_MS = 10L * 60L * 1000L; + + // ==================== FIELDS ==================== + + private final UUID sessionId; + private final UUID playerId; + private final long createdAt; + + /** Current required direction (0-3 for W/A/S/D) */ + private Direction currentDirection; + + /** Current resistance remaining */ + private int currentResistance; + + /** Maximum resistance (initial value) for display percentage */ + private final int maxResistance; + + /** Accumulated progress toward next resistance point (0.0 to 1.0) */ + private float accumulatedProgress; + + /** Tick when direction last changed */ + private long lastDirectionChangeTick; + + /** Tick when shock was last checked */ + private long lastShockCheckTick; + + /** Tick when kidnappers were last notified */ + private long lastKidnapperNotifyTick; + + /** Tick when struggle sound was last played */ + private long lastStruggleSoundTick; + + /** Ticks until direction change */ + private int ticksUntilDirectionChange; + + /** Current session state */ + private State state; + + /** Ticks remaining in shock stun */ + private int shockStunTicksRemaining; + + /** Whether player is currently holding the correct key */ + private boolean isHoldingCorrectKey; + + /** Direction player is currently holding (-1 if none) */ + private int heldDirection; + + /** Whether the bind is locked (affects display only, locks add +250 resistance) */ + private boolean isLocked; + + /** Target accessory slot (null = bind, otherwise = accessory) */ + private Integer targetSlot; + + /** Target V2 body region (null = V1 session, non-null = V2 session) */ + @Nullable + private BodyRegionV2 targetRegion; + + /** Target furniture entity ID (non-zero = furniture struggle session) */ + private int furnitureEntityId; + + /** Target seat ID for furniture struggles (null = not a furniture session) */ + @Nullable + private String furnitureSeatId; + + /** Resistance reduction per tick (computed from ticksPerResistance) */ + private final float resistancePerTick; + + private final Random random = new Random(); + + // ==================== CONSTRUCTOR ==================== + + public ContinuousStruggleMiniGameState( + UUID playerId, + int targetResistance, + boolean isLocked + ) { + this( + playerId, + targetResistance, + isLocked, + null, + DEFAULT_TICKS_PER_RESISTANCE + ); + } + + public ContinuousStruggleMiniGameState( + UUID playerId, + int targetResistance, + boolean isLocked, + Integer targetSlot + ) { + this( + playerId, + targetResistance, + isLocked, + targetSlot, + DEFAULT_TICKS_PER_RESISTANCE + ); + } + + /** + * Constructor with target slot and configurable rate. + * + * @param playerId Player UUID + * @param targetResistance Current resistance + * @param isLocked Whether the target is locked + * @param targetSlot Target accessory slot (null = bind) + * @param ticksPerResistance Ticks per 1 resistance point (from game rule) + */ + public ContinuousStruggleMiniGameState( + UUID playerId, + int targetResistance, + boolean isLocked, + Integer targetSlot, + int ticksPerResistance + ) { + this.sessionId = UUID.randomUUID(); + this.playerId = playerId; + this.createdAt = System.currentTimeMillis(); + this.resistancePerTick = 1.0f / Math.max(1, ticksPerResistance); + + this.currentResistance = targetResistance; + this.maxResistance = targetResistance; + this.isLocked = isLocked; + this.targetSlot = targetSlot; + + // Initialize direction + this.currentDirection = Direction.random(random); + this.accumulatedProgress = 0.0f; + + // Initialize timers (will be set properly on first tick) + this.lastDirectionChangeTick = 0; + this.lastShockCheckTick = 0; + this.lastKidnapperNotifyTick = 0; + this.ticksUntilDirectionChange = randomDirectionChangeInterval(); + + // Initial state + this.state = State.ACTIVE; + this.shockStunTicksRemaining = 0; + this.isHoldingCorrectKey = false; + this.heldDirection = -1; + } + + // ==================== GETTERS ==================== + + public UUID getSessionId() { + return sessionId; + } + + public UUID getPlayerId() { + return playerId; + } + + public Direction getCurrentDirection() { + return currentDirection; + } + + public int getCurrentDirectionIndex() { + return currentDirection.getIndex(); + } + + public int getCurrentResistance() { + return currentResistance; + } + + public int getMaxResistance() { + return maxResistance; + } + + public float getProgressPercentage() { + if (maxResistance == 0) return 0.0f; + return 1.0f - ((float) currentResistance / (float) maxResistance); + } + + public State getState() { + return state; + } + + public boolean isLocked() { + return isLocked; + } + + public Integer getTargetSlot() { + return targetSlot; + } + + public boolean isAccessoryStruggle() { + return targetSlot != null; + } + + /** Get the V2 target region (null for V1 sessions). */ + @Nullable + public BodyRegionV2 getTargetRegion() { + return targetRegion; + } + + /** Set the V2 target region. */ + public void setTargetRegion(@Nullable BodyRegionV2 region) { + this.targetRegion = region; + } + + /** Whether this is a V2 region-based struggle session. */ + public boolean isV2Struggle() { + return targetRegion != null; + } + + /** Get the furniture entity ID (0 = not a furniture session). */ + public int getFurnitureEntityId() { + return furnitureEntityId; + } + + /** Get the furniture seat ID (null = not a furniture session). */ + @Nullable + public String getFurnitureSeatId() { + return furnitureSeatId; + } + + /** Set furniture struggle context. */ + public void setFurnitureContext(int entityId, String seatId) { + this.furnitureEntityId = entityId; + this.furnitureSeatId = seatId; + } + + /** Whether this is a furniture escape struggle session. */ + public boolean isFurnitureStruggle() { + return furnitureEntityId != 0 && furnitureSeatId != null; + } + + public boolean isActive() { + return state == State.ACTIVE; + } + + public boolean isShocked() { + return state == State.SHOCKED; + } + + public boolean isComplete() { + return state == State.ESCAPED || state == State.CANCELLED; + } + + public boolean isExpired() { + return System.currentTimeMillis() - createdAt > SESSION_TIMEOUT_MS; + } + + public boolean isHoldingCorrectKey() { + return isHoldingCorrectKey; + } + + public int getHeldDirection() { + return heldDirection; + } + + // ==================== STATE UPDATES ==================== + + /** + * Update the held direction from client input. + * + * @param direction Direction index (-1 if not holding any key) + * @param isHolding True if player is actively holding the key + */ + public void updateHeldDirection(int direction, boolean isHolding) { + this.heldDirection = direction; + this.isHoldingCorrectKey = + isHolding && (direction == currentDirection.getIndex()); + } + + /** + * Process a server tick. + * + * @param currentTick Current game tick + * @return True if state changed significantly (needs client update) + */ + public TickResult tick(long currentTick) { + if (isComplete()) { + return TickResult.NO_CHANGE; + } + + // Handle shock stun + if (state == State.SHOCKED) { + shockStunTicksRemaining--; + if (shockStunTicksRemaining <= 0) { + state = State.ACTIVE; + return TickResult.SHOCK_END; + } + return TickResult.NO_CHANGE; + } + + boolean needsUpdate = false; + TickResult result = TickResult.NO_CHANGE; + + // Direction change check + ticksUntilDirectionChange--; + if (ticksUntilDirectionChange <= 0) { + changeDirection(currentTick); + result = TickResult.DIRECTION_CHANGE; + } + + // Progress resistance if holding correct key + if (isHoldingCorrectKey) { + accumulatedProgress += resistancePerTick; + + if (accumulatedProgress >= 1.0f) { + int resistanceReduced = (int) accumulatedProgress; + accumulatedProgress -= resistanceReduced; + currentResistance = Math.max( + 0, + currentResistance - resistanceReduced + ); + + if (result == TickResult.NO_CHANGE) { + result = TickResult.RESISTANCE_UPDATE; + } + + // Check for escape + if (currentResistance <= 0) { + state = State.ESCAPED; + return TickResult.ESCAPED; + } + } + } + + return result; + } + + /** + * Result of a tick update. + */ + public enum TickResult { + NO_CHANGE, + DIRECTION_CHANGE, + RESISTANCE_UPDATE, + SHOCK_START, + SHOCK_END, + ESCAPED, + KIDNAPPER_NOTIFY, + } + + /** + * Check if shock collar should trigger. + * + * @param currentTick Current game tick + * @return True if shock should be triggered + */ + public boolean shouldTriggerShock(long currentTick) { + if (state != State.ACTIVE) return false; + if ( + currentTick - lastShockCheckTick < SHOCK_CHECK_INTERVAL_TICKS + ) return false; + + lastShockCheckTick = currentTick; + return random.nextInt(100) < SHOCK_PROBABILITY; + } + + /** + * Trigger a shock event. + */ + public void triggerShock() { + if (state != State.ACTIVE) return; + state = State.SHOCKED; + shockStunTicksRemaining = SHOCK_STUN_TICKS; + isHoldingCorrectKey = false; + } + + /** + * Check if kidnappers should be notified. + * + * @param currentTick Current game tick + * @return True if kidnappers should be notified + */ + public boolean shouldNotifyKidnappers(long currentTick) { + if (state != State.ACTIVE || !isHoldingCorrectKey) return false; + if ( + currentTick - lastKidnapperNotifyTick < + KIDNAPPER_NOTIFY_INTERVAL_TICKS + ) return false; + + lastKidnapperNotifyTick = currentTick; + return true; + } + + /** + * Check if struggle sound should play. + * + * @param currentTick Current game tick + * @return True if struggle sound should be played + */ + public boolean shouldPlayStruggleSound(long currentTick) { + if (state != State.ACTIVE || !isHoldingCorrectKey) return false; + if ( + currentTick - lastStruggleSoundTick < STRUGGLE_SOUND_INTERVAL_TICKS + ) return false; + + lastStruggleSoundTick = currentTick; + return true; + } + + /** + * Cancel the session. + */ + public void cancel() { + state = State.CANCELLED; + } + + // ==================== PRIVATE HELPERS ==================== + + private void changeDirection(long currentTick) { + Direction newDirection; + do { + newDirection = Direction.random(random); + } while (newDirection == currentDirection); + + currentDirection = newDirection; + lastDirectionChangeTick = currentTick; + ticksUntilDirectionChange = randomDirectionChangeInterval(); + + // LOW FIX: Always reset held key status to prevent lucky guess exploit + // Player must release and re-press the key to react to direction change + // even if they were already holding the new correct key + isHoldingCorrectKey = false; + } + + private int randomDirectionChangeInterval() { + return ( + DIRECTION_CHANGE_MIN_TICKS + + random.nextInt( + DIRECTION_CHANGE_MAX_TICKS - DIRECTION_CHANGE_MIN_TICKS + 1 + ) + ); + } + + @Override + public String toString() { + return String.format( + "ContinuousStruggleMiniGameState{session=%s, player=%s, dir=%s, resistance=%d/%d, state=%s}", + sessionId.toString().substring(0, 8), + playerId.toString().substring(0, 8), + currentDirection, + currentResistance, + maxResistance, + state + ); + } +} diff --git a/src/main/java/com/tiedup/remake/minigame/GuardNotificationHelper.java b/src/main/java/com/tiedup/remake/minigame/GuardNotificationHelper.java new file mode 100644 index 0000000..dae66b7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/minigame/GuardNotificationHelper.java @@ -0,0 +1,98 @@ +package com.tiedup.remake.minigame; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.EntityMaid; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; + +/** + * Helper for notifying nearby guards (Kidnappers, Maids, Traders) about + * escape-related noise (struggle, lockpick, knife cutting). + * + *

Extracted from {@link MiniGameSessionManager} (M15 split) so that + * any system can trigger guard alerts without depending on a session manager. + */ +public final class GuardNotificationHelper { + + /** + * Radius in blocks for struggle noise to alert nearby NPCs. + * 32 blocks is the standard hearing range for mobs in Minecraft. + * NPCs within this range will be notified of struggle attempts. + */ + private static final double STRUGGLE_NOISE_RADIUS = 32.0; + + private GuardNotificationHelper() {} + + /** + * Notify nearby guards about escape noise (struggle, lockpick, knife cutting). + * Public entry point for external systems (e.g. GenericKnife). + * + * @param player The player who is making noise + */ + public static void notifyNearbyGuards(ServerPlayer player) { + notifyNearbyKidnappersOfStruggle(player); + } + + /** + * Notify nearby guards (Kidnappers, Maids, Traders) when a player starts struggling. + * This creates "noise" that guards can detect. + * + * Guards will only react if they have LINE OF SIGHT to the struggling prisoner. + * If they see the prisoner, they will: + * 1. Shock the prisoner (punishment) + * 2. Approach to tighten their binds (reset resistance) + * + * @param player The player who started struggling + */ + public static void notifyNearbyKidnappersOfStruggle(ServerPlayer player) { + if (player == null) return; + + ServerLevel level = player.serverLevel(); + if (level == null) return; + + int notifiedCount = 0; + + // MEDIUM FIX: Performance optimization - use single entity search instead of 3 + // Old code did 3 separate searches for EntityKidnapper, EntityMaid, EntitySlaveTrader + // with the SAME bounding box, causing 3x iterations over entities in the area. + // New code does 1 search for LivingEntity and filters by instanceof. + java.util.List nearbyEntities = + level.getEntitiesOfClass( + net.minecraft.world.entity.LivingEntity.class, + player.getBoundingBox().inflate(STRUGGLE_NOISE_RADIUS), + e -> e.isAlive() + ); + + for (net.minecraft.world.entity.LivingEntity entity : nearbyEntities) { + if ( + entity instanceof EntityKidnapper kidnapper && + !kidnapper.isTiedUp() + ) { + kidnapper.onStruggleDetected(player); + notifiedCount++; + } else if ( + entity instanceof EntityMaid maid && + !maid.isTiedUp() && + !maid.isFreed() + ) { + maid.onStruggleDetected(player); + notifiedCount++; + } else if ( + entity instanceof + com.tiedup.remake.entities.EntitySlaveTrader trader + ) { + trader.onStruggleDetected(player); + notifiedCount++; + } + } + + if (notifiedCount > 0) { + TiedUpMod.LOGGER.debug( + "[GuardNotificationHelper] Notified {} guards about struggle from {}", + notifiedCount, + player.getName().getString() + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/minigame/LockpickMiniGameState.java b/src/main/java/com/tiedup/remake/minigame/LockpickMiniGameState.java new file mode 100644 index 0000000..630eb06 --- /dev/null +++ b/src/main/java/com/tiedup/remake/minigame/LockpickMiniGameState.java @@ -0,0 +1,283 @@ +package com.tiedup.remake.minigame; + +import java.util.Random; +import java.util.UUID; + +/** + * Phase 2: Server-side state for a Lockpick mini-game session. + * + * Tracks: + * - Session UUID (anti-cheat) + * - Sweet spot position and width + * - Current lockpick position + * - Remaining lockpick uses + */ +public class LockpickMiniGameState { + + private final UUID sessionId; + private final UUID playerId; + private final long createdAt; + private final int targetSlot; + + /** + * Session timeout in milliseconds (2 minutes) + */ + private static final long SESSION_TIMEOUT_MS = 2L * 60L * 1000L; + + /** + * Position of the sweet spot center (0.0 to 1.0) + * BUG FIX: Removed final to allow regeneration after failed attempts. + */ + private float sweetSpotCenter; + + /** + * Width of the sweet spot (0.0 to 1.0, e.g. 0.15 = 15%) + */ + private final float sweetSpotWidth; + + /** + * Current lockpick position (0.0 to 1.0) + */ + private float currentPosition; + + /** + * Number of remaining lockpick uses + */ + private int remainingUses; + + /** + * Whether the session is complete + */ + private boolean complete; + + /** + * Whether the lock was successfully picked + */ + private boolean success; + + private final Random random = new Random(); + + public LockpickMiniGameState( + UUID playerId, + int targetSlot, + float sweetSpotWidth + ) { + this.sessionId = UUID.randomUUID(); + this.playerId = playerId; + this.targetSlot = targetSlot; + this.createdAt = System.currentTimeMillis(); + + // Generate random sweet spot position + // Keep it away from edges (at least width/2 from 0 and 1) + float minCenter = sweetSpotWidth / 2; + float maxCenter = 1.0f - sweetSpotWidth / 2; + this.sweetSpotCenter = + minCenter + random.nextFloat() * (maxCenter - minCenter); + this.sweetSpotWidth = sweetSpotWidth; + + // Start position at random location + this.currentPosition = random.nextFloat(); + this.remainingUses = 5; // Default, will be updated by caller + this.complete = false; + this.success = false; + } + + // ==================== GETTERS ==================== + + public UUID getSessionId() { + return sessionId; + } + + public UUID getPlayerId() { + return playerId; + } + + public int getTargetSlot() { + return targetSlot; + } + + public float getSweetSpotCenter() { + return sweetSpotCenter; + } + + public float getSweetSpotWidth() { + return sweetSpotWidth; + } + + public float getCurrentPosition() { + return currentPosition; + } + + public int getRemainingUses() { + return remainingUses; + } + + public boolean isComplete() { + return complete; + } + + public boolean isSuccess() { + return success; + } + + public boolean isExpired() { + return System.currentTimeMillis() - createdAt > SESSION_TIMEOUT_MS; + } + + // ==================== SETTERS ==================== + + public void setRemainingUses(int uses) { + this.remainingUses = uses; + } + + public void setCurrentPosition(float position) { + this.currentPosition = Math.max(0.0f, Math.min(1.0f, position)); + } + + // ==================== GAME LOGIC ==================== + + /** + * Move the lockpick position. + * + * @param delta Amount to move (-1.0 to 1.0, scaled by sensitivity) + */ + public void move(float delta) { + currentPosition = Math.max( + 0.0f, + Math.min(1.0f, currentPosition + delta) + ); + } + + /** + * Check if current position is within the sweet spot. + */ + public boolean isInSweetSpot() { + float minPos = sweetSpotCenter - sweetSpotWidth / 2; + float maxPos = sweetSpotCenter + sweetSpotWidth / 2; + return currentPosition >= minPos && currentPosition <= maxPos; + } + + /** + * Attempt to pick the lock at current position. + * + * @return Result of the attempt + */ + public PickAttemptResult attemptPick() { + if (complete) { + return PickAttemptResult.SESSION_COMPLETE; + } + + if (isInSweetSpot()) { + // Success! + complete = true; + success = true; + return PickAttemptResult.SUCCESS; + } + + // Failed - use up a lockpick + remainingUses--; + + if (remainingUses <= 0) { + // Out of lockpicks + complete = true; + return PickAttemptResult.OUT_OF_PICKS; + } + + // Generate new sweet spot position for next attempt + regenerateSweetSpot(); + + return PickAttemptResult.MISSED; + } + + /** + * Regenerate sweet spot position (called after failed attempt). + * BUG FIX: Actually regenerate the sweet spot to prevent players from memorizing position. + * Problem: Players could memorize position and lockpicking became trivial. + */ + private void regenerateSweetSpot() { + // Generate new random sweet spot position + // Keep it away from edges (at least width/2 from 0 and 1) + float minCenter = sweetSpotWidth / 2; + float maxCenter = 1.0f - sweetSpotWidth / 2; + this.sweetSpotCenter = + minCenter + random.nextFloat() * (maxCenter - minCenter); + } + + /** + * Get distance from current position to sweet spot center. + * Used for feedback (closer = warmer). + * + * @return Distance as 0.0 (exact) to 1.0 (max distance) + */ + public float getDistanceToSweetSpot() { + return Math.abs(currentPosition - sweetSpotCenter); + } + + /** + * Get warmth feedback (how close to sweet spot). + * + * @return Warmth level: COLD, WARM, HOT, IN_SPOT + */ + public WarmthLevel getWarmthLevel() { + if (isInSweetSpot()) { + return WarmthLevel.IN_SPOT; + } + + float distance = getDistanceToSweetSpot(); + if (distance < sweetSpotWidth) { + return WarmthLevel.HOT; + } else if (distance < sweetSpotWidth * 2) { + return WarmthLevel.WARM; + } + return WarmthLevel.COLD; + } + + /** + * Result of a pick attempt. + */ + public enum PickAttemptResult { + /** + * Successfully picked the lock! + */ + SUCCESS, + + /** + * Missed the sweet spot, lockpick used + */ + MISSED, + + /** + * Ran out of lockpicks + */ + OUT_OF_PICKS, + + /** + * Session already complete + */ + SESSION_COMPLETE, + } + + /** + * Warmth feedback level. + */ + public enum WarmthLevel { + COLD, + WARM, + HOT, + IN_SPOT, + } + + @Override + public String toString() { + return String.format( + "LockpickMiniGameState{session=%s, player=%s, slot=%d, pos=%.2f, sweet=%.2f(w=%.2f), uses=%d}", + sessionId.toString().substring(0, 8), + playerId.toString().substring(0, 8), + targetSlot, + currentPosition, + sweetSpotCenter, + sweetSpotWidth, + remainingUses + ); + } +} diff --git a/src/main/java/com/tiedup/remake/minigame/LockpickSessionManager.java b/src/main/java/com/tiedup/remake/minigame/LockpickSessionManager.java new file mode 100644 index 0000000..a8e6a81 --- /dev/null +++ b/src/main/java/com/tiedup/remake/minigame/LockpickSessionManager.java @@ -0,0 +1,186 @@ +package com.tiedup.remake.minigame; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import org.jetbrains.annotations.Nullable; + +/** + * Manages lockpick mini-game sessions. + * + *

Extracted from {@link MiniGameSessionManager} (M15 split) to give lockpick + * sessions their own focused manager. Singleton, thread-safe via ConcurrentHashMap. + */ +public class LockpickSessionManager { + + private static final LockpickSessionManager INSTANCE = + new LockpickSessionManager(); + + /** + * Active lockpick mini-game sessions by player UUID + */ + private final Map lockpickSessions = + new ConcurrentHashMap<>(); + + private LockpickSessionManager() {} + + public static LockpickSessionManager getInstance() { + return INSTANCE; + } + + /** + * Start a new lockpick session for a player. + * If player already has an active session, it will be replaced (handles ESC cancel case). + * + * @param player The server player + * @param targetSlot The bondage slot being picked + * @param sweetSpotWidth The width of the sweet spot (based on tool) + * @return The new session + */ + public LockpickMiniGameState startLockpickSession( + ServerPlayer player, + int targetSlot, + float sweetSpotWidth + ) { + UUID playerId = player.getUUID(); + + // Check for existing session - remove it (handles ESC cancel case) + LockpickMiniGameState existing = lockpickSessions.get(playerId); + if (existing != null) { + TiedUpMod.LOGGER.debug( + "[LockpickSessionManager] Replacing existing lockpick session for {}", + player.getName().getString() + ); + lockpickSessions.remove(playerId); + } + + // Create new session + LockpickMiniGameState session = new LockpickMiniGameState( + playerId, + targetSlot, + sweetSpotWidth + ); + lockpickSessions.put(playerId, session); + + TiedUpMod.LOGGER.info( + "[LockpickSessionManager] Started lockpick session {} for {} (slot: {}, width: {}%)", + session.getSessionId().toString().substring(0, 8), + player.getName().getString(), + targetSlot, + (int) (sweetSpotWidth * 100) + ); + + // Notify nearby guards about lockpicking attempt + GuardNotificationHelper.notifyNearbyKidnappersOfStruggle(player); + + return session; + } + + /** + * Play lockpick attempt sound and notify guards. + * Called when player attempts to pick (tests position). + * + * @param player The player attempting to pick + */ + public void onLockpickAttempt(ServerPlayer player) { + // Play metallic clicking sound + player + .serverLevel() + .playSound( + null, + player.getX(), + player.getY(), + player.getZ(), + SoundEvents.CHAIN_HIT, + SoundSource.PLAYERS, + 0.6f, + 1.2f + player.getRandom().nextFloat() * 0.3f + ); + + // Notify guards + GuardNotificationHelper.notifyNearbyKidnappersOfStruggle(player); + } + + /** + * Get active lockpick session for a player. + * + * @param playerId The player UUID + * @return The session, or null if none active + */ + @Nullable + public LockpickMiniGameState getLockpickSession(UUID playerId) { + LockpickMiniGameState session = lockpickSessions.get(playerId); + if (session != null && session.isExpired()) { + lockpickSessions.remove(playerId); + return null; + } + return session; + } + + /** + * Validate a lockpick session. + * + * @param playerId The player UUID + * @param sessionId The session UUID to validate + * @return true if session is valid and active + */ + public boolean validateLockpickSession(UUID playerId, UUID sessionId) { + LockpickMiniGameState session = getLockpickSession(playerId); + if (session == null) { + return false; + } + return session.getSessionId().equals(sessionId); + } + + /** + * End a lockpick session. + * + * @param playerId The player UUID + * @param success Whether the session ended in success + */ + public void endLockpickSession(UUID playerId, boolean success) { + LockpickMiniGameState session = lockpickSessions.remove(playerId); + if (session != null) { + TiedUpMod.LOGGER.info( + "[LockpickSessionManager] Ended lockpick session for player {} (success: {})", + playerId.toString().substring(0, 8), + success + ); + } + } + + /** + * Clean up all lockpick sessions for a player (called on disconnect). + * + * @param playerId The player UUID + */ + public void cleanupPlayer(UUID playerId) { + lockpickSessions.remove(playerId); + } + + /** + * Periodic cleanup of expired lockpick sessions. + * Should be called from server tick handler. + */ + public void tickCleanup(long currentTick) { + // Only run every 100 ticks (5 seconds) + if (currentTick % 100 != 0) { + return; + } + + lockpickSessions + .entrySet() + .removeIf(entry -> entry.getValue().isExpired()); + } + + /** + * Get count of active lockpick sessions (for debugging). + */ + public int getActiveSessionCount() { + return lockpickSessions.size(); + } +} diff --git a/src/main/java/com/tiedup/remake/minigame/MiniGameSessionManager.java b/src/main/java/com/tiedup/remake/minigame/MiniGameSessionManager.java new file mode 100644 index 0000000..fc8a4f7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/minigame/MiniGameSessionManager.java @@ -0,0 +1,47 @@ +package com.tiedup.remake.minigame; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.UUID; + +/** + * Lightweight facade over {@link LockpickSessionManager} and + * {@link StruggleSessionManager}. + * + *

After the M15 split, this class only delegates cross-cutting concerns + * (cleanup on disconnect, aggregate session count). Callers that need a + * specific session type should use the sub-manager directly. + */ +public class MiniGameSessionManager { + + private static final MiniGameSessionManager INSTANCE = + new MiniGameSessionManager(); + + private MiniGameSessionManager() {} + + public static MiniGameSessionManager getInstance() { + return INSTANCE; + } + + /** + * Clean up all sessions for a player (called on disconnect). + * Delegates to both sub-managers. + * + * @param playerId The player UUID + */ + public void cleanupPlayer(UUID playerId) { + LockpickSessionManager.getInstance().cleanupPlayer(playerId); + StruggleSessionManager.getInstance().cleanupPlayer(playerId); + TiedUpMod.LOGGER.debug( + "[MiniGameSessionManager] Cleaned up all sessions for player {}", + playerId.toString().substring(0, 8) + ); + } + + /** + * Get count of active sessions across both sub-managers (for debugging). + */ + public int getActiveSessionCount() { + return LockpickSessionManager.getInstance().getActiveSessionCount() + + StruggleSessionManager.getInstance().getActiveSessionCount(); + } +} diff --git a/src/main/java/com/tiedup/remake/minigame/StruggleSessionManager.java b/src/main/java/com/tiedup/remake/minigame/StruggleSessionManager.java new file mode 100644 index 0000000..5439932 --- /dev/null +++ b/src/main/java/com/tiedup/remake/minigame/StruggleSessionManager.java @@ -0,0 +1,842 @@ +package com.tiedup.remake.minigame; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.ItemShockCollar; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import com.tiedup.remake.minigame.ContinuousStruggleMiniGameState.TickResult; +import com.tiedup.remake.minigame.ContinuousStruggleMiniGameState.UpdateType; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.minigame.PacketContinuousStruggleState; +import com.tiedup.remake.network.sync.SyncManager; +import com.tiedup.remake.state.PlayerBindState; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; + +/** + * Manages continuous struggle mini-game sessions. + * + *

Extracted from {@link MiniGameSessionManager} (M15 split). Handles all + * struggle variants: bind struggle, accessory struggle, V2 region struggle, + * and furniture escape struggle. + * + *

Singleton, thread-safe via ConcurrentHashMap. + */ +public class StruggleSessionManager { + + private static final StruggleSessionManager INSTANCE = + new StruggleSessionManager(); + + /** + * Active continuous struggle mini-game sessions by player UUID + */ + private final Map< + UUID, + ContinuousStruggleMiniGameState + > continuousSessions = new ConcurrentHashMap<>(); + + /** + * Mapping from legacy V1 slot indices to V2 BodyRegionV2. + * Used to convert V1 session targetSlot ordinals to V2 regions. + * Index: 0=ARMS, 1=MOUTH, 2=EYES, 3=EARS, 4=NECK, 5=TORSO, 6=HANDS. + */ + private static final BodyRegionV2[] SLOT_TO_REGION = { + BodyRegionV2.ARMS, // 0 = BIND + BodyRegionV2.MOUTH, // 1 = GAG + BodyRegionV2.EYES, // 2 = BLINDFOLD + BodyRegionV2.EARS, // 3 = EARPLUGS + BodyRegionV2.NECK, // 4 = COLLAR + BodyRegionV2.TORSO, // 5 = CLOTHES + BodyRegionV2.HANDS, // 6 = MITTENS + }; + + private StruggleSessionManager() {} + + public static StruggleSessionManager getInstance() { + return INSTANCE; + } + + // ==================== SESSION START VARIANTS ==================== + + /** + * Start a new continuous struggle session for a player. + * + * @param player The server player + * @param targetResistance Current bind resistance + * @param isLocked Whether the bind is locked + * @return The new session + */ + public ContinuousStruggleMiniGameState startContinuousStruggleSession( + ServerPlayer player, + int targetResistance, + boolean isLocked + ) { + UUID playerId = player.getUUID(); + + // Remove any existing continuous session + ContinuousStruggleMiniGameState existing = continuousSessions.get( + playerId + ); + if (existing != null) { + TiedUpMod.LOGGER.debug( + "[StruggleSessionManager] Replacing existing continuous struggle session for {}", + player.getName().getString() + ); + continuousSessions.remove(playerId); + } + + // Create new session with configurable rate + int ticksPerResistance = + com.tiedup.remake.core.SettingsAccessor.getStruggleContinuousRate( + player.level().getGameRules() + ); + ContinuousStruggleMiniGameState session = + new ContinuousStruggleMiniGameState( + playerId, + targetResistance, + isLocked, + null, + ticksPerResistance + ); + continuousSessions.put(playerId, session); + + TiedUpMod.LOGGER.info( + "[StruggleSessionManager] Started continuous struggle session {} for {} (resistance: {}, locked: {})", + session.getSessionId().toString().substring(0, 8), + player.getName().getString(), + targetResistance, + isLocked + ); + + // Set struggle animation state and sync to client + PlayerBindState state = PlayerBindState.getInstance(player); + if (state != null) { + state.setStruggling(true, player.level().getGameTime()); + SyncManager.syncStruggleState(player); + } + + // Notify nearby kidnappers + GuardNotificationHelper.notifyNearbyKidnappersOfStruggle(player); + + return session; + } + + /** + * Start a new continuous struggle session for an accessory. + * + * @param player The server player + * @param targetSlot Target accessory slot ordinal + * @param lockResistance Current lock resistance + * @return The new session + */ + public ContinuousStruggleMiniGameState startContinuousAccessoryStruggleSession( + ServerPlayer player, + int targetSlot, + int lockResistance + ) { + UUID playerId = player.getUUID(); + + // Remove any existing session + ContinuousStruggleMiniGameState existing = continuousSessions.get( + playerId + ); + if (existing != null) { + continuousSessions.remove(playerId); + } + + // Create new session with target slot and configurable rate + int ticksPerResistance = + com.tiedup.remake.core.SettingsAccessor.getStruggleContinuousRate( + player.level().getGameRules() + ); + ContinuousStruggleMiniGameState session = + new ContinuousStruggleMiniGameState( + playerId, + lockResistance, + true, + targetSlot, + ticksPerResistance + ); + continuousSessions.put(playerId, session); + + TiedUpMod.LOGGER.info( + "[StruggleSessionManager] Started continuous accessory struggle session {} for {} (slot: {}, resistance: {})", + session.getSessionId().toString().substring(0, 8), + player.getName().getString(), + targetSlot, + lockResistance + ); + + // Set struggle animation state and sync to client + PlayerBindState state = PlayerBindState.getInstance(player); + if (state != null) { + state.setStruggling(true, player.level().getGameTime()); + SyncManager.syncStruggleState(player); + } + + // Notify nearby kidnappers + GuardNotificationHelper.notifyNearbyKidnappersOfStruggle(player); + + return session; + } + + /** + * Start a new continuous struggle session for a V2 bondage item. + * + * @param player The server player + * @param targetRegion V2 body region to struggle against + * @param targetResistance Current resistance value + * @param isLocked Whether the item is locked + * @return The new session, or null if creation failed + */ + public ContinuousStruggleMiniGameState startV2StruggleSession( + ServerPlayer player, + com.tiedup.remake.v2.BodyRegionV2 targetRegion, + int targetResistance, + boolean isLocked + ) { + UUID playerId = player.getUUID(); + + // RISK-001 fix: reject if an active session already exists (prevents direction re-roll exploit) + ContinuousStruggleMiniGameState existing = continuousSessions.get( + playerId + ); + if (existing != null) { + TiedUpMod.LOGGER.debug( + "[StruggleSessionManager] Rejected V2 session: active session already exists for {}", + player.getName().getString() + ); + return null; + } + + int ticksPerResistance = + com.tiedup.remake.core.SettingsAccessor.getStruggleContinuousRate( + player.level().getGameRules() + ); + ContinuousStruggleMiniGameState session = + new ContinuousStruggleMiniGameState( + playerId, + targetResistance, + isLocked, + null, + ticksPerResistance + ); + session.setTargetRegion(targetRegion); + continuousSessions.put(playerId, session); + + TiedUpMod.LOGGER.info( + "[StruggleSessionManager] Started V2 struggle session {} for {} (region: {}, resistance: {}, locked: {})", + session.getSessionId().toString().substring(0, 8), + player.getName().getString(), + targetRegion.name(), + targetResistance, + isLocked + ); + + // Set struggle animation state and sync to client + PlayerBindState state = PlayerBindState.getInstance(player); + if (state != null) { + state.setStruggling(true, player.level().getGameTime()); + SyncManager.syncStruggleState(player); + } + + GuardNotificationHelper.notifyNearbyKidnappersOfStruggle(player); + return session; + } + + /** + * Start a new continuous struggle session for a furniture seat escape. + * + *

The session behaves identically to a V2 struggle (direction-hold to + * reduce resistance) but on completion it unlocks the seat and dismounts + * the player instead of removing a bondage item.

+ * + * @param player The server player (must be seated and locked) + * @param furnitureEntityId Entity ID of the furniture + * @param seatId The locked seat ID + * @param totalDifficulty Combined resistance (seat base + item bonus) + * @return The new session, or null if creation failed + */ + public ContinuousStruggleMiniGameState startFurnitureStruggleSession( + ServerPlayer player, + int furnitureEntityId, + String seatId, + int totalDifficulty + ) { + UUID playerId = player.getUUID(); + + // Reject if an active session already exists + ContinuousStruggleMiniGameState existing = continuousSessions.get(playerId); + if (existing != null) { + TiedUpMod.LOGGER.debug( + "[StruggleSessionManager] Rejected furniture session: active session already exists for {}", + player.getName().getString() + ); + return null; + } + + int ticksPerResistance = + com.tiedup.remake.core.SettingsAccessor.getStruggleContinuousRate( + player.level().getGameRules() + ); + ContinuousStruggleMiniGameState session = + new ContinuousStruggleMiniGameState( + playerId, + totalDifficulty, + true, // furniture seats are always "locked" in context + null, + ticksPerResistance + ); + session.setFurnitureContext(furnitureEntityId, seatId); + continuousSessions.put(playerId, session); + + TiedUpMod.LOGGER.info( + "[StruggleSessionManager] Started furniture struggle session {} for {} (entity: {}, seat: '{}', difficulty: {})", + session.getSessionId().toString().substring(0, 8), + player.getName().getString(), + furnitureEntityId, + seatId, + totalDifficulty + ); + + // Set struggle animation state and sync to client + PlayerBindState state = PlayerBindState.getInstance(player); + if (state != null) { + state.setStruggling(true, player.level().getGameTime()); + SyncManager.syncStruggleState(player); + } + + // Play struggle loop sound from furniture definition (plays once on start; + // true looping sound would require client-side sound management -- future scope) + net.minecraft.world.entity.Entity furnitureEntity = player.level().getEntity(furnitureEntityId); + if (furnitureEntity instanceof com.tiedup.remake.v2.furniture.EntityFurniture furniture) { + com.tiedup.remake.v2.furniture.FurnitureDefinition def = furniture.getDefinition(); + if (def != null && def.feedback().struggleLoopSound() != null) { + player.level().playSound(null, player.getX(), player.getY(), player.getZ(), + net.minecraft.sounds.SoundEvent.createVariableRangeEvent(def.feedback().struggleLoopSound()), + SoundSource.PLAYERS, 0.6f, 1.0f); + } + } + + GuardNotificationHelper.notifyNearbyKidnappersOfStruggle(player); + return session; + } + + // ==================== SESSION QUERY ==================== + + /** + * Get active continuous struggle session for a player. + * + * @param playerId The player UUID + * @return The session, or null if none active + */ + @Nullable + public ContinuousStruggleMiniGameState getContinuousStruggleSession( + UUID playerId + ) { + ContinuousStruggleMiniGameState session = continuousSessions.get( + playerId + ); + if (session != null && session.isExpired()) { + continuousSessions.remove(playerId); + return null; + } + return session; + } + + /** + * Validate a continuous struggle session. + * + * @param playerId The player UUID + * @param sessionId The session UUID to validate + * @return true if session is valid and active + */ + public boolean validateContinuousStruggleSession( + UUID playerId, + UUID sessionId + ) { + ContinuousStruggleMiniGameState session = getContinuousStruggleSession( + playerId + ); + if (session == null) { + return false; + } + return session.getSessionId().equals(sessionId); + } + + /** + * End a continuous struggle session. + * + * @param playerId The player UUID + * @param success Whether the session ended in success (escape) + */ + public void endContinuousStruggleSession(UUID playerId, boolean success) { + ContinuousStruggleMiniGameState session = continuousSessions.remove( + playerId + ); + if (session != null) { + TiedUpMod.LOGGER.info( + "[StruggleSessionManager] Ended continuous struggle session for player {} (success: {})", + playerId.toString().substring(0, 8), + success + ); + + // Clear struggle animation state + // We need to find the player to clear the animation + // This will be handled by the caller if they have access to the player + } + } + + // ==================== TICK ==================== + + /** + * Tick all continuous struggle sessions. + * Should be called every server tick. + * + * @param server The server instance for player lookups + * @param currentTick Current game tick + */ + public void tickContinuousSessions( + net.minecraft.server.MinecraftServer server, + long currentTick + ) { + if (continuousSessions.isEmpty()) return; + + // Collect sessions to process (avoid ConcurrentModificationException) + List> toProcess = + new ArrayList<>(continuousSessions.entrySet()); + + for (Map.Entry< + UUID, + ContinuousStruggleMiniGameState + > entry : toProcess) { + UUID playerId = entry.getKey(); + ContinuousStruggleMiniGameState session = entry.getValue(); + + // Get player + ServerPlayer player = server.getPlayerList().getPlayer(playerId); + if (player == null) { + // Player disconnected, clean up + continuousSessions.remove(playerId); + continue; + } + + // Check for expired/completed sessions + if (session.isExpired() || session.isComplete()) { + continuousSessions.remove(playerId); + clearStruggleAnimation(player); + continue; + } + + // Tick the session + TickResult result = session.tick(currentTick); + + // Handle tick result + switch (result) { + case DIRECTION_CHANGE -> { + sendContinuousStruggleUpdate( + player, + session, + UpdateType.DIRECTION_CHANGE + ); + } + case RESISTANCE_UPDATE -> { + // Update actual bind resistance + updateBindResistance(player, session); + // Send update to client immediately for real-time feedback + sendContinuousStruggleUpdate( + player, + session, + UpdateType.RESISTANCE_UPDATE + ); + } + case ESCAPED -> { + handleStruggleEscape(player, session); + } + case NO_CHANGE -> { + // No action needed + } + default -> { + } + } + + // Check shock collar (separate from tick result) + if ( + shouldCheckShockCollar(player) && + session.shouldTriggerShock(currentTick) + ) { + handleShockCollar(player, session); + } + + // Check kidnapper notification + if (session.shouldNotifyKidnappers(currentTick)) { + GuardNotificationHelper.notifyNearbyKidnappersOfStruggle(player); + } + + // Play struggle sound periodically + if (session.shouldPlayStruggleSound(currentTick)) { + playStruggleSound(player); + } + } + } + + // ==================== PRIVATE HELPERS ==================== + + /** + * Play struggle sound for player and nearby entities. + */ + private void playStruggleSound(ServerPlayer player) { + // Play leather creaking sound - audible to player and nearby + player + .serverLevel() + .playSound( + null, // null = all players can hear + player.getX(), + player.getY(), + player.getZ(), + SoundEvents.ARMOR_EQUIP_LEATHER, + SoundSource.PLAYERS, + 0.8f, // volume + 0.9f + player.getRandom().nextFloat() * 0.2f // slight pitch variation + ); + } + + /** + * Check if shock collar check is applicable for this player. + */ + private boolean shouldCheckShockCollar(ServerPlayer player) { + ItemStack collar = V2EquipmentHelper.getInRegion(player, BodyRegionV2.NECK); + if (collar.isEmpty()) return false; + + // Only shock collars can trigger during struggle + if (!(collar.getItem() instanceof ItemShockCollar)) return false; + + // Must be locked + if (collar.getItem() instanceof ItemCollar collarItem) { + return collarItem.isLocked(collar); + } + return false; + } + + /** + * Handle shock collar trigger. + */ + private void handleShockCollar( + ServerPlayer player, + ContinuousStruggleMiniGameState session + ) { + PlayerBindState state = PlayerBindState.getInstance(player); + if (state != null) { + state.shockKidnapped(" (Your struggle was interrupted!)", 2.0f); + } + + session.triggerShock(); + sendContinuousStruggleUpdate(player, session, UpdateType.SHOCK); + + TiedUpMod.LOGGER.debug( + "[StruggleSessionManager] Shock collar triggered for {} during struggle", + player.getName().getString() + ); + } + + /** + * Update the actual bind resistance based on session state. + */ + private void updateBindResistance( + ServerPlayer player, + ContinuousStruggleMiniGameState session + ) { + // V2 region-based resistance update + if (session.isV2Struggle()) { + com.tiedup.remake.v2.BodyRegionV2 region = + session.getTargetRegion(); + ItemStack stack = + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper + .getInRegion(player, region); + if (stack.isEmpty()) return; + + if ( + stack.getItem() instanceof + com.tiedup.remake.items.base.IHasResistance resistanceItem + ) { + resistanceItem.setCurrentResistance( + stack, + session.getCurrentResistance() + ); + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.sync( + player + ); + } + return; + } + + if (session.isAccessoryStruggle()) { + // Handle accessory resistance update (V1 path -- slot index is legacy ordinal) + Integer slotIndex = session.getTargetSlot(); + if (slotIndex == null || slotIndex < 0 || slotIndex >= SLOT_TO_REGION.length) return; + + BodyRegionV2 region = SLOT_TO_REGION[slotIndex]; + ItemStack accessoryStack = V2EquipmentHelper.getInRegion(player, region); + if (accessoryStack.isEmpty()) return; + + if ( + accessoryStack.getItem() instanceof + com.tiedup.remake.items.base.ILockable lockable + ) { + // Update the lock resistance to match session state + lockable.setCurrentLockResistance( + accessoryStack, + session.getCurrentResistance() + ); + // Sync V2 equipment state + V2EquipmentHelper.sync(player); + } + return; + } + + // Update bind resistance + ItemStack bindStack = V2EquipmentHelper.getInRegion(player, BodyRegionV2.ARMS); + if ( + bindStack.isEmpty() || + !(bindStack.getItem() instanceof ItemBind bind) + ) { + return; + } + + bind.setCurrentResistance(bindStack, session.getCurrentResistance()); + } + + /** + * Handle successful escape from struggle. + */ + private void handleStruggleEscape( + ServerPlayer player, + ContinuousStruggleMiniGameState session + ) { + TiedUpMod.LOGGER.info( + "[StruggleSessionManager] Player {} escaped from struggle!", + player.getName().getString() + ); + + // Send escape update to client + sendContinuousStruggleUpdate(player, session, UpdateType.ESCAPE); + + // Clear animation state + clearStruggleAnimation(player); + + // Furniture escape: unlock seat + dismount + if (session.isFurnitureStruggle()) { + handleFurnitureEscape(player, session); + continuousSessions.remove(player.getUUID()); + return; + } + + // V2 region-based escape + if (session.isV2Struggle()) { + com.tiedup.remake.v2.BodyRegionV2 region = + session.getTargetRegion(); + // BUG-001 fix: break lock before unequip (consistent with V1 accessory escape) + ItemStack stackInRegion = + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper + .getInRegion(player, region); + if (!stackInRegion.isEmpty() + && stackInRegion.getItem() instanceof com.tiedup.remake.items.base.ILockable lockable) { + lockable.breakLock(stackInRegion); + } + ItemStack removed = + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper + .unequipFromRegion(player, region, true); + if (!removed.isEmpty()) { + if (!player.getInventory().add(removed)) { + player.drop(removed, false); + } + } + continuousSessions.remove(player.getUUID()); + return; + } + + // Remove the bind + if (!session.isAccessoryStruggle()) { + PlayerBindState state = PlayerBindState.getInstance(player); + if (state != null) { + ItemStack bind = state.unequip(BodyRegionV2.ARMS); + if (!bind.isEmpty()) { + state.kidnappedDropItem(bind); + } + } + } else { + // Handle accessory escape (V1 path -- slot index is legacy ordinal) + Integer slotIndex = session.getTargetSlot(); + if (slotIndex != null && slotIndex >= 0 && slotIndex < SLOT_TO_REGION.length) { + BodyRegionV2 region = SLOT_TO_REGION[slotIndex]; + ItemStack accessoryStack = V2EquipmentHelper.getInRegion(player, region); + if (!accessoryStack.isEmpty()) { + // Break the lock on the accessory + if ( + accessoryStack.getItem() instanceof + com.tiedup.remake.items.base.ILockable lockable + ) { + lockable.breakLock(accessoryStack); + } + // Remove the accessory from the region and drop it + ItemStack removed = V2EquipmentHelper.unequipFromRegion( + player, region, true + ); + if (!removed.isEmpty()) { + // Drop the item at player's feet + player.drop(removed, true); + } + } + } + } + + // Remove session + continuousSessions.remove(player.getUUID()); + } + + /** + * Handle successful furniture escape: unlock the seat, dismount the player, + * play sounds, and broadcast state to tracking clients. + */ + private void handleFurnitureEscape( + ServerPlayer player, + ContinuousStruggleMiniGameState session + ) { + int furnitureEntityId = session.getFurnitureEntityId(); + String seatId = session.getFurnitureSeatId(); + + net.minecraft.world.entity.Entity entity = player.level().getEntity(furnitureEntityId); + if (entity == null || entity.isRemoved()) { + TiedUpMod.LOGGER.warn( + "[StruggleSessionManager] Furniture entity {} no longer exists for escape", + furnitureEntityId + ); + // Player still needs to be freed even if entity is gone + player.stopRiding(); + return; + } + + if (!(entity instanceof com.tiedup.remake.v2.furniture.ISeatProvider provider)) { + TiedUpMod.LOGGER.warn( + "[StruggleSessionManager] Entity {} is not an ISeatProvider", + furnitureEntityId + ); + player.stopRiding(); + return; + } + + // Unlock the seat + provider.setSeatLocked(seatId, false); + + // Clear persistent data tag (reconnection system) + net.minecraft.nbt.CompoundTag persistentData = player.getPersistentData(); + persistentData.remove("tiedup_locked_furniture"); + + // Dismount the player + player.stopRiding(); + + // Play escape sound: prefer furniture-specific sound, fall back to CHAIN_BREAK + net.minecraft.sounds.SoundEvent escapeSound = net.minecraft.sounds.SoundEvents.CHAIN_BREAK; + if (entity instanceof com.tiedup.remake.v2.furniture.EntityFurniture furniture) { + com.tiedup.remake.v2.furniture.FurnitureDefinition def = furniture.getDefinition(); + if (def != null && def.feedback().escapeSound() != null) { + escapeSound = net.minecraft.sounds.SoundEvent.createVariableRangeEvent( + def.feedback().escapeSound() + ); + } + } + player.serverLevel().playSound( + null, + player.getX(), player.getY(), player.getZ(), + escapeSound, + net.minecraft.sounds.SoundSource.PLAYERS, + 1.0f, 1.0f + ); + + // Broadcast updated furniture state to all tracking clients + if (entity instanceof com.tiedup.remake.v2.furniture.EntityFurniture furniture) { + com.tiedup.remake.v2.furniture.network.PacketSyncFurnitureState.sendToTracking(furniture); + } + + TiedUpMod.LOGGER.info( + "[StruggleSessionManager] {} escaped furniture {} seat '{}'", + player.getName().getString(), furnitureEntityId, seatId + ); + } + + /** + * Send a continuous struggle state update to the client. + */ + private void sendContinuousStruggleUpdate( + ServerPlayer player, + ContinuousStruggleMiniGameState session, + UpdateType updateType + ) { + ModNetwork.sendToPlayer( + new PacketContinuousStruggleState( + session.getSessionId(), + updateType, + session.getCurrentDirectionIndex(), + session.getCurrentResistance(), + session.getMaxResistance(), + session.isLocked() + ), + player + ); + } + + /** + * Clear struggle animation state for a player and sync to clients. + */ + private void clearStruggleAnimation(ServerPlayer player) { + PlayerBindState state = PlayerBindState.getInstance(player); + if (state != null) { + state.setStruggling(false, 0); + SyncManager.syncStruggleState(player); + } + } + + // ==================== CLEANUP ==================== + + /** + * Clean up all struggle sessions for a player (called on disconnect). + * + * @param playerId The player UUID + */ + public void cleanupPlayer(UUID playerId) { + continuousSessions.remove(playerId); + } + + /** + * Periodic cleanup of expired struggle sessions. + * Should be called from server tick handler. + */ + public void tickCleanup(long currentTick) { + // Only run every 100 ticks (5 seconds) + if (currentTick % 100 != 0) { + return; + } + + continuousSessions + .entrySet() + .removeIf(entry -> entry.getValue().isExpired()); + } + + /** + * Get count of active struggle sessions (for debugging). + */ + public int getActiveSessionCount() { + return continuousSessions.size(); + } +} diff --git a/src/main/java/com/tiedup/remake/mixin/MixinLivingEntityBodyRot.java b/src/main/java/com/tiedup/remake/mixin/MixinLivingEntityBodyRot.java new file mode 100644 index 0000000..bb419e6 --- /dev/null +++ b/src/main/java/com/tiedup/remake/mixin/MixinLivingEntityBodyRot.java @@ -0,0 +1,56 @@ +package com.tiedup.remake.mixin; + +import com.tiedup.remake.state.HumanChairHelper; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.state.PlayerBindState; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** + * Mixin to lock player body rotation during human chair mode. + * + * Vanilla {@code LivingEntity.tickHeadTurn()} gradually rotates yBodyRot + * toward the head/movement direction. This overrides any value set from + * a tick handler. By cancelling the method here, the body stays locked + * at the stored facing direction. + */ +@Mixin(LivingEntity.class) +public abstract class MixinLivingEntityBodyRot { + + @Inject(method = "tickHeadTurn", at = @At("HEAD"), cancellable = true) + private void tiedup$lockBodyForHumanChair( + float yRot, + float animStep, + CallbackInfoReturnable cir + ) { + LivingEntity self = (LivingEntity) (Object) this; + if (!(self instanceof Player player)) { + return; + } + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null || !state.isTiedUp()) { + return; + } + + ItemStack bind = state.getEquipment(BodyRegionV2.ARMS); + if (bind.isEmpty()) { + return; + } + + if (!HumanChairHelper.isActive(bind)) { + return; + } + + // Lock body rotation to the stored facing and skip vanilla update + float lockedYaw = HumanChairHelper.getFacing(bind); + self.yBodyRot = lockedYaw; + self.yBodyRotO = lockedYaw; + cir.setReturnValue(animStep); + } +} diff --git a/src/main/java/com/tiedup/remake/mixin/MixinMCAMessenger.java b/src/main/java/com/tiedup/remake/mixin/MixinMCAMessenger.java new file mode 100644 index 0000000..79ff7c7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/mixin/MixinMCAMessenger.java @@ -0,0 +1,186 @@ +package com.tiedup.remake.mixin; + +import com.tiedup.remake.compat.mca.MCACompat; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.GagTalkManager; +import com.tiedup.remake.state.IBondageState; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Mixin for MCA's VillagerEntityMCA to handle gagged speech. + * + *

This mixin provides method implementations that override the default methods + * from MCA's Messenger interface. When the villager is gagged: + *

    + *
  • isSpeechImpaired() returns true (blocks TTS)
  • + *
  • playSpeechEffect() does nothing (silence)
  • + *
  • transformMessage() applies gagtalk transformation
  • + *
  • playWelcomeSound() is cancelled (no greeting)
  • + *
  • playSurprisedSound() is cancelled (no surprise sound)
  • + *
+ * + *

Uses @Pseudo for soft dependency - only applies if MCA is present. + */ +@Pseudo +@Mixin(targets = "forge.net.mca.entity.VillagerEntityMCA", remap = false) +public abstract class MixinMCAMessenger { + + /** + * Override isSpeechImpaired to return true when gagged. + * + *

This blocks TTS voice generation in MCA's SpeechManager. + * When the villager is not gagged, returns false (normal speech). + * + * @return true if gagged, false otherwise + */ + public boolean isSpeechImpaired() { + try { + LivingEntity entity = (LivingEntity) (Object) this; + IBondageState state = MCACompat.getKidnappedState(entity); + + if (state != null && state.isGagged()) { + return true; + } + } catch (Exception e) { + TiedUpMod.LOGGER.debug( + "[MCA] isSpeechImpaired check failed: {}", + e.getMessage() + ); + } + + return false; + } + + /** + * Override playSpeechEffect to produce complete silence when gagged. + * + *

When the villager is gagged, this method does nothing (no sound). + * When not gagged, delegates to default behavior. + */ + public void playSpeechEffect() { + try { + LivingEntity entity = (LivingEntity) (Object) this; + IBondageState state = MCACompat.getKidnappedState(entity); + + if (state != null && state.isGagged()) { + TiedUpMod.LOGGER.debug( + "[MCA] playSpeechEffect cancelled for gagged villager" + ); + return; + } + } catch (Exception e) { + TiedUpMod.LOGGER.debug( + "[MCA] playSpeechEffect check failed: {}", + e.getMessage() + ); + } + } + + /** + * Override transformMessage to apply gagtalk when gagged. + * + *

When the villager is gagged, transforms the message using + * TiedUp's GagTalkManager. When not gagged, returns the original + * message unchanged. + * + * @param message The original message + * @return The gagged message if gagged, original otherwise + */ + public MutableComponent transformMessage(MutableComponent message) { + try { + LivingEntity entity = (LivingEntity) (Object) this; + IBondageState state = MCACompat.getKidnappedState(entity); + + if (state != null && state.isGagged()) { + ItemStack gag = state.getEquipment(BodyRegionV2.MOUTH); + String originalText = message.getString(); + String gaggedText = GagTalkManager.transformToGaggedSpeech( + originalText, + gag + ); + + TiedUpMod.LOGGER.debug( + "[MCA] Applied gagtalk transformation for villager" + ); + return Component.literal(gaggedText); + } + } catch (Exception e) { + TiedUpMod.LOGGER.debug( + "[MCA] transformMessage failed: {}", + e.getMessage() + ); + } + + return message; + } + + /** + * Intercept playWelcomeSound to block greeting sounds when gagged. + * + *

MCA calls playWelcomeSound() in interactAt() when a player interacts + * with a villager. This plays the "hi"/"hello" greeting sound. + */ + @Inject( + method = "playWelcomeSound", + at = @At("HEAD"), + cancellable = true, + remap = false, + require = 0 + ) + private void tiedup$blockWelcomeSound(CallbackInfo ci) { + try { + LivingEntity entity = (LivingEntity) (Object) this; + IBondageState state = MCACompat.getKidnappedState(entity); + + if (state != null && state.isGagged()) { + TiedUpMod.LOGGER.debug( + "[MCA] playWelcomeSound blocked for gagged villager" + ); + ci.cancel(); + } + } catch (Exception e) { + TiedUpMod.LOGGER.debug( + "[MCA] playWelcomeSound check failed: {}", + e.getMessage() + ); + } + } + + /** + * Intercept playSurprisedSound to block surprised sounds when gagged. + */ + @Inject( + method = "playSurprisedSound", + at = @At("HEAD"), + cancellable = true, + remap = false, + require = 0 + ) + private void tiedup$blockSurprisedSound(CallbackInfo ci) { + try { + LivingEntity entity = (LivingEntity) (Object) this; + IBondageState state = MCACompat.getKidnappedState(entity); + + if (state != null && state.isGagged()) { + TiedUpMod.LOGGER.debug( + "[MCA] playSurprisedSound blocked for gagged villager" + ); + ci.cancel(); + } + } catch (Exception e) { + TiedUpMod.LOGGER.debug( + "[MCA] playSurprisedSound check failed: {}", + e.getMessage() + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/mixin/MixinMCAOpenAIChatAI.java b/src/main/java/com/tiedup/remake/mixin/MixinMCAOpenAIChatAI.java new file mode 100644 index 0000000..16c5eb3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/mixin/MixinMCAOpenAIChatAI.java @@ -0,0 +1,129 @@ +package com.tiedup.remake.mixin; + +import com.tiedup.remake.compat.mca.MCACompat; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.compat.mca.ai.chatai.TiedUpModule; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.GagTalkManager; +import com.tiedup.remake.state.IBondageState; +import java.util.List; +import java.util.Optional; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.ModifyVariable; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +import org.spongepowered.asm.mixin.injection.callback.LocalCapture; + +/** + * Mixin for MCA's OpenAIChatAI to integrate TiedUp bondage context. + * + *

This mixin: + *

    + *
  • Injects TiedUp state into the AI context (tied, gagged, blindfolded, etc.)
  • + *
  • Transforms AI responses with gagtalk if the villager is gagged
  • + *
+ * + *

Note: Uses @Pseudo for soft dependency - only applies if MCA is present. + */ +@Pseudo +@Mixin(targets = "forge.net.mca.entity.ai.chatAI.OpenAIChatAI", remap = false) +public class MixinMCAOpenAIChatAI { + + /** + * Inject TiedUp context after MCA's modules are applied. + * + *

We capture the 'input' list local variable and add our TiedUp context to it. + * This is called after PlayerModule.apply() which is the last module in the chain. + */ + @Inject( + method = "answer", + at = @At( + value = "INVOKE", + target = "Lforge/net/mca/entity/ai/chatAI/modules/PlayerModule;apply(Ljava/util/List;Lforge/net/mca/entity/VillagerEntityMCA;Lnet/minecraft/server/network/ServerPlayerEntity;)V", + shift = At.Shift.AFTER + ), + locals = LocalCapture.CAPTURE_FAILSOFT, + require = 0 // Soft requirement - don't crash if not found + ) + private void tiedup$injectBondageContext( + Object player, // ServerPlayerEntity + Object villager, // VillagerEntityMCA + String msg, + CallbackInfoReturnable> cir, + // Local variables captured (order matters!) + Object config, + boolean isInHouse, + String playerName, + String villagerName, + long time, + List pastDialogue, + List input + ) { + try { + if ( + villager instanceof LivingEntity living && + player instanceof net.minecraft.world.entity.player.Player p + ) { + TiedUpModule.apply(input, living, p); + + TiedUpMod.LOGGER.debug( + "[MCA AI] Injected TiedUp context for {}", + living.getName().getString() + ); + } + } catch (Exception e) { + TiedUpMod.LOGGER.debug( + "[MCA AI] Failed to inject context: {}", + e.getMessage() + ); + } + } + + /** + * Transform the AI response with gagtalk if the villager is gagged. + * + *

Intercepts the return value and applies gagtalk transformation + * using TiedUp's GagTalkManager. + */ + @Inject( + method = "answer", + at = @At("RETURN"), + cancellable = true, + require = 0 + ) + private void tiedup$transformGaggedResponse( + Object player, // ServerPlayerEntity + Object villager, // VillagerEntityMCA + String msg, + CallbackInfoReturnable> cir + ) { + try { + Optional result = cir.getReturnValue(); + if (result == null || result.isEmpty()) return; + + if (!(villager instanceof LivingEntity living)) return; + + IBondageState state = MCACompat.getKidnappedState(living); + if (state == null || !state.isGagged()) return; + + // Apply gagtalk transformation + ItemStack gag = state.getEquipment(BodyRegionV2.MOUTH); + String gaggedResponse = GagTalkManager.transformToGaggedSpeech( + result.get(), + gag + ); + + TiedUpMod.LOGGER.debug("[MCA AI] Applied gagtalk to AI response"); + cir.setReturnValue(Optional.of(gaggedResponse)); + } catch (Exception e) { + TiedUpMod.LOGGER.debug( + "[MCA AI] Failed to transform gagged response: {}", + e.getMessage() + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/mixin/MixinMCAVillagerInteraction.java b/src/main/java/com/tiedup/remake/mixin/MixinMCAVillagerInteraction.java new file mode 100644 index 0000000..cc76847 --- /dev/null +++ b/src/main/java/com/tiedup/remake/mixin/MixinMCAVillagerInteraction.java @@ -0,0 +1,192 @@ +package com.tiedup.remake.mixin; + +import com.tiedup.remake.compat.mca.MCACompat; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.ItemKey; +import com.tiedup.remake.items.ItemMasterKey; +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.state.IBondageState; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** + * Mixin to manage TiedUp vs MCA interaction priority. + * + *

Handles TiedUp interactions DIRECTLY instead of just blocking MCA, + * because vanilla leash/item handling happens at different points in the + * interaction chain that MCA may bypass. + * + *

Priority rules: + *

    + *
  1. Empty hand + leashed → detach leash
  2. + *
  3. Holding lead + (tied OR collar owner) → attach leash
  4. + *
  5. Holding key + collared → call key.interactLivingEntity()
  6. + *
  7. Shift+click + tied + empty hand → let event handler untie
  8. + *
  9. Holding bondage item → let item handle tying
  10. + *
  11. Otherwise → let MCA handle
  12. + *
+ * + *

Uses @Pseudo - mixin is optional and will be skipped if MCA is not installed. + */ +@Pseudo +@Mixin(targets = "forge.net.mca.entity.VillagerEntityMCA", remap = false) +public abstract class MixinMCAVillagerInteraction { + + /** + * Inject at HEAD of mobInteract to handle TiedUp interactions. + * Method: InteractionResult mobInteract(Player, InteractionHand) + */ + @Inject( + method = "m_6071_", + at = @At("HEAD"), + cancellable = true, + remap = false + ) + private void tiedup$handleMobInteract( + Player player, + InteractionHand hand, + CallbackInfoReturnable cir + ) { + InteractionResult result = tiedup$handleInteraction(player, hand); + if (result != null) { + cir.setReturnValue(result); + } + } + + /** + * Inject at HEAD of interactAt to handle TiedUp interactions BEFORE MCA opens its menu. + * Method: InteractionResult interactAt(Player, Vec3, InteractionHand) + */ + @Inject( + method = "m_7111_", + at = @At("HEAD"), + cancellable = true, + remap = false + ) + private void tiedup$handleInteractAt( + Player player, + net.minecraft.world.phys.Vec3 vec, + InteractionHand hand, + CallbackInfoReturnable cir + ) { + InteractionResult result = tiedup$handleInteraction(player, hand); + if (result != null) { + cir.setReturnValue(result); + } + } + + /** + * Common interaction handling logic for both mobInteract and interactAt. + * + * @param player The player interacting + * @param hand The hand used for interaction + * @return InteractionResult to return, or null to let MCA handle + */ + @Unique + private InteractionResult tiedup$handleInteraction( + Player player, + InteractionHand hand + ) { + Mob mob = (Mob) (Object) this; + IBondageState state = MCACompat.getKidnappedState(mob); + ItemStack heldItem = player.getItemInHand(hand); + boolean isClientSide = player.level().isClientSide; + + // 1. LEASH DETACHMENT: Empty hand + leashed → drop leash + if (mob.isLeashed() && heldItem.isEmpty()) { + if (!isClientSide) { + mob.dropLeash(true, !player.getAbilities().instabuild); + TiedUpMod.LOGGER.debug("[MCA] Detached leash from villager"); + } + return InteractionResult.sidedSuccess(isClientSide); + } + + // 2. LEASH ATTACHMENT: Holding lead + (tied OR collar owner) + if (heldItem.is(Items.LEAD) && !mob.isLeashed()) { + if (tiedup$canLeash(player, state)) { + if (!isClientSide) { + mob.setLeashedTo(player, true); + if (!player.getAbilities().instabuild) { + heldItem.shrink(1); + } + TiedUpMod.LOGGER.debug("[MCA] Attached leash to villager"); + } + return InteractionResult.sidedSuccess(isClientSide); + } + } + + // 3. KEY INTERACTION: Key + collared → call key's interactLivingEntity + if (state != null && state.hasCollar()) { + if (heldItem.getItem() instanceof ItemKey key) { + return key.interactLivingEntity(heldItem, player, mob, hand); + } + if (heldItem.getItem() instanceof ItemMasterKey masterKey) { + return masterKey.interactLivingEntity( + heldItem, + player, + mob, + hand + ); + } + } + + // 4. UNTYING: Shift+click + tied + empty hand → let event handler process + if ( + state != null && + state.isTiedUp() && + heldItem.isEmpty() && + player.isShiftKeyDown() + ) { + return InteractionResult.PASS; + } + + // 5. BONDAGE ITEM: Let TiedUp item's interactLivingEntity handle tying + if (heldItem.getItem() instanceof IV2BondageItem) { + return InteractionResult.PASS; + } + + // Let MCA handle + return null; + } + + /** + * Check if a player can leash this MCA villager. + * + * @param player The player trying to leash + * @param state The villager's IBondageState state (may be null) + * @return true if leashing is allowed + */ + @Unique + private boolean tiedup$canLeash(Player player, IBondageState state) { + if (state == null) { + return false; + } + + // Can leash if villager is tied up + if (state.isTiedUp()) { + return true; + } + + // Can leash if player is a collar owner + if (state.hasCollar()) { + ItemStack collar = state.getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemCollar collarItem) { + return collarItem.getOwners(collar).contains(player.getUUID()); + } + } + + return false; + } +} diff --git a/src/main/java/com/tiedup/remake/mixin/MixinMCAVillagerLeash.java b/src/main/java/com/tiedup/remake/mixin/MixinMCAVillagerLeash.java new file mode 100644 index 0000000..79d3b7a --- /dev/null +++ b/src/main/java/com/tiedup/remake/mixin/MixinMCAVillagerLeash.java @@ -0,0 +1,84 @@ +package com.tiedup.remake.mixin; + +import com.tiedup.remake.compat.mca.MCACompat; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.state.IBondageState; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** + * Mixin to allow vanilla leash attachment to MCA villagers when tied or collar owner. + * + *

By default, MCA villagers cannot be leashed. This mixin overrides + * the canBeLeashed check to allow leashing when: + *

    + *
  • The villager is tied up (any bind), OR
  • + *
  • The player is a collar owner
  • + *
+ * + *

Uses @Pseudo annotation - mixin is optional and will be skipped if MCA is not installed. + */ +@Pseudo +@Mixin(targets = "forge.net.mca.entity.VillagerEntityMCA", remap = false) +public class MixinMCAVillagerLeash { + + /** + * Override canBeLeashed to allow TiedUp leash mechanics. + * + *

The canBeLeashed method is a vanilla method (remapped), so we use remap = true. + * + * @param player The player trying to leash + * @param cir Callback info for returning the result + */ + @Inject( + method = "canBeLeashed", + at = @At("HEAD"), + cancellable = true, + remap = true + ) + private void tiedup$overrideCanBeLeashed( + Player player, + CallbackInfoReturnable cir + ) { + LivingEntity entity = (LivingEntity) (Object) this; + IBondageState state = MCACompat.getKidnappedState(entity); + + // No TiedUp state - let vanilla/MCA handle it + if (state == null) { + return; + } + + // Already leashed - cannot leash again + if (entity instanceof Mob mob && mob.isLeashed()) { + cir.setReturnValue(false); + return; + } + + // Can be leashed if tied up + if (state.isTiedUp()) { + cir.setReturnValue(true); + return; + } + + // Can be leashed if player is collar owner + if (state.hasCollar()) { + ItemStack collar = state.getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemCollar collarItem) { + if (collarItem.getOwners(collar).contains(player.getUUID())) { + cir.setReturnValue(true); + return; + } + } + } + + // Default: let MCA handle it (usually returns false) + } +} diff --git a/src/main/java/com/tiedup/remake/mixin/MixinServerPlayer.java b/src/main/java/com/tiedup/remake/mixin/MixinServerPlayer.java new file mode 100644 index 0000000..2338bf1 --- /dev/null +++ b/src/main/java/com/tiedup/remake/mixin/MixinServerPlayer.java @@ -0,0 +1,473 @@ +package com.tiedup.remake.mixin; + +import com.tiedup.remake.entities.LeashProxyEntity; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.sync.PacketSyncLeashProxy; +import com.tiedup.remake.state.IPlayerLeashAccess; +import net.minecraft.network.protocol.game.ClientboundSetEntityMotionPacket; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.decoration.LeashFenceKnotEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +/** + * Mixin for ServerPlayer to add leash proxy functionality. + * + * This replaces the old EntityInvisibleSlaveTransporter mount-based system + * with a proxy-based system where: + * - The player does NOT ride an entity + * - A LeashProxyEntity follows the player and holds the leash + * - Traction is applied via push() when the player is too far from the holder + */ +@Mixin(ServerPlayer.class) +public abstract class MixinServerPlayer implements IPlayerLeashAccess { + + @Unique + private final ServerPlayer tiedup$self = (ServerPlayer) (Object) this; + + /** The proxy entity that follows this player and renders the leash */ + @Unique + private LeashProxyEntity tiedup$leashProxy; + + /** The entity holding this player's leash (master or fence knot) */ + @Unique + private Entity tiedup$leashHolder; + + /** Tick counter since last leash attachment (prevents immediate detach) */ + @Unique + private int tiedup$leashAge; + + /** Previous X position for stuck detection */ + @Unique + private double tiedup$prevX; + + /** Previous Z position for stuck detection */ + @Unique + private double tiedup$prevZ; + + /** Ticks spent stuck (not moving towards holder) */ + @Unique + private int tiedup$leashStuckCounter; + + /** Extra slack on leash - increases pull/max distances (for "pet leads" dogwalk) */ + @Unique + private double tiedup$leashSlack = 0.0; + + /** Tick counter for periodic leash proxy resync (for late-joining clients) */ + @Unique + private int tiedup$leashResyncTimer = 0; + + // ==================== Leash Constants ==================== + + /** Distance at which pull force starts (4 free blocks before any pull) */ + @Unique + private static final double LEASH_PULL_START_DISTANCE = 4.0; + + /** Maximum distance before instant teleport (6-block elastic zone) */ + @Unique + private static final double LEASH_MAX_DISTANCE = 10.0; + + /** Distance at which stuck detection activates (middle of elastic zone) */ + @Unique + private static final double LEASH_TELEPORT_DISTANCE = 7.0; + + /** Ticks of being stuck before safety teleport (2 seconds) */ + @Unique + private static final int LEASH_STUCK_THRESHOLD = 40; + + /** Maximum pull force cap */ + @Unique + private static final double LEASH_MAX_FORCE = 0.14; + + /** Force ramp per block beyond pull start */ + @Unique + private static final double LEASH_FORCE_RAMP = 0.04; + + /** Blend factor for pull vs momentum (0.6 = 60% pull, 40% momentum) */ + @Unique + private static final double LEASH_BLEND_FACTOR = 0.6; + + // ==================== IPlayerLeashAccess Implementation ==================== + + @Override + public void tiedup$attachLeash(Entity holder) { + if (holder == null) return; + + tiedup$leashHolder = holder; + + // Create proxy if not exists + if (tiedup$leashProxy == null) { + tiedup$leashProxy = new LeashProxyEntity(tiedup$self); + tiedup$leashProxy.setPos( + tiedup$self.getX(), + tiedup$self.getY(), + tiedup$self.getZ() + ); + tiedup$self.level().addFreshEntity(tiedup$leashProxy); + } + + // Attach leash from proxy to holder + tiedup$leashProxy.setLeashedTo(tiedup$leashHolder, true); + tiedup$leashAge = tiedup$self.tickCount; + tiedup$leashStuckCounter = 0; + tiedup$leashResyncTimer = 0; + + // Send sync packet to all tracking clients for smooth rendering + PacketSyncLeashProxy packet = PacketSyncLeashProxy.attach( + tiedup$self.getUUID(), + tiedup$leashProxy.getId() + ); + ModNetwork.sendToAllTrackingAndSelf(packet, tiedup$self); + } + + @Override + public void tiedup$detachLeash() { + tiedup$leashHolder = null; + + if (tiedup$leashProxy != null) { + if ( + tiedup$leashProxy.isAlive() && + !tiedup$leashProxy.proxyIsRemoved() + ) { + tiedup$leashProxy.proxyRemove(); + } + tiedup$leashProxy = null; + + // Send detach packet to all tracking clients + PacketSyncLeashProxy packet = PacketSyncLeashProxy.detach( + tiedup$self.getUUID() + ); + ModNetwork.sendToAllTrackingAndSelf(packet, tiedup$self); + } + } + + @Override + public void tiedup$dropLeash() { + // Don't drop if player is disconnected or dead (position may be invalid) + if (tiedup$self.hasDisconnected() || !tiedup$self.isAlive()) { + return; + } + tiedup$self.drop(new ItemStack(Items.LEAD), false, true); + } + + @Override + public boolean tiedup$isLeashed() { + return ( + tiedup$leashHolder != null && + tiedup$leashProxy != null && + !tiedup$leashProxy.proxyIsRemoved() + ); + } + + @Override + public Entity tiedup$getLeashHolder() { + return tiedup$leashHolder; + } + + @Override + public LeashProxyEntity tiedup$getLeashProxy() { + return tiedup$leashProxy; + } + + @Override + public void tiedup$setLeashSlack(double slack) { + this.tiedup$leashSlack = slack; + } + + @Override + public double tiedup$getLeashSlack() { + return this.tiedup$leashSlack; + } + + // ==================== Tick Update (called from Forge event) ==================== + + /** + * Update leash state and apply traction if needed. + * Called from LeashTickHandler via Forge TickEvent. + */ + @Override + public void tiedup$tickLeash() { + // Check if this player is still valid + if (!tiedup$self.isAlive() || tiedup$self.hasDisconnected()) { + tiedup$detachLeash(); + // Don't drop leash if we're disconnected (we won't be there to pick it up) + return; + } + + // Check if holder is still valid + if (tiedup$leashHolder != null) { + boolean holderInvalid = + !tiedup$leashHolder.isAlive() || tiedup$leashHolder.isRemoved(); + + // If holder is a player, also check if they disconnected + if ( + !holderInvalid && + tiedup$leashHolder instanceof ServerPlayer holderPlayer + ) { + holderInvalid = holderPlayer.hasDisconnected(); + } + + // If player is being used as vehicle, break leash + if (!holderInvalid && tiedup$self.isVehicle()) { + holderInvalid = true; + } + + if (holderInvalid) { + tiedup$detachLeash(); + tiedup$dropLeash(); + return; + } + } + + // Sync proxy state with actual leash holder + if (tiedup$leashProxy != null) { + if (tiedup$leashProxy.proxyIsRemoved()) { + tiedup$leashProxy = null; + } else { + Entity holderActual = tiedup$leashHolder; + Entity holderFromProxy = tiedup$leashProxy.getLeashHolder(); + + // Leash was broken externally (by another player) + if (holderFromProxy == null && holderActual != null) { + tiedup$detachLeash(); + tiedup$dropLeash(); + return; + } + // Holder changed (shouldn't happen normally) + else if (holderFromProxy != holderActual) { + tiedup$leashHolder = holderFromProxy; + } + } + } + + // Periodic resync for late-joining clients (every 5 seconds) + if (tiedup$leashProxy != null && ++tiedup$leashResyncTimer >= 100) { + tiedup$leashResyncTimer = 0; + PacketSyncLeashProxy packet = PacketSyncLeashProxy.attach( + tiedup$self.getUUID(), + tiedup$leashProxy.getId() + ); + ModNetwork.sendToAllTrackingAndSelf(packet, tiedup$self); + } + + // Apply traction force + tiedup$applyLeashPull(); + } + + /** + * Apply pull force towards the leash holder if player is too far. + * + * Uses normalized direction, progressive capped force, velocity blending, + * and conditional sync to prevent jitter, oscillation, and runaway velocity. + * Modeled after DamselAIController.tickLeashTraction(). + */ + @Unique + private void tiedup$applyLeashPull() { + if (tiedup$leashHolder == null) return; + + // Cross-dimension: detach leash cleanly instead of silently ignoring + if (tiedup$leashHolder.level() != tiedup$self.level()) { + tiedup$detachLeash(); + tiedup$dropLeash(); + return; + } + + float distance = tiedup$self.distanceTo(tiedup$leashHolder); + + // Apply slack to effective distances (for "pet leads" dogwalk) + double effectivePullStart = + LEASH_PULL_START_DISTANCE + tiedup$leashSlack; + double effectiveMaxDistance = LEASH_MAX_DISTANCE + tiedup$leashSlack; + double effectiveTeleportDist = + LEASH_TELEPORT_DISTANCE + tiedup$leashSlack; + + // Close enough: no pull needed, reset stuck counter + if (distance < effectivePullStart) { + tiedup$leashStuckCounter = 0; + return; + } + + // Too far: teleport to holder instead of breaking + if (distance > effectiveMaxDistance) { + tiedup$teleportToSafePositionNearHolder(); + tiedup$leashStuckCounter = 0; + return; + } + + // Direction to holder + double dx = tiedup$leashHolder.getX() - tiedup$self.getX(); + double dy = tiedup$leashHolder.getY() - tiedup$self.getY(); + double dz = tiedup$leashHolder.getZ() - tiedup$self.getZ(); + + // Normalized horizontal direction (replaces Math.signum bang-bang) + double horizontalDist = Math.sqrt(dx * dx + dz * dz); + double dirX = horizontalDist > 0.01 ? dx / horizontalDist : 0.0; + double dirZ = horizontalDist > 0.01 ? dz / horizontalDist : 0.0; + + // Calculate how much the player moved since last check + double movedX = tiedup$self.getX() - tiedup$prevX; + double movedZ = tiedup$self.getZ() - tiedup$prevZ; + double movedHorizontal = Math.sqrt(movedX * movedX + movedZ * movedZ); + + // Store current position for next stuck check + tiedup$prevX = tiedup$self.getX(); + tiedup$prevZ = tiedup$self.getZ(); + + // Stuck detection - slack-aware threshold + boolean isStuck = + distance > effectiveTeleportDist && + tiedup$self.getDeltaMovement().lengthSqr() < 0.001 && + movedHorizontal < 0.05; + + if (isStuck) { + tiedup$leashStuckCounter++; + + if (tiedup$leashStuckCounter >= LEASH_STUCK_THRESHOLD) { + tiedup$teleportToSafePositionNearHolder(); + tiedup$leashStuckCounter = 0; + return; + } + } else { + tiedup$leashStuckCounter = 0; + } + + // Progressive capped force: 0.04 per block beyond pull start, max 0.14 + double distanceBeyond = distance - effectivePullStart; + double forceFactor = Math.min( + LEASH_MAX_FORCE, + distanceBeyond * LEASH_FORCE_RAMP + ); + + // Fence knots are static, need 1.3x pull + if (tiedup$leashHolder instanceof LeashFenceKnotEntity) { + forceFactor *= 1.3; + } + + // Velocity blending: 60% pull direction + 40% existing momentum + net.minecraft.world.phys.Vec3 currentMotion = + tiedup$self.getDeltaMovement(); + double pullVelX = dirX * forceFactor * 3.0; + double pullVelZ = dirZ * forceFactor * 3.0; + double newVelX = + currentMotion.x * (1.0 - LEASH_BLEND_FACTOR) + + pullVelX * LEASH_BLEND_FACTOR; + double newVelZ = + currentMotion.z * (1.0 - LEASH_BLEND_FACTOR) + + pullVelZ * LEASH_BLEND_FACTOR; + + // Soft auto-step (replaces 0.42 vanilla jump velocity) + double newVelY = currentMotion.y; + if ( + tiedup$self.onGround() && + movedHorizontal < 0.1 && + distanceBeyond > 0.5 + ) { + if (dy > 0.3) { + newVelY += 0.08; // Holder is above: gentle upward boost + } else { + newVelY += 0.05; // Normal step-up + } + } else if (dy > 0.5 && !tiedup$self.onGround()) { + newVelY += 0.02; // Gentle aerial drift + } + + tiedup$self.setDeltaMovement(newVelX, newVelY, newVelZ); + + // Conditional velocity sync: only send packet when force is meaningful + if (forceFactor > 0.02 && tiedup$self.connection != null) { + tiedup$self.connection.send( + new ClientboundSetEntityMotionPacket(tiedup$self) + ); + // Only suppress impulse flag after we've actually synced to the client + tiedup$self.hasImpulse = false; + } + } + + /** + * Teleport player to a safe position near the leash holder. + * Used when player is stuck and can't path to holder. + */ + @Unique + private void tiedup$teleportToSafePositionNearHolder() { + if (tiedup$leashHolder == null) return; + + // Target: 2 blocks away from holder in player's direction + double dx = tiedup$self.getX() - tiedup$leashHolder.getX(); + double dz = tiedup$self.getZ() - tiedup$leashHolder.getZ(); + double dist = Math.sqrt(dx * dx + dz * dz); + + double offsetX = 0; + double offsetZ = 0; + if (dist > 0.1) { + offsetX = (dx / dist) * 2.0; + offsetZ = (dz / dist) * 2.0; + } + + double targetX = tiedup$leashHolder.getX() + offsetX; + double targetZ = tiedup$leashHolder.getZ() + offsetZ; + + // Find safe Y (ground level) + double targetY = tiedup$findSafeY( + targetX, + tiedup$leashHolder.getY(), + targetZ + ); + + tiedup$self.teleportTo(targetX, targetY, targetZ); + + // Sync position to client (null check for fake players) + if (tiedup$self.connection != null) { + tiedup$self.connection.send( + new ClientboundSetEntityMotionPacket(tiedup$self) + ); + } + } + + /** + * Find a safe Y coordinate for teleporting. + * + * @param x Target X coordinate + * @param startY Starting Y to search from + * @param z Target Z coordinate + * @return Safe Y coordinate on solid ground + */ + @Unique + private double tiedup$findSafeY(double x, double startY, double z) { + net.minecraft.core.BlockPos.MutableBlockPos mutable = + new net.minecraft.core.BlockPos.MutableBlockPos(); + + // Search down first (max 5 blocks) + for (int y = 0; y > -5; y--) { + mutable.set((int) x, (int) startY + y, (int) z); + if ( + tiedup$self + .level() + .getBlockState(mutable) + .isSolidRender(tiedup$self.level(), mutable) && + tiedup$self.level().getBlockState(mutable.above()).isAir() + ) { + return mutable.getY() + 1; + } + } + + // Search up (max 5 blocks) + for (int y = 1; y < 5; y++) { + mutable.set((int) x, (int) startY + y, (int) z); + if ( + tiedup$self + .level() + .getBlockState(mutable.below()) + .isSolidRender(tiedup$self.level(), mutable.below()) && + tiedup$self.level().getBlockState(mutable).isAir() + ) { + return mutable.getY(); + } + } + + // Fallback: use holder's Y + return startY; + } +} diff --git a/src/main/java/com/tiedup/remake/mixin/client/MixinCamera.java b/src/main/java/com/tiedup/remake/mixin/client/MixinCamera.java new file mode 100644 index 0000000..ee030c2 --- /dev/null +++ b/src/main/java/com/tiedup/remake/mixin/client/MixinCamera.java @@ -0,0 +1,81 @@ +package com.tiedup.remake.mixin.client; + +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.items.base.PoseType; +import com.tiedup.remake.state.HumanChairHelper; +import com.tiedup.remake.state.PlayerBindState; +import net.minecraft.client.Camera; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.phys.Vec3; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Mixin for Camera to lower first-person view when in DOG pose. + * + * In DOG pose, the player model is horizontal (like a dog), so the camera + * should be lowered to match the eye level being closer to the ground. + * Normal eye height is ~1.62 blocks, we lower it by ~0.6 blocks. + */ +@Mixin(Camera.class) +public abstract class MixinCamera { + + @Shadow + private Vec3 position; + + @Shadow + protected abstract void setPosition(Vec3 pos); + + @Inject(method = "setup", at = @At("TAIL")) + private void tiedup$lowerCameraForDogPose( + BlockGetter level, + Entity entity, + boolean detached, + boolean thirdPersonReverse, + float partialTick, + CallbackInfo ci + ) { + // Only affect first-person view + if (detached) { + return; + } + + if (!(entity instanceof Player player)) { + return; + } + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + return; + } + + ItemStack bind = state.getEquipment(BodyRegionV2.ARMS); + if (bind.isEmpty() || !(bind.getItem() instanceof ItemBind itemBind)) { + return; + } + + if (itemBind.getPoseType() != PoseType.DOG) { + return; + } + + // Lower camera by 0.6 blocks to match the horizontal body position + // Normal eye height is ~1.62, DOG pose should be around ~1.0 + setPosition(position.add(0, -0.6, 0)); + + // Human chair: move camera forward into the head + if (HumanChairHelper.isActive(bind)) { + float facing = HumanChairHelper.getFacing(bind); + float facingRad = (float) Math.toRadians(facing); + double fwdX = -Math.sin(facingRad) * 0.6; + double fwdZ = Math.cos(facingRad) * 0.6; + setPosition(position.add(fwdX, 0, fwdZ)); + } + } +} diff --git a/src/main/java/com/tiedup/remake/mixin/client/MixinLivingEntitySleeping.java b/src/main/java/com/tiedup/remake/mixin/client/MixinLivingEntitySleeping.java new file mode 100644 index 0000000..0f87422 --- /dev/null +++ b/src/main/java/com/tiedup/remake/mixin/client/MixinLivingEntitySleeping.java @@ -0,0 +1,30 @@ +package com.tiedup.remake.mixin.client; + +import com.tiedup.remake.client.state.PetBedClientState; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** + * Client-side mixin: prevent vanilla sleeping visuals (laying flat) for + * players on a pet bed in SLEEP mode. Server-side isSleeping() is unaffected, + * so night skip still works. + */ +@Mixin(LivingEntity.class) +public abstract class MixinLivingEntitySleeping { + + @Inject(method = "isSleeping", at = @At("HEAD"), cancellable = true) + private void tiedup$hidePetBedSleeping( + CallbackInfoReturnable cir + ) { + LivingEntity self = (LivingEntity) (Object) this; + if (self.level().isClientSide() && self instanceof Player player) { + if (PetBedClientState.get(player.getUUID()) == 2) { + cir.setReturnValue(false); + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/mixin/client/MixinMCAPlayerExtendedModel.java b/src/main/java/com/tiedup/remake/mixin/client/MixinMCAPlayerExtendedModel.java new file mode 100644 index 0000000..fa8ca9e --- /dev/null +++ b/src/main/java/com/tiedup/remake/mixin/client/MixinMCAPlayerExtendedModel.java @@ -0,0 +1,106 @@ +package com.tiedup.remake.mixin.client; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.tiedup.remake.compat.wildfire.WildfireCompat; +import net.minecraft.client.model.geom.ModelPart; +import net.minecraft.world.entity.LivingEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Mixin for MCA's PlayerEntityExtendedModel to hide breasts when Wildfire is loaded. + * + *

MCA adds breasts to players through PlayerEntityExtendedModel. When Wildfire + * is also installed, we disable MCA's breasts to avoid double-rendering (Wildfire + * renders its own breasts with physics). + * + *

Uses @Pseudo annotation - mixin is optional and will be skipped if MCA is not installed. + * + *

Target class: forge.net.mca.client.model.PlayerEntityExtendedModel + */ +@Pseudo +@Mixin( + targets = "forge.net.mca.client.model.PlayerEntityExtendedModel", + remap = false +) +public class MixinMCAPlayerExtendedModel { + + /** + * Shadow the breasts ModelPart to control visibility. + */ + @Shadow(remap = false) + public ModelPart breasts; + + /** + * Shadow the breastsWear ModelPart (overlay layer). + */ + @Shadow(remap = false) + public ModelPart breastsWear; + + /** + * Inject at the end of setAngles (m_6973_) to hide MCA breasts when Wildfire is loaded. + * + *

This runs after applyVillagerDimensions() which sets breast visibility. + */ + @Inject(method = "m_6973_", at = @At("TAIL"), remap = false) + private void tiedup$hideBreastsInSetAngles( + T entity, + float limbSwing, + float limbSwingAmount, + float ageInTicks, + float netHeadYaw, + float headPitch, + CallbackInfo ci + ) { + tiedup$hideBreastsIfWildfire(); + } + + /** + * Inject at the end of copyVisibility to prevent it from re-enabling breasts. + * + *

MCA's copyVisibility sets breasts.visible = model.body.visible. + */ + @Inject(method = "copyVisibility", at = @At("TAIL"), remap = false) + private void tiedup$hideBreastsAfterCopyVisibility(CallbackInfo ci) { + tiedup$hideBreastsIfWildfire(); + } + + /** + * Inject at the end of render to hide breasts after breastsWear visibility is set. + * + *

MCA's render() sets breastsWear.visible = jacket.visible before rendering. + */ + @Inject(method = "m_7695_", at = @At("HEAD"), remap = false) + private void tiedup$hideBreastsBeforeRender( + PoseStack matrices, + VertexConsumer vertices, + int light, + int overlay, + float red, + float green, + float blue, + float alpha, + CallbackInfo ci + ) { + tiedup$hideBreastsIfWildfire(); + } + + /** + * Helper method to hide both breast parts when Wildfire is loaded. + */ + private void tiedup$hideBreastsIfWildfire() { + if (WildfireCompat.isLoaded()) { + if (breasts != null) { + breasts.visible = false; + } + if (breastsWear != null) { + breastsWear.visible = false; + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/mixin/client/MixinMCASpeechManager.java b/src/main/java/com/tiedup/remake/mixin/client/MixinMCASpeechManager.java new file mode 100644 index 0000000..85c93b5 --- /dev/null +++ b/src/main/java/com/tiedup/remake/mixin/client/MixinMCASpeechManager.java @@ -0,0 +1,83 @@ +package com.tiedup.remake.mixin.client; + +import com.tiedup.remake.compat.mca.MCACompat; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.IBondageState; +import java.util.UUID; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Mixin for MCA's SpeechManager to block TTS when villager is gagged. + * + *

MCA's speech system works via TTS (Text-to-Speech) on the client side: + *

    + *
  1. Server sends VillagerMessage packet to client
  2. + *
  3. ClientInteractionManagerImpl.handleVillagerMessage() receives it
  4. + *
  5. SpeechManager.onChatMessage() is called
  6. + *
  7. TTS plays the sound
  8. + *
+ * + *

This mixin intercepts onChatMessage and cancels it if the villager is gagged. + * + *

Uses @Pseudo for soft dependency - only applies if MCA is present. + * CLIENT SIDE ONLY. + */ +@Pseudo +@Mixin(targets = "forge.net.mca.client.tts.SpeechManager", remap = false) +public class MixinMCASpeechManager { + + /** + * Inject at HEAD of onChatMessage to cancel TTS for gagged villagers. + * + *

MCA signature: void onChatMessage(Text text, UUID sender) + *

Note: Text = net.minecraft.network.chat.Component (Yarn mapping) + */ + @Inject( + method = "onChatMessage", + at = @At("HEAD"), + cancellable = true, + require = 0 + ) + private void tiedup$cancelGaggedSpeech( + Component text, + UUID sender, + CallbackInfo ci + ) { + try { + ClientLevel level = Minecraft.getInstance().level; + if (level == null) return; + + // Find entity by UUID in rendered entities + for (Entity entity : level.entitiesForRendering()) { + if ( + entity.getUUID().equals(sender) && + entity instanceof LivingEntity living + ) { + IBondageState state = MCACompat.getKidnappedState(living); + if (state != null && state.isGagged()) { + TiedUpMod.LOGGER.debug( + "[MCA] Blocked TTS for gagged villager: {}", + living.getName().getString() + ); + ci.cancel(); + } + break; + } + } + } catch (Exception e) { + TiedUpMod.LOGGER.debug( + "[MCA] TTS cancellation check failed: {}", + e.getMessage() + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/mixin/client/MixinPlayerModel.java b/src/main/java/com/tiedup/remake/mixin/client/MixinPlayerModel.java new file mode 100644 index 0000000..9c6e79c --- /dev/null +++ b/src/main/java/com/tiedup/remake/mixin/client/MixinPlayerModel.java @@ -0,0 +1,83 @@ +package com.tiedup.remake.mixin.client; + +import com.tiedup.remake.client.animation.render.DogPoseRenderHandler; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.client.animation.util.DogPoseHelper; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.items.base.PoseType; +import com.tiedup.remake.state.PlayerBindState; +import net.minecraft.client.model.PlayerModel; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Mixin for PlayerModel to handle DOG pose head adjustments. + * + * When in DOG pose (body horizontal): + * - Head pitch offset so player looks forward + * - Head yaw converted to zRot (roll) since yRot axis is sideways when body is horizontal + */ +@Mixin(PlayerModel.class) +public class MixinPlayerModel { + + @Inject(method = "setupAnim", at = @At("TAIL")) + private void tiedup$adjustDogPose( + LivingEntity entity, + float limbSwing, + float limbSwingAmount, + float ageInTicks, + float netHeadYaw, + float headPitch, + CallbackInfo ci + ) { + if (!(entity instanceof AbstractClientPlayer player)) { + return; + } + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + return; + } + + ItemStack bind = state.getEquipment(BodyRegionV2.ARMS); + if (bind.isEmpty() || !(bind.getItem() instanceof ItemBind itemBind)) { + return; + } + + if (itemBind.getPoseType() != PoseType.DOG) { + return; + } + + PlayerModel model = (PlayerModel) (Object) this; + + // === HEAD ROTATION FOR HORIZONTAL BODY === + // Body is at -90° pitch (horizontal, face down) + // We apply a rotation delta to the poseStack in PlayerArmHideEventHandler + // The head needs to compensate for this transformation + + float rotationDelta = DogPoseRenderHandler.getAppliedRotationDelta( + player.getId() + ); + boolean moving = DogPoseRenderHandler.isDogPoseMoving(player.getId()); + + // netHeadYaw is head relative to vanilla body (yHeadRot - yBodyRot) + // We rotated the model by rotationDelta, so compensate: + // effectiveHeadYaw = netHeadYaw + rotationDelta + float headYaw = netHeadYaw + rotationDelta; + + // Clamp based on movement state and apply head compensation + float maxYaw = moving ? 60f : 90f; + DogPoseHelper.applyHeadCompensationClamped( + model.head, + model.hat, + headPitch, + headYaw, + maxYaw + ); + } +} diff --git a/src/main/java/com/tiedup/remake/mixin/client/MixinVillagerEntityBaseModelMCA.java b/src/main/java/com/tiedup/remake/mixin/client/MixinVillagerEntityBaseModelMCA.java new file mode 100644 index 0000000..8842535 --- /dev/null +++ b/src/main/java/com/tiedup/remake/mixin/client/MixinVillagerEntityBaseModelMCA.java @@ -0,0 +1,198 @@ +package com.tiedup.remake.mixin.client; + +import com.tiedup.remake.client.animation.BondageAnimationManager; +import com.tiedup.remake.client.animation.StaticPoseApplier; +import com.tiedup.remake.client.animation.util.AnimationIdBuilder; +import com.tiedup.remake.compat.mca.MCACompat; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.items.base.PoseType; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import com.tiedup.remake.state.IBondageState; +import dev.kosmx.playerAnim.impl.IAnimatedPlayer; +import dev.kosmx.playerAnim.impl.animation.AnimationApplier; +import java.util.UUID; +import net.minecraft.client.model.HumanoidModel; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Mixin for MCA's VillagerEntityBaseModelMCA to apply tied poses. + * + *

This mixin injects at the end of setupAnim (m_6973_) to override MCA's default + * animations when a villager is tied up. Without this, the bondage render layer + * shows the tied pose but the underlying MCA model still shows normal walking/idle + * animations. + * + *

Uses @Pseudo annotation - mixin is optional and will be skipped if MCA is not installed. + * + *

Target class: net.mca.client.model.VillagerEntityBaseModelMCA + *

Target method: setupAnim (MCP name, remapped from Yarn by Architectury) + */ +@Pseudo +@Mixin( + targets = "forge.net.mca.client.model.VillagerEntityBaseModelMCA", + remap = false +) +public class MixinVillagerEntityBaseModelMCA { + + // Note: Tick tracking moved to MCAAnimationTickCache for cleanup on world unload + + /** + * Inject at the end of setupAnim to apply tied pose after MCA has set its animations. + * + *

This completely overrides arm/leg positions when the villager is tied up. + * + *

Method signature: void setupAnim(T entity, float limbSwing, float limbSwingAmount, float ageInTicks, float netHeadYaw, float headPitch) + *

Note: MCA uses Architectury which remaps Yarn's method names to Forge/MCP names + */ + @Inject(method = "m_6973_", at = @At("TAIL"), remap = false) + private void tiedup$applyTiedPose( + T villager, + float limbSwing, + float limbSwingAmount, + float ageInTicks, + float netHeadYaw, + float headPitch, + CallbackInfo ci + ) { + // Only process on client side + if (villager.level() == null || !villager.level().isClientSide()) { + return; + } + + // Check if MCA is loaded and this villager is tied + if (!MCACompat.isMCALoaded()) { + return; + } + + IBondageState state = MCACompat.getKidnappedState(villager); + if (state == null || !state.isTiedUp()) { + return; + } + + // Get pose info from bind item + ItemStack bind = state.getEquipment(BodyRegionV2.ARMS); + PoseType poseType = PoseType.STANDARD; + + if (bind.getItem() instanceof ItemBind itemBind) { + poseType = itemBind.getPoseType(); + } + + // Derive bound state from V2 regions, fallback to V1 bind mode NBT + boolean armsBound = V2EquipmentHelper.isRegionOccupied(villager, BodyRegionV2.ARMS); + boolean legsBound = V2EquipmentHelper.isRegionOccupied(villager, BodyRegionV2.LEGS); + + if (!armsBound && !legsBound && bind.getItem() instanceof ItemBind) { + armsBound = ItemBind.hasArmsBound(bind); + legsBound = ItemBind.hasLegsBound(bind); + } + + // MCA doesn't track struggling state - use false for now + // TODO: Add struggling support to MCA integration + boolean isStruggling = false; + + // Cast this mixin to HumanoidModel to apply pose + // MCA's VillagerEntityBaseModelMCA extends HumanoidModel + @SuppressWarnings("unchecked") + HumanoidModel model = (HumanoidModel) (Object) this; + + // Check if villager supports PlayerAnimator (via our mixin) + if (villager instanceof IAnimatedPlayer animated) { + // Build animation ID and play animation + String animId = AnimationIdBuilder.build( + poseType, + armsBound, + legsBound, + null, + isStruggling, + true + ); + BondageAnimationManager.playAnimation(villager, animId); + + // Tick the animation stack only once per game tick (not every render frame) + // ageInTicks increments by 1 each game tick, with fractional values between ticks + int currentTick = (int) ageInTicks; + UUID entityId = villager.getUUID(); + int lastTick = + com.tiedup.remake.client.animation.tick.MCAAnimationTickCache.getLastTick( + entityId + ); + + if (lastTick != currentTick) { + // New game tick - tick the animation + animated.getAnimationStack().tick(); + com.tiedup.remake.client.animation.tick.MCAAnimationTickCache.setLastTick( + entityId, + currentTick + ); + } + + // Apply animation transforms to model parts + AnimationApplier emote = animated.playerAnimator_getAnimation(); + if (emote != null && emote.isActive()) { + // Use correct PlayerAnimator part names (torso, not body) + emote.updatePart("head", model.head); + emote.updatePart("torso", model.body); + emote.updatePart("leftArm", model.leftArm); + emote.updatePart("rightArm", model.rightArm); + emote.updatePart("leftLeg", model.leftLeg); + emote.updatePart("rightLeg", model.rightLeg); + + // Force rotations using setRotation to ensure they're applied + model.rightArm.setRotation( + model.rightArm.xRot, + model.rightArm.yRot, + model.rightArm.zRot + ); + model.leftArm.setRotation( + model.leftArm.xRot, + model.leftArm.yRot, + model.leftArm.zRot + ); + model.rightLeg.setRotation( + model.rightLeg.xRot, + model.rightLeg.yRot, + model.rightLeg.zRot + ); + model.leftLeg.setRotation( + model.leftLeg.xRot, + model.leftLeg.yRot, + model.leftLeg.zRot + ); + model.body.setRotation( + model.body.xRot, + model.body.yRot, + model.body.zRot + ); + model.head.setRotation( + model.head.xRot, + model.head.yRot, + model.head.zRot + ); + } else { + // Fallback to static poses if animation not active + StaticPoseApplier.applyStaticPose( + model, + poseType, + armsBound, + legsBound + ); + } + } else { + // Fallback: entity doesn't support PlayerAnimator, use static poses + StaticPoseApplier.applyStaticPose(model, poseType, armsBound, legsBound); + } + + // Hide arms for WRAP/LATEX_SACK poses (like DamselModel does) + if (poseType == PoseType.WRAP || poseType == PoseType.LATEX_SACK) { + model.leftArm.visible = false; + model.rightArm.visible = false; + } + } +} diff --git a/src/main/java/com/tiedup/remake/mixin/client/MixinVillagerEntityMCAAnimated.java b/src/main/java/com/tiedup/remake/mixin/client/MixinVillagerEntityMCAAnimated.java new file mode 100644 index 0000000..c9f92ba --- /dev/null +++ b/src/main/java/com/tiedup/remake/mixin/client/MixinVillagerEntityMCAAnimated.java @@ -0,0 +1,121 @@ +package com.tiedup.remake.mixin.client; + +import dev.kosmx.playerAnim.api.layered.AnimationStack; +import dev.kosmx.playerAnim.api.layered.IAnimation; +import dev.kosmx.playerAnim.impl.IAnimatedPlayer; +import dev.kosmx.playerAnim.impl.animation.AnimationApplier; +import java.util.HashMap; +import java.util.Map; +import net.minecraft.resources.ResourceLocation; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Mixin to inject IAnimatedPlayer support into MCA villagers. + * + *

This allows MCA villagers to use PlayerAnimator animations for bondage poses, + * instead of just static pose rotations. + * + *

Uses @Pseudo annotation - mixin is optional and will be skipped if MCA is not installed. + * + *

Target class: net.mca.entity.VillagerEntityMCA + */ +@Pseudo +@Mixin(targets = "forge.net.mca.entity.VillagerEntityMCA", remap = false) +public abstract class MixinVillagerEntityMCAAnimated + implements IAnimatedPlayer +{ + + /** + * Animation stack for layered animations. + */ + @Unique + private AnimationStack tiedup$animationStack; + + /** + * Animation applier for applying animations to model parts. + */ + @Unique + private AnimationApplier tiedup$animationApplier; + + /** + * Storage for named animations. + */ + @Unique + private final Map tiedup$storedAnimations = + new HashMap<>(); + + /** + * Track if animation system has been initialized. + */ + @Unique + private boolean tiedup$animInitialized = false; + + /** + * Initialize animation system after entity construction. + */ + @Inject(method = "*", at = @At("RETURN"), remap = false) + private void tiedup$initAnimations(CallbackInfo ci) { + tiedup$ensureAnimationInit(); + } + + /** + * Lazy initialization of animation system. + * Called on first access to ensure system is ready. + * Only initializes on CLIENT side! + */ + @Unique + private void tiedup$ensureAnimationInit() { + if (!tiedup$animInitialized) { + // Only create animation stack on client side + net.minecraft.world.entity.LivingEntity self = + (net.minecraft.world.entity.LivingEntity) (Object) this; + if (self.level() == null || !self.level().isClientSide()) { + return; // Don't initialize on server + } + + this.tiedup$animationStack = new AnimationStack(); + this.tiedup$animationApplier = new AnimationApplier( + this.tiedup$animationStack + ); + tiedup$animInitialized = true; + } + } + + // ======================================== + // IAnimatedPlayer Implementation + // ======================================== + + @Override + public AnimationStack getAnimationStack() { + tiedup$ensureAnimationInit(); + return this.tiedup$animationStack; + } + + @Override + public AnimationApplier playerAnimator_getAnimation() { + tiedup$ensureAnimationInit(); + return this.tiedup$animationApplier; + } + + @Override + public IAnimation playerAnimator_getAnimation(ResourceLocation id) { + return this.tiedup$storedAnimations.get(id); + } + + @Override + public IAnimation playerAnimator_setAnimation( + ResourceLocation id, + IAnimation animation + ) { + if (animation == null) { + return this.tiedup$storedAnimations.remove(id); + } else { + return this.tiedup$storedAnimations.put(id, animation); + } + } +} diff --git a/src/main/java/com/tiedup/remake/network/ModNetwork.java b/src/main/java/com/tiedup/remake/network/ModNetwork.java new file mode 100644 index 0000000..4a38bc8 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/ModNetwork.java @@ -0,0 +1,280 @@ +package com.tiedup.remake.network; + +import com.tiedup.remake.compat.mca.network.PacketSyncMCABondage; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.action.PacketForceFeeding; +import com.tiedup.remake.network.action.PacketForceSeatModifier; +import com.tiedup.remake.network.action.PacketSetKnifeCutTarget; +import com.tiedup.remake.network.action.PacketStruggle; +import com.tiedup.remake.network.action.PacketTighten; +import com.tiedup.remake.network.action.PacketTying; +import com.tiedup.remake.network.action.PacketUntying; +import com.tiedup.remake.network.armorstand.PacketSyncArmorStandBondage; +import com.tiedup.remake.network.bounty.PacketDeleteBounty; +import com.tiedup.remake.network.bounty.PacketRequestBounties; +import com.tiedup.remake.network.bounty.PacketSendBounties; +import com.tiedup.remake.network.cell.PacketAssignCellToCollar; +import com.tiedup.remake.network.cell.PacketCellAction; +import com.tiedup.remake.network.cell.PacketCoreMenuAction; +import com.tiedup.remake.network.cell.PacketOpenCellManager; +import com.tiedup.remake.network.cell.PacketOpenCellSelector; +import com.tiedup.remake.network.cell.PacketOpenCoreMenu; +import com.tiedup.remake.network.cell.PacketRenameCell; +import com.tiedup.remake.network.cell.PacketRequestCellList; +import com.tiedup.remake.network.cell.PacketSyncCellData; +import com.tiedup.remake.network.conversation.PacketEndConversationC2S; +import com.tiedup.remake.network.conversation.PacketEndConversationS2C; +import com.tiedup.remake.network.conversation.PacketOpenConversation; +import com.tiedup.remake.network.conversation.PacketRequestConversation; +import com.tiedup.remake.network.conversation.PacketSelectTopic; +import com.tiedup.remake.network.item.PacketAdjustItem; +import com.tiedup.remake.network.item.PacketAdjustRemote; +import com.tiedup.remake.network.labor.PacketSyncLaborProgress; +import com.tiedup.remake.network.master.PacketMasterStateSync; +import com.tiedup.remake.network.master.PacketOpenPetRequestMenu; +import com.tiedup.remake.network.master.PacketPetRequest; +import com.tiedup.remake.network.merchant.PacketCloseMerchantScreen; +import com.tiedup.remake.network.merchant.PacketOpenMerchantScreen; +import com.tiedup.remake.network.merchant.PacketPurchaseTrade; +import com.tiedup.remake.network.minigame.PacketContinuousStruggleHold; +import com.tiedup.remake.network.minigame.PacketContinuousStruggleState; +import com.tiedup.remake.network.minigame.PacketContinuousStruggleStop; +import com.tiedup.remake.network.minigame.PacketLockpickAttempt; +import com.tiedup.remake.network.minigame.PacketLockpickMiniGameMove; +import com.tiedup.remake.network.minigame.PacketLockpickMiniGameResult; +import com.tiedup.remake.network.minigame.PacketLockpickMiniGameStart; +import com.tiedup.remake.network.minigame.PacketLockpickMiniGameState; +import com.tiedup.remake.network.personality.PacketDisciplineAction; +import com.tiedup.remake.network.personality.PacketNpcCommand; +import com.tiedup.remake.network.personality.PacketOpenCommandWandScreen; +import com.tiedup.remake.network.personality.PacketRequestNpcInventory; +import com.tiedup.remake.network.personality.PacketSlaveBeingFreed; +import com.tiedup.remake.network.selfbondage.PacketSelfBondage; +import com.tiedup.remake.network.slave.PacketMasterEquip; +import com.tiedup.remake.network.slave.PacketSlaveAction; +import com.tiedup.remake.network.slave.PacketSlaveItemManage; +import com.tiedup.remake.network.sync.PacketPlayTestAnimation; +import com.tiedup.remake.network.sync.PacketSyncBindState; +import com.tiedup.remake.network.sync.PacketSyncClothesConfig; +import com.tiedup.remake.network.sync.PacketSyncCollarRegistry; +import com.tiedup.remake.network.sync.PacketSyncEnslavement; +import com.tiedup.remake.network.sync.PacketSyncLeashProxy; +import com.tiedup.remake.network.sync.PacketSyncMovementStyle; +import com.tiedup.remake.network.sync.PacketSyncPetBedState; +import com.tiedup.remake.network.sync.PacketSyncStruggleState; +import com.tiedup.remake.network.trader.PacketBuyCaptive; +import com.tiedup.remake.network.trader.PacketOpenTraderScreen; +import com.tiedup.remake.v2.bondage.network.PacketSyncV2Equipment; +import com.tiedup.remake.v2.bondage.network.PacketV2LockToggle; +import com.tiedup.remake.v2.bondage.network.PacketV2SelfEquip; +import com.tiedup.remake.v2.bondage.network.PacketV2SelfLock; +import com.tiedup.remake.v2.bondage.network.PacketV2SelfRemove; +import com.tiedup.remake.v2.bondage.network.PacketV2SelfUnlock; +import com.tiedup.remake.v2.bondage.network.PacketV2StruggleStart; +import com.tiedup.remake.v2.furniture.network.PacketFurnitureEscape; +import com.tiedup.remake.v2.furniture.network.PacketFurnitureForcemount; +import com.tiedup.remake.v2.furniture.network.PacketFurnitureLock; +import com.tiedup.remake.v2.furniture.network.PacketSyncFurnitureDefinitions; +import com.tiedup.remake.v2.furniture.network.PacketSyncFurnitureState; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.network.NetworkEvent; +import net.minecraftforge.network.NetworkRegistry; +import net.minecraftforge.network.PacketDistributor; +import net.minecraftforge.network.simple.SimpleChannel; + +/** + * Network handler for TiedUp mod. + * Manages packet registration and sending. + */ +public class ModNetwork { + + private static final String PROTOCOL_VERSION = "1"; + + public static final SimpleChannel CHANNEL = + NetworkRegistry.newSimpleChannel( + ResourceLocation.fromNamespaceAndPath(TiedUpMod.MOD_ID, "main"), + () -> PROTOCOL_VERSION, + PROTOCOL_VERSION::equals, + PROTOCOL_VERSION::equals + ); + + private static int packetId = 0; + + /** + * Register all packets. Called during mod initialization. + * Order matters — packet IDs are assigned sequentially. + */ + public static void register() { + // Sync (S2C) + reg(PacketSyncBindState.class, PacketSyncBindState::encode, PacketSyncBindState::decode, PacketSyncBindState::handle); + reg(PacketSyncStruggleState.class, PacketSyncStruggleState::encode, PacketSyncStruggleState::decode, PacketSyncStruggleState::handle); + reg(PacketSyncEnslavement.class, PacketSyncEnslavement::encode, PacketSyncEnslavement::decode, PacketSyncEnslavement::handle); + reg(PacketSyncLeashProxy.class, PacketSyncLeashProxy::encode, PacketSyncLeashProxy::decode, PacketSyncLeashProxy::handle); + reg(PacketSyncCollarRegistry.class, PacketSyncCollarRegistry::encode, PacketSyncCollarRegistry::decode, PacketSyncCollarRegistry::handle); + reg(PacketSyncClothesConfig.class, PacketSyncClothesConfig::encode, PacketSyncClothesConfig::decode, PacketSyncClothesConfig::handle); + reg(PacketSyncPetBedState.class, PacketSyncPetBedState::encode, PacketSyncPetBedState::decode, PacketSyncPetBedState::handle); + reg(PacketSyncCellData.class, PacketSyncCellData::encode, PacketSyncCellData::decode, PacketSyncCellData::handle); + reg(PacketSyncLaborProgress.class, PacketSyncLaborProgress::encode, PacketSyncLaborProgress::decode, PacketSyncLaborProgress::handle); + reg(PacketSyncArmorStandBondage.class, PacketSyncArmorStandBondage::encode, PacketSyncArmorStandBondage::decode, PacketSyncArmorStandBondage::handle); + reg(PacketPlayTestAnimation.class, PacketPlayTestAnimation::encode, PacketPlayTestAnimation::decode, PacketPlayTestAnimation::handle); + reg(PacketSyncMCABondage.class, PacketSyncMCABondage::encode, PacketSyncMCABondage::decode, PacketSyncMCABondage::handle); + + // Actions (bidirectional) + reg(PacketTying.class, PacketTying::encode, PacketTying::decode, PacketTying::handle); + reg(PacketUntying.class, PacketUntying::encode, PacketUntying::decode, PacketUntying::handle); + reg(PacketForceFeeding.class, PacketForceFeeding::encode, PacketForceFeeding::decode, PacketForceFeeding::handle); + reg(PacketStruggle.class, PacketStruggle::encode, PacketStruggle::decode, PacketStruggle::handle); + reg(PacketTighten.class, PacketTighten::encode, PacketTighten::decode, PacketTighten::handle); + reg(PacketSetKnifeCutTarget.class, PacketSetKnifeCutTarget::encode, PacketSetKnifeCutTarget::decode, PacketSetKnifeCutTarget::handle); + reg(PacketSelfBondage.class, PacketSelfBondage::encode, PacketSelfBondage::decode, PacketSelfBondage::handle); + reg(PacketForceSeatModifier.class, PacketForceSeatModifier::encode, PacketForceSeatModifier::decode, PacketForceSeatModifier::handle); + + // Items (C2S) + reg(PacketAdjustItem.class, PacketAdjustItem::encode, PacketAdjustItem::decode, PacketAdjustItem::handle); + reg(PacketAdjustRemote.class, PacketAdjustRemote::encode, PacketAdjustRemote::decode, PacketAdjustRemote::handle); + + // Slave management + reg(PacketSlaveAction.class, PacketSlaveAction::encode, PacketSlaveAction::decode, PacketSlaveAction::handle); + reg(PacketSlaveItemManage.class, PacketSlaveItemManage::encode, PacketSlaveItemManage::decode, PacketSlaveItemManage::handle); + reg(PacketSlaveBeingFreed.class, PacketSlaveBeingFreed::encode, PacketSlaveBeingFreed::decode, PacketSlaveBeingFreed::handle); + + // NPC commands + reg(PacketNpcCommand.class, PacketNpcCommand::encode, PacketNpcCommand::decode, PacketNpcCommand::handle); + reg(PacketOpenCommandWandScreen.class, PacketOpenCommandWandScreen::encode, PacketOpenCommandWandScreen::decode, PacketOpenCommandWandScreen::handle); + reg(PacketRequestNpcInventory.class, PacketRequestNpcInventory::encode, PacketRequestNpcInventory::decode, PacketRequestNpcInventory::handle); + reg(PacketDisciplineAction.class, PacketDisciplineAction::encode, PacketDisciplineAction::decode, PacketDisciplineAction::handle); + + // Bounty + reg(PacketRequestBounties.class, PacketRequestBounties::encode, PacketRequestBounties::decode, PacketRequestBounties::handle); + reg(PacketSendBounties.class, PacketSendBounties::encode, PacketSendBounties::decode, PacketSendBounties::handle); + reg(PacketDeleteBounty.class, PacketDeleteBounty::encode, PacketDeleteBounty::decode, PacketDeleteBounty::handle); + + // Struggle mini-game + reg(PacketContinuousStruggleState.class, PacketContinuousStruggleState::encode, PacketContinuousStruggleState::decode, PacketContinuousStruggleState::handle); + reg(PacketContinuousStruggleHold.class, PacketContinuousStruggleHold::encode, PacketContinuousStruggleHold::decode, PacketContinuousStruggleHold::handle); + reg(PacketContinuousStruggleStop.class, PacketContinuousStruggleStop::encode, PacketContinuousStruggleStop::decode, PacketContinuousStruggleStop::handle); + + // Lockpick mini-game + reg(PacketLockpickMiniGameStart.class, PacketLockpickMiniGameStart::encode, PacketLockpickMiniGameStart::decode, PacketLockpickMiniGameStart::handle); + reg(PacketLockpickMiniGameState.class, PacketLockpickMiniGameState::encode, PacketLockpickMiniGameState::decode, PacketLockpickMiniGameState::handle); + reg(PacketLockpickMiniGameMove.class, PacketLockpickMiniGameMove::encode, PacketLockpickMiniGameMove::decode, PacketLockpickMiniGameMove::handle); + reg(PacketLockpickAttempt.class, PacketLockpickAttempt::encode, PacketLockpickAttempt::decode, PacketLockpickAttempt::handle); + reg(PacketLockpickMiniGameResult.class, PacketLockpickMiniGameResult::encode, PacketLockpickMiniGameResult::decode, PacketLockpickMiniGameResult::handle); + + // Merchant trading + reg(PacketOpenMerchantScreen.class, PacketOpenMerchantScreen::encode, PacketOpenMerchantScreen::decode, PacketOpenMerchantScreen::handle); + reg(PacketPurchaseTrade.class, PacketPurchaseTrade::encode, PacketPurchaseTrade::decode, PacketPurchaseTrade::handle); + reg(PacketCloseMerchantScreen.class, PacketCloseMerchantScreen::encode, PacketCloseMerchantScreen::decode, PacketCloseMerchantScreen::handle); + + // Slave trader + reg(PacketOpenTraderScreen.class, PacketOpenTraderScreen::encode, PacketOpenTraderScreen::decode, PacketOpenTraderScreen::handle); + reg(PacketBuyCaptive.class, PacketBuyCaptive::encode, PacketBuyCaptive::decode, PacketBuyCaptive::handle); + + // Cell management + reg(PacketOpenCellManager.class, PacketOpenCellManager::encode, PacketOpenCellManager::decode, PacketOpenCellManager::handle); + reg(PacketCellAction.class, PacketCellAction::encode, PacketCellAction::decode, PacketCellAction::handle); + reg(PacketRenameCell.class, PacketRenameCell::encode, PacketRenameCell::decode, PacketRenameCell::handle); + reg(PacketAssignCellToCollar.class, PacketAssignCellToCollar::encode, PacketAssignCellToCollar::decode, PacketAssignCellToCollar::handle); + reg(PacketRequestCellList.class, PacketRequestCellList::encode, PacketRequestCellList::decode, PacketRequestCellList::handle); + reg(PacketOpenCellSelector.class, PacketOpenCellSelector::encode, PacketOpenCellSelector::decode, PacketOpenCellSelector::handle); + reg(PacketOpenCoreMenu.class, PacketOpenCoreMenu::encode, PacketOpenCoreMenu::decode, (msg, ctx) -> msg.handle(ctx)); + reg(PacketCoreMenuAction.class, PacketCoreMenuAction::encode, PacketCoreMenuAction::decode, PacketCoreMenuAction::handle); + + // Conversation + reg(PacketOpenConversation.class, PacketOpenConversation::encode, PacketOpenConversation::decode, PacketOpenConversation::handle); + reg(PacketSelectTopic.class, PacketSelectTopic::encode, PacketSelectTopic::decode, PacketSelectTopic::handle); + reg(PacketEndConversationC2S.class, PacketEndConversationC2S::encode, PacketEndConversationC2S::decode, PacketEndConversationC2S::handle); + reg(PacketEndConversationS2C.class, PacketEndConversationS2C::encode, PacketEndConversationS2C::decode, PacketEndConversationS2C::handle); + reg(PacketRequestConversation.class, PacketRequestConversation::encode, PacketRequestConversation::decode, PacketRequestConversation::handle); + + // Master / pet + reg(PacketMasterStateSync.class, PacketMasterStateSync::encode, PacketMasterStateSync::decode, PacketMasterStateSync::handle); + reg(PacketOpenPetRequestMenu.class, PacketOpenPetRequestMenu::encode, PacketOpenPetRequestMenu::decode, PacketOpenPetRequestMenu::handle); + reg(PacketPetRequest.class, PacketPetRequest::encode, PacketPetRequest::decode, PacketPetRequest::handle); + + // V2 bondage equipment + reg(PacketSyncV2Equipment.class, PacketSyncV2Equipment::encode, PacketSyncV2Equipment::decode, PacketSyncV2Equipment::handle); + reg(PacketV2SelfRemove.class, PacketV2SelfRemove::encode, PacketV2SelfRemove::decode, PacketV2SelfRemove::handle); + reg(PacketV2StruggleStart.class, PacketV2StruggleStart::encode, PacketV2StruggleStart::decode, PacketV2StruggleStart::handle); + reg(PacketV2LockToggle.class, PacketV2LockToggle::encode, PacketV2LockToggle::decode, PacketV2LockToggle::handle); + reg(PacketV2SelfEquip.class, PacketV2SelfEquip::encode, PacketV2SelfEquip::decode, PacketV2SelfEquip::handle); + reg(PacketV2SelfLock.class, PacketV2SelfLock::encode, PacketV2SelfLock::decode, PacketV2SelfLock::handle); + reg(PacketV2SelfUnlock.class, PacketV2SelfUnlock::encode, PacketV2SelfUnlock::decode, PacketV2SelfUnlock::handle); + reg(PacketMasterEquip.class, PacketMasterEquip::encode, PacketMasterEquip::decode, PacketMasterEquip::handle); + + // Furniture + reg(PacketSyncFurnitureState.class, PacketSyncFurnitureState::encode, PacketSyncFurnitureState::decode, PacketSyncFurnitureState::handle); + reg(PacketSyncFurnitureDefinitions.class, PacketSyncFurnitureDefinitions::encode, PacketSyncFurnitureDefinitions::decode, PacketSyncFurnitureDefinitions::handle); + reg(PacketFurnitureLock.class, PacketFurnitureLock::encode, PacketFurnitureLock::decode, PacketFurnitureLock::handle); + reg(PacketFurnitureForcemount.class, PacketFurnitureForcemount::encode, PacketFurnitureForcemount::decode, PacketFurnitureForcemount::handle); + reg(PacketFurnitureEscape.class, PacketFurnitureEscape::encode, PacketFurnitureEscape::decode, PacketFurnitureEscape::handle); + + // Movement style + reg(PacketSyncMovementStyle.class, PacketSyncMovementStyle::encode, PacketSyncMovementStyle::decode, PacketSyncMovementStyle::handle); + + TiedUpMod.LOGGER.info("Registered {} network packets", packetId); + } + + private static void reg( + Class clazz, + BiConsumer encoder, + Function decoder, + BiConsumer> handler + ) { + CHANNEL.registerMessage(nextId(), clazz, encoder, decoder, handler); + } + + private static int nextId() { + return packetId++; + } + + public static void sendToPlayer(Object packet, ServerPlayer player) { + CHANNEL.send(PacketDistributor.PLAYER.with(() -> player), packet); + } + + public static void sendToAllTracking(Object packet, ServerPlayer player) { + CHANNEL.send( + PacketDistributor.TRACKING_ENTITY.with(() -> player), + packet + ); + } + + public static void sendToAllTrackingEntity( + Object packet, + net.minecraft.world.entity.Entity entity + ) { + CHANNEL.send( + PacketDistributor.TRACKING_ENTITY.with(() -> entity), + packet + ); + } + + public static void sendToAllTrackingAndSelf( + Object packet, + ServerPlayer player + ) { + CHANNEL.send( + PacketDistributor.TRACKING_ENTITY_AND_SELF.with(() -> player), + packet + ); + } + + public static void sendToServer(Object packet) { + CHANNEL.sendToServer(packet); + } + + public static void sendToTracking( + Object packet, + net.minecraft.world.entity.Entity entity + ) { + CHANNEL.send( + PacketDistributor.TRACKING_ENTITY.with(() -> entity), + packet + ); + } +} diff --git a/src/main/java/com/tiedup/remake/network/NetworkEventHandler.java b/src/main/java/com/tiedup/remake/network/NetworkEventHandler.java new file mode 100644 index 0000000..659d1fa --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/NetworkEventHandler.java @@ -0,0 +1,444 @@ +package com.tiedup.remake.network; + +import com.tiedup.remake.compat.mca.MCABondageManager; +import com.tiedup.remake.compat.mca.MCACompat; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.sync.PacketSyncBindState; +import com.tiedup.remake.network.sync.PacketSyncCollarRegistry; +import com.tiedup.remake.network.sync.PacketSyncEnslavement; +import com.tiedup.remake.network.sync.PacketSyncStruggleState; +import com.tiedup.remake.network.sync.SyncManager; +import com.tiedup.remake.v2.furniture.EntityFurniture; +import com.tiedup.remake.v2.furniture.ISeatProvider; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.protocol.game.ClientboundSetPassengersPacket; +import net.minecraft.network.protocol.game.ClientboundTeleportEntityPacket; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; +import net.minecraftforge.event.entity.player.PlayerEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; +import org.jetbrains.annotations.Nullable; + +/** + * Handles network synchronization for bondage equipment in multiplayer. + * + *

Sync Events

+ *
    + *
  • onStartTracking: When player A enters player B's view range, + * sync B's bondage inventory to A so they see the correct model layers.
  • + *
  • onPlayerLoggedIn: Sync all players' states to the newly joined player, + * and sync the new player's state to everyone else.
  • + *
+ * + *

Position Sync (MC-262715 Fix)

+ * When a player's state changes (freed from leash, etc.), other clients may have + * stale data. We send delayed position/passenger packets to correct this. + * + * @see SyncManager + * @see PacketSyncBindState + */ +@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID) +public class NetworkEventHandler { + + /** + * Called when a player starts tracking an entity (another player enters their view range). + * We sync the tracked player's bondage inventory to the tracker. + * + * CRITICAL FIX: Also sync riding state and position to fix MC-262715 desync bug. + * When a tracker reconnects, they may have stale data about the tracked player's + * riding status, causing the player to appear frozen. + */ + @SubscribeEvent + public static void onStartTracking(PlayerEvent.StartTracking event) { + if (!(event.getEntity() instanceof ServerPlayer tracker)) return; + + // Handle MCA villagers - sync their bondage state to the tracker + if ( + event.getTarget() instanceof LivingEntity target && + MCACompat.isMCALoaded() && + MCACompat.isMCAVillager(target) + ) { + syncMCAVillagerToTracker(target, tracker); + return; + } + + // Handle players + if (!(event.getTarget() instanceof Player trackedPlayer)) return; + + // Sync tracked player's V2 equipment to the tracker (so they see the bondage layers) + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.syncTo( + trackedPlayer, tracker + ); + + // Also sync state flags + PacketSyncBindState statePacket = PacketSyncBindState.fromPlayer( + trackedPlayer + ); + if (statePacket != null) { + ModNetwork.sendToPlayer(statePacket, tracker); + } + + // Sync struggle state (needed for animations) + PacketSyncStruggleState strugglePacket = + PacketSyncStruggleState.fromPlayer(trackedPlayer); + if (strugglePacket != null) { + ModNetwork.sendToPlayer(strugglePacket, tracker); + } + + // CRITICAL FIX: Sync enslavement state (needed for leash visibility) + PacketSyncEnslavement enslavementPacket = + PacketSyncEnslavement.fromPlayer(trackedPlayer); + if (enslavementPacket != null) { + ModNetwork.sendToPlayer(enslavementPacket, tracker); + } + + // FIX MC-262715: Explicitly sync riding state and position + // This fixes the "frozen player" bug when tracker reconnects after + // the tracked player was freed from a vehicle + if (trackedPlayer instanceof ServerPlayer trackedServerPlayer) { + syncRidingStateAndPosition(trackedServerPlayer, tracker); + } + } + + /** + * Sync MCA villager's bondage state to a specific tracker. + * Called when a player starts tracking an MCA villager. + * Delegates to MCABondageManager. + */ + private static void syncMCAVillagerToTracker( + LivingEntity villager, + ServerPlayer tracker + ) { + MCABondageManager.getInstance().syncBondageStateTo(villager, tracker); + } + + /** Delay before sending position sync (in ticks) - allows entity spawn to complete */ + private static final int POSITION_SYNC_DELAY = 5; + + /** + * Sync the riding state and position of a player to a specific tracker. + * + * This handles edge cases where the tracker has stale data about the tracked player's + * riding status (e.g., tracker reconnects after tracked player was freed from transport). + * + * The delay ensures the entity spawn packet is processed before we send corrections. + */ + private static void syncRidingStateAndPosition( + ServerPlayer trackedPlayer, + ServerPlayer tracker + ) { + var server = tracker.getServer(); + if (server == null) return; + + server.tell( + new net.minecraft.server.TickTask( + server.getTickCount() + POSITION_SYNC_DELAY, + () -> { + if (!trackedPlayer.isAlive() || !tracker.isAlive()) return; + if ( + trackedPlayer.isRemoved() || tracker.isRemoved() + ) return; + + var vehicle = trackedPlayer.getVehicle(); + boolean hasValidVehicle = + vehicle != null && + vehicle.isAlive() && + !vehicle.isRemoved(); + + if (!trackedPlayer.isPassenger() || !hasValidVehicle) { + // Not riding - send position to clear stale riding state on client + tracker.connection.send( + new ClientboundTeleportEntityPacket(trackedPlayer) + ); + + // Fix orphaned passenger state + if (trackedPlayer.isPassenger() && !hasValidVehicle) { + trackedPlayer.stopRiding(); + } + } else { + // Riding valid vehicle - sync passenger relationship + tracker.connection.send( + new ClientboundSetPassengersPacket(vehicle) + ); + } + } + ) + ); + } + + /** + * Called when a player logs in. + * We sync: + * 1. The player's own inventory to themselves + * 2. The player's inventory to all other players (so they see the new player) + * 3. All other players' inventories to the new player (so they see everyone) + * 4. The player's collar registry (Phase 17) + */ + @SubscribeEvent + public static void onPlayerLoggedIn(PlayerEvent.PlayerLoggedInEvent event) { + if (!(event.getEntity() instanceof ServerPlayer player)) return; + + // Sync this player's state to all others, and all others' states to this player + SyncManager.syncAll(player); + SyncManager.syncAllPlayersTo(player); + + // Phase 17: Sync collar registry to this player + syncCollarRegistry(player); + + // Sync furniture definitions + com.tiedup.remake.v2.furniture.network.PacketSyncFurnitureDefinitions.sendToPlayer(player); + + // Check for furniture reconnection (player was locked in a seat and disconnected) + handleFurnitureReconnection(player); + + TiedUpMod.LOGGER.debug( + "[Network] Player {} logged in - sync complete", + player.getName().getString() + ); + } + + /** + * Sync the collar registry to a player. + * Sends all slaves (collar wearers) owned by this player. + */ + private static void syncCollarRegistry(ServerPlayer player) { + var server = player.getServer(); + if (server == null) return; + + var registry = com.tiedup.remake.state.CollarRegistry.get(server); + if (registry == null) return; + + java.util.Set slaves = registry.getSlaves( + player.getUUID() + ); + ModNetwork.sendToPlayer(new PacketSyncCollarRegistry(slaves), player); + + TiedUpMod.LOGGER.debug( + "[Network] Synced {} slaves to {}", + slaves.size(), + player.getName().getString() + ); + } + + /** + * Handle furniture reconnection for a player who was locked in a seat when + * they disconnected. Reads the {@code tiedup_locked_furniture} tag from + * persistent data, finds the furniture entity, and re-mounts the player. + * + *

The re-mount is deferred by a few ticks to ensure the player's entity + * is fully spawned and the furniture's chunk is loaded.

+ * + * @param player the player who just logged in + */ + private static void handleFurnitureReconnection(ServerPlayer player) { + CompoundTag persistentData = player.getPersistentData(); + if (!persistentData.contains("tiedup_locked_furniture", 10)) return; + + CompoundTag tag = persistentData.getCompound("tiedup_locked_furniture"); + + // Read stored data + if (!tag.contains("x") || !tag.contains("y") || !tag.contains("z") + || !tag.contains("dim") || !tag.contains("furniture_uuid") + || !tag.contains("seat_id")) { + TiedUpMod.LOGGER.warn( + "[Network] Malformed furniture reconnection tag for {}, removing", + player.getName().getString() + ); + persistentData.remove("tiedup_locked_furniture"); + return; + } + + int x = tag.getInt("x"); + int y = tag.getInt("y"); + int z = tag.getInt("z"); + String dimStr = tag.getString("dim"); + String furnitureUuidStr = tag.getString("furniture_uuid"); + String seatId = tag.getString("seat_id"); + + // Validate furniture UUID + UUID furnitureUuid; + try { + furnitureUuid = UUID.fromString(furnitureUuidStr); + } catch (IllegalArgumentException e) { + TiedUpMod.LOGGER.warn( + "[Network] Invalid furniture UUID '{}' in reconnection tag for {}, removing", + furnitureUuidStr, player.getName().getString() + ); + persistentData.remove("tiedup_locked_furniture"); + return; + } + + // Resolve the dimension + ResourceKey dimKey = ResourceKey.create( + net.minecraft.core.registries.Registries.DIMENSION, + new ResourceLocation(dimStr) + ); + + var server = player.getServer(); + if (server == null) { + persistentData.remove("tiedup_locked_furniture"); + return; + } + + ServerLevel targetLevel = server.getLevel(dimKey); + if (targetLevel == null) { + TiedUpMod.LOGGER.warn( + "[Network] Dimension '{}' not found for furniture reconnection, removing tag", + dimStr + ); + persistentData.remove("tiedup_locked_furniture"); + return; + } + + // Defer the re-mount to ensure the player entity is fully spawned + BlockPos furniturePos = new BlockPos(x, y, z); + + server.tell( + new net.minecraft.server.TickTask( + server.getTickCount() + FURNITURE_RECONNECT_DELAY, + () -> { + if (!player.isAlive() || player.isRemoved()) { + persistentData.remove("tiedup_locked_furniture"); + return; + } + + // Search for the furniture entity near the stored position + Entity furniture = findFurnitureEntity( + targetLevel, furniturePos, furnitureUuid + ); + + if (furniture == null || !(furniture instanceof ISeatProvider provider)) { + TiedUpMod.LOGGER.info( + "[Network] Furniture entity {} not found at {} for reconnection of {}. " + + "Teleporting player to last furniture position.", + furnitureUuidStr, furniturePos, + player.getName().getString() + ); + // Teleport to furniture position to prevent "disconnect to escape" + teleportPlayerTo(player, targetLevel, x + 0.5, y, z + 0.5); + persistentData.remove("tiedup_locked_furniture"); + return; + } + + // Verify the seat is still locked + if (!provider.isSeatLocked(seatId)) { + TiedUpMod.LOGGER.info( + "[Network] Seat '{}' is no longer locked on furniture {}. Freeing {}.", + seatId, furnitureUuidStr, player.getName().getString() + ); + persistentData.remove("tiedup_locked_furniture"); + return; + } + + // Teleport to furniture dimension/position if needed + if (player.level() != targetLevel || player.distanceToSqr(furniture) > 25.0) { + teleportPlayerTo( + player, targetLevel, + furniture.getX(), furniture.getY(), furniture.getZ() + ); + } + + // Re-mount the player + boolean mounted = player.startRiding(furniture, true); + if (mounted) { + provider.assignSeat(player, seatId); + TiedUpMod.LOGGER.info( + "[Network] Re-mounted {} in furniture {} seat '{}'", + player.getName().getString(), + furnitureUuidStr, seatId + ); + } else { + TiedUpMod.LOGGER.warn( + "[Network] Failed to re-mount {} in furniture {}. Teleporting to position.", + player.getName().getString(), furnitureUuidStr + ); + teleportPlayerTo( + player, (ServerLevel) furniture.level(), + furniture.getX(), furniture.getY(), furniture.getZ() + ); + persistentData.remove("tiedup_locked_furniture"); + } + } + ) + ); + } + + /** Delay before re-mounting on reconnection (in ticks). Allows entity spawn to complete. */ + private static final int FURNITURE_RECONNECT_DELAY = 10; + + /** + * Teleport a player to a position, handling cross-dimension transfer if needed. + * Uses the same-dimension {@code teleportTo(x, y, z)} for same-level moves, + * and TeleportHelper for cross-dimension moves. + */ + private static void teleportPlayerTo( + ServerPlayer player, + ServerLevel targetLevel, + double x, double y, double z + ) { + if (player.serverLevel() == targetLevel) { + player.teleportTo(x, y, z); + } else { + // Cross-dimension: use the project's TeleportHelper for correct handling + com.tiedup.remake.util.teleport.Position pos = + new com.tiedup.remake.util.teleport.Position(x, y, z, targetLevel.dimension()); + com.tiedup.remake.util.teleport.TeleportHelper.teleportEntity(player, pos); + } + } + + /** + * Search for a furniture entity near the given position, matching the expected UUID. + * Searches a small area around the stored position to account for entity position drift. + * + * @param level the server level to search in + * @param pos the approximate position of the furniture + * @param expectedUuid the UUID of the furniture entity + * @return the entity if found, or null + */ + @Nullable + private static Entity findFurnitureEntity( + ServerLevel level, + BlockPos pos, + UUID expectedUuid + ) { + // Search in a small area around the stored position (furniture shouldn't move, + // but entity positions can drift slightly due to floating point) + AABB searchBox = new AABB(pos).inflate(2.0); + java.util.List entities = level.getEntitiesOfClass( + EntityFurniture.class, searchBox, + e -> e.isAlive() && !e.isRemoved() + ); + + // First, try to find by UUID (most reliable) + for (EntityFurniture entity : entities) { + if (entity.getUUID().equals(expectedUuid)) { + return entity; + } + } + + // Fallback: if UUID doesn't match (entity recreated by chunk reload), + // find the nearest furniture at approximately the same position + for (EntityFurniture entity : entities) { + if (entity.blockPosition().equals(pos)) { + TiedUpMod.LOGGER.debug( + "[Network] Furniture UUID mismatch but position matches at {}. " + + "Using entity {} instead of expected {}.", + pos, entity.getUUID(), expectedUuid + ); + return entity; + } + } + + return null; + } +} diff --git a/src/main/java/com/tiedup/remake/network/PacketRateLimiter.java b/src/main/java/com/tiedup/remake/network/PacketRateLimiter.java new file mode 100644 index 0000000..ee8be9c --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/PacketRateLimiter.java @@ -0,0 +1,244 @@ +package com.tiedup.remake.network; + +import com.mojang.logging.LogUtils; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.server.level.ServerPlayer; +import org.slf4j.Logger; + +/** + * Rate limiter for network packets to prevent server abuse. + * + *

Uses the Token Bucket algorithm to limit packet rates per player. + * Each player has a bucket of tokens that refills over time. Each packet + * consumes one token. If the bucket is empty, the packet is rejected. + * + *

Phase: Server Protection & Performance + * + *

This prevents malicious clients from: + *

    + *
  • Spamming packets to overload the server
  • + *
  • Creating lag for other players
  • + *
  • Exploiting game mechanics through rapid packet sending
  • + *
+ * + *

Example usage in packet handlers: + *

{@code
+ * public void handle(Supplier ctx) {
+ *     ctx.get().enqueueWork(() -> {
+ *         ServerPlayer player = ctx.get().getSender();
+ *         if (player == null) return;
+ *
+ *         // Rate limit check
+ *         if (!PacketRateLimiter.allowPacket(player, "struggle")) {
+ *             return; // Packet rejected
+ *         }
+ *
+ *         handleServer(player);
+ *     });
+ *     ctx.get().setPacketHandled(true);
+ * }
+ * }
+ */ +public class PacketRateLimiter { + + private static final Logger LOGGER = LogUtils.getLogger(); + + /** + * Map of player UUID -> packet type -> token bucket. + * Concurrent to support multi-threaded packet handling. + */ + private static final Map> playerBuckets = + new ConcurrentHashMap<>(); + + /** + * Rate limit configurations for different packet categories. + * + *

Categories: + *

    + *
  • struggle: Struggle keybind spam (5 tokens, 1/sec refill)
  • + *
  • minigame: Minigame inputs like QTE or lockpick (20 tokens, 5/sec refill)
  • + *
  • action: Player actions like tying/untying (10 tokens, 2/sec refill)
  • + *
  • selfbondage: Self-bondage continuous packets (15 tokens, 6/sec refill)
  • + *
  • ui: UI interactions like opening screens (3 tokens, 0.5/sec refill)
  • + *
  • default: Fallback for uncategorized packets (10 tokens, 1/sec refill)
  • + *
+ */ + private static final Map configs = Map.of( + "struggle", + new RateLimitConfig(5, 1.0), + "minigame", + new RateLimitConfig(20, 5.0), + "action", + new RateLimitConfig(10, 2.0), + "selfbondage", + new RateLimitConfig(15, 6.0), + "ui", + new RateLimitConfig(3, 0.5), + "default", + new RateLimitConfig(10, 1.0) + ); + + /** + * Check if a packet from a player should be allowed. + * + *

This method is thread-safe and can be called from packet handlers + * running on different threads. + * + * @param player The player sending the packet + * @param packetType The packet category (struggle, minigame, action, ui) + * @return true if the packet should be processed, false if rate limited + */ + public static boolean allowPacket(ServerPlayer player, String packetType) { + if (player == null) { + return false; + } + + UUID playerId = player.getUUID(); + + // Get or create the bucket for this player + packet type + TokenBucket bucket = playerBuckets + .computeIfAbsent(playerId, k -> new ConcurrentHashMap<>()) + .computeIfAbsent(packetType, k -> { + RateLimitConfig config = configs.getOrDefault( + k, + configs.get("default") + ); + return new TokenBucket(config); + }); + + boolean allowed = bucket.tryConsume(); + + if (!allowed) { + LOGGER.warn( + "Rate limit exceeded for player '{}' on packet type '{}'. " + + "This may indicate packet spam or a malicious client.", + player.getName().getString(), + packetType + ); + + // Future enhancement: Track violations and kick/ban repeat offenders + // For now, we just log and reject the packet + } + + return allowed; + } + + /** + * Clean up rate limiter state for a disconnected player. + * + *

This should be called when a player logs out to prevent + * the map from growing unbounded. + * + * @param playerId The UUID of the disconnected player + */ + public static void cleanup(UUID playerId) { + Map removed = playerBuckets.remove(playerId); + if (removed != null) { + LOGGER.debug( + "Cleaned up rate limiter state for player: {}", + playerId + ); + } + } + + /** + * Get current statistics for a player (for debugging/monitoring). + * + * @param playerId The player UUID + * @param packetType The packet type + * @return String representation of bucket state, or null if not found + */ + public static String getStats(UUID playerId, String packetType) { + Map buckets = playerBuckets.get(playerId); + if (buckets == null) { + return null; + } + + TokenBucket bucket = buckets.get(packetType); + if (bucket == null) { + return null; + } + + return String.format( + "Tokens: %.2f/%.0f (refill: %.1f/s)", + bucket.getCurrentTokens(), + bucket.maxTokens, + bucket.refillRate + ); + } + + /** + * Token Bucket implementation for rate limiting. + * + *

The bucket starts full and refills over time. Each packet consumes + * one token. If the bucket is empty, packets are rejected. + * + *

This allows for "bursts" of activity (using accumulated tokens) + * while enforcing a long-term rate limit. + */ + private static class TokenBucket { + + private final double maxTokens; + private final double refillRate; // tokens per second + private double tokens; + private long lastRefillNanos; + + /** + * Create a new token bucket. + * + * @param config The rate limit configuration + */ + TokenBucket(RateLimitConfig config) { + this.maxTokens = config.capacity; + this.refillRate = config.refillRate; + this.tokens = maxTokens; // Start full + this.lastRefillNanos = System.nanoTime(); + } + + /** + * Try to consume one token from the bucket. + * + * @return true if a token was consumed, false if bucket is empty + */ + synchronized boolean tryConsume() { + refill(); + + if (tokens >= 1.0) { + tokens -= 1.0; + return true; + } + + return false; + } + + /** + * Get current token count (for debugging). + */ + synchronized double getCurrentTokens() { + refill(); + return tokens; + } + + /** + * Refill the bucket based on elapsed time. + */ + private void refill() { + long now = System.nanoTime(); + double elapsedSeconds = (now - lastRefillNanos) / 1_000_000_000.0; + + // Add tokens based on elapsed time, capped at max capacity + tokens = Math.min(maxTokens, tokens + elapsedSeconds * refillRate); + lastRefillNanos = now; + } + } + + /** + * Configuration for a rate limit category. + * + * @param capacity Maximum number of tokens (burst size) + * @param refillRate Number of tokens added per second + */ + private record RateLimitConfig(double capacity, double refillRate) {} +} diff --git a/src/main/java/com/tiedup/remake/network/action/PacketForceFeeding.java b/src/main/java/com/tiedup/remake/network/action/PacketForceFeeding.java new file mode 100644 index 0000000..63da6bf --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/action/PacketForceFeeding.java @@ -0,0 +1,47 @@ +package com.tiedup.remake.network.action; + +import com.tiedup.remake.network.base.AbstractProgressPacketWithRole; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.tasks.PlayerStateTask; +import java.util.function.BiConsumer; +import java.util.function.Function; +import net.minecraft.network.FriendlyByteBuf; + +/** + * Packet for synchronizing force feeding progress from server to client. + * + * Sent by server to update the client's feeding task progress. + * Includes role info so the progress bar can show different text + * for feeder vs target. + */ +public class PacketForceFeeding extends AbstractProgressPacketWithRole { + + public PacketForceFeeding( + int stateInfo, + int maxState, + boolean isActiveRole, + String otherEntityName + ) { + super(stateInfo, maxState, isActiveRole, otherEntityName); + } + + public static PacketForceFeeding decode(FriendlyByteBuf buf) { + RolePacketData data = decodeRoleFields(buf); + return new PacketForceFeeding( + data.stateInfo(), + data.maxState(), + data.isActiveRole(), + data.otherEntityName() + ); + } + + @Override + protected Function getTaskGetter() { + return PlayerBindState::getClientFeedingTask; + } + + @Override + protected BiConsumer getTaskSetter() { + return PlayerBindState::setClientFeedingTask; + } +} diff --git a/src/main/java/com/tiedup/remake/network/action/PacketForceSeatModifier.java b/src/main/java/com/tiedup/remake/network/action/PacketForceSeatModifier.java new file mode 100644 index 0000000..26dec09 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/action/PacketForceSeatModifier.java @@ -0,0 +1,57 @@ +package com.tiedup.remake.network.action; + +import com.tiedup.remake.events.captivity.ForcedSeatingHandler; +import com.tiedup.remake.network.PacketRateLimiter; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet to sync Force Seat keybind state from client to server. + * + * When the player presses/releases the Force Seat key (default: ALT), + * this packet is sent to update the server-side state. + * ForcedSeatingHandler uses this state to determine if ALT+click + * should mount/dismount captives on vehicles. + */ +public class PacketForceSeatModifier { + + private final boolean pressed; + + public PacketForceSeatModifier(boolean pressed) { + this.pressed = pressed; + } + + public static void encode( + PacketForceSeatModifier packet, + FriendlyByteBuf buf + ) { + buf.writeBoolean(packet.pressed); + } + + public static PacketForceSeatModifier decode(FriendlyByteBuf buf) { + return new PacketForceSeatModifier(buf.readBoolean()); + } + + public static void handle( + PacketForceSeatModifier packet, + Supplier ctx + ) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer player = ctx.get().getSender(); + if (player != null) { + if ( + !PacketRateLimiter.allowPacket(player, "action") + ) return; + ForcedSeatingHandler.setForceSeatPressed( + player.getUUID(), + packet.pressed + ); + } + }); + ctx.get().setPacketHandled(true); + } +} diff --git a/src/main/java/com/tiedup/remake/network/action/PacketSetKnifeCutTarget.java b/src/main/java/com/tiedup/remake/network/action/PacketSetKnifeCutTarget.java new file mode 100644 index 0000000..9886c56 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/action/PacketSetKnifeCutTarget.java @@ -0,0 +1,66 @@ +package com.tiedup.remake.network.action; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.PacketRateLimiter; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.v2.BodyRegionV2; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.network.NetworkEvent; + +/** + * v2.5: Packet to set the knife cut target accessory slot (Client to Server). + * + * When player clicks "Cut" on a locked accessory in StruggleChoiceScreen, + * this packet stores which slot they want to cut. The player must then + * right-click with a knife to start the cutting process. + */ +public class PacketSetKnifeCutTarget { + + private final BodyRegionV2 targetRegion; + + public PacketSetKnifeCutTarget(BodyRegionV2 targetRegion) { + this.targetRegion = targetRegion; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeEnum(targetRegion); + } + + public static PacketSetKnifeCutTarget decode(FriendlyByteBuf buf) { + BodyRegionV2 region = buf.readEnum(BodyRegionV2.class); + return new PacketSetKnifeCutTarget(region); + } + + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer player = ctx.get().getSender(); + if (player == null) { + return; + } + handleServer(player); + }); + ctx.get().setPacketHandled(true); + } + + private void handleServer(ServerPlayer player) { + if (!PacketRateLimiter.allowPacket(player, "action")) return; + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + return; + } + + // Direct V2: PlayerBindState now uses BodyRegionV2 natively + state.setKnifeCutTarget(targetRegion); + + TiedUpMod.LOGGER.debug( + "[PacketSetKnifeCutTarget] {} set knife cut target to region {}", + player.getName().getString(), + targetRegion + ); + } +} diff --git a/src/main/java/com/tiedup/remake/network/action/PacketStruggle.java b/src/main/java/com/tiedup/remake/network/action/PacketStruggle.java new file mode 100644 index 0000000..e7c2e9f --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/action/PacketStruggle.java @@ -0,0 +1,99 @@ +package com.tiedup.remake.network.action; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.PlayerBindState; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.network.NetworkEvent; + +/** + * Phase 7: Packet for struggling (Client to Server). + * + * Based on original PacketStruggleServer from 1.12.2 + * + * Sent by client when player presses the struggle keybind. + * No payload needed - just signals the server that the player wants to struggle. + */ +public class PacketStruggle { + + /** + * Empty packet - no data needed. + */ + public PacketStruggle() { + // Empty packet + } + + /** + * Encode the packet to the network buffer. + * Nothing to encode for this packet. + * + * @param buf The buffer to write to + */ + public void encode(FriendlyByteBuf buf) { + // Empty - no data + } + + /** + * Decode the packet from the network buffer. + * Nothing to decode for this packet. + * + * @param buf The buffer to read from + * @return The decoded packet + */ + public static PacketStruggle decode(FriendlyByteBuf buf) { + return new PacketStruggle(); + } + + /** + * Handle the packet on the receiving side (SERVER SIDE). + * + * @param ctx The network context + */ + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + // This runs on the SERVER side only + ServerPlayer player = ctx.get().getSender(); + if (player == null) { + return; + } + + // Rate limiting: Prevent struggle spam + if ( + !com.tiedup.remake.network.PacketRateLimiter.allowPacket( + player, + "struggle" + ) + ) { + return; + } + + handleServer(player); + }); + ctx.get().setPacketHandled(true); + } + + /** + * Handle the packet on the server side. + * Calls the player's struggle() method. + * + * Based on original PacketStruggleServer handler + * + * @param player The player who sent the packet + */ + private void handleServer(ServerPlayer player) { + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + TiedUpMod.LOGGER.warn( + "[PACKET] PacketStruggle received but PlayerBindState is null for {}", + player.getName().getString() + ); + return; + } + + // Call struggle + state.struggle(); + } +} diff --git a/src/main/java/com/tiedup/remake/network/action/PacketTighten.java b/src/main/java/com/tiedup/remake/network/action/PacketTighten.java new file mode 100644 index 0000000..9a98503 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/action/PacketTighten.java @@ -0,0 +1,207 @@ +package com.tiedup.remake.network.action; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.network.PacketRateLimiter; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.state.ICaptor; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.List; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.network.NetworkEvent; + +/** + * Phase 7: Packet for tightening binds (Client to Server). + * + * Based on original PacketTightenBinds from 1.12.2 + * + * Sent by client when player wants to tighten a nearby tied player's binds. + * No payload needed - server will find the nearest tied player the sender is looking at. + */ +public class PacketTighten { + + /** + * Empty packet - no data needed. + */ + public PacketTighten() { + // Empty packet + } + + /** + * Encode the packet to the network buffer. + * Nothing to encode for this packet. + * + * @param buf The buffer to write to + */ + public void encode(FriendlyByteBuf buf) { + // Empty - no data + } + + /** + * Decode the packet from the network buffer. + * Nothing to decode for this packet. + * + * @param buf The buffer to read from + * @return The decoded packet + */ + public static PacketTighten decode(FriendlyByteBuf buf) { + return new PacketTighten(); + } + + /** + * Handle the packet on the receiving side (SERVER SIDE). + * + * @param ctx The network context + */ + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + // This runs on the SERVER side only + ServerPlayer tightener = ctx.get().getSender(); + if (tightener == null) { + return; + } + + handleServer(tightener); + }); + ctx.get().setPacketHandled(true); + } + + /** + * Handle the packet on the server side. + * Finds the nearest tied entity (player or NPC) the tightener is looking at + * and tightens their binds. + * + * Based on original PacketTightenBinds handler + * + * @param tightener The player who sent the packet + */ + private void handleServer(ServerPlayer tightener) { + if (!PacketRateLimiter.allowPacket(tightener, "action")) return; + + // Find nearby tied entities within 5 blocks + Vec3 eyePos = tightener.getEyePosition(); + Vec3 lookVec = tightener.getLookAngle(); + double maxDistance = 5.0; + + AABB searchBox = new AABB( + eyePos.x - maxDistance, + eyePos.y - maxDistance, + eyePos.z - maxDistance, + eyePos.x + maxDistance, + eyePos.y + maxDistance, + eyePos.z + maxDistance + ); + + // Search for all LivingEntities (Players + NPCs) + List nearbyEntities = tightener + .level() + .getEntitiesOfClass( + LivingEntity.class, + searchBox, + entity -> entity != tightener && entity.isAlive() + ); + + // Find the closest entity the tightener is looking at + LivingEntity closestTarget = null; + IRestrainable closestState = null; + double closestDistance = maxDistance; + + for (LivingEntity candidate : nearbyEntities) { + // Get kidnapped state (works for Players and NPCs) + IRestrainable candidateState = KidnappedHelper.getKidnappedState( + candidate + ); + if (candidateState == null || !candidateState.isTiedUp()) { + continue; // Only tighten tied entities + } + + // Check if tightener is looking at this entity + Vec3 toCandidate = candidate + .position() + .add(0, candidate.getBbHeight() / 2, 0) + .subtract(eyePos); + double distance = toCandidate.length(); + + if (distance > maxDistance) { + continue; + } + + // Check angle - must be looking roughly at the entity + Vec3 toCandidateNorm = toCandidate.normalize(); + double dot = lookVec.dot(toCandidateNorm); + + if (dot > 0.9 && distance < closestDistance) { + // ~25 degree cone + closestTarget = candidate; + closestState = candidateState; + closestDistance = distance; + } + } + + if (closestTarget != null && closestState != null) { + // Security: Verify sender has permission to tighten + // Must be captor of target, collar owner, or admin + boolean hasPermission = false; + + // Check if sender is captor + if (closestState.isCaptive()) { + ICaptor captor = closestState.getCaptor(); + if ( + captor != null && + captor.getEntity() != null && + captor.getEntity().getUUID().equals(tightener.getUUID()) + ) { + hasPermission = true; + } + } + + // Check if sender owns collar + if (!hasPermission && closestState.hasCollar()) { + var collarStack = closestState.getEquipment(BodyRegionV2.NECK); + if ( + collarStack.getItem() instanceof + com.tiedup.remake.items.base.ItemCollar collar + ) { + if (collar.isOwner(collarStack, tightener)) { + hasPermission = true; + } + } + } + + // Check if sender is admin + if (!hasPermission && tightener.hasPermissions(2)) { + hasPermission = true; + } + + if (!hasPermission) { + TiedUpMod.LOGGER.debug( + "[PACKET] {} tried to tighten {} but has no permission", + tightener.getName().getString(), + closestTarget.getName().getString() + ); + return; + } + + closestState.tighten(tightener); + TiedUpMod.LOGGER.info( + "[PACKET] {} tightened {}'s binds", + tightener.getName().getString(), + closestTarget.getName().getString() + ); + } else { + TiedUpMod.LOGGER.debug( + "[PACKET] {} tried to tighten but no valid target found", + tightener.getName().getString() + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/network/action/PacketTying.java b/src/main/java/com/tiedup/remake/network/action/PacketTying.java new file mode 100644 index 0000000..79d533e --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/action/PacketTying.java @@ -0,0 +1,58 @@ +package com.tiedup.remake.network.action; + +import com.tiedup.remake.network.base.AbstractProgressPacketWithRole; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.tasks.PlayerStateTask; +import java.util.function.BiConsumer; +import java.util.function.Function; +import net.minecraft.network.FriendlyByteBuf; + +/** + * Packet for synchronizing tying progress from server to client. + * + * Sent by server to update the client's tying task progress. + * Includes role info so the progress bar can show different text + * for kidnapper vs victim. + */ +public class PacketTying extends AbstractProgressPacketWithRole { + + /** + * Create a new tying progress packet with role info. + * + * @param stateInfo Current elapsed time in seconds (-1 for completion/cancel) + * @param maxState Total duration in seconds + * @param isKidnapper true if the recipient is doing the tying + * @param otherEntityName Name of the other party + */ + public PacketTying( + int stateInfo, + int maxState, + boolean isKidnapper, + String otherEntityName + ) { + super(stateInfo, maxState, isKidnapper, otherEntityName); + } + + /** + * Decode the packet from the network buffer. + */ + public static PacketTying decode(FriendlyByteBuf buf) { + RolePacketData data = decodeRoleFields(buf); + return new PacketTying( + data.stateInfo(), + data.maxState(), + data.isActiveRole(), + data.otherEntityName() + ); + } + + @Override + protected Function getTaskGetter() { + return PlayerBindState::getClientTyingTask; + } + + @Override + protected BiConsumer getTaskSetter() { + return PlayerBindState::setClientTyingTask; + } +} diff --git a/src/main/java/com/tiedup/remake/network/action/PacketUntying.java b/src/main/java/com/tiedup/remake/network/action/PacketUntying.java new file mode 100644 index 0000000..a2075e3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/action/PacketUntying.java @@ -0,0 +1,58 @@ +package com.tiedup.remake.network.action; + +import com.tiedup.remake.network.base.AbstractProgressPacketWithRole; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.tasks.PlayerStateTask; +import java.util.function.BiConsumer; +import java.util.function.Function; +import net.minecraft.network.FriendlyByteBuf; + +/** + * Packet for synchronizing untying progress from server to client. + * + * Sent by server to update the client's untying task progress. + * Includes role info so the progress bar can show different text + * for helper vs victim. + */ +public class PacketUntying extends AbstractProgressPacketWithRole { + + /** + * Create a new untying progress packet with role info. + * + * @param stateInfo Current elapsed time (-1 for completion/cancel) + * @param maxState Total duration + * @param isHelper true if the recipient is doing the untying + * @param otherEntityName Name of the other party + */ + public PacketUntying( + int stateInfo, + int maxState, + boolean isHelper, + String otherEntityName + ) { + super(stateInfo, maxState, isHelper, otherEntityName); + } + + /** + * Decode the packet from the network buffer. + */ + public static PacketUntying decode(FriendlyByteBuf buf) { + RolePacketData data = decodeRoleFields(buf); + return new PacketUntying( + data.stateInfo(), + data.maxState(), + data.isActiveRole(), + data.otherEntityName() + ); + } + + @Override + protected Function getTaskGetter() { + return PlayerBindState::getClientUntyingTask; + } + + @Override + protected BiConsumer getTaskSetter() { + return PlayerBindState::setClientUntyingTask; + } +} diff --git a/src/main/java/com/tiedup/remake/network/armorstand/PacketSyncArmorStandBondage.java b/src/main/java/com/tiedup/remake/network/armorstand/PacketSyncArmorStandBondage.java new file mode 100644 index 0000000..4ce4cd8 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/armorstand/PacketSyncArmorStandBondage.java @@ -0,0 +1,99 @@ +package com.tiedup.remake.network.armorstand; + +import com.tiedup.remake.entities.armorstand.ArmorStandBondageClientCache; +import com.tiedup.remake.network.base.AbstractClientPacket; +import com.tiedup.remake.v2.BodyRegionV2; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Packet to synchronize armor stand bondage data to clients. + * + * Direction: Server -> Client (S2C) + * + * Sent when: + * - A bondage item is equipped to an armor stand + * - A bondage item is removed from an armor stand + * - A player joins and armor stands with bondage items are loaded + * + *

Wire format (Epic 7D): uses {@link BodyRegionV2} enum for region identification. + */ +public class PacketSyncArmorStandBondage extends AbstractClientPacket { + + private final int entityId; + private final BodyRegionV2 region; + private final ItemStack item; + + /** + * Create a sync packet for a single slot update. + * + * @param entityId The armor stand entity ID + * @param region The body region + * @param item The item in the slot (empty to clear) + */ + public PacketSyncArmorStandBondage( + int entityId, + BodyRegionV2 region, + ItemStack item + ) { + this.entityId = entityId; + this.region = region; + this.item = item != null ? item : ItemStack.EMPTY; + } + + /** + * Encode the packet to a byte buffer. + */ + public void encode(FriendlyByteBuf buf) { + buf.writeVarInt(entityId); + buf.writeEnum(region); + + if (item.isEmpty()) { + buf.writeBoolean(false); + } else { + buf.writeBoolean(true); + buf.writeNbt(item.save(new CompoundTag())); + } + } + + /** + * Decode the packet from a byte buffer. + */ + public static PacketSyncArmorStandBondage decode(FriendlyByteBuf buf) { + int entityId = buf.readVarInt(); + BodyRegionV2 region = buf.readEnum(BodyRegionV2.class); + + ItemStack item; + if (buf.readBoolean()) { + CompoundTag tag = buf.readNbt(); + item = tag != null ? ItemStack.of(tag) : ItemStack.EMPTY; + } else { + item = ItemStack.EMPTY; + } + + return new PacketSyncArmorStandBondage(entityId, region, item); + } + + @Override + @OnlyIn(Dist.CLIENT) + protected void handleClientImpl() { + // Update the client-side cache + ArmorStandBondageClientCache.updateItem(entityId, region, item); + } + + // Getters for debugging + public int getEntityId() { + return entityId; + } + + public BodyRegionV2 getRegion() { + return region; + } + + public ItemStack getItem() { + return item; + } +} diff --git a/src/main/java/com/tiedup/remake/network/base/AbstractClientPacket.java b/src/main/java/com/tiedup/remake/network/base/AbstractClientPacket.java new file mode 100644 index 0000000..a49e3d5 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/base/AbstractClientPacket.java @@ -0,0 +1,47 @@ +package com.tiedup.remake.network.base; + +import java.util.function.Supplier; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.fml.loading.FMLEnvironment; +import net.minecraftforge.network.NetworkEvent; + +/** + * Abstract base class for client-bound packets. + * + * Provides the standard handle() implementation that: + * 1. Enqueues work to the main thread + * 2. Checks for client distribution + * 3. Calls the abstract handleClientImpl() + * + * Subclasses must implement: + * - encode(FriendlyByteBuf) - serialize packet data + * - decode(FriendlyByteBuf) - static, deserialize packet data + * - handleClientImpl() - client-side logic + */ +public abstract class AbstractClientPacket { + + /** + * Handle the packet on the receiving side (client). + * This runs on the network thread, so we enqueue to main thread. + * + * @param ctx The network context + */ + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + if (FMLEnvironment.dist == Dist.CLIENT) { + handleClientImpl(); + } + }); + ctx.get().setPacketHandled(true); + } + + /** + * Client-side packet handling implementation. + * Called on the main client thread after the packet is received. + */ + @OnlyIn(Dist.CLIENT) + protected abstract void handleClientImpl(); +} diff --git a/src/main/java/com/tiedup/remake/network/base/AbstractPlayerSyncPacket.java b/src/main/java/com/tiedup/remake/network/base/AbstractPlayerSyncPacket.java new file mode 100644 index 0000000..86b3e9d --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/base/AbstractPlayerSyncPacket.java @@ -0,0 +1,105 @@ +package com.tiedup.remake.network.base; + +import java.util.UUID; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Abstract base class for player sync packets (Server → Client). + * + * Extends AbstractClientPacket with player UUID lookup logic. + * Used for packets that sync data for a specific player. + * + * Subclasses must implement: + * - encode(FriendlyByteBuf) - serialize packet data (should call super.encodeUUID first) + * - decode(FriendlyByteBuf) - static, deserialize packet data + * - applySync(Player) - apply the sync to the player + * - queueForRetry() - optional, queue for SyncManager retry if player not loaded + */ +public abstract class AbstractPlayerSyncPacket extends AbstractClientPacket { + + protected final UUID playerUUID; + + /** + * Create a new player sync packet. + * + * @param playerUUID The target player's UUID + */ + protected AbstractPlayerSyncPacket(UUID playerUUID) { + this.playerUUID = playerUUID; + } + + /** + * Get the target player's UUID. + */ + public UUID getPlayerUUID() { + return playerUUID; + } + + /** + * Encode the player UUID to the buffer. + * Subclasses should call this in their encode() method. + * + * @param buf The buffer to write to + */ + protected void encodeUUID(FriendlyByteBuf buf) { + buf.writeUUID(playerUUID); + } + + /** + * Decode a player UUID from the buffer. + * Subclasses should call this in their static decode() method. + * + * @param buf The buffer to read from + * @return The decoded UUID + */ + protected static UUID decodeUUID(FriendlyByteBuf buf) { + return buf.readUUID(); + } + + /** + * Client-side packet handling implementation. + * Looks up the player by UUID and calls applySync if found. + */ + @Override + @OnlyIn(Dist.CLIENT) + protected void handleClientImpl() { + if (net.minecraft.client.Minecraft.getInstance().level == null) { + return; + } + + Player player = + net.minecraft.client.Minecraft.getInstance().level.getPlayerByUUID( + playerUUID + ); + if (player == null) { + // Player not loaded yet - subclass may queue for retry + queueForRetry(); + return; + } + + applySync(player); + } + + /** + * Apply the sync to the target player. + * Called when the player is found in the client world. + * + * @param player The target player + */ + @OnlyIn(Dist.CLIENT) + protected abstract void applySync(Player player); + + /** + * Queue this packet for retry via SyncManager. + * Called when the player is not yet loaded. + * Default implementation does nothing (ignore). + * Subclasses can override to queue for retry. + */ + @OnlyIn(Dist.CLIENT) + protected void queueForRetry() { + // Default: do nothing (packet is non-critical or will be re-sent) + } +} diff --git a/src/main/java/com/tiedup/remake/network/base/AbstractProgressPacket.java b/src/main/java/com/tiedup/remake/network/base/AbstractProgressPacket.java new file mode 100644 index 0000000..96faa05 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/base/AbstractProgressPacket.java @@ -0,0 +1,157 @@ +package com.tiedup.remake.network.base; + +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.tasks.PlayerStateTask; +import java.util.function.BiConsumer; +import java.util.function.Function; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Phase 2 Refactoring: Abstract base class for progress packets. + * + * Eliminates code duplication between PacketTying and PacketUntying. + * Both packets share identical structure for encoding/decoding/handling, + * differing only in which client task they update. + * + * Subclasses only need to implement: + * - decode() - static method returning the concrete type + * - getTaskGetter() - returns the function to get the task from PlayerBindState + * - getTaskSetter() - returns the function to set the task on PlayerBindState + * - calculateElapsed() - how to calculate elapsed time from stateInfo + */ +public abstract class AbstractProgressPacket extends AbstractClientPacket { + + protected final int stateInfo; // Current time value (-1 = done/cancelled) + protected final int maxState; // Total duration + + /** + * Create a new progress packet. + * + * @param stateInfo Current time value (-1 for completion/cancel) + * @param maxState Total duration in seconds + */ + protected AbstractProgressPacket(int stateInfo, int maxState) { + this.stateInfo = stateInfo; + this.maxState = maxState; + } + + /** + * Get the current state info value. + */ + public int getStateInfo() { + return stateInfo; + } + + /** + * Get the max state value. + */ + public int getMaxState() { + return maxState; + } + + /** + * Encode the packet to the network buffer. + * + * @param buf The buffer to write to + */ + public void encode(FriendlyByteBuf buf) { + buf.writeInt(stateInfo); + buf.writeInt(maxState); + } + + /** + * Static helper to decode common fields from buffer. + * Subclasses should call this in their static decode() method. + * + * @param buf The buffer to read from + * @return Array of [stateInfo, maxState] + */ + protected static int[] decodeFields(FriendlyByteBuf buf) { + int stateInfo = buf.readInt(); + int maxState = buf.readInt(); + return new int[] { stateInfo, maxState }; + } + + /** + * Client-side packet handling from AbstractClientPacket. + * This method is stripped from dedicated server builds. + * Contains references to client-only classes (Minecraft, LocalPlayer). + */ + @Override + @OnlyIn(Dist.CLIENT) + protected void handleClientImpl() { + net.minecraft.client.player.LocalPlayer player = + net.minecraft.client.Minecraft.getInstance().player; + if (player == null) { + return; + } + + PlayerBindState playerState = PlayerBindState.getInstance(player); + if (playerState == null) { + return; + } + + handleProgressUpdate(playerState); + } + + /** + * Implementation of progress update logic. + * Subclasses can override to customize task creation (e.g., with role info). + * + * @param playerState The player's bind state + */ + @OnlyIn(Dist.CLIENT) + protected void handleProgressUpdate(PlayerBindState playerState) { + if (stateInfo == -1) { + // Task completed or cancelled - clear client-side state + getTaskSetter().accept(playerState, null); + } else { + // Update or create client-side task + PlayerStateTask task = getTaskGetter().apply(playerState); + + if (task == null || task.isOutdated()) { + // Create new task state + task = new PlayerStateTask(maxState); + getTaskSetter().accept(playerState, task); + } + + // Update progress + task.update(calculateElapsed()); + } + } + + /** + * Get the function to retrieve the task from PlayerBindState. + * Subclasses must implement this. + * + * @return Function that gets the task from PlayerBindState + */ + protected abstract Function< + PlayerBindState, + PlayerStateTask + > getTaskGetter(); + + /** + * Get the function to set the task on PlayerBindState. + * Subclasses must implement this. + * + * @return BiConsumer that sets the task on PlayerBindState + */ + protected abstract BiConsumer< + PlayerBindState, + PlayerStateTask + > getTaskSetter(); + + /** + * Calculate the elapsed time value to pass to task.update(). + * Default implementation returns stateInfo directly. + * Subclasses can override for different calculation (e.g., untying uses maxState - stateInfo). + * + * @return The elapsed time value + */ + protected int calculateElapsed() { + return stateInfo; + } +} diff --git a/src/main/java/com/tiedup/remake/network/base/AbstractProgressPacketWithRole.java b/src/main/java/com/tiedup/remake/network/base/AbstractProgressPacketWithRole.java new file mode 100644 index 0000000..8e038b1 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/base/AbstractProgressPacketWithRole.java @@ -0,0 +1,121 @@ +package com.tiedup.remake.network.base; + +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.tasks.PlayerStateTask; +import java.util.function.BiConsumer; +import java.util.function.Function; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Extended progress packet that includes role information. + * + * Used for tying/untying packets where we need to show different + * progress text based on whether the player is the active party + * (kidnapper/helper) or the passive party (victim). + * + * Subclasses only need to implement: + * - decode() - static method returning the concrete type + * - getTaskGetter() - returns the function to get the task from PlayerBindState + * - getTaskSetter() - returns the function to set the task on PlayerBindState + */ +public abstract class AbstractProgressPacketWithRole + extends AbstractProgressPacket +{ + + protected final boolean isActiveRole; + protected final String otherEntityName; + + /** + * Create a new progress packet with role info. + * + * @param stateInfo Current time value (-1 for completion/cancel) + * @param maxState Total duration in seconds + * @param isActiveRole true if recipient is the active party (kidnapper/helper) + * @param otherEntityName Name of the other party + */ + protected AbstractProgressPacketWithRole( + int stateInfo, + int maxState, + boolean isActiveRole, + String otherEntityName + ) { + super(stateInfo, maxState); + this.isActiveRole = isActiveRole; + this.otherEntityName = otherEntityName != null ? otherEntityName : ""; + } + + public boolean isActiveRole() { + return isActiveRole; + } + + public String getOtherEntityName() { + return otherEntityName; + } + + @Override + public void encode(FriendlyByteBuf buf) { + super.encode(buf); + buf.writeBoolean(isActiveRole); + buf.writeUtf(otherEntityName); + } + + /** + * Static helper to decode all fields from buffer. + * Subclasses should call this in their static decode() method. + * + * @param buf The buffer to read from + * @return RolePacketData with all decoded fields + */ + protected static RolePacketData decodeRoleFields(FriendlyByteBuf buf) { + int stateInfo = buf.readInt(); + int maxState = buf.readInt(); + boolean isActiveRole = buf.readBoolean(); + String otherEntityName = buf.readUtf(); + return new RolePacketData( + stateInfo, + maxState, + isActiveRole, + otherEntityName + ); + } + + /** + * Data container for decoded packet fields. + */ + protected record RolePacketData( + int stateInfo, + int maxState, + boolean isActiveRole, + String otherEntityName + ) {} + + /** + * Override to create task with role info. + */ + @Override + @OnlyIn(Dist.CLIENT) + protected void handleProgressUpdate(PlayerBindState playerState) { + if (stateInfo == -1) { + // Task completed or cancelled - clear client-side state + getTaskSetter().accept(playerState, null); + } else { + // Update or create client-side task + PlayerStateTask task = getTaskGetter().apply(playerState); + + if (task == null || task.isOutdated()) { + // Create new task state WITH ROLE INFO + task = new PlayerStateTask( + maxState, + isActiveRole, + otherEntityName + ); + getTaskSetter().accept(playerState, task); + } + + // Update progress + task.update(calculateElapsed()); + } + } +} diff --git a/src/main/java/com/tiedup/remake/network/bounty/PacketDeleteBounty.java b/src/main/java/com/tiedup/remake/network/bounty/PacketDeleteBounty.java new file mode 100644 index 0000000..6d3427a --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/bounty/PacketDeleteBounty.java @@ -0,0 +1,64 @@ +package com.tiedup.remake.network.bounty; + +import com.tiedup.remake.bounty.BountyManager; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.PacketRateLimiter; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet: Client requests to delete/cancel a bounty. + * + * Phase 17: Bounty System + * + * Only the bounty client or an admin can delete. + * If client deletes, reward is returned. + */ +public class PacketDeleteBounty { + + private final String bountyId; + + public PacketDeleteBounty(String bountyId) { + this.bountyId = bountyId; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeUtf(bountyId); + } + + public static PacketDeleteBounty decode(FriendlyByteBuf buf) { + return new PacketDeleteBounty(buf.readUtf(256)); + } + + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer player = ctx.get().getSender(); + if (player == null) return; + if (!PacketRateLimiter.allowPacket(player, "action")) return; + + BountyManager manager = BountyManager.get(player.serverLevel()); + boolean success = manager.cancelBounty(player, bountyId); + + if (!success) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "Cannot delete this bounty." + ); + } + + TiedUpMod.LOGGER.debug( + "[BOUNTY] Delete request from {}: bounty={}, success={}", + player.getName().getString(), + bountyId, + success + ); + }); + ctx.get().setPacketHandled(true); + } +} diff --git a/src/main/java/com/tiedup/remake/network/bounty/PacketRequestBounties.java b/src/main/java/com/tiedup/remake/network/bounty/PacketRequestBounties.java new file mode 100644 index 0000000..5a6de2b --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/bounty/PacketRequestBounties.java @@ -0,0 +1,54 @@ +package com.tiedup.remake.network.bounty; + +import com.tiedup.remake.bounty.Bounty; +import com.tiedup.remake.bounty.BountyManager; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.PacketRateLimiter; +import java.util.List; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet: Client requests bounty list from server. + * + * Phase 17: Bounty System + * + * Flow: Client → Server → PacketSendBounties → Client + */ +public class PacketRequestBounties { + + public PacketRequestBounties() {} + + public void encode(FriendlyByteBuf buf) { + // No data needed + } + + public static PacketRequestBounties decode(FriendlyByteBuf buf) { + return new PacketRequestBounties(); + } + + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer player = ctx.get().getSender(); + if (player == null) return; + if (!PacketRateLimiter.allowPacket(player, "ui")) return; + + BountyManager manager = BountyManager.get(player.serverLevel()); + List bounties = manager.getBounties( + player.serverLevel() + ); + boolean isAdmin = player.hasPermissions(2); + + // Send bounty list back to client + ModNetwork.sendToPlayer( + new PacketSendBounties(bounties, isAdmin), + player + ); + }); + ctx.get().setPacketHandled(true); + } +} diff --git a/src/main/java/com/tiedup/remake/network/bounty/PacketSendBounties.java b/src/main/java/com/tiedup/remake/network/bounty/PacketSendBounties.java new file mode 100644 index 0000000..0633c0f --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/bounty/PacketSendBounties.java @@ -0,0 +1,82 @@ +package com.tiedup.remake.network.bounty; + +import com.tiedup.remake.bounty.Bounty; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.fml.loading.FMLEnvironment; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet: Server sends bounty list to client. + * + * Phase 17: Bounty System + * + * Flow: Server → Client (opens BountyListScreen) + */ +public class PacketSendBounties { + + private final List bounties; + private final boolean isAdmin; + + public PacketSendBounties(List bounties, boolean isAdmin) { + this.bounties = bounties; + this.isAdmin = isAdmin; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeBoolean(isAdmin); + + // Serialize bounties as NBT list + ListTag listTag = new ListTag(); + for (Bounty bounty : bounties) { + listTag.add(bounty.save()); + } + CompoundTag wrapper = new CompoundTag(); + wrapper.put("bounties", listTag); + buf.writeNbt(wrapper); + } + + public static PacketSendBounties decode(FriendlyByteBuf buf) { + boolean isAdmin = buf.readBoolean(); + + CompoundTag wrapper = buf.readNbt(); + List bounties = new ArrayList<>(); + + if (wrapper != null && wrapper.contains("bounties")) { + ListTag listTag = wrapper.getList("bounties", Tag.TAG_COMPOUND); + for (int i = 0; i < listTag.size(); i++) { + bounties.add(Bounty.load(listTag.getCompound(i))); + } + } + + return new PacketSendBounties(bounties, isAdmin); + } + + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + if (FMLEnvironment.dist == Dist.CLIENT) { + handleClient(); + } + }); + ctx.get().setPacketHandled(true); + } + + @OnlyIn(Dist.CLIENT) + private void handleClient() { + net.minecraft.client.Minecraft.getInstance().setScreen( + new com.tiedup.remake.client.gui.screens.BountyListScreen( + bounties, + isAdmin + ) + ); + } +} diff --git a/src/main/java/com/tiedup/remake/network/cell/PacketAssignCellToCollar.java b/src/main/java/com/tiedup/remake/network/cell/PacketAssignCellToCollar.java new file mode 100644 index 0000000..20c1515 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/cell/PacketAssignCellToCollar.java @@ -0,0 +1,212 @@ +package com.tiedup.remake.network.cell; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.network.PacketRateLimiter; +import com.tiedup.remake.network.sync.SyncManager; +import com.tiedup.remake.personality.PersonalityState; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet to assign (or clear) a cell on a collar. + * Client -> Server + */ +public class PacketAssignCellToCollar { + + private final UUID targetEntityUUID; // The entity wearing the collar + private final UUID cellId; // The cell to assign (null = clear) + + public PacketAssignCellToCollar(UUID targetEntityUUID, UUID cellId) { + this.targetEntityUUID = targetEntityUUID; + this.cellId = cellId; + } + + public static void encode( + PacketAssignCellToCollar msg, + FriendlyByteBuf buf + ) { + buf.writeUUID(msg.targetEntityUUID); + buf.writeBoolean(msg.cellId != null); + if (msg.cellId != null) { + buf.writeUUID(msg.cellId); + } + } + + public static PacketAssignCellToCollar decode(FriendlyByteBuf buf) { + UUID targetEntityUUID = buf.readUUID(); + UUID cellId = buf.readBoolean() ? buf.readUUID() : null; + return new PacketAssignCellToCollar(targetEntityUUID, cellId); + } + + public static void handle( + PacketAssignCellToCollar msg, + Supplier ctx + ) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer sender = ctx.get().getSender(); + if (sender == null) return; + + // MEDIUM FIX: Rate limiting to prevent cell assignment spam + if (!PacketRateLimiter.allowPacket(sender, "action")) { + return; + } + + // Find target entity + LivingEntity target = findEntity(sender, msg.targetEntityUUID); + if (target == null) { + TiedUpMod.LOGGER.debug( + "[PacketAssignCellToCollar] Target not found: {}", + msg.targetEntityUUID + ); + return; + } + + // Check distance (max 10 blocks) + if (sender.distanceTo(target) > 10.0) { + TiedUpMod.LOGGER.debug( + "[PacketAssignCellToCollar] Target too far" + ); + return; + } + + // Get target's kidnapped state + IBondageState state = KidnappedHelper.getKidnappedState(target); + if (state == null || !state.hasCollar()) { + TiedUpMod.LOGGER.debug( + "[PacketAssignCellToCollar] Target has no collar" + ); + return; + } + + ItemStack collarStack = state.getEquipment(BodyRegionV2.NECK); + if (!(collarStack.getItem() instanceof ItemCollar collar)) { + TiedUpMod.LOGGER.debug( + "[PacketAssignCellToCollar] Invalid collar item" + ); + return; + } + + // Security: Verify sender owns the collar (or is admin) + if ( + !collar.isOwner(collarStack, sender) && + !sender.hasPermissions(2) + ) { + TiedUpMod.LOGGER.debug( + "[PacketAssignCellToCollar] Sender is not collar owner" + ); + return; + } + + // If assigning a cell, verify ownership + CellDataV2 cell = null; + if (msg.cellId != null) { + CellRegistryV2 registry = CellRegistryV2.get( + sender.serverLevel() + ); + cell = registry.getCell(msg.cellId); + if (cell == null) { + TiedUpMod.LOGGER.debug( + "[PacketAssignCellToCollar] Cell not found" + ); + return; + } + if ( + !cell.isOwnedBy(sender.getUUID()) && + !sender.hasPermissions(2) + ) { + TiedUpMod.LOGGER.debug( + "[PacketAssignCellToCollar] Sender doesn't own the cell" + ); + return; + } + } + + // Set the cell ID on the collar + collar.setCellId(collarStack, msg.cellId); + + // Sync PersonalityState for damsels + if (target instanceof EntityDamsel damsel) { + PersonalityState pState = damsel.getPersonalityState(); + if (pState != null) { + if (msg.cellId != null && cell != null) { + pState.assignCell( + msg.cellId, + cell, + damsel.getUUID() + ); + } else { + pState.unassignCell(); + } + } + } + + // Sync changes + if (target instanceof ServerPlayer targetPlayer) { + SyncManager.syncAll(targetPlayer); + } + + // Send feedback + if (msg.cellId != null) { + String cellName = + cell != null && cell.getName() != null + ? cell.getName() + : msg.cellId.toString().substring(0, 8); + + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.INFO, + "Cell '" + cellName + "' assigned to collar" + ); + } else { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.INFO, + "Cell cleared from collar" + ); + } + + TiedUpMod.LOGGER.info( + "[PacketAssignCellToCollar] {} {} cell on {}'s collar", + sender.getName().getString(), + msg.cellId != null ? "assigned" : "cleared", + target.getName().getString() + ); + }); + + ctx.get().setPacketHandled(true); + } + + private static LivingEntity findEntity(ServerPlayer sender, UUID entityId) { + // Try player first + Player player = sender.level().getPlayerByUUID(entityId); + if (player != null) return player; + + // Search nearby entities + var searchBox = sender.getBoundingBox().inflate(64); + for (LivingEntity entity : sender + .level() + .getEntitiesOfClass(LivingEntity.class, searchBox)) { + if (entity.getUUID().equals(entityId)) { + return entity; + } + } + return null; + } +} diff --git a/src/main/java/com/tiedup/remake/network/cell/PacketCellAction.java b/src/main/java/com/tiedup/remake/network/cell/PacketCellAction.java new file mode 100644 index 0000000..2e7cc63 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/cell/PacketCellAction.java @@ -0,0 +1,437 @@ +package com.tiedup.remake.network.cell; + +import com.tiedup.remake.blocks.BlockMarker; +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.core.BlockPos; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet for cell management actions. + * Client -> Server + */ +public class PacketCellAction { + + /** + * Action to perform on the cell/prisoner. + */ + public enum Action { + RELEASE, // Release a prisoner from cell + TRANSFER, // Transfer a prisoner to another cell + TELEPORT, // Teleport prisoner to cell + DELETE_CELL, // Delete the cell + } + + private final Action action; + private final UUID cellId; + private final UUID prisonerId; // For RELEASE/TRANSFER/TELEPORT + private final UUID targetCellId; // For TRANSFER + + public PacketCellAction( + Action action, + UUID cellId, + UUID prisonerId, + UUID targetCellId + ) { + this.action = action; + this.cellId = cellId; + this.prisonerId = prisonerId; + this.targetCellId = targetCellId; + } + + public static void encode(PacketCellAction msg, FriendlyByteBuf buf) { + buf.writeEnum(msg.action); + buf.writeUUID(msg.cellId); + buf.writeBoolean(msg.prisonerId != null); + if (msg.prisonerId != null) { + buf.writeUUID(msg.prisonerId); + } + buf.writeBoolean(msg.targetCellId != null); + if (msg.targetCellId != null) { + buf.writeUUID(msg.targetCellId); + } + } + + public static PacketCellAction decode(FriendlyByteBuf buf) { + Action action = buf.readEnum(Action.class); + UUID cellId = buf.readUUID(); + UUID prisonerId = buf.readBoolean() ? buf.readUUID() : null; + UUID targetCellId = buf.readBoolean() ? buf.readUUID() : null; + return new PacketCellAction(action, cellId, prisonerId, targetCellId); + } + + public static void handle( + PacketCellAction msg, + Supplier ctx + ) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer sender = ctx.get().getSender(); + if (sender == null) return; + + // CRITICAL FIX: Add rate limiting to prevent DoS via packet spam + if ( + !com.tiedup.remake.network.PacketRateLimiter.allowPacket( + sender, + "action" + ) + ) { + return; + } + + CellRegistryV2 registry = CellRegistryV2.get( + sender.serverLevel() + ); + CellDataV2 cell = registry.getCell(msg.cellId); + + if (cell == null) { + TiedUpMod.LOGGER.debug( + "[PacketCellAction] Cell not found: {}", + msg.cellId + ); + return; + } + + // Verify ownership + if ( + !cell.canPlayerManage( + sender.getUUID(), + sender.hasPermissions(2) + ) + ) { + TiedUpMod.LOGGER.debug( + "[PacketCellAction] Player is not cell owner" + ); + return; + } + + switch (msg.action) { + case RELEASE -> handleRelease( + sender, + registry, + cell, + msg.prisonerId + ); + case TRANSFER -> handleTransfer( + sender, + registry, + cell, + msg.prisonerId, + msg.targetCellId + ); + case TELEPORT -> handleTeleport( + sender, + registry, + cell, + msg.prisonerId + ); + case DELETE_CELL -> handleDeleteCell( + sender, + registry, + cell + ); + } + }); + + ctx.get().setPacketHandled(true); + } + + private static void handleRelease( + ServerPlayer sender, + CellRegistryV2 registry, + CellDataV2 cell, + UUID prisonerId + ) { + if (prisonerId == null) { + TiedUpMod.LOGGER.debug( + "[PacketCellAction] No prisoner ID for RELEASE" + ); + return; + } + + if (!cell.hasPrisoner(prisonerId)) { + TiedUpMod.LOGGER.debug("[PacketCellAction] Prisoner not in cell"); + return; + } + + // Release the prisoner from the cell (HIGH FIX: pass server for state cleanup) + registry.releasePrisoner(cell.getId(), prisonerId, sender.getServer()); + + // Get prisoner name for message + String prisonerName = getPrisonerName(sender, prisonerId); + + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.PRISONER_RELEASED, + prisonerName + ); + + TiedUpMod.LOGGER.info( + "[PacketCellAction] {} released {} from cell {}", + sender.getName().getString(), + prisonerName, + cell.getId().toString().substring(0, 8) + ); + } + + private static void handleTransfer( + ServerPlayer sender, + CellRegistryV2 registry, + CellDataV2 sourceCell, + UUID prisonerId, + UUID targetCellId + ) { + if (prisonerId == null || targetCellId == null) { + TiedUpMod.LOGGER.debug( + "[PacketCellAction] Missing IDs for TRANSFER" + ); + return; + } + + // Security: Verify the entity is actually a prisoner in the source cell + if (!sourceCell.hasPrisoner(prisonerId)) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + "Entity is not a prisoner in the source cell" + ); + TiedUpMod.LOGGER.debug( + "[PacketCellAction] TRANSFER denied: entity {} is not prisoner in cell {}", + prisonerId.toString().substring(0, 8), + sourceCell.getId().toString().substring(0, 8) + ); + return; + } + + CellDataV2 targetCell = registry.getCell(targetCellId); + if (targetCell == null) { + TiedUpMod.LOGGER.debug("[PacketCellAction] Target cell not found"); + return; + } + + // Verify ownership of target cell too + if ( + !targetCell.canPlayerManage( + sender.getUUID(), + sender.hasPermissions(2) + ) + ) { + TiedUpMod.LOGGER.debug( + "[PacketCellAction] Player doesn't own target cell" + ); + return; + } + + if (targetCell.isFull()) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + "Target cell is full" + ); + return; + } + + // Transfer (HIGH FIX: pass server for state cleanup) + registry.releasePrisoner( + sourceCell.getId(), + prisonerId, + sender.getServer() + ); + + // BUG FIX: Check if assignment succeeded to prevent data desync + boolean assigned = registry.assignPrisoner(targetCellId, prisonerId); + if (!assigned) { + // Assignment failed - re-assign to source cell to prevent data loss + registry.assignPrisoner(sourceCell.getId(), prisonerId); + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + "Failed to transfer prisoner - cell assignment failed" + ); + TiedUpMod.LOGGER.error( + "[PacketCellAction] Failed to assign prisoner {} to target cell {}", + prisonerId, + targetCellId + ); + return; + } + + String prisonerName = getPrisonerName(sender, prisonerId); + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.INFO, + prisonerName + " transferred to " + targetCell.getName() + ); + + TiedUpMod.LOGGER.info( + "[PacketCellAction] {} transferred {} from cell {} to cell {}", + sender.getName().getString(), + prisonerName, + sourceCell.getId().toString().substring(0, 8), + targetCellId.toString().substring(0, 8) + ); + } + + private static void handleTeleport( + ServerPlayer sender, + CellRegistryV2 registry, + CellDataV2 cell, + UUID prisonerId + ) { + if (prisonerId == null) { + TiedUpMod.LOGGER.debug( + "[PacketCellAction] No prisoner ID for TELEPORT" + ); + return; + } + + // Security: Verify the entity is actually a prisoner in this cell + if (!cell.hasPrisoner(prisonerId)) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + "Entity is not a prisoner in this cell" + ); + TiedUpMod.LOGGER.debug( + "[PacketCellAction] TELEPORT denied: entity {} is not prisoner in cell {}", + prisonerId.toString().substring(0, 8), + cell.getId().toString().substring(0, 8) + ); + return; + } + + // Find the prisoner entity + LivingEntity prisoner = findEntity(sender, prisonerId); + if (prisoner == null) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + "Prisoner not found or offline" + ); + return; + } + + // Teleport to cell core position + prisoner.teleportTo( + cell.getCorePos().getX() + 0.5, + cell.getCorePos().getY(), + cell.getCorePos().getZ() + 0.5 + ); + + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.INFO, + prisoner.getName().getString() + " teleported to cell" + ); + + TiedUpMod.LOGGER.info( + "[PacketCellAction] {} teleported {} to cell {}", + sender.getName().getString(), + prisoner.getName().getString(), + cell.getId().toString().substring(0, 8) + ); + } + + private static void handleDeleteCell( + ServerPlayer sender, + CellRegistryV2 registry, + CellDataV2 cell + ) { + UUID cellId = cell.getId(); + String cellName = + cell.getName() != null + ? cell.getName() + : cellId.toString().substring(0, 8); + ServerLevel level = sender.serverLevel(); + + // Release all prisoners first (HIGH FIX: pass server for state cleanup) + for (UUID prisonerId : cell.getPrisonerIds()) { + registry.releasePrisoner(cellId, prisonerId, sender.getServer()); + } + + // Remove the Cell Core block at the core position + BlockPos corePos = cell.getCorePos(); + BlockState state = level.getBlockState(corePos); + if (state.getBlock() instanceof BlockMarker) { + // Destroy the core block (this also triggers cleanup in BlockMarker.onRemove) + level.destroyBlock(corePos, false); + TiedUpMod.LOGGER.debug( + "[PacketCellAction] Destroyed Cell Core at {}", + corePos.toShortString() + ); + } + + // Remove the cell from registry (includes all linked positions) + // Note: This may already be handled by BlockMarker.onRemove, but we do it + // explicitly to ensure cleanup even if the marker block was already gone + registry.removeCell(cellId); + + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.CELL_DELETED + ); + + TiedUpMod.LOGGER.info( + "[PacketCellAction] {} deleted cell {}", + sender.getName().getString(), + cellName + ); + } + + private static String getPrisonerName( + ServerPlayer sender, + UUID prisonerId + ) { + LivingEntity entity = findEntity(sender, prisonerId); + return entity != null + ? entity.getName().getString() + : prisonerId.toString().substring(0, 8); + } + + /** + * CRITICAL FIX: Bounded entity search to prevent DoS. + * Previous implementation used getAllEntities() which scans EVERY entity in the world. + * New implementation uses spatial search with 200-block radius. + */ + private static LivingEntity findEntity(ServerPlayer sender, UUID entityId) { + // Try player first (fast path - O(1) lookup) + Player player = sender.getServer().getPlayerList().getPlayer(entityId); + if (player != null) return player; + + // CRITICAL FIX: Use bounded spatial search instead of getAllEntities() + // Search within 200 blocks of the player (reasonable range for cell operations) + final int SEARCH_RADIUS = 200; + net.minecraft.world.phys.AABB searchBox = + new net.minecraft.world.phys.AABB( + sender.getX() - SEARCH_RADIUS, + sender.getY() - SEARCH_RADIUS, + sender.getZ() - SEARCH_RADIUS, + sender.getX() + SEARCH_RADIUS, + sender.getY() + SEARCH_RADIUS, + sender.getZ() + SEARCH_RADIUS + ); + + // Search only LivingEntity instances within the bounded area + for (LivingEntity entity : sender + .serverLevel() + .getEntitiesOfClass(LivingEntity.class, searchBox, e -> + e.getUUID().equals(entityId) + )) { + return entity; // Found the entity + } + + return null; // Entity not found within search radius + } +} diff --git a/src/main/java/com/tiedup/remake/network/cell/PacketCoreMenuAction.java b/src/main/java/com/tiedup/remake/network/cell/PacketCoreMenuAction.java new file mode 100644 index 0000000..cefae81 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/cell/PacketCoreMenuAction.java @@ -0,0 +1,200 @@ +package com.tiedup.remake.network.cell; + +import com.tiedup.remake.blocks.entity.CellCoreBlockEntity; +import com.tiedup.remake.cells.*; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.PacketRateLimiter; +import java.util.function.Supplier; +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraftforge.network.NetworkEvent; + +/** + * Client → Server packet: handles Cell Core menu button actions. + */ +public class PacketCoreMenuAction { + + public enum Action { + SET_SPAWN, + SET_DELIVERY, + SET_DISGUISE, + RESCAN, + } + + private final BlockPos corePos; + private final Action action; + + public PacketCoreMenuAction(BlockPos corePos, Action action) { + this.corePos = corePos; + this.action = action; + } + + public static void encode(PacketCoreMenuAction msg, FriendlyByteBuf buf) { + buf.writeBlockPos(msg.corePos); + buf.writeEnum(msg.action); + } + + public static PacketCoreMenuAction decode(FriendlyByteBuf buf) { + return new PacketCoreMenuAction( + buf.readBlockPos(), + buf.readEnum(Action.class) + ); + } + + public static void handle( + PacketCoreMenuAction msg, + Supplier ctx + ) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer sender = ctx.get().getSender(); + if (sender == null) return; + + if (!PacketRateLimiter.allowPacket(sender, "core_menu")) { + return; + } + + ServerLevel level = sender.serverLevel(); + BlockEntity be = level.getBlockEntity(msg.corePos); + if ( + !(be instanceof CellCoreBlockEntity core) || + core.getCellId() == null + ) { + return; + } + + CellRegistryV2 registry = CellRegistryV2.get(level); + CellDataV2 cell = registry.getCell(core.getCellId()); + if (cell == null) return; + + // Verify ownership + if ( + !cell.canPlayerManage( + sender.getUUID(), + sender.hasPermissions(2) + ) + ) { + sender.displayClientMessage( + Component.translatable( + "msg.tiedup.cell_core.not_owner" + ), + true + ); + return; + } + + switch (msg.action) { + case SET_SPAWN -> { + CellSelectionManager.startSelection( + sender.getUUID(), + SelectionMode.SET_SPAWN, + msg.corePos, + cell.getId(), + sender.blockPosition() + ); + sender.displayClientMessage( + Component.translatable( + "msg.tiedup.cell_core.selection.spawn" + ).withStyle(ChatFormatting.YELLOW), + true + ); + } + case SET_DELIVERY -> { + CellSelectionManager.startSelection( + sender.getUUID(), + SelectionMode.SET_DELIVERY, + msg.corePos, + cell.getId(), + sender.blockPosition() + ); + sender.displayClientMessage( + Component.translatable( + "msg.tiedup.cell_core.selection.delivery" + ).withStyle(ChatFormatting.YELLOW), + true + ); + } + case SET_DISGUISE -> { + CellSelectionManager.startSelection( + sender.getUUID(), + SelectionMode.SET_DISGUISE, + msg.corePos, + cell.getId(), + sender.blockPosition() + ); + sender.displayClientMessage( + Component.translatable( + "msg.tiedup.cell_core.selection.disguise" + ).withStyle(ChatFormatting.YELLOW), + true + ); + } + case RESCAN -> handleRescan( + sender, + registry, + cell, + core, + level, + msg.corePos + ); + } + }); + + ctx.get().setPacketHandled(true); + } + + private static void handleRescan( + ServerPlayer sender, + CellRegistryV2 registry, + CellDataV2 cell, + CellCoreBlockEntity core, + ServerLevel level, + BlockPos corePos + ) { + FloodFillResult result = FloodFillAlgorithm.tryFill(level, corePos); + + if (result.isSuccess()) { + registry.rescanCell(cell.getId(), result); + + // Sync spawn/delivery from Core BE to CellDataV2 + if (core.getSpawnPoint() != null) { + cell.setSpawnPoint(core.getSpawnPoint()); + } + if (core.getDeliveryPoint() != null) { + cell.setDeliveryPoint(core.getDeliveryPoint()); + } + + sender.displayClientMessage( + Component.translatable( + "msg.tiedup.cell_core.rescan_success", + result.getInterior().size(), + result.getWalls().size() + ).withStyle(ChatFormatting.GREEN), + true + ); + + TiedUpMod.LOGGER.info( + "[PacketCoreMenuAction] {} rescanned cell {} ({} interior, {} walls)", + sender.getName().getString(), + cell.getId().toString().substring(0, 8), + result.getInterior().size(), + result.getWalls().size() + ); + } else { + sender.displayClientMessage( + Component.translatable( + "msg.tiedup.cell_core.rescan_fail", + Component.translatable(result.getErrorKey()) + ).withStyle(ChatFormatting.RED), + true + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/network/cell/PacketOpenCellManager.java b/src/main/java/com/tiedup/remake/network/cell/PacketOpenCellManager.java new file mode 100644 index 0000000..ec7120d --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/cell/PacketOpenCellManager.java @@ -0,0 +1,218 @@ +package com.tiedup.remake.network.cell; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.base.AbstractClientPacket; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Packet to open the CellManager screen on the client. + * Sends list of cells accessible to the player. + * + * For operators: sends ALL cells with ability to manage any + * For non-operators: sends only owned cells + * + * Server -> Client + */ +public class PacketOpenCellManager extends AbstractClientPacket { + + private final List cells; + private final boolean isOperator; + + /** + * Network-serializable cell data. + */ + public static class CellSyncData { + + public final UUID cellId; + public final String name; + public final BlockPos spawnPoint; + public final int prisonerCount; + public final int maxPrisoners; + public final List prisoners; + public final boolean isOwned; // true if the viewing player owns this cell + public final String ownerName; // Owner's name (for OPs viewing other players' cells) + + public CellSyncData( + UUID cellId, + String name, + BlockPos spawnPoint, + int prisonerCount, + int maxPrisoners, + List prisoners, + boolean isOwned, + String ownerName + ) { + this.cellId = cellId; + this.name = name; + this.spawnPoint = spawnPoint; + this.prisonerCount = prisonerCount; + this.maxPrisoners = maxPrisoners; + this.prisoners = prisoners != null ? prisoners : new ArrayList<>(); + this.isOwned = isOwned; + this.ownerName = ownerName; + } + + /** + * Legacy constructor for backward compatibility. + */ + public CellSyncData( + UUID cellId, + String name, + BlockPos spawnPoint, + int prisonerCount, + int maxPrisoners, + List prisoners + ) { + this( + cellId, + name, + spawnPoint, + prisonerCount, + maxPrisoners, + prisoners, + true, + null + ); + } + + /** + * Get display name (name or truncated UUID). + */ + public String getDisplayName() { + if (name != null && !name.isEmpty()) { + return name; + } + return "Cell " + cellId.toString().substring(0, 8); + } + } + + /** + * Network-serializable prisoner info. + */ + public static class PrisonerInfo { + + public final UUID prisonerId; + public final String prisonerName; + + public PrisonerInfo(UUID prisonerId, String prisonerName) { + this.prisonerId = prisonerId; + this.prisonerName = prisonerName; + } + } + + public PacketOpenCellManager(List cells, boolean isOperator) { + this.cells = cells != null ? cells : new ArrayList<>(); + this.isOperator = isOperator; + } + + /** + * Legacy constructor for backward compatibility (assumes not operator). + */ + public PacketOpenCellManager(List cells) { + this(cells, false); + } + + public void encode(FriendlyByteBuf buf) { + buf.writeBoolean(this.isOperator); + buf.writeInt(this.cells.size()); + + for (CellSyncData cell : this.cells) { + buf.writeUUID(cell.cellId); + buf.writeBoolean(cell.name != null); + if (cell.name != null) { + buf.writeUtf(cell.name, 64); + } + buf.writeBlockPos(cell.spawnPoint); + buf.writeInt(cell.prisonerCount); + buf.writeInt(cell.maxPrisoners); + buf.writeBoolean(cell.isOwned); + buf.writeBoolean(cell.ownerName != null); + if (cell.ownerName != null) { + buf.writeUtf(cell.ownerName, 64); + } + + // Write prisoners + buf.writeInt(cell.prisoners.size()); + for (PrisonerInfo prisoner : cell.prisoners) { + buf.writeUUID(prisoner.prisonerId); + buf.writeUtf(prisoner.prisonerName, 64); + } + } + } + + public static PacketOpenCellManager decode(FriendlyByteBuf buf) { + boolean isOperator = buf.readBoolean(); + int cellCount = buf.readInt(); + List cells = new ArrayList<>(); + + for (int i = 0; i < cellCount; i++) { + UUID cellId = buf.readUUID(); + String name = buf.readBoolean() ? buf.readUtf(64) : null; + BlockPos spawnPoint = buf.readBlockPos(); + int prisonerCount = buf.readInt(); + int maxPrisoners = buf.readInt(); + boolean isOwned = buf.readBoolean(); + String ownerName = buf.readBoolean() ? buf.readUtf(64) : null; + + // Read prisoners + int prisonerListSize = buf.readInt(); + List prisoners = new ArrayList<>(); + for (int j = 0; j < prisonerListSize; j++) { + UUID prisonerId = buf.readUUID(); + String prisonerName = buf.readUtf(64); + prisoners.add(new PrisonerInfo(prisonerId, prisonerName)); + } + + cells.add( + new CellSyncData( + cellId, + name, + spawnPoint, + prisonerCount, + maxPrisoners, + prisoners, + isOwned, + ownerName + ) + ); + } + + return new PacketOpenCellManager(cells, isOperator); + } + + @Override + @OnlyIn(Dist.CLIENT) + protected void handleClientImpl() { + ClientHandler.handle(this); + } + + @OnlyIn(Dist.CLIENT) + private static class ClientHandler { + private static void handle(PacketOpenCellManager pkt) { + net.minecraft.client.Minecraft mc = net.minecraft.client.Minecraft.getInstance(); + if (mc.player == null) return; + + TiedUpMod.LOGGER.info( + "[PacketOpenCellManager] Opening cell manager with {} cells (operator: {})", + pkt.cells.size(), + pkt.isOperator + ); + + mc.setScreen(new com.tiedup.remake.client.gui.screens.CellManagerScreen(pkt.cells, pkt.isOperator)); + } + } + + public List getCells() { + return cells; + } + + public boolean isOperator() { + return isOperator; + } +} diff --git a/src/main/java/com/tiedup/remake/network/cell/PacketOpenCellSelector.java b/src/main/java/com/tiedup/remake/network/cell/PacketOpenCellSelector.java new file mode 100644 index 0000000..c5fbe04 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/cell/PacketOpenCellSelector.java @@ -0,0 +1,112 @@ +package com.tiedup.remake.network.cell; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.base.AbstractClientPacket; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Packet to open the CellSelector screen on the client. + * Server -> Client + */ +public class PacketOpenCellSelector extends AbstractClientPacket { + + private final UUID targetEntityUUID; // The entity whose collar we want to assign + private final List cells; + + /** + * Cell option for the selector. + */ + public static class CellOption { + + public final UUID cellId; + public final String displayName; + public final int prisonerCount; + public final int maxPrisoners; + + public CellOption( + UUID cellId, + String displayName, + int prisonerCount, + int maxPrisoners + ) { + this.cellId = cellId; + this.displayName = displayName; + this.prisonerCount = prisonerCount; + this.maxPrisoners = maxPrisoners; + } + } + + public PacketOpenCellSelector( + UUID targetEntityUUID, + List cells + ) { + this.targetEntityUUID = targetEntityUUID; + this.cells = cells != null ? cells : new ArrayList<>(); + } + + public void encode(FriendlyByteBuf buf) { + buf.writeUUID(this.targetEntityUUID); + buf.writeInt(this.cells.size()); + + for (CellOption cell : this.cells) { + buf.writeUUID(cell.cellId); + buf.writeUtf(cell.displayName, 64); + buf.writeInt(cell.prisonerCount); + buf.writeInt(cell.maxPrisoners); + } + } + + public static PacketOpenCellSelector decode(FriendlyByteBuf buf) { + UUID targetEntityUUID = buf.readUUID(); + int cellCount = buf.readInt(); + + List cells = new ArrayList<>(); + for (int i = 0; i < cellCount; i++) { + UUID cellId = buf.readUUID(); + String displayName = buf.readUtf(64); + int prisonerCount = buf.readInt(); + int maxPrisoners = buf.readInt(); + + cells.add( + new CellOption(cellId, displayName, prisonerCount, maxPrisoners) + ); + } + + return new PacketOpenCellSelector(targetEntityUUID, cells); + } + + @Override + @OnlyIn(Dist.CLIENT) + protected void handleClientImpl() { + ClientHandler.handle(this); + } + + @OnlyIn(Dist.CLIENT) + private static class ClientHandler { + private static void handle(PacketOpenCellSelector pkt) { + net.minecraft.client.Minecraft mc = net.minecraft.client.Minecraft.getInstance(); + if (mc.player == null) return; + + TiedUpMod.LOGGER.info( + "[PacketOpenCellSelector] Opening cell selector with {} options for target {}", + pkt.cells.size(), + pkt.targetEntityUUID.toString().substring(0, 8) + ); + + mc.setScreen(new com.tiedup.remake.client.gui.screens.CellSelectorScreen(pkt.targetEntityUUID, pkt.cells)); + } + } + + public UUID getTargetEntityUUID() { + return targetEntityUUID; + } + + public List getCells() { + return cells; + } +} diff --git a/src/main/java/com/tiedup/remake/network/cell/PacketOpenCoreMenu.java b/src/main/java/com/tiedup/remake/network/cell/PacketOpenCoreMenu.java new file mode 100644 index 0000000..749a353 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/cell/PacketOpenCoreMenu.java @@ -0,0 +1,128 @@ +package com.tiedup.remake.network.cell; + +import com.tiedup.remake.network.base.AbstractClientPacket; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Server → Client packet: opens the Cell Core menu with all cell info. + * Carries all data so the Info panel works without a round-trip. + */ +public class PacketOpenCoreMenu extends AbstractClientPacket { + + private final BlockPos corePos; + private final UUID cellId; + private final String cellName; + private final String stateName; + private final int interiorVolume; + private final int wallCount; + private final int breachCount; + private final int prisonerCount; + private final int bedCount; + private final int doorCount; + private final int anchorCount; + private final boolean hasSpawn; + private final boolean hasDelivery; + private final boolean hasDisguise; + + public PacketOpenCoreMenu( + BlockPos corePos, + UUID cellId, + String cellName, + String stateName, + int interiorVolume, + int wallCount, + int breachCount, + int prisonerCount, + int bedCount, + int doorCount, + int anchorCount, + boolean hasSpawn, + boolean hasDelivery, + boolean hasDisguise + ) { + this.corePos = corePos; + this.cellId = cellId; + this.cellName = cellName; + this.stateName = stateName; + this.interiorVolume = interiorVolume; + this.wallCount = wallCount; + this.breachCount = breachCount; + this.prisonerCount = prisonerCount; + this.bedCount = bedCount; + this.doorCount = doorCount; + this.anchorCount = anchorCount; + this.hasSpawn = hasSpawn; + this.hasDelivery = hasDelivery; + this.hasDisguise = hasDisguise; + } + + public static void encode(PacketOpenCoreMenu msg, FriendlyByteBuf buf) { + buf.writeBlockPos(msg.corePos); + buf.writeUUID(msg.cellId); + buf.writeUtf(msg.cellName, 64); + buf.writeUtf(msg.stateName, 32); + buf.writeVarInt(msg.interiorVolume); + buf.writeVarInt(msg.wallCount); + buf.writeVarInt(msg.breachCount); + buf.writeVarInt(msg.prisonerCount); + buf.writeVarInt(msg.bedCount); + buf.writeVarInt(msg.doorCount); + buf.writeVarInt(msg.anchorCount); + buf.writeBoolean(msg.hasSpawn); + buf.writeBoolean(msg.hasDelivery); + buf.writeBoolean(msg.hasDisguise); + } + + public static PacketOpenCoreMenu decode(FriendlyByteBuf buf) { + return new PacketOpenCoreMenu( + buf.readBlockPos(), + buf.readUUID(), + buf.readUtf(64), + buf.readUtf(32), + buf.readVarInt(), + buf.readVarInt(), + buf.readVarInt(), + buf.readVarInt(), + buf.readVarInt(), + buf.readVarInt(), + buf.readVarInt(), + buf.readBoolean(), + buf.readBoolean(), + buf.readBoolean() + ); + } + + @Override + @OnlyIn(Dist.CLIENT) + protected void handleClientImpl() { + ClientHandler.handle(this); + } + + @OnlyIn(Dist.CLIENT) + private static class ClientHandler { + private static void handle(PacketOpenCoreMenu pkt) { + net.minecraft.client.Minecraft.getInstance().setScreen( + new com.tiedup.remake.client.gui.screens.CellCoreScreen( + pkt.corePos, + pkt.cellId, + pkt.cellName, + pkt.stateName, + pkt.interiorVolume, + pkt.wallCount, + pkt.breachCount, + pkt.prisonerCount, + pkt.bedCount, + pkt.doorCount, + pkt.anchorCount, + pkt.hasSpawn, + pkt.hasDelivery, + pkt.hasDisguise + ) + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/network/cell/PacketRenameCell.java b/src/main/java/com/tiedup/remake/network/cell/PacketRenameCell.java new file mode 100644 index 0000000..4c4c7b5 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/cell/PacketRenameCell.java @@ -0,0 +1,102 @@ +package com.tiedup.remake.network.cell; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.PacketRateLimiter; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet to rename a cell. + * Client -> Server + */ +public class PacketRenameCell { + + private final UUID cellId; + private final String newName; + + public PacketRenameCell(UUID cellId, String newName) { + this.cellId = cellId; + this.newName = newName; + } + + public static void encode(PacketRenameCell msg, FriendlyByteBuf buf) { + buf.writeUUID(msg.cellId); + buf.writeUtf(msg.newName, 64); + } + + public static PacketRenameCell decode(FriendlyByteBuf buf) { + UUID cellId = buf.readUUID(); + String newName = buf.readUtf(64); + return new PacketRenameCell(cellId, newName); + } + + public static void handle( + PacketRenameCell msg, + Supplier ctx + ) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer sender = ctx.get().getSender(); + if (sender == null) return; + if (!PacketRateLimiter.allowPacket(sender, "action")) return; + + CellRegistryV2 registry = CellRegistryV2.get( + sender.serverLevel() + ); + CellDataV2 cell = registry.getCell(msg.cellId); + + if (cell == null) { + TiedUpMod.LOGGER.debug( + "[PacketRenameCell] Cell not found: {}", + msg.cellId + ); + return; + } + + // Verify ownership + if ( + !cell.canPlayerManage( + sender.getUUID(), + sender.hasPermissions(2) + ) + ) { + TiedUpMod.LOGGER.debug( + "[PacketRenameCell] Player is not cell owner" + ); + return; + } + + // Sanitize name + String sanitizedName = msg.newName.trim(); + if (sanitizedName.length() > 32) { + sanitizedName = sanitizedName.substring(0, 32); + } + + // Set name (null to clear) + cell.setName(sanitizedName.isEmpty() ? null : sanitizedName); + registry.setDirty(); + + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.CELL_RENAMED, + sanitizedName.isEmpty() ? "(cleared)" : sanitizedName + ); + + TiedUpMod.LOGGER.info( + "[PacketRenameCell] {} renamed cell {} to '{}'", + sender.getName().getString(), + msg.cellId.toString().substring(0, 8), + sanitizedName + ); + }); + + ctx.get().setPacketHandled(true); + } +} diff --git a/src/main/java/com/tiedup/remake/network/cell/PacketRequestCellList.java b/src/main/java/com/tiedup/remake/network/cell/PacketRequestCellList.java new file mode 100644 index 0000000..15d1e24 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/cell/PacketRequestCellList.java @@ -0,0 +1,92 @@ +package com.tiedup.remake.network.cell; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.PacketRateLimiter; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet to request the list of cells owned by the player. + * Used to populate the CellSelectorScreen. + * + * Client -> Server (triggers PacketOpenCellSelector response) + */ +public class PacketRequestCellList { + + private final UUID targetEntityUUID; // The entity whose collar we want to assign + + public PacketRequestCellList(UUID targetEntityUUID) { + this.targetEntityUUID = targetEntityUUID; + } + + public static void encode(PacketRequestCellList msg, FriendlyByteBuf buf) { + buf.writeUUID(msg.targetEntityUUID); + } + + public static PacketRequestCellList decode(FriendlyByteBuf buf) { + UUID targetEntityUUID = buf.readUUID(); + return new PacketRequestCellList(targetEntityUUID); + } + + public static void handle( + PacketRequestCellList msg, + Supplier ctx + ) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer sender = ctx.get().getSender(); + if (sender == null) return; + if (!PacketRateLimiter.allowPacket(sender, "ui")) return; + + CellRegistryV2 registry = CellRegistryV2.get( + sender.serverLevel() + ); + List playerCells = registry.getCellsByOwner( + sender.getUUID() + ); + + // Build cell list for packet + List options = + new ArrayList<>(); + for (CellDataV2 cell : playerCells) { + String displayName = + cell.getName() != null + ? cell.getName() + : "Cell " + cell.getId().toString().substring(0, 8); + + options.add( + new PacketOpenCellSelector.CellOption( + cell.getId(), + displayName, + cell.getPrisonerCount(), + 4 // MAX_PRISONERS + ) + ); + } + + // Send response packet to open the selector + ModNetwork.sendToPlayer( + new PacketOpenCellSelector(msg.targetEntityUUID, options), + sender + ); + + TiedUpMod.LOGGER.debug( + "[PacketRequestCellList] Sent {} cells to {}", + options.size(), + sender.getName().getString() + ); + }); + + ctx.get().setPacketHandled(true); + } +} diff --git a/src/main/java/com/tiedup/remake/network/cell/PacketSyncCellData.java b/src/main/java/com/tiedup/remake/network/cell/PacketSyncCellData.java new file mode 100644 index 0000000..d879c16 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/cell/PacketSyncCellData.java @@ -0,0 +1,256 @@ +package com.tiedup.remake.network.cell; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.MarkerType; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.base.AbstractClientPacket; +import java.util.*; +import net.minecraft.core.BlockPos; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Packet to sync cell data from server to client. + * This enables cell outline rendering on dedicated servers. + * + * Server -> Client + * + * Phase: Kidnapper Revamp - Cell System Network Sync + */ +public class PacketSyncCellData extends AbstractClientPacket { + + private final UUID cellId; + private final BlockPos spawnPoint; + private final Map> positions; + private final List pathWaypoints; + private final String name; + private final UUID ownerId; + + /** + * Create a sync packet for a V2 cell. + * Maps V2 feature lists back to MarkerType sets for wire format compatibility. + */ + public PacketSyncCellData(CellDataV2 cell) { + this.cellId = cell.getId(); + this.spawnPoint = cell.getCorePos(); + this.positions = new EnumMap<>(MarkerType.class); + this.pathWaypoints = new ArrayList<>(cell.getPathWaypoints()); + this.name = cell.getName(); + this.ownerId = cell.getOwnerId(); + + // Map V2 feature lists to MarkerType sets for serialization + if (!cell.getWallBlocks().isEmpty()) { + this.positions.put( + MarkerType.WALL, + new HashSet<>(cell.getWallBlocks()) + ); + } + if (!cell.getAnchors().isEmpty()) { + this.positions.put( + MarkerType.ANCHOR, + new HashSet<>(cell.getAnchors()) + ); + } + if (!cell.getDoors().isEmpty()) { + this.positions.put(MarkerType.DOOR, new HashSet<>(cell.getDoors())); + } + if (!cell.getBeds().isEmpty()) { + this.positions.put(MarkerType.BED, new HashSet<>(cell.getBeds())); + } + BlockPos delivery = cell.getDeliveryPoint(); + if (delivery != null) { + Set deliverySet = new HashSet<>(); + deliverySet.add(delivery); + this.positions.put(MarkerType.DELIVERY, deliverySet); + } + } + + /** + * Private constructor for decoding. + */ + private PacketSyncCellData( + UUID cellId, + BlockPos spawnPoint, + Map> positions, + List pathWaypoints, + String name, + UUID ownerId + ) { + this.cellId = cellId; + this.spawnPoint = spawnPoint; + this.positions = positions; + this.pathWaypoints = pathWaypoints; + this.name = name; + this.ownerId = ownerId; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeUUID(this.cellId); + buf.writeBlockPos(this.spawnPoint); + + // Write name (nullable) + buf.writeBoolean(this.name != null); + if (this.name != null) { + buf.writeUtf(this.name, 64); + } + + // Write owner (nullable) + buf.writeBoolean(this.ownerId != null); + if (this.ownerId != null) { + buf.writeUUID(this.ownerId); + } + + // Write positions by type + // First, count non-empty types + int typeCount = 0; + for (MarkerType type : MarkerType.values()) { + if ( + this.positions.containsKey(type) && + !this.positions.get(type).isEmpty() + ) { + typeCount++; + } + } + buf.writeInt(typeCount); + + // Write each non-empty type + for (MarkerType type : MarkerType.values()) { + Set typePositions = this.positions.get(type); + if (typePositions != null && !typePositions.isEmpty()) { + buf.writeUtf(type.getSerializedName(), 32); + buf.writeInt(typePositions.size()); + for (BlockPos pos : typePositions) { + buf.writeBlockPos(pos); + } + } + } + + // Write path waypoints + buf.writeInt(this.pathWaypoints.size()); + for (BlockPos wp : this.pathWaypoints) { + buf.writeBlockPos(wp); + } + } + + public static PacketSyncCellData decode(FriendlyByteBuf buf) { + UUID cellId = buf.readUUID(); + BlockPos spawnPoint = buf.readBlockPos(); + + // Read name + String name = buf.readBoolean() ? buf.readUtf(64) : null; + + // Read owner + UUID ownerId = buf.readBoolean() ? buf.readUUID() : null; + + // Read positions + Map> positions = new EnumMap<>( + MarkerType.class + ); + int typeCount = buf.readInt(); + + for (int i = 0; i < typeCount; i++) { + String typeName = buf.readUtf(32); + MarkerType type = MarkerType.fromString(typeName); + + int posCount = buf.readInt(); + Set typePositions = new HashSet<>(); + for (int j = 0; j < posCount; j++) { + typePositions.add(buf.readBlockPos()); + } + positions.put(type, typePositions); + } + + // Read path waypoints + int waypointCount = buf.readInt(); + List pathWaypoints = new ArrayList<>(waypointCount); + for (int i = 0; i < waypointCount; i++) { + pathWaypoints.add(buf.readBlockPos()); + } + + return new PacketSyncCellData( + cellId, + spawnPoint, + positions, + pathWaypoints, + name, + ownerId + ); + } + + @Override + @OnlyIn(Dist.CLIENT) + protected void handleClientImpl() { + ClientHandler.handle(this); + } + + @OnlyIn(Dist.CLIENT) + private static class ClientHandler { + private static void handle(PacketSyncCellData pkt) { + TiedUpMod.LOGGER.debug( + "[PacketSyncCellData] Received cell sync: {} at {} with {} position types, {} waypoints", + pkt.cellId.toString().substring(0, 8), + pkt.spawnPoint.toShortString(), + pkt.positions.size(), + pkt.pathWaypoints.size() + ); + + // Reconstruct a CellDataV2 from the wire data + CellDataV2 cell = new CellDataV2(pkt.cellId, pkt.spawnPoint); + cell.setName(pkt.name); + cell.setOwnerId(pkt.ownerId); + + // Populate geometry from MarkerType positions + for (BlockPos pos : pkt.positions.getOrDefault( + MarkerType.WALL, + Collections.emptySet() + )) { + cell.addWallBlock(pos); + } + for (BlockPos pos : pkt.positions.getOrDefault( + MarkerType.ANCHOR, + Collections.emptySet() + )) { + cell.addAnchor(pos); + } + for (BlockPos pos : pkt.positions.getOrDefault( + MarkerType.DOOR, + Collections.emptySet() + )) { + cell.addDoor(pos); + } + for (BlockPos pos : pkt.positions.getOrDefault( + MarkerType.BED, + Collections.emptySet() + )) { + cell.addBed(pos); + } + Set delivery = pkt.positions.getOrDefault( + MarkerType.DELIVERY, + Collections.emptySet() + ); + if (!delivery.isEmpty()) { + cell.setDeliveryPoint(delivery.iterator().next()); + } + + // Add path waypoints + cell.setPathWaypoints(pkt.pathWaypoints); + + // Update the client-side cache + com.tiedup.remake.client.events.CellHighlightHandler.updateCachedCell(cell); + } + } + + // Getters for testing/debugging + public UUID getCellId() { + return cellId; + } + + public BlockPos getSpawnPoint() { + return spawnPoint; + } + + public Map> getPositions() { + return Collections.unmodifiableMap(positions); + } +} diff --git a/src/main/java/com/tiedup/remake/network/conversation/PacketEndConversationC2S.java b/src/main/java/com/tiedup/remake/network/conversation/PacketEndConversationC2S.java new file mode 100644 index 0000000..9ed66d8 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/conversation/PacketEndConversationC2S.java @@ -0,0 +1,65 @@ +package com.tiedup.remake.network.conversation; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.conversation.ConversationManager; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.network.PacketRateLimiter; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraftforge.network.NetworkEvent; + +/** + * Client-to-Server packet: player closes conversation GUI. + * + * Split from the former bidirectional PacketEndConversation (H16 fix). + */ +public class PacketEndConversationC2S { + + private final int entityId; + + public PacketEndConversationC2S(int entityId) { + this.entityId = entityId; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeInt(entityId); + } + + public static PacketEndConversationC2S decode(FriendlyByteBuf buf) { + return new PacketEndConversationC2S(buf.readInt()); + } + + public void handle(Supplier ctx) { + ctx.get().enqueueWork(() -> { + ServerPlayer sender = ctx.get().getSender(); + if (sender == null) return; + + if (!PacketRateLimiter.allowPacket(sender, "action")) return; + + TiedUpMod.LOGGER.info( + "[PacketEndConversationC2S] {} ended conversation with entity {}", + sender.getName().getString(), + entityId + ); + + // Get the damsel entity to properly end with cooldown + EntityDamsel damsel = null; + Entity entity = sender.level().getEntity(entityId); + if (entity instanceof EntityDamsel d) { + damsel = d; + } + + // Always clean up conversation state — this is a teardown packet. + // Distance check removed: blocking cleanup causes permanent state leak + // in ConversationManager.activeConversations (reviewer H18 BUG-001). + ConversationManager.endConversation(sender, damsel); + }); + ctx.get().setPacketHandled(true); + } + + public int getEntityId() { + return entityId; + } +} diff --git a/src/main/java/com/tiedup/remake/network/conversation/PacketEndConversationS2C.java b/src/main/java/com/tiedup/remake/network/conversation/PacketEndConversationS2C.java new file mode 100644 index 0000000..8cfe383 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/conversation/PacketEndConversationS2C.java @@ -0,0 +1,63 @@ +package com.tiedup.remake.network.conversation; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.network.NetworkEvent; + +/** + * Server-to-Client packet: server forces conversation end + * (e.g., NPC died, player moved too far). + * + * Split from the former bidirectional PacketEndConversation (H16 fix). + */ +public class PacketEndConversationS2C { + + private final int entityId; + + public PacketEndConversationS2C(int entityId) { + this.entityId = entityId; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeInt(entityId); + } + + public static PacketEndConversationS2C decode(FriendlyByteBuf buf) { + return new PacketEndConversationS2C(buf.readInt()); + } + + public void handle(Supplier ctx) { + ctx.get().enqueueWork(this::handleClient); + ctx.get().setPacketHandled(true); + } + + @OnlyIn(Dist.CLIENT) + private void handleClient() { + ClientHandler.handle(this); + } + + public int getEntityId() { + return entityId; + } + + @OnlyIn(Dist.CLIENT) + private static class ClientHandler { + private static void handle(PacketEndConversationS2C pkt) { + TiedUpMod.LOGGER.info( + "[PacketEndConversationS2C] Server ended conversation with entity {}", + pkt.entityId + ); + + // Close any open conversation screen + if ( + net.minecraft.client.Minecraft.getInstance().screen instanceof + com.tiedup.remake.client.gui.screens.ConversationScreen + ) { + net.minecraft.client.Minecraft.getInstance().setScreen(null); + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/network/conversation/PacketOpenConversation.java b/src/main/java/com/tiedup/remake/network/conversation/PacketOpenConversation.java new file mode 100644 index 0000000..518c7ab --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/conversation/PacketOpenConversation.java @@ -0,0 +1,109 @@ +package com.tiedup.remake.network.conversation; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.conversation.ConversationTopic; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet sent from server to client to open the conversation GUI. + * Contains the entity ID, NPC name, and available conversation topics. + * + * Phase 14: Conversation System + */ +public class PacketOpenConversation { + + private final int entityId; + private final String npcName; + private final List availableTopics; + + public PacketOpenConversation( + int entityId, + String npcName, + List topics + ) { + this.entityId = entityId; + this.npcName = npcName; + this.availableTopics = new ArrayList<>(); + for (ConversationTopic topic : topics) { + this.availableTopics.add(topic.name()); + } + } + + private PacketOpenConversation( + int entityId, + String npcName, + List topicNames, + boolean raw + ) { + this.entityId = entityId; + this.npcName = npcName; + this.availableTopics = topicNames; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeInt(entityId); + buf.writeUtf(npcName, 64); + buf.writeInt(availableTopics.size()); + for (String topic : availableTopics) { + buf.writeUtf(topic, 64); + } + } + + public static PacketOpenConversation decode(FriendlyByteBuf buf) { + int entityId = buf.readInt(); + String npcName = buf.readUtf(64); + int topicCount = buf.readInt(); + List topics = new ArrayList<>(topicCount); + for (int i = 0; i < topicCount; i++) { + topics.add(buf.readUtf(64)); + } + return new PacketOpenConversation(entityId, npcName, topics, true); + } + + public void handle(Supplier ctx) { + ctx.get().enqueueWork(() -> handleClient()); + ctx.get().setPacketHandled(true); + } + + private void handleClient() { + // DISABLED: Conversation system not in use + TiedUpMod.LOGGER.info( + "[PacketOpenConversation] Conversation system disabled - ignoring request" + ); + + /* + // Convert topic names back to enum values + List topics = new ArrayList<>(); + for (String topicName : availableTopics) { + try { + topics.add(ConversationTopic.valueOf(topicName)); + } catch (IllegalArgumentException e) { + TiedUpMod.LOGGER.warn( + "[PacketOpenConversation] Unknown topic: {}", + topicName + ); + } + } + + // Open the conversation screen + // Delegate to client-only helper to avoid class loading on server + com.tiedup.remake.client.network.ClientPacketHandler.openConversationScreen(entityId, npcName, topics); + */ + } + + public int getEntityId() { + return entityId; + } + + public String getNpcName() { + return npcName; + } + + public List getAvailableTopics() { + return availableTopics; + } +} diff --git a/src/main/java/com/tiedup/remake/network/conversation/PacketRequestConversation.java b/src/main/java/com/tiedup/remake/network/conversation/PacketRequestConversation.java new file mode 100644 index 0000000..b2c7802 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/conversation/PacketRequestConversation.java @@ -0,0 +1,108 @@ +package com.tiedup.remake.network.conversation; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.IDialogueSpeaker; +import com.tiedup.remake.dialogue.conversation.ConversationManager; +import com.tiedup.remake.network.PacketRateLimiter; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet sent from client to server to request opening a conversation. + * Sent when player clicks the "Ask..." button in DialogueScreen. + * + * Phase 14: Conversation System + * + * DISABLED: Conversation system not in use. Kept because it is still registered + * in ModNetwork — removing a registered packet would shift packet IDs. + */ +public class PacketRequestConversation { + + /** Maximum distance for conversation request */ + private static final double MAX_DISTANCE = 6.0; + + private final int entityId; + + public PacketRequestConversation(int entityId) { + this.entityId = entityId; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeInt(entityId); + } + + public static PacketRequestConversation decode(FriendlyByteBuf buf) { + int entityId = buf.readInt(); + return new PacketRequestConversation(entityId); + } + + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer sender = ctx.get().getSender(); + if (sender == null) return; + + handleServer(sender); + }); + ctx.get().setPacketHandled(true); + } + + private void handleServer(ServerPlayer sender) { + if (!PacketRateLimiter.allowPacket(sender, "action")) return; + + // Find the target entity + Entity entity = sender.level().getEntity(entityId); + if (entity == null) { + TiedUpMod.LOGGER.warn( + "[PacketRequestConversation] Entity {} not found", + entityId + ); + return; + } + + // Check if entity is a dialogue speaker + if (!(entity instanceof IDialogueSpeaker speaker)) { + TiedUpMod.LOGGER.warn( + "[PacketRequestConversation] Entity {} is not a dialogue speaker", + entityId + ); + return; + } + + // Validate distance + double distance = sender.distanceTo(entity); + if (distance > MAX_DISTANCE) { + TiedUpMod.LOGGER.warn( + "[PacketRequestConversation] Player {} too far from entity {} ({})", + sender.getName().getString(), + entityId, + distance + ); + return; + } + + TiedUpMod.LOGGER.info( + "[PacketRequestConversation] {} requesting conversation with {}", + sender.getName().getString(), + speaker.getDialogueName() + ); + + // Open the conversation - this will send PacketOpenConversation to client + boolean success = ConversationManager.openConversation(speaker, sender); + + if (!success) { + TiedUpMod.LOGGER.warn( + "[PacketRequestConversation] Failed to open conversation with {}", + speaker.getDialogueName() + ); + } + } + + public int getEntityId() { + return entityId; + } +} diff --git a/src/main/java/com/tiedup/remake/network/conversation/PacketSelectTopic.java b/src/main/java/com/tiedup/remake/network/conversation/PacketSelectTopic.java new file mode 100644 index 0000000..661f292 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/conversation/PacketSelectTopic.java @@ -0,0 +1,125 @@ +package com.tiedup.remake.network.conversation; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.IDialogueSpeaker; +import com.tiedup.remake.dialogue.conversation.ConversationManager; +import com.tiedup.remake.dialogue.conversation.ConversationTopic; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.network.PacketRateLimiter; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet sent from client to server when player selects a conversation topic. + * + * Phase 14: Conversation System + */ +public class PacketSelectTopic { + + /** Maximum distance for conversation */ + private static final double MAX_DISTANCE = 5.0; + + private final int entityId; + private final String topicName; + + public PacketSelectTopic(int entityId, ConversationTopic topic) { + this.entityId = entityId; + this.topicName = topic.name(); + } + + private PacketSelectTopic(int entityId, String topicName) { + this.entityId = entityId; + this.topicName = topicName; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeInt(entityId); + buf.writeUtf(topicName, 64); + } + + public static PacketSelectTopic decode(FriendlyByteBuf buf) { + int entityId = buf.readInt(); + String topicName = buf.readUtf(64); + return new PacketSelectTopic(entityId, topicName); + } + + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer sender = ctx.get().getSender(); + if (sender == null) return; + + handleServer(sender); + }); + ctx.get().setPacketHandled(true); + } + + private void handleServer(ServerPlayer sender) { + if (!PacketRateLimiter.allowPacket(sender, "action")) return; + + // Find the target entity + Entity entity = sender.level().getEntity(entityId); + if (entity == null) { + TiedUpMod.LOGGER.warn( + "[PacketSelectTopic] Entity {} not found", + entityId + ); + return; + } + + // Check if entity is a dialogue speaker + if (!(entity instanceof IDialogueSpeaker speaker)) { + TiedUpMod.LOGGER.warn( + "[PacketSelectTopic] Entity {} is not a dialogue speaker", + entityId + ); + return; + } + + // Validate distance + double distance = sender.distanceTo(entity); + if (distance > MAX_DISTANCE) { + TiedUpMod.LOGGER.warn( + "[PacketSelectTopic] Player {} too far from entity {} ({})", + sender.getName().getString(), + entityId, + distance + ); + return; + } + + // Parse topic + ConversationTopic topic; + try { + topic = ConversationTopic.valueOf(topicName); + } catch (IllegalArgumentException e) { + TiedUpMod.LOGGER.warn( + "[PacketSelectTopic] Unknown topic: {}", + topicName + ); + return; + } + + TiedUpMod.LOGGER.info( + "[PacketSelectTopic] {} selected topic {} for {}", + sender.getName().getString(), + topic.name(), + speaker.getDialogueName() + ); + + // Handle the topic selection - this sends the response dialogue + ConversationManager.handleTopicSelection(speaker, sender, topic); + } + + public int getEntityId() { + return entityId; + } + + public String getTopicName() { + return topicName; + } +} diff --git a/src/main/java/com/tiedup/remake/network/item/PacketAdjustItem.java b/src/main/java/com/tiedup/remake/network/item/PacketAdjustItem.java new file mode 100644 index 0000000..0af80c8 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/item/PacketAdjustItem.java @@ -0,0 +1,179 @@ +package com.tiedup.remake.network.item; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.base.AdjustmentHelper; +import com.tiedup.remake.items.base.IAdjustable; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.network.sync.SyncManager; +import com.tiedup.remake.state.PlayerBindState; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.network.NetworkEvent; + +/** + * Phase 16: Packet for adjusting item Y position (Client to Server). + * + * Sent by client when player adjusts a gag or blindfold position. + * Server validates and applies the adjustment, then syncs to all clients. + */ +public class PacketAdjustItem { + + private final BodyRegionV2 region; + private final float adjustmentValue; + private final float scaleValue; + + /** + * Create adjustment packet. + * + * @param region The body region (MOUTH or EYES) + * @param adjustmentValue The adjustment value (-4.0 to +4.0) + * @param scaleValue The scale value (0.5 to 2.0) + */ + public PacketAdjustItem( + BodyRegionV2 region, + float adjustmentValue, + float scaleValue + ) { + this.region = region; + this.adjustmentValue = adjustmentValue; + this.scaleValue = scaleValue; + } + + /** + * Encode the packet to the network buffer. + * + * @param buf The buffer to write to + */ + public void encode(FriendlyByteBuf buf) { + buf.writeEnum(region); + buf.writeFloat(adjustmentValue); + buf.writeFloat(scaleValue); + } + + /** + * Decode the packet from the network buffer. + * + * @param buf The buffer to read from + * @return The decoded packet + */ + public static PacketAdjustItem decode(FriendlyByteBuf buf) { + BodyRegionV2 region = buf.readEnum(BodyRegionV2.class); + float value = buf.readFloat(); + float scale = buf.readFloat(); + return new PacketAdjustItem(region, value, scale); + } + + /** + * Handle the packet on the receiving side (SERVER SIDE). + * + * @param ctx The network context + */ + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer player = ctx.get().getSender(); + if (player == null) { + return; + } + + // Rate limiting: Prevent adjustment spam + if ( + !com.tiedup.remake.network.PacketRateLimiter.allowPacket( + player, + "action" + ) + ) { + return; + } + + handleServer(player); + }); + ctx.get().setPacketHandled(true); + } + + /** + * Handle the packet on the server side. + * Validates the adjustment and applies it to the player's item. + * + * @param player The player who sent the packet + */ + private void handleServer(ServerPlayer player) { + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + TiedUpMod.LOGGER.warn( + "[PACKET] PacketAdjustItem received but PlayerBindState is null for {}", + player.getName().getString() + ); + return; + } + + // SECURITY: Validate adjustment value (prevent NaN, Infinity, out-of-bounds) + if ( + Float.isNaN(adjustmentValue) || + Float.isInfinite(adjustmentValue) || + Float.isNaN(scaleValue) || + Float.isInfinite(scaleValue) + ) { + TiedUpMod.LOGGER.warn( + "SECURITY: Invalid adjustment value from {}: adj={}, scale={}", + player.getName().getString(), + adjustmentValue, + scaleValue + ); + return; + } + + // Valid range check (-5.0 to +5.0 pixels, with margin) + if (adjustmentValue < -5.0f || adjustmentValue > 5.0f) { + TiedUpMod.LOGGER.debug( + "[PACKET] Adjustment value out of bounds from {}: {}", + player.getName().getString(), + adjustmentValue + ); + return; + } + + // Get the item to adjust + ItemStack stack = switch (region) { + case MOUTH -> state.getEquipment(BodyRegionV2.MOUTH); + case EYES -> state.getEquipment(BodyRegionV2.EYES); + default -> ItemStack.EMPTY; + }; + + // Validate + if (stack.isEmpty()) { + TiedUpMod.LOGGER.debug( + "[PACKET] PacketAdjustItem: No {} equipped for {}", + region, + player.getName().getString() + ); + return; + } + + if (!(stack.getItem() instanceof IAdjustable)) { + TiedUpMod.LOGGER.warn( + "[PACKET] PacketAdjustItem: Item {} is not adjustable", + stack.getItem() + ); + return; + } + + // Apply adjustment (AdjustmentHelper clamps the value) + AdjustmentHelper.setAdjustment(stack, adjustmentValue); + AdjustmentHelper.setScale(stack, scaleValue); + + TiedUpMod.LOGGER.debug( + "[PACKET] Applied adjustment {} scale {} to {} for {}", + adjustmentValue, + scaleValue, + region, + player.getName().getString() + ); + + // Sync inventory to all tracking players + SyncManager.syncInventory(player); + } +} diff --git a/src/main/java/com/tiedup/remake/network/item/PacketAdjustRemote.java b/src/main/java/com/tiedup/remake/network/item/PacketAdjustRemote.java new file mode 100644 index 0000000..96b556b --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/item/PacketAdjustRemote.java @@ -0,0 +1,270 @@ +package com.tiedup.remake.network.item; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.base.AdjustmentHelper; +import com.tiedup.remake.items.base.IAdjustable; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.network.sync.SyncManager; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.state.PlayerCaptorManager; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.ChatFormatting; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet for adjusting a slave's gag/blindfold remotely. + * + * Phase 16: GUI Revamp - Remote adjustment packet + * + * Security: Distance and dimension validation added to prevent griefing + */ +public class PacketAdjustRemote { + + /** Maximum interaction range for remote adjustments (blocks) */ + private static final double MAX_INTERACTION_RANGE = 100.0; + + private final UUID targetId; + private final BodyRegionV2 region; + private final float adjustmentValue; + private final float scaleValue; + + public PacketAdjustRemote( + UUID targetId, + BodyRegionV2 region, + float adjustmentValue, + float scaleValue + ) { + this.targetId = targetId; + this.region = region; + this.adjustmentValue = adjustmentValue; + this.scaleValue = scaleValue; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeUUID(targetId); + buf.writeEnum(region); + buf.writeFloat(adjustmentValue); + buf.writeFloat(scaleValue); + } + + public static PacketAdjustRemote decode(FriendlyByteBuf buf) { + UUID id = buf.readUUID(); + BodyRegionV2 region = buf.readEnum(BodyRegionV2.class); + float value = buf.readFloat(); + float scale = buf.readFloat(); + return new PacketAdjustRemote(id, region, value, scale); + } + + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer sender = ctx.get().getSender(); + if (sender == null) return; + + // Rate limiting: Prevent adjustment spam + if ( + !com.tiedup.remake.network.PacketRateLimiter.allowPacket( + sender, + "action" + ) + ) { + return; + } + + handleServer(sender); + }); + ctx.get().setPacketHandled(true); + } + + private void handleServer(ServerPlayer sender) { + // Get sender's kidnapper manager + PlayerBindState senderState = PlayerBindState.getInstance(sender); + if (senderState == null) { + return; + } + + // SECURITY: Validate adjustment value (prevent NaN, Infinity, out-of-bounds) + if ( + Float.isNaN(adjustmentValue) || + Float.isInfinite(adjustmentValue) || + Float.isNaN(scaleValue) || + Float.isInfinite(scaleValue) + ) { + TiedUpMod.LOGGER.warn( + "SECURITY: Invalid adjustment value from {}: adj={}, scale={}", + sender.getName().getString(), + adjustmentValue, + scaleValue + ); + return; + } + + // Valid range check (-5.0 to +5.0 pixels, with margin) + if (adjustmentValue < -5.0f || adjustmentValue > 5.0f) { + TiedUpMod.LOGGER.debug( + "[PACKET] Adjustment value out of bounds from {}: {}", + sender.getName().getString(), + adjustmentValue + ); + return; + } + + // Find the target - first try captives, then collar-owned entities + IBondageState targetCaptive = null; + + // 1. Check captives first + PlayerCaptorManager manager = senderState.getCaptorManager(); + if (manager != null) { + for (IBondageState captive : manager.getCaptives()) { + LivingEntity entity = captive.asLivingEntity(); + if (entity != null && entity.getUUID().equals(targetId)) { + targetCaptive = captive; + break; + } + } + } + + // 2. If not found in captives, check nearby collar-owned entities + if (targetCaptive == null) { + targetCaptive = findCollarOwnedEntity(sender); + } + + if (targetCaptive == null) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + "Target not found or not under your control!" + ); + return; + } + + LivingEntity targetEntity = targetCaptive.asLivingEntity(); + if (targetEntity == null) { + TiedUpMod.LOGGER.warn( + "[PACKET] PacketAdjustRemote: Target entity is null" + ); + return; + } + String targetName = targetCaptive.getKidnappedName(); + + // Security: Validate dimension and distance + if (sender.level() != targetEntity.level()) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + targetName + " is in a different dimension!" + ); + return; + } + + double distance = sender.distanceTo(targetEntity); + if (distance > MAX_INTERACTION_RANGE) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + targetName + + " is too far away! (Distance: " + + (int) distance + + " blocks, Max: " + + (int) MAX_INTERACTION_RANGE + + ")" + ); + return; + } + + // Get the item to adjust + ItemStack stack = switch (region) { + case MOUTH -> targetCaptive.getEquipment(BodyRegionV2.MOUTH); + case EYES -> targetCaptive.getEquipment(BodyRegionV2.EYES); + default -> ItemStack.EMPTY; + }; + + if (stack.isEmpty()) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + "Target has no " + region.name().toLowerCase() + " item!" + ); + return; + } + + if (!(stack.getItem() instanceof IAdjustable)) { + TiedUpMod.LOGGER.warn( + "[PACKET] PacketAdjustRemote: Item {} is not adjustable", + stack.getItem() + ); + return; + } + + // Apply adjustment + AdjustmentHelper.setAdjustment(stack, adjustmentValue); + AdjustmentHelper.setScale(stack, scaleValue); + + SystemMessageManager.sendToPlayer( + sender, + "Adjusted " + + targetName + + "'s " + + region.name().toLowerCase() + + " item to " + + adjustmentValue, + ChatFormatting.GREEN + ); + + TiedUpMod.LOGGER.debug( + "[PACKET] {} adjusted {}'s {} to {}", + sender.getName().getString(), + targetName, + region, + adjustmentValue + ); + + // Sync if target is a player + if (targetEntity instanceof ServerPlayer targetPlayer) { + SyncManager.syncInventory(targetPlayer); + } + } + + /** + * Find a collar-owned entity by UUID. + * Searches nearby entities for one with a collar owned by the sender. + */ + private IBondageState findCollarOwnedEntity(ServerPlayer sender) { + net.minecraft.world.phys.AABB searchBox = sender + .getBoundingBox() + .inflate(32); // Security: reduced from 100 + + for (LivingEntity entity : sender + .level() + .getEntitiesOfClass(LivingEntity.class, searchBox)) { + if (entity == sender) continue; + if (!entity.getUUID().equals(targetId)) continue; + + IBondageState kidnapped = + com.tiedup.remake.util.KidnappedHelper.getKidnappedState( + entity + ); + if (kidnapped != null && kidnapped.hasCollar()) { + ItemStack collarStack = kidnapped.getEquipment(BodyRegionV2.NECK); + if ( + collarStack.getItem() instanceof + com.tiedup.remake.items.base.ItemCollar collar + ) { + if (collar.isOwner(collarStack, sender)) { + return kidnapped; + } + } + } + } + + return null; + } +} diff --git a/src/main/java/com/tiedup/remake/network/labor/PacketSyncLaborProgress.java b/src/main/java/com/tiedup/remake/network/labor/PacketSyncLaborProgress.java new file mode 100644 index 0000000..b03d890 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/labor/PacketSyncLaborProgress.java @@ -0,0 +1,105 @@ +package com.tiedup.remake.network.labor; + +import com.tiedup.remake.network.base.AbstractClientPacket; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Server-to-Client packet for synchronizing labor task progress. + * Sent when: + * - A task is assigned (start) + * - Progress is made (increment) + * - A task is completed or cancelled (clear) + */ +public class PacketSyncLaborProgress extends AbstractClientPacket { + + private final boolean hasTask; + private final String taskDescription; + private final int progress; + private final int quota; + private final int valueEmeralds; + + /** + * Create a packet with task data. + */ + public PacketSyncLaborProgress( + String taskDescription, + int progress, + int quota, + int valueEmeralds + ) { + this.hasTask = true; + this.taskDescription = taskDescription; + this.progress = progress; + this.quota = quota; + this.valueEmeralds = valueEmeralds; + } + + /** + * Create a packet to clear the task (no active task). + */ + public PacketSyncLaborProgress() { + this.hasTask = false; + this.taskDescription = ""; + this.progress = 0; + this.quota = 0; + this.valueEmeralds = 0; + } + + /** + * Encode packet data to buffer. + */ + public void encode(FriendlyByteBuf buf) { + buf.writeBoolean(hasTask); + if (hasTask) { + buf.writeUtf(taskDescription); + buf.writeInt(progress); + buf.writeInt(quota); + buf.writeInt(valueEmeralds); + } + } + + /** + * Decode packet data from buffer. + */ + public static PacketSyncLaborProgress decode(FriendlyByteBuf buf) { + boolean hasTask = buf.readBoolean(); + if (hasTask) { + String taskDescription = buf.readUtf(); + int progress = buf.readInt(); + int quota = buf.readInt(); + int valueEmeralds = buf.readInt(); + return new PacketSyncLaborProgress( + taskDescription, + progress, + quota, + valueEmeralds + ); + } else { + return new PacketSyncLaborProgress(); + } + } + + @Override + @OnlyIn(Dist.CLIENT) + protected void handleClientImpl() { + ClientHandler.handle(this); + } + + @OnlyIn(Dist.CLIENT) + private static class ClientHandler { + private static void handle(PacketSyncLaborProgress pkt) { + if (pkt.hasTask) { + com.tiedup.remake.client.state.ClientLaborState.setTask( + pkt.taskDescription, + pkt.progress, + pkt.quota, + pkt.valueEmeralds + ); + } else { + com.tiedup.remake.client.state.ClientLaborState.clearTask(); + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/network/master/PacketMasterStateSync.java b/src/main/java/com/tiedup/remake/network/master/PacketMasterStateSync.java new file mode 100644 index 0000000..6a047fd --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/master/PacketMasterStateSync.java @@ -0,0 +1,103 @@ +package com.tiedup.remake.network.master; + +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.entities.ai.master.MasterState; +import com.tiedup.remake.network.base.AbstractClientPacket; +import java.util.UUID; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Packet to synchronize Master entity state to tracking clients. + * + * Direction: Server → Client (S2C) + * + * Syncs: + * - Current MasterState + * - Pet player UUID + * - Remaining distraction ticks + */ +public class PacketMasterStateSync extends AbstractClientPacket { + + private final int entityId; + private final int stateOrdinal; + private final UUID petPlayerUUID; + private final long remainingDistractionTicks; + + /** + * Create a state sync packet. + * + * @param entityId The master entity ID + * @param stateOrdinal The state ordinal value + * @param petPlayerUUID The pet player UUID (may be null) + * @param remainingDistractionTicks Remaining ticks of distraction + */ + public PacketMasterStateSync( + int entityId, + int stateOrdinal, + UUID petPlayerUUID, + long remainingDistractionTicks + ) { + this.entityId = entityId; + this.stateOrdinal = stateOrdinal; + this.petPlayerUUID = petPlayerUUID; + this.remainingDistractionTicks = remainingDistractionTicks; + } + + /** + * Encode the packet to buffer. + */ + public void encode(FriendlyByteBuf buf) { + buf.writeInt(entityId); + buf.writeInt(stateOrdinal); + buf.writeBoolean(petPlayerUUID != null); + if (petPlayerUUID != null) { + buf.writeUUID(petPlayerUUID); + } + buf.writeLong(remainingDistractionTicks); + } + + /** + * Decode the packet from buffer. + */ + public static PacketMasterStateSync decode(FriendlyByteBuf buf) { + int entityId = buf.readInt(); + int stateOrdinal = buf.readInt(); + UUID petPlayerUUID = null; + if (buf.readBoolean()) { + petPlayerUUID = buf.readUUID(); + } + long remainingDistractionTicks = buf.readLong(); + return new PacketMasterStateSync( + entityId, + stateOrdinal, + petPlayerUUID, + remainingDistractionTicks + ); + } + + /** + * Handle packet on client side. + */ + @Override + @OnlyIn(Dist.CLIENT) + protected void handleClientImpl() { + ClientHandler.handle(this); + } + + @OnlyIn(Dist.CLIENT) + private static class ClientHandler { + private static void handle(PacketMasterStateSync pkt) { + net.minecraft.client.Minecraft mc = net.minecraft.client.Minecraft.getInstance(); + if (mc.level == null) return; + + net.minecraft.world.entity.Entity entity = mc.level.getEntity(pkt.entityId); + if (entity instanceof EntityMaster master) { + // Apply state sync + // Note: Client-side state is read-only via synced entity data + // This packet is for extended state info like distraction timer + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/network/master/PacketOpenPetRequestMenu.java b/src/main/java/com/tiedup/remake/network/master/PacketOpenPetRequestMenu.java new file mode 100644 index 0000000..8660ce4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/master/PacketOpenPetRequestMenu.java @@ -0,0 +1,76 @@ +package com.tiedup.remake.network.master; + +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet sent from server to client to open the pet request menu. + * + * Direction: Server → Client (S2C) + * + * Contains: + * - Master entity ID + * - Master name (for display) + */ +public class PacketOpenPetRequestMenu { + + private final int entityId; + private final String masterName; + + /** + * Create a packet to open the pet request menu. + * + * @param entityId The master entity ID + * @param masterName The master's display name + */ + public PacketOpenPetRequestMenu(int entityId, String masterName) { + this.entityId = entityId; + this.masterName = masterName; + } + + /** + * Encode the packet to buffer. + */ + public void encode(FriendlyByteBuf buf) { + buf.writeInt(entityId); + buf.writeUtf(masterName, 64); + } + + /** + * Decode the packet from buffer. + */ + public static PacketOpenPetRequestMenu decode(FriendlyByteBuf buf) { + int entityId = buf.readInt(); + String masterName = buf.readUtf(64); + return new PacketOpenPetRequestMenu(entityId, masterName); + } + + /** + * Handle the packet on the network thread. + */ + public void handle(Supplier ctx) { + ctx.get().enqueueWork(() -> handleClient()); + ctx.get().setPacketHandled(true); + } + + /** + * Client-side handling. + * This method is only called on the client, avoiding class loading issues. + */ + private void handleClient() { + // Delegate to client-only helper to avoid class loading on server + com.tiedup.remake.client.network.ClientPacketHandler.openPetRequestScreen( + entityId, + masterName + ); + } + + public int getEntityId() { + return entityId; + } + + public String getMasterName() { + return masterName; + } +} diff --git a/src/main/java/com/tiedup/remake/network/master/PacketPetRequest.java b/src/main/java/com/tiedup/remake/network/master/PacketPetRequest.java new file mode 100644 index 0000000..f25811d --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/master/PacketPetRequest.java @@ -0,0 +1,145 @@ +package com.tiedup.remake.network.master; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.conversation.PetRequest; +import com.tiedup.remake.dialogue.conversation.PetRequestManager; +import com.tiedup.remake.entities.EntityMaster; +import com.tiedup.remake.network.PacketRateLimiter; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet sent from client to server when player selects a pet request option. + * + * Direction: Client → Server (C2S) + * + * Contains: + * - Master entity ID + * - Request type (enum name) + */ +public class PacketPetRequest { + + /** Maximum distance for request interaction */ + private static final double MAX_DISTANCE = 8.0; + + private final int entityId; + private final String requestName; + + /** + * Create a pet request packet. + * + * @param entityId The master entity ID + * @param request The request type + */ + public PacketPetRequest(int entityId, PetRequest request) { + this.entityId = entityId; + this.requestName = request.name(); + } + + private PacketPetRequest(int entityId, String requestName) { + this.entityId = entityId; + this.requestName = requestName; + } + + /** + * Encode the packet to buffer. + */ + public void encode(FriendlyByteBuf buf) { + buf.writeInt(entityId); + buf.writeUtf(requestName, 64); + } + + /** + * Decode the packet from buffer. + */ + public static PacketPetRequest decode(FriendlyByteBuf buf) { + int entityId = buf.readInt(); + String requestName = buf.readUtf(64); + return new PacketPetRequest(entityId, requestName); + } + + /** + * Handle the packet on server side. + */ + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer sender = ctx.get().getSender(); + if (sender == null) return; + + handleServer(sender); + }); + ctx.get().setPacketHandled(true); + } + + private void handleServer(ServerPlayer sender) { + // MEDIUM FIX: Rate limiting to prevent pet request spam + if (!PacketRateLimiter.allowPacket(sender, "action")) { + return; + } + + // Find the master entity + Entity entity = sender.level().getEntity(entityId); + if (entity == null) { + TiedUpMod.LOGGER.warn( + "[PacketPetRequest] Entity {} not found", + entityId + ); + return; + } + + if (!(entity instanceof EntityMaster master)) { + TiedUpMod.LOGGER.warn( + "[PacketPetRequest] Entity {} is not a Master", + entityId + ); + return; + } + + // Validate distance + double distance = sender.distanceTo(entity); + if (distance > MAX_DISTANCE) { + TiedUpMod.LOGGER.warn( + "[PacketPetRequest] Player {} too far from master {} ({})", + sender.getName().getString(), + entityId, + distance + ); + return; + } + + // Parse request + PetRequest request; + try { + request = PetRequest.valueOf(requestName); + } catch (IllegalArgumentException e) { + TiedUpMod.LOGGER.warn( + "[PacketPetRequest] Unknown request: {}", + requestName + ); + return; + } + + TiedUpMod.LOGGER.info( + "[PacketPetRequest] {} sent request {} to {}", + sender.getName().getString(), + request.name(), + master.getNpcName() + ); + + // Handle the request + PetRequestManager.handleRequest(master, sender, request); + } + + public int getEntityId() { + return entityId; + } + + public String getRequestName() { + return requestName; + } +} diff --git a/src/main/java/com/tiedup/remake/network/merchant/PacketCloseMerchantScreen.java b/src/main/java/com/tiedup/remake/network/merchant/PacketCloseMerchantScreen.java new file mode 100644 index 0000000..e473ec0 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/merchant/PacketCloseMerchantScreen.java @@ -0,0 +1,88 @@ +package com.tiedup.remake.network.merchant; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityKidnapperMerchant; +import com.tiedup.remake.network.PacketRateLimiter; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet sent from client to server when player closes the merchant trading screen. + * + * Contains: + * - Merchant entity ID + */ +public class PacketCloseMerchantScreen { + + private final UUID merchantUUID; + + /** + * Create packet to notify merchant screen closed. + * + * @param merchantUUID The UUID of the merchant entity (persistent across restarts) + */ + public PacketCloseMerchantScreen(UUID merchantUUID) { + this.merchantUUID = merchantUUID; + } + + /** + * Encode the packet to the network buffer. + */ + public void encode(FriendlyByteBuf buf) { + buf.writeUUID(merchantUUID); + } + + /** + * Decode the packet from the network buffer. + */ + public static PacketCloseMerchantScreen decode(FriendlyByteBuf buf) { + UUID uuid = buf.readUUID(); + return new PacketCloseMerchantScreen(uuid); + } + + /** + * Handle the packet on the receiving side (SERVER SIDE). + */ + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer player = ctx.get().getSender(); + if (player == null) { + return; + } + + handleServer(player); + }); + ctx.get().setPacketHandled(true); + } + + /** + * Handle the packet on the server side. + * Marks that the player is no longer trading with the merchant. + */ + private void handleServer(ServerPlayer player) { + if (!PacketRateLimiter.allowPacket(player, "action")) return; + + // Find the merchant entity by UUID (persistent across restarts) + Entity entity = ( + (net.minecraft.server.level.ServerLevel) player.level() + ).getEntity(merchantUUID); + if (!(entity instanceof EntityKidnapperMerchant merchant)) { + TiedUpMod.LOGGER.warn( + "[PacketCloseMerchantScreen] Entity {} is not a merchant or not found", + merchantUUID + ); + return; + } + + // Always clean up trading state — this is a teardown packet. + // Distance check removed: blocking cleanup causes permanent state leak + // in tradingPlayers map (reviewer H18 BUG-002). + merchant.stopTrading(player.getUUID()); + } +} diff --git a/src/main/java/com/tiedup/remake/network/merchant/PacketOpenMerchantScreen.java b/src/main/java/com/tiedup/remake/network/merchant/PacketOpenMerchantScreen.java new file mode 100644 index 0000000..a6c4aa2 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/merchant/PacketOpenMerchantScreen.java @@ -0,0 +1,105 @@ +package com.tiedup.remake.network.merchant; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.MerchantTrade; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.fml.loading.FMLEnvironment; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet sent from server to client to open the merchant trading screen. + * + * Contains: + * - Merchant entity ID + * - List of available trades + */ +public class PacketOpenMerchantScreen { + + private final UUID merchantUUID; + private final List trades; + + /** + * Create packet to open merchant screen. + * + * @param merchantUUID The UUID of the merchant entity (persistent across restarts) + * @param trades List of available trades + */ + public PacketOpenMerchantScreen( + UUID merchantUUID, + List trades + ) { + this.merchantUUID = merchantUUID; + this.trades = new ArrayList<>(trades); + } + + /** + * Encode the packet to the network buffer. + */ + public void encode(FriendlyByteBuf buf) { + buf.writeUUID(merchantUUID); + buf.writeInt(trades.size()); + for (MerchantTrade trade : trades) { + buf.writeNbt(trade.save()); + } + } + + /** + * Decode the packet from the network buffer. + */ + public static PacketOpenMerchantScreen decode(FriendlyByteBuf buf) { + UUID uuid = buf.readUUID(); + int tradeCount = buf.readInt(); + List trades = new ArrayList<>(); + + for (int i = 0; i < tradeCount; i++) { + CompoundTag tag = buf.readNbt(); + if (tag != null) { + trades.add(MerchantTrade.load(tag)); + } + } + + return new PacketOpenMerchantScreen(uuid, trades); + } + + /** + * Handle the packet on the receiving side (CLIENT SIDE). + */ + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + if (FMLEnvironment.dist == Dist.CLIENT) { + handleClient(); + } + }); + ctx.get().setPacketHandled(true); + } + + /** + * Handle the packet on the client side. + * Opens the merchant trading screen. + */ + @OnlyIn(Dist.CLIENT) + private void handleClient() { + net.minecraft.client.Minecraft mc = + net.minecraft.client.Minecraft.getInstance(); + mc.setScreen( + new com.tiedup.remake.client.gui.screens.MerchantTradingScreen( + merchantUUID, + trades + ) + ); + + TiedUpMod.LOGGER.debug( + "[PacketOpenMerchantScreen] Opening merchant screen with {} trades", + trades.size() + ); + } +} diff --git a/src/main/java/com/tiedup/remake/network/merchant/PacketPurchaseTrade.java b/src/main/java/com/tiedup/remake/network/merchant/PacketPurchaseTrade.java new file mode 100644 index 0000000..6b11e36 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/merchant/PacketPurchaseTrade.java @@ -0,0 +1,234 @@ +package com.tiedup.remake.network.merchant; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityKidnapperMerchant; +import com.tiedup.remake.entities.MerchantTrade; +import java.util.List; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.ChatFormatting; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet sent from client to server when player attempts to purchase a trade. + * + * Contains: + * - Merchant entity ID + * - Trade index (which trade to purchase) + */ +public class PacketPurchaseTrade { + + private final UUID merchantUUID; + private final int tradeIndex; + + /** + * Create packet to purchase a trade. + * + * @param merchantUUID The UUID of the merchant entity (persistent across restarts) + * @param tradeIndex Index of the trade to purchase (0-based) + */ + public PacketPurchaseTrade(UUID merchantUUID, int tradeIndex) { + this.merchantUUID = merchantUUID; + this.tradeIndex = tradeIndex; + } + + /** + * Encode the packet to the network buffer. + */ + public void encode(FriendlyByteBuf buf) { + buf.writeUUID(merchantUUID); + buf.writeInt(tradeIndex); + } + + /** + * Decode the packet from the network buffer. + */ + public static PacketPurchaseTrade decode(FriendlyByteBuf buf) { + UUID uuid = buf.readUUID(); + int tradeIndex = buf.readInt(); + return new PacketPurchaseTrade(uuid, tradeIndex); + } + + /** + * Handle the packet on the receiving side (SERVER SIDE). + */ + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer player = ctx.get().getSender(); + if (player == null) { + return; + } + + // CRITICAL FIX: Add rate limiting to prevent DoS via packet spam + if ( + !com.tiedup.remake.network.PacketRateLimiter.allowPacket( + player, + "action" + ) + ) { + return; + } + + handleServer(player); + }); + ctx.get().setPacketHandled(true); + } + + /** + * Handle the packet on the server side. + * Validates and executes the trade. + */ + private void handleServer(ServerPlayer player) { + // Find the merchant entity by UUID (persistent across restarts) + Entity entity = ( + (net.minecraft.server.level.ServerLevel) player.level() + ).getEntity(merchantUUID); + if (!(entity instanceof EntityKidnapperMerchant merchant)) { + TiedUpMod.LOGGER.warn( + "[PacketPurchaseTrade] Entity {} is not a merchant or not found", + merchantUUID + ); + return; + } + + // Validate merchant is in range (10 blocks) + if (player.distanceTo(merchant) > 10.0) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "Merchant is too far away!" + ); + return; + } + + // Validate merchant is in merchant mode + if (!merchant.isMerchant()) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "The merchant is too angry to trade!" + ); + return; + } + + // Get the trade + List trades = merchant.getTrades(); + if (tradeIndex < 0 || tradeIndex >= trades.size()) { + TiedUpMod.LOGGER.warn( + "[PacketPurchaseTrade] Invalid trade index: {}", + tradeIndex + ); + return; + } + + MerchantTrade trade = trades.get(tradeIndex); + + // Count total gold in player inventory (across all slots) + int totalIngots = countGoldIngots(player); + int totalNuggets = countGoldNuggets(player); + + // Check if player can afford + if ( + totalIngots < trade.getIngotPrice() || + totalNuggets < trade.getNuggetPrice() + ) { + SystemMessageManager.sendToPlayer( + player, + SystemMessageManager.MessageCategory.ERROR, + "Not enough gold!" + ); + return; + } + + // Consume payment from multiple slots if needed + consumeGoldIngots(player, trade.getIngotPrice()); + consumeGoldNuggets(player, trade.getNuggetPrice()); + + // Give item to player + ItemStack purchasedItem = trade.getItem().copy(); + if (!player.getInventory().add(purchasedItem)) { + // Inventory full, drop at feet + player.drop(purchasedItem, false); + } + + // Success message + SystemMessageManager.sendChatToPlayer( + player, + "Purchased: " + trade.getItemName().getString(), + ChatFormatting.GREEN + ); + + // Merchant response + merchant.talkToPlayersInRadius("Pleasure doing business!", 10); + + TiedUpMod.LOGGER.info( + "[PacketPurchaseTrade] {} purchased {} from merchant", + player.getName().getString(), + trade.getItemName().getString() + ); + } + + /** + * Count total gold ingots across all inventory slots. + */ + private int countGoldIngots(ServerPlayer player) { + int total = 0; + for (ItemStack stack : player.getInventory().items) { + if (stack.is(Items.GOLD_INGOT)) { + total += stack.getCount(); + } + } + return total; + } + + /** + * Count total gold nuggets across all inventory slots. + */ + private int countGoldNuggets(ServerPlayer player) { + int total = 0; + for (ItemStack stack : player.getInventory().items) { + if (stack.is(Items.GOLD_NUGGET)) { + total += stack.getCount(); + } + } + return total; + } + + /** + * Consume gold ingots from inventory (multiple slots if needed). + */ + private void consumeGoldIngots(ServerPlayer player, int amount) { + int remaining = amount; + for (ItemStack stack : player.getInventory().items) { + if (remaining <= 0) break; + if (stack.is(Items.GOLD_INGOT)) { + int toRemove = Math.min(remaining, stack.getCount()); + stack.shrink(toRemove); + remaining -= toRemove; + } + } + } + + /** + * Consume gold nuggets from inventory (multiple slots if needed). + */ + private void consumeGoldNuggets(ServerPlayer player, int amount) { + int remaining = amount; + for (ItemStack stack : player.getInventory().items) { + if (remaining <= 0) break; + if (stack.is(Items.GOLD_NUGGET)) { + int toRemove = Math.min(remaining, stack.getCount()); + stack.shrink(toRemove); + remaining -= toRemove; + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/network/minigame/PacketContinuousStruggleHold.java b/src/main/java/com/tiedup/remake/network/minigame/PacketContinuousStruggleHold.java new file mode 100644 index 0000000..1085fc8 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/minigame/PacketContinuousStruggleHold.java @@ -0,0 +1,109 @@ +package com.tiedup.remake.network.minigame; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.minigame.ContinuousStruggleMiniGameState; +import com.tiedup.remake.minigame.StruggleSessionManager; +import com.tiedup.remake.network.PacketRateLimiter; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.network.NetworkEvent; + +/** + * Client to Server packet for continuous struggle key hold state. + * + * Sent every 5 ticks (4 times per second) while the struggle screen is open. + * Reports which direction key the player is holding (or -1 if none). + */ +public class PacketContinuousStruggleHold { + + private final UUID sessionId; + private final int heldDirection; // -1 if not holding any direction key + private final boolean isHolding; + + public PacketContinuousStruggleHold( + UUID sessionId, + int heldDirection, + boolean isHolding + ) { + this.sessionId = sessionId; + this.heldDirection = heldDirection; + this.isHolding = isHolding; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeUUID(sessionId); + buf.writeVarInt(heldDirection); + buf.writeBoolean(isHolding); + } + + public static PacketContinuousStruggleHold decode(FriendlyByteBuf buf) { + UUID sessionId = buf.readUUID(); + int heldDirection = buf.readVarInt(); + boolean isHolding = buf.readBoolean(); + return new PacketContinuousStruggleHold( + sessionId, + heldDirection, + isHolding + ); + } + + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer player = ctx.get().getSender(); + if (player == null) { + return; + } + handleServer(player); + }); + ctx.get().setPacketHandled(true); + } + + private void handleServer(ServerPlayer player) { + // HIGH FIX #18: Rate limiting to prevent packet spam exploit + // Designed for 4/sec, allow up to 12/sec with buffer + if (!PacketRateLimiter.allowPacket(player, "minigame")) { + return; + } + + StruggleSessionManager manager = StruggleSessionManager.getInstance(); + ContinuousStruggleMiniGameState session = + manager.getContinuousStruggleSession(player.getUUID()); + + if (session == null) { + TiedUpMod.LOGGER.debug( + "[PacketContinuousStruggleHold] No active session for {}", + player.getName().getString() + ); + return; + } + + // Validate session ID + if (!session.getSessionId().equals(sessionId)) { + TiedUpMod.LOGGER.debug( + "[PacketContinuousStruggleHold] Session ID mismatch for {}", + player.getName().getString() + ); + return; + } + + // Update held direction + session.updateHeldDirection(heldDirection, isHolding); + } + + // Getters + public UUID getSessionId() { + return sessionId; + } + + public int getHeldDirection() { + return heldDirection; + } + + public boolean isHolding() { + return isHolding; + } +} diff --git a/src/main/java/com/tiedup/remake/network/minigame/PacketContinuousStruggleState.java b/src/main/java/com/tiedup/remake/network/minigame/PacketContinuousStruggleState.java new file mode 100644 index 0000000..5253718 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/minigame/PacketContinuousStruggleState.java @@ -0,0 +1,177 @@ +package com.tiedup.remake.network.minigame; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.minigame.ContinuousStruggleMiniGameState.UpdateType; +import com.tiedup.remake.network.base.AbstractClientPacket; +import java.util.UUID; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Server to Client packet for continuous struggle mini-game state updates. + * + * Sent when: + * - START: Session begins (opens the GUI) + * - DIRECTION_CHANGE: Direction changed + * - RESISTANCE_UPDATE: Resistance reduced + * - SHOCK: Shock collar triggered + * - ESCAPE: Player escaped successfully + * - END: Session ended (cancelled or completed) + */ +public class PacketContinuousStruggleState extends AbstractClientPacket { + + private final UUID sessionId; + private final UpdateType updateType; + private final int currentDirection; + private final int currentResistance; + private final int maxResistance; + private final boolean isLocked; + + public PacketContinuousStruggleState( + UUID sessionId, + UpdateType updateType, + int currentDirection, + int currentResistance, + int maxResistance, + boolean isLocked + ) { + this.sessionId = sessionId; + this.updateType = updateType; + this.currentDirection = currentDirection; + this.currentResistance = currentResistance; + this.maxResistance = maxResistance; + this.isLocked = isLocked; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeUUID(sessionId); + buf.writeVarInt(updateType.ordinal()); + buf.writeVarInt(currentDirection); + buf.writeVarInt(currentResistance); + buf.writeVarInt(maxResistance); + buf.writeBoolean(isLocked); + } + + public static PacketContinuousStruggleState decode(FriendlyByteBuf buf) { + UUID sessionId = buf.readUUID(); + UpdateType updateType = UpdateType.values()[buf.readVarInt()]; + int currentDirection = buf.readVarInt(); + int currentResistance = buf.readVarInt(); + int maxResistance = buf.readVarInt(); + boolean isLocked = buf.readBoolean(); + return new PacketContinuousStruggleState( + sessionId, + updateType, + currentDirection, + currentResistance, + maxResistance, + isLocked + ); + } + + @Override + @OnlyIn(Dist.CLIENT) + protected void handleClientImpl() { + ClientHandler.handle(this); + } + + @OnlyIn(Dist.CLIENT) + private static class ClientHandler { + private static void handle(PacketContinuousStruggleState pkt) { + net.minecraft.client.Minecraft mc = net.minecraft.client.Minecraft.getInstance(); + if (mc.player == null) { + TiedUpMod.LOGGER.warn( + "[PacketContinuousStruggleState] Player is null, cannot handle packet" + ); + return; + } + + TiedUpMod.LOGGER.info( + "[PacketContinuousStruggleState] Received update: type={}, direction={}, resistance={}/{}", + pkt.updateType, + pkt.currentDirection, + pkt.currentResistance, + pkt.maxResistance + ); + + switch (pkt.updateType) { + case START -> { + // Open the continuous struggle screen + TiedUpMod.LOGGER.info( + "[PacketContinuousStruggleState] Opening ContinuousStruggleMiniGameScreen" + ); + mc.setScreen( + new com.tiedup.remake.client.gui.screens.ContinuousStruggleMiniGameScreen( + pkt.sessionId, + pkt.currentDirection, + pkt.currentResistance, + pkt.maxResistance, + pkt.isLocked + ) + ); + } + case DIRECTION_CHANGE -> { + if ( + mc.screen instanceof com.tiedup.remake.client.gui.screens.ContinuousStruggleMiniGameScreen screen + ) { + screen.onDirectionChange(pkt.currentDirection); + } + } + case RESISTANCE_UPDATE -> { + if ( + mc.screen instanceof com.tiedup.remake.client.gui.screens.ContinuousStruggleMiniGameScreen screen + ) { + screen.onResistanceUpdate(pkt.currentResistance); + } + } + case SHOCK -> { + if ( + mc.screen instanceof com.tiedup.remake.client.gui.screens.ContinuousStruggleMiniGameScreen screen + ) { + screen.onShock(); + } + } + case ESCAPE -> { + if ( + mc.screen instanceof com.tiedup.remake.client.gui.screens.ContinuousStruggleMiniGameScreen screen + ) { + screen.onEscape(); + } + } + case END -> { + if ( + mc.screen instanceof com.tiedup.remake.client.gui.screens.ContinuousStruggleMiniGameScreen screen + ) { + screen.onEnd(); + } + } + } + } + } + + // Getters for potential external use + public UUID getSessionId() { + return sessionId; + } + + public UpdateType getUpdateType() { + return updateType; + } + + public int getCurrentDirection() { + return currentDirection; + } + + public int getCurrentResistance() { + return currentResistance; + } + + public int getMaxResistance() { + return maxResistance; + } + + public boolean isLocked() { + return isLocked; + } +} diff --git a/src/main/java/com/tiedup/remake/network/minigame/PacketContinuousStruggleStop.java b/src/main/java/com/tiedup/remake/network/minigame/PacketContinuousStruggleStop.java new file mode 100644 index 0000000..e444b78 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/minigame/PacketContinuousStruggleStop.java @@ -0,0 +1,99 @@ +package com.tiedup.remake.network.minigame; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.minigame.ContinuousStruggleMiniGameState; +import com.tiedup.remake.minigame.StruggleSessionManager; +import com.tiedup.remake.network.PacketRateLimiter; +import com.tiedup.remake.network.sync.SyncManager; +import com.tiedup.remake.state.PlayerBindState; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.network.NetworkEvent; + +/** + * Client to Server packet to stop the continuous struggle session. + * + * Sent when: + * - Player presses ESC to close the screen + * - Player takes damage (detected client-side) + */ +public class PacketContinuousStruggleStop { + + private final UUID sessionId; + + public PacketContinuousStruggleStop(UUID sessionId) { + this.sessionId = sessionId; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeUUID(sessionId); + } + + public static PacketContinuousStruggleStop decode(FriendlyByteBuf buf) { + UUID sessionId = buf.readUUID(); + return new PacketContinuousStruggleStop(sessionId); + } + + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer player = ctx.get().getSender(); + if (player == null) { + return; + } + handleServer(player); + }); + ctx.get().setPacketHandled(true); + } + + private void handleServer(ServerPlayer player) { + if (!PacketRateLimiter.allowPacket(player, "minigame")) return; + + StruggleSessionManager manager = StruggleSessionManager.getInstance(); + ContinuousStruggleMiniGameState session = + manager.getContinuousStruggleSession(player.getUUID()); + + if (session == null) { + TiedUpMod.LOGGER.debug( + "[PacketContinuousStruggleStop] No active session for {}", + player.getName().getString() + ); + return; + } + + // Validate session ID + if (!session.getSessionId().equals(sessionId)) { + TiedUpMod.LOGGER.debug( + "[PacketContinuousStruggleStop] Session ID mismatch for {}", + player.getName().getString() + ); + return; + } + + TiedUpMod.LOGGER.info( + "[PacketContinuousStruggleStop] Player {} stopped struggle session", + player.getName().getString() + ); + + // Cancel the session + session.cancel(); + + // Clear struggle animation state and sync + PlayerBindState state = PlayerBindState.getInstance(player); + if (state != null) { + state.setStruggling(false, 0); + SyncManager.syncStruggleState(player); + } + + // End the session in the manager + manager.endContinuousStruggleSession(player.getUUID(), false); + } + + // Getter + public UUID getSessionId() { + return sessionId; + } +} diff --git a/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickAttempt.java b/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickAttempt.java new file mode 100644 index 0000000..d0a3340 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickAttempt.java @@ -0,0 +1,447 @@ +package com.tiedup.remake.network.minigame; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.ItemLockpick; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import com.tiedup.remake.v2.furniture.EntityFurniture; +import com.tiedup.remake.v2.furniture.FurnitureDefinition; +import com.tiedup.remake.v2.furniture.network.PacketSyncFurnitureState; +import com.tiedup.remake.minigame.LockpickMiniGameState; +import com.tiedup.remake.minigame.LockpickMiniGameState.PickAttemptResult; +import com.tiedup.remake.minigame.LockpickSessionManager; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.PacketRateLimiter; +import com.tiedup.remake.network.sync.SyncManager; +import com.tiedup.remake.state.PlayerBindState; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.ChatFormatting; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet for lockpick attempt at current position (Client to Server). + * + * Sent when player presses SPACE to attempt picking at current position. + */ +public class PacketLockpickAttempt { + + private final UUID sessionId; + + public PacketLockpickAttempt(UUID sessionId) { + this.sessionId = sessionId; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeUUID(sessionId); + } + + public static PacketLockpickAttempt decode(FriendlyByteBuf buf) { + UUID sessionId = buf.readUUID(); + return new PacketLockpickAttempt(sessionId); + } + + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer player = ctx.get().getSender(); + if (player == null) { + return; + } + handleServer(player); + }); + ctx.get().setPacketHandled(true); + } + + private void handleServer(ServerPlayer player) { + // Rate limiting to prevent lockpick attempt spam exploit + if (!PacketRateLimiter.allowPacket(player, "minigame")) { + return; + } + + LockpickSessionManager manager = LockpickSessionManager.getInstance(); + + // Validate session + if (!manager.validateLockpickSession(player.getUUID(), sessionId)) { + TiedUpMod.LOGGER.warn( + "[PacketLockpickAttempt] Invalid session {} for player {}", + sessionId.toString().substring(0, 8), + player.getName().getString() + ); + return; + } + + LockpickMiniGameState session = manager.getLockpickSession( + player.getUUID() + ); + if (session == null || session.isComplete()) { + return; + } + + // Play sound and notify guards + manager.onLockpickAttempt(player); + + // Attempt to pick + PickAttemptResult result = session.attemptPick(); + + TiedUpMod.LOGGER.debug( + "[PacketLockpickAttempt] Player {} attempted pick, result: {}", + player.getName().getString(), + result + ); + + // Handle result + switch (result) { + case SUCCESS -> handleSuccess(player, session); + case OUT_OF_PICKS -> handleOutOfPicks(player, session); + case MISSED -> handleMissed(player, session); + default -> { + // Session already complete + } + } + } + + private void handleSuccess( + ServerPlayer player, + LockpickMiniGameState session + ) { + // Check for furniture lockpick context FIRST — if present, this is a + // furniture seat lockpick, not a body item lockpick. The context tag is + // written by PacketFurnitureEscape.handleLockpick() when starting the session. + CompoundTag furnitureCtx = player.getPersistentData() + .getCompound("tiedup_furniture_lockpick_ctx"); + if (furnitureCtx != null && furnitureCtx.contains("furniture_id")) { + // H18: Distance check BEFORE ending session — prevents consuming session + // without reward if player moved away (reviewer H18 RISK-001) + int furnitureId = furnitureCtx.getInt("furniture_id"); + Entity furnitureEntity = player.level().getEntity(furnitureId); + if (furnitureEntity == null || player.distanceTo(furnitureEntity) > 10.0) { + return; + } + + // Session validated — now end it + LockpickSessionManager.getInstance().endLockpickSession( + player.getUUID(), + true + ); + handleFurnitureLockpickSuccess(player, furnitureCtx); + player.getPersistentData().remove("tiedup_furniture_lockpick_ctx"); + damageLockpick(player); + + // Send result to client + ModNetwork.sendToPlayer( + new PacketLockpickMiniGameResult( + session.getSessionId(), + PacketLockpickMiniGameResult.ResultType.SUCCESS, + 0 + ), + player + ); + return; + } + + // Body item lockpick — self-targeting, no distance check needed + LockpickSessionManager.getInstance().endLockpickSession( + player.getUUID(), + true + ); + + // Body item lockpick path: targetSlot stores BodyRegionV2 ordinal + BodyRegionV2 targetRegion = + BodyRegionV2.values()[session.getTargetSlot()]; + ItemStack targetStack = V2EquipmentHelper.getInRegion( + player, + targetRegion + ); + + if ( + !targetStack.isEmpty() && + targetStack.getItem() instanceof ILockable lockable + ) { + // Get lock resistance BEFORE clearing it + int lockResistance = lockable.getCurrentLockResistance(targetStack); + + // Unlock the item + lockable.setLockedByKeyUUID(targetStack, null); + lockable.clearLockResistance(targetStack); + + TiedUpMod.LOGGER.info( + "[PacketLockpickAttempt] Player {} successfully picked lock on {} ({}, resistance was {})", + player.getName().getString(), + targetStack.getDisplayName().getString(), + targetRegion, + lockResistance + ); + + // Deduct lock resistance from bind resistance + PlayerBindState state = PlayerBindState.getInstance(player); + if (state != null && state.isTiedUp() && lockResistance > 0) { + int currentBindResistance = state.getCurrentBindResistance(); + int newBindResistance = Math.max( + 0, + currentBindResistance - lockResistance + ); + state.setCurrentBindResistance(newBindResistance); + + TiedUpMod.LOGGER.info( + "[PacketLockpickAttempt] Deducted {} from bind resistance: {} -> {}", + lockResistance, + currentBindResistance, + newBindResistance + ); + + // Check if player escaped (resistance = 0) + if (newBindResistance <= 0) { + state.getStruggleBinds().successActionExternal(state); + TiedUpMod.LOGGER.info( + "[PacketLockpickAttempt] Player {} escaped via lockpick!", + player.getName().getString() + ); + } + } + } + + // Damage lockpick + damageLockpick(player); + + // Sync to all players so unlock is visible immediately + SyncManager.syncInventory(player); + + // Send result to client + ModNetwork.sendToPlayer( + new PacketLockpickMiniGameResult( + session.getSessionId(), + PacketLockpickMiniGameResult.ResultType.SUCCESS, + 0 + ), + player + ); + } + + /** + * Handle a successful furniture seat lockpick: unlock the seat, dismount + * the passenger, play the unlock sound, and broadcast the updated state. + */ + private void handleFurnitureLockpickSuccess( + ServerPlayer player, + CompoundTag ctx + ) { + int furnitureEntityId = ctx.getInt("furniture_id"); + String seatId = ctx.getString("seat_id"); + + Entity entity = player.level().getEntity(furnitureEntityId); + if (!(entity instanceof EntityFurniture furniture)) { + TiedUpMod.LOGGER.warn( + "[PacketLockpickAttempt] Furniture entity {} not found or wrong type for lockpick success", + furnitureEntityId + ); + return; + } + + // Unlock the seat + furniture.setSeatLocked(seatId, false); + + // Dismount the passenger in that seat + Entity passenger = furniture.findPassengerInSeat(seatId); + if (passenger != null) { + // Clear reconnection tag before dismount + if (passenger instanceof ServerPlayer passengerPlayer) { + passengerPlayer.getPersistentData().remove("tiedup_locked_furniture"); + } + passenger.stopRiding(); + } + + // Play unlock sound from the furniture definition + FurnitureDefinition def = furniture.getDefinition(); + if (def != null && def.feedback().unlockSound() != null) { + player.level().playSound( + null, + entity.getX(), entity.getY(), entity.getZ(), + SoundEvent.createVariableRangeEvent(def.feedback().unlockSound()), + SoundSource.BLOCKS, 1.0f, 1.0f + ); + } + + // Broadcast updated lock/anim state to all tracking clients + PacketSyncFurnitureState.sendToTracking(furniture); + + TiedUpMod.LOGGER.info( + "[PacketLockpickAttempt] Player {} picked furniture lock on entity {} seat '{}'", + player.getName().getString(), furnitureEntityId, seatId + ); + } + + private void handleOutOfPicks( + ServerPlayer player, + LockpickMiniGameState session + ) { + LockpickSessionManager.getInstance().endLockpickSession( + player.getUUID(), + false + ); + + // Destroy the lockpick + ItemStack lockpickStack = ItemLockpick.findLockpickInInventory(player); + if (!lockpickStack.isEmpty()) { + lockpickStack.shrink(1); + } + + TiedUpMod.LOGGER.info( + "[PacketLockpickAttempt] Player {} ran out of lockpicks", + player.getName().getString() + ); + + // Trigger shock if wearing shock collar + triggerShockIfCollar(player); + + // Send result to client + ModNetwork.sendToPlayer( + new PacketLockpickMiniGameResult( + session.getSessionId(), + PacketLockpickMiniGameResult.ResultType.OUT_OF_PICKS, + 0 + ), + player + ); + } + + private void handleMissed( + ServerPlayer player, + LockpickMiniGameState session + ) { + // Calculate distance BEFORE damaging (for animation feedback) + float distance = session.getDistanceToSweetSpot(); + + // Damage lockpick + damageLockpick(player); + + // Update remaining uses from actual lockpick + int remainingUses = 0; + ItemStack lockpickStack = ItemLockpick.findLockpickInInventory(player); + if (!lockpickStack.isEmpty()) { + remainingUses = + lockpickStack.getMaxDamage() - lockpickStack.getDamageValue(); + } + session.setRemainingUses(remainingUses); + + // Check for JAM (5% chance on miss) — only applies to body item lockpick sessions. + // Furniture seat locks do not have a jam mechanic (there is no ILockable item to jam). + boolean jammed = false; + boolean isFurnitureSession = player.getPersistentData() + .getCompound("tiedup_furniture_lockpick_ctx") + .contains("furniture_id"); + + if (!isFurnitureSession && player.getRandom().nextFloat() < 0.05f) { + int targetSlot = session.getTargetSlot(); + if (targetSlot >= 0 && targetSlot < BodyRegionV2.values().length) { + BodyRegionV2 targetRegion = BodyRegionV2.values()[targetSlot]; + ItemStack targetStack = V2EquipmentHelper.getInRegion( + player, + targetRegion + ); + + if ( + !targetStack.isEmpty() && + targetStack.getItem() instanceof ILockable lockable + ) { + lockable.setJammed(targetStack, true); + jammed = true; + + player.sendSystemMessage( + Component.literal( + "The lock jammed! Only struggle can open it now." + ).withStyle(ChatFormatting.RED) + ); + + TiedUpMod.LOGGER.info( + "[PacketLockpickAttempt] Player {} jammed the lock on {} ({})", + player.getName().getString(), + targetStack.getDisplayName().getString(), + targetRegion + ); + + // End session since lock is jammed + LockpickSessionManager.getInstance().endLockpickSession( + player.getUUID(), + false + ); + + // Sync jam state to all players + SyncManager.syncInventory(player); + } + } + } + + // Trigger shock if wearing shock collar + triggerShockIfCollar(player); + + // Send result to client with distance for animation + if (jammed) { + // Send cancelled result since lock is jammed + ModNetwork.sendToPlayer( + new PacketLockpickMiniGameResult( + session.getSessionId(), + PacketLockpickMiniGameResult.ResultType.CANCELLED, + 0, + distance + ), + player + ); + } else { + ModNetwork.sendToPlayer( + new PacketLockpickMiniGameResult( + session.getSessionId(), + PacketLockpickMiniGameResult.ResultType.MISSED, + remainingUses, + distance + ), + player + ); + } + } + + /** + * Damage the player's lockpick by 1 durability. If durability is exhausted, + * the lockpick item is consumed (shrunk). Shared by handleSuccess, handleMissed, + * and handleFurnitureLockpickSuccess paths. + */ + private void damageLockpick(ServerPlayer player) { + ItemStack lockpickStack = ItemLockpick.findLockpickInInventory(player); + if (!lockpickStack.isEmpty()) { + lockpickStack.setDamageValue(lockpickStack.getDamageValue() + 1); + if (lockpickStack.getDamageValue() >= lockpickStack.getMaxDamage()) { + lockpickStack.shrink(1); + } + } + } + + private void triggerShockIfCollar(ServerPlayer player) { + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) return; + + ItemStack collar = V2EquipmentHelper.getInRegion( + player, BodyRegionV2.NECK + ); + if (collar.isEmpty()) return; + + if ( + collar.getItem() instanceof com.tiedup.remake.items.ItemShockCollar + ) { + state.shockKidnapped(" (Failed lockpick attempt)", 2.0f); + TiedUpMod.LOGGER.info( + "[PacketLockpickAttempt] Player {} shocked for failed lockpick", + player.getName().getString() + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickMiniGameMove.java b/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickMiniGameMove.java new file mode 100644 index 0000000..bd0a4a4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickMiniGameMove.java @@ -0,0 +1,87 @@ +package com.tiedup.remake.network.minigame; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.minigame.LockpickMiniGameState; +import com.tiedup.remake.minigame.LockpickSessionManager; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.network.NetworkEvent; + +/** + * Phase 2: Packet for player position update during lockpick mini-game (Client to Server). + * + * Contains: + * - Session UUID + * - New position (0.0 to 1.0) + */ +public class PacketLockpickMiniGameMove { + + private final UUID sessionId; + private final float newPosition; + + public PacketLockpickMiniGameMove(UUID sessionId, float newPosition) { + this.sessionId = sessionId; + this.newPosition = newPosition; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeUUID(sessionId); + buf.writeFloat(newPosition); + } + + public static PacketLockpickMiniGameMove decode(FriendlyByteBuf buf) { + UUID sessionId = buf.readUUID(); + float newPosition = buf.readFloat(); + return new PacketLockpickMiniGameMove(sessionId, newPosition); + } + + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer player = ctx.get().getSender(); + if (player == null) { + return; + } + + // Rate limiting: Prevent lockpick spam + if ( + !com.tiedup.remake.network.PacketRateLimiter.allowPacket( + player, + "minigame" + ) + ) { + return; + } + + handleServer(player); + }); + ctx.get().setPacketHandled(true); + } + + private void handleServer(ServerPlayer player) { + LockpickSessionManager manager = LockpickSessionManager.getInstance(); + + // Validate session + if (!manager.validateLockpickSession(player.getUUID(), sessionId)) { + TiedUpMod.LOGGER.warn( + "[PacketLockpickMiniGameMove] Invalid session {} for player {}", + sessionId.toString().substring(0, 8), + player.getName().getString() + ); + return; + } + + LockpickMiniGameState session = manager.getLockpickSession( + player.getUUID() + ); + if (session == null || session.isComplete()) { + return; + } + + // Update position (server-side validation: clamp to 0-1) + session.setCurrentPosition(Math.max(0.0f, Math.min(1.0f, newPosition))); + } +} diff --git a/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickMiniGameResult.java b/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickMiniGameResult.java new file mode 100644 index 0000000..a786f5a --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickMiniGameResult.java @@ -0,0 +1,154 @@ +package com.tiedup.remake.network.minigame; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.base.AbstractClientPacket; +import java.util.UUID; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Phase 2: Packet to send lockpick result to client (Server to Client). + * + * Contains: + * - Session UUID + * - Result type (SUCCESS, MISSED, OUT_OF_PICKS) + * - Remaining uses + */ +public class PacketLockpickMiniGameResult extends AbstractClientPacket { + + public enum ResultType { + /** + * Successfully picked the lock + */ + SUCCESS(0), + + /** + * Missed the sweet spot + */ + MISSED(1), + + /** + * Ran out of lockpick uses + */ + OUT_OF_PICKS(2), + + /** + * Session cancelled + */ + CANCELLED(3); + + private final int id; + + ResultType(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public static ResultType fromId(int id) { + return switch (id) { + case 0 -> SUCCESS; + case 1 -> MISSED; + case 2 -> OUT_OF_PICKS; + case 3 -> CANCELLED; + default -> MISSED; + }; + } + } + + private final UUID sessionId; + private final ResultType resultType; + private final int remainingUses; + private final float distance; // Distance to sweet spot (0.0-1.0), used for MISSED animation + + public PacketLockpickMiniGameResult( + UUID sessionId, + ResultType resultType, + int remainingUses + ) { + this(sessionId, resultType, remainingUses, 0f); + } + + public PacketLockpickMiniGameResult( + UUID sessionId, + ResultType resultType, + int remainingUses, + float distance + ) { + this.sessionId = sessionId; + this.resultType = resultType; + this.remainingUses = remainingUses; + this.distance = distance; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeUUID(sessionId); + buf.writeVarInt(resultType.getId()); + buf.writeVarInt(remainingUses); + buf.writeFloat(distance); + } + + public static PacketLockpickMiniGameResult decode(FriendlyByteBuf buf) { + UUID sessionId = buf.readUUID(); + ResultType resultType = ResultType.fromId(buf.readVarInt()); + int remainingUses = buf.readVarInt(); + float distance = buf.readFloat(); + return new PacketLockpickMiniGameResult( + sessionId, + resultType, + remainingUses, + distance + ); + } + + @Override + @OnlyIn(Dist.CLIENT) + protected void handleClientImpl() { + ClientHandler.handle(this); + } + + @OnlyIn(Dist.CLIENT) + private static class ClientHandler { + private static void handle(PacketLockpickMiniGameResult pkt) { + net.minecraft.client.Minecraft mc = net.minecraft.client.Minecraft.getInstance(); + if (mc.player == null) { + return; + } + + TiedUpMod.LOGGER.debug( + "[PacketLockpickMiniGameResult] Received result: type={}, uses={}", + pkt.resultType, + pkt.remainingUses + ); + + if (mc.screen instanceof com.tiedup.remake.client.gui.screens.LockpickMiniGameScreen screen) { + switch (pkt.resultType) { + case SUCCESS -> screen.onSuccess(); + case MISSED -> screen.onMissed(pkt.remainingUses, pkt.distance); + case OUT_OF_PICKS -> screen.onOutOfPicks(); + case CANCELLED -> screen.onCancelled(); + } + } + } + } + + // Getters + public UUID getSessionId() { + return sessionId; + } + + public ResultType getResultType() { + return resultType; + } + + public int getRemainingUses() { + return remainingUses; + } + + public float getDistance() { + return distance; + } +} diff --git a/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickMiniGameStart.java b/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickMiniGameStart.java new file mode 100644 index 0000000..361efe0 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickMiniGameStart.java @@ -0,0 +1,157 @@ +package com.tiedup.remake.network.minigame; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.ItemLockpick; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.minigame.LockpickMiniGameState; +import com.tiedup.remake.minigame.LockpickSessionManager; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.PacketRateLimiter; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.network.NetworkEvent; + +/** + * Phase 2: Packet to start a Lockpick mini-game session (Client to Server). + * + * Sent when player clicks "Lockpick" on a locked item. + */ +public class PacketLockpickMiniGameStart { + + private final BodyRegionV2 targetRegion; + + public PacketLockpickMiniGameStart(BodyRegionV2 targetRegion) { + this.targetRegion = targetRegion; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeEnum(targetRegion); + } + + public static PacketLockpickMiniGameStart decode(FriendlyByteBuf buf) { + return new PacketLockpickMiniGameStart(buf.readEnum(BodyRegionV2.class)); + } + + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer player = ctx.get().getSender(); + if (player == null) { + return; + } + handleServer(player); + }); + ctx.get().setPacketHandled(true); + } + + private void handleServer(ServerPlayer player) { + if (!PacketRateLimiter.allowPacket(player, "minigame")) return; + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + return; + } + + // Check for mittens + if (state.hasMittens()) { + TiedUpMod.LOGGER.debug( + "[PacketLockpickMiniGameStart] Player {} has mittens, cannot lockpick", + player.getName().getString() + ); + return; + } + + // Find lockpick in inventory + ItemStack lockpickStack = ItemLockpick.findLockpickInInventory(player); + if (lockpickStack.isEmpty()) { + TiedUpMod.LOGGER.debug( + "[PacketLockpickMiniGameStart] Player {} has no lockpick", + player.getName().getString() + ); + return; + } + + // Get target item via V2 equipment system + ItemStack targetStack = V2EquipmentHelper.getInRegion( + player, + targetRegion + ); + + if ( + targetStack.isEmpty() || + !(targetStack.getItem() instanceof ILockable lockable) + ) { + TiedUpMod.LOGGER.debug( + "[PacketLockpickMiniGameStart] Target region {} is not lockable", + targetRegion + ); + return; + } + + if (!lockable.isLocked(targetStack)) { + TiedUpMod.LOGGER.debug( + "[PacketLockpickMiniGameStart] Target region {} is not locked", + targetRegion + ); + return; + } + + if (lockable.isJammed(targetStack)) { + TiedUpMod.LOGGER.debug( + "[PacketLockpickMiniGameStart] Target region {} is jammed", + targetRegion + ); + return; + } + + // Determine sweet spot width based on lockpick type + float sweetSpotWidth = 0.03f; // 3% - Very difficult sweet spot (Skyrim-style) + + // Get remaining uses + int remainingUses = + lockpickStack.getMaxDamage() - lockpickStack.getDamageValue(); + + // Start session + LockpickSessionManager manager = LockpickSessionManager.getInstance(); + LockpickMiniGameState session = manager.startLockpickSession( + player, + targetRegion.ordinal(), + sweetSpotWidth + ); + + if (session == null) { + TiedUpMod.LOGGER.warn( + "[PacketLockpickMiniGameStart] Failed to create lockpick session for {}", + player.getName().getString() + ); + return; + } + + session.setRemainingUses(remainingUses); + + TiedUpMod.LOGGER.info( + "[PacketLockpickMiniGameStart] Started lockpick session for {} (region: {}, uses: {})", + player.getName().getString(), + targetRegion, + remainingUses + ); + + // Send initial state to client + ModNetwork.sendToPlayer( + new PacketLockpickMiniGameState( + session.getSessionId(), + session.getSweetSpotCenter(), + session.getSweetSpotWidth(), + session.getCurrentPosition(), + session.getRemainingUses() + ), + player + ); + } +} diff --git a/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickMiniGameState.java b/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickMiniGameState.java new file mode 100644 index 0000000..a3bb13b --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickMiniGameState.java @@ -0,0 +1,119 @@ +package com.tiedup.remake.network.minigame; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.base.AbstractClientPacket; +import java.util.UUID; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Phase 2: Packet to send lockpick initial state to client (Server to Client). + * + * Contains: + * - Session UUID + * - Sweet spot center and width (server-authoritative) + * - Current position + * - Remaining lockpick uses + */ +public class PacketLockpickMiniGameState extends AbstractClientPacket { + + private final UUID sessionId; + private final float sweetSpotCenter; + private final float sweetSpotWidth; + private final float currentPosition; + private final int remainingUses; + + public PacketLockpickMiniGameState( + UUID sessionId, + float sweetSpotCenter, + float sweetSpotWidth, + float currentPosition, + int remainingUses + ) { + this.sessionId = sessionId; + this.sweetSpotCenter = sweetSpotCenter; + this.sweetSpotWidth = sweetSpotWidth; + this.currentPosition = currentPosition; + this.remainingUses = remainingUses; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeUUID(sessionId); + buf.writeFloat(sweetSpotCenter); + buf.writeFloat(sweetSpotWidth); + buf.writeFloat(currentPosition); + buf.writeVarInt(remainingUses); + } + + public static PacketLockpickMiniGameState decode(FriendlyByteBuf buf) { + UUID sessionId = buf.readUUID(); + float sweetSpotCenter = buf.readFloat(); + float sweetSpotWidth = buf.readFloat(); + float currentPosition = buf.readFloat(); + int remainingUses = buf.readVarInt(); + return new PacketLockpickMiniGameState( + sessionId, + sweetSpotCenter, + sweetSpotWidth, + currentPosition, + remainingUses + ); + } + + @Override + @OnlyIn(Dist.CLIENT) + protected void handleClientImpl() { + ClientHandler.handle(this); + } + + @OnlyIn(Dist.CLIENT) + private static class ClientHandler { + private static void handle(PacketLockpickMiniGameState pkt) { + net.minecraft.client.Minecraft mc = net.minecraft.client.Minecraft.getInstance(); + if (mc.player == null) { + return; + } + + TiedUpMod.LOGGER.debug( + "[PacketLockpickMiniGameState] Received state: sweet={}(w={}), pos={}, uses={}", + pkt.sweetSpotCenter, + pkt.sweetSpotWidth, + pkt.currentPosition, + pkt.remainingUses + ); + + // Open the lockpick mini-game screen + mc.setScreen( + new com.tiedup.remake.client.gui.screens.LockpickMiniGameScreen( + pkt.sessionId, + pkt.sweetSpotCenter, + pkt.sweetSpotWidth, + pkt.currentPosition, + pkt.remainingUses + ) + ); + } + } + + // Getters + public UUID getSessionId() { + return sessionId; + } + + public float getSweetSpotCenter() { + return sweetSpotCenter; + } + + public float getSweetSpotWidth() { + return sweetSpotWidth; + } + + public float getCurrentPosition() { + return currentPosition; + } + + public int getRemainingUses() { + return remainingUses; + } +} diff --git a/src/main/java/com/tiedup/remake/network/personality/NpcCommandType.java b/src/main/java/com/tiedup/remake/network/personality/NpcCommandType.java new file mode 100644 index 0000000..0c33c96 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/personality/NpcCommandType.java @@ -0,0 +1,13 @@ +package com.tiedup.remake.network.personality; + +/** + * Discriminator for the unified NPC command packet. + * Each type maps to what was previously a separate packet class. + */ +public enum NpcCommandType { + GIVE_COMMAND, + CANCEL_COMMAND, + SELECT_JOB, + CYCLE_FOLLOW_DISTANCE, + TOGGLE_AUTO_REST +} diff --git a/src/main/java/com/tiedup/remake/network/personality/PacketDisciplineAction.java b/src/main/java/com/tiedup/remake/network/personality/PacketDisciplineAction.java new file mode 100644 index 0000000..f4ba861 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/personality/PacketDisciplineAction.java @@ -0,0 +1,140 @@ +package com.tiedup.remake.network.personality; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +// Discipline now only triggers dialogue, no personality effects +import com.tiedup.remake.dialogue.EntityDialogueManager; +import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.network.PacketRateLimiter; +import com.tiedup.remake.personality.DisciplineType; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet sent from client to server to apply discipline to an NPC. + * Used for verbal discipline actions (Praise, Scold, Threaten) from the DialogueScreen. + * + * Training System V2: Verbal discipline support + */ +public class PacketDisciplineAction { + + /** Maximum range for discipline actions */ + private static final double MAX_RANGE = 32.0; + + private final int entityId; + private final String disciplineTypeName; + + public PacketDisciplineAction(int entityId, DisciplineType type) { + this.entityId = entityId; + this.disciplineTypeName = type.name(); + } + + private PacketDisciplineAction(int entityId, String disciplineTypeName) { + this.entityId = entityId; + this.disciplineTypeName = disciplineTypeName; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeInt(entityId); + buf.writeUtf(disciplineTypeName, 32); + } + + public static PacketDisciplineAction decode(FriendlyByteBuf buf) { + return new PacketDisciplineAction(buf.readInt(), buf.readUtf(32)); + } + + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer sender = ctx.get().getSender(); + if (sender == null) return; + handleServer(sender); + }); + ctx.get().setPacketHandled(true); + } + + private void handleServer(ServerPlayer sender) { + // MEDIUM FIX: Rate limiting to prevent discipline spam exploit + if (!PacketRateLimiter.allowPacket(sender, "action")) { + return; + } + + // Find the target entity + Entity entity = sender.level().getEntity(entityId); + if (!(entity instanceof EntityDamsel damsel)) { + TiedUpMod.LOGGER.warn( + "[PacketDisciplineAction] Entity {} not found or not a Damsel", + entityId + ); + return; + } + + // Validate distance + double distance = sender.distanceTo(damsel); + if (distance > MAX_RANGE) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + "Target is too far away!" + ); + return; + } + + // Parse discipline type + DisciplineType type; + try { + type = DisciplineType.valueOf(disciplineTypeName); + } catch (IllegalArgumentException e) { + TiedUpMod.LOGGER.warn( + "[PacketDisciplineAction] Unknown discipline type: {}", + disciplineTypeName + ); + return; + } + + // Only allow verbal discipline from this packet (Praise, Scold, Threaten) + if (!type.isVerbal()) { + TiedUpMod.LOGGER.warn( + "[PacketDisciplineAction] Non-verbal discipline {} attempted via packet", + type.name() + ); + return; + } + + // Show appropriate dialogue + DialogueCategory category = switch (type) { + case PRAISE -> DialogueCategory.PRAISE_RESPONSE; + case SCOLD -> DialogueCategory.SCOLD_RESPONSE; + case THREATEN -> DialogueCategory.THREATEN_RESPONSE; + default -> DialogueCategory.SCOLD_RESPONSE; + }; + EntityDialogueManager.talkTo(damsel, sender, category); + + // Send feedback + String npcName = damsel.getNpcName(); + String actionDesc = switch (type) { + case PRAISE -> "praised"; + case SCOLD -> "scolded"; + case THREATEN -> "threatened"; + default -> "disciplined"; + }; + + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.INFO, + "You " + actionDesc + " " + npcName + "." + ); + + TiedUpMod.LOGGER.debug( + "[PacketDisciplineAction] {} {} {}", + sender.getName().getString(), + actionDesc, + npcName + ); + } +} diff --git a/src/main/java/com/tiedup/remake/network/personality/PacketNpcCommand.java b/src/main/java/com/tiedup/remake/network/personality/PacketNpcCommand.java new file mode 100644 index 0000000..cf5ca07 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/personality/PacketNpcCommand.java @@ -0,0 +1,312 @@ +package com.tiedup.remake.network.personality; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.dialogue.EntityDialogueManager; +import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.items.ItemCommandWand; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.PacketRateLimiter; +import com.tiedup.remake.personality.JobExperience; +import com.tiedup.remake.personality.NpcCommand; +import com.tiedup.remake.personality.NpcNeeds; +import com.tiedup.remake.personality.PersonalityState; +import com.tiedup.remake.v2.BodyRegionV2; +import java.util.UUID; +import java.util.function.Supplier; +import org.jetbrains.annotations.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.network.NetworkEvent; + +/** + * Unified C2S packet for all NPC command wand actions. + * Replaces PacketGiveCommand, PacketCancelCommand, PacketSelectJobCommand, + * PacketCycleFollowDistance, and PacketToggleAutoRest. + */ +public class PacketNpcCommand { + + private static final double MAX_COMMAND_RANGE = 32.0; + + private final UUID entityUUID; + private final NpcCommandType type; + + // Optional fields depending on type + private final String commandName; // GIVE_COMMAND, SELECT_JOB + @Nullable + private final BlockPos targetPos; // GIVE_COMMAND only + private final boolean refreshScreen; // CYCLE_FOLLOW_DISTANCE, TOGGLE_AUTO_REST + + // --- Constructors for each command type --- + + public static PacketNpcCommand giveCommand(UUID entityUUID, NpcCommand command, @Nullable BlockPos targetPos) { + return new PacketNpcCommand(entityUUID, NpcCommandType.GIVE_COMMAND, command.name(), targetPos, false); + } + + public static PacketNpcCommand cancelCommand(UUID entityUUID) { + return new PacketNpcCommand(entityUUID, NpcCommandType.CANCEL_COMMAND, "", null, false); + } + + public static PacketNpcCommand selectJob(UUID entityUUID, NpcCommand job) { + return new PacketNpcCommand(entityUUID, NpcCommandType.SELECT_JOB, job.name(), null, false); + } + + public static PacketNpcCommand cycleFollowDistance(UUID entityUUID, boolean refreshScreen) { + return new PacketNpcCommand(entityUUID, NpcCommandType.CYCLE_FOLLOW_DISTANCE, "", null, refreshScreen); + } + + public static PacketNpcCommand toggleAutoRest(UUID entityUUID, boolean refreshScreen) { + return new PacketNpcCommand(entityUUID, NpcCommandType.TOGGLE_AUTO_REST, "", null, refreshScreen); + } + + private PacketNpcCommand(UUID entityUUID, NpcCommandType type, String commandName, + @Nullable BlockPos targetPos, boolean refreshScreen) { + this.entityUUID = entityUUID; + this.type = type; + this.commandName = commandName; + this.targetPos = targetPos; + this.refreshScreen = refreshScreen; + } + + // --- Wire format --- + + public void encode(FriendlyByteBuf buf) { + buf.writeUUID(entityUUID); + buf.writeEnum(type); + switch (type) { + case GIVE_COMMAND -> { + buf.writeUtf(commandName); + buf.writeBoolean(targetPos != null); + if (targetPos != null) { + buf.writeBlockPos(targetPos); + } + } + case SELECT_JOB -> buf.writeUtf(commandName); + case CYCLE_FOLLOW_DISTANCE, TOGGLE_AUTO_REST -> buf.writeBoolean(refreshScreen); + case CANCEL_COMMAND -> {} // no extra data + } + } + + public static PacketNpcCommand decode(FriendlyByteBuf buf) { + UUID uuid = buf.readUUID(); + NpcCommandType type = buf.readEnum(NpcCommandType.class); + String cmd = ""; + BlockPos pos = null; + boolean refresh = false; + + switch (type) { + case GIVE_COMMAND -> { + cmd = buf.readUtf(32); + if (buf.readBoolean()) { + pos = buf.readBlockPos(); + } + } + case SELECT_JOB -> cmd = buf.readUtf(32); + case CYCLE_FOLLOW_DISTANCE, TOGGLE_AUTO_REST -> refresh = buf.readBoolean(); + case CANCEL_COMMAND -> {} + } + + return new PacketNpcCommand(uuid, type, cmd, pos, refresh); + } + + public void handle(Supplier ctx) { + ctx.get().enqueueWork(() -> { + ServerPlayer sender = ctx.get().getSender(); + if (sender == null) return; + handleServer(sender); + }); + ctx.get().setPacketHandled(true); + } + + // --- Server handling --- + + private void handleServer(ServerPlayer sender) { + if (!PacketRateLimiter.allowPacket(sender, "action")) return; + + Entity entity = ((net.minecraft.server.level.ServerLevel) sender.level()).getEntity(entityUUID); + if (!(entity instanceof EntityDamsel damsel)) { + TiedUpMod.LOGGER.warn("[PacketNpcCommand:{}] Entity {} not found or not a Damsel", + type, entityUUID); + return; + } + + double distance = sender.distanceTo(damsel); + if (distance > MAX_COMMAND_RANGE) { + SystemMessageManager.sendToPlayer(sender, + SystemMessageManager.MessageCategory.ERROR, "Target is too far away!"); + return; + } + + switch (type) { + case GIVE_COMMAND -> handleGiveCommand(sender, damsel); + case CANCEL_COMMAND -> handleCancelCommand(sender, damsel); + case SELECT_JOB -> handleSelectJob(sender, damsel); + case CYCLE_FOLLOW_DISTANCE -> handleCycleFollowDistance(sender, damsel); + case TOGGLE_AUTO_REST -> handleToggleAutoRest(sender, damsel); + } + } + + private void handleGiveCommand(ServerPlayer sender, EntityDamsel damsel) { + NpcCommand command = NpcCommand.fromString(commandName); + if (command == NpcCommand.NONE && !"NONE".equals(commandName)) { + TiedUpMod.LOGGER.warn("[PacketNpcCommand:GIVE] Unknown command: {}", commandName); + return; + } + + boolean success = damsel.giveCommand(sender, command, targetPos); + String npcName = damsel.getNpcName(); + if (success) { + EntityDialogueManager.talkTo(damsel, sender, DialogueCategory.COMMAND_ACCEPT); + SystemMessageManager.sendToPlayer(sender, + SystemMessageManager.MessageCategory.INFO, + npcName + " accepted command: " + command.name()); + TiedUpMod.LOGGER.debug("[PacketNpcCommand:GIVE] {} gave command {} to {}", + sender.getName().getString(), command.name(), npcName); + } + } + + private void handleCancelCommand(ServerPlayer sender, EntityDamsel damsel) { + if (!validateCollarOwnership(sender, damsel)) return; + + damsel.cancelCommand(); + String npcName = damsel.getNpcName(); + SystemMessageManager.sendToPlayer(sender, + SystemMessageManager.MessageCategory.INFO, npcName + "'s command cancelled."); + TiedUpMod.LOGGER.debug("[PacketNpcCommand:CANCEL] {} cancelled command for {}", + sender.getName().getString(), npcName); + } + + private void handleSelectJob(ServerPlayer sender, EntityDamsel damsel) { + NpcCommand job = NpcCommand.fromString(commandName); + if (!job.isWorkCommand()) { + TiedUpMod.LOGGER.warn("[PacketNpcCommand:JOB] {} is not a work command", commandName); + return; + } + + PersonalityState state = damsel.getPersonalityState(); + if (state == null) return; + + String npcName = damsel.getNpcName(); + if (state.willObeyCommand(sender, job)) { + ItemStack mainHand = sender.getItemInHand(InteractionHand.MAIN_HAND); + ItemStack offHand = sender.getItemInHand(InteractionHand.OFF_HAND); + + ItemStack wand = null; + if (mainHand.getItem() == ModItems.COMMAND_WAND.get()) { + wand = mainHand; + } else if (offHand.getItem() == ModItems.COMMAND_WAND.get()) { + wand = offHand; + } + + if (wand != null) { + ItemCommandWand.enterSelectionMode(wand, damsel.getUUID(), job); + EntityDialogueManager.talkTo(damsel, sender, DialogueCategory.COMMAND_ACCEPT); + SystemMessageManager.sendToPlayer(sender, + SystemMessageManager.MessageCategory.INFO, + npcName + " is ready to " + job.name() + ". Click a chest to set work zone!"); + TiedUpMod.LOGGER.debug("[PacketNpcCommand:JOB] {} accepted job {} - wand in selection mode", + npcName, job.name()); + } + } + } + + private void handleCycleFollowDistance(ServerPlayer sender, EntityDamsel damsel) { + if (!validateCollarOwnership(sender, damsel)) return; + + PersonalityState state = damsel.getPersonalityState(); + if (state == null) return; + + NpcCommand.FollowDistance current = state.getFollowDistance(); + NpcCommand.FollowDistance next = switch (current) { + case FAR -> NpcCommand.FollowDistance.CLOSE; + case CLOSE -> NpcCommand.FollowDistance.HEEL; + case HEEL -> NpcCommand.FollowDistance.FAR; + }; + state.setFollowDistance(next); + + TiedUpMod.LOGGER.debug("[PacketNpcCommand:FOLLOW] {} changed {} follow distance to {}", + sender.getName().getString(), damsel.getNpcName(), next.name()); + + if (refreshScreen) { + sendRefreshedScreen(sender, damsel, state); + } + } + + private void handleToggleAutoRest(ServerPlayer sender, EntityDamsel damsel) { + if (!validateCollarOwnership(sender, damsel)) return; + + PersonalityState state = damsel.getPersonalityState(); + if (state == null) return; + + boolean newState = state.toggleAutoRest(); + TiedUpMod.LOGGER.debug("[PacketNpcCommand:REST] {} toggled {} auto-rest to {}", + sender.getName().getString(), damsel.getNpcName(), newState ? "ON" : "OFF"); + SystemMessageManager.sendToPlayer(sender, + SystemMessageManager.MessageCategory.INFO, + "Auto-Rest: " + (newState ? "ON" : "OFF")); + + if (refreshScreen) { + sendRefreshedScreen(sender, damsel, state); + } + } + + // --- Shared helpers --- + + private boolean validateCollarOwnership(ServerPlayer sender, EntityDamsel damsel) { + if (!damsel.hasCollar()) { + SystemMessageManager.sendToPlayer(sender, + SystemMessageManager.MessageCategory.ERROR, + damsel.getNpcName() + " is not wearing a collar!"); + return false; + } + + ItemStack collar = damsel.getEquipment(BodyRegionV2.NECK); + if (!(collar.getItem() instanceof ItemCollar collarItem)) { + return false; + } + + if (!collarItem.getOwners(collar).contains(sender.getUUID())) { + SystemMessageManager.sendToPlayer(sender, + SystemMessageManager.MessageCategory.ERROR, + "You don't own " + damsel.getNpcName() + "'s collar!"); + return false; + } + + return true; + } + + private static void sendRefreshedScreen(ServerPlayer sender, EntityDamsel damsel, + PersonalityState state) { + NpcNeeds needs = state.getNeeds(); + String homeType = state.getHomeType().name(); + + JobExperience jobExp = state.getJobExperience(); + NpcCommand activeCmd = state.getActiveCommand(); + String activeJobLevelName = ""; + int activeJobXp = 0; + int activeJobXpMax = 10; + if (activeCmd.isActiveJob()) { + JobExperience.JobLevel level = jobExp.getJobLevel(activeCmd); + activeJobLevelName = level.name(); + activeJobXp = jobExp.getExperience(activeCmd); + activeJobXpMax = level.maxExp; + } + + ModNetwork.sendToPlayer( + new PacketOpenCommandWandScreen( + damsel.getUUID(), damsel.getNpcName(), + state.getPersonality().name(), activeCmd.name(), + needs.getHunger(), needs.getRest(), state.getMood(), + state.getFollowDistance().name(), homeType, + state.isAutoRestEnabled(), "", "", + activeJobLevelName, activeJobXp, activeJobXpMax), + sender); + } +} diff --git a/src/main/java/com/tiedup/remake/network/personality/PacketOpenCommandWandScreen.java b/src/main/java/com/tiedup/remake/network/personality/PacketOpenCommandWandScreen.java new file mode 100644 index 0000000..7eedb35 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/personality/PacketOpenCommandWandScreen.java @@ -0,0 +1,284 @@ +package com.tiedup.remake.network.personality; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.fml.loading.FMLEnvironment; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet sent from server to client to open the Command Wand screen. + * Contains all personality data needed for the GUI. + * + * Simplified: removed discovery, fear, relationship, secondaryTrait fields. + */ +public class PacketOpenCommandWandScreen { + + private final java.util.UUID entityUUID; + private final String npcName; + private final String personalityTypeName; + private final String activeCommandName; + private final float hunger; + private final float rest; + private final float mood; + private final String followDistanceMode; + private final String homeType; + private final boolean autoRestEnabled; + private final String cellName; + private final String cellQualityName; + private final String activeJobLevelName; + private final int activeJobXp; + private final int activeJobXpMax; + + /** + * Constructor without cell info or job experience (defaults to ""). + */ + public PacketOpenCommandWandScreen( + java.util.UUID entityUUID, + String npcName, + String personalityTypeName, + String activeCommandName, + float hunger, + float rest, + float mood, + String followDistanceMode, + String homeType, + boolean autoRestEnabled + ) { + this( + entityUUID, + npcName, + personalityTypeName, + activeCommandName, + hunger, + rest, + mood, + followDistanceMode, + homeType, + autoRestEnabled, + "", + "", + "", + 0, + 10 + ); + } + + /** + * Constructor with cell info but no job experience. + */ + public PacketOpenCommandWandScreen( + java.util.UUID entityUUID, + String npcName, + String personalityTypeName, + String activeCommandName, + float hunger, + float rest, + float mood, + String followDistanceMode, + String homeType, + boolean autoRestEnabled, + String cellName, + String cellQualityName + ) { + this( + entityUUID, + npcName, + personalityTypeName, + activeCommandName, + hunger, + rest, + mood, + followDistanceMode, + homeType, + autoRestEnabled, + cellName, + cellQualityName, + "", + 0, + 10 + ); + } + + /** + * Full constructor with cell info and job experience. + */ + public PacketOpenCommandWandScreen( + java.util.UUID entityUUID, + String npcName, + String personalityTypeName, + String activeCommandName, + float hunger, + float rest, + float mood, + String followDistanceMode, + String homeType, + boolean autoRestEnabled, + String cellName, + String cellQualityName, + String activeJobLevelName, + int activeJobXp, + int activeJobXpMax + ) { + this.entityUUID = entityUUID; + this.npcName = npcName; + this.personalityTypeName = personalityTypeName; + this.activeCommandName = activeCommandName; + this.hunger = hunger; + this.rest = rest; + this.mood = mood; + this.followDistanceMode = followDistanceMode; + this.homeType = homeType; + this.autoRestEnabled = autoRestEnabled; + this.cellName = cellName; + this.cellQualityName = cellQualityName; + this.activeJobLevelName = activeJobLevelName; + this.activeJobXp = activeJobXp; + this.activeJobXpMax = activeJobXpMax; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeUUID(entityUUID); + buf.writeUtf(npcName, 64); + buf.writeUtf(personalityTypeName, 32); + buf.writeUtf(activeCommandName, 32); + buf.writeFloat(hunger); + buf.writeFloat(rest); + buf.writeFloat(mood); + buf.writeUtf(followDistanceMode, 16); + buf.writeUtf(homeType, 16); + buf.writeBoolean(autoRestEnabled); + buf.writeUtf(cellName, 64); + buf.writeUtf(cellQualityName, 16); + buf.writeUtf(activeJobLevelName, 32); + buf.writeInt(activeJobXp); + buf.writeInt(activeJobXpMax); + } + + public static PacketOpenCommandWandScreen decode(FriendlyByteBuf buf) { + return new PacketOpenCommandWandScreen( + buf.readUUID(), + buf.readUtf(64), // npcName + buf.readUtf(32), // personalityTypeName + buf.readUtf(32), // activeCommandName + buf.readFloat(), // hunger + buf.readFloat(), // rest + buf.readFloat(), // mood + buf.readUtf(16), // followDistanceMode + buf.readUtf(16), // homeType + buf.readBoolean(), // autoRestEnabled + buf.readUtf(64), // cellName + buf.readUtf(16), // cellQualityName + buf.readUtf(32), // activeJobLevelName + buf.readInt(), // activeJobXp + buf.readInt() // activeJobXpMax + ); + } + + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + if (FMLEnvironment.dist == Dist.CLIENT) { + handleClient(); + } + }); + ctx.get().setPacketHandled(true); + } + + @OnlyIn(Dist.CLIENT) + private void handleClient() { + net.minecraft.client.Minecraft mc = + net.minecraft.client.Minecraft.getInstance(); + + TiedUpMod.LOGGER.debug( + "[PacketOpenCommandWandScreen] Opening screen for {} (entity {})", + npcName, + entityUUID + ); + + mc.setScreen( + new com.tiedup.remake.client.gui.screens.CommandWandScreen( + entityUUID, + npcName, + personalityTypeName, + activeCommandName, + hunger, + rest, + mood, + followDistanceMode, + homeType, + autoRestEnabled, + cellName, + cellQualityName, + activeJobLevelName, + activeJobXp, + activeJobXpMax + ) + ); + } + + // --- Getters for GUI use --- + + public java.util.UUID getEntityUUID() { + return entityUUID; + } + + public String getNpcName() { + return npcName; + } + + public String getPersonalityTypeName() { + return personalityTypeName; + } + + public String getActiveCommandName() { + return activeCommandName; + } + + public float getHunger() { + return hunger; + } + + public float getRest() { + return rest; + } + + public float getMood() { + return mood; + } + + public String getFollowDistanceMode() { + return followDistanceMode; + } + + public String getHomeType() { + return homeType; + } + + public boolean isAutoRestEnabled() { + return autoRestEnabled; + } + + public String getCellName() { + return cellName; + } + + public String getCellQualityName() { + return cellQualityName; + } + + public String getActiveJobLevelName() { + return activeJobLevelName; + } + + public int getActiveJobXp() { + return activeJobXp; + } + + public int getActiveJobXpMax() { + return activeJobXpMax; + } +} diff --git a/src/main/java/com/tiedup/remake/network/personality/PacketRequestNpcInventory.java b/src/main/java/com/tiedup/remake/network/personality/PacketRequestNpcInventory.java new file mode 100644 index 0000000..dd288a4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/personality/PacketRequestNpcInventory.java @@ -0,0 +1,106 @@ +package com.tiedup.remake.network.personality; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.network.PacketRateLimiter; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet sent from client to server to request opening the NPC inventory screen. + * Server responds with PacketOpenNpcInventory if validation passes. + * + * Personality System Phase J: NPC Inventory + */ +public class PacketRequestNpcInventory { + + private final java.util.UUID entityUUID; // HIGH FIX: use UUID for persistence + + /** + * Create a request packet. + * + * @param entityUUID The NPC entity UUID + */ + public PacketRequestNpcInventory(java.util.UUID entityUUID) { + this.entityUUID = entityUUID; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeUUID(entityUUID); // HIGH FIX + } + + public static PacketRequestNpcInventory decode(FriendlyByteBuf buf) { + return new PacketRequestNpcInventory(buf.readUUID()); + } + + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer player = ctx.get().getSender(); + if (player == null) return; + if (!PacketRateLimiter.allowPacket(player, "ui")) return; + + // HIGH FIX: lookup by UUID + Entity entity = ( + (net.minecraft.server.level.ServerLevel) player.level() + ).getEntity(entityUUID); + if (!(entity instanceof EntityDamsel damsel)) { + TiedUpMod.LOGGER.warn( + "[PacketRequestNpcInventory] Entity {} is not a damsel", + entityUUID + ); + return; + } + + // Verify player is nearby + if (player.distanceTo(damsel) > 6.0) { + TiedUpMod.LOGGER.warn( + "[PacketRequestNpcInventory] Player {} too far from NPC", + player.getName().getString() + ); + return; + } + + // Verify player is owner of collar (or NPC has no collar) + if (damsel.hasCollar()) { + ItemStack collar = damsel.getEquipment(BodyRegionV2.NECK); + if ( + collar.getItem() instanceof + com.tiedup.remake.items.base.ItemCollar collarItem + ) { + if (!collarItem.isOwner(collar, player)) { + TiedUpMod.LOGGER.warn( + "[PacketRequestNpcInventory] Player {} is not owner of NPC collar", + player.getName().getString() + ); + return; + } + } + } + + // Open vanilla container via NetworkHooks + TiedUpMod.LOGGER.debug( + "[PacketRequestNpcInventory] Opening inventory for {} to {}", + damsel.getNpcName(), + player.getName().getString() + ); + + net.minecraftforge.network.NetworkHooks.openScreen( + player, + damsel, + buf -> { + buf.writeInt(damsel.getId()); + buf.writeInt(damsel.getNpcInventorySize()); + buf.writeUtf(damsel.getNpcName(), 64); + } + ); + }); + ctx.get().setPacketHandled(true); + } +} diff --git a/src/main/java/com/tiedup/remake/network/personality/PacketSlaveBeingFreed.java b/src/main/java/com/tiedup/remake/network/personality/PacketSlaveBeingFreed.java new file mode 100644 index 0000000..cc4b7a3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/personality/PacketSlaveBeingFreed.java @@ -0,0 +1,143 @@ +package com.tiedup.remake.network.personality; + +import java.util.function.Supplier; +import net.minecraft.ChatFormatting; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.chat.Component; +import net.minecraft.sounds.SoundEvents; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.fml.loading.FMLEnvironment; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet sent from server to client to alert an owner that someone + * is trying to free their slave. + * + * Phase 11: Multiplayer protection system + */ +public class PacketSlaveBeingFreed { + + private final String slaveName; + private final String liberatorName; + private final int x; + private final int y; + private final int z; + + /** + * Create alert packet. + * + * @param slaveName Name of the slave being freed + * @param liberatorName Name of the player trying to free them + * @param x X coordinate + * @param y Y coordinate + * @param z Z coordinate + */ + public PacketSlaveBeingFreed( + String slaveName, + String liberatorName, + int x, + int y, + int z + ) { + this.slaveName = slaveName; + this.liberatorName = liberatorName; + this.x = x; + this.y = y; + this.z = z; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeUtf(slaveName, 64); + buf.writeUtf(liberatorName, 64); + buf.writeInt(x); + buf.writeInt(y); + buf.writeInt(z); + } + + public static PacketSlaveBeingFreed decode(FriendlyByteBuf buf) { + return new PacketSlaveBeingFreed( + buf.readUtf(64), + buf.readUtf(64), + buf.readInt(), + buf.readInt(), + buf.readInt() + ); + } + + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + if (FMLEnvironment.dist == Dist.CLIENT) { + handleClient(); + } + }); + ctx.get().setPacketHandled(true); + } + + @OnlyIn(Dist.CLIENT) + private void handleClient() { + net.minecraft.client.Minecraft mc = + net.minecraft.client.Minecraft.getInstance(); + + if (mc.player == null) return; + + // Build alert message + Component message = Component.literal("") + .append( + Component.literal("[WARNING] ").withStyle( + ChatFormatting.RED, + ChatFormatting.BOLD + ) + ) + .append( + Component.literal(liberatorName).withStyle( + ChatFormatting.YELLOW + ) + ) + .append( + Component.literal(" is trying to free ").withStyle( + ChatFormatting.RED + ) + ) + .append( + Component.literal(slaveName).withStyle(ChatFormatting.YELLOW) + ) + .append(Component.literal(" at ").withStyle(ChatFormatting.RED)) + .append( + Component.literal( + "[" + x + ", " + y + ", " + z + "]" + ).withStyle(ChatFormatting.AQUA) + ) + .append(Component.literal("!").withStyle(ChatFormatting.RED)); + + // Send to player chat + mc.player.displayClientMessage(message, false); + + // Play warning sound + mc.player.playSound(SoundEvents.BELL_BLOCK, 1.0f, 1.0f); + } + + // --- Getters --- + + public String getSlaveName() { + return slaveName; + } + + public String getLiberatorName() { + return liberatorName; + } + + public int getX() { + return x; + } + + public int getY() { + return y; + } + + public int getZ() { + return z; + } +} diff --git a/src/main/java/com/tiedup/remake/network/selfbondage/PacketSelfBondage.java b/src/main/java/com/tiedup/remake/network/selfbondage/PacketSelfBondage.java new file mode 100644 index 0000000..62afb50 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/selfbondage/PacketSelfBondage.java @@ -0,0 +1,357 @@ +package com.tiedup.remake.network.selfbondage; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.base.*; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.PacketRateLimiter; +import com.tiedup.remake.network.action.PacketTying; +import com.tiedup.remake.network.sync.SyncManager; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.tasks.TyingPlayerTask; +import com.tiedup.remake.tasks.TyingTask; +import com.tiedup.remake.tasks.V2TyingPlayerTask; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.core.SettingsAccessor; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet sent from client to server when player attempts self-bondage. + * Triggered by left-click with bondage item in hand. + * + * Self-bondage rules: + * - Binds: Requires tying task (same duration as normal tying) + * - Accessories (gag, blindfold, mittens, earplugs): Instant, can be equipped anytime + * - Collar: NOT allowed (cannot self-collar) + */ +public class PacketSelfBondage { + + private final InteractionHand hand; + + public PacketSelfBondage(InteractionHand hand) { + this.hand = hand; + } + + public static void encode(PacketSelfBondage msg, FriendlyByteBuf buf) { + buf.writeEnum(msg.hand); + } + + public static PacketSelfBondage decode(FriendlyByteBuf buf) { + return new PacketSelfBondage(buf.readEnum(InteractionHand.class)); + } + + public static void handle( + PacketSelfBondage msg, + Supplier ctx + ) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer player = ctx.get().getSender(); + if (player == null) return; + + // Rate limiting (dedicated bucket: 15 tokens, 6/sec refill for continuous 5 pkt/sec) + if (!PacketRateLimiter.allowPacket(player, "selfbondage")) { + return; + } + + ItemStack stack = player.getItemInHand(msg.hand); + if (stack.isEmpty()) return; + + Item item = stack.getItem(); + + // Get player's kidnapped state + IBondageState state = KidnappedHelper.getKidnappedState(player); + if (state == null) return; + + // Route to appropriate handler based on item type + // V2 bondage items — use tying task with V2 equip + if (item instanceof IV2BondageItem v2Item) { + handleV2SelfBondage(player, stack, v2Item, state); + return; + } + + // V1 routes below (legacy) + if (item instanceof ItemBind bind) { + handleSelfBind(player, stack, bind, state); + } else if (item instanceof ItemGag) { + handleSelfAccessory( + player, + stack, + state, + "gag", + s -> s.isGagged(), + s -> s.getEquipment(BodyRegionV2.MOUTH), + s -> s.unequip(BodyRegionV2.MOUTH), + (s, i) -> s.equip(BodyRegionV2.MOUTH, i) + ); + } else if (item instanceof ItemBlindfold) { + handleSelfAccessory( + player, + stack, + state, + "blindfold", + s -> s.isBlindfolded(), + s -> s.getEquipment(BodyRegionV2.EYES), + s -> s.unequip(BodyRegionV2.EYES), + (s, i) -> s.equip(BodyRegionV2.EYES, i) + ); + } else if (item instanceof ItemMittens) { + handleSelfAccessory( + player, + stack, + state, + "mittens", + s -> s.hasMittens(), + s -> s.getEquipment(BodyRegionV2.HANDS), + s -> s.unequip(BodyRegionV2.HANDS), + (s, i) -> s.equip(BodyRegionV2.HANDS, i) + ); + } else if (item instanceof ItemEarplugs) { + handleSelfAccessory( + player, + stack, + state, + "earplugs", + s -> s.hasEarplugs(), + s -> s.getEquipment(BodyRegionV2.EARS), + s -> s.unequip(BodyRegionV2.EARS), + (s, i) -> s.equip(BodyRegionV2.EARS, i) + ); + } + // ItemCollar: NOT handled - cannot self-collar + }); + + ctx.get().setPacketHandled(true); + } + + /** + * Handle self-binding with a bind item (rope, chain, etc.). + * Uses tying task system - requires holding left-click. + */ + private static void handleSelfBind( + ServerPlayer player, + ItemStack stack, + ItemBind bind, + IBondageState state + ) { + // Can't self-tie if already tied + if (state.isTiedUp()) { + TiedUpMod.LOGGER.debug( + "[SelfBondage] {} tried to self-tie but is already tied", + player.getName().getString() + ); + return; + } + + // Get player's bind state for tying task management + PlayerBindState playerState = PlayerBindState.getInstance(player); + if (playerState == null) return; + + // Get tying duration from GameRule + int tyingSeconds = SettingsAccessor.getTyingPlayerTime( + player.level().getGameRules() + ); + + // Create self-tying task (target == kidnapper) + TyingPlayerTask newTask = new TyingPlayerTask( + stack.copy(), + state, + player, // Target is self + tyingSeconds, + player.level(), + player // Kidnapper is also self + ); + + // Get current tying task + TyingTask currentTask = playerState.getCurrentTyingTask(); + + // Check if we should start a new task or continue existing one + if ( + currentTask == null || + !currentTask.isSameTarget(player) || + currentTask.isOutdated() || + !ItemStack.matches(currentTask.getBind(), stack) + ) { + // Start new self-tying task + playerState.setCurrentTyingTask(newTask); + newTask.start(); + + TiedUpMod.LOGGER.debug( + "[SelfBondage] {} started self-tying ({} seconds)", + player.getName().getString(), + tyingSeconds + ); + } else { + // Continue existing task + newTask = (TyingPlayerTask) currentTask; + } + + // Update task progress + newTask.update(); + + // Check if task completed + if (newTask.isStopped()) { + // Self-tying complete! Consume the item + stack.shrink(1); + playerState.setCurrentTyingTask(null); + + TiedUpMod.LOGGER.info( + "[SelfBondage] {} successfully self-tied", + player.getName().getString() + ); + } + } + + /** + * Handle self-bondage with a V2 bondage item. + * Uses V2TyingPlayerTask for progress, V2EquipmentHelper for equip. + */ + private static void handleV2SelfBondage( + ServerPlayer player, + ItemStack stack, + IV2BondageItem v2Item, + IBondageState state + ) { + // Check if all target regions are already occupied or blocked + boolean allBlocked = true; + for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) { + if (!V2EquipmentHelper.isRegionOccupied(player, region) + && !V2EquipmentHelper.isRegionBlocked(player, region)) { + allBlocked = false; + break; + } + } + if (allBlocked) { + TiedUpMod.LOGGER.debug( + "[SelfBondage] {} tried V2 self-equip but all regions occupied", + player.getName().getString() + ); + return; + } + + PlayerBindState playerState = PlayerBindState.getInstance(player); + if (playerState == null) return; + + int tyingSeconds = SettingsAccessor.getTyingPlayerTime( + player.level().getGameRules() + ); + + // Create V2 tying task (uses V2EquipmentHelper on completion, NOT putBindOn) + V2TyingPlayerTask newTask = new V2TyingPlayerTask( + stack.copy(), // copy for display/matching + stack, // live reference for consumption + state, + player, // target is self + tyingSeconds, + player.level(), + player // kidnapper is also self + ); + + TyingTask currentTask = playerState.getCurrentTyingTask(); + + if (currentTask == null + || !currentTask.isSameTarget(player) + || currentTask.isOutdated() + || !ItemStack.matches(currentTask.getBind(), stack)) { + // Start new task + playerState.setCurrentTyingTask(newTask); + newTask.start(); + + TiedUpMod.LOGGER.debug( + "[SelfBondage] {} started V2 self-tying ({} seconds)", + player.getName().getString(), + tyingSeconds + ); + } else { + // Continue existing task — just mark active + currentTask.update(); + } + + // If we started a new task, mark it active too + if (playerState.getCurrentTyingTask() == newTask) { + newTask.update(); + } + } + + /** + * Handle self-equipping an accessory (gag, blindfold, mittens, earplugs). + * Can be used anytime (no need to be tied). + * Blocked only if arms are fully bound. + * Supports swapping: if same type already equipped (and not locked), swap them. + */ + private static void handleSelfAccessory( + ServerPlayer player, + ItemStack stack, + IBondageState state, + String itemType, + java.util.function.Predicate isEquipped, + java.util.function.Function getCurrent, + java.util.function.Function takeOff, + java.util.function.BiConsumer putOn + ) { + // Can't equip if arms are fully bound (need hands to put on accessories) + ItemStack currentBind = state.getEquipment(BodyRegionV2.ARMS); + if (!currentBind.isEmpty()) { + if (ItemBind.hasArmsBound(currentBind)) { + TiedUpMod.LOGGER.debug( + "[SelfBondage] {} can't self-{} - arms are bound", + player.getName().getString(), + itemType + ); + return; + } + } + + // Already equipped? Try to swap + if (isEquipped.test(state)) { + ItemStack currentItem = getCurrent.apply(state); + + // Check if current item is locked + if ( + currentItem.getItem() instanceof ILockable lockable && + lockable.isLocked(currentItem) + ) { + TiedUpMod.LOGGER.debug( + "[SelfBondage] {} can't swap {} - current is locked", + player.getName().getString(), + itemType + ); + return; + } + + // Remove current and drop it + ItemStack removed = takeOff.apply(state); + if (!removed.isEmpty()) { + state.kidnappedDropItem(removed); + TiedUpMod.LOGGER.debug( + "[SelfBondage] {} swapping {} - dropped old one", + player.getName().getString(), + itemType + ); + } + } + + // Equip new item on self + putOn.accept(state, stack.copy()); + stack.shrink(1); + + // Sync to client + SyncManager.syncInventory(player); + + TiedUpMod.LOGGER.info( + "[SelfBondage] {} self-equipped {}", + player.getName().getString(), + itemType + ); + } +} diff --git a/src/main/java/com/tiedup/remake/network/slave/PacketMasterEquip.java b/src/main/java/com/tiedup/remake/network/slave/PacketMasterEquip.java new file mode 100644 index 0000000..3072a8f --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/slave/PacketMasterEquip.java @@ -0,0 +1,97 @@ +package com.tiedup.remake.network.slave; + +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.network.PacketRateLimiter; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import com.tiedup.remake.v2.bondage.V2EquipResult; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.network.NetworkEvent; + +/** + * Client→Server: Master equips an item from their inventory onto a slave's body region. + */ +public class PacketMasterEquip { + + private final UUID targetEntityUUID; + private final BodyRegionV2 region; + private final int inventorySlot; + + public PacketMasterEquip(UUID targetEntityUUID, BodyRegionV2 region, int inventorySlot) { + this.targetEntityUUID = targetEntityUUID; + this.region = region; + this.inventorySlot = inventorySlot; + } + + public static void encode(PacketMasterEquip msg, FriendlyByteBuf buf) { + buf.writeUUID(msg.targetEntityUUID); + buf.writeEnum(msg.region); + buf.writeVarInt(msg.inventorySlot); + } + + public static PacketMasterEquip decode(FriendlyByteBuf buf) { + return new PacketMasterEquip(buf.readUUID(), buf.readEnum(BodyRegionV2.class), buf.readVarInt()); + } + + public static void handle(PacketMasterEquip msg, Supplier ctxSupplier) { + NetworkEvent.Context ctx = ctxSupplier.get(); + ctx.enqueueWork(() -> { + ServerPlayer sender = ctx.getSender(); + if (sender == null) return; + if (!PacketRateLimiter.allowPacket(sender, "action")) return; + + // Find target entity (reuse package-private method from PacketSlaveItemManage) + LivingEntity target = PacketSlaveItemManage.findTargetEntity(sender, msg.targetEntityUUID); + if (target == null) return; + + // Distance check (5 blocks) + if (sender.distanceTo(target) > 5.0f) return; + + // Collar ownership check + IBondageState targetState = KidnappedHelper.getKidnappedState(target); + if (targetState == null || !targetState.hasCollar()) return; + ItemStack collarStack = targetState.getEquipment(BodyRegionV2.NECK); + if (collarStack.getItem() instanceof ItemCollar collar) { + if (!collar.isOwner(collarStack, sender) && !sender.hasPermissions(2)) return; + } + + // Validate sender's inventory slot + if (msg.inventorySlot < 0 || msg.inventorySlot >= sender.getInventory().getContainerSize()) return; + ItemStack stack = sender.getInventory().getItem(msg.inventorySlot); + if (stack.isEmpty()) return; + if (!(stack.getItem() instanceof IV2BondageItem bondageItem)) return; + if (!bondageItem.getOccupiedRegions(stack).contains(msg.region)) return; + + // Furniture seat blocks this region + if (target.isPassenger() && target.getVehicle() instanceof com.tiedup.remake.v2.furniture.ISeatProvider provider) { + com.tiedup.remake.v2.furniture.SeatDefinition seat = provider.getSeatForPassenger(target); + if (seat != null && seat.blockedRegions().contains(msg.region)) { + return; // Region blocked by furniture + } + } + + // Equip on target + V2EquipResult result = V2EquipmentHelper.equipItem(target, stack); + if (result.isSuccess()) { + sender.getInventory().removeItem(msg.inventorySlot, 1); + // Return any displaced items to master's inventory + if (result.displaced() != null) { + for (ItemStack displaced : result.displaced()) { + if (!displaced.isEmpty()) { + sender.getInventory().placeItemBackInInventory(displaced); + } + } + } + } + }); + ctx.setPacketHandled(true); + } +} diff --git a/src/main/java/com/tiedup/remake/network/slave/PacketSlaveAction.java b/src/main/java/com/tiedup/remake/network/slave/PacketSlaveAction.java new file mode 100644 index 0000000..1bdfd68 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/slave/PacketSlaveAction.java @@ -0,0 +1,394 @@ +package com.tiedup.remake.network.slave; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.ItemGpsCollar; +import com.tiedup.remake.items.ItemShockCollar; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.state.PlayerCaptorManager; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.ChatFormatting; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.AABB; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet for slave management actions from SlaveManagementScreen. + * Handles: SHOCK, LOCATE, FREE actions on owned slaves. + * + * Phase 16: GUI Revamp - Slave management packets + * + * Security: Distance and dimension validation added to prevent griefing + */ +public class PacketSlaveAction { + + /** Maximum interaction range for SHOCK and FREE actions (blocks) */ + private static final double MAX_INTERACTION_RANGE = 100.0; + + public enum Action { + SHOCK, + LOCATE, + FREE, + } + + private final UUID targetId; + private final Action action; + + public PacketSlaveAction(UUID targetId, Action action) { + this.targetId = targetId; + this.action = action; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeUUID(targetId); + buf.writeEnum(action); + } + + public static PacketSlaveAction decode(FriendlyByteBuf buf) { + UUID id = buf.readUUID(); + Action action = buf.readEnum(Action.class); + return new PacketSlaveAction(id, action); + } + + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer sender = ctx.get().getSender(); + if (sender == null) return; + + // Rate limiting: Prevent action spam + if ( + !com.tiedup.remake.network.PacketRateLimiter.allowPacket( + sender, + "action" + ) + ) { + return; + } + + handleServer(sender); + }); + ctx.get().setPacketHandled(true); + } + + private void handleServer(ServerPlayer sender) { + // Get sender's kidnapper manager + PlayerBindState senderState = PlayerBindState.getInstance(sender); + if (senderState == null) { + TiedUpMod.LOGGER.warn( + "[PACKET] PacketSlaveAction: No PlayerBindState for sender {}", + sender.getName().getString() + ); + return; + } + + // Phase 17: PlayerKidnapperManager → PlayerCaptorManager + PlayerCaptorManager manager = senderState.getCaptorManager(); + + // Find the target - check both formal captives AND collar-owned entities + IRestrainable targetCaptive = null; + boolean isFormalCaptive = false; + + // 1. Check formal captives first + if (manager != null && manager.hasCaptives()) { + for (IBondageState captive : manager.getCaptives()) { + LivingEntity entity = captive.asLivingEntity(); + if (entity != null && entity.getUUID().equals(targetId) + && captive instanceof IRestrainable r) { + targetCaptive = r; + isFormalCaptive = true; + break; + } + } + } + + // 2. If not found, search for nearby entities with collar owned by sender + if (targetCaptive == null) { + AABB searchBox = sender.getBoundingBox().inflate(32); // Security: reduced from 100 + for (LivingEntity entity : sender + .level() + .getEntitiesOfClass(LivingEntity.class, searchBox)) { + if (!entity.getUUID().equals(targetId)) continue; + + IRestrainable kidnapped = KidnappedHelper.getKidnappedState( + entity + ); + if (kidnapped != null && kidnapped.hasCollar()) { + ItemStack collarStack = kidnapped.getEquipment(BodyRegionV2.NECK); + if (collarStack.getItem() instanceof ItemCollar collar) { + if (collar.isOwner(collarStack, sender)) { + targetCaptive = kidnapped; + break; + } + } + } + } + } + + if (targetCaptive == null) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + "Target not found or you don't own their collar!" + ); + return; + } + + LivingEntity targetEntity = targetCaptive.asLivingEntity(); + String targetName = targetCaptive.getKidnappedName(); + + // Security: Validate dimension and distance (except for LOCATE which is GPS-based) + if (action != Action.LOCATE) { + // Check same dimension + if (sender.level() != targetEntity.level()) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + targetName + " is in a different dimension!" + ); + return; + } + + // Check distance + double distance = sender.distanceTo(targetEntity); + if (distance > MAX_INTERACTION_RANGE) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + targetName + + " is too far away! (Distance: " + + (int) distance + + " blocks, Max: " + + (int) MAX_INTERACTION_RANGE + + ")" + ); + return; + } + } + + switch (action) { + case SHOCK -> handleShock( + sender, + targetCaptive, + targetEntity, + targetName + ); + case LOCATE -> handleLocate( + sender, + targetCaptive, + targetEntity, + targetName + ); + case FREE -> handleFree( + sender, + targetCaptive, + manager, + targetName, + isFormalCaptive + ); + } + } + + private void handleShock( + ServerPlayer sender, + IRestrainable target, + LivingEntity targetEntity, + String name + ) { + // Check if target has shock collar + if (!target.hasCollar()) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + name + " is not wearing a collar!" + ); + return; + } + + ItemStack collarStack = target.getEquipment(BodyRegionV2.NECK); + if ( + !(collarStack.getItem() instanceof ItemCollar collar) || + !collar.canShock() + ) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + name + " is not wearing a shock collar!" + ); + return; + } + + // Check if sender is owner of the collar or collar is public + // FIX: Always check permissions for ANY collar that can shock, not just ItemShockCollar + boolean isOwner = collar.isOwner(collarStack, sender); + boolean isPublic = false; + + // ItemShockCollar has additional "public mode" that allows anyone to shock + if (collarStack.getItem() instanceof ItemShockCollar shockCollar) { + isPublic = shockCollar.isPublic(collarStack); + } + + if (!isOwner && !isPublic) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + "You don't have permission to shock " + name + "!" + ); + return; + } + + // Execute shock + target.shockKidnapped( + " (Remote shock from " + sender.getName().getString() + ")", + 3.0f + ); + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.SHOCKER_TRIGGERED, + name + ); + + TiedUpMod.LOGGER.debug( + "[PACKET] {} shocked slave {}", + sender.getName().getString(), + name + ); + } + + private void handleLocate( + ServerPlayer sender, + IRestrainable target, + LivingEntity targetEntity, + String name + ) { + // Check if target has GPS collar + if (!target.hasCollar()) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + name + " is not wearing a collar!" + ); + return; + } + + ItemStack collarStack = target.getEquipment(BodyRegionV2.NECK); + if ( + !(collarStack.getItem() instanceof ItemCollar collar) || + !collar.hasGPS() + ) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + name + " is not wearing a GPS collar!" + ); + return; + } + + // Check permissions + if (collarStack.getItem() instanceof ItemGpsCollar gpsCollar) { + boolean isOwner = collar.isOwner(collarStack, sender); + boolean isPublic = gpsCollar.hasPublicTracking(collarStack); + + if (!isOwner && !isPublic) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + "You don't have permission to track " + name + "!" + ); + return; + } + } + + // Check same dimension + if ( + targetEntity == null || + !targetEntity.level().dimension().equals(sender.level().dimension()) + ) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + "Cannot locate " + name + " (different dimension or offline)!" + ); + return; + } + + // Calculate distance and direction + double distance = sender.distanceTo(targetEntity); + String direction = getDirection(sender, targetEntity); + + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.LOCATOR_DETECTED, + name + ": " + (int) distance + "m [" + direction + "]" + ); + + TiedUpMod.LOGGER.debug( + "[PACKET] {} located slave {} at {}m {}", + sender.getName().getString(), + name, + (int) distance, + direction + ); + } + + // Phase 17: PlayerKidnapperManager → PlayerCaptorManager, isFormalSlave → isFormalCaptive + private void handleFree( + ServerPlayer sender, + IRestrainable target, + PlayerCaptorManager manager, + String name, + boolean isFormalCaptive + ) { + if (isFormalCaptive && manager != null) { + // Remove formal captive from manager + manager.removeCaptive(target, false); + SystemMessageManager.sendToPlayer( + sender, + "Freed " + name + "!", + ChatFormatting.GREEN + ); + TiedUpMod.LOGGER.debug( + "[PACKET] {} freed captive {}", + sender.getName().getString(), + name + ); + } else { + // For collar-owned entities, just remove collar ownership + ItemStack collarStack = target.getEquipment(BodyRegionV2.NECK); + if (collarStack.getItem() instanceof ItemCollar collar) { + collar.removeOwner(collarStack, sender.getUUID()); + SystemMessageManager.sendToPlayer( + sender, + "Released collar control of " + name + "!", + ChatFormatting.GREEN + ); + TiedUpMod.LOGGER.debug( + "[PACKET] {} released collar control of {}", + sender.getName().getString(), + name + ); + } + } + } + + private String getDirection(LivingEntity source, LivingEntity target) { + double dx = target.getX() - source.getX(); + double dz = target.getZ() - source.getZ(); + + if (Math.abs(dx) > Math.abs(dz)) { + return dx > 0 ? "EAST" : "WEST"; + } else { + return dz > 0 ? "SOUTH" : "NORTH"; + } + } +} diff --git a/src/main/java/com/tiedup/remake/network/slave/PacketSlaveItemManage.java b/src/main/java/com/tiedup/remake/network/slave/PacketSlaveItemManage.java new file mode 100644 index 0000000..3cbb7d3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/slave/PacketSlaveItemManage.java @@ -0,0 +1,657 @@ +package com.tiedup.remake.network.slave; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.network.sync.SyncManager; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet sent from client to server when master uses key to manage slave's items. + * Handles LOCK, UNLOCK, and REMOVE operations using the keyUUID system. + * + * Phase 20: Key-Lock System - Slave Item Management + */ +public class PacketSlaveItemManage { + + /** + * Action to perform on the item. + */ + public enum Action { + LOCK, + UNLOCK, + REMOVE, + TOGGLE_BONDAGE_SERVICE, + } + + private final UUID targetEntityUUID; + private final BodyRegionV2 region; + private final Action action; + private final UUID keyUUID; + private final boolean isMasterKey; + + public PacketSlaveItemManage( + UUID targetEntityUUID, + BodyRegionV2 region, + Action action, + UUID keyUUID, + boolean isMasterKey + ) { + this.targetEntityUUID = targetEntityUUID; + this.region = region; + this.action = action; + this.keyUUID = keyUUID; + this.isMasterKey = isMasterKey; + } + + /** + * Encode packet to buffer. + */ + public static void encode(PacketSlaveItemManage msg, FriendlyByteBuf buf) { + buf.writeUUID(msg.targetEntityUUID); + buf.writeEnum(msg.region); + buf.writeEnum(msg.action); + buf.writeBoolean(msg.keyUUID != null); + if (msg.keyUUID != null) { + buf.writeUUID(msg.keyUUID); + } + buf.writeBoolean(msg.isMasterKey); + } + + /** + * Decode packet from buffer. + */ + public static PacketSlaveItemManage decode(FriendlyByteBuf buf) { + UUID targetEntityUUID = buf.readUUID(); + BodyRegionV2 region = buf.readEnum(BodyRegionV2.class); + Action action = buf.readEnum(Action.class); + UUID keyUUID = buf.readBoolean() ? buf.readUUID() : null; + boolean isMasterKey = buf.readBoolean(); + return new PacketSlaveItemManage( + targetEntityUUID, + region, + action, + keyUUID, + isMasterKey + ); + } + + /** + * Handle packet on server. + */ + public static void handle( + PacketSlaveItemManage msg, + Supplier ctx + ) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer sender = ctx.get().getSender(); + if (sender == null) return; + + // Rate limiting: Prevent item management spam + if ( + !com.tiedup.remake.network.PacketRateLimiter.allowPacket( + sender, + "action" + ) + ) { + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + "Too fast! Wait a moment." + ); + return; + } + + // Find target entity (player or NPC) + LivingEntity targetEntity = findTargetEntity( + sender, + msg.targetEntityUUID + ); + if (targetEntity == null) { + TiedUpMod.LOGGER.debug( + "[PacketSlaveItemManage] Target entity not found: {}", + msg.targetEntityUUID + ); + return; + } + + // Check distance (max 5 blocks) + if (sender.distanceTo(targetEntity) > 5.0) { + TiedUpMod.LOGGER.debug( + "[PacketSlaveItemManage] Sender too far from target" + ); + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + "Too far! Get closer to manage items." + ); + return; + } + + // Get target's kidnapped state + IBondageState targetState = KidnappedHelper.getKidnappedState( + targetEntity + ); + if (targetState == null) { + TiedUpMod.LOGGER.debug( + "[PacketSlaveItemManage] Target has no kidnapped state" + ); + return; + } + + // Target must have a collar + if (!targetState.hasCollar()) { + TiedUpMod.LOGGER.debug( + "[PacketSlaveItemManage] Target has no collar" + ); + return; + } + + // Security: Verify sender owns the collar (or is admin) + ItemStack collarStack = targetState.getEquipment(BodyRegionV2.NECK); + if ( + collarStack.getItem() instanceof + com.tiedup.remake.items.base.ItemCollar collar + ) { + if ( + !collar.isOwner(collarStack, sender) && + !sender.hasPermissions(2) + ) { + TiedUpMod.LOGGER.debug( + "[PacketSlaveItemManage] Sender is not collar owner and not admin" + ); + return; + } + } + + // Furniture seat blocks this region + if (targetEntity.isPassenger() && targetEntity.getVehicle() instanceof com.tiedup.remake.v2.furniture.ISeatProvider provider) { + com.tiedup.remake.v2.furniture.SeatDefinition seat = provider.getSeatForPassenger(targetEntity); + if (seat != null && seat.blockedRegions().contains(msg.region)) { + return; // Region blocked by furniture + } + } + + // Get item from region (V2 storage) + ItemStack itemStack = getItemInRegion( + targetState, + msg.region + ); + if (itemStack.isEmpty()) { + TiedUpMod.LOGGER.debug( + "[PacketSlaveItemManage] Region {} is empty", + msg.region + ); + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + "Region " + msg.region.name() + " is empty." + ); + return; + } + + // Perform action + switch (msg.action) { + case LOCK -> handleLock( + sender, + targetEntity, + targetState, + msg.region, + itemStack, + msg.keyUUID, + msg.isMasterKey + ); + case UNLOCK -> handleUnlock( + sender, + targetEntity, + targetState, + msg.region, + itemStack, + msg.keyUUID, + msg.isMasterKey + ); + case REMOVE -> handleRemove( + sender, + targetEntity, + targetState, + msg.region, + itemStack, + msg.keyUUID, + msg.isMasterKey + ); + case TOGGLE_BONDAGE_SERVICE -> handleToggleBondageService( + sender, + targetEntity, + targetState, + collarStack + ); + } + + // Sync changes + if (targetEntity instanceof ServerPlayer targetPlayer) { + SyncManager.syncAll(targetPlayer); + } else { + // For NPCs: V2 sync via EntityDataAccessor (IV2EquipmentHolder) + // or packet-based sync for other entity types + V2EquipmentHelper.sync(targetEntity); + } + + TiedUpMod.LOGGER.debug( + "[PacketSlaveItemManage] {} performed {} on {}'s {} region", + sender.getName().getString(), + msg.action, + targetEntity.getName().getString(), + msg.region + ); + }); + + ctx.get().setPacketHandled(true); + } + + /** + * Find target entity by UUID (player or NPC). + */ + static LivingEntity findTargetEntity( + ServerPlayer sender, + UUID targetUUID + ) { + // Try player first + Player player = sender.level().getPlayerByUUID(targetUUID); + if (player != null) { + return player; + } + + // Try other entities within reasonable range (64 blocks) + net.minecraft.world.phys.AABB searchBox = sender + .getBoundingBox() + .inflate(64); + for (LivingEntity entity : sender + .level() + .getEntitiesOfClass(LivingEntity.class, searchBox)) { + if (entity.getUUID().equals(targetUUID)) { + return entity; + } + } + return null; + } + + /** + * Get item from a V2 body region. + */ + private static ItemStack getItemInRegion( + IBondageState state, + BodyRegionV2 region + ) { + return state.getItemInRegion(region); + } + + /** + * SECURITY: Verifies that the player actually holds a Master Key in their inventory. + * Prevents client-side spoofing of the isMasterKey boolean. + * + * @param player The player to check + * @return true if player holds Master Key in main hand or offhand + */ + private static boolean verifyMasterKey(ServerPlayer player) { + ItemStack mainHand = player.getMainHandItem(); + ItemStack offHand = player.getOffhandItem(); + + return ( + mainHand.getItem() == + com.tiedup.remake.items.ModItems.MASTER_KEY.get() || + offHand.getItem() == + com.tiedup.remake.items.ModItems.MASTER_KEY.get() + ); + } + + /** + * SECURITY FIX: Verifies that the player actually holds a physical key with the specified UUID. + * Prevents key spoofing exploit where clients could send arbitrary UUIDs to unlock items. + * + * @param player The player to check + * @param keyUUID The key UUID to verify + * @return true if player possesses a physical ItemKey with matching UUID + */ + private static boolean verifyPlayerHasKey( + ServerPlayer player, + UUID keyUUID + ) { + if (keyUUID == null) return false; + + // Search entire player inventory for a key with matching UUID + for (ItemStack stack : player.getInventory().items) { + if ( + stack.getItem() instanceof com.tiedup.remake.items.ItemKey key + ) { + if (keyUUID.equals(key.getKeyUUID(stack))) { + return true; // Found matching key + } + } + } + + // Also check offhand + ItemStack offhand = player.getOffhandItem(); + if (offhand.getItem() instanceof com.tiedup.remake.items.ItemKey key) { + if (keyUUID.equals(key.getKeyUUID(offhand))) { + return true; + } + } + + return false; // Player does not possess this key + } + + /** + * Handle LOCK action - locks the item with the key's UUID. + * Item must have a padlock attached (isLockable) to be locked. + */ + private static void handleLock( + ServerPlayer sender, + LivingEntity target, + IBondageState targetState, + BodyRegionV2 region, + ItemStack itemStack, + UUID keyUUID, + boolean clientClaimsMasterKey + ) { + // SECURITY: Verify master key server-side (don't trust client boolean) + boolean actuallyHasMasterKey = verifyMasterKey(sender); + + // Master key cannot lock items + if (actuallyHasMasterKey) { + TiedUpMod.LOGGER.debug( + "[PacketSlaveItemManage] Master key cannot lock items" + ); + return; + } + + // SECURITY: Detect spoofing attempt + if (clientClaimsMasterKey && !actuallyHasMasterKey) { + TiedUpMod.LOGGER.warn( + "SECURITY: Player {} attempted to spoof Master Key in LOCK operation!", + sender.getName().getString() + ); + return; + } + + // Key UUID required for locking + if (keyUUID == null) { + TiedUpMod.LOGGER.debug( + "[PacketSlaveItemManage] No key UUID provided for locking" + ); + return; + } + + // SECURITY FIX: Verify player actually possesses the key + if (!verifyPlayerHasKey(sender, keyUUID)) { + TiedUpMod.LOGGER.warn( + "SECURITY: Player {} attempted to lock with non-existent key UUID {}!", + sender.getName().getString(), + keyUUID + ); + return; + } + + // Item must be ILockable + if (!(itemStack.getItem() instanceof ILockable lockable)) { + TiedUpMod.LOGGER.debug( + "[PacketSlaveItemManage] Item is not ILockable: {}", + itemStack + ); + return; + } + + // Item must have a padlock attached (isLockable = true) + if (!lockable.isLockable(itemStack)) { + TiedUpMod.LOGGER.debug( + "[PacketSlaveItemManage] Item has no padlock attached. Use padlock first." + ); + return; + } + + // Item must not already be locked + if (lockable.isLocked(itemStack)) { + TiedUpMod.LOGGER.debug( + "[PacketSlaveItemManage] Item is already locked" + ); + return; + } + + // Lock with keyUUID + lockable.setLockedByKeyUUID(itemStack, keyUUID); + + // Phase 21: Add lock resistance (configurable, default 250) to bind items when locked + if ( + region == BodyRegionV2.ARMS && + itemStack.getItem() instanceof + com.tiedup.remake.items.base.ItemBind bind + ) { + int currentResistance = bind.getCurrentResistance( + itemStack, + target + ); + int lockResistance = lockable.getLockResistance(); // Configurable via ModConfig + bind.setCurrentResistance( + itemStack, + currentResistance + lockResistance + ); + TiedUpMod.LOGGER.info( + "[PacketSlaveItemManage] Added {} lock resistance to bind (total: {})", + lockResistance, + currentResistance + lockResistance + ); + } + + TiedUpMod.LOGGER.info( + "[PacketSlaveItemManage] {} locked {}'s {} with key {}", + sender.getName().getString(), + target.getName().getString(), + region, + keyUUID + ); + } + + /** + * Handle UNLOCK action - unlocks the item if key matches or master key. + * Drops the padlock and removes the lockable state. + */ + private static void handleUnlock( + ServerPlayer sender, + LivingEntity target, + IBondageState targetState, + BodyRegionV2 region, + ItemStack itemStack, + UUID keyUUID, + boolean clientClaimsMasterKey + ) { + // Item must be ILockable + if (!(itemStack.getItem() instanceof ILockable lockable)) { + TiedUpMod.LOGGER.debug( + "[PacketSlaveItemManage] Item is not ILockable: {}", + itemStack + ); + return; + } + + // Item must be locked + if (!lockable.isLocked(itemStack)) { + TiedUpMod.LOGGER.debug( + "[PacketSlaveItemManage] Item is not locked" + ); + return; + } + + // SECURITY: Verify master key server-side (don't trust client boolean) + boolean actuallyHasMasterKey = verifyMasterKey(sender); + + // SECURITY: Detect spoofing attempt + if (clientClaimsMasterKey && !actuallyHasMasterKey) { + TiedUpMod.LOGGER.warn( + "SECURITY: Player {} attempted to spoof Master Key in UNLOCK operation!", + sender.getName().getString() + ); + return; + } + + // Check if key matches (or ACTUAL master key bypasses) + if (!actuallyHasMasterKey) { + UUID lockedByUUID = lockable.getLockedByKeyUUID(itemStack); + + // SECURITY FIX: Verify player actually possesses the key BEFORE checking if it matches + // Previous exploit: Client could send any UUID that matched lockedByUUID without owning the key + if (keyUUID != null && !verifyPlayerHasKey(sender, keyUUID)) { + TiedUpMod.LOGGER.warn( + "SECURITY: Player {} attempted to unlock with non-existent key UUID {}!", + sender.getName().getString(), + keyUUID + ); + return; + } + + if (lockedByUUID != null && !lockedByUUID.equals(keyUUID)) { + TiedUpMod.LOGGER.debug( + "[PacketSlaveItemManage] Wrong key! Item locked by different key" + ); + return; + } + } + + // Unlock the item - just unlock, keep padlock attached (can be re-locked) + lockable.setLockedByKeyUUID(itemStack, null); + lockable.clearLockResistance(itemStack); // Clear any struggle progress + // Note: Padlock is only dropped on REMOVE, not on UNLOCK + + TiedUpMod.LOGGER.info( + "[PacketSlaveItemManage] {} unlocked {}'s {} (masterKey={})", + sender.getName().getString(), + target.getName().getString(), + region, + actuallyHasMasterKey + ); + } + + /** + * Handle REMOVE action - removes item from region and gives to sender. + * Padlock stays attached to the item (permanent via anvil). + */ + private static void handleRemove( + ServerPlayer sender, + LivingEntity target, + IBondageState targetState, + BodyRegionV2 region, + ItemStack itemStack, + UUID keyUUID, + boolean isMasterKey + ) { + // Note: Remove button is hidden when locked, so this check is just a safety + if (itemStack.getItem() instanceof ILockable lockable) { + if (lockable.isLocked(itemStack)) { + TiedUpMod.LOGGER.debug( + "[PacketSlaveItemManage] Cannot remove: item is locked!" + ); + return; + } + } + + // Remove from region (V2 storage) + ItemStack removed = removeFromRegion(targetState, region); + + if (!removed.isEmpty()) { + // Give item to sender (or drop if inventory full) + // Note: Padlock stays on the item (lockable state preserved) + if (!sender.getInventory().add(removed.copy())) { + sender.drop(removed.copy(), false); + } + + TiedUpMod.LOGGER.info( + "[PacketSlaveItemManage] {} removed {} from {}'s {} region", + sender.getName().getString(), + removed.getDisplayName().getString(), + target.getName().getString(), + region + ); + } + } + + /** + * Handle TOGGLE_BONDAGE_SERVICE action - toggles bondage service on the collar. + * Requires a prison to be configured on the collar. + */ + private static void handleToggleBondageService( + ServerPlayer sender, + LivingEntity target, + IBondageState targetState, + ItemStack collarStack + ) { + if ( + collarStack.isEmpty() || + !(collarStack.getItem() instanceof + com.tiedup.remake.items.base.ItemCollar collar) + ) { + TiedUpMod.LOGGER.debug( + "[PacketSlaveItemManage] No collar for bondage service toggle" + ); + return; + } + + // Check if cell is configured (required for bondage service) + if (!collar.hasCellAssigned(collarStack)) { + TiedUpMod.LOGGER.debug( + "[PacketSlaveItemManage] Cannot enable bondage service: no cell configured" + ); + // Send feedback to player + SystemMessageManager.sendToPlayer( + sender, + SystemMessageManager.MessageCategory.ERROR, + "Cannot enable Bondage Service: Assign a cell first!" + ); + return; + } + + // Toggle bondage service + boolean currentState = collar.isBondageServiceEnabled(collarStack); + collar.setBondageServiceEnabled(collarStack, !currentState); + + String newState = !currentState ? "enabled" : "disabled"; + TiedUpMod.LOGGER.info( + "[PacketSlaveItemManage] {} {} bondage service on {}'s collar", + sender.getName().getString(), + newState, + target.getName().getString() + ); + + // Send feedback + SystemMessageManager.sendChatToPlayer( + sender, + "Bondage Service " + + newState + + " on " + + target.getName().getString(), + !currentState + ? net.minecraft.ChatFormatting.LIGHT_PURPLE + : net.minecraft.ChatFormatting.GRAY + ); + } + + /** + * Remove item from a V2 body region. + */ + private static ItemStack removeFromRegion( + IBondageState state, + BodyRegionV2 region + ) { + return state.unequipFromRegion(region); + } +} diff --git a/src/main/java/com/tiedup/remake/network/sync/ClientSyncHandler.java b/src/main/java/com/tiedup/remake/network/sync/ClientSyncHandler.java new file mode 100644 index 0000000..0bc3128 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/sync/ClientSyncHandler.java @@ -0,0 +1,255 @@ +package com.tiedup.remake.network.sync; + +import com.mojang.logging.LogUtils; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.IBondageState; +import java.util.*; +import java.util.concurrent.ConcurrentLinkedQueue; +import net.minecraft.client.Minecraft; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; +import org.slf4j.Logger; + +/** + * Client-side sync queue handler. + * + * This class is ONLY loaded on the client side to avoid NoClassDefFoundError + * on dedicated servers. It handles the pending sync queue for players that + * are not yet loaded when packets arrive. + * + * Separated from SyncManager to ensure server-side code doesn't trigger + * class loading of Minecraft client classes. + */ +@OnlyIn(Dist.CLIENT) +@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT) +public class ClientSyncHandler { + + private static final Logger LOGGER = LogUtils.getLogger(); + + // ==================== Configuration ==================== + + /** Maximum ticks before a pending sync entry expires (5 seconds) */ + private static final int MAX_PENDING_TICKS = 100; + + /** Maximum retry attempts before giving up */ + private static final int MAX_RETRIES = 20; + + /** Ticks between retry attempts */ + private static final int RETRY_INTERVAL = 5; + + // ==================== Client-side pending queue ==================== + + /** + * Queue of pending sync entries waiting to be applied. + * Thread-safe for access from network thread and client tick. + */ + private static final Queue pendingQueue = + new ConcurrentLinkedQueue<>(); + + /** + * Track last known tick for expiration checks. + * Volatile to ensure visibility across network and tick threads. + */ + private static volatile long currentClientTick = 0; + + // ==================== Queue methods ==================== + + /** + * Queue a bind state sync for later processing. + * Called from PacketSyncBindState.handle() when target player is null. + * + * @param targetUUID The UUID of the player to sync + * @param stateFlags The state flags to apply + */ + public static void queueBindStateSync(UUID targetUUID, byte stateFlags) { + PendingSyncEntry entry = PendingSyncEntry.forBindState( + targetUUID, + stateFlags, + currentClientTick + ); + pendingQueue.add(entry); + LOGGER.debug( + "[ClientSyncHandler] Queued bind state sync for {} (queue size: {})", + targetUUID, + pendingQueue.size() + ); + } + + /** + * Queue an enslavement sync for later processing. + * Called from PacketSyncEnslavement.handle() when target player is null. + * + * @param targetUUID The UUID of the player to sync + * @param masterUUID The master's UUID (null if free) + * @param transportUUID The transport entity UUID (null if no transport) + * @param isEnslaved Whether the player is enslaved + */ + public static void queueEnslavementSync( + UUID targetUUID, + UUID masterUUID, + UUID transportUUID, + boolean isEnslaved + ) { + PendingSyncEntry entry = PendingSyncEntry.forEnslavement( + targetUUID, + masterUUID, + transportUUID, + isEnslaved, + currentClientTick + ); + pendingQueue.add(entry); + LOGGER.debug( + "[ClientSyncHandler] Queued enslavement sync for {} (queue size: {})", + targetUUID, + pendingQueue.size() + ); + } + + /** + * Process the pending sync queue. Called every few ticks on client. + * Attempts to apply queued syncs and removes expired entries. + */ + private static void processPendingQueue() { + if (pendingQueue.isEmpty()) return; + + Minecraft mc = Minecraft.getInstance(); + if (mc.level == null) return; + + int processed = 0; + int expired = 0; + + // Process queue + Iterator iterator = pendingQueue.iterator(); + while (iterator.hasNext()) { + PendingSyncEntry entry = iterator.next(); + + // Check expiration + if ( + entry.isExpired( + currentClientTick, + MAX_PENDING_TICKS, + MAX_RETRIES + ) + ) { + iterator.remove(); + expired++; + continue; + } + + // Check if retry interval has passed + if (entry.getRetryCount() > 0) { + long ticksSinceCreation = + currentClientTick - entry.getCreationTick(); + if (ticksSinceCreation % RETRY_INTERVAL != 0) { + continue; // Wait for retry interval + } + } + + // Try to find the player + Player player = mc.level.getPlayerByUUID( + entry.getTargetPlayerUUID() + ); + if (player == null) { + // Player still not loaded, increment retry count + entry.incrementRetryCount(); + continue; + } + + // Player found! Apply the sync + boolean success = applyPendingSync(entry, player); + if (success) { + iterator.remove(); + processed++; + } + } + + if (processed > 0 || expired > 0) { + LOGGER.debug( + "[ClientSyncHandler] Processed {} pending, {} expired", + processed, + expired + ); + } + } + + /** + * Apply a pending sync entry to a player. + * + * @param entry The sync entry + * @param player The target player + * @return true if successful + */ + private static boolean applyPendingSync( + PendingSyncEntry entry, + Player player + ) { + switch (entry.getType()) { + case BIND_STATE -> { + // Bind state is derived from inventory, so just trigger a state check + // The actual flags are informational + return true; + } + case ENSLAVEMENT -> { + // Enslavement state is primarily server-side, but we store it for UI awareness + // The actual leash rendering uses Minecraft's entity system + IBondageState kidnappedState = + com.tiedup.remake.util.KidnappedHelper.getKidnappedState( + player + ); + if (kidnappedState != null) { + // Client now knows about this player's enslavement state + // This enables UI indicators and rendering awareness + LOGGER.debug( + "[ClientSyncHandler] Applied enslavement sync for {} (enslaved: {})", + player.getName().getString(), + entry.isEnslaved() + ); + } + return true; + } + default -> { + return false; + } + } + } + + /** + * Clear all pending syncs for a player (e.g., when they disconnect). + * + * @param playerUUID The player's UUID + */ + public static void clearPendingForPlayer(UUID playerUUID) { + pendingQueue.removeIf(entry -> + entry.getTargetPlayerUUID().equals(playerUUID) + ); + } + + /** + * Clear the entire pending queue (e.g., when disconnecting from server). + */ + public static void clearAllPending() { + pendingQueue.clear(); + LOGGER.debug("[ClientSyncHandler] Cleared all pending syncs"); + } + + // ==================== Tick handler ==================== + + /** + * Client tick handler to process pending syncs. + */ + @SubscribeEvent + public static void onClientTick(TickEvent.ClientTickEvent event) { + if (event.phase != TickEvent.Phase.END) return; + + currentClientTick++; + + // Process queue every RETRY_INTERVAL ticks + if (currentClientTick % RETRY_INTERVAL == 0) { + processPendingQueue(); + } + } +} diff --git a/src/main/java/com/tiedup/remake/network/sync/PacketPlayTestAnimation.java b/src/main/java/com/tiedup/remake/network/sync/PacketPlayTestAnimation.java new file mode 100644 index 0000000..9044ed4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/sync/PacketPlayTestAnimation.java @@ -0,0 +1,51 @@ +package com.tiedup.remake.network.sync; + +import com.tiedup.remake.network.base.AbstractPlayerSyncPacket; +import java.util.UUID; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Test packet to play an arbitrary animation on a player. + * Direction: Server → Client (S2C) + * Used by /tiedup testanim command for debugging animations. + */ +public class PacketPlayTestAnimation extends AbstractPlayerSyncPacket { + + private final String animId; + + public PacketPlayTestAnimation(UUID playerUUID, String animId) { + super(playerUUID); + this.animId = animId; + } + + public void encode(FriendlyByteBuf buf) { + encodeUUID(buf); + buf.writeUtf(animId); + } + + public static PacketPlayTestAnimation decode(FriendlyByteBuf buf) { + UUID uuid = decodeUUID(buf); + String animId = buf.readUtf(); + return new PacketPlayTestAnimation(uuid, animId); + } + + @Override + @OnlyIn(Dist.CLIENT) + protected void applySync(Player player) { + ClientHandler.handle(this, player); + } + + @OnlyIn(Dist.CLIENT) + private static class ClientHandler { + private static void handle(PacketPlayTestAnimation pkt, Player player) { + if (pkt.animId.isEmpty()) { + com.tiedup.remake.client.animation.BondageAnimationManager.stopAnimation(player); + } else { + com.tiedup.remake.client.animation.BondageAnimationManager.playAnimation(player, pkt.animId); + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/network/sync/PacketSyncBindState.java b/src/main/java/com/tiedup/remake/network/sync/PacketSyncBindState.java new file mode 100644 index 0000000..7beaefe --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/sync/PacketSyncBindState.java @@ -0,0 +1,140 @@ +package com.tiedup.remake.network.sync; + +import com.tiedup.remake.network.base.AbstractPlayerSyncPacket; +import com.tiedup.remake.state.PlayerBindState; +import java.util.UUID; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Packet to synchronize player bondage state (tied, gagged, etc.). + * Sends boolean flags for bind state (lighter than full equipment sync). + * + * Direction: Server → Client (S2C) + */ +public class PacketSyncBindState extends AbstractPlayerSyncPacket { + + private final byte stateFlags; // Packed boolean flags + + // Bit flags for state + private static final byte FLAG_TIED_UP = 1 << 0; // 0x01 + private static final byte FLAG_GAGGED = 1 << 1; // 0x02 + private static final byte FLAG_BLINDFOLDED = 1 << 2; // 0x04 + private static final byte FLAG_HAS_EARPLUGS = 1 << 3; // 0x08 + private static final byte FLAG_HAS_COLLAR = 1 << 4; // 0x10 + private static final byte FLAG_HAS_CLOTHES = 1 << 5; // 0x20 + private static final byte FLAG_ONLINE = 1 << 6; // 0x40 + + /** + * Create a packet to sync a player's bondage state. + * + * @param playerUUID The player's UUID + * @param stateFlags Packed boolean flags + */ + private PacketSyncBindState(UUID playerUUID, byte stateFlags) { + super(playerUUID); + this.stateFlags = stateFlags; + } + + /** + * Create a packet from a player's current state. + * + * @param player The player + * @return The packet, or null if player has no state + */ + public static PacketSyncBindState fromPlayer(Player player) { + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + return null; + } + + byte flags = 0; + if (state.isTiedUp()) flags |= FLAG_TIED_UP; + if (state.isGagged()) flags |= FLAG_GAGGED; + if (state.isBlindfolded()) flags |= FLAG_BLINDFOLDED; + if (state.hasEarplugs()) flags |= FLAG_HAS_EARPLUGS; + if (state.hasCollar()) flags |= FLAG_HAS_COLLAR; + if (state.hasClothes()) flags |= FLAG_HAS_CLOTHES; + if (state.isOnline()) flags |= FLAG_ONLINE; + + return new PacketSyncBindState(player.getUUID(), flags); + } + + /** + * Encode the packet to a byte buffer. + */ + public void encode(FriendlyByteBuf buf) { + encodeUUID(buf); + buf.writeByte(stateFlags); + } + + /** + * Decode the packet from a byte buffer. + */ + public static PacketSyncBindState decode(FriendlyByteBuf buf) { + UUID playerUUID = decodeUUID(buf); + byte stateFlags = buf.readByte(); + return new PacketSyncBindState(playerUUID, stateFlags); + } + + /** + * Apply the bind state sync to a player. + * + * This method is intentionally a no-op: + * - State is automatically derived from inventory via V2EquipmentHelper + * - Rendering updates are handled by event handlers + * - The packet serves as a lightweight notification that state has changed + */ + @Override + @OnlyIn(Dist.CLIENT) + protected void applySync(Player player) { + // No-op: State is derived from inventory (synced via PacketSyncV2Equipment) + } + + @Override + @OnlyIn(Dist.CLIENT) + protected void queueForRetry() { + SyncManager.queueBindStateSync(playerUUID, stateFlags); + } + + // Helper method + private boolean hasFlag(byte flag) { + return (stateFlags & flag) != 0; + } + + // Getters for state flags + + public boolean isTiedUp() { + return hasFlag(FLAG_TIED_UP); + } + + public boolean isGagged() { + return hasFlag(FLAG_GAGGED); + } + + public boolean isBlindfolded() { + return hasFlag(FLAG_BLINDFOLDED); + } + + public boolean hasEarplugs() { + return hasFlag(FLAG_HAS_EARPLUGS); + } + + public boolean hasCollar() { + return hasFlag(FLAG_HAS_COLLAR); + } + + public boolean hasClothes() { + return hasFlag(FLAG_HAS_CLOTHES); + } + + public boolean isOnline() { + return hasFlag(FLAG_ONLINE); + } + + public byte getStateFlags() { + return stateFlags; + } +} diff --git a/src/main/java/com/tiedup/remake/network/sync/PacketSyncClothesConfig.java b/src/main/java/com/tiedup/remake/network/sync/PacketSyncClothesConfig.java new file mode 100644 index 0000000..9a84472 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/sync/PacketSyncClothesConfig.java @@ -0,0 +1,212 @@ +package com.tiedup.remake.network.sync; + +import com.tiedup.remake.items.clothes.ClothesProperties; +import com.tiedup.remake.items.clothes.GenericClothes; +import com.tiedup.remake.network.base.AbstractClientPacket; +import java.util.EnumSet; +import java.util.UUID; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Packet to synchronize clothes configuration from server to clients. + * Sent when a player's clothes settings change (URL, fullSkin, smallArms, layers). + * + * Direction: Server → Client (S2C) + * + * This packet enables other players to see dynamic textures on clothes worn by players. + */ +public class PacketSyncClothesConfig extends AbstractClientPacket { + + private final UUID playerUUID; + private final boolean hasClothes; + private final String dynamicUrl; // May be null/empty + private final boolean fullSkin; + private final boolean smallArms; + private final boolean keepHead; + private final byte layerVisibility; // Bitfield for 6 layers + + /** + * Create a packet for a player wearing clothes. + * + * @param playerUUID The player's UUID + * @param clothes The clothes ItemStack (may be empty) + */ + public PacketSyncClothesConfig(UUID playerUUID, ItemStack clothes) { + this.playerUUID = playerUUID; + + if ( + !clothes.isEmpty() && clothes.getItem() instanceof GenericClothes gc + ) { + this.hasClothes = true; + this.dynamicUrl = gc.getDynamicTextureUrl(clothes); + this.fullSkin = gc.isFullSkinEnabled(clothes); + this.smallArms = gc.shouldForceSmallArms(clothes); + this.keepHead = gc.isKeepHeadEnabled(clothes); + this.layerVisibility = encodeLayerVisibility(gc, clothes); + } else { + this.hasClothes = false; + this.dynamicUrl = null; + this.fullSkin = false; + this.smallArms = false; + this.keepHead = false; + this.layerVisibility = 0b111111; // All visible (default) + } + } + + /** + * Create a packet indicating no clothes. + * + * @param playerUUID The player's UUID + */ + public static PacketSyncClothesConfig noClothes(UUID playerUUID) { + return new PacketSyncClothesConfig(playerUUID, ItemStack.EMPTY); + } + + /** + * Private constructor for decoding. + */ + private PacketSyncClothesConfig( + UUID playerUUID, + boolean hasClothes, + String dynamicUrl, + boolean fullSkin, + boolean smallArms, + boolean keepHead, + byte layerVisibility + ) { + this.playerUUID = playerUUID; + this.hasClothes = hasClothes; + this.dynamicUrl = dynamicUrl; + this.fullSkin = fullSkin; + this.smallArms = smallArms; + this.keepHead = keepHead; + this.layerVisibility = layerVisibility; + } + + /** + * Encode layer visibility as a byte bitfield. + */ + private byte encodeLayerVisibility(GenericClothes gc, ItemStack stack) { + byte bits = 0; + if (gc.isLayerEnabled(stack, GenericClothes.LAYER_HEAD)) bits |= + 0b000001; + if (gc.isLayerEnabled(stack, GenericClothes.LAYER_BODY)) bits |= + 0b000010; + if (gc.isLayerEnabled(stack, GenericClothes.LAYER_LEFT_ARM)) bits |= + 0b000100; + if (gc.isLayerEnabled(stack, GenericClothes.LAYER_RIGHT_ARM)) bits |= + 0b001000; + if (gc.isLayerEnabled(stack, GenericClothes.LAYER_LEFT_LEG)) bits |= + 0b010000; + if (gc.isLayerEnabled(stack, GenericClothes.LAYER_RIGHT_LEG)) bits |= + 0b100000; + return bits; + } + + /** + * Encode the packet to a byte buffer. + */ + public void encode(FriendlyByteBuf buf) { + buf.writeUUID(playerUUID); + buf.writeBoolean(hasClothes); + + if (hasClothes) { + // Write URL (empty string if null) + buf.writeUtf(dynamicUrl != null ? dynamicUrl : "", 2048); + buf.writeBoolean(fullSkin); + buf.writeBoolean(smallArms); + buf.writeBoolean(keepHead); + buf.writeByte(layerVisibility); + } + } + + /** + * Decode the packet from a byte buffer. + */ + public static PacketSyncClothesConfig decode(FriendlyByteBuf buf) { + UUID playerUUID = buf.readUUID(); + boolean hasClothes = buf.readBoolean(); + + if (hasClothes) { + String dynamicUrl = buf.readUtf(2048); + if (dynamicUrl.isEmpty()) dynamicUrl = null; + boolean fullSkin = buf.readBoolean(); + boolean smallArms = buf.readBoolean(); + boolean keepHead = buf.readBoolean(); + byte layerVisibility = buf.readByte(); + + return new PacketSyncClothesConfig( + playerUUID, + true, + dynamicUrl, + fullSkin, + smallArms, + keepHead, + layerVisibility + ); + } else { + return new PacketSyncClothesConfig( + playerUUID, + false, + null, + false, + false, + false, + (byte) 0b111111 + ); + } + } + + /** + * Client-side handling - update the clothes cache. + */ + @Override + @OnlyIn(Dist.CLIENT) + protected void handleClientImpl() { + if (hasClothes) { + EnumSet layers = + ClothesProperties.decodeLayerVisibility(layerVisibility); + com.tiedup.remake.client.state.ClothesClientCache.updatePlayerClothes( + playerUUID, + dynamicUrl, + fullSkin, + smallArms, + keepHead, + layers + ); + } else { + com.tiedup.remake.client.state.ClothesClientCache.removePlayerClothes( + playerUUID + ); + } + } + + // Getters + + public UUID getPlayerUUID() { + return playerUUID; + } + + public boolean hasClothes() { + return hasClothes; + } + + public String getDynamicUrl() { + return dynamicUrl; + } + + public boolean isFullSkin() { + return fullSkin; + } + + public boolean isSmallArms() { + return smallArms; + } + + public byte getLayerVisibility() { + return layerVisibility; + } +} diff --git a/src/main/java/com/tiedup/remake/network/sync/PacketSyncCollarRegistry.java b/src/main/java/com/tiedup/remake/network/sync/PacketSyncCollarRegistry.java new file mode 100644 index 0000000..9ef80e5 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/sync/PacketSyncCollarRegistry.java @@ -0,0 +1,125 @@ +package com.tiedup.remake.network.sync; + +import com.tiedup.remake.network.base.AbstractClientPacket; +import java.util.*; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Packet to sync collar registry data from server to client. + * + * Sends the list of collar wearers (slaves) owned by the receiving player. + * This allows the client to display slave management UI without spatial queries. + * + * Direction: Server → Client (S2C) + */ +public class PacketSyncCollarRegistry extends AbstractClientPacket { + + // Player's owned slaves (wearer UUIDs) + private final Set slaveUUIDs; + + // Full sync flag (true = replace all, false = incremental update) + private final boolean fullSync; + + // For incremental updates: UUIDs to remove + private final Set removedUUIDs; + + /** + * Create a full sync packet with all slave UUIDs. + */ + public PacketSyncCollarRegistry(Set slaveUUIDs) { + this.slaveUUIDs = new HashSet<>(slaveUUIDs); + this.fullSync = true; + this.removedUUIDs = Collections.emptySet(); + } + + /** + * Create an incremental update packet. + * + * @param addedUUIDs UUIDs to add to the client's cache + * @param removedUUIDs UUIDs to remove from the client's cache + */ + public PacketSyncCollarRegistry( + Set addedUUIDs, + Set removedUUIDs + ) { + this.slaveUUIDs = new HashSet<>(addedUUIDs); + this.fullSync = false; + this.removedUUIDs = new HashSet<>(removedUUIDs); + } + + // ==================== ENCODE/DECODE ==================== + + public void encode(FriendlyByteBuf buf) { + buf.writeBoolean(fullSync); + + // Write slave UUIDs + buf.writeVarInt(slaveUUIDs.size()); + for (UUID uuid : slaveUUIDs) { + buf.writeUUID(uuid); + } + + // Write removed UUIDs (only for incremental) + if (!fullSync) { + buf.writeVarInt(removedUUIDs.size()); + for (UUID uuid : removedUUIDs) { + buf.writeUUID(uuid); + } + } + } + + public static PacketSyncCollarRegistry decode(FriendlyByteBuf buf) { + boolean fullSync = buf.readBoolean(); + + // Read slave UUIDs (cap at 10000 to prevent memory exhaustion from malformed packets) + int slaveCount = Math.min(buf.readVarInt(), 10000); + Set slaveUUIDs = new HashSet<>(slaveCount); + for (int i = 0; i < slaveCount; i++) { + slaveUUIDs.add(buf.readUUID()); + } + + if (fullSync) { + return new PacketSyncCollarRegistry(slaveUUIDs); + } else { + // Read removed UUIDs (cap at 10000) + int removedCount = Math.min(buf.readVarInt(), 10000); + Set removedUUIDs = new HashSet<>(removedCount); + for (int i = 0; i < removedCount; i++) { + removedUUIDs.add(buf.readUUID()); + } + return new PacketSyncCollarRegistry(slaveUUIDs, removedUUIDs); + } + } + + // ==================== HANDLE ==================== + + @Override + @OnlyIn(Dist.CLIENT) + protected void handleClientImpl() { + if (fullSync) { + com.tiedup.remake.client.state.CollarRegistryClient.setSlaves( + slaveUUIDs + ); + } else { + com.tiedup.remake.client.state.CollarRegistryClient.updateSlaves( + slaveUUIDs, + removedUUIDs + ); + } + } + + // ==================== GETTERS ==================== + + public Set getSlaveUUIDs() { + return Collections.unmodifiableSet(slaveUUIDs); + } + + public boolean isFullSync() { + return fullSync; + } + + public Set getRemovedUUIDs() { + return Collections.unmodifiableSet(removedUUIDs); + } +} diff --git a/src/main/java/com/tiedup/remake/network/sync/PacketSyncEnslavement.java b/src/main/java/com/tiedup/remake/network/sync/PacketSyncEnslavement.java new file mode 100644 index 0000000..e65b1c5 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/sync/PacketSyncEnslavement.java @@ -0,0 +1,169 @@ +package com.tiedup.remake.network.sync; + +import com.tiedup.remake.network.base.AbstractPlayerSyncPacket; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.ICaptor; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.UUID; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Packet to synchronize enslavement state between server and client. + * Sent when a player is enslaved, freed, or transfers masters. + * + * Direction: Server → Client (S2C) + */ +public class PacketSyncEnslavement extends AbstractPlayerSyncPacket { + + private final UUID masterUUID; // The master's UUID (null if free) + private final UUID transportUUID; // The transport entity's UUID (null if no transport) + private final boolean isEnslaved; // Quick flag for state check + + /** + * Create a packet to sync a player's enslavement state. + * + * @param targetUUID The slave player's UUID + * @param masterUUID The master's UUID, or null if not enslaved + * @param transportUUID The transport entity's UUID, or null if no transport + * @param isEnslaved Whether the player is currently enslaved + */ + public PacketSyncEnslavement( + UUID targetUUID, + UUID masterUUID, + UUID transportUUID, + boolean isEnslaved + ) { + super(targetUUID); + this.masterUUID = masterUUID; + this.transportUUID = transportUUID; + this.isEnslaved = isEnslaved; + } + + /** + * Create a packet from a player's current enslavement state. + * + * @param player The player + * @return The packet, or null if player has no kidnapped state + */ + public static PacketSyncEnslavement fromPlayer(Player player) { + IBondageState state = KidnappedHelper.getKidnappedState(player); + if (state == null) { + return null; + } + + UUID captorUUID = null; + UUID proxyUUID = null; + boolean isCaptured = state.isCaptive(); + + if (isCaptured) { + ICaptor captor = state.getCaptor(); + if (captor != null && captor.getEntity() != null) { + captorUUID = captor.getEntity().getUUID(); + } + + Entity proxy = state.getTransport(); + if (proxy != null) { + proxyUUID = proxy.getUUID(); + } + } + + return new PacketSyncEnslavement( + player.getUUID(), + captorUUID, + proxyUUID, + isCaptured + ); + } + + /** + * Encode the packet to a byte buffer. + */ + public void encode(FriendlyByteBuf buf) { + encodeUUID(buf); + buf.writeBoolean(isEnslaved); + + // Write optional master UUID + buf.writeBoolean(masterUUID != null); + if (masterUUID != null) { + buf.writeUUID(masterUUID); + } + + // Write optional transport UUID + buf.writeBoolean(transportUUID != null); + if (transportUUID != null) { + buf.writeUUID(transportUUID); + } + } + + /** + * Decode the packet from a byte buffer. + */ + public static PacketSyncEnslavement decode(FriendlyByteBuf buf) { + UUID targetUUID = decodeUUID(buf); + boolean isEnslaved = buf.readBoolean(); + + UUID masterUUID = null; + if (buf.readBoolean()) { + masterUUID = buf.readUUID(); + } + + UUID transportUUID = null; + if (buf.readBoolean()) { + transportUUID = buf.readUUID(); + } + + return new PacketSyncEnslavement( + targetUUID, + masterUUID, + transportUUID, + isEnslaved + ); + } + + /** + * Apply the enslavement sync to a player. + * + * Note: This is primarily for CLIENT-SIDE visual/state updates. + * The actual enslavement logic is handled server-side. + */ + @Override + @OnlyIn(Dist.CLIENT) + protected void applySync(Player player) { + // Client-side state awareness (for UI indicators, animations) + // The client doesn't directly manipulate enslavement state + // It receives updates from entity syncs + } + + @Override + @OnlyIn(Dist.CLIENT) + protected void queueForRetry() { + SyncManager.queueEnslavementSync( + playerUUID, + masterUUID, + transportUUID, + isEnslaved + ); + } + + // Getters + + public UUID getTargetUUID() { + return playerUUID; + } + + public UUID getMasterUUID() { + return masterUUID; + } + + public UUID getTransportUUID() { + return transportUUID; + } + + public boolean isEnslaved() { + return isEnslaved; + } +} diff --git a/src/main/java/com/tiedup/remake/network/sync/PacketSyncLeashProxy.java b/src/main/java/com/tiedup/remake/network/sync/PacketSyncLeashProxy.java new file mode 100644 index 0000000..a6b1aa7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/sync/PacketSyncLeashProxy.java @@ -0,0 +1,85 @@ +package com.tiedup.remake.network.sync; + +import com.tiedup.remake.network.base.AbstractClientPacket; +import java.util.UUID; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Packet sent from server to client to sync leash proxy information. + * Tells the client which proxy entity follows which player. + * + * This allows the client to position the proxy locally each frame, + * rather than waiting for server position updates (much smoother). + * + * Uses UUID for player identification (persistent across restarts) + * and entity ID for proxy (runtime entity only). + */ +public class PacketSyncLeashProxy extends AbstractClientPacket { + + /** The player UUID that the proxy follows (persistent) */ + private final UUID targetPlayerUUID; + + /** The proxy entity ID (runtime only, recreated on restart) */ + private final int proxyId; + + /** True = attach proxy to player, False = detach */ + private final boolean attach; + + /** + * Create an attach packet. + */ + public static PacketSyncLeashProxy attach( + UUID targetPlayerUUID, + int proxyId + ) { + return new PacketSyncLeashProxy(targetPlayerUUID, proxyId, true); + } + + /** + * Create a detach packet. + */ + public static PacketSyncLeashProxy detach(UUID targetPlayerUUID) { + return new PacketSyncLeashProxy(targetPlayerUUID, -1, false); + } + + private PacketSyncLeashProxy( + UUID targetPlayerUUID, + int proxyId, + boolean attach + ) { + this.targetPlayerUUID = targetPlayerUUID; + this.proxyId = proxyId; + this.attach = attach; + } + + /** + * Encode packet to buffer. + */ + public void encode(FriendlyByteBuf buf) { + buf.writeUUID(targetPlayerUUID); + buf.writeInt(proxyId); + buf.writeBoolean(attach); + } + + /** + * Decode packet from buffer. + */ + public static PacketSyncLeashProxy decode(FriendlyByteBuf buf) { + UUID targetPlayerUUID = buf.readUUID(); + int proxyId = buf.readInt(); + boolean attach = buf.readBoolean(); + return new PacketSyncLeashProxy(targetPlayerUUID, proxyId, attach); + } + + @Override + @OnlyIn(Dist.CLIENT) + protected void handleClientImpl() { + com.tiedup.remake.client.events.LeashProxyClientHandler.handleSyncPacket( + targetPlayerUUID, + proxyId, + attach + ); + } +} diff --git a/src/main/java/com/tiedup/remake/network/sync/PacketSyncMovementStyle.java b/src/main/java/com/tiedup/remake/network/sync/PacketSyncMovementStyle.java new file mode 100644 index 0000000..b29f8f9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/sync/PacketSyncMovementStyle.java @@ -0,0 +1,99 @@ +package com.tiedup.remake.network.sync; + +import com.tiedup.remake.network.base.AbstractPlayerSyncPacket; +import com.tiedup.remake.v2.bondage.movement.MovementStyle; +import java.util.UUID; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.world.entity.Pose; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.jetbrains.annotations.Nullable; + +/** + * Server-to-client packet that syncs the active movement style for a player. + * Sent when the active style changes (including clearing to null). + * + *

Extends {@link AbstractPlayerSyncPacket} for UUID-based player lookup + * with retry support for login race conditions.

+ * + *

Distribution: {@code ModNetwork.sendToAllTrackingAndSelf()} so all + * nearby clients can render the correct animation for remote players.

+ */ +public class PacketSyncMovementStyle extends AbstractPlayerSyncPacket { + + /** Marker byte for "no style" (null). */ + private static final byte NO_STYLE = -1; + + /** The active style ordinal, or -1 for null. */ + private final byte styleOrdinal; + + /** + * Construct a sync packet. + * + * @param playerUUID the affected player's UUID + * @param style the active style, or null to clear + */ + public PacketSyncMovementStyle(UUID playerUUID, @Nullable MovementStyle style) { + super(playerUUID); + this.styleOrdinal = style == null ? NO_STYLE : (byte) style.ordinal(); + } + + private PacketSyncMovementStyle(UUID playerUUID, byte styleOrdinal) { + super(playerUUID); + this.styleOrdinal = styleOrdinal; + } + + public void encode(FriendlyByteBuf buf) { + encodeUUID(buf); + buf.writeByte(styleOrdinal); + } + + public static PacketSyncMovementStyle decode(FriendlyByteBuf buf) { + UUID uuid = decodeUUID(buf); + byte ordinal = buf.readByte(); + return new PacketSyncMovementStyle(uuid, ordinal); + } + + @Override + @OnlyIn(Dist.CLIENT) + protected void applySync(Player player) { + ClientHandler.handle(this, player); + } + + @Override + @OnlyIn(Dist.CLIENT) + protected void queueForRetry() { + // Movement style will be re-sent on next change or re-resolved on tick. + // Non-critical: skip retry to avoid complexity. + } + + @OnlyIn(Dist.CLIENT) + private static class ClientHandler { + private static void handle(PacketSyncMovementStyle pkt, Player player) { + MovementStyle style = null; + if (pkt.styleOrdinal >= 0 && pkt.styleOrdinal < MovementStyle.values().length) { + style = MovementStyle.values()[pkt.styleOrdinal]; + } + + if (style != null) { + com.tiedup.remake.client.state.MovementStyleClientState.set(player.getUUID(), style); + } else { + com.tiedup.remake.client.state.MovementStyleClientState.clear(player.getUUID()); + } + + // Crawl pose management: server sets forced pose for hitbox, + // client must also set it for correct rendering + if (style == MovementStyle.CRAWL) { + player.setForcedPose(Pose.SWIMMING); + player.refreshDimensions(); + } else { + // Clear forced pose if it was previously set by crawl + if (player.getForcedPose() == Pose.SWIMMING) { + player.setForcedPose(null); + player.refreshDimensions(); + } + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/network/sync/PacketSyncPetBedState.java b/src/main/java/com/tiedup/remake/network/sync/PacketSyncPetBedState.java new file mode 100644 index 0000000..488e0a4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/sync/PacketSyncPetBedState.java @@ -0,0 +1,74 @@ +package com.tiedup.remake.network.sync; + +import com.tiedup.remake.network.base.AbstractPlayerSyncPacket; +import com.tiedup.remake.v2.blocks.PetBedBlock; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Sync packet for pet bed state (SIT/SLEEP/CLEAR). + * Direction: Server → Client (S2C) + */ +public class PacketSyncPetBedState extends AbstractPlayerSyncPacket { + + /** 0=CLEAR, 1=SIT, 2=SLEEP */ + private final byte mode; + private final BlockPos pos; + + public PacketSyncPetBedState(UUID playerUUID, byte mode, BlockPos pos) { + super(playerUUID); + this.mode = mode; + this.pos = pos; + } + + public void encode(FriendlyByteBuf buf) { + encodeUUID(buf); + buf.writeByte(mode); + buf.writeBlockPos(pos); + } + + public static PacketSyncPetBedState decode(FriendlyByteBuf buf) { + UUID uuid = decodeUUID(buf); + byte mode = buf.readByte(); + BlockPos pos = buf.readBlockPos(); + return new PacketSyncPetBedState(uuid, mode, pos); + } + + @Override + @OnlyIn(Dist.CLIENT) + protected void applySync(Player player) { + ClientHandler.handle(this, player); + } + + @OnlyIn(Dist.CLIENT) + private static class ClientHandler { + private static void handle(PacketSyncPetBedState pkt, Player player) { + if (pkt.mode == 0) { + // CLEAR — only stop animation if no bondage animation should be playing + // (AnimationTickHandler will re-apply bondage anim on next tick if needed) + com.tiedup.remake.client.animation.BondageAnimationManager.stopAnimation(player); + com.tiedup.remake.client.state.PetBedClientState.clear(player.getUUID()); + } else { + // Compute bed facing angle from block state + float facingYRot = 0f; + BlockState state = player.level().getBlockState(pkt.pos); + if (state.hasProperty(PetBedBlock.FACING)) { + facingYRot = state.getValue(PetBedBlock.FACING).toYRot(); + } + + com.tiedup.remake.client.state.PetBedClientState.set(player.getUUID(), pkt.mode, facingYRot); + + if (pkt.mode == 1) { + com.tiedup.remake.client.animation.BondageAnimationManager.playAnimation(player, "pet_bed_sit"); + } else if (pkt.mode == 2) { + com.tiedup.remake.client.animation.BondageAnimationManager.playAnimation(player, "pet_bed_sleep"); + } + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/network/sync/PacketSyncStruggleState.java b/src/main/java/com/tiedup/remake/network/sync/PacketSyncStruggleState.java new file mode 100644 index 0000000..c7eba33 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/sync/PacketSyncStruggleState.java @@ -0,0 +1,93 @@ +package com.tiedup.remake.network.sync; + +import com.tiedup.remake.network.base.AbstractPlayerSyncPacket; +import com.tiedup.remake.state.PlayerBindState; +import java.util.UUID; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Packet to synchronize struggle animation state. + * Lightweight packet (17 bytes): UUID (16 bytes) + boolean (1 byte). + * + * Direction: Server → Client (S2C) + * + * Sent when: + * - Player starts struggle animation (R key pressed) + * - Struggle animation auto-stops after 80 ticks + */ +public class PacketSyncStruggleState extends AbstractPlayerSyncPacket { + + private final boolean isStruggling; + + /** + * Create a packet to sync struggle animation state. + * + * @param playerUUID The player's UUID + * @param isStruggling True if player is struggling, false otherwise + */ + private PacketSyncStruggleState(UUID playerUUID, boolean isStruggling) { + super(playerUUID); + this.isStruggling = isStruggling; + } + + /** + * Create a packet from a player's current struggle state. + * + * @param player The player + * @return The packet, or null if player has no bind state + */ + public static PacketSyncStruggleState fromPlayer(Player player) { + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + return null; + } + + return new PacketSyncStruggleState( + player.getUUID(), + state.isStruggling() + ); + } + + /** + * Encode the packet to a byte buffer. + */ + public void encode(FriendlyByteBuf buf) { + encodeUUID(buf); + buf.writeBoolean(isStruggling); + } + + /** + * Decode the packet from a byte buffer. + */ + public static PacketSyncStruggleState decode(FriendlyByteBuf buf) { + UUID playerUUID = decodeUUID(buf); + boolean isStruggling = buf.readBoolean(); + return new PacketSyncStruggleState(playerUUID, isStruggling); + } + + /** + * Apply the struggle state sync to a player. + */ + @Override + @OnlyIn(Dist.CLIENT) + protected void applySync(Player player) { + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + return; + } + + // Update struggle state (client-side only - server manages timer) + state.setStrugglingClient(isStruggling); + } + + // queueForRetry() not needed - struggle state is non-critical and will be re-sent + + // Getters + + public boolean isStruggling() { + return isStruggling; + } +} diff --git a/src/main/java/com/tiedup/remake/network/sync/PendingSyncEntry.java b/src/main/java/com/tiedup/remake/network/sync/PendingSyncEntry.java new file mode 100644 index 0000000..eaee16c --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/sync/PendingSyncEntry.java @@ -0,0 +1,133 @@ +package com.tiedup.remake.network.sync; + +import java.util.UUID; + +/** + * Represents a pending synchronization entry for a player that wasn't loaded yet. + * Stores the data needed to retry the sync later. + */ +public class PendingSyncEntry { + + private final SyncType type; + private final UUID targetPlayerUUID; + private final long creationTick; + private int retryCount; + + // Data for BIND_STATE sync + private final byte stateFlags; + + // Data for ENSLAVEMENT sync + private final UUID masterUUID; + private final UUID transportUUID; + private final boolean isEnslaved; + + /** + * Create a pending bind state sync entry. + */ + public static PendingSyncEntry forBindState( + UUID targetUUID, + byte flags, + long currentTick + ) { + return new PendingSyncEntry( + SyncType.BIND_STATE, + targetUUID, + currentTick, + flags, + null, + null, + false + ); + } + + /** + * Create a pending enslavement sync entry. + */ + public static PendingSyncEntry forEnslavement( + UUID targetUUID, + UUID masterUUID, + UUID transportUUID, + boolean isEnslaved, + long currentTick + ) { + return new PendingSyncEntry( + SyncType.ENSLAVEMENT, + targetUUID, + currentTick, + (byte) 0, + masterUUID, + transportUUID, + isEnslaved + ); + } + + private PendingSyncEntry( + SyncType type, + UUID targetUUID, + long creationTick, + byte stateFlags, + UUID masterUUID, + UUID transportUUID, + boolean isEnslaved + ) { + this.type = type; + this.targetPlayerUUID = targetUUID; + this.creationTick = creationTick; + this.retryCount = 0; + this.stateFlags = stateFlags; + this.masterUUID = masterUUID; + this.transportUUID = transportUUID; + this.isEnslaved = isEnslaved; + } + + public SyncType getType() { + return type; + } + + public UUID getTargetPlayerUUID() { + return targetPlayerUUID; + } + + public long getCreationTick() { + return creationTick; + } + + public int getRetryCount() { + return retryCount; + } + + public void incrementRetryCount() { + retryCount++; + } + + /** + * Check if this entry has expired (too many retries or too old). + * @param currentTick The current game tick + * @param maxTicks Maximum age in ticks before expiring + * @param maxRetries Maximum retry attempts + * @return true if expired and should be discarded + */ + public boolean isExpired(long currentTick, int maxTicks, int maxRetries) { + return ( + (currentTick - creationTick) > maxTicks || retryCount >= maxRetries + ); + } + + // Getters for sync data + + public byte getStateFlags() { + return stateFlags; + } + + public UUID getMasterUUID() { + return masterUUID; + } + + public UUID getTransportUUID() { + return transportUUID; + } + + public boolean isEnslaved() { + return isEnslaved; + } +} diff --git a/src/main/java/com/tiedup/remake/network/sync/SyncManager.java b/src/main/java/com/tiedup/remake/network/sync/SyncManager.java new file mode 100644 index 0000000..1a9059d --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/sync/SyncManager.java @@ -0,0 +1,262 @@ +package com.tiedup.remake.network.sync; + +import com.mojang.logging.LogUtils; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.util.ValidationHelper; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.*; +import java.util.function.Function; +import org.jetbrains.annotations.Nullable; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.slf4j.Logger; + +/** + * Centralized synchronization manager for TiedUp mod. + * + * This manager handles all synchronization between server and client: + * - Provides unified methods for syncing inventory, state, and enslavement + * - Server-side: Sends packets to tracking clients + * - Client-side: Delegates to ClientSyncHandler for queue management + * + * IMPORTANT: This class is loaded on both server and client. + * All client-specific code (Minecraft.getInstance(), queue processing) + * has been moved to ClientSyncHandler to avoid NoClassDefFoundError + * on dedicated servers. + * + * @see ClientSyncHandler for client-side queue handling + */ +public class SyncManager { + + private static final Logger LOGGER = LogUtils.getLogger(); + + // ==================== Server-side sync methods ==================== + + /** + * Generic sync method that handles the common pattern: + * 1. Check if server-side + * 2. Create packet from player + * 3. Send to all tracking and self + * + * Phase 2 Refactoring: Eliminates code duplication in sync methods. + * + * @param player The player to sync (must be ServerPlayer) + * @param packetFactory Function to create packet from player + * @param The packet type + */ + private static void sendSync( + @Nullable Player player, + Function packetFactory + ) { + ValidationHelper.asServerPlayer(player).ifPresent(serverPlayer -> { + T packet = packetFactory.apply(player); + if (packet != null) { + ModNetwork.sendToAllTrackingAndSelf(packet, serverPlayer); + } + }); + } + + /** + * Sync a player's bondage inventory to all tracking clients and themselves. + * This is the main method to call when equipment changes. + * + *

Epic 5A: Delegates to {@link V2EquipmentHelper#sync} which sends + * {@code PacketSyncV2Equipment}. + * + * @param player The player whose inventory changed (must be ServerPlayer) + */ + public static void syncInventory(Player player) { + // V2: Use unified V2 equipment sync + ValidationHelper.asServerPlayer(player).ifPresent(serverPlayer -> { + V2EquipmentHelper.sync(serverPlayer); + }); + } + + /** + * Sync a player's bind state flags to all tracking clients and themselves. + * + * @param player The player whose state changed (must be ServerPlayer) + */ + public static void syncBindState(Player player) { + sendSync(player, PacketSyncBindState::fromPlayer); + } + + /** + * Sync a player's enslavement state to all tracking clients and themselves. + * + * @param player The player whose enslavement state changed (must be ServerPlayer) + */ + public static void syncEnslavement(Player player) { + sendSync(player, PacketSyncEnslavement::fromPlayer); + } + + /** + * Sync a player's struggle animation state to all tracking clients and themselves. + * Used when struggle animation starts or stops. + * + * @param player The player whose struggle state changed (must be ServerPlayer) + */ + public static void syncStruggleState(Player player) { + sendSync(player, PacketSyncStruggleState::fromPlayer); + } + + /** + * Sync a player's clothes configuration to all tracking clients and themselves. + * Used when clothes are equipped, unequipped, or their settings are changed. + * + * @param player The player whose clothes state changed (must be ServerPlayer) + */ + public static void syncClothesConfig(Player player) { + ValidationHelper.asServerPlayer(player).ifPresent(serverPlayer -> { + ItemStack clothes = V2EquipmentHelper.getInRegion( + player, BodyRegionV2.TORSO + ); + PacketSyncClothesConfig packet = new PacketSyncClothesConfig( + player.getUUID(), + clothes + ); + ModNetwork.sendToAllTrackingAndSelf(packet, serverPlayer); + }); + } + + /** + * Sync all data (inventory + state + enslavement + struggle + clothes) for a player. + * + * @param player The player to sync + */ + public static void syncAll(Player player) { + syncInventory(player); + syncBindState(player); + syncEnslavement(player); + syncStruggleState(player); + syncClothesConfig(player); + } + + /** + * Sync all online players' data TO a specific player. + * Called when a player joins to ensure they see everyone's state. + * + *

Epic 5A: Equipment sync uses V2 {@link V2EquipmentHelper#syncTo}. + * + * @param target The player who just joined and needs to receive all data + */ + public static void syncAllPlayersTo(ServerPlayer target) { + if (target.level().isClientSide) return; + + var server = target.getServer(); + if (server == null) return; + + int syncedCount = 0; + for (ServerPlayer otherPlayer : server.getPlayerList().getPlayers()) { + if (otherPlayer == target) continue; // Skip self + + // V2: Send other player's V2 equipment to target + V2EquipmentHelper.syncTo(otherPlayer, target); + + // Send other player's state to target + PacketSyncBindState statePacket = PacketSyncBindState.fromPlayer( + otherPlayer + ); + if (statePacket != null) { + ModNetwork.sendToPlayer(statePacket, target); + } + + // Send other player's enslavement state to target + PacketSyncEnslavement enslavementPacket = + PacketSyncEnslavement.fromPlayer(otherPlayer); + if (enslavementPacket != null) { + ModNetwork.sendToPlayer(enslavementPacket, target); + } + + // Send other player's struggle state to target + PacketSyncStruggleState strugglePacket = + PacketSyncStruggleState.fromPlayer(otherPlayer); + if (strugglePacket != null) { + ModNetwork.sendToPlayer(strugglePacket, target); + } + + // Send other player's clothes config to target + ItemStack clothes = V2EquipmentHelper.getInRegion( + otherPlayer, BodyRegionV2.TORSO + ); + if (!clothes.isEmpty()) { + PacketSyncClothesConfig clothesPacket = + new PacketSyncClothesConfig(otherPlayer.getUUID(), clothes); + ModNetwork.sendToPlayer(clothesPacket, target); + } + + syncedCount++; + } + + if (syncedCount > 0) { + LOGGER.debug( + "[SyncManager] Synced {} players' data to {}", + syncedCount, + target.getName().getString() + ); + } + } + + // ==================== Client-side queue delegation ==================== + // These methods delegate to ClientSyncHandler on client side. + // They are safe to call from packet handlers because they check the side. + + /** + * Queue a bind state sync for later processing. + * Called from PacketSyncBindState.handle() when target player is null. + * + * @param targetUUID The UUID of the player to sync + * @param stateFlags The state flags to apply + */ + @OnlyIn(Dist.CLIENT) + public static void queueBindStateSync(UUID targetUUID, byte stateFlags) { + ClientSyncHandler.queueBindStateSync(targetUUID, stateFlags); + } + + /** + * Queue an enslavement sync for later processing. + * Called from PacketSyncEnslavement.handle() when target player is null. + * + * @param targetUUID The UUID of the player to sync + * @param masterUUID The master's UUID (null if free) + * @param transportUUID The transport entity UUID (null if no transport) + * @param isEnslaved Whether the player is enslaved + */ + @OnlyIn(Dist.CLIENT) + public static void queueEnslavementSync( + UUID targetUUID, + UUID masterUUID, + UUID transportUUID, + boolean isEnslaved + ) { + ClientSyncHandler.queueEnslavementSync( + targetUUID, + masterUUID, + transportUUID, + isEnslaved + ); + } + + /** + * Clear all pending syncs for a player (e.g., when they disconnect). + * + * @param playerUUID The player's UUID + */ + @OnlyIn(Dist.CLIENT) + public static void clearPendingForPlayer(UUID playerUUID) { + ClientSyncHandler.clearPendingForPlayer(playerUUID); + } + + /** + * Clear the entire pending queue (e.g., when disconnecting from server). + */ + @OnlyIn(Dist.CLIENT) + public static void clearAllPending() { + ClientSyncHandler.clearAllPending(); + } +} diff --git a/src/main/java/com/tiedup/remake/network/sync/SyncType.java b/src/main/java/com/tiedup/remake/network/sync/SyncType.java new file mode 100644 index 0000000..ea5239c --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/sync/SyncType.java @@ -0,0 +1,16 @@ +package com.tiedup.remake.network.sync; + +/** + * Types of synchronization packets. + * Used by SyncManager to categorize and process pending syncs. + */ +public enum SyncType { + /** Bind state flags sync (PacketSyncBindState) */ + BIND_STATE, + + /** Enslavement state sync (PacketSyncEnslavement) */ + ENSLAVEMENT, + + /** Full sync (all of the above) */ + FULL, +} diff --git a/src/main/java/com/tiedup/remake/network/trader/PacketBuyCaptive.java b/src/main/java/com/tiedup/remake/network/trader/PacketBuyCaptive.java new file mode 100644 index 0000000..962211f --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/trader/PacketBuyCaptive.java @@ -0,0 +1,342 @@ +package com.tiedup.remake.network.trader; + +import com.tiedup.remake.cells.CampOwnership; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.EntitySlaveTrader; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.util.tasks.ItemTask; +import java.util.List; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.ChatFormatting; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.network.NetworkEvent; + +/** + * Packet for buying a captive from a SlaveTrader. + * + * Client -> Server + */ +public class PacketBuyCaptive { + + private final int traderEntityId; + private final UUID captiveId; + + public PacketBuyCaptive(int traderEntityId, UUID captiveId) { + this.traderEntityId = traderEntityId; + this.captiveId = captiveId; + } + + public void encode(FriendlyByteBuf buf) { + buf.writeInt(traderEntityId); + buf.writeUUID(captiveId); + } + + public static PacketBuyCaptive decode(FriendlyByteBuf buf) { + int traderEntityId = buf.readInt(); + UUID captiveId = buf.readUUID(); + return new PacketBuyCaptive(traderEntityId, captiveId); + } + + public void handle(Supplier ctx) { + ctx + .get() + .enqueueWork(() -> { + ServerPlayer player = ctx.get().getSender(); + if (player == null) return; + + // CRITICAL FIX: Add rate limiting to prevent DoS via packet spam + if ( + !com.tiedup.remake.network.PacketRateLimiter.allowPacket( + player, + "action" + ) + ) { + return; + } + + handleServer(player); + }); + ctx.get().setPacketHandled(true); + } + + private void handleServer(ServerPlayer buyer) { + ServerLevel level = buyer.serverLevel(); + + // Find trader entity + Entity traderEntity = level.getEntity(traderEntityId); + if (!(traderEntity instanceof EntitySlaveTrader trader)) { + buyer.sendSystemMessage( + Component.literal("Trader not found.").withStyle( + ChatFormatting.RED + ) + ); + return; + } + + // Validate distance to trader + if (buyer.distanceTo(trader) > 10.0) { + return; + } + + // Verify player has token + if (!EntityKidnapper.hasTokenInInventory(buyer)) { + buyer.sendSystemMessage( + Component.literal("You need a token to trade.").withStyle( + ChatFormatting.RED + ) + ); + return; + } + + // Find captive in trader's camp cells + UUID campId = trader.getCampUUID(); + if (campId == null) { + buyer.sendSystemMessage( + Component.literal("This trader has no camp.").withStyle( + ChatFormatting.RED + ) + ); + return; + } + + CampOwnership ownership = CampOwnership.get(level); + CampOwnership.CampData campData = ownership.getCamp(campId); + if (campData == null) { + buyer.sendSystemMessage( + Component.literal("Camp not found.").withStyle( + ChatFormatting.RED + ) + ); + return; + } + + CellRegistryV2 cellRegistry = CellRegistryV2.get(level); + List cells = cellRegistry.findCellsNear( + campData.getCenter(), + 50.0 + ); + + CellDataV2 targetCell = null; + for (CellDataV2 cell : cells) { + if (cell.hasPrisoner(captiveId)) { + targetCell = cell; + break; + } + } + + if (targetCell == null) { + buyer.sendSystemMessage( + Component.literal("Captive not found in camp.").withStyle( + ChatFormatting.RED + ) + ); + return; + } + + // Find captive - try Player first, then Entity (Damsel) + net.minecraft.world.entity.LivingEntity captiveEntity = null; + IRestrainable kidnappedState = null; + + ServerPlayer captivePlayer = level + .getServer() + .getPlayerList() + .getPlayer(captiveId); + if (captivePlayer != null) { + captiveEntity = captivePlayer; + kidnappedState = KidnappedHelper.getKidnappedState(captivePlayer); + } else { + // Try as Entity (Damsel) + net.minecraft.world.entity.Entity entity = level.getEntity( + captiveId + ); + if ( + entity instanceof net.minecraft.world.entity.LivingEntity living + ) { + captiveEntity = living; + kidnappedState = KidnappedHelper.getKidnappedState(living); + } + } + + if (kidnappedState == null || !kidnappedState.isForSell()) { + buyer.sendSystemMessage( + Component.literal("This captive is not for sale.").withStyle( + ChatFormatting.RED + ) + ); + return; + } + + // Get price + ItemTask price = kidnappedState.getSalePrice(); + if (price == null) { + buyer.sendSystemMessage( + Component.literal("Price not set for this captive.").withStyle( + ChatFormatting.RED + ) + ); + return; + } + + // Check if buyer has enough items + int required = price.getAmount(); + int available = countItemInInventory(buyer, price); + + if (available < required) { + buyer.sendSystemMessage( + Component.literal( + "You need " + + required + + "x " + + price.getItem().getDescription().getString() + + " (have " + + available + + ")" + ).withStyle(ChatFormatting.RED) + ); + return; + } + + // Take payment + removeItemFromInventory(buyer, price, required); + + // Mark captive as sold immediately (prevents re-purchase) + kidnappedState.cancelSale(); + + // Order maid to fetch and deliver the captive + com.tiedup.remake.entities.EntityMaid maid = trader.getMaid(); + if (maid != null && !maid.getMaidState().isBusy()) { + // Maid will fetch from cell, release, and deliver to buyer + maid.startFetchAndDeliver(captiveId, buyer.getUUID()); + + TiedUpMod.LOGGER.info( + "[PacketBuyCaptive] {} bought captive {} from trader {} for {} {} - maid delivering", + buyer.getName().getString(), + captiveId.toString().substring(0, 8), + trader.getNpcName(), + required, + price.getItem().getDescription().getString() + ); + + buyer.sendSystemMessage( + Component.literal( + "Purchase complete! The maid will deliver your captive." + ).withStyle(ChatFormatting.GREEN) + ); + } else { + // No maid available - release directly (fallback) + // Cancel sale state (already called above but defensive) + kidnappedState.cancelSale(); + + // Transfer collar ownership to buyer + net.minecraft.world.item.ItemStack collar = + kidnappedState.getEquipment(BodyRegionV2.NECK); + if ( + !collar.isEmpty() && + collar.getItem() instanceof + com.tiedup.remake.items.base.ItemCollar collarItem + ) { + // Remove all existing owners from collar NBT + for (UUID ownerId : new java.util.ArrayList<>( + collarItem.getOwners(collar) + )) { + collarItem.removeOwner(collar, ownerId); + } + // Add buyer as new owner + collarItem.addOwner( + collar, + buyer.getUUID(), + buyer.getName().getString() + ); + collarItem.setLocked(collar, false); + + // Re-apply modified collar to persist NBT changes + kidnappedState.equip(BodyRegionV2.NECK, collar); + + // Update CollarRegistry for SlaveManagementScreen + com.tiedup.remake.state.CollarRegistry collarRegistry = + com.tiedup.remake.state.CollarRegistry.get(level); + if (collarRegistry != null) { + // Remove all previous owners from registry + collarRegistry.unregisterWearer(captiveId); + // Register buyer as new owner + collarRegistry.registerCollar(captiveId, buyer.getUUID()); + } + + // Sync collar changes to client + if (captivePlayer != null) { + com.tiedup.remake.network.sync.SyncManager.syncBindState( + captivePlayer + ); + } + } + + // Release via PrisonerService (state transition + cell cleanup + restraint removal) + com.tiedup.remake.prison.service.PrisonerService.get().release( + level, + captiveId, + 6000L // 5 minutes grace period + ); + + TiedUpMod.LOGGER.info( + "[PacketBuyCaptive] {} bought captive {} from trader {} for {} {} - direct release (no maid)", + buyer.getName().getString(), + captiveId.toString().substring(0, 8), + trader.getNpcName(), + required, + price.getItem().getDescription().getString() + ); + + buyer.sendSystemMessage( + Component.translatable( + "tiedup.trader.purchase_success" + ).withStyle(ChatFormatting.GREEN) + ); + } + } + + private int countItemInInventory(ServerPlayer player, ItemTask task) { + if (task.getItem() == null) return 0; + + int count = 0; + for (int i = 0; i < player.getInventory().getContainerSize(); i++) { + ItemStack stack = player.getInventory().getItem(i); + if (!stack.isEmpty() && stack.getItem() == task.getItem()) { + count += stack.getCount(); + } + } + return count; + } + + private void removeItemFromInventory( + ServerPlayer player, + ItemTask task, + int amount + ) { + if (task.getItem() == null) return; + + int remaining = amount; + for ( + int i = 0; + i < player.getInventory().getContainerSize() && remaining > 0; + i++ + ) { + ItemStack stack = player.getInventory().getItem(i); + if (!stack.isEmpty() && stack.getItem() == task.getItem()) { + int toRemove = Math.min(stack.getCount(), remaining); + stack.shrink(toRemove); + remaining -= toRemove; + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/network/trader/PacketOpenTraderScreen.java b/src/main/java/com/tiedup/remake/network/trader/PacketOpenTraderScreen.java new file mode 100644 index 0000000..2117ef4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/network/trader/PacketOpenTraderScreen.java @@ -0,0 +1,136 @@ +package com.tiedup.remake.network.trader; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.base.AbstractClientPacket; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Packet to open the SlaveTrader trading screen on the client. + * + * Server -> Client + */ +public class PacketOpenTraderScreen extends AbstractClientPacket { + + private final int traderEntityId; + private final String traderName; + private final List offers; + + /** + * Network-serializable captive offer data. + */ + public static class CaptiveOfferData { + + public final UUID captiveId; + public final String captiveName; + public final String priceDescription; + public final int priceAmount; + public final String priceItemId; + + public CaptiveOfferData( + UUID captiveId, + String captiveName, + String priceDescription, + int priceAmount, + String priceItemId + ) { + this.captiveId = captiveId; + this.captiveName = captiveName; + this.priceDescription = priceDescription; + this.priceAmount = priceAmount; + this.priceItemId = priceItemId; + } + } + + public PacketOpenTraderScreen( + int traderEntityId, + String traderName, + List offers + ) { + this.traderEntityId = traderEntityId; + this.traderName = traderName; + this.offers = offers != null ? offers : new ArrayList<>(); + } + + public void encode(FriendlyByteBuf buf) { + buf.writeInt(traderEntityId); + buf.writeUtf(traderName, 64); + buf.writeInt(offers.size()); + + for (CaptiveOfferData offer : offers) { + buf.writeUUID(offer.captiveId); + buf.writeUtf(offer.captiveName, 64); + buf.writeUtf(offer.priceDescription, 128); + buf.writeInt(offer.priceAmount); + buf.writeUtf(offer.priceItemId, 128); + } + } + + public static PacketOpenTraderScreen decode(FriendlyByteBuf buf) { + int traderEntityId = buf.readInt(); + String traderName = buf.readUtf(64); + int offerCount = buf.readInt(); + + List offers = new ArrayList<>(); + for (int i = 0; i < offerCount; i++) { + UUID captiveId = buf.readUUID(); + String captiveName = buf.readUtf(64); + String priceDescription = buf.readUtf(128); + int priceAmount = buf.readInt(); + String priceItemId = buf.readUtf(128); + + offers.add( + new CaptiveOfferData( + captiveId, + captiveName, + priceDescription, + priceAmount, + priceItemId + ) + ); + } + + return new PacketOpenTraderScreen(traderEntityId, traderName, offers); + } + + @Override + @OnlyIn(Dist.CLIENT) + protected void handleClientImpl() { + ClientHandler.handle(this); + } + + @OnlyIn(Dist.CLIENT) + private static class ClientHandler { + private static void handle(PacketOpenTraderScreen pkt) { + net.minecraft.client.Minecraft mc = net.minecraft.client.Minecraft.getInstance(); + if (mc.player == null) return; + + // Convert network data to screen data + List screenOffers = new ArrayList<>(); + for (CaptiveOfferData data : pkt.offers) { + screenOffers.add( + new com.tiedup.remake.client.gui.screens.SlaveTraderScreen.CaptiveOffer( + data.captiveId, + data.captiveName, + data.priceDescription, + data.priceAmount, + data.priceItemId + ) + ); + } + + TiedUpMod.LOGGER.info( + "[PacketOpenTraderScreen] Opening trader screen with {} offers", + screenOffers.size() + ); + + mc.setScreen( + new com.tiedup.remake.client.gui.screens.SlaveTraderScreen(pkt.traderEntityId, pkt.traderName, screenOffers) + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/personality/CellQuality.java b/src/main/java/com/tiedup/remake/personality/CellQuality.java new file mode 100644 index 0000000..a84ef77 --- /dev/null +++ b/src/main/java/com/tiedup/remake/personality/CellQuality.java @@ -0,0 +1,134 @@ +package com.tiedup.remake.personality; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.v2.blocks.PetBedBlock; +import org.jetbrains.annotations.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LightLayer; +import net.minecraft.world.level.block.BedBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.CarpetBlock; + +/** + * Quality of the cell/environment around an NPC's home. + * + *

Evaluated in a radius around the home position and applies + * a mood modifier based on living conditions. + * + *

    + *
  • LUXURY: Bed + Carpet + Light > 10 → +5 mood
  • + *
  • STANDARD: PetBed OR Bed → +0 mood
  • + *
  • DUNGEON: No bed + Light < 5 → -5 mood
  • + *
+ */ +public enum CellQuality { + /** Bed + Carpet + Light > 10: good living conditions */ + LUXURY(5.0f), + + /** PetBed OR Bed: normal conditions */ + STANDARD(0.0f), + + /** No bed + Light < 5: harsh conditions */ + DUNGEON(-5.0f); + + /** Mood modifier applied when NPC lives in this quality cell */ + public final float moodModifier; + + CellQuality(float moodModifier) { + this.moodModifier = moodModifier; + } + + /** Detection radius around home (5 blocks) */ + public static final int DETECTION_RADIUS = 5; + + /** Light level threshold for LUXURY (> 10) */ + public static final int LIGHT_LUXURY_THRESHOLD = 10; + + /** Light level threshold for DUNGEON (< 5) */ + public static final int LIGHT_DUNGEON_THRESHOLD = 5; + + /** + * Evaluate cell quality around a home position, optionally using CellDataV2 features. + * + *

When a cell is provided, bed/pet bed detection uses pre-computed flood-fill data + * instead of scanning blocks. Carpets still require a block scan since they are not + * tracked by the flood-fill. + * + * @param homePos The home block position + * @param level The world level + * @param cell Optional CellDataV2 for optimized feature detection + * @return Evaluated cell quality + */ + public static CellQuality evaluate( + BlockPos homePos, + Level level, + @Nullable CellDataV2 cell + ) { + if (homePos == null || level == null) return STANDARD; + + boolean hasBed = false; + boolean hasPetBed = false; + boolean hasCarpet = false; + + if (cell != null) { + // Use cell features (already detected by flood-fill) + hasBed = !cell.getBeds().isEmpty(); + hasPetBed = !cell.getPetBeds().isEmpty(); + // Carpets not tracked by flood-fill, still need scan + for (BlockPos pos : BlockPos.betweenClosed( + homePos.offset(-DETECTION_RADIUS, -1, -DETECTION_RADIUS), + homePos.offset(DETECTION_RADIUS, 0, DETECTION_RADIUS) + )) { + if ( + level.getBlockState(pos).getBlock() instanceof CarpetBlock + ) { + hasCarpet = true; + break; + } + } + } else { + // Fallback: full environment scan (no cell link) + for (BlockPos pos : BlockPos.betweenClosed( + homePos.offset(-DETECTION_RADIUS, -1, -DETECTION_RADIUS), + homePos.offset(DETECTION_RADIUS, 2, DETECTION_RADIUS) + )) { + Block block = level.getBlockState(pos).getBlock(); + + if (block instanceof BedBlock) hasBed = true; + if (block instanceof PetBedBlock) hasPetBed = true; + if (block instanceof CarpetBlock) hasCarpet = true; + + // Early exit if we found all luxury requirements + if (hasBed && hasCarpet) break; + } + } + + // Get light level at home position + int lightLevel = level.getBrightness(LightLayer.BLOCK, homePos); + + // LUXURY: Bed + Carpet + Light > 10 + if (hasBed && hasCarpet && lightLevel > LIGHT_LUXURY_THRESHOLD) { + return LUXURY; + } + + // DUNGEON: No bed/pet bed + Light < 5 + if (!hasBed && !hasPetBed && lightLevel < LIGHT_DUNGEON_THRESHOLD) { + return DUNGEON; + } + + // STANDARD: Bed or PetBed (or medium light) + return STANDARD; + } + + /** + * Evaluate cell quality around a home position. + * + * @param homePos The home block position + * @param level The world level + * @return Evaluated cell quality + */ + public static CellQuality evaluate(BlockPos homePos, Level level) { + return evaluate(homePos, level, null); + } +} diff --git a/src/main/java/com/tiedup/remake/personality/DisciplineType.java b/src/main/java/com/tiedup/remake/personality/DisciplineType.java new file mode 100644 index 0000000..43b35dc --- /dev/null +++ b/src/main/java/com/tiedup/remake/personality/DisciplineType.java @@ -0,0 +1,49 @@ +package com.tiedup.remake.personality; + +/** + * Types of discipline that can be applied to NPCs. + * Only affects mood — no relationship, fear, willpower, or resentment effects. + */ +public enum DisciplineType { + WHIP(-15), + PADDLE(-5), + PRAISE(10), + SHOCK(-20), + HAND(-3), + SCOLD(-5), + THREATEN(-8); + + /** Mood modifier applied to personality state. */ + public final int moodChange; + + DisciplineType(int moodChange) { + this.moodChange = moodChange; + } + + /** + * Check if this discipline type is punishment (causes pain/fear). + * + * @return True for all punishment types, false for PRAISE + */ + public boolean isPunishment() { + return this != PRAISE; + } + + /** + * Check if this discipline is verbal (no physical contact). + * + * @return True for PRAISE, SCOLD, THREATEN + */ + public boolean isVerbal() { + return this == PRAISE || this == SCOLD || this == THREATEN; + } + + /** + * Get the dialogue key for this discipline type. + * + * @return Key like "action.whip", "action.paddle", "action.praise" + */ + public String getDialogueKey() { + return "action." + name().toLowerCase(); + } +} diff --git a/src/main/java/com/tiedup/remake/personality/HomeType.java b/src/main/java/com/tiedup/remake/personality/HomeType.java new file mode 100644 index 0000000..5b629f4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/personality/HomeType.java @@ -0,0 +1,40 @@ +package com.tiedup.remake.personality; + +/** + * Types of homes that can be assigned to NPCs. + * + *

Home types determine comfort level, which affects: + *

    + *
  • Hope regeneration (higher comfort = more hope)
  • + *
  • Cell quality evaluation (Phase 6)
  • + *
  • Mental state decay rates
  • + *
+ */ +public enum HomeType { + /** No home assigned - NPC sleeps anywhere */ + NONE(0), + + /** Cell with spawn point only - No comfort, but valid home */ + CELL(0), + + /** Pet bed block - Low comfort, for SUBJUGATED slaves */ + PET_BED(1), + + /** Standard Minecraft bed - High comfort, for trusted/DEVOTED */ + BED(2); + + /** Comfort level (0 = none, 1 = low, 2 = high) */ + public final int comfortLevel; + + HomeType(int comfortLevel) { + this.comfortLevel = comfortLevel; + } + + /** + * Check if this home type provides any comfort. + * @return true if comfort level > 0 + */ + public boolean hasComfort() { + return comfortLevel > 0; + } +} diff --git a/src/main/java/com/tiedup/remake/personality/JobExperience.java b/src/main/java/com/tiedup/remake/personality/JobExperience.java new file mode 100644 index 0000000..08dd200 --- /dev/null +++ b/src/main/java/com/tiedup/remake/personality/JobExperience.java @@ -0,0 +1,140 @@ +package com.tiedup.remake.personality; + +import java.util.EnumMap; +import java.util.Map; +import net.minecraft.nbt.CompoundTag; + +/** + * Tracks job experience per NpcCommand type. + * Experience accumulates as NPCs perform jobs and unlocks efficiency/yield bonuses. + * Experience accumulates per command type with proficiency tiers. + */ +public class JobExperience { + + /** + * Job proficiency levels based on accumulated experience. + */ + public enum JobLevel { + NOVICE(0, 9, 1.0f, 1.0f, 0), + APPRENTICE(10, 24, 1.1f, 1.05f, 1), + SKILLED(25, 49, 1.25f, 1.15f, 2), + EXPERT(50, Integer.MAX_VALUE, 1.5f, 1.3f, 4); + + public final int minExp; + public final int maxExp; + /** Speed multiplier for job actions */ + public final float speedMultiplier; + /** Yield multiplier for job outputs */ + public final float yieldMultiplier; + /** Extra work radius bonus (blocks) */ + public final int rangeBonus; + + JobLevel( + int minExp, + int maxExp, + float speedMultiplier, + float yieldMultiplier, + int rangeBonus + ) { + this.minExp = minExp; + this.maxExp = maxExp; + this.speedMultiplier = speedMultiplier; + this.yieldMultiplier = yieldMultiplier; + this.rangeBonus = rangeBonus; + } + + /** + * Get the job level for a given experience value. + */ + public static JobLevel fromExperience(int exp) { + if (exp >= EXPERT.minExp) return EXPERT; + if (exp >= SKILLED.minExp) return SKILLED; + if (exp >= APPRENTICE.minExp) return APPRENTICE; + return NOVICE; + } + } + + /** Experience count per job command type. */ + private final Map jobExperience = new EnumMap<>( + NpcCommand.class + ); + + /** + * Get experience for a specific job command. + * + * @param command The job command + * @return Experience count (0 if never performed) + */ + public int getExperience(NpcCommand command) { + return jobExperience.getOrDefault(command, 0); + } + + /** + * Get the job level for a specific command. + * + * @param command The job command + * @return Current JobLevel based on experience + */ + public JobLevel getJobLevel(NpcCommand command) { + return JobLevel.fromExperience(getExperience(command)); + } + + /** + * Add experience for a job command (+1). + * + * @param command The job command + */ + public void addExperience(NpcCommand command) { + jobExperience.merge(command, 1, Integer::sum); + } + + /** + * Get the speed multiplier for a job command based on experience. + * + * @param command The job command + * @return Speed multiplier (1.0 to 1.5) + */ + public float getSpeedMultiplier(NpcCommand command) { + return getJobLevel(command).speedMultiplier; + } + + /** + * Get the yield multiplier for a job command based on experience. + * + * @param command The job command + * @return Yield multiplier (1.0 to 1.3) + */ + public float getYieldMultiplier(NpcCommand command) { + return getJobLevel(command).yieldMultiplier; + } + + /** + * Get the range bonus for a job command based on experience. + * + * @param command The job command + * @return Extra radius in blocks (0 to 4) + */ + public int getRangeBonus(NpcCommand command) { + return getJobLevel(command).rangeBonus; + } + + // --- NBT Persistence --- + + public CompoundTag save() { + CompoundTag tag = new CompoundTag(); + for (Map.Entry entry : jobExperience.entrySet()) { + tag.putInt(entry.getKey().name(), entry.getValue()); + } + return tag; + } + + public static JobExperience load(CompoundTag tag) { + JobExperience exp = new JobExperience(); + for (NpcCommand cmd : NpcCommand.values()) { + if (tag.contains(cmd.name())) { + exp.jobExperience.put(cmd, tag.getInt(cmd.name())); + } + } + return exp; + } +} diff --git a/src/main/java/com/tiedup/remake/personality/JobPersonalityModifiers.java b/src/main/java/com/tiedup/remake/personality/JobPersonalityModifiers.java new file mode 100644 index 0000000..afa41d8 --- /dev/null +++ b/src/main/java/com/tiedup/remake/personality/JobPersonalityModifiers.java @@ -0,0 +1,183 @@ +package com.tiedup.remake.personality; + +import java.util.EnumMap; +import java.util.Map; + +/** + * Centralized lookup table for personality ↔ job interactions. + * Avoids scattered switch statements in each goal. + */ +public final class JobPersonalityModifiers { + + private JobPersonalityModifiers() {} + + /** + * Per-personality job modifier entry. + */ + private record JobModEntry( + float efficiencyMod, + NpcCommand preferredJob, + NpcCommand dislikedJob, + float moodModifier + ) {} + + /** + * Map of (PersonalityType, NpcCommand) → efficiency modifier. + * Only non-default entries are stored; default is 1.0. + */ + private static final Map< + PersonalityType, + Map + > EFFICIENCY_TABLE = new EnumMap<>(PersonalityType.class); + + /** Preferred jobs per personality */ + private static final Map PREFERRED_JOBS = + new EnumMap<>(PersonalityType.class); + + /** Disliked jobs per personality */ + private static final Map DISLIKED_JOBS = + new EnumMap<>(PersonalityType.class); + + static { + // GENTLE: Good at breeding/farming, bad at mining + putEfficiency(PersonalityType.GENTLE, NpcCommand.BREED, 1.3f); + putEfficiency(PersonalityType.GENTLE, NpcCommand.FARM, 1.2f); + putEfficiency(PersonalityType.GENTLE, NpcCommand.MINE, 0.7f); + PREFERRED_JOBS.put(PersonalityType.GENTLE, NpcCommand.BREED); + DISLIKED_JOBS.put(PersonalityType.GENTLE, NpcCommand.MINE); + + // FIERCE: Good at guarding/mining, bad at cooking + putEfficiency(PersonalityType.FIERCE, NpcCommand.GUARD, 1.3f); + putEfficiency(PersonalityType.FIERCE, NpcCommand.MINE, 1.2f); + putEfficiency(PersonalityType.FIERCE, NpcCommand.COOK, 0.7f); + PREFERRED_JOBS.put(PersonalityType.FIERCE, NpcCommand.GUARD); + DISLIKED_JOBS.put(PersonalityType.FIERCE, NpcCommand.COOK); + + // CURIOUS: Good at collecting, bad at patrol + putEfficiency(PersonalityType.CURIOUS, NpcCommand.COLLECT, 1.3f); + putEfficiency(PersonalityType.CURIOUS, NpcCommand.PATROL, 0.8f); + PREFERRED_JOBS.put(PersonalityType.CURIOUS, NpcCommand.COLLECT); + DISLIKED_JOBS.put(PersonalityType.CURIOUS, NpcCommand.PATROL); + + // PLAYFUL: Good at breeding/collecting, bad at sorting + putEfficiency(PersonalityType.PLAYFUL, NpcCommand.BREED, 1.2f); + putEfficiency(PersonalityType.PLAYFUL, NpcCommand.COLLECT, 1.1f); + putEfficiency(PersonalityType.PLAYFUL, NpcCommand.SORT, 0.8f); + PREFERRED_JOBS.put(PersonalityType.PLAYFUL, NpcCommand.BREED); + DISLIKED_JOBS.put(PersonalityType.PLAYFUL, NpcCommand.SORT); + + // PROUD: Good at guarding/patrolling, bad at collecting + putEfficiency(PersonalityType.PROUD, NpcCommand.GUARD, 1.2f); + putEfficiency(PersonalityType.PROUD, NpcCommand.PATROL, 1.1f); + putEfficiency(PersonalityType.PROUD, NpcCommand.COLLECT, 0.7f); + PREFERRED_JOBS.put(PersonalityType.PROUD, NpcCommand.GUARD); + DISLIKED_JOBS.put(PersonalityType.PROUD, NpcCommand.COLLECT); + + // TIMID: Good at fishing (peaceful), bad at guarding + putEfficiency(PersonalityType.TIMID, NpcCommand.FISH, 1.2f); + putEfficiency(PersonalityType.TIMID, NpcCommand.FARM, 1.1f); + putEfficiency(PersonalityType.TIMID, NpcCommand.GUARD, 0.7f); + PREFERRED_JOBS.put(PersonalityType.TIMID, NpcCommand.FISH); + DISLIKED_JOBS.put(PersonalityType.TIMID, NpcCommand.GUARD); + + // SUBMISSIVE: Good at all jobs (obedient), slight preference for sorting + putEfficiency(PersonalityType.SUBMISSIVE, NpcCommand.SORT, 1.2f); + putEfficiency(PersonalityType.SUBMISSIVE, NpcCommand.TRANSFER, 1.1f); + PREFERRED_JOBS.put(PersonalityType.SUBMISSIVE, NpcCommand.SORT); + DISLIKED_JOBS.put(PersonalityType.SUBMISSIVE, NpcCommand.MINE); + + // CALM: Balanced, slight cooking bonus + putEfficiency(PersonalityType.CALM, NpcCommand.COOK, 1.1f); + putEfficiency(PersonalityType.CALM, NpcCommand.FARM, 1.1f); + PREFERRED_JOBS.put(PersonalityType.CALM, NpcCommand.COOK); + DISLIKED_JOBS.put(PersonalityType.CALM, NpcCommand.MINE); + + // DEFIANT: Bad at most jobs (resistant), decent at mining (physical outlet) + putEfficiency(PersonalityType.DEFIANT, NpcCommand.MINE, 1.1f); + putEfficiency(PersonalityType.DEFIANT, NpcCommand.FARM, 0.7f); + putEfficiency(PersonalityType.DEFIANT, NpcCommand.SORT, 0.6f); + PREFERRED_JOBS.put(PersonalityType.DEFIANT, NpcCommand.MINE); + DISLIKED_JOBS.put(PersonalityType.DEFIANT, NpcCommand.SORT); + + // MASOCHIST: Good at hard labor + putEfficiency(PersonalityType.MASOCHIST, NpcCommand.MINE, 1.2f); + putEfficiency(PersonalityType.MASOCHIST, NpcCommand.FARM, 1.1f); + PREFERRED_JOBS.put(PersonalityType.MASOCHIST, NpcCommand.MINE); + DISLIKED_JOBS.put(PersonalityType.MASOCHIST, NpcCommand.FISH); + + // SADIST: Good at guarding (intimidation), bad at breeding (no patience) + putEfficiency(PersonalityType.SADIST, NpcCommand.GUARD, 1.3f); + putEfficiency(PersonalityType.SADIST, NpcCommand.BREED, 0.7f); + PREFERRED_JOBS.put(PersonalityType.SADIST, NpcCommand.GUARD); + DISLIKED_JOBS.put(PersonalityType.SADIST, NpcCommand.BREED); + } + + private static void putEfficiency( + PersonalityType type, + NpcCommand cmd, + float mod + ) { + EFFICIENCY_TABLE.computeIfAbsent(type, k -> + new EnumMap<>(NpcCommand.class) + ).put(cmd, mod); + } + + /** + * Get the efficiency modifier for a personality performing a specific job. + * + * @param personality NPC personality type + * @param command Job command being performed + * @return Efficiency multiplier (0.6 to 1.3, default 1.0) + */ + public static float getEfficiencyModifier( + PersonalityType personality, + NpcCommand command + ) { + Map personalityMods = EFFICIENCY_TABLE.get( + personality + ); + if (personalityMods == null) return 1.0f; + return personalityMods.getOrDefault(command, 1.0f); + } + + /** + * Get the preferred job for a personality type. + * + * @param personality NPC personality type + * @return Preferred NpcCommand, or FARM as default + */ + public static NpcCommand getPreferredJob(PersonalityType personality) { + return PREFERRED_JOBS.getOrDefault(personality, NpcCommand.FARM); + } + + /** + * Get the disliked job for a personality type. + * + * @param personality NPC personality type + * @return Disliked NpcCommand, or NONE as default + */ + public static NpcCommand getDislikedJob(PersonalityType personality) { + return DISLIKED_JOBS.getOrDefault(personality, NpcCommand.NONE); + } + + /** + * Get the mood modifier for a personality performing a specific job. + * Positive if preferred, negative if disliked, 0 otherwise. + * + * @param personality NPC personality type + * @param command Job command being performed + * @return Mood modifier (-0.1 to +0.1) + */ + public static float getJobMoodModifier( + PersonalityType personality, + NpcCommand command + ) { + if (command == getPreferredJob(personality)) { + return 0.1f; + } + if (command == getDislikedJob(personality)) { + return -0.1f; + } + return 0.0f; + } +} diff --git a/src/main/java/com/tiedup/remake/personality/NpcCommand.java b/src/main/java/com/tiedup/remake/personality/NpcCommand.java new file mode 100644 index 0000000..19678e3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/personality/NpcCommand.java @@ -0,0 +1,242 @@ +package com.tiedup.remake.personality; + +import org.jetbrains.annotations.Nullable; + +/** + * Commands that can be given to NPCs with collar (collar required). + * All commands are always available - no training tier restrictions. + */ +public enum NpcCommand { + /** No active command */ + NONE(CommandType.INSTANT, 1.0f), + + // --- Basic Commands --- + /** Follow the master */ + FOLLOW(CommandType.CONTINUOUS, 0.75f), + + /** Stay in current position */ + STAY(CommandType.CONTINUOUS, 0.80f), + + /** Come to the master */ + COME(CommandType.INSTANT, 0.75f), + + /** Rest in place, speak idle dialogue */ + IDLE(CommandType.CONTINUOUS, 0.90f), + + /** Go to assigned home position */ + GO_HOME(CommandType.INSTANT, 0.85f), + + // --- Pose Commands --- + /** Sit down */ + SIT(CommandType.INSTANT, 0.70f), + + // HEEL removed - now a FollowDistance mode for FOLLOW command + + /** Kneel position */ + KNEEL(CommandType.INSTANT, 0.60f), + + // --- Job Commands --- + /** Patrol a defined zone */ + PATROL(CommandType.JOB, 0.65f), + + /** Guard a zone, alert on intruders */ + GUARD(CommandType.JOB, 0.60f), + + /** Pick up an item */ + FETCH(CommandType.INSTANT, 0.55f), + + /** Collect all items in a zone */ + COLLECT(CommandType.JOB, 0.50f), + + // --- Work Commands --- + /** Farm crops in zone around chest hub */ + FARM(CommandType.JOB, 0.55f), + + /** Cook items using furnace, resources from chest hub */ + COOK(CommandType.JOB, 0.50f), + + /** Transfer items from chest A to chest B */ + TRANSFER(CommandType.JOB, 0.60f), + + /** Shear sheep in zone (requires shears in hand) */ + SHEAR(CommandType.JOB, 0.55f), + + /** Mine blocks in zone (requires pickaxe in hand) */ + MINE(CommandType.JOB, 0.50f), + + /** Breed animals in zone using food from chest hub */ + BREED(CommandType.JOB, 0.60f), + + /** Fish near water using simulated fishing */ + FISH(CommandType.JOB, 0.65f), + + /** Sort items between source and destination chests by category */ + SORT(CommandType.JOB, 0.60f); + + // NOTE: Combat commands (ATTACK, DEFEND, CAPTURE) have been removed. + // Combat behavior is now determined by the item in the NPC's main hand + // during FOLLOW command. See ToolMode enum and NpcFollowCommandGoal. + + /** Type of command execution */ + public enum CommandType { + /** Executes once and completes */ + INSTANT, + /** Continues until cancelled */ + CONTINUOUS, + /** Has progress and completion state */ + JOB, + } + + /** + * Distance modes for FOLLOW command. + * Allows configuring how closely the NPC follows. + */ + public enum FollowDistance { + /** Far following (6-10 blocks) - original FOLLOW behavior */ + FAR(2.0, 6.0, 10, 1200, 2, false), + /** Close following (2-4 blocks) - moderate distance */ + CLOSE(1.5, 4.0, 8, 1000, 2, false), + /** Heel position (1-2 blocks) - stays behind master */ + HEEL(0.8, 2.0, 5, 800, 3, true); + + /** Minimum distance to keep from master */ + public final double minDistance; + /** Distance at which to start following */ + public final double startDistance; + /** Ticks between path recalculations */ + public final int pathRecalcInterval; + /** Ticks between XP awards */ + public final int xpInterval; + /** XP awarded per interval */ + public final int xpAmount; + /** Whether to follow behind master (heel behavior) */ + public final boolean followBehind; + + FollowDistance( + double minDistance, + double startDistance, + int pathRecalcInterval, + int xpInterval, + int xpAmount, + boolean followBehind + ) { + this.minDistance = minDistance; + this.startDistance = startDistance; + this.pathRecalcInterval = pathRecalcInterval; + this.xpInterval = xpInterval; + this.xpAmount = xpAmount; + this.followBehind = followBehind; + } + + /** + * Get translation key for this distance mode. + */ + public String getTranslationKey() { + return "command.follow.distance." + this.name().toLowerCase(); + } + } + + /** Command execution type */ + public final CommandType type; + + /** Base success chance (before modifiers) */ + public final float baseSuccessChance; + + NpcCommand(CommandType type, float baseSuccessChance) { + this.type = type; + this.baseSuccessChance = baseSuccessChance; + } + + /** + * Check if this command requires a target position. + * + * @return true if command needs BlockPos + */ + public boolean requiresPosition() { + return ( + this == PATROL || + this == GUARD || + this == COLLECT || + this == FARM || + this == COOK || + this == TRANSFER || + this == SHEAR || + this == BREED || + this == FISH || + this == SORT + ); + } + + /** + * Check if this command is a work command (uses chest-hub system). + * + * @return true if this is a work command + */ + public boolean isWorkCommand() { + return ( + this == FARM || + this == COOK || + this == TRANSFER || + this == SHEAR || + this == BREED || + this == FISH || + this == SORT + ); + } + + /** + * Check if this command is an active job that consumes rest. + * Jobs that involve physical labor drain rest over time. + * + * @return true if this command drains rest + */ + public boolean isActiveJob() { + return this.type == CommandType.JOB; + } + + /** + * Check if this command requires a target entity. + * + * @return true if command needs entity target + */ + public boolean requiresEntityTarget() { + return this == FETCH; + } + + /** + * Get XP reward for successful completion. + * + * @return XP amount (1-10) + */ + public int getCompletionXP() { + return switch (this.type) { + case INSTANT -> 2; + case CONTINUOUS -> 2; + case JOB -> 5; + }; + } + + /** + * Get localization key for this command. + * + * @return Translation key + */ + public String getTranslationKey() { + return "command." + this.name().toLowerCase(); + } + + /** + * Parse command from string (case-insensitive). + * + * @param name Command name + * @return NpcCommand or NONE if not found + */ + public static NpcCommand fromString(@Nullable String name) { + if (name == null || name.isEmpty()) return NONE; + try { + return valueOf(name.toUpperCase()); + } catch (IllegalArgumentException e) { + return NONE; + } + } +} diff --git a/src/main/java/com/tiedup/remake/personality/NpcNeeds.java b/src/main/java/com/tiedup/remake/personality/NpcNeeds.java new file mode 100644 index 0000000..414476b --- /dev/null +++ b/src/main/java/com/tiedup/remake/personality/NpcNeeds.java @@ -0,0 +1,321 @@ +package com.tiedup.remake.personality; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.entity.LivingEntity; + +/** + * Manages the 2 basic needs of an NPC: HUNGER and REST. + * Needs decay over time and affect mood and behavior. + * + * REST replaces the old DIGNITY system: + * - Decreases during active work (jobs) + * - Increases when idle, sitting, or sleeping + * - Affects job efficiency, movement speed, and attack damage + */ +public class NpcNeeds { + + /** Maximum value for any need */ + public static final float MAX_VALUE = 100.0f; + + /** Threshold for "low" state (tired/hungry) */ + public static final float LOW_THRESHOLD = 30.0f; + + /** Threshold for "critical" state (exhausted/starving) */ + public static final float CRITICAL_THRESHOLD = 10.0f; + + /** Threshold for "well rested" bonus */ + public static final float RESTED_THRESHOLD = 70.0f; + + // Current need values (0-100) + private float hunger = MAX_VALUE; + private float rest = MAX_VALUE; + + // Previous tick states for transition detection + private boolean wasHungry = false; + private boolean wasStarving = false; + private boolean wasTired = false; + private boolean wasExhausted = false; + + // Decay/recovery rates per tick (20 ticks = 1 second) + private static final float HUNGER_DECAY = 0.001f; // ~8.3 min to empty + + // Rest drain during work + private static final float REST_DRAIN_WORK = 0.002f; // ~8.3 min of work to empty + + // Rest recovery rates (per tick, 20 ticks/sec) + private static final float REST_RECOVERY_SLEEPING = 0.028f; // ~3 min from 0 to 100 (in bed) + private static final float REST_RECOVERY_SITTING = 0.01f; // ~8 min from 0 to 100 (in pet bed/home) + private static final float REST_RECOVERY_IDLE = 0.0025f; // ~33 min from 0 to 100 (standing idle) + + // --- Getters --- + + public float getHunger() { + return hunger; + } + + public float getRest() { + return rest; + } + + // --- State checks --- + + public boolean isHungry() { + return hunger < LOW_THRESHOLD; + } + + public boolean isStarving() { + return hunger < CRITICAL_THRESHOLD; + } + + public boolean isTired() { + return rest < LOW_THRESHOLD; + } + + public boolean isExhausted() { + return rest < CRITICAL_THRESHOLD; + } + + public boolean isRested() { + return rest >= RESTED_THRESHOLD; + } + + // --- Modification --- + + public void setHunger(float value) { + this.hunger = clamp(value); + } + + public void setRest(float value) { + this.rest = clamp(value); + } + + public void modifyHunger(float delta) { + this.hunger = clamp(this.hunger + delta); + } + + public void modifyRest(float delta) { + this.rest = clamp(this.rest + delta); + } + + /** + * Drain rest during active work. + * @param workIntensity Multiplier for drain rate (1.0 = normal) + */ + public void drainFromWork(float workIntensity) { + this.rest = clamp(this.rest - REST_DRAIN_WORK * workIntensity); + } + + /** + * Recover rest while resting. + * @param restType Type of rest (SLEEPING, SITTING, or IDLE) + */ + public void recoverRest(RestType restType) { + float recovery = switch (restType) { + case SLEEPING -> REST_RECOVERY_SLEEPING; + case SITTING -> REST_RECOVERY_SITTING; + case IDLE -> REST_RECOVERY_IDLE; + }; + this.rest = clamp(this.rest + recovery); + } + + /** + * Feed the NPC. + * + * @param nutritionValue How much hunger to restore (1-20 typically) + */ + public void feed(float nutritionValue) { + modifyHunger(nutritionValue * 5); // Food items restore 5x their value + } + + // --- Efficiency Modifiers --- + + /** + * Get job efficiency modifier based on rest level. + * @return 0.4 (exhausted) to 1.0 (normal/rested) + */ + public float getEfficiencyModifier() { + if (isExhausted()) return 0.4f; + if (isTired()) return 0.7f; + return 1.0f; + } + + /** + * Get movement speed modifier based on rest level. + * @return 0.6 (exhausted) to 1.0 (normal/rested) + */ + public float getSpeedModifier() { + if (isExhausted()) return 0.6f; + if (isTired()) return 0.8f; + return 1.0f; + } + + /** + * Get attack damage modifier based on rest level. + * @return 0.7 (exhausted) to 1.0 (normal/rested) + */ + public float getDamageModifier() { + if (isExhausted()) return 0.7f; + if (isTired()) return 0.9f; + return 1.0f; + } + + // --- Tick update --- + + /** Shared empty transitions instance to avoid allocation */ + private static final NeedTransitions EMPTY_TRANSITIONS = + new NeedTransitions(); + + /** + * Update needs based on current state. Called every tick. + * + * @param entity The entity to check state from + * @param personality The personality (unused, kept for API compat) + * @param isWorking Whether the NPC is currently doing an active job + * @return NeedTransitions containing any threshold crossings this tick + */ + public NeedTransitions tick( + LivingEntity entity, + PersonalityType personality, + boolean isWorking, + RestType restType + ) { + // Hunger always decays + hunger = clamp(hunger - HUNGER_DECAY); + + // Rest logic: drain when working, recover based on rest type + if (isWorking) { + rest = clamp(rest - REST_DRAIN_WORK); + } else { + // Recovery rate depends on rest type (BED=SLEEPING, PET_BED=SITTING, else=IDLE) + recoverRest(restType); + } + + // Detect transitions (threshold crossings) + boolean transitionOccurred = false; + NeedTransitions transitions = null; + + // Hunger transitions + if (isHungry() && !wasHungry) { + if (transitions == null) transitions = new NeedTransitions(); + transitions.becameHungry = true; + transitionOccurred = true; + } + if (isStarving() && !wasStarving) { + if (transitions == null) transitions = new NeedTransitions(); + transitions.becameStarving = true; + transitionOccurred = true; + } + + // Rest transitions + if (isTired() && !wasTired) { + if (transitions == null) transitions = new NeedTransitions(); + transitions.becameTired = true; + transitionOccurred = true; + } + if (isExhausted() && !wasExhausted) { + if (transitions == null) transitions = new NeedTransitions(); + transitions.becameExhausted = true; + transitionOccurred = true; + } + + // Update previous states for next tick + wasHungry = isHungry(); + wasStarving = isStarving(); + wasTired = isTired(); + wasExhausted = isExhausted(); + + return transitionOccurred ? transitions : EMPTY_TRANSITIONS; + } + + /** + * Calculate overall mood impact from needs. + * + * @return Mood modifier (-40 to +5) + */ + public float getMoodImpact() { + float impact = 0; + + // Hunger impact + if (isStarving()) impact -= 20; + else if (isHungry()) impact -= 10; + else if (hunger > 80) impact += 5; + + // Rest impact + if (isExhausted()) impact -= 20; + else if (isTired()) impact -= 10; + else if (isRested()) impact += 5; + + return impact; + } + + /** + * Get the most critical need (lowest value). + * + * @return The need type that's lowest + */ + public NeedType getMostCriticalNeed() { + if (rest < hunger) { + return NeedType.REST; + } + return NeedType.HUNGER; + } + + public enum NeedType { + HUNGER, + REST, + } + + public enum RestType { + SLEEPING, // In bed - fastest recovery + SITTING, // In pet bed/home - medium recovery + IDLE, // Standing idle - slow recovery + } + + /** + * Holds information about need state transitions that occurred this tick. + * Used to trigger dialogues when needs cross thresholds. + */ + public static class NeedTransitions { + + public boolean becameHungry = false; + public boolean becameStarving = false; + public boolean becameTired = false; + public boolean becameExhausted = false; + + /** Returns true if any transition occurred */ + public boolean hasAny() { + return ( + becameHungry || becameStarving || becameTired || becameExhausted + ); + } + } + + // --- NBT Persistence --- + + public CompoundTag save() { + CompoundTag tag = new CompoundTag(); + tag.putFloat("Hunger", hunger); + tag.putFloat("Rest", rest); + return tag; + } + + public static NpcNeeds load(CompoundTag tag) { + NpcNeeds needs = new NpcNeeds(); + if (tag.contains("Hunger")) needs.hunger = clamp( + tag.getFloat("Hunger") + ); + // Support both new "Rest" and legacy "Dignity" keys + if (tag.contains("Rest")) { + needs.rest = clamp(tag.getFloat("Rest")); + } else if (tag.contains("Dignity")) { + needs.rest = clamp(tag.getFloat("Dignity")); + } + return needs; + } + + // --- Utility --- + + private static float clamp(float value) { + return Math.max(0, Math.min(MAX_VALUE, value)); + } +} diff --git a/src/main/java/com/tiedup/remake/personality/PersonalityState.java b/src/main/java/com/tiedup/remake/personality/PersonalityState.java new file mode 100644 index 0000000..e867674 --- /dev/null +++ b/src/main/java/com/tiedup/remake/personality/PersonalityState.java @@ -0,0 +1,709 @@ +package com.tiedup.remake.personality; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.entities.EntityDamsel; +import java.util.List; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerLevel; + +/** + * Main personality state manager for an NPC. + * Contains personality type, needs, mood, command state, and job experience. + * + * NPCs always obey commands when captured — no refusal, no relationship checks. + */ +public class PersonalityState { + + // --- Core personality (immutable after generation) --- + private final PersonalityType personality; + + // --- Needs --- + private final NpcNeeds needs = new NpcNeeds(); + + // --- Current command state --- + private NpcCommand activeCommand = NpcCommand.NONE; + + /** Current follow distance (for FOLLOW command) */ + private NpcCommand.FollowDistance followDistance = + NpcCommand.FollowDistance.FAR; + + @Nullable + private UUID commandingPlayer = null; + + @Nullable + private BlockPos commandTarget = null; + + /** Secondary command target (for TRANSFER: destination chest) */ + @Nullable + private BlockPos commandTarget2 = null; + + private int commandProgress = 0; + + // --- Struggle state --- + private int struggleTimer = 0; + private int consecutiveStruggleFails = 0; + + // --- Mood (calculated from needs) --- + private float mood = 50.0f; // 0-100, 50 is neutral + + // --- Name state --- + private boolean hasBeenNamed = false; + + // --- Home State --- + @Nullable + private BlockPos homePos = null; + + private HomeType homeType = HomeType.NONE; + + @Nullable + private UUID cellId = null; + + // --- Cell Quality --- + private CellQuality cellQuality = CellQuality.STANDARD; + + // --- Cell Navigation --- + @Nullable + private BlockPos cellDeliveryPoint = null; + + // --- Auto-Rest Setting --- + /** Whether the NPC will automatically go rest when tired */ + private boolean autoRestEnabled = true; + + // --- Job Experience --- + /** Persistent job experience tracking */ + private JobExperience jobExperience = new JobExperience(); + + // --- Constructor --- + + public PersonalityState(PersonalityType personality) { + this.personality = personality; + resetStruggleTimer(); + } + + // --- Factory methods --- + + /** + * Generate a random personality state for a Damsel. + * + * @param entityUUID Entity UUID for seeding + * @return New PersonalityState + */ + public static PersonalityState generateForDamsel(UUID entityUUID) { + PersonalityType type = PersonalityType.randomForDamsel(); + return new PersonalityState(type); + } + + /** + * Generate a random personality state for a Kidnapper. + * + * @param entityUUID Entity UUID for seeding + * @return New PersonalityState + */ + public static PersonalityState generateForKidnapper(UUID entityUUID) { + PersonalityType type = PersonalityType.randomForKidnapper(); + return new PersonalityState(type); + } + + // --- Getters --- + + public PersonalityType getPersonality() { + return personality; + } + + public NpcNeeds getNeeds() { + return needs; + } + + public NpcCommand getActiveCommand() { + return activeCommand; + } + + public NpcCommand.FollowDistance getFollowDistance() { + return followDistance; + } + + public void setFollowDistance(NpcCommand.FollowDistance distance) { + this.followDistance = distance; + } + + @Nullable + public UUID getCommandingPlayer() { + return commandingPlayer; + } + + @Nullable + public BlockPos getCommandTarget() { + return commandTarget; + } + + /** + * Get secondary command target (for TRANSFER: destination chest B). + */ + @Nullable + public BlockPos getCommandTarget2() { + return commandTarget2; + } + + /** + * Set secondary command target. + */ + public void setCommandTarget2(@Nullable BlockPos pos) { + this.commandTarget2 = pos; + } + + public int getCommandProgress() { + return commandProgress; + } + + public float getMood() { + return mood; + } + + public boolean hasBeenNamed() { + return hasBeenNamed; + } + + /** + * Modify mood directly. + * + * @param amount Amount to add (can be negative) + */ + public void modifyMood(float amount) { + this.mood = Math.max(0, Math.min(100, this.mood + amount)); + } + + // --- Home Management --- + + @Nullable + public BlockPos getHomePos() { + return homePos; + } + + public HomeType getHomeType() { + return homeType; + } + + @Nullable + public BlockPos getCellDeliveryPoint() { + return cellDeliveryPoint; + } + + @Nullable + public UUID getCellId() { + return cellId; + } + + public boolean hasHome() { + return homePos != null && homeType != HomeType.NONE; + } + + public void clearHome() { + this.homePos = null; + this.homeType = HomeType.NONE; + this.cellId = null; + this.cellDeliveryPoint = null; + } + + /** + * Assign a cell to this NPC. Derives homePos/homeType from cell content. + * Uses NPC's index in the prisoner list to distribute beds among multiple NPCs. + */ + public void assignCell(UUID cellId, CellDataV2 cell, UUID npcId) { + this.cellId = cellId; + deriveHomeFromCell(cell, npcId); + } + + /** + * Derive homePos and homeType from cell content. + * Priority: PetBed > Bed > SpawnPoint. + */ + private void deriveHomeFromCell(CellDataV2 cell, @Nullable UUID npcId) { + int npcIndex = 0; + if (npcId != null) { + List prisoners = cell.getPrisonerIds(); + int idx = prisoners.indexOf(npcId); + if (idx >= 0) npcIndex = idx; + } + + if (!cell.getPetBeds().isEmpty()) { + int bedIdx = npcIndex % cell.getPetBeds().size(); + this.homePos = cell.getPetBeds().get(bedIdx); + this.homeType = HomeType.PET_BED; + } else if (!cell.getBeds().isEmpty()) { + int bedIdx = npcIndex % cell.getBeds().size(); + this.homePos = cell.getBeds().get(bedIdx); + this.homeType = HomeType.BED; + } else { + this.homePos = cell.getSpawnPoint(); + this.homeType = HomeType.CELL; + } + this.cellDeliveryPoint = + cell.getDeliveryPoint() != null + ? cell.getDeliveryPoint() + : cell.getSpawnPoint(); + } + + public void unassignCell() { + clearHome(); + } + + public boolean isNearHome(BlockPos currentPos, int radius) { + if (homePos == null) return false; + return homePos.closerThan(currentPos, radius); + } + + /** + * Derive the rest type based on home type and proximity. + */ + public NpcNeeds.RestType deriveRestType(EntityDamsel entity) { + if (!isNearHome(entity.blockPosition(), 3)) { + return NpcNeeds.RestType.IDLE; + } + return switch (homeType) { + case BED -> NpcNeeds.RestType.SLEEPING; + case PET_BED -> NpcNeeds.RestType.SITTING; + default -> NpcNeeds.RestType.IDLE; + }; + } + + /** + * Called when home block is destroyed. + */ + public void onHomeDestroyed() { + if (homePos != null) { + modifyMood(-5); + clearHome(); + } + } + + // --- Cell Quality --- + + public CellQuality getCellQuality() { + return cellQuality; + } + + // --- Auto-Rest Setting --- + + public boolean isAutoRestEnabled() { + return autoRestEnabled; + } + + public void setAutoRestEnabled(boolean enabled) { + this.autoRestEnabled = enabled; + } + + public boolean toggleAutoRest() { + this.autoRestEnabled = !this.autoRestEnabled; + return this.autoRestEnabled; + } + + // --- Job Experience --- + + public JobExperience getJobExperience() { + return jobExperience; + } + + // --- Discipline System --- + + /** + * Apply discipline to the NPC. Only affects mood. + * + * @param type Discipline type + * @param worldTime Current world time (unused, kept for API compat) + * @return false (no brutality tracking) + */ + public boolean applyDiscipline(DisciplineType type, long worldTime) { + // MASOCHIST: Inverted mood for punishment + float moodMult = 1.0f; + if (personality == PersonalityType.MASOCHIST && type.isPunishment()) { + moodMult = -1.0f; + } + + modifyMood(type.moodChange * moodMult); + return false; + } + + // --- Command System --- + + /** + * Check if the NPC will obey a command from a player. + * Always returns true — NPCs obey when captured. + */ + public boolean willObeyCommand( + net.minecraft.world.entity.player.Player commander, + NpcCommand command + ) { + return true; + } + + /** + * Set the active command. + */ + public void setActiveCommand( + NpcCommand command, + UUID playerUUID, + @Nullable BlockPos target + ) { + this.activeCommand = command; + this.commandingPlayer = playerUUID; + this.commandTarget = target; + this.commandProgress = 0; + } + + /** + * Clear the active command. + */ + public void clearCommand() { + this.activeCommand = NpcCommand.NONE; + this.commandingPlayer = null; + this.commandTarget = null; + this.commandTarget2 = null; + this.commandProgress = 0; + } + + /** + * Increment command progress. + */ + public void addCommandProgress(int amount) { + this.commandProgress += amount; + } + + // --- Struggle System --- + + public void resetStruggleTimer() { + resetStruggleTimer(6000); + } + + public void resetStruggleTimer(int baseInterval) { + float personalityMod = personality.getStruggleTimerMultiplier(); + float randomFactor = 0.8f + (float) Math.random() * 0.4f; + this.struggleTimer = Math.round( + baseInterval * personalityMod * randomFactor + ); + } + + public int getStruggleTimer() { + return struggleTimer; + } + + public boolean tickStruggleTimer() { + if (struggleTimer > 0) { + struggleTimer--; + return false; + } + return true; + } + + /** + * Calculate struggle success chance. + * + * @param bindResistance Resistance of current bind item + * @param captorNearby Whether the captor is nearby + * @param allyNearby Whether an ally NPC is nearby + * @return Success chance (0.0 to 1.0) + */ + public float calculateStruggleChance( + float bindResistance, + boolean captorNearby, + boolean allyNearby + ) { + float baseChance = 0.15f; + + // Personality modifier + baseChance *= personality.struggleModifier; + + // Bind resistance + baseChance *= (1.0f / Math.max(1.0f, bindResistance)); + + // Mood affects struggle (miserable = more likely) + if (mood < 30) baseChance *= 1.5f; + else if (mood > 70) baseChance *= 0.7f; + + // Environmental factors + if (captorNearby) baseChance *= 0.7f; + if (allyNearby) baseChance *= 1.3f; + + return Math.min(0.8f, Math.max(0.01f, baseChance)); + } + + /** + * Record struggle result. + */ + public void recordStruggleResult(boolean success, int baseInterval) { + if (success) { + consecutiveStruggleFails = 0; + modifyMood(5); + } else { + consecutiveStruggleFails++; + modifyMood(-2); + } + resetStruggleTimer(baseInterval); + } + + // --- Mood --- + + /** + * Get mood modifier for command compliance (kept for dialogue variety). + * + * @return Modifier (0.7 to 1.3) + */ + public float getMoodModifier() { + if (mood < 20) return 0.7f; + if (mood < 40) return 0.85f; + if (mood > 80) return 1.2f; + if (mood > 60) return 1.1f; + return 1.0f; + } + + /** + * Recalculate mood based on needs and cell quality. + */ + public void recalculateMood() { + float newMood = 50.0f; + + // Needs impact + newMood += needs.getMoodImpact(); + + // Job personality preference impact + if (activeCommand != null && activeCommand.isActiveJob()) { + float jobMoodMod = JobPersonalityModifiers.getJobMoodModifier( + personality, + activeCommand + ); + newMood += jobMoodMod * 10; + } + + // Cell quality impact + newMood += cellQuality.moodModifier; + + // Clamp + this.mood = Math.max(0, Math.min(100, newMood)); + } + + // --- Naming --- + + public void markAsNamed() { + this.hasBeenNamed = true; + } + + // --- Tick --- + + /** + * Tick the personality state. Called every game tick. + * + * @param entity The entity + * @param isNight Whether it's night (unused, kept for API compat) + * @param masterUUID Current collar owner UUID (unused, kept for API compat) + * @param isLeashed Whether the entity is currently leashed (unused) + * @return NeedTransitions containing any threshold crossings this tick + */ + public NpcNeeds.NeedTransitions tick( + EntityDamsel entity, + boolean isNight, + @Nullable UUID masterUUID, + boolean isLeashed + ) { + // Determine if NPC is doing active work (jobs that consume rest) + boolean isWorking = + activeCommand != null && activeCommand.isActiveJob(); + + // Derive rest type based on home proximity and type + NpcNeeds.RestType restType = deriveRestType(entity); + + // Update needs and get transitions + NpcNeeds.NeedTransitions transitions = needs.tick( + entity, + personality, + isWorking, + restType + ); + + // Cell quality evaluation (every 60 seconds = 1200 ticks) + if (entity.tickCount % 1200 == 0 && hasHome()) { + // Validate cell link + CellDataV2 cell = null; + if ( + cellId != null && + entity.level() instanceof ServerLevel serverLevel + ) { + CellRegistryV2 registry = CellRegistryV2.get(serverLevel); + cell = registry.getCell(cellId); + if (cell == null) { + // Cell destroyed + onHomeDestroyed(); + return transitions; + } + // Re-derive homePos in case cell content changed + deriveHomeFromCell(cell, entity.getUUID()); + } + + cellQuality = CellQuality.evaluate(homePos, entity.level(), cell); + } + + // Recalculate mood every 100 ticks (5 seconds) + if (entity.tickCount % 100 == 0) { + recalculateMood(); + } + + return transitions; + } + + // --- NBT Persistence --- + + public CompoundTag save() { + CompoundTag tag = new CompoundTag(); + + // Core + tag.putString("Personality", personality.name()); + + // Needs + tag.put("Needs", needs.save()); + + // Command state + tag.putString("ActiveCommand", activeCommand.name()); + tag.putString("FollowDistance", followDistance.name()); + if (commandingPlayer != null) { + tag.putUUID("CommandingPlayer", commandingPlayer); + } + if (commandTarget != null) { + tag.putLong("CommandTarget", commandTarget.asLong()); + } + if (commandTarget2 != null) { + tag.putLong("CommandTarget2", commandTarget2.asLong()); + } + tag.putInt("CommandProgress", commandProgress); + + // Struggle state + tag.putInt("StruggleTimer", struggleTimer); + tag.putInt("ConsecutiveFails", consecutiveStruggleFails); + + // Other + tag.putFloat("Mood", mood); + tag.putBoolean("HasBeenNamed", hasBeenNamed); + + // Home state + if (homePos != null) { + tag.putLong("HomePos", homePos.asLong()); + tag.putString("HomeType", homeType.name()); + } + if (cellId != null) { + tag.putUUID("HomeCellId", cellId); + } + if (cellDeliveryPoint != null) { + tag.putLong("CellDeliveryPoint", cellDeliveryPoint.asLong()); + } + + // Cell quality + tag.putString("CellQuality", cellQuality.name()); + + // Auto-rest setting + tag.putBoolean("AutoRestEnabled", autoRestEnabled); + + // Job experience + tag.put("JobExperience", jobExperience.save()); + + return tag; + } + + public static PersonalityState load(CompoundTag tag) { + // Core + PersonalityType type; + try { + type = PersonalityType.valueOf(tag.getString("Personality")); + } catch (IllegalArgumentException e) { + type = PersonalityType.CALM; + } + + PersonalityState state = new PersonalityState(type); + + // Needs + if (tag.contains("Needs")) { + NpcNeeds loadedNeeds = NpcNeeds.load(tag.getCompound("Needs")); + state.needs.setHunger(loadedNeeds.getHunger()); + state.needs.setRest(loadedNeeds.getRest()); + } + + // Command state (gracefully handles removed commands) + try { + state.activeCommand = NpcCommand.valueOf( + tag.getString("ActiveCommand") + ); + } catch (IllegalArgumentException e) { + state.activeCommand = NpcCommand.NONE; + } + try { + state.followDistance = NpcCommand.FollowDistance.valueOf( + tag.getString("FollowDistance") + ); + } catch (IllegalArgumentException e) { + state.followDistance = NpcCommand.FollowDistance.FAR; + } + if (tag.contains("CommandingPlayer")) { + state.commandingPlayer = tag.getUUID("CommandingPlayer"); + } + if (tag.contains("CommandTarget")) { + state.commandTarget = BlockPos.of(tag.getLong("CommandTarget")); + } + if (tag.contains("CommandTarget2")) { + state.commandTarget2 = BlockPos.of(tag.getLong("CommandTarget2")); + } + state.commandProgress = tag.getInt("CommandProgress"); + + // Struggle state + state.struggleTimer = tag.getInt("StruggleTimer"); + state.consecutiveStruggleFails = tag.getInt("ConsecutiveFails"); + + // Other + state.mood = tag.getFloat("Mood"); + state.hasBeenNamed = tag.getBoolean("HasBeenNamed"); + + // Home state + if (tag.contains("HomePos")) { + state.homePos = BlockPos.of(tag.getLong("HomePos")); + try { + String homeTypeName = tag.getString("HomeType"); + if ("BASKET".equals(homeTypeName)) homeTypeName = "PET_BED"; // migration + state.homeType = HomeType.valueOf(homeTypeName); + } catch (IllegalArgumentException e) { + state.homeType = HomeType.NONE; + } + } + if (tag.hasUUID("HomeCellId")) { + state.cellId = tag.getUUID("HomeCellId"); + } + if (tag.contains("CellDeliveryPoint")) { + state.cellDeliveryPoint = BlockPos.of( + tag.getLong("CellDeliveryPoint") + ); + } + + // Cell quality + if (tag.contains("CellQuality")) { + try { + state.cellQuality = CellQuality.valueOf( + tag.getString("CellQuality") + ); + } catch (IllegalArgumentException e) { + state.cellQuality = CellQuality.STANDARD; + } + } + + // Auto-rest setting (default true if not present) + if (tag.contains("AutoRestEnabled")) { + state.autoRestEnabled = tag.getBoolean("AutoRestEnabled"); + } + + // Job experience + if (tag.contains("JobExperience")) { + state.jobExperience = JobExperience.load( + tag.getCompound("JobExperience") + ); + } + + return state; + } +} diff --git a/src/main/java/com/tiedup/remake/personality/PersonalityType.java b/src/main/java/com/tiedup/remake/personality/PersonalityType.java new file mode 100644 index 0000000..b46dd39 --- /dev/null +++ b/src/main/java/com/tiedup/remake/personality/PersonalityType.java @@ -0,0 +1,121 @@ +package com.tiedup.remake.personality; + +import java.util.Random; + +/** + * Defines the 11 base personality types for NPCs. + * Each personality affects behavior: + * - struggleModifier: Multiplier for struggle success chance + * - flightModifier: Multiplier for flee behavior distance/urgency + * - baseCompliance: Base compliance rate (kept for dialogue variety) + * - spawnWeight: Weight for random generation + */ +public enum PersonalityType { + TIMID(0.7f, 1.5f, 0.75f, 0.15f), + GENTLE(0.5f, 1.2f, 0.80f, 0.12f), + SUBMISSIVE(0.3f, 1.0f, 0.90f, 0.05f), + CALM(1.0f, 1.0f, 0.60f, 0.20f), + CURIOUS(0.8f, 0.9f, 0.70f, 0.10f), + PROUD(1.3f, 0.7f, 0.30f, 0.10f), + FIERCE(1.5f, 0.3f, 0.20f, 0.08f), + DEFIANT(1.4f, 0.5f, 0.10f, 0.07f), + PLAYFUL(0.9f, 1.1f, 0.60f, 0.08f), + MASOCHIST(0.4f, 0.8f, 0.85f, 0.03f), + SADIST(1.2f, 0.6f, 0.40f, 0.02f); + + /** Multiplier for struggle escape chance */ + public final float struggleModifier; + + /** Multiplier for flight behavior */ + public final float flightModifier; + + /** Base compliance rate (0.0 to 1.0) — for dialogue/RP variety */ + public final float baseCompliance; + + /** Spawn weight for random generation */ + public final float spawnWeight; + + PersonalityType( + float struggleModifier, + float flightModifier, + float baseCompliance, + float spawnWeight + ) { + this.struggleModifier = struggleModifier; + this.flightModifier = flightModifier; + this.baseCompliance = baseCompliance; + this.spawnWeight = spawnWeight; + } + + /** + * Calculate struggle timer multiplier. + * Lower = struggles more often. + * + * @return Timer multiplier (0.3 for FIERCE to 4.0 for MASOCHIST) + */ + public float getStruggleTimerMultiplier() { + return switch (this) { + case FIERCE -> 0.3f; + case DEFIANT -> 0.5f; + case PROUD -> 0.6f; + case CALM, CURIOUS, PLAYFUL -> 1.0f; + case TIMID -> 1.5f; + case GENTLE -> 2.0f; + case SUBMISSIVE -> 3.0f; + case MASOCHIST -> 4.0f; + case SADIST -> 0.8f; + }; + } + + // --- Static utility methods --- + + private static final Random RANDOM = new Random(); + private static float totalWeight = -1; + + /** + * Generate a random personality based on spawn weights. + * + * @return Random PersonalityType + */ + public static PersonalityType randomForDamsel() { + if (totalWeight < 0) { + totalWeight = 0; + for (PersonalityType type : values()) { + if (type != SADIST) { + totalWeight += type.spawnWeight; + } + } + } + + float roll = RANDOM.nextFloat() * totalWeight; + float cumulative = 0; + + for (PersonalityType type : values()) { + if (type == SADIST) continue; + cumulative += type.spawnWeight; + if (roll < cumulative) { + return type; + } + } + + return CALM; // Fallback + } + + /** + * Generate a random personality for kidnappers. + * Includes SADIST and excludes SUBMISSIVE. + * + * @return Random PersonalityType for kidnapper + */ + public static PersonalityType randomForKidnapper() { + float roll = RANDOM.nextFloat(); + + if (roll < 0.25f) return FIERCE; + if (roll < 0.45f) return SADIST; + if (roll < 0.60f) return PROUD; + if (roll < 0.75f) return DEFIANT; + if (roll < 0.85f) return CALM; + if (roll < 0.92f) return PLAYFUL; + return CURIOUS; + } +} diff --git a/src/main/java/com/tiedup/remake/personality/ToolMode.java b/src/main/java/com/tiedup/remake/personality/ToolMode.java new file mode 100644 index 0000000..11e230a --- /dev/null +++ b/src/main/java/com/tiedup/remake/personality/ToolMode.java @@ -0,0 +1,66 @@ +package com.tiedup.remake.personality; + +import com.tiedup.remake.items.base.ItemBind; +import net.minecraft.world.item.AxeItem; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.PickaxeItem; +import net.minecraft.world.item.SwordItem; + +/** + * Tool modes that determine NPC behavior during FOLLOW command. + * The item in the NPC's main hand determines their active behavior. + */ +public enum ToolMode { + /** No tool or unrecognized item - passive following */ + PASSIVE, + + /** SwordItem - attack hostile mobs near master */ + ATTACK, + + /** PickaxeItem - mine stone/ores near master */ + MINING, + + /** AxeItem - chop wood near master */ + WOODCUTTING, + + /** IBondageItem (BIND type) - capture untied damsels */ + CAPTURE; + + /** + * Detect tool mode from main hand item. + * + * @param mainHand Item in main hand + * @return Detected tool mode + */ + public static ToolMode fromItem(ItemStack mainHand) { + if (mainHand.isEmpty()) { + return PASSIVE; + } + + var item = mainHand.getItem(); + + if (item instanceof SwordItem) { + return ATTACK; + } + if (item instanceof PickaxeItem) { + return MINING; + } + if (item instanceof AxeItem) { + return WOODCUTTING; + } + if (item instanceof ItemBind) { + return CAPTURE; + } + + return PASSIVE; + } + + /** + * Get translation key for this mode. + * + * @return Translation key + */ + public String getTranslationKey() { + return "toolmode." + this.name().toLowerCase(); + } +} diff --git a/src/main/java/com/tiedup/remake/prison/LaborRecord.java b/src/main/java/com/tiedup/remake/prison/LaborRecord.java new file mode 100644 index 0000000..ee9da51 --- /dev/null +++ b/src/main/java/com/tiedup/remake/prison/LaborRecord.java @@ -0,0 +1,455 @@ +package com.tiedup.remake.prison; + +import com.tiedup.remake.labor.LaborTask; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.nbt.CompoundTag; + +/** + * Labor-specific data for a prisoner. + * + * Separated from PrisonerRecord to keep concerns clean. + * Contains task assignment, work phase, maid tracking, and activity monitoring. + */ +public class LaborRecord { + + /** + * Sub-phases within the WORKING/IMPRISONED state. + * More granular than PrisonerState, used by maid goals. + */ + public enum WorkPhase { + /** In cell, no task assigned */ + IDLE, + /** Task assigned, waiting for extraction */ + PENDING_EXTRACTION, + /** Being escorted to work area */ + EXTRACTING, + /** Actively working on task */ + WORKING, + /** Task complete, waiting for return */ + PENDING_RETURN, + /** Being escorted back to cell */ + RETURNING, + /** Back in cell, resting before next task */ + RESTING, + } + + // ==================== TASK DATA ==================== + + /** Current labor task (null if no task assigned) */ + @Nullable + private LaborTask task; + + /** Current work phase */ + private WorkPhase phase; + + /** UUID of the maid managing this prisoner */ + @Nullable + private UUID maidId; + + /** UUID of the maid currently escorting (during extract/return) */ + @Nullable + private UUID escortMaidId; + + /** UUID of the guard entity assigned to this prisoner during labor */ + @Nullable + private UUID guardId; + + // ==================== PROGRESS ==================== + + /** Total emeralds earned through labor */ + private int totalEarned; + + /** Number of completed tasks this session */ + private int completedTasks; + + /** Shock level (0-3, escalating punishment for inactivity) */ + private int shockLevel; + + /** Whether the current/last task failed */ + private boolean taskFailed; + + // ==================== ACTIVITY TRACKING ==================== + + /** Last time activity was detected */ + private long lastActivityTime; + + /** Last known position (for movement detection) */ + private int lastPosX, lastPosY, lastPosZ; + + /** When current phase started */ + private long phaseStartTime; + + // ==================== BONDAGE SNAPSHOT ==================== + + /** Saved bondage state for restoration after labor */ + @Nullable + private CompoundTag bondageSnapshot; + + // ==================== CONSTRUCTOR ==================== + + public LaborRecord() { + this.task = null; + this.phase = WorkPhase.IDLE; + this.maidId = null; + this.escortMaidId = null; + this.guardId = null; + this.totalEarned = 0; + this.completedTasks = 0; + this.shockLevel = 0; + this.taskFailed = false; + this.lastActivityTime = 0; + this.phaseStartTime = 0; + this.bondageSnapshot = null; + } + + // ==================== GETTERS ==================== + + @Nullable + public LaborTask getTask() { + return task; + } + + public WorkPhase getPhase() { + return phase; + } + + @Nullable + public UUID getMaidId() { + return maidId; + } + + @Nullable + public UUID getEscortMaidId() { + return escortMaidId; + } + + @Nullable + public UUID getGuardId() { + return guardId; + } + + public int getTotalEarned() { + return totalEarned; + } + + public int getCompletedTasks() { + return completedTasks; + } + + public int getShockLevel() { + return shockLevel; + } + + public boolean isTaskFailed() { + return taskFailed; + } + + public long getLastActivityTime() { + return lastActivityTime; + } + + public long getPhaseStartTime() { + return phaseStartTime; + } + + @Nullable + public CompoundTag getBondageSnapshot() { + return bondageSnapshot; + } + + // ==================== SETTERS ==================== + + public void setTask(@Nullable LaborTask task) { + this.task = task; + } + + public void setPhase(WorkPhase phase, long currentTime) { + this.phase = phase; + this.phaseStartTime = currentTime; + } + + public void setMaidId(@Nullable UUID maidId) { + this.maidId = maidId; + } + + public void setEscortMaidId(@Nullable UUID escortMaidId) { + this.escortMaidId = escortMaidId; + } + + public void setGuardId(@Nullable UUID guardId) { + this.guardId = guardId; + } + + public void setTotalEarned(int totalEarned) { + this.totalEarned = totalEarned; + } + + public void addEarnings(int amount) { + this.totalEarned += amount; + } + + public void incrementCompletedTasks() { + this.completedTasks++; + } + + public void setShockLevel(int shockLevel) { + this.shockLevel = Math.max(0, Math.min(3, shockLevel)); + } + + public void incrementShockLevel() { + setShockLevel(shockLevel + 1); + } + + public void resetShockLevel() { + this.shockLevel = 0; + } + + public void setTaskFailed(boolean taskFailed) { + this.taskFailed = taskFailed; + } + + public void updateActivity(long currentTime, int x, int y, int z) { + this.lastActivityTime = currentTime; + this.lastPosX = x; + this.lastPosY = y; + this.lastPosZ = z; + } + + public void setBondageSnapshot(@Nullable CompoundTag snapshot) { + this.bondageSnapshot = snapshot != null ? snapshot.copy() : null; + } + + // ==================== QUERY METHODS ==================== + + /** + * Check if a task is currently assigned. + */ + public boolean hasTask() { + return task != null; + } + + /** + * Check if currently being escorted. + */ + public boolean isBeingEscorted() { + return phase == WorkPhase.EXTRACTING || phase == WorkPhase.RETURNING; + } + + /** + * Check if can be assigned a new task. + */ + public boolean canAssignTask() { + return phase == WorkPhase.IDLE || phase == WorkPhase.RESTING; + } + + /** + * Check if prisoner has moved from last known position. + */ + public boolean hasMovedFrom(int x, int y, int z, int threshold) { + int dx = Math.abs(x - lastPosX); + int dy = Math.abs(y - lastPosY); + int dz = Math.abs(z - lastPosZ); + return dx > threshold || dy > threshold || dz > threshold; + } + + /** + * Get time in current phase. + */ + public long getTimeInPhase(long currentTime) { + return currentTime - phaseStartTime; + } + + // ==================== LIFECYCLE ==================== + + /** + * Assign a new task. + */ + public void assignTask(LaborTask task, UUID maidId, long currentTime) { + if (!canAssignTask()) { + return; // Guard: only assign during IDLE or RESTING + } + this.task = task; + this.maidId = maidId; + this.phase = WorkPhase.PENDING_EXTRACTION; + this.phaseStartTime = currentTime; + this.taskFailed = false; + this.shockLevel = 0; + } + + /** + * Start extraction (maid is coming to get prisoner). + */ + public void startExtraction(UUID escortMaidId, long currentTime) { + this.escortMaidId = escortMaidId; + this.phase = WorkPhase.EXTRACTING; + this.phaseStartTime = currentTime; + } + + /** + * Extraction complete, prisoner is now working. + */ + public void startWorking(long currentTime) { + this.phase = WorkPhase.WORKING; + this.phaseStartTime = currentTime; + this.lastActivityTime = currentTime; + this.escortMaidId = null; + } + + /** + * Task complete, waiting for maid to return prisoner. + */ + public void completeTask(long currentTime) { + this.phase = WorkPhase.PENDING_RETURN; + this.phaseStartTime = currentTime; + } + + /** + * Task failed (timeout or abandoned). + */ + public void failTask(long currentTime) { + this.taskFailed = true; + this.phase = WorkPhase.PENDING_RETURN; + this.phaseStartTime = currentTime; + } + + /** + * Start return to cell. + */ + public void startReturn(UUID escortMaidId, long currentTime) { + this.escortMaidId = escortMaidId; + this.phase = WorkPhase.RETURNING; + this.phaseStartTime = currentTime; + } + + /** + * Returned to cell, start rest period. + */ + public void startRest(long currentTime) { + if (!taskFailed) { + incrementCompletedTasks(); + } + this.task = null; + this.phase = WorkPhase.RESTING; + this.phaseStartTime = currentTime; + this.escortMaidId = null; + this.guardId = null; + this.bondageSnapshot = null; // Clear after restoration + } + + /** + * Rest complete, back to idle. + */ + public void finishRest(long currentTime) { + this.phase = WorkPhase.IDLE; + this.phaseStartTime = currentTime; + this.taskFailed = false; + } + + /** + * Reset all labor data (on release). + */ + public void reset() { + this.task = null; + this.phase = WorkPhase.IDLE; + this.maidId = null; + this.escortMaidId = null; + this.guardId = null; + this.totalEarned = 0; + this.completedTasks = 0; + this.shockLevel = 0; + this.taskFailed = false; + this.lastActivityTime = 0; + this.phaseStartTime = 0; + this.bondageSnapshot = null; + } + + // ==================== SERIALIZATION ==================== + + public CompoundTag save() { + CompoundTag tag = new CompoundTag(); + + if (task != null) { + tag.put("task", task.save()); + } + tag.putString("phase", phase.name()); + + if (maidId != null) { + tag.putUUID("maidId", maidId); + } + if (escortMaidId != null) { + tag.putUUID("escortMaidId", escortMaidId); + } + if (guardId != null) { + tag.putUUID("guardId", guardId); + } + + tag.putInt("totalEarned", totalEarned); + tag.putInt("completedTasks", completedTasks); + tag.putInt("shockLevel", shockLevel); + tag.putBoolean("taskFailed", taskFailed); + + tag.putLong("lastActivityTime", lastActivityTime); + tag.putInt("lastPosX", lastPosX); + tag.putInt("lastPosY", lastPosY); + tag.putInt("lastPosZ", lastPosZ); + tag.putLong("phaseStartTime", phaseStartTime); + + if (bondageSnapshot != null) { + tag.put("bondageSnapshot", bondageSnapshot.copy()); + } + + return tag; + } + + public static LaborRecord load(CompoundTag tag) { + LaborRecord record = new LaborRecord(); + + if (tag.contains("task")) { + record.task = LaborTask.load(tag.getCompound("task")); + } + + try { + record.phase = WorkPhase.valueOf(tag.getString("phase")); + } catch (IllegalArgumentException e) { + record.phase = WorkPhase.IDLE; + } + + if (tag.contains("maidId")) { + record.maidId = tag.getUUID("maidId"); + } + if (tag.contains("escortMaidId")) { + record.escortMaidId = tag.getUUID("escortMaidId"); + } + if (tag.contains("guardId")) { + record.guardId = tag.getUUID("guardId"); + } + + record.totalEarned = tag.getInt("totalEarned"); + record.completedTasks = tag.getInt("completedTasks"); + record.shockLevel = tag.getInt("shockLevel"); + record.taskFailed = tag.getBoolean("taskFailed"); + + record.lastActivityTime = tag.getLong("lastActivityTime"); + record.lastPosX = tag.getInt("lastPosX"); + record.lastPosY = tag.getInt("lastPosY"); + record.lastPosZ = tag.getInt("lastPosZ"); + record.phaseStartTime = tag.getLong("phaseStartTime"); + + if (tag.contains("bondageSnapshot")) { + record.bondageSnapshot = tag.getCompound("bondageSnapshot").copy(); + } + + return record; + } + + @Override + public String toString() { + return String.format( + "LaborRecord{phase=%s, task=%s, earned=%d}", + phase, + task != null ? task.getDescription() : "none", + totalEarned + ); + } +} diff --git a/src/main/java/com/tiedup/remake/prison/PrisonerManager.java b/src/main/java/com/tiedup/remake/prison/PrisonerManager.java new file mode 100644 index 0000000..2792ee4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/prison/PrisonerManager.java @@ -0,0 +1,800 @@ +package com.tiedup.remake.prison; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.saveddata.SavedData; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Central manager for all prisoner data. + * + * Replaces: + * - CaptivityStateManager (state tracking) + * - Prisoner tracking in CellRegistry + * - Prisoner tracking in CampOwnership + * + * This is the SINGLE SOURCE OF TRUTH for prisoner state. + * All transitions must go through this class. + */ +public class PrisonerManager extends SavedData { + + private static final String DATA_NAME = "tiedup_prisoner_manager"; + + // ==================== PRIMARY DATA ==================== + + /** Player UUID -> PrisonerRecord */ + private final Map prisoners = + new ConcurrentHashMap<>(); + + /** Player UUID -> LaborRecord */ + private final Map laborRecords = + new ConcurrentHashMap<>(); + + /** Player UUID -> RansomRecord */ + private final Map ransomRecords = + new ConcurrentHashMap<>(); + + // ==================== INDEXES ==================== + + /** Camp UUID -> Set of prisoner UUIDs */ + private final Map> prisonersByCamp = + new ConcurrentHashMap<>(); + + /** Cell UUID -> Set of prisoner UUIDs */ + private final Map> prisonersByCell = + new ConcurrentHashMap<>(); + + // ==================== STATIC ACCESS ==================== + + public static PrisonerManager get(ServerLevel level) { + return level + .getDataStorage() + .computeIfAbsent( + PrisonerManager::load, + PrisonerManager::new, + DATA_NAME + ); + } + + public static PrisonerManager get(MinecraftServer server) { + return get(server.overworld()); + } + + @Nullable + public static PrisonerManager get(ServerPlayer player) { + if (player.getServer() == null) return null; + return get(player.getServer()); + } + + // ==================== RECORD ACCESS ==================== + + /** + * Mark this SavedData as dirty so it gets persisted on next save. + * Exposed for external callers that modify records directly (e.g., clearing stale guard refs). + */ + public void markDirty() { + setDirty(); + } + + /** + * Get or create the prisoner record for a player. + * Never returns null - creates a FREE record if none exists. + */ + public PrisonerRecord getRecord(UUID playerId) { + return prisoners.computeIfAbsent(playerId, id -> new PrisonerRecord()); + } + + /** + * Get the prisoner record if the player is a prisoner (non-FREE state). + * Returns null if player is FREE or has no record. + */ + @Nullable + public PrisonerRecord getPrisoner(UUID playerId) { + PrisonerRecord record = prisoners.get(playerId); + if (record == null || record.getState() == PrisonerState.FREE) { + return null; + } + return record; + } + + /** + * Get or create the labor record for a player. + */ + public LaborRecord getLaborRecord(UUID playerId) { + return laborRecords.computeIfAbsent(playerId, id -> new LaborRecord()); + } + + /** + * Set the labor record for a player. + */ + public void setLaborRecord(UUID playerId, LaborRecord record) { + laborRecords.put(playerId, record); + setDirty(); + } + + /** + * Get the ransom record for a player (may be null). + */ + @Nullable + public RansomRecord getRansomRecord(UUID playerId) { + return ransomRecords.get(playerId); + } + + /** + * Set the ransom record for a player. + */ + public void setRansomRecord(UUID playerId, @Nullable RansomRecord record) { + if (record == null) { + ransomRecords.remove(playerId); + } else { + ransomRecords.put(playerId, record); + } + setDirty(); + } + + /** + * Create or get ransom record for a player. + */ + public RansomRecord getOrCreateRansomRecord(UUID playerId) { + return ransomRecords.computeIfAbsent(playerId, id -> + new RansomRecord() + ); + } + + /** + * Check if a player has any prisoner data. + */ + public boolean hasRecord(UUID playerId) { + PrisonerRecord record = prisoners.get(playerId); + return record != null && record.getState() != PrisonerState.FREE; + } + + // ==================== STATE QUERIES ==================== + + /** + * Get the current state for a player. + */ + public PrisonerState getState(UUID playerId) { + PrisonerRecord record = prisoners.get(playerId); + return record != null ? record.getState() : PrisonerState.FREE; + } + + /** + * Check if player is captive (captured or imprisoned). + */ + public boolean isCaptive(UUID playerId) { + return getState(playerId).isCaptive(); + } + + /** + * Check if player is imprisoned (in cell or working). + */ + public boolean isImprisoned(UUID playerId) { + return getState(playerId).isImprisoned(); + } + + /** + * Check if player can be targeted by kidnappers. + */ + public boolean isTargetable(UUID playerId, long currentTime) { + PrisonerRecord record = prisoners.get(playerId); + if (record == null) return true; + return record.isTargetable(currentTime); + } + + /** + * Check if player is protected from capture. + */ + public boolean isProtected(UUID playerId, long currentTime) { + PrisonerRecord record = prisoners.get(playerId); + if (record == null) return false; + return record.isProtected(currentTime); + } + + // ==================== INDEX QUERIES ==================== + + /** + * Get all prisoners in a camp. + */ + public Set getPrisonersInCamp(UUID campId) { + Set prisoners = prisonersByCamp.get(campId); + return prisoners != null + ? new HashSet<>(prisoners) + : Collections.emptySet(); + } + + /** + * Get all prisoners in a cell. + */ + public Set getPrisonersInCell(UUID cellId) { + Set prisoners = prisonersByCell.get(cellId); + return prisoners != null + ? new HashSet<>(prisoners) + : Collections.emptySet(); + } + + /** + * Get prisoner count in a camp. + */ + public int getPrisonerCountInCamp(UUID campId) { + Set prisoners = prisonersByCamp.get(campId); + return prisoners != null ? prisoners.size() : 0; + } + + /** + * Get prisoner count in a cell. + */ + public int getPrisonerCountInCell(UUID cellId) { + Set prisoners = prisonersByCell.get(cellId); + return prisoners != null ? prisoners.size() : 0; + } + + /** + * Get all prisoner IDs (for iteration). + */ + public Set getAllPrisonerIds() { + return prisoners + .keySet() + .stream() + .filter(id -> prisoners.get(id).getState() != PrisonerState.FREE) + .collect(Collectors.toSet()); + } + + /** + * Get all prisoners in a specific state. + */ + public Set getPrisonersInState(PrisonerState state) { + return prisoners + .entrySet() + .stream() + .filter(e -> e.getValue().getState() == state) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + } + + /** + * Get prisoners in camp with specific state. + */ + public Set getPrisonersInCampWithState( + UUID campId, + PrisonerState state + ) { + Set campPrisoners = prisonersByCamp.get(campId); + if (campPrisoners == null) return Collections.emptySet(); + + return campPrisoners + .stream() + .filter(id -> { + PrisonerRecord record = prisoners.get(id); + return record != null && record.getState() == state; + }) + .collect(Collectors.toSet()); + } + + // ==================== STATE TRANSITIONS ==================== + + /** + * Capture a free player. + * + * @param playerId Player to capture + * @param captorId UUID of the kidnapper + * @param currentTime Current game time + * @return true if capture was successful + */ + public boolean capture(UUID playerId, UUID captorId, long currentTime) { + PrisonerRecord record = getRecord(playerId); + + if ( + !PrisonerTransition.capture(record, captorId, currentTime, playerId) + ) { + return false; + } + + setDirty(); + return true; + } + + /** + * Imprison a captured player in a camp cell. + * + * @param playerId Player to imprison + * @param campId Camp UUID + * @param cellId Cell UUID + * @param currentTime Current game time + * @return true if imprisonment was successful + */ + public boolean imprison( + UUID playerId, + UUID campId, + UUID cellId, + long currentTime + ) { + PrisonerRecord record = getRecord(playerId); + + if ( + !PrisonerTransition.imprison( + record, + campId, + cellId, + currentTime, + playerId + ) + ) { + return false; + } + + // Update indexes + addToIndex(prisonersByCamp, campId, playerId); + addToIndex(prisonersByCell, cellId, playerId); + + // Initialize labor record + LaborRecord labor = getLaborRecord(playerId); + labor.setPhase(LaborRecord.WorkPhase.IDLE, currentTime); + + setDirty(); + return true; + } + + /** + * Extract prisoner from cell for labor. + * + * @param playerId Player to extract + * @param currentTime Current game time + * @return true if extraction was successful + */ + public boolean extract(UUID playerId, long currentTime) { + PrisonerRecord record = getRecord(playerId); + + if (!PrisonerTransition.extract(record, currentTime, playerId)) { + return false; + } + + // Remove from cell index (still in camp though) + UUID cellId = record.getCellId(); + if (cellId != null) { + removeFromIndex(prisonersByCell, cellId, playerId); + } + + setDirty(); + return true; + } + + /** + * Return prisoner to cell after labor. + * + * @param playerId Player to return + * @param cellId Cell to return to (may be different from original) + * @param currentTime Current game time + * @return true if return was successful + */ + public boolean returnToCell(UUID playerId, UUID cellId, long currentTime) { + PrisonerRecord record = getRecord(playerId); + + if (!PrisonerTransition.returnToCell(record, currentTime, playerId)) { + return false; + } + + // Update cell assignment and index + record.setCellId(cellId); + addToIndex(prisonersByCell, cellId, playerId); + + setDirty(); + return true; + } + + /** + * Release a prisoner with grace period. + * + * @param playerId Player to release + * @param currentTime Current game time + * @param gracePeriodTicks Protection duration (default: 6000 = 5 min) + * @return true if release was successful + */ + public boolean release( + UUID playerId, + long currentTime, + long gracePeriodTicks + ) { + PrisonerRecord record = getRecord(playerId); + UUID campId = record.getCampId(); + UUID cellId = record.getCellId(); + + if ( + !PrisonerTransition.release( + record, + currentTime, + gracePeriodTicks, + playerId + ) + ) { + return false; + } + + // Remove from indexes + if (campId != null) { + removeFromIndex(prisonersByCamp, campId, playerId); + } + if (cellId != null) { + removeFromIndex(prisonersByCell, cellId, playerId); + } + + // Clear labor and ransom records + laborRecords.remove(playerId); + ransomRecords.remove(playerId); + + setDirty(); + return true; + } + + /** + * Release a prisoner with default 5-minute grace period. + * + * @param playerId Player to release + * @param currentTime Current game time + * @return true if release was successful + */ + public boolean release(UUID playerId, long currentTime) { + return release(playerId, currentTime, 6000); // 5 minutes default + } + + /** + * Transition prisoner from IMPRISONED to WORKING state. + * Used when extracting for labor. + * + * @param playerId Player to transition + * @param currentTime Current game time + * @return true if transition was successful + */ + public boolean transitionToWorking(UUID playerId, long currentTime) { + return extract(playerId, currentTime); + } + + /** + * Mark a prisoner as escaped (goes directly to FREE). + * + * @param playerId Player who escaped + * @param currentTime Current game time + * @param reason Reason for escape (for logging) + * @return true if escape was processed + */ + public boolean escape(UUID playerId, long currentTime, String reason) { + PrisonerRecord record = getRecord(playerId); + UUID campId = record.getCampId(); + UUID cellId = record.getCellId(); + + if (!PrisonerTransition.escape(record, currentTime, playerId, reason)) { + return false; + } + + // Remove from indexes + if (campId != null) { + removeFromIndex(prisonersByCamp, campId, playerId); + } + if (cellId != null) { + removeFromIndex(prisonersByCell, cellId, playerId); + } + + // Clear labor and ransom records + laborRecords.remove(playerId); + ransomRecords.remove(playerId); + + setDirty(); + return true; + } + + /** + * Expire protection (PROTECTED -> FREE). + */ + public boolean expireProtection(UUID playerId, long currentTime) { + PrisonerRecord record = prisoners.get(playerId); + if (record == null || record.getState() != PrisonerState.PROTECTED) { + return false; + } + + PrisonerTransition.forceTransition( + record, + PrisonerState.FREE, + currentTime + ); + setDirty(); + return true; + } + + // ==================== RANSOM MANAGEMENT ==================== + + /** + * Create a ransom for a prisoner. + * + * @param playerId Prisoner UUID + * @param totalDebt Total debt amount + * @param currentTime Current game time + */ + public void createRansom(UUID playerId, int totalDebt, long currentTime) { + RansomRecord ransom = getOrCreateRansomRecord(playerId); + ransom.setTotalDebt(totalDebt); + setDirty(); + } + + /** + * Add payment to a prisoner's ransom. + * + * @param playerId Prisoner UUID + * @param amount Payment amount + * @param contributorId Player who paid (null for labor) + * @return true if ransom is now fully paid + */ + public boolean addRansomPayment( + UUID playerId, + int amount, + @Nullable UUID contributorId + ) { + RansomRecord ransom = ransomRecords.get(playerId); + if (ransom == null) return false; + + boolean paid = ransom.addPayment(amount, contributorId); + setDirty(); + return paid; + } + + /** + * Increase prisoner's debt (punishment). + */ + public void increaseDebt(UUID playerId, int amount) { + RansomRecord ransom = ransomRecords.get(playerId); + if (ransom != null) { + ransom.increaseDebt(amount); + setDirty(); + } + } + + // ==================== LABOR MANAGEMENT ==================== + + /** + * Assign a task to a prisoner. + */ + public boolean assignTask( + UUID playerId, + com.tiedup.remake.labor.LaborTask task, + UUID maidId, + long currentTime + ) { + LaborRecord labor = getLaborRecord(playerId); + if (!labor.canAssignTask()) { + return false; + } + + labor.assignTask(task, maidId, currentTime); + setDirty(); + return true; + } + + /** + * Get the current work phase for a prisoner. + */ + public LaborRecord.WorkPhase getWorkPhase(UUID playerId) { + LaborRecord labor = laborRecords.get(playerId); + return labor != null ? labor.getPhase() : LaborRecord.WorkPhase.IDLE; + } + + // ==================== INDEX HELPERS ==================== + + private void addToIndex(Map> index, UUID key, UUID value) { + if (key == null) return; + index + .computeIfAbsent(key, k -> ConcurrentHashMap.newKeySet()) + .add(value); + } + + private void removeFromIndex( + Map> index, + UUID key, + UUID value + ) { + if (key == null) return; + Set set = index.get(key); + if (set != null) { + set.remove(value); + if (set.isEmpty()) { + index.remove(key); + } + } + } + + // ==================== CLEANUP ==================== + + /** + * Clean up stale records for offline players. + * Called periodically. + */ + public void cleanupOfflinePlayers( + MinecraftServer server, + long currentTime, + long offlineTimeoutTicks + ) { + List toCleanup = new ArrayList<>(); + + for (Map.Entry entry : prisoners.entrySet()) { + UUID playerId = entry.getKey(); + PrisonerRecord record = entry.getValue(); + + // Skip FREE players + if (record.getState() == PrisonerState.FREE) { + continue; + } + + // Check if player is online + ServerPlayer player = server.getPlayerList().getPlayer(playerId); + if (player != null) { + continue; // Online, skip + } + + // Check timeout + long timeInState = record.getTimeInState(currentTime); + if (timeInState > offlineTimeoutTicks) { + toCleanup.add(playerId); + } + } + + for (UUID playerId : toCleanup) { + TiedUpMod.LOGGER.info( + "[PrisonerManager] Cleaning up offline prisoner: {}", + playerId.toString().substring(0, 8) + ); + escape(playerId, currentTime, "offline timeout"); + } + } + + /** + * Expire protection for players whose grace period ended. + */ + public void tickProtectionExpiry(long currentTime) { + List toExpire = new ArrayList<>(); + + for (Map.Entry entry : prisoners.entrySet()) { + PrisonerRecord record = entry.getValue(); + if ( + record.getState() == PrisonerState.PROTECTED && + currentTime >= record.getProtectionExpiry() + ) { + toExpire.add(entry.getKey()); + } + } + + for (UUID playerId : toExpire) { + expireProtection(playerId, currentTime); + } + } + + // ==================== PERSISTENCE ==================== + + @Override + public @NotNull CompoundTag save(@NotNull CompoundTag tag) { + // Save prisoners + ListTag prisonerList = new ListTag(); + for (Map.Entry entry : prisoners.entrySet()) { + CompoundTag prisonerTag = new CompoundTag(); + prisonerTag.putUUID("id", entry.getKey()); + prisonerTag.put("record", entry.getValue().save()); + prisonerList.add(prisonerTag); + } + tag.put("prisoners", prisonerList); + + // Save labor records + ListTag laborList = new ListTag(); + for (Map.Entry entry : laborRecords.entrySet()) { + CompoundTag laborTag = new CompoundTag(); + laborTag.putUUID("id", entry.getKey()); + laborTag.put("record", entry.getValue().save()); + laborList.add(laborTag); + } + tag.put("laborRecords", laborList); + + // Save ransom records + ListTag ransomList = new ListTag(); + for (Map.Entry entry : ransomRecords.entrySet()) { + CompoundTag ransomTag = new CompoundTag(); + ransomTag.putUUID("id", entry.getKey()); + ransomTag.put("record", entry.getValue().save()); + ransomList.add(ransomTag); + } + tag.put("ransomRecords", ransomList); + + return tag; + } + + public static PrisonerManager load(CompoundTag tag) { + PrisonerManager manager = new PrisonerManager(); + + // Load prisoners + if (tag.contains("prisoners")) { + ListTag prisonerList = tag.getList("prisoners", Tag.TAG_COMPOUND); + for (int i = 0; i < prisonerList.size(); i++) { + CompoundTag prisonerTag = prisonerList.getCompound(i); + UUID id = prisonerTag.getUUID("id"); + PrisonerRecord record = PrisonerRecord.load( + prisonerTag.getCompound("record") + ); + manager.prisoners.put(id, record); + + // Rebuild indexes + if (record.getCampId() != null) { + manager.addToIndex( + manager.prisonersByCamp, + record.getCampId(), + id + ); + } + if (record.getCellId() != null) { + manager.addToIndex( + manager.prisonersByCell, + record.getCellId(), + id + ); + } + } + } + + // Load labor records + if (tag.contains("laborRecords")) { + ListTag laborList = tag.getList("laborRecords", Tag.TAG_COMPOUND); + for (int i = 0; i < laborList.size(); i++) { + CompoundTag laborTag = laborList.getCompound(i); + UUID id = laborTag.getUUID("id"); + LaborRecord record = LaborRecord.load( + laborTag.getCompound("record") + ); + manager.laborRecords.put(id, record); + } + } + + // Load ransom records + if (tag.contains("ransomRecords")) { + ListTag ransomList = tag.getList("ransomRecords", Tag.TAG_COMPOUND); + for (int i = 0; i < ransomList.size(); i++) { + CompoundTag ransomTag = ransomList.getCompound(i); + UUID id = ransomTag.getUUID("id"); + RansomRecord record = RansomRecord.load( + ransomTag.getCompound("record") + ); + manager.ransomRecords.put(id, record); + } + } + + return manager; + } + + // ==================== DEBUG ==================== + + public String toDebugString() { + StringBuilder sb = new StringBuilder(); + sb.append("PrisonerManager:\n"); + sb.append(" Total records: ").append(prisoners.size()).append("\n"); + sb + .append(" Active prisoners: ") + .append(getAllPrisonerIds().size()) + .append("\n"); + + for (PrisonerState state : PrisonerState.values()) { + int count = getPrisonersInState(state).size(); + if (count > 0) { + sb + .append(" - ") + .append(state) + .append(": ") + .append(count) + .append("\n"); + } + } + + return sb.toString(); + } +} diff --git a/src/main/java/com/tiedup/remake/prison/PrisonerRecord.java b/src/main/java/com/tiedup/remake/prison/PrisonerRecord.java new file mode 100644 index 0000000..f996c15 --- /dev/null +++ b/src/main/java/com/tiedup/remake/prison/PrisonerRecord.java @@ -0,0 +1,239 @@ +package com.tiedup.remake.prison; + +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.nbt.CompoundTag; + +/** + * Minimal prisoner data record. + * + * Replaces the complex 17-field CaptivityState with essential data only. + * Labor-specific data is in LaborRecord, ransom data in RansomRecord. + * + * This is a simple mutable POJO for performance (no withXxx() overhead). + */ +public class PrisonerRecord { + + // ==================== CORE STATE ==================== + + /** Current prisoner state */ + private PrisonerState state; + + /** When this state was last changed (game time) */ + private long stateTimestamp; + + // ==================== OWNERSHIP ==================== + + /** UUID of the captor (kidnapper who captured) */ + @Nullable + private UUID captorId; + + /** UUID of the camp this prisoner belongs to */ + @Nullable + private UUID campId; + + /** UUID of the cell this prisoner is assigned to */ + @Nullable + private UUID cellId; + + // ==================== PROTECTION ==================== + + /** Protection expiry time (game time) - for PROTECTED state */ + private long protectionExpiry; + + // ==================== CONSTRUCTOR ==================== + + public PrisonerRecord() { + this.state = PrisonerState.FREE; + this.stateTimestamp = 0; + this.captorId = null; + this.campId = null; + this.cellId = null; + this.protectionExpiry = 0; + } + + public PrisonerRecord(PrisonerState state, long timestamp) { + this.state = state; + this.stateTimestamp = timestamp; + this.captorId = null; + this.campId = null; + this.cellId = null; + this.protectionExpiry = 0; + } + + // ==================== GETTERS ==================== + + public PrisonerState getState() { + return state; + } + + public long getStateTimestamp() { + return stateTimestamp; + } + + @Nullable + public UUID getCaptorId() { + return captorId; + } + + @Nullable + public UUID getCampId() { + return campId; + } + + @Nullable + public UUID getCellId() { + return cellId; + } + + public long getProtectionExpiry() { + return protectionExpiry; + } + + // ==================== SETTERS ==================== + + public void setState(PrisonerState state, long timestamp) { + this.state = state; + this.stateTimestamp = timestamp; + } + + public void setCaptorId(@Nullable UUID captorId) { + this.captorId = captorId; + } + + public void setCampId(@Nullable UUID campId) { + this.campId = campId; + } + + public void setCellId(@Nullable UUID cellId) { + this.cellId = cellId; + } + + public void setProtectionExpiry(long protectionExpiry) { + this.protectionExpiry = protectionExpiry; + } + + // ==================== QUERY METHODS ==================== + + /** + * Check if player is protected from capture at the given time. + */ + public boolean isProtected(long currentTime) { + return ( + state == PrisonerState.PROTECTED || currentTime < protectionExpiry + ); + } + + /** + * Check if player can be targeted by kidnappers. + */ + public boolean isTargetable(long currentTime) { + return state.isTargetable() && !isProtected(currentTime); + } + + /** + * Check if player is under any form of captivity. + */ + public boolean isCaptive() { + return state.isCaptive(); + } + + /** + * Check if player is imprisoned (in cell or working). + */ + public boolean isImprisoned() { + return state.isImprisoned(); + } + + /** + * Get time since last state change in ticks. + */ + public long getTimeInState(long currentTime) { + return currentTime - stateTimestamp; + } + + // ==================== SERIALIZATION ==================== + + public CompoundTag save() { + CompoundTag tag = new CompoundTag(); + + tag.putString("state", state.name()); + tag.putLong("stateTimestamp", stateTimestamp); + + if (captorId != null) { + tag.putUUID("captorId", captorId); + } + if (campId != null) { + tag.putUUID("campId", campId); + } + if (cellId != null) { + tag.putUUID("cellId", cellId); + } + tag.putLong("protectionExpiry", protectionExpiry); + + return tag; + } + + public static PrisonerRecord load(CompoundTag tag) { + PrisonerRecord record = new PrisonerRecord(); + + try { + record.state = PrisonerState.valueOf(tag.getString("state")); + } catch (IllegalArgumentException e) { + record.state = PrisonerState.FREE; + } + + record.stateTimestamp = tag.getLong("stateTimestamp"); + + if (tag.contains("captorId")) { + record.captorId = tag.getUUID("captorId"); + } + if (tag.contains("campId")) { + record.campId = tag.getUUID("campId"); + } + if (tag.contains("cellId")) { + record.cellId = tag.getUUID("cellId"); + } + record.protectionExpiry = tag.getLong("protectionExpiry"); + + return record; + } + + // ==================== RESET ==================== + + /** + * Reset to FREE state, clearing all ownership data. + */ + public void reset(long timestamp) { + this.state = PrisonerState.FREE; + this.stateTimestamp = timestamp; + this.captorId = null; + this.campId = null; + this.cellId = null; + this.protectionExpiry = 0; + } + + /** + * Create a deep copy. + */ + public PrisonerRecord copy() { + PrisonerRecord copy = new PrisonerRecord(); + copy.state = this.state; + copy.stateTimestamp = this.stateTimestamp; + copy.captorId = this.captorId; + copy.campId = this.campId; + copy.cellId = this.cellId; + copy.protectionExpiry = this.protectionExpiry; + return copy; + } + + @Override + public String toString() { + return String.format( + "PrisonerRecord{state=%s, campId=%s, cellId=%s}", + state, + campId != null ? campId.toString().substring(0, 8) : "null", + cellId != null ? cellId.toString().substring(0, 8) : "null" + ); + } +} diff --git a/src/main/java/com/tiedup/remake/prison/PrisonerState.java b/src/main/java/com/tiedup/remake/prison/PrisonerState.java new file mode 100644 index 0000000..5c9b9b0 --- /dev/null +++ b/src/main/java/com/tiedup/remake/prison/PrisonerState.java @@ -0,0 +1,94 @@ +package com.tiedup.remake.prison; + +/** + * Simplified prisoner state enum. + * + * Replaces the complex 9-state CaptivityStateEnum with 5 clear states. + * All "in-cell" sub-states (IDLE, PENDING, RESTING) are consolidated into IMPRISONED. + * ESCAPED state is removed - escaped prisoners go straight to FREE. + */ +public enum PrisonerState { + /** + * Player is not captured and not under protection. + * Can be targeted by kidnappers normally. + */ + FREE(false, false, false), + + /** + * Player has been captured and is being transported by a kidnapper. + * Cannot be retargeted by other kidnappers. + */ + CAPTURED(true, false, true), + + /** + * Player is in a cell. + * This consolidates the old IDLE, PENDING, RESTING states. + * The specific sub-state is tracked via MaidWorkPhase in LaborRecord. + */ + IMPRISONED(true, false, true), + + /** + * Player has been extracted from cell and is actively working on a task. + * Includes both the working phase and the returning phase. + */ + WORKING(true, false, true), + + /** + * Player has been released with temporary protection. + * Cannot be targeted by kidnappers until protection expires. + * Duration: 5 minutes (6000 ticks). + */ + PROTECTED(false, true, false); + + private final boolean imprisoned; + private final boolean protected_; + private final boolean captive; + + PrisonerState(boolean imprisoned, boolean protected_, boolean captive) { + this.imprisoned = imprisoned; + this.protected_ = protected_; + this.captive = captive; + } + + /** + * @return true if player is in any imprisonment state (in cell or working) + */ + public boolean isImprisoned() { + return imprisoned; + } + + /** + * @return true if player is protected from being captured/recaptured + */ + public boolean isProtected() { + return protected_; + } + + /** + * @return true if player is under active captivity (being transported or imprisoned) + */ + public boolean isCaptive() { + return captive; + } + + /** + * @return true if player can be targeted by kidnappers + */ + public boolean isTargetable() { + return this == FREE; + } + + /** + * @return true if player is in a cell (IMPRISONED state) + */ + public boolean isInCell() { + return this == IMPRISONED; + } + + /** + * @return true if player is actively working + */ + public boolean isWorking() { + return this == WORKING; + } +} diff --git a/src/main/java/com/tiedup/remake/prison/PrisonerTransition.java b/src/main/java/com/tiedup/remake/prison/PrisonerTransition.java new file mode 100644 index 0000000..c10dc0b --- /dev/null +++ b/src/main/java/com/tiedup/remake/prison/PrisonerTransition.java @@ -0,0 +1,364 @@ +package com.tiedup.remake.prison; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.EnumSet; +import java.util.Map; +import java.util.Set; + +/** + * Defines valid state transitions for prisoners. + * + * All transitions must go through this class to ensure consistency. + * Invalid transitions are rejected with logging. + */ +public class PrisonerTransition { + + /** + * Valid transitions from each state. + * + * State diagram: + * + * ┌─────────┐ capture ┌──────────┐ imprison ┌────────────┐ + * │ FREE │──────────────►│ CAPTURED │───────────────►│ IMPRISONED │ + * └─────────┘ └──────────┘ └────────────┘ + * ▲ │ │ ▲ + * │ extract│ │ │return + * │ expire ▼ │ │ + * ┌────────────┐◄───────────────────────────────────┌─────────┐ │ + * │ PROTECTED │ release │ WORKING │───┘ + * └────────────┘ └─────────┘ + * │ │ + * │ expire │escape + * ▼ ▼ + * ┌─────────┐◄───────────────────────────────────────────┘ + * │ FREE │ escape (distance/timeout) + * └─────────┘ + */ + private static final Map< + PrisonerState, + Set + > VALID_TRANSITIONS = Map.of( + PrisonerState.FREE, + EnumSet.of( + PrisonerState.CAPTURED, // Kidnapper captures player + PrisonerState.PROTECTED // Admin command or special case + ), + PrisonerState.CAPTURED, + EnumSet.of( + PrisonerState.IMPRISONED, // Delivered to cell + PrisonerState.FREE // Escape during transport + ), + PrisonerState.IMPRISONED, + EnumSet.of( + PrisonerState.WORKING, // Extracted for labor + PrisonerState.PROTECTED, // Released with grace period + PrisonerState.FREE // Escape (distance, collar removed) + ), + PrisonerState.WORKING, + EnumSet.of( + PrisonerState.IMPRISONED, // Returned to cell after task + PrisonerState.PROTECTED, // Ransom paid during work + PrisonerState.FREE // Escape (distance, timeout) + ), + PrisonerState.PROTECTED, + EnumSet.of( + PrisonerState.FREE // Protection expired + ) + ); + + /** + * Attempt a state transition. + * + * @param record The prisoner record to modify + * @param newState The target state + * @param currentTime Current game time + * @param playerId Player UUID (for logging) + * @return true if transition was valid and applied + */ + public static boolean transition( + PrisonerRecord record, + PrisonerState newState, + long currentTime, + java.util.UUID playerId + ) { + PrisonerState oldState = record.getState(); + + // Same state is a no-op + if (oldState == newState) { + return true; + } + + // Check if transition is valid + Set validTargets = VALID_TRANSITIONS.get(oldState); + if (validTargets == null || !validTargets.contains(newState)) { + TiedUpMod.LOGGER.warn( + "[PrisonerTransition] Invalid transition {} -> {} for player {}", + oldState, + newState, + playerId != null ? playerId.toString().substring(0, 8) : "null" + ); + return false; + } + + // Apply transition + record.setState(newState, currentTime); + + // Clean up state-specific data on certain transitions + switch (newState) { + case FREE: + // Clear all ownership data + record.setCaptorId(null); + record.setCampId(null); + record.setCellId(null); + record.setProtectionExpiry(0); + break; + case PROTECTED: + // Keep camp/cell data in case protection expires and they're recaptured + // Protection expiry should be set by caller + break; + default: + // No automatic cleanup for other states + break; + } + + TiedUpMod.LOGGER.debug( + "[PrisonerTransition] {} -> {} for player {}", + oldState, + newState, + playerId != null ? playerId.toString().substring(0, 8) : "null" + ); + + return true; + } + + /** + * Force a state transition without validation. + * Use sparingly - only for admin commands or initialization. + * + * @param record The prisoner record to modify + * @param newState The target state + * @param currentTime Current game time + */ + public static void forceTransition( + PrisonerRecord record, + PrisonerState newState, + long currentTime + ) { + PrisonerState oldState = record.getState(); + record.setState(newState, currentTime); + + // Still clean up on FREE + if (newState == PrisonerState.FREE) { + record.setCaptorId(null); + record.setCampId(null); + record.setCellId(null); + record.setProtectionExpiry(0); + } + + TiedUpMod.LOGGER.info( + "[PrisonerTransition] FORCED {} -> {}", + oldState, + newState + ); + } + + /** + * Check if a transition is valid without applying it. + * + * @param fromState Current state + * @param toState Target state + * @return true if transition would be valid + */ + public static boolean isValidTransition( + PrisonerState fromState, + PrisonerState toState + ) { + if (fromState == toState) { + return true; + } + Set validTargets = VALID_TRANSITIONS.get(fromState); + return validTargets != null && validTargets.contains(toState); + } + + /** + * Get all valid target states from a given state. + * + * @param fromState Current state + * @return Set of valid target states + */ + public static Set getValidTargets(PrisonerState fromState) { + Set targets = VALID_TRANSITIONS.get(fromState); + return targets != null + ? EnumSet.copyOf(targets) + : EnumSet.noneOf(PrisonerState.class); + } + + // ==================== CONVENIENCE METHODS ==================== + + /** + * Capture a free player. + * Sets captor ID and transitions to CAPTURED. + */ + public static boolean capture( + PrisonerRecord record, + java.util.UUID captorId, + long currentTime, + java.util.UUID playerId + ) { + if (record.getState() != PrisonerState.FREE) { + TiedUpMod.LOGGER.warn( + "[PrisonerTransition] Cannot capture - not FREE" + ); + return false; + } + + record.setCaptorId(captorId); + return transition( + record, + PrisonerState.CAPTURED, + currentTime, + playerId + ); + } + + /** + * Imprison a captured player in a cell. + * Sets camp and cell IDs and transitions to IMPRISONED. + */ + public static boolean imprison( + PrisonerRecord record, + java.util.UUID campId, + java.util.UUID cellId, + long currentTime, + java.util.UUID playerId + ) { + if (record.getState() != PrisonerState.CAPTURED) { + TiedUpMod.LOGGER.warn( + "[PrisonerTransition] Cannot imprison - not CAPTURED" + ); + return false; + } + + record.setCampId(campId); + record.setCellId(cellId); + return transition( + record, + PrisonerState.IMPRISONED, + currentTime, + playerId + ); + } + + /** + * Extract prisoner from cell for labor. + * Transitions to WORKING. + */ + public static boolean extract( + PrisonerRecord record, + long currentTime, + java.util.UUID playerId + ) { + if (record.getState() != PrisonerState.IMPRISONED) { + TiedUpMod.LOGGER.warn( + "[PrisonerTransition] Cannot extract - not IMPRISONED" + ); + return false; + } + + return transition(record, PrisonerState.WORKING, currentTime, playerId); + } + + /** + * Return prisoner to cell after labor. + * Transitions to IMPRISONED. + */ + public static boolean returnToCell( + PrisonerRecord record, + long currentTime, + java.util.UUID playerId + ) { + if (record.getState() != PrisonerState.WORKING) { + TiedUpMod.LOGGER.warn( + "[PrisonerTransition] Cannot return - not WORKING" + ); + return false; + } + + return transition( + record, + PrisonerState.IMPRISONED, + currentTime, + playerId + ); + } + + /** + * Release prisoner with grace period. + * Sets protection expiry and transitions to PROTECTED. + * + * @param gracePeriodTicks Duration of protection in ticks (default: 6000 = 5 min) + */ + public static boolean release( + PrisonerRecord record, + long currentTime, + long gracePeriodTicks, + java.util.UUID playerId + ) { + PrisonerState current = record.getState(); + if ( + current != PrisonerState.IMPRISONED && + current != PrisonerState.WORKING + ) { + TiedUpMod.LOGGER.warn( + "[PrisonerTransition] Cannot release - not IMPRISONED or WORKING" + ); + return false; + } + + if (gracePeriodTicks <= 0) { + // No grace period — go straight to FREE + record.setProtectionExpiry(0); + return transition( + record, + PrisonerState.FREE, + currentTime, + playerId + ); + } + + record.setProtectionExpiry(currentTime + gracePeriodTicks); + return transition( + record, + PrisonerState.PROTECTED, + currentTime, + playerId + ); + } + + /** + * Escape from captivity. + * Transitions to FREE, clearing all ownership data. + */ + public static boolean escape( + PrisonerRecord record, + long currentTime, + java.util.UUID playerId, + String reason + ) { + PrisonerState current = record.getState(); + if (!current.isCaptive() && current != PrisonerState.PROTECTED) { + TiedUpMod.LOGGER.warn( + "[PrisonerTransition] Cannot escape - not captive" + ); + return false; + } + + TiedUpMod.LOGGER.info( + "[PrisonerTransition] Player {} escaped: {}", + playerId != null ? playerId.toString().substring(0, 8) : "null", + reason + ); + + return transition(record, PrisonerState.FREE, currentTime, playerId); + } +} diff --git a/src/main/java/com/tiedup/remake/prison/RansomData.java b/src/main/java/com/tiedup/remake/prison/RansomData.java new file mode 100644 index 0000000..ff41a36 --- /dev/null +++ b/src/main/java/com/tiedup/remake/prison/RansomData.java @@ -0,0 +1,471 @@ +package com.tiedup.remake.prison; + +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtUtils; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.Items; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.registries.ForgeRegistries; +import com.mojang.logging.LogUtils; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; + +/** + * Phase 2: Data class representing a single ransom demand. + * + * Contains: + * - Captive and captor IDs + * - Demanded item and amount + * - Deadline tick + * - State (PENDING, PAID, EXPIRED, CANCELLED) + * - Optional cell/prison association + */ +public class RansomData { + + private static final Logger LOGGER = LogUtils.getLogger(); + + /** + * Ransom state enumeration + */ + public enum RansomState { + /** + * Ransom is active and awaiting payment + */ + PENDING("pending"), + + /** + * Ransom has been paid, captive can be released + */ + PAID("paid"), + + /** + * Deadline passed without payment + */ + EXPIRED("expired"), + + /** + * Ransom was cancelled (captive escaped, captor died, etc.) + */ + CANCELLED("cancelled"); + + private final String serializedName; + + RansomState(String serializedName) { + this.serializedName = serializedName; + } + + public String getSerializedName() { + return serializedName; + } + + public static RansomState fromString(String name) { + for (RansomState state : values()) { + if (state.serializedName.equalsIgnoreCase(name)) { + return state; + } + } + return PENDING; + } + } + + /** + * Ransom difficulty level (determines item type and amount) + */ + public enum RansomDifficulty { + EASY(Items.IRON_INGOT, 8, 16, 24000L), // 1 MC day = 20 min real time + NORMAL(Items.GOLD_INGOT, 4, 8, 48000L), // 2 MC days + HARD(Items.DIAMOND, 1, 3, 72000L); // 3 MC days + + private final Item demandItem; + private final int minAmount; + private final int maxAmount; + private final long deadlineTicks; + + RansomDifficulty( + Item demandItem, + int minAmount, + int maxAmount, + long deadlineTicks + ) { + this.demandItem = demandItem; + this.minAmount = minAmount; + this.maxAmount = maxAmount; + this.deadlineTicks = deadlineTicks; + } + + public Item getDemandItem() { + return demandItem; + } + + public int getMinAmount() { + return minAmount; + } + + public int getMaxAmount() { + return maxAmount; + } + + public long getDeadlineTicks() { + return deadlineTicks; + } + } + + private final UUID ransomId; + private final UUID captiveId; + private final UUID captorId; + private final Item demandItem; + private final int demandAmount; + private final long createdTick; + private final long deadlineTick; + private final RansomDifficulty difficulty; + + private RansomState state; + private int amountPaid; + + @Nullable + private UUID cellId; + + @Nullable + private BlockPos dropChestPos; + + public RansomData( + UUID captiveId, + UUID captorId, + RansomDifficulty difficulty, + long currentTick + ) { + this.ransomId = UUID.randomUUID(); + this.captiveId = captiveId; + this.captorId = captorId; + this.difficulty = difficulty; + this.demandItem = difficulty.getDemandItem(); + this.demandAmount = + difficulty.getMinAmount() + + (int) (Math.random() * + (difficulty.getMaxAmount() - difficulty.getMinAmount() + 1)); + this.createdTick = currentTick; + this.deadlineTick = currentTick + difficulty.getDeadlineTicks(); + this.state = RansomState.PENDING; + this.amountPaid = 0; + } + + /** + * Constructor for custom item/amount ransoms (used by camp labor system). + * + * @param captiveId The captive's UUID + * @param captorId The captor's UUID (trader/maid) + * @param demandItem The item type demanded (e.g., EMERALD) + * @param demandAmount The amount demanded + * @param deadlineTicks How long until deadline (0 = no deadline for labor) + * @param currentTick The current game tick + */ + public RansomData( + UUID captiveId, + UUID captorId, + Item demandItem, + int demandAmount, + long deadlineTicks, + long currentTick + ) { + this.ransomId = UUID.randomUUID(); + this.captiveId = captiveId; + this.captorId = captorId; + this.difficulty = RansomDifficulty.NORMAL; // Default for serialization + this.demandItem = demandItem; + this.demandAmount = demandAmount; + this.createdTick = currentTick; + // If deadlineTicks is 0 or negative, set very far in the future (labor has no deadline) + this.deadlineTick = + deadlineTicks > 0 + ? currentTick + deadlineTicks + : currentTick + (365L * 24L * 60L * 60L * 20L); // 1 year in ticks + this.state = RansomState.PENDING; + this.amountPaid = 0; + } + + // Private constructor for loading + private RansomData( + UUID ransomId, + UUID captiveId, + UUID captorId, + Item demandItem, + int demandAmount, + long createdTick, + long deadlineTick, + RansomDifficulty difficulty, + RansomState state, + int amountPaid, + @Nullable UUID cellId, + @Nullable BlockPos dropChestPos + ) { + this.ransomId = ransomId; + this.captiveId = captiveId; + this.captorId = captorId; + this.demandItem = demandItem; + this.demandAmount = demandAmount; + this.createdTick = createdTick; + this.deadlineTick = deadlineTick; + this.difficulty = difficulty; + this.state = state; + this.amountPaid = amountPaid; + this.cellId = cellId; + this.dropChestPos = dropChestPos; + } + + // ==================== GETTERS ==================== + + public UUID getRansomId() { + return ransomId; + } + + public UUID getCaptiveId() { + return captiveId; + } + + public UUID getCaptorId() { + return captorId; + } + + public Item getDemandItem() { + return demandItem; + } + + public int getDemandAmount() { + return demandAmount; + } + + public long getCreatedTick() { + return createdTick; + } + + public long getDeadlineTick() { + return deadlineTick; + } + + public RansomDifficulty getDifficulty() { + return difficulty; + } + + public RansomState getState() { + return state; + } + + public int getAmountPaid() { + return amountPaid; + } + + @Nullable + public UUID getCellId() { + return cellId; + } + + @Nullable + public BlockPos getDropChestPos() { + return dropChestPos; + } + + // ==================== SETTERS ==================== + + public void setState(RansomState state) { + this.state = state; + } + + public void setCellId(@Nullable UUID cellId) { + this.cellId = cellId; + } + + public void setDropChestPos(@Nullable BlockPos pos) { + this.dropChestPos = pos; + } + + // ==================== LOGIC ==================== + + /** + * Check if the ransom is still active (pending). + */ + public boolean isActive() { + return state == RansomState.PENDING; + } + + /** + * Check if the deadline has passed. + */ + public boolean isExpired(long currentTick) { + return currentTick >= deadlineTick; + } + + /** + * Get remaining time in ticks. + */ + public long getRemainingTicks(long currentTick) { + return Math.max(0, deadlineTick - currentTick); + } + + /** + * Get remaining time as formatted string (e.g., "1d 12h 30m"). + */ + public String getRemainingTimeFormatted(long currentTick) { + long remaining = getRemainingTicks(currentTick); + long seconds = remaining / 20; + long minutes = seconds / 60; + long hours = minutes / 60; + long days = hours / 24; + + minutes %= 60; + hours %= 24; + + if (days > 0) { + return String.format("%dd %dh %dm", days, hours, minutes); + } else if (hours > 0) { + return String.format("%dh %dm", hours, minutes); + } else { + return String.format("%dm", minutes); + } + } + + /** + * Add payment towards the ransom. + * + * @param amount Amount of items paid + * @return true if ransom is now fully paid + */ + public boolean addPayment(int amount) { + if (state != RansomState.PENDING) { + return false; + } + + amountPaid += amount; + + if (amountPaid >= demandAmount) { + state = RansomState.PAID; + return true; + } + + return false; + } + + /** + * Increase the debt by reducing the amount paid (used for punishments). + * Can make amountPaid negative, effectively increasing the total debt. + * + * @param amount Amount to increase the debt by + */ + public void increaseDebt(int amount) { + if (state != RansomState.PENDING) { + return; + } + amountPaid -= amount; + } + + /** + * Get remaining amount to pay. + */ + public int getRemainingAmount() { + return Math.max(0, demandAmount - amountPaid); + } + + /** + * Get payment progress as percentage. + */ + public float getPaymentProgress() { + return ((float) amountPaid / demandAmount) * 100f; + } + + // ==================== SERIALIZATION ==================== + + public CompoundTag save() { + CompoundTag tag = new CompoundTag(); + tag.putUUID("ransomId", ransomId); + tag.putUUID("captiveId", captiveId); + tag.putUUID("captorId", captorId); + ResourceLocation demandItemKey = ForgeRegistries.ITEMS.getKey(demandItem); + if (demandItemKey != null) { + tag.putString("demandItem", demandItemKey.toString()); + } else { + LOGGER.warn("[RansomData] Unregistered demand item {}, falling back to iron_ingot", demandItem); + tag.putString("demandItem", "minecraft:iron_ingot"); + } + tag.putInt("demandAmount", demandAmount); + tag.putLong("createdTick", createdTick); + tag.putLong("deadlineTick", deadlineTick); + tag.putString("difficulty", difficulty.name()); + tag.putString("state", state.getSerializedName()); + tag.putInt("amountPaid", amountPaid); + if (cellId != null) { + tag.putUUID("cellId", cellId); + } + if (dropChestPos != null) { + tag.put("dropChestPos", NbtUtils.writeBlockPos(dropChestPos)); + } + return tag; + } + + public static RansomData load(CompoundTag tag) { + UUID ransomId = tag.getUUID("ransomId"); + UUID captiveId = tag.getUUID("captiveId"); + UUID captorId = tag.getUUID("captorId"); + + String itemKey = tag.getString("demandItem"); + Item demandItem; + try { + demandItem = ForgeRegistries.ITEMS.getValue( + net.minecraft.resources.ResourceLocation.parse(itemKey) + ); + if (demandItem == null) demandItem = Items.IRON_INGOT; + } catch (Exception e) { + LOGGER.warn("[RansomData] Failed to parse demand item key '{}', falling back to iron_ingot", itemKey, e); + demandItem = Items.IRON_INGOT; + } + + int demandAmount = tag.getInt("demandAmount"); + long createdTick = tag.getLong("createdTick"); + long deadlineTick = tag.getLong("deadlineTick"); + + RansomDifficulty difficulty; + try { + difficulty = RansomDifficulty.valueOf(tag.getString("difficulty")); + } catch (IllegalArgumentException e) { + LOGGER.warn("[RansomData] Unknown difficulty '{}', falling back to NORMAL", tag.getString("difficulty")); + difficulty = RansomDifficulty.NORMAL; + } + RansomState state = RansomState.fromString(tag.getString("state")); + int amountPaid = tag.getInt("amountPaid"); + + UUID cellId = tag.contains("cellId") ? tag.getUUID("cellId") : null; + BlockPos dropChestPos = tag.contains("dropChestPos") + ? NbtUtils.readBlockPos(tag.getCompound("dropChestPos")) + : null; + + return new RansomData( + ransomId, + captiveId, + captorId, + demandItem, + demandAmount, + createdTick, + deadlineTick, + difficulty, + state, + amountPaid, + cellId, + dropChestPos + ); + } + + @Override + public String toString() { + ResourceLocation demandItemKey = ForgeRegistries.ITEMS.getKey(demandItem); + return String.format( + "RansomData{id=%s, captive=%s, demand=%dx%s, state=%s, paid=%d/%d}", + ransomId.toString().substring(0, 8), + captiveId.toString().substring(0, 8), + demandAmount, + demandItemKey != null ? demandItemKey : "unknown", + state, + amountPaid, + demandAmount + ); + } +} diff --git a/src/main/java/com/tiedup/remake/prison/RansomRecord.java b/src/main/java/com/tiedup/remake/prison/RansomRecord.java new file mode 100644 index 0000000..69f7e88 --- /dev/null +++ b/src/main/java/com/tiedup/remake/prison/RansomRecord.java @@ -0,0 +1,263 @@ +package com.tiedup.remake.prison; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.Items; +import net.minecraftforge.registries.ForgeRegistries; + +/** + * Ransom/debt tracking for a prisoner. + * + * Simplified from RansomData - focused on debt tracking for the labor system. + * Uses emeralds as the standard currency (matches LaborTask value system). + */ +public class RansomRecord { + + // ==================== CORE DATA ==================== + + /** Total debt amount (in emeralds) */ + private int totalDebt; + + /** Amount already paid */ + private int amountPaid; + + /** When the ransom was created (game time) */ + private long createdTime; + + /** Item type for payment (default: emeralds) */ + private Item paymentItem; + + // ==================== CONTRIBUTIONS ==================== + + /** Tracking who paid what (playerUUID -> amount) */ + private final Map contributions; + + // ==================== CONSTRUCTOR ==================== + + public RansomRecord() { + this.totalDebt = 0; + this.amountPaid = 0; + this.createdTime = 0; + this.paymentItem = Items.EMERALD; + this.contributions = new HashMap<>(); + } + + public RansomRecord(int totalDebt, long createdTime) { + this.totalDebt = totalDebt; + this.amountPaid = 0; + this.createdTime = createdTime; + this.paymentItem = Items.EMERALD; + this.contributions = new HashMap<>(); + } + + public RansomRecord(int totalDebt, Item paymentItem, long createdTime) { + this.totalDebt = totalDebt; + this.amountPaid = 0; + this.createdTime = createdTime; + this.paymentItem = paymentItem; + this.contributions = new HashMap<>(); + } + + // ==================== GETTERS ==================== + + public int getTotalDebt() { + return totalDebt; + } + + public int getAmountPaid() { + return amountPaid; + } + + public int getRemainingDebt() { + return Math.max(0, totalDebt - amountPaid); + } + + public long getCreatedTime() { + return createdTime; + } + + public Item getPaymentItem() { + return paymentItem; + } + + public Map getContributions() { + return new HashMap<>(contributions); + } + + // ==================== PAYMENT ==================== + + /** + * Add a payment toward the ransom. + * + * @param amount Amount to pay + * @return true if ransom is now fully paid + */ + public boolean addPayment(int amount) { + return addPayment(amount, null); + } + + /** + * Add a payment toward the ransom with contributor tracking. + * + * @param amount Amount to pay + * @param contributorId UUID of player who paid (null for labor earnings) + * @return true if ransom is now fully paid + */ + public boolean addPayment(int amount, @Nullable UUID contributorId) { + if (amount <= 0) { + return isPaid(); + } + + this.amountPaid += amount; + + if (contributorId != null) { + contributions.merge(contributorId, amount, Integer::sum); + } + + return isPaid(); + } + + /** + * Increase the debt (used for punishments). + * Can make effective debt go into negative paid amount. + * + * @param amount Amount to increase debt by + */ + public void increaseDebt(int amount) { + if (amount > 0) { + this.totalDebt += amount; + } + } + + /** + * Set the total debt directly. + * Used when initializing ransom. + */ + public void setTotalDebt(int totalDebt) { + this.totalDebt = Math.max(0, totalDebt); + } + + // ==================== QUERY ==================== + + /** + * Check if ransom is fully paid. + */ + public boolean isPaid() { + return amountPaid >= totalDebt; + } + + /** + * Get payment progress as percentage (0-100). + */ + public int getProgressPercent() { + if (totalDebt <= 0) return 100; + return Math.min(100, (amountPaid * 100) / totalDebt); + } + + /** + * Get contribution from a specific player. + */ + public int getContribution(UUID playerId) { + return contributions.getOrDefault(playerId, 0); + } + + /** + * Check if this record is active (has unpaid debt). + */ + public boolean isActive() { + return totalDebt > 0 && !isPaid(); + } + + // ==================== SERIALIZATION ==================== + + public CompoundTag save() { + CompoundTag tag = new CompoundTag(); + + tag.putInt("totalDebt", totalDebt); + tag.putInt("amountPaid", amountPaid); + tag.putLong("createdTime", createdTime); + + var itemKey = ForgeRegistries.ITEMS.getKey(paymentItem); + if (itemKey != null) { + tag.putString("paymentItem", itemKey.toString()); + } + + // Save contributions + if (!contributions.isEmpty()) { + ListTag contribList = new ListTag(); + for (Map.Entry entry : contributions.entrySet()) { + CompoundTag contribTag = new CompoundTag(); + contribTag.putUUID("playerId", entry.getKey()); + contribTag.putInt("amount", entry.getValue()); + contribList.add(contribTag); + } + tag.put("contributions", contribList); + } + + return tag; + } + + public static RansomRecord load(CompoundTag tag) { + RansomRecord record = new RansomRecord(); + + record.totalDebt = tag.getInt("totalDebt"); + record.amountPaid = tag.getInt("amountPaid"); + record.createdTime = tag.getLong("createdTime"); + + if (tag.contains("paymentItem")) { + String itemKeyStr = tag.getString("paymentItem"); + var itemKey = net.minecraft.resources.ResourceLocation.tryParse( + itemKeyStr + ); + if (itemKey != null) { + Item item = ForgeRegistries.ITEMS.getValue(itemKey); + if (item != null && item != Items.AIR) { + record.paymentItem = item; + } + } + } + + // Load contributions + if (tag.contains("contributions")) { + ListTag contribList = tag.getList( + "contributions", + Tag.TAG_COMPOUND + ); + for (int i = 0; i < contribList.size(); i++) { + CompoundTag contribTag = contribList.getCompound(i); + UUID playerId = contribTag.getUUID("playerId"); + int amount = contribTag.getInt("amount"); + record.contributions.put(playerId, amount); + } + } + + return record; + } + + /** + * Reset to default state. + */ + public void reset() { + this.totalDebt = 0; + this.amountPaid = 0; + this.createdTime = 0; + this.paymentItem = Items.EMERALD; + this.contributions.clear(); + } + + @Override + public String toString() { + return String.format( + "RansomRecord{debt=%d, paid=%d, remaining=%d}", + totalDebt, + amountPaid, + getRemainingDebt() + ); + } +} diff --git a/src/main/java/com/tiedup/remake/prison/service/BondageService.java b/src/main/java/com/tiedup/remake/prison/service/BondageService.java new file mode 100644 index 0000000..3226db3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/prison/service/BondageService.java @@ -0,0 +1,255 @@ +package com.tiedup.remake.prison.service; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.state.IBondageState; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.item.ItemStack; + +/** + * Centralized bondage state management. + * + * Handles saving/restoring bondage state for labor extraction. + * When a prisoner is extracted for labor, their restraints are temporarily removed. + * When they return, the original restraints are restored. + */ +public class BondageService { + + // Singleton instance + private static final BondageService INSTANCE = new BondageService(); + + public static BondageService get() { + return INSTANCE; + } + + private BondageService() {} + + // ==================== SNAPSHOT ==================== + + /** + * Save the prisoner's current bondage state before extraction. + * Captures all restraints EXCEPT the collar (which must never be removed). + * + * @param cap The prisoner's kidnapped capability + * @return CompoundTag containing the snapshot + */ + public CompoundTag saveSnapshot(IBondageState cap) { + CompoundTag tag = new CompoundTag(); + + // Save bind/tie state + if (cap.isTiedUp()) { + ItemStack bind = cap.getEquipment(BodyRegionV2.ARMS); + if (!bind.isEmpty()) { + tag.put("BindItem", bind.save(new CompoundTag())); + TiedUpMod.LOGGER.debug( + "[BondageService] SAVE: BindItem = {}", + bind.getItem().getDescriptionId() + ); + } + } + + // Save gag state + if (cap.isGagged()) { + ItemStack gag = cap.getEquipment(BodyRegionV2.MOUTH); + if (!gag.isEmpty()) { + tag.put("GagItem", gag.save(new CompoundTag())); + TiedUpMod.LOGGER.debug( + "[BondageService] SAVE: GagItem = {}", + gag.getItem().getDescriptionId() + ); + } + } + + // Save blindfold state + tag.putBoolean("Blindfolded", cap.isBlindfolded()); + if (cap.isBlindfolded()) { + ItemStack blindfold = cap.getEquipment(BodyRegionV2.EYES); + if (!blindfold.isEmpty()) { + tag.put("BlindfoldItem", blindfold.save(new CompoundTag())); + } + } + + // Save mittens state + tag.putBoolean("HasMittens", cap.hasMittens()); + if (cap.hasMittens()) { + ItemStack mittens = cap.getEquipment(BodyRegionV2.HANDS); + if (!mittens.isEmpty()) { + tag.put("MittensItem", mittens.save(new CompoundTag())); + } + } + + // Save earplugs state + tag.putBoolean("HasEarplugs", cap.hasEarplugs()); + if (cap.hasEarplugs()) { + ItemStack earplugs = cap.getEquipment(BodyRegionV2.EARS); + if (!earplugs.isEmpty()) { + tag.put("EarplugsItem", earplugs.save(new CompoundTag())); + } + } + + TiedUpMod.LOGGER.debug( + "[BondageService] SAVE complete: keys={}", + tag.getAllKeys() + ); + + // NOTE: Collar is NEVER saved - it's invariant and must stay on + return tag; + } + + /** + * Restore the prisoner's bondage state after labor completion. + * Reapplies all restraints that were saved EXCEPT the collar. + * + * @param cap The prisoner's kidnapped capability + * @param snapshot The saved bondage snapshot + */ + public void restoreSnapshot(IBondageState cap, CompoundTag snapshot) { + if (snapshot == null || snapshot.isEmpty()) { + TiedUpMod.LOGGER.warn( + "[BondageService] RESTORE: snapshot is NULL or empty - nothing to restore!" + ); + return; + } + + TiedUpMod.LOGGER.debug( + "[BondageService] RESTORE: Starting restore, keys={}", + snapshot.getAllKeys() + ); + + // Restore bind/tie + if (snapshot.contains("BindItem")) { + ItemStack bind = ItemStack.of(snapshot.getCompound("BindItem")); + if (!bind.isEmpty()) { + cap.equip(BodyRegionV2.ARMS, bind); + TiedUpMod.LOGGER.debug( + "[BondageService] RESTORE: Applied BindItem = {}", + bind.getItem().getDescriptionId() + ); + } + } + + // Restore gag + if (snapshot.contains("GagItem")) { + ItemStack gag = ItemStack.of(snapshot.getCompound("GagItem")); + if (!gag.isEmpty()) { + cap.equip(BodyRegionV2.MOUTH, gag); + TiedUpMod.LOGGER.debug( + "[BondageService] RESTORE: Applied GagItem = {}", + gag.getItem().getDescriptionId() + ); + } + } + + // Restore blindfold + if ( + snapshot.getBoolean("Blindfolded") && + snapshot.contains("BlindfoldItem") + ) { + ItemStack blindfold = ItemStack.of( + snapshot.getCompound("BlindfoldItem") + ); + if (!blindfold.isEmpty()) { + cap.equip(BodyRegionV2.EYES, blindfold); + } + } + + // Restore mittens + if ( + snapshot.getBoolean("HasMittens") && + snapshot.contains("MittensItem") + ) { + ItemStack mittens = ItemStack.of( + snapshot.getCompound("MittensItem") + ); + if (!mittens.isEmpty()) { + cap.equip(BodyRegionV2.HANDS, mittens); + } + } + + // Restore earplugs + if ( + snapshot.getBoolean("HasEarplugs") && + snapshot.contains("EarplugsItem") + ) { + ItemStack earplugs = ItemStack.of( + snapshot.getCompound("EarplugsItem") + ); + if (!earplugs.isEmpty()) { + cap.equip(BodyRegionV2.EARS, earplugs); + } + } + + // NOTE: Collar is NEVER restored - it's invariant and already on + } + + /** + * Remove temporary bondage restraints for labor. + * Called after saveSnapshot() to free the prisoner's hands for work. + * + * @param cap The prisoner's kidnapped capability + */ + public void removeForLabor(IBondageState cap) { + // Remove bind (so they can use hands) + if (cap.isTiedUp()) { + cap.unequip(BodyRegionV2.ARMS); + } + + // Remove gag (so they can communicate if needed) + if (cap.isGagged()) { + cap.unequip(BodyRegionV2.MOUTH); + } + + // Remove blindfold (so they can see) + if (cap.isBlindfolded()) { + cap.unequip(BodyRegionV2.EYES); + } + + // Remove mittens (so they can use hands) + if (cap.hasMittens()) { + cap.unequip(BodyRegionV2.HANDS); + } + + // Remove earplugs (guards need to communicate with the prisoner) + if (cap.hasEarplugs()) { + cap.unequip(BodyRegionV2.EARS); + } + + // NOTE: Collar ALWAYS stays on - never remove it + + TiedUpMod.LOGGER.debug("[BondageService] Removed restraints for labor"); + } + + /** + * Check if a player has any restraints that would impede labor. + */ + public boolean hasLaborImpedingRestraints(IBondageState cap) { + return cap.isTiedUp() || cap.isBlindfolded() || cap.hasMittens(); + } + + /** + * Apply basic prisoner restraints (bind only). + * Used when initially imprisoning a captive. + */ + public void applyBasicRestraints(IBondageState cap, ItemStack rope) { + if (!cap.isTiedUp() && !rope.isEmpty()) { + cap.equip(BodyRegionV2.ARMS, rope.copy()); + } + } + + /** + * Apply full restraints for cell confinement. + * Typically includes bind + gag. + */ + public void applyFullRestraints( + IBondageState cap, + ItemStack bind, + ItemStack gag + ) { + if (!cap.isTiedUp() && !bind.isEmpty()) { + cap.equip(BodyRegionV2.ARMS, bind.copy()); + } + if (!cap.isGagged() && gag != null && !gag.isEmpty()) { + cap.equip(BodyRegionV2.MOUTH, gag.copy()); + } + } +} diff --git a/src/main/java/com/tiedup/remake/prison/service/ItemService.java b/src/main/java/com/tiedup/remake/prison/service/ItemService.java new file mode 100644 index 0000000..f44a477 --- /dev/null +++ b/src/main/java/com/tiedup/remake/prison/service/ItemService.java @@ -0,0 +1,525 @@ +package com.tiedup.remake.prison.service; + +import com.tiedup.remake.blocks.entity.MarkerBlockEntity; +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.cells.MarkerType; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.labor.LaborTask; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.Container; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.ChestBlockEntity; + +/** + * Centralized item management for the prison system. + * + * Handles: + * - Confiscating valuables from new prisoners + * - Confiscating contraband from working prisoners + * - Collecting task items from completed labor + * - Finding and managing camp storage chests + */ +public class ItemService { + + // Singleton instance + private static final ItemService INSTANCE = new ItemService(); + + public static ItemService get() { + return INSTANCE; + } + + private ItemService() {} + + // ==================== ITEM CATEGORIES ==================== + + /** Items considered "valuables" to confiscate on imprisonment */ + private static final Set VALUABLE_ITEMS = Set.of( + Items.DIAMOND, + Items.EMERALD, + Items.GOLD_INGOT, + Items.IRON_INGOT, + Items.NETHERITE_INGOT, + Items.NETHERITE_SCRAP, + Items.DIAMOND_BLOCK, + Items.EMERALD_BLOCK, + Items.GOLD_BLOCK, + Items.IRON_BLOCK, + Items.NETHERITE_BLOCK, + Items.LAPIS_LAZULI, + Items.LAPIS_BLOCK, + Items.AMETHYST_SHARD + ); + + /** Items considered "contraband" to confiscate during labor */ + private static final Set CONTRABAND_ITEMS = Set.of( + // Weapons + Items.DIAMOND_SWORD, + Items.IRON_SWORD, + Items.GOLDEN_SWORD, + Items.STONE_SWORD, + Items.WOODEN_SWORD, + Items.NETHERITE_SWORD, + Items.BOW, + Items.CROSSBOW, + Items.TRIDENT, + // Tools that can be weapons + Items.DIAMOND_AXE, + Items.IRON_AXE, + Items.GOLDEN_AXE, + Items.STONE_AXE, + Items.WOODEN_AXE, + Items.NETHERITE_AXE, + Items.DIAMOND_PICKAXE, + Items.NETHERITE_PICKAXE, + // Escape items + Items.ENDER_PEARL, + Items.CHORUS_FRUIT, + Items.ELYTRA, + // Dangerous items + Items.FLINT_AND_STEEL, + Items.FIRE_CHARGE, + Items.TNT + ); + + // ==================== CONFISCATION ==================== + + /** + * Confiscate valuable items from a new prisoner. + * Called when a prisoner is first imprisoned. + * + * @param player The prisoner + * @return List of confiscated items + */ + public List confiscateValuables(ServerPlayer player) { + List confiscated = new ArrayList<>(); + var inventory = player.getInventory(); + + for (int i = 0; i < inventory.getContainerSize(); i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty() && isValuable(stack.getItem())) { + confiscated.add(stack.copy()); + inventory.setItem(i, ItemStack.EMPTY); + } + } + + if (!confiscated.isEmpty()) { + TiedUpMod.LOGGER.info( + "[ItemService] Confiscated {} valuable stacks from {}", + confiscated.size(), + player.getName().getString() + ); + } + + return confiscated; + } + + /** + * Confiscate contraband items from a working prisoner. + * Called periodically during labor or when returning to cell. + * + * @param player The prisoner + * @return List of confiscated items + */ + public List confiscateContraband(ServerPlayer player) { + List confiscated = new ArrayList<>(); + var inventory = player.getInventory(); + + for (int i = 0; i < inventory.getContainerSize(); i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty() && isContraband(stack)) { + confiscated.add(stack.copy()); + inventory.setItem(i, ItemStack.EMPTY); + } + } + + if (!confiscated.isEmpty()) { + TiedUpMod.LOGGER.info( + "[ItemService] Confiscated {} contraband stacks from {}", + confiscated.size(), + player.getName().getString() + ); + } + + return confiscated; + } + + /** + * Check if an item is considered a valuable. + */ + public boolean isValuable(Item item) { + return VALUABLE_ITEMS.contains(item); + } + + /** + * Check if an item stack is contraband. + * Excludes items tagged as LaborTool. + */ + public boolean isContraband(ItemStack stack) { + if (stack.isEmpty()) return false; + + // Labor tools are allowed + if (isLaborTool(stack)) return false; + + return CONTRABAND_ITEMS.contains(stack.getItem()); + } + + /** + * Check if an item is a labor tool (from the camp). + */ + private boolean isLaborTool(ItemStack stack) { + if (!stack.hasTag()) return false; + var tag = stack.getTag(); + return tag != null && tag.getBoolean("LaborTool"); + } + + // ==================== TASK ITEM COLLECTION ==================== + + /** + * Collect task items from a worker's inventory. + * Called by maid when task is complete. + * + * @param player The worker + * @param task The completed task + * @return List of collected item stacks + */ + public List collectTaskItems( + ServerPlayer player, + LaborTask task + ) { + return task.collectItems(player); + } + + /** + * Reclaim labor equipment from a worker. + * Called when returning prisoner to cell. + * + * @param player The worker + * @param task The task (for tool identification) + * @return List of reclaimed tools (for storage) + */ + public List reclaimEquipment( + ServerPlayer player, + LaborTask task + ) { + return task.reclaimEquipment(player); + } + + // ==================== CHEST MANAGEMENT ==================== + + /** + * Find the storage chest for a camp. + * + * Lookup order: + * 1. CampData registered loot positions (fast path) + * 2. Lazy discovery: scan 30-block radius around camp center for chests with LOOT markers above + * — registers found chests in CampData so future lookups are instant + * + * @param level The server level + * @param campId The camp UUID + * @return BlockPos of chest, or null if not found + */ + @Nullable + public BlockPos findCampChest(ServerLevel level, UUID campId) { + com.tiedup.remake.cells.CampOwnership ownership = + com.tiedup.remake.cells.CampOwnership.get(level); + com.tiedup.remake.cells.CampOwnership.CampData camp = ownership.getCamp( + campId + ); + + // Fast path: CampData has registered loot positions + if (camp != null && !camp.getLootChestPositions().isEmpty()) { + for (BlockPos pos : camp.getLootChestPositions()) { + BlockEntity be = level.getBlockEntity(pos); + if (be instanceof ChestBlockEntity) { + return pos; + } + } + } + + // Lazy discovery: scan around camp center for chests with LOOT markers above + if (camp != null && camp.getCenter() != null) { + List discovered = discoverLootChests(level, camp, 30); + if (!discovered.isEmpty()) { + return discovered.get(0); + } + } + + return null; + } + + /** + * Find all storage chests for a camp. + * + * @param level The server level + * @param campId The camp UUID + * @return List of chest positions + */ + public List findAllCampChests(ServerLevel level, UUID campId) { + com.tiedup.remake.cells.CampOwnership ownership = + com.tiedup.remake.cells.CampOwnership.get(level); + com.tiedup.remake.cells.CampOwnership.CampData camp = ownership.getCamp( + campId + ); + if (camp == null) return List.of(); + + // Fast path: CampData has registered loot positions + if (!camp.getLootChestPositions().isEmpty()) { + List valid = new ArrayList<>(); + for (BlockPos pos : camp.getLootChestPositions()) { + BlockEntity be = level.getBlockEntity(pos); + if (be instanceof ChestBlockEntity) { + valid.add(pos); + } + } + if (!valid.isEmpty()) return valid; + } + + // Lazy discovery + if (camp.getCenter() != null) { + return discoverLootChests(level, camp, 30); + } + + return List.of(); + } + + /** + * Find the chest for a specific cell. + * + * @param level The server level + * @param cellId The cell UUID + * @return BlockPos of chest, or null if not found + */ + @Nullable + public BlockPos findCellChest(ServerLevel level, UUID cellId) { + CellRegistryV2 registry = CellRegistryV2.get(level); + CellDataV2 cell = registry.getCell(cellId); + + if (cell == null) return null; + + // V2: scan interior blocks for chests (no LOOT markers in V2) + for (BlockPos pos : cell.getInteriorBlocks()) { + BlockEntity be = level.getBlockEntity(pos); + if (be instanceof ChestBlockEntity) { + return pos; + } + } + + return null; + } + + /** + * Scan around camp center for chests with LOOT markers above them. + * Registers found chests in CampData for future fast lookups. + * + * @param level The server level + * @param camp The camp data + * @param radius Horizontal search radius + * @return List of discovered chest positions + */ + private List discoverLootChests( + ServerLevel level, + com.tiedup.remake.cells.CampOwnership.CampData camp, + int radius + ) { + BlockPos center = camp.getCenter(); + List found = new ArrayList<>(); + int yRange = 15; + + for (int x = -radius; x <= radius; x++) { + for (int z = -radius; z <= radius; z++) { + for (int y = -yRange; y <= yRange; y++) { + BlockPos pos = center.offset(x, y, z); + BlockEntity be = level.getBlockEntity(pos); + if (!(be instanceof ChestBlockEntity)) continue; + + // Check for LOOT marker above + BlockPos above = pos.above(); + BlockEntity markerBe = level.getBlockEntity(above); + if ( + markerBe instanceof MarkerBlockEntity marker && + marker.getMarkerType() == MarkerType.LOOT + ) { + found.add(pos); + + // Register in CampData for future fast lookups + camp.addLootChestPosition(pos); + } + } + } + } + + if (!found.isEmpty()) { + com.tiedup.remake.cells.CampOwnership.get(level).setDirty(); + TiedUpMod.LOGGER.info( + "[ItemService] Lazy discovery: found {} LOOT chests for camp {} (scanned {}r around {})", + found.size(), + camp.getCampId().toString().substring(0, 8), + radius, + center.toShortString() + ); + } + + return found; + } + + /** + * Store items in a chest. + * + * @param level The server level + * @param chestPos Position of the chest + * @param items Items to store + * @return Number of items that couldn't be stored (chest full) + */ + public int storeInChest( + ServerLevel level, + BlockPos chestPos, + List items + ) { + BlockEntity be = level.getBlockEntity(chestPos); + if (!(be instanceof Container container)) { + return items.stream().mapToInt(ItemStack::getCount).sum(); + } + + int notStored = 0; + + for (ItemStack stack : items) { + ItemStack remaining = insertIntoContainer(container, stack.copy()); + notStored += remaining.getCount(); + } + + return notStored; + } + + /** + * Retrieve a tool from a chest. + * + * @param level The server level + * @param chestPos Position of the chest + * @param toolType The type of tool to find + * @return The tool stack, or empty if not found + */ + public ItemStack retrieveToolFromChest( + ServerLevel level, + BlockPos chestPos, + Item toolType + ) { + BlockEntity be = level.getBlockEntity(chestPos); + if (!(be instanceof Container container)) { + return ItemStack.EMPTY; + } + + for (int i = 0; i < container.getContainerSize(); i++) { + ItemStack stack = container.getItem(i); + if ( + !stack.isEmpty() && + stack.getItem() == toolType && + isLaborTool(stack) + ) { + ItemStack retrieved = stack.copy(); + container.setItem(i, ItemStack.EMPTY); + container.setChanged(); + return retrieved; + } + } + + return ItemStack.EMPTY; + } + + /** + * Insert an item stack into a container. + * + * @param container The container + * @param stack The stack to insert + * @return Remaining items that couldn't be inserted + */ + private ItemStack insertIntoContainer( + Container container, + ItemStack stack + ) { + if (stack.isEmpty()) return ItemStack.EMPTY; + + // First try to merge with existing stacks + for ( + int i = 0; + i < container.getContainerSize() && !stack.isEmpty(); + i++ + ) { + ItemStack slotStack = container.getItem(i); + if ( + !slotStack.isEmpty() && + ItemStack.isSameItemSameTags(slotStack, stack) && + slotStack.getCount() < slotStack.getMaxStackSize() + ) { + int space = slotStack.getMaxStackSize() - slotStack.getCount(); + int toAdd = Math.min(space, stack.getCount()); + slotStack.grow(toAdd); + stack.shrink(toAdd); + } + } + + // Then try empty slots + for ( + int i = 0; + i < container.getContainerSize() && !stack.isEmpty(); + i++ + ) { + if (container.getItem(i).isEmpty()) { + container.setItem(i, stack.copy()); + stack.setCount(0); + } + } + + if (container instanceof BlockEntity blockEntity) { + blockEntity.setChanged(); + } + + return stack; + } + + /** + * Clear prisoner's entire inventory except for armor. + * Used for maximum security confinement. + * + * @param player The prisoner + * @return All cleared items + */ + public List clearInventory(ServerPlayer player) { + List cleared = new ArrayList<>(); + var inventory = player.getInventory(); + + // Clear main inventory and hotbar (slots 0-35) + for (int i = 0; i < 36; i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty()) { + cleared.add(stack.copy()); + inventory.setItem(i, ItemStack.EMPTY); + } + } + + // Clear offhand (slot 40) + ItemStack offhand = inventory.offhand.get(0); + if (!offhand.isEmpty()) { + cleared.add(offhand.copy()); + inventory.offhand.set(0, ItemStack.EMPTY); + } + + TiedUpMod.LOGGER.info( + "[ItemService] Cleared {} stacks from {}'s inventory", + cleared.size(), + player.getName().getString() + ); + + return cleared; + } +} diff --git a/src/main/java/com/tiedup/remake/prison/service/PrisonerService.java b/src/main/java/com/tiedup/remake/prison/service/PrisonerService.java new file mode 100644 index 0000000..7fa0a0d --- /dev/null +++ b/src/main/java/com/tiedup/remake/prison/service/PrisonerService.java @@ -0,0 +1,1046 @@ +package com.tiedup.remake.prison.service; + +import com.tiedup.remake.cells.CellDataV2; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.AbstractTiedUpNpc; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.personality.PersonalityState; +import com.tiedup.remake.prison.LaborRecord; +import com.tiedup.remake.prison.PrisonerManager; +import com.tiedup.remake.prison.PrisonerRecord; +import com.tiedup.remake.prison.PrisonerState; +import com.tiedup.remake.prison.service.BondageService; +import com.tiedup.remake.state.CollarRegistry; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.ICaptor; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.util.KidnappedHelper; +import java.util.*; +import org.jetbrains.annotations.Nullable; +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * Centralized prisoner lifecycle service. + * + * Manages ALL prisoner state transitions atomically: + * - PrisonerManager (SavedData state) + * - CellRegistryV2 (cell assignment) + * - Leash/captor refs (IBondageState / ICaptor) + * - Escape detection (tick) + * + * Replaces EscapeService and unifies scattered capture/imprison/extract/return/transfer logic. + * + * AI goals manage: Navigation, Teleport, Equipment, Dialogue. + * PrisonerService manages: State transitions + leash coordination. + */ +public class PrisonerService { + + private static final PrisonerService INSTANCE = new PrisonerService(); + + public static PrisonerService get() { + return INSTANCE; + } + + private PrisonerService() {} + + // ==================== CONSTANTS (from EscapeService) ==================== + + /** Maximum distance from cell for IMPRISONED prisoners (blocks) */ + public static final double CELL_ESCAPE_DISTANCE = 20.0; + + /** Maximum distance from camp for WORKING prisoners (blocks) */ + public static final double WORK_ESCAPE_DISTANCE = 100.0; + + /** Maximum time offline before escape (30 minutes in ticks) */ + public static final long OFFLINE_TIMEOUT_TICKS = 30 * 60 * 20L; + + /** Maximum time in WORKING state before forced return (10 minutes in ticks) */ + public static final long WORK_TIMEOUT_TICKS = 10 * 60 * 20L; + + /** Maximum time in RETURNING phase before escape (5 minutes in ticks) */ + public static final long RETURN_TIMEOUT_TICKS = 5 * 60 * 20L; + + /** Maximum time in PENDING_RETURN phase before forced return (2.5 minutes in ticks) */ + public static final long PENDING_RETURN_TIMEOUT_TICKS = 3000; + + /** Check interval in ticks (every 5 seconds) */ + public static final int CHECK_INTERVAL_TICKS = 100; + + // ==================== CAPTURE ==================== + + /** + * Capture a free entity. + * FREE → CAPTURED + leash + captor refs. + * + * @param level Server level + * @param captive The entity being captured (must have IBondageState state) + * @param captor The capturing entity + * @return true if capture succeeded + */ + public boolean capture( + ServerLevel level, + LivingEntity captive, + ICaptor captor + ) { + UUID captiveId = captive.getUUID(); + PrisonerManager manager = PrisonerManager.get(level); + long gameTime = level.getGameTime(); + + // 1. PrisonerManager state transition: FREE → CAPTURED + if ( + !manager.capture(captiveId, captor.getEntity().getUUID(), gameTime) + ) { + TiedUpMod.LOGGER.warn( + "[PrisonerService] capture() failed PrisonerManager transition for {}", + captiveId.toString().substring(0, 8) + ); + return false; + } + + // 2. Leash attach + IBondageState kidnappedState = KidnappedHelper.getKidnappedState(captive); + if (kidnappedState == null) { + // Rollback + manager.escape( + captiveId, + gameTime, + "capture rollback: no kidnapped state" + ); + return false; + } + + boolean leashed; + if (captive instanceof AbstractTiedUpNpc npc) { + // NPC: use forceCapturedBy to bypass canCapture() PrisonerManager check + // (PrisonerManager state is already CAPTURED at this point) + leashed = npc.forceCapturedBy(captor); + } else { + // Player: getCapturedBy checks isEnslavable() first (OR logic), + // so canCapture() PrisonerManager check is bypassed if tied up. + // Generic IBondageState: same path. + leashed = kidnappedState.getCapturedBy(captor); + } + + if (!leashed) { + // Rollback PrisonerManager + manager.escape( + captiveId, + gameTime, + "capture rollback: leash failed" + ); + TiedUpMod.LOGGER.warn( + "[PrisonerService] capture() leash failed for {}, rolled back", + captiveId.toString().substring(0, 8) + ); + return false; + } + + TiedUpMod.LOGGER.info( + "[PrisonerService] Captured {} by {}", + captive.getName().getString(), + captor.getEntity().getName().getString() + ); + return true; + } + + // ==================== IMPRISONMENT ==================== + + /** + * Imprison a captured entity in a cell. + * CAPTURED → IMPRISONED + unleash + CellRegistryV2.assign. + * + * The caller handles: teleport, confiscation, bind, dialogue. + * + * @param level Server level + * @param captive The captured entity + * @param captor The current captor + * @param campId Camp UUID + * @param cell The target cell + * @return true if imprisonment succeeded + */ + public boolean imprison( + ServerLevel level, + LivingEntity captive, + ICaptor captor, + UUID campId, + CellDataV2 cell + ) { + if (cell == null) { + TiedUpMod.LOGGER.warn( + "[PrisonerService] imprison() called with null cell" + ); + return false; + } + + UUID captiveId = captive.getUUID(); + PrisonerManager manager = PrisonerManager.get(level); + CellRegistryV2 cellRegistry = CellRegistryV2.get(level); + long gameTime = level.getGameTime(); + + // 1. Assign to cell registry + boolean assigned = cellRegistry.assignPrisoner(cell.getId(), captiveId); + if (!assigned) { + TiedUpMod.LOGGER.warn( + "[PrisonerService] imprison() cell assignment failed for {}", + captiveId.toString().substring(0, 8) + ); + return false; + } + + // Defense: if prisoner is FREE, capture first + boolean defenseCaptured = false; + PrisonerRecord record = manager.getRecord(captiveId); + if (record.getState() == PrisonerState.FREE) { + TiedUpMod.LOGGER.info( + "[PrisonerService] {} is FREE, capturing before imprison", + captive.getName().getString() + ); + manager.capture(captiveId, captor.getEntity().getUUID(), gameTime); + defenseCaptured = true; + } + + // 2. PrisonerManager state transition: CAPTURED → IMPRISONED + boolean imprisoned = manager.imprison( + captiveId, + campId, + cell.getId(), + gameTime + ); + if (!imprisoned) { + // Rollback cell assignment + cellRegistry.releasePrisoner( + cell.getId(), + captiveId, + level.getServer() + ); + // Rollback defense auto-capture if we triggered it + if (defenseCaptured) { + manager.escape(captiveId, gameTime, "imprison_rollback"); + } + TiedUpMod.LOGGER.warn( + "[PrisonerService] imprison() PrisonerManager transition failed for {} (state={})", + captiveId.toString().substring(0, 8), + manager.getState(captiveId) + ); + return false; + } + + // 3. Unleash: remove captor refs and drop leash + IBondageState kidnappedState = KidnappedHelper.getKidnappedState(captive); + if (kidnappedState != null) { + kidnappedState.free(false); // Drop leash without dialogue/items + captor.removeCaptive(kidnappedState, false); + } + + // 4. Sync PersonalityState cellId for damsels + if (captive instanceof EntityDamsel damsel) { + PersonalityState pState = damsel.getPersonalityState(); + if (pState != null) { + pState.assignCell(cell.getId(), cell, damsel.getUUID()); + } + } + + TiedUpMod.LOGGER.info( + "[PrisonerService] Imprisoned {} in cell {} (camp={})", + captive.getName().getString(), + cell.getId().toString().substring(0, 8), + campId != null ? campId.toString().substring(0, 8) : "null" + ); + return true; + } + + // ==================== EXTRACTION ==================== + + /** + * Extract a prisoner from their cell. + * IMPRISONED → WORKING + CellRegistryV2.release + leash to new captor. + * + * For: dogwalk, maid fetch, labor extract. + * Note: If the entity is not tracked by PrisonerManager (e.g., damsels), + * the PrisonerManager step is skipped — only CellRegistryV2 + leash are handled. + * + * @param level Server level + * @param captive The imprisoned entity + * @param newCaptor The entity extracting (kidnapper or maid) + * @param cell The cell to extract from + * @return true if extraction succeeded + */ + public boolean extractFromCell( + ServerLevel level, + LivingEntity captive, + ICaptor newCaptor, + CellDataV2 cell + ) { + UUID captiveId = captive.getUUID(); + PrisonerManager manager = PrisonerManager.get(level); + CellRegistryV2 cellRegistry = CellRegistryV2.get(level); + long gameTime = level.getGameTime(); + + // 1. PrisonerManager: IMPRISONED → WORKING (players only — damsels aren't tracked) + boolean isPlayer = captive instanceof Player; + if (isPlayer) { + if (!manager.extract(captiveId, gameTime)) { + TiedUpMod.LOGGER.warn( + "[PrisonerService] extractFromCell() PrisonerManager transition failed for {}", + captiveId.toString().substring(0, 8) + ); + return false; + } + } + + // 2. Release from cell registry + cellRegistry.releasePrisoner( + cell.getId(), + captiveId, + level.getServer() + ); + + // 3. Leash to new captor — forceCapturedBy for damsels + IBondageState kidnappedState = KidnappedHelper.getKidnappedState(captive); + if (kidnappedState == null) { + // Rollback + if (isPlayer) { + manager.returnToCell(captiveId, cell.getId(), gameTime); + } + cellRegistry.assignPrisoner(cell.getId(), captiveId); + TiedUpMod.LOGGER.warn( + "[PrisonerService] extractFromCell() no kidnapped state for {}, rolled back", + captiveId.toString().substring(0, 8) + ); + return false; + } + + boolean leashed; + if (captive instanceof AbstractTiedUpNpc npc) { + leashed = npc.forceCapturedBy(newCaptor); + } else { + leashed = kidnappedState.getCapturedBy(newCaptor); + } + + if (!leashed) { + // Rollback: return to IMPRISONED + reassign cell + if (isPlayer) { + manager.returnToCell(captiveId, cell.getId(), gameTime); + } + cellRegistry.assignPrisoner(cell.getId(), captiveId); + TiedUpMod.LOGGER.warn( + "[PrisonerService] extractFromCell() leash failed for {}, rolled back", + captiveId.toString().substring(0, 8) + ); + return false; + } + + TiedUpMod.LOGGER.info( + "[PrisonerService] Extracted {} from cell {} by {}", + captive.getName().getString(), + cell.getId().toString().substring(0, 8), + newCaptor.getEntity().getName().getString() + ); + return true; + } + + // ==================== RETURN TO CELL ==================== + + /** + * Return a prisoner to their cell. + * WORKING → IMPRISONED + unleash + CellRegistryV2.assign. + * + * For: dogwalk return, labor return. + * Note: If the entity is not tracked by PrisonerManager (e.g., damsels), + * the PrisonerManager step is skipped — only unleash + CellRegistryV2 are handled. + * + * @param level Server level + * @param captive The working entity + * @param currentCaptor The current captor holding the leash + * @param cell The cell to return to + * @return true if return succeeded + */ + public boolean returnToCell( + ServerLevel level, + LivingEntity captive, + @Nullable ICaptor currentCaptor, + CellDataV2 cell + ) { + UUID captiveId = captive.getUUID(); + PrisonerManager manager = PrisonerManager.get(level); + CellRegistryV2 cellRegistry = CellRegistryV2.get(level); + long gameTime = level.getGameTime(); + + // 1. Unleash + IBondageState kidnappedState = KidnappedHelper.getKidnappedState(captive); + if (kidnappedState != null && kidnappedState.isCaptive()) { + kidnappedState.free(false); + } + if (currentCaptor != null && kidnappedState != null) { + currentCaptor.removeCaptive(kidnappedState, false); + } + + // 2. PrisonerManager: WORKING → IMPRISONED (players only — damsels aren't tracked) + if (captive instanceof Player) { + boolean returned = manager.returnToCell( + captiveId, + cell.getId(), + gameTime + ); + if (!returned) { + // State might not be WORKING (escape during return, etc.) + // Force-repair: try capture+imprison path + PrisonerRecord record = manager.getRecord(captiveId); + PrisonerState currentState = record.getState(); + TiedUpMod.LOGGER.warn( + "[PrisonerService] returnToCell() transition failed for {} (state={}). Force-repairing.", + captiveId.toString().substring(0, 8), + currentState + ); + + if (currentState == PrisonerState.FREE) { + UUID captorId = + currentCaptor != null + ? currentCaptor.getEntity().getUUID() + : captiveId; + manager.capture(captiveId, captorId, gameTime); + UUID campId = record.getCampId(); + manager.imprison(captiveId, campId, cell.getId(), gameTime); + } else if (currentState == PrisonerState.CAPTURED) { + UUID campId = record.getCampId(); + manager.imprison(captiveId, campId, cell.getId(), gameTime); + } + // If IMPRISONED already — no transition needed, just reassign cell + } + } + + // 3. Reassign to cell registry + cellRegistry.assignPrisoner(cell.getId(), captiveId); + + TiedUpMod.LOGGER.info( + "[PrisonerService] Returned {} to cell {}", + captive.getName().getString(), + cell.getId().toString().substring(0, 8) + ); + return true; + } + + // ==================== TRANSFER ==================== + + /** + * Transfer a captive from one captor to another. + * PrisonerManager state unchanged. + * + * For: maid→buyer, kidnapper→kidnapper. + * + * @param level Server level + * @param captive The captive entity + * @param oldCaptor The current captor + * @param newCaptor The new captor + * @return true if transfer succeeded + */ + public boolean transferCaptive( + ServerLevel level, + LivingEntity captive, + ICaptor oldCaptor, + ICaptor newCaptor + ) { + IBondageState kidnappedState = KidnappedHelper.getKidnappedState(captive); + if (kidnappedState == null) { + TiedUpMod.LOGGER.warn( + "[PrisonerService] transferCaptive() no kidnapped state for {}", + captive.getName().getString() + ); + return false; + } + + // 1. Remove from old captor + oldCaptor.removeCaptive(kidnappedState, false); + + // 2. Clear leash + kidnappedState.free(false); + + // 3. Re-leash to new captor + boolean leashed; + if (captive instanceof AbstractTiedUpNpc npc) { + leashed = npc.forceCapturedBy(newCaptor); + } else { + leashed = kidnappedState.getCapturedBy(newCaptor); + } + + if (!leashed) { + // Rollback: try to re-leash to old captor + TiedUpMod.LOGGER.warn( + "[PrisonerService] transferCaptive() re-leash to new captor failed, attempting rollback" + ); + if ( + captive instanceof AbstractTiedUpNpc npc + ) { + npc.forceCapturedBy(oldCaptor); + } else { + kidnappedState.getCapturedBy(oldCaptor); + } + return false; + } + + TiedUpMod.LOGGER.info( + "[PrisonerService] Transferred {} from {} to {}", + captive.getName().getString(), + oldCaptor.getEntity().getName().getString(), + newCaptor.getEntity().getName().getString() + ); + return true; + } + + // ==================== ESCAPE (from EscapeService) ==================== + + /** + * CENTRAL ESCAPE METHOD - All escapes must go through here. + * + * Handles complete cleanup: + * - State transition (PrisonerManager) + * - Cell registry cleanup + * - Guard despawn + * - Restraints removal (if online) + * - Inventory NOT restored (stays in camp chest as punishment) + * + * @param level The server level + * @param playerId Player UUID + * @param reason Reason for escape (for logging) + * @return true if escape was processed successfully + */ + public boolean escape(ServerLevel level, UUID playerId, String reason) { + long currentTime = level.getGameTime(); + PrisonerManager manager = PrisonerManager.get(level); + CellRegistryV2 cellRegistry = CellRegistryV2.get(level); + + TiedUpMod.LOGGER.info( + "[PrisonerService] Processing escape for {} - reason: {}", + playerId.toString().substring(0, 8), + reason + ); + + // Step 1: Save guard ID BEFORE state transition (manager.escape() removes the LaborRecord) + LaborRecord laborBeforeEscape = manager.getLaborRecord(playerId); + UUID guardId = laborBeforeEscape.getGuardId(); + + // Step 2: Transition prisoner state to FREE + boolean stateChanged = manager.escape(playerId, currentTime, reason); + if (!stateChanged) { + TiedUpMod.LOGGER.warn( + "[PrisonerService] Failed to change state for {} - invalid transition", + playerId.toString().substring(0, 8) + ); + return false; + } + + // Step 3: Cleanup CellRegistryV2 - remove from all cells + int cellsCleared = cellRegistry.releasePrisonerFromAllCells(playerId); + if (cellsCleared > 0) { + TiedUpMod.LOGGER.debug( + "[PrisonerService] Cleared {} from {} cells", + playerId.toString().substring(0, 8), + cellsCleared + ); + } + + // Step 4: Cleanup guard using saved reference (LaborRecord was removed by manager.escape()) + if (guardId != null) { + net.minecraft.world.entity.Entity guardEntity = level.getEntity( + guardId + ); + if (guardEntity != null) { + guardEntity.discard(); + TiedUpMod.LOGGER.debug( + "[PrisonerService] Despawned guard {} during escape", + guardId.toString().substring(0, 8) + ); + } + } + + // Step 5: Free from restraints (if player is online) + // NOTE: Inventory is NOT restored on escape - items remain in camp chest as punishment + ServerPlayer player = level + .getServer() + .getPlayerList() + .getPlayer(playerId); + if (player != null) { + IBondageState cap = KidnappedHelper.getKidnappedState(player); + if (cap != null) { + cap.free(false); + TiedUpMod.LOGGER.info( + "[PrisonerService] Freed {} from restraints (inventory remains in camp chest)", + player.getName().getString() + ); + } + } else { + TiedUpMod.LOGGER.debug( + "[PrisonerService] Player {} offline - restraints cleanup deferred", + playerId.toString().substring(0, 8) + ); + } + + TiedUpMod.LOGGER.info( + "[PrisonerService] Escape complete for {} - items stay in camp chest", + playerId.toString().substring(0, 8) + ); + + return true; + } + + // ==================== RELEASE (from EscapeService) ==================== + + /** + * CENTRAL RELEASE METHOD - All legitimate releases must go through here. + * + * Similar to escape() but transitions to PROTECTED state with grace period. + * Handles complete cleanup: + * - State transition to PROTECTED (PrisonerManager) + * - Cell registry cleanup + * - Inventory restoration + * - Restraints removal (if online) + * + * @param level The server level + * @param playerId Player UUID + * @param gracePeriodTicks Grace period before returning to FREE (0 = instant FREE) + * @return true if release was processed successfully + */ + public boolean release( + ServerLevel level, + UUID playerId, + long gracePeriodTicks + ) { + long currentTime = level.getGameTime(); + PrisonerManager manager = PrisonerManager.get(level); + CellRegistryV2 cellRegistry = CellRegistryV2.get(level); + com.tiedup.remake.cells.ConfiscatedInventoryRegistry inventoryRegistry = + com.tiedup.remake.cells.ConfiscatedInventoryRegistry.get(level); + + TiedUpMod.LOGGER.info( + "[PrisonerService] Processing release for {} - grace period: {} ticks", + playerId.toString().substring(0, 8), + gracePeriodTicks + ); + + // Step 1: Transition prisoner state to PROTECTED (or FREE if gracePeriod = 0) + boolean stateChanged = manager.release( + playerId, + currentTime, + gracePeriodTicks + ); + if (!stateChanged) { + TiedUpMod.LOGGER.warn( + "[PrisonerService] Failed to change state for {} - invalid transition", + playerId.toString().substring(0, 8) + ); + return false; + } + + // Step 2: Cleanup CellRegistryV2 - remove from all cells + int cellsCleared = cellRegistry.releasePrisonerFromAllCells(playerId); + if (cellsCleared > 0) { + TiedUpMod.LOGGER.debug( + "[PrisonerService] Cleared {} from {} cells", + playerId.toString().substring(0, 8), + cellsCleared + ); + } + + // Step 3: Restore confiscated inventory (if player is online) + ServerPlayer player = level + .getServer() + .getPlayerList() + .getPlayer(playerId); + if (player != null) { + // Restore inventory + if (inventoryRegistry.hasConfiscatedInventory(playerId)) { + boolean restored = inventoryRegistry.restoreInventory(player); + if (restored) { + TiedUpMod.LOGGER.info( + "[PrisonerService] Restored confiscated inventory for {}", + player.getName().getString() + ); + } + } + + // Free from restraints + IBondageState cap = KidnappedHelper.getKidnappedState(player); + if (cap != null) { + cap.free(false); + TiedUpMod.LOGGER.debug( + "[PrisonerService] Freed {} from restraints", + player.getName().getString() + ); + } + } else { + TiedUpMod.LOGGER.debug( + "[PrisonerService] Player {} offline - inventory cleanup deferred", + playerId.toString().substring(0, 8) + ); + } + + TiedUpMod.LOGGER.info( + "[PrisonerService] Release complete for {} - full cleanup done", + playerId.toString().substring(0, 8) + ); + + return true; + } + + // ==================== TICK (escape detection, from EscapeService) ==================== + + /** + * Tick escape detection. + * Called from CampManagementHandler. + * + * @param server The server + * @param currentTime Current game time + */ + public void tick(MinecraftServer server, long currentTime) { + if (currentTime % CHECK_INTERVAL_TICKS != 0) { + return; + } + + ServerLevel level = server.overworld(); + PrisonerManager manager = PrisonerManager.get(level); + CellRegistryV2 cells = CellRegistryV2.get(level); + CollarRegistry collars = CollarRegistry.get(level); + + List escapees = new ArrayList<>(); + + for (UUID playerId : manager.getAllPrisonerIds()) { + PrisonerRecord record = manager.getRecord(playerId); + PrisonerState state = record.getState(); + + if (!state.isCaptive()) { + continue; + } + + ServerPlayer player = server.getPlayerList().getPlayer(playerId); + + // === OFFLINE CHECK === + if (player == null) { + long timeInState = record.getTimeInState(currentTime); + if (timeInState > OFFLINE_TIMEOUT_TICKS) { + escapees.add( + new EscapeCandidate(playerId, "offline timeout") + ); + } + continue; + } + + // === COLLAR CHECK === + if (state != PrisonerState.CAPTURED) { + boolean hasCollarOwners = collars.hasOwners(playerId); + if (!hasCollarOwners) { + IBondageState cap = KidnappedHelper.getKidnappedState(player); + ItemStack collar = + cap != null ? cap.getEquipment(BodyRegionV2.NECK) : ItemStack.EMPTY; + + if ( + !collar.isEmpty() && + collar.getItem() instanceof + com.tiedup.remake.items.base.ItemCollar collarItem + ) { + List nbtOwners = collarItem.getOwners(collar); + if (!nbtOwners.isEmpty()) { + for (UUID ownerUUID : nbtOwners) { + collars.registerCollar(playerId, ownerUUID); + } + TiedUpMod.LOGGER.info( + "[PrisonerService] Re-synced collar registry for {} - found {} owners in NBT", + playerId.toString().substring(0, 8), + nbtOwners.size() + ); + continue; + } + } + + TiedUpMod.LOGGER.warn( + "[PrisonerService] Collar check failed for {} (state={}): collarEquipped={}, collarItem={}", + playerId.toString().substring(0, 8), + state, + !collar.isEmpty(), + collar.isEmpty() ? "none" : collar.getItem().toString() + ); + escapees.add( + new EscapeCandidate(playerId, "collar removed") + ); + continue; + } + } + + // === STATE-SPECIFIC CHECKS === + switch (state) { + case IMPRISONED -> { + UUID cellId = record.getCellId(); + if (cellId != null) { + CellDataV2 cell = cells.getCell(cellId); + if (cell == null) { + escapees.add( + new EscapeCandidate( + playerId, + "cell no longer exists" + ) + ); + TiedUpMod.LOGGER.warn( + "[PrisonerService] Prisoner {} has orphaned cellId {} - triggering escape", + playerId.toString().substring(0, 8), + cellId.toString().substring(0, 8) + ); + } else { + double distance = Math.sqrt( + player + .blockPosition() + .distSqr(cell.getCorePos()) + ); + if (distance > CELL_ESCAPE_DISTANCE) { + escapees.add( + new EscapeCandidate( + playerId, + String.format( + "too far from cell (%.1f blocks)", + distance + ) + ) + ); + } + } + } + } + case WORKING -> { + LaborRecord labor = manager.getLaborRecord(playerId); + + long timeInPhase = labor.getTimeInPhase(currentTime); + if ( + labor.getPhase() == LaborRecord.WorkPhase.WORKING && + timeInPhase > WORK_TIMEOUT_TICKS + ) { + labor.failTask(currentTime); + TiedUpMod.LOGGER.info( + "[PrisonerService] {} work timeout - forcing return", + playerId.toString().substring(0, 8) + ); + continue; + } + + if ( + labor.getPhase() == LaborRecord.WorkPhase.RETURNING && + timeInPhase > RETURN_TIMEOUT_TICKS + ) { + escapees.add( + new EscapeCandidate(playerId, "return timeout") + ); + continue; + } + + // PENDING_RETURN timeout: maid failed to pick up prisoner — force return to cell + if ( + labor.getPhase() == + LaborRecord.WorkPhase.PENDING_RETURN && + timeInPhase > PENDING_RETURN_TIMEOUT_TICKS + ) { + PrisonerRecord rec = manager.getRecord(playerId); + UUID cellId = rec.getCellId(); + if (cellId != null) { + CellDataV2 cell = cells.getCell(cellId); + if (cell != null) { + BlockPos teleportPos = + cell.getSpawnPoint() != null + ? cell.getSpawnPoint() + : cell.getCorePos().above(); + player.teleportTo( + teleportPos.getX() + 0.5, + teleportPos.getY(), + teleportPos.getZ() + 0.5 + ); + IBondageState cap = + KidnappedHelper.getKidnappedState(player); + if (cap != null) { + CompoundTag snapshot = + labor.getBondageSnapshot(); + if (snapshot != null) { + BondageService.get().restoreSnapshot( + cap, + snapshot + ); + } + } + returnToCell(level, player, null, cell); + labor.startRest(currentTime); + if (labor.getGuardId() != null) { + net.minecraft.world.entity.Entity guardEntity = + level.getEntity(labor.getGuardId()); + if ( + guardEntity != null + ) guardEntity.discard(); + } + player.sendSystemMessage( + Component.literal( + "You have been returned to your cell." + ).withStyle(ChatFormatting.GRAY) + ); + TiedUpMod.LOGGER.info( + "[PrisonerService] Force-returned {} to cell (PENDING_RETURN timeout)", + playerId.toString().substring(0, 8) + ); + continue; + } + } + // Cell gone → trigger escape + escapees.add( + new EscapeCandidate( + playerId, + "pending_return timeout, cell gone" + ) + ); + continue; + } + + if (labor.getGuardId() != null) { + net.minecraft.world.entity.Entity guardEntity = + level.getEntity(labor.getGuardId()); + if (guardEntity != null && guardEntity.isAlive()) { + continue; + } + } + + UUID campId = record.getCampId(); + if (campId != null) { + List campCells = cells.getCellsByCamp( + campId + ); + if (campCells.isEmpty()) { + escapees.add( + new EscapeCandidate( + playerId, + "camp no longer exists" + ) + ); + TiedUpMod.LOGGER.warn( + "[PrisonerService] Worker {} has orphaned campId {} with no cells - triggering escape", + playerId.toString().substring(0, 8), + campId.toString().substring(0, 8) + ); + } else { + BlockPos campCenter = campCells.get(0).getCorePos(); + double distance = Math.sqrt( + player.blockPosition().distSqr(campCenter) + ); + if (distance > WORK_ESCAPE_DISTANCE) { + escapees.add( + new EscapeCandidate( + playerId, + String.format( + "too far from camp (%.1f blocks)", + distance + ) + ) + ); + } + } + } + } + case CAPTURED -> { + // During transport - handled by kidnapper goals + } + default -> { + // Other states don't need distance checks + } + } + } + + for (EscapeCandidate candidate : escapees) { + escape(level, candidate.playerId, candidate.reason); + } + } + + // ==================== VALIDATION ==================== + + /** + * Check if a player should be considered escaped. + * Called on-demand (e.g., when player moves). + */ + @Nullable + public String checkEscape(ServerLevel level, ServerPlayer player) { + PrisonerManager manager = PrisonerManager.get(level); + CellRegistryV2 cells = CellRegistryV2.get(level); + CollarRegistry collars = CollarRegistry.get(level); + + UUID playerId = player.getUUID(); + PrisonerRecord record = manager.getRecord(playerId); + PrisonerState state = record.getState(); + + if (!state.isCaptive()) { + return null; + } + + if (!collars.hasOwners(playerId)) { + return "collar removed"; + } + + switch (state) { + case IMPRISONED -> { + UUID cellId = record.getCellId(); + if (cellId != null) { + CellDataV2 cell = cells.getCell(cellId); + if (cell != null) { + double distance = Math.sqrt( + player.blockPosition().distSqr(cell.getCorePos()) + ); + if (distance > CELL_ESCAPE_DISTANCE) { + return String.format( + "too far from cell (%.1f blocks)", + distance + ); + } + } + } + } + case WORKING -> { + UUID campId = record.getCampId(); + if (campId != null) { + List campCells = cells.getCellsByCamp(campId); + if (!campCells.isEmpty()) { + BlockPos campCenter = campCells.get(0).getCorePos(); + double distance = Math.sqrt( + player.blockPosition().distSqr(campCenter) + ); + if (distance > WORK_ESCAPE_DISTANCE) { + return String.format( + "too far from camp (%.1f blocks)", + distance + ); + } + } + } + } + default -> { + // Other states don't have distance limits + } + } + + return null; + } + + /** + * Handle player movement event. + * Called from event handler to check distance-based escapes. + */ + public void onPlayerMove(ServerPlayer player, ServerLevel level) { + String escapeReason = checkEscape(level, player); + if (escapeReason != null) { + escape(level, player.getUUID(), escapeReason); + } + } + + // ==================== HELPER ==================== + + private record EscapeCandidate(UUID playerId, String reason) {} +} diff --git a/src/main/java/com/tiedup/remake/state/CollarRegistry.java b/src/main/java/com/tiedup/remake/state/CollarRegistry.java new file mode 100644 index 0000000..a2db0b9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/CollarRegistry.java @@ -0,0 +1,386 @@ +package com.tiedup.remake.state; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.level.saveddata.SavedData; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Global registry for collar ownership relationships. + * + * This registry tracks which entities are wearing collars and who owns them. + * It persists across server restarts and provides efficient lookups in both directions: + * - Owner UUID → Set of collar-wearer UUIDs (slaves) + * - Wearer UUID → Set of owner UUIDs (masters) + * + * Terminology: + * - "Slave" = Entity wearing a collar owned by a player (passive ownership) + * - "Captive" = Entity attached by leash (active physical control) - managed by PlayerCaptiveManager + * + * Phase 17: Terminology Refactoring + */ +public class CollarRegistry extends SavedData { + + private static final String DATA_NAME = "tiedup_collar_registry"; + + // Owner UUID → Set of wearer UUIDs (a master can own multiple slaves) + private final Map> ownerToWearers = + new ConcurrentHashMap<>(); + + // Wearer UUID → Set of owner UUIDs (a collar can have multiple owners) + private final Map> wearerToOwners = + new ConcurrentHashMap<>(); + + // ==================== STATIC ACCESS ==================== + + /** + * Get the CollarRegistry for a server level. + * Creates a new registry if one doesn't exist. + */ + public static CollarRegistry get(ServerLevel level) { + return level + .getDataStorage() + .computeIfAbsent( + CollarRegistry::load, + CollarRegistry::new, + DATA_NAME + ); + } + + /** + * Get the CollarRegistry from a MinecraftServer. + * Uses the overworld as the storage dimension. + */ + public static CollarRegistry get(MinecraftServer server) { + ServerLevel overworld = server.overworld(); + return get(overworld); + } + + /** + * Convenience method to get registry from a ServerPlayer. + */ + @Nullable + public static CollarRegistry get(ServerPlayer player) { + if (player.getServer() == null) return null; + return get(player.getServer()); + } + + // ==================== REGISTRATION ==================== + + /** + * Register a collar relationship: owner now owns the collar on wearer. + * + * @param wearerUUID UUID of the entity wearing the collar + * @param ownerUUID UUID of the collar's owner + */ + public void registerCollar(UUID wearerUUID, UUID ownerUUID) { + // Add to owner → wearers map + ownerToWearers + .computeIfAbsent(ownerUUID, k -> ConcurrentHashMap.newKeySet()) + .add(wearerUUID); + + // Add to wearer → owners map + wearerToOwners + .computeIfAbsent(wearerUUID, k -> ConcurrentHashMap.newKeySet()) + .add(ownerUUID); + + setDirty(); + } + + /** + * Register a collar with multiple owners at once. + * + * @param wearerUUID UUID of the entity wearing the collar + * @param ownerUUIDs Set of owner UUIDs + */ + public void registerCollar(UUID wearerUUID, Set ownerUUIDs) { + for (UUID ownerUUID : ownerUUIDs) { + registerCollar(wearerUUID, ownerUUID); + } + } + + /** + * Unregister a specific owner from a collar wearer. + * + * @param wearerUUID UUID of the entity wearing the collar + * @param ownerUUID UUID of the owner to remove + */ + private void unregisterOwner(UUID wearerUUID, UUID ownerUUID) { + // Remove from owner → wearers map using atomic operation + ownerToWearers.computeIfPresent(ownerUUID, (key, wearers) -> { + wearers.remove(wearerUUID); + return wearers.isEmpty() ? null : wearers; + }); + + // Remove from wearer → owners map using atomic operation + wearerToOwners.computeIfPresent(wearerUUID, (key, owners) -> { + owners.remove(ownerUUID); + return owners.isEmpty() ? null : owners; + }); + + setDirty(); + } + + /** + * Completely unregister a collar wearer (removes all owner relationships). + * Called when a collar is removed from an entity. + * + * @param wearerUUID UUID of the entity whose collar was removed + */ + public void unregisterWearer(UUID wearerUUID) { + Set owners = wearerToOwners.remove(wearerUUID); + if (owners != null) { + for (UUID ownerUUID : owners) { + // Use atomic operation for thread-safe removal + ownerToWearers.computeIfPresent(ownerUUID, (key, wearers) -> { + wearers.remove(wearerUUID); + return wearers.isEmpty() ? null : wearers; + }); + } + setDirty(); + } + } + + /** + * Update a wearer's owners completely (replaces all existing owners). + * Useful when collar NBT is the source of truth. + * + * @param wearerUUID UUID of the entity wearing the collar + * @param newOwnerUUIDs New set of owner UUIDs + */ + public void updateWearerOwners(UUID wearerUUID, Set newOwnerUUIDs) { + // Get current owners + Set currentOwners = wearerToOwners.get(wearerUUID); + if (currentOwners == null) { + currentOwners = Collections.emptySet(); + } + + // Find owners to remove + Set toRemove = new HashSet<>(currentOwners); + toRemove.removeAll(newOwnerUUIDs); + + // Find owners to add + Set toAdd = new HashSet<>(newOwnerUUIDs); + toAdd.removeAll(currentOwners); + + // Apply changes + for (UUID ownerUUID : toRemove) { + unregisterOwner(wearerUUID, ownerUUID); + } + for (UUID ownerUUID : toAdd) { + registerCollar(wearerUUID, ownerUUID); + } + } + + // ==================== QUERIES ==================== + + /** + * Get all slaves (collar wearers) owned by a specific owner. + * + * @param ownerUUID UUID of the owner + * @return Unmodifiable set of wearer UUIDs (never null) + */ + public Set getSlaves(UUID ownerUUID) { + Set wearers = ownerToWearers.get(ownerUUID); + if (wearers == null) return Collections.emptySet(); + return Collections.unmodifiableSet(new HashSet<>(wearers)); + } + + /** + * Get all owners of a specific collar wearer. + * + * @param wearerUUID UUID of the wearer + * @return Unmodifiable set of owner UUIDs (never null) + */ + public Set getOwners(UUID wearerUUID) { + Set owners = wearerToOwners.get(wearerUUID); + if (owners == null) return Collections.emptySet(); + return Collections.unmodifiableSet(new HashSet<>(owners)); + } + + /** + * Check if an owner has any slaves. + */ + public boolean hasSlaves(UUID ownerUUID) { + Set wearers = ownerToWearers.get(ownerUUID); + return wearers != null && !wearers.isEmpty(); + } + + /** + * Check if a wearer has any owners. + */ + public boolean hasOwners(UUID wearerUUID) { + Set owners = wearerToOwners.get(wearerUUID); + return owners != null && !owners.isEmpty(); + } + + /** + * Check if a specific owner owns a specific wearer. + */ + public boolean isOwner(UUID ownerUUID, UUID wearerUUID) { + Set wearers = ownerToWearers.get(ownerUUID); + return wearers != null && wearers.contains(wearerUUID); + } + + /** + * Get the count of slaves for an owner. + */ + public int getSlaveCount(UUID ownerUUID) { + Set wearers = ownerToWearers.get(ownerUUID); + return wearers == null ? 0 : wearers.size(); + } + + /** + * Get all registered wearers (for admin/debug purposes). + */ + public Set getAllWearers() { + return Collections.unmodifiableSet( + new HashSet<>(wearerToOwners.keySet()) + ); + } + + /** + * Get all registered owners (for admin/debug purposes). + */ + public Set getAllOwners() { + return Collections.unmodifiableSet( + new HashSet<>(ownerToWearers.keySet()) + ); + } + + // ==================== ENTITY RESOLUTION ==================== + + /** + * Find all slave entities for an owner that are currently loaded. + * This is useful for GUI and proximity-based actions. + * + * @param owner The owner player + * @return List of currently loaded slave entities + */ + public List findLoadedSlaves(ServerPlayer owner) { + List loadedSlaves = new ArrayList<>(); + Set slaveUUIDs = getSlaves(owner.getUUID()); + + MinecraftServer server = owner.getServer(); + if (server == null) return loadedSlaves; + + for (UUID slaveUUID : slaveUUIDs) { + Entity entity = findEntityByUUID(server, slaveUUID); + if (entity instanceof LivingEntity living) { + loadedSlaves.add(living); + } + } + + return loadedSlaves; + } + + /** + * Find an entity by UUID across all dimensions. + * Optimized: checks player list first (O(1)) before searching dimensions. + */ + @Nullable + private Entity findEntityByUUID(MinecraftServer server, UUID uuid) { + // Check player first - O(1) lookup + net.minecraft.server.level.ServerPlayer player = server + .getPlayerList() + .getPlayer(uuid); + if (player != null) { + return player; + } + + // Fallback: search dimensions for NPCs + for (ServerLevel level : server.getAllLevels()) { + Entity entity = level.getEntity(uuid); + if (entity != null) { + return entity; + } + } + return null; + } + + // ==================== PERSISTENCE ==================== + + @Override + public @NotNull CompoundTag save(@NotNull CompoundTag tag) { + ListTag registryList = new ListTag(); + + for (Map.Entry> entry : wearerToOwners.entrySet()) { + CompoundTag wearerTag = new CompoundTag(); + wearerTag.putUUID("wearer", entry.getKey()); + + ListTag ownersTag = new ListTag(); + for (UUID ownerUUID : entry.getValue()) { + CompoundTag ownerTag = new CompoundTag(); + ownerTag.putUUID("uuid", ownerUUID); + ownersTag.add(ownerTag); + } + wearerTag.put("owners", ownersTag); + registryList.add(wearerTag); + } + + tag.put("collar_registry", registryList); + return tag; + } + + public static CollarRegistry load(CompoundTag tag) { + CollarRegistry registry = new CollarRegistry(); + + ListTag registryList = tag.getList("collar_registry", Tag.TAG_COMPOUND); + for (int i = 0; i < registryList.size(); i++) { + CompoundTag wearerTag = registryList.getCompound(i); + UUID wearerUUID = wearerTag.getUUID("wearer"); + + ListTag ownersTag = wearerTag.getList("owners", Tag.TAG_COMPOUND); + for (int j = 0; j < ownersTag.size(); j++) { + CompoundTag ownerTag = ownersTag.getCompound(j); + UUID ownerUUID = ownerTag.getUUID("uuid"); + + // Register relationship (without marking dirty - we're loading) + registry.ownerToWearers + .computeIfAbsent(ownerUUID, k -> + ConcurrentHashMap.newKeySet() + ) + .add(wearerUUID); + registry.wearerToOwners + .computeIfAbsent(wearerUUID, k -> + ConcurrentHashMap.newKeySet() + ) + .add(ownerUUID); + } + } + + return registry; + } + + // ==================== DEBUG ==================== + + /** + * Get a debug string representation of the registry. + */ + public String toDebugString() { + StringBuilder sb = new StringBuilder(); + sb.append("CollarRegistry:\n"); + sb.append(" Owners: ").append(ownerToWearers.size()).append("\n"); + sb.append(" Wearers: ").append(wearerToOwners.size()).append("\n"); + + for (Map.Entry> entry : ownerToWearers.entrySet()) { + sb + .append(" Owner ") + .append(entry.getKey().toString().substring(0, 8)) + .append("... → ") + .append(entry.getValue().size()) + .append(" slaves\n"); + } + + return sb.toString(); + } +} diff --git a/src/main/java/com/tiedup/remake/state/HumanChairHelper.java b/src/main/java/com/tiedup/remake/state/HumanChairHelper.java new file mode 100644 index 0000000..73aa51b --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/HumanChairHelper.java @@ -0,0 +1,61 @@ +package com.tiedup.remake.state; + +import com.tiedup.remake.items.base.PoseType; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.item.ItemStack; + +/** + * Centralizes human chair NBT tag keys and resolution logic. + * + *

The human chair mode is stored as NBT tags on the dog bind item. + * This helper eliminates hardcoded string literals scattered across 7+ files. + */ +public final class HumanChairHelper { + + /** NBT key indicating this bind is in human chair mode */ + public static final String NBT_KEY = "humanChairMode"; + + /** NBT key for the locked facing direction (degrees) */ + public static final String NBT_FACING_KEY = "humanChairFacing"; + + private HumanChairHelper() {} + + /** + * Check if a bind item is in human chair mode. + * + * @param bind The bind ItemStack to check + * @return true if the bind has humanChairMode NBT set to true + */ + public static boolean isActive(ItemStack bind) { + if (bind.isEmpty()) return false; + CompoundTag tag = bind.getTag(); + return tag != null && tag.getBoolean(NBT_KEY); + } + + /** + * Get the locked facing direction for human chair mode. + * + * @param bind The bind ItemStack + * @return The facing angle in degrees, or 0 if not set + */ + public static float getFacing(ItemStack bind) { + if (bind.isEmpty()) return 0f; + CompoundTag tag = bind.getTag(); + return tag != null ? tag.getFloat(NBT_FACING_KEY) : 0f; + } + + /** + * Resolve the effective pose type, overriding DOG to HUMAN_CHAIR + * when the bind has humanChairMode enabled. + * + * @param base The base pose type from the bind item + * @param bind The bind ItemStack (checked for humanChairMode NBT) + * @return HUMAN_CHAIR if base is DOG and humanChairMode is active, otherwise base + */ + public static PoseType resolveEffectivePose(PoseType base, ItemStack bind) { + if (base == PoseType.DOG && isActive(bind)) { + return PoseType.HUMAN_CHAIR; + } + return base; + } +} diff --git a/src/main/java/com/tiedup/remake/state/IBondageState.java b/src/main/java/com/tiedup/remake/state/IBondageState.java new file mode 100644 index 0000000..7b23ef4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/IBondageState.java @@ -0,0 +1,553 @@ +package com.tiedup.remake.state; + +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.v2.BodyRegionV2; +import java.util.function.Supplier; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * Equipment CRUD, state checks, bulk operations, resistance, and lock safety + * for bondage items on kidnapped entities. + * + *

This is the largest sub-interface, covering:

+ *
    + *
  • State query methods (isTiedUp, isGagged, etc.)
  • + *
  • Bind mode defaults (hasArmsBound, hasLegsBound, getBindModeId)
  • + *
  • Deprecated per-slot put-on / take-off / get-current / replace methods
  • + *
  • Bulk operations (applyBondage, untie, dropBondageItems)
  • + *
  • Post-apply callbacks (checkXAfterApply defaults)
  • + *
  • Lock safety helpers (ifUnlocked, isLocked, ifUnlockedReturn, takeBondageItemIfUnlocked)
  • + *
  • Clothes permissions
  • + *
  • Resistance system defaults
  • + *
+ * + * @see ICapturable + * @see IRestrainableEntity + * @see IRestrainable + */ +public interface IBondageState extends ICapturable { + + // ======================================== + // V2 REGION-BASED EQUIPMENT ACCESS + // ======================================== + + /** + * Get the item equipped in a V2 body region. + * + * @param region The body region to query + * @return The equipped ItemStack, or {@link ItemStack#EMPTY} if empty + */ + ItemStack getEquipment(BodyRegionV2 region); + + /** + * Equip an item in a V2 body region. + * Dispatches to the appropriate slot-specific equip logic (sounds, hooks, sync). + * + * @param region The body region to equip into + * @param stack The ItemStack to equip + */ + void equip(BodyRegionV2 region, ItemStack stack); + + /** + * Unequip the item from a V2 body region, respecting locks. + * + * @param region The body region to unequip from + * @return The removed ItemStack, or {@link ItemStack#EMPTY} if locked/empty + */ + ItemStack unequip(BodyRegionV2 region); + + /** + * Force-unequip the item from a V2 body region, ignoring locks. + * Used for admin commands, kidnapper theft, kill cleanup, etc. + * + * @param region The body region to force-unequip from + * @return The removed ItemStack, or {@link ItemStack#EMPTY} if empty + */ + ItemStack forceUnequip(BodyRegionV2 region); + + /** + * Replace the item in a body region, returning the old item. + * + * @param region The body region + * @param newStack The new ItemStack to equip + * @param force If true, replace even if current item is locked + * @return The old ItemStack, or empty if locked (and !force) or nothing equipped + */ + ItemStack replaceEquipment(BodyRegionV2 region, ItemStack newStack, boolean force); + + // ======================================== + // STATE QUERIES - BONDAGE EQUIPMENT + // ======================================== + + /** + * Check if entity has bind/ropes equipped. + * @return true if BIND slot is not empty + */ + boolean isTiedUp(); + + /** + * Check if entity's arms are bound. + * True if tied with mode ARMS or FULL. + * Leg Binding System. + * + * @return true if arms are bound + */ + default boolean hasArmsBound() { + if (!isTiedUp()) return false; + ItemStack bind = getEquipment(BodyRegionV2.ARMS); + if (bind.isEmpty()) return false; + return ItemBind.hasArmsBound(bind); + } + + /** + * Check if entity's legs are bound. + * True if tied with mode LEGS or FULL. + * Leg Binding System. + * + * @return true if legs are bound + */ + default boolean hasLegsBound() { + if (!isTiedUp()) return false; + ItemStack bind = getEquipment(BodyRegionV2.ARMS); + if (bind.isEmpty()) return false; + return ItemBind.hasLegsBound(bind); + } + + /** + * Get the bind mode ID string. + * Leg Binding System. + * + * @return "full", "arms", or "legs" + */ + default String getBindModeId() { + if (!isTiedUp()) return ItemBind.BIND_MODE_FULL; + ItemStack bind = getEquipment(BodyRegionV2.ARMS); + if (bind.isEmpty()) return ItemBind.BIND_MODE_FULL; + return ItemBind.getBindModeId(bind); + } + + /** + * Check if entity is gagged. + * @return true if GAG slot is not empty + */ + boolean isGagged(); + + /** + * Check if entity is blindfolded. + * @return true if BLINDFOLD slot is not empty + */ + boolean isBlindfolded(); + + /** + * Check if entity has earplugs. + * @return true if EARPLUGS slot is not empty + */ + boolean hasEarplugs(); + + /** + * Check if entity has collar. + * @return true if COLLAR slot is not empty + */ + boolean hasCollar(); + + /** + * Check if entity is wearing a locked collar. + * @return true if has collar AND collar is locked + */ + boolean hasLockedCollar(); + + /** + * Check if entity's collar has a custom name/nickname. + * Used for collar ownership display. + * + * @return true if collar has NBT nickname + */ + boolean hasNamedCollar(); + + /** + * Check if entity has clothes equipped. + * @return true if CLOTHES slot is not empty + */ + boolean hasClothes(); + + /** + * Check if entity has mittens equipped. + * Phase 14.4: Mittens system + * @return true if MITTENS slot is not empty + */ + boolean hasMittens(); + + /** + * Check if clothes have "small arms" rendering flag. + * Used by EntityDamsel for custom model rendering. + * + * @return true if clothes enable small arms mode + */ + boolean hasClothesWithSmallArms(); + + /** + * Check if entity is both tied AND gagged. + * Common check for "fully restrained" state. + * + * @return true if {@code isTiedUp() && isGagged()} + */ + boolean isBoundAndGagged(); + + /** + * Check if gag item has gagging sound effect. + * @return true if gag implements ItemGaggingEffect + */ + boolean hasGaggingEffect(); + + /** + * Check if blindfold item has blinding visual effect. + * @return true if blindfold implements IHasBlindingEffect + */ + boolean hasBlindingEffect(); + + /** + * Check if entity has knives in inventory (for struggling). + * @return true if inventory contains knife items + */ + boolean hasKnives(); + + // ======================================== + // BULK OPERATIONS + // ======================================== + + /** + * Apply a full set of bondage equipment at once. + * + *

Typically used when:

+ *
    + *
  • EntityKidnapper captures a player
  • + *
  • Trapped bed activates
  • + *
  • Rope arrow hits target
  • + *
  • Admin command applies full restraint
  • + *
+ * + * @param bind Bind ItemStack (or empty) + * @param gag Gag ItemStack (or empty) + * @param blindfold Blindfold ItemStack (or empty) + * @param earplugs Earplugs ItemStack (or empty) + * @param collar Collar ItemStack (or empty) + * @param clothes Clothes ItemStack (or empty) + */ + void applyBondage( + ItemStack bind, + ItemStack gag, + ItemStack blindfold, + ItemStack earplugs, + ItemStack collar, + ItemStack clothes + ); + + /** + * Untie this entity, removing all bondage items. + * + * @param drop If true, drop items on ground. If false, items are deleted. + */ + void untie(boolean drop); + + /** + * Drop all equipped bondage items on the ground. + * Equivalent to {@code dropBondageItems(true)}. + */ + void dropBondageItems(boolean drop); + + /** + * Drop specific bondage items with granular control. + * + * @param drop If false, skip all drops (no-op) + * @param dropBind If true, drop bind item + */ + void dropBondageItems(boolean drop, boolean dropBind); + + /** + * Drop bondage items with full granular control. + * + * @param drop If false, skip all drops (no-op) + * @param dropBind If true, drop bind + * @param dropGag If true, drop gag + * @param dropBlindfold If true, drop blindfold + * @param dropEarplugs If true, drop earplugs + * @param dropCollar If true, drop collar + * @param dropClothes If true, drop clothes + */ + void dropBondageItems( + boolean drop, + boolean dropBind, + boolean dropGag, + boolean dropBlindfold, + boolean dropEarplugs, + boolean dropCollar, + boolean dropClothes + ); + + /** + * Drop only clothes item. + */ + void dropClothes(); + + /** + * Count how many bondage items can be removed (are not locked). + * + * @return Count of removable items (0-6) + */ + int getBondageItemsWhichCanBeRemovedCount(); + + // ======================================== + // CLOTHES PERMISSION SYSTEM + // ======================================== + + /** + * Check if a player can remove clothes from this entity. + * Used for permission checks in multiplayer. + * + * @param player The player attempting to remove clothes + * @return true if allowed + */ + boolean canTakeOffClothes(Player player); + + /** + * Check if a player can change clothes on this entity. + * + * @param player The player attempting to change clothes + * @return true if allowed + */ + boolean canChangeClothes(Player player); + + /** + * Check if clothes can be changed (global permission). + * + * @return true if clothes are changeable + */ + boolean canChangeClothes(); + + // ======================================== + // POST-APPLY CALLBACKS + // ======================================== + + /** + * Called after a bind is applied. + * Implementations can trigger additional effects (sounds, particles, etc.). + * + * Phase 14.1.7: Added missing callback (was documented but not defined) + */ + default void checkBindAfterApply() { + // Default: no-op + // NPCs don't need post-apply logic by default + } + + /** + * Called after a gag is applied. + * Implementations can trigger additional effects (sounds, particles, etc.). + */ + default void checkGagAfterApply() { + // Default: no-op + // NPCs don't need post-apply logic by default + } + + /** + * Called after a blindfold is applied. + */ + default void checkBlindfoldAfterApply() { + // Default: no-op + } + + /** + * Called after earplugs are applied. + */ + default void checkEarplugsAfterApply() { + // Default: no-op + } + + /** + * Called after a collar is applied. + */ + default void checkCollarAfterApply() { + // Default: no-op + } + + /** + * Called after mittens are applied. + * Phase 14.4: Mittens system + */ + default void checkMittensAfterApply() { + // Default: no-op + // NPCs don't need post-apply logic by default + } + + // ======================================== + // LOCK SAFETY HELPERS + // ======================================== + + /** + * Execute a runnable only if the item is unlocked. + * + *

Lock Check: If {@code stack} is ILockable and locked, runnable is NOT executed.

+ * + *

Usage Example:

+ *
{@code
+     * ifUnlocked(currentGag, false, () -> {
+     *     // Remove gag code here
+     * });
+     * }
+ * + * @param stack The item to check + * @param force If true, ignore lock (NOT USED in default implementation - for consistency) + * @param run The code to execute if unlocked + */ + default void ifUnlocked(ItemStack stack, boolean force, Runnable run) { + if (!stack.isEmpty()) { + if ( + stack.getItem() instanceof ILockable lockable && + lockable.isLocked(stack) + ) { + return; // Locked - don't run + } + run.run(); // Unlocked or not lockable - safe to run + } + } + + /** + * Check if an item is locked. + * + * @param stack The item to check + * @param force If true, returns false (treat as unlocked) + * @return true if item is locked AND force is false + */ + default boolean isLocked(ItemStack stack, boolean force) { + return ( + !force && + stack.getItem() instanceof ILockable lockable && + lockable.isLocked(stack) + ); + } + + /** + * Return a value based on item lock state. + * + *

Usage Example:

+ *
{@code
+     * return ifUnlockedReturn(currentGag, false,
+     *     () -> removeAndReturnGag(),  // If unlocked
+     *     () -> ItemStack.EMPTY        // If locked
+     * );
+     * }
+ * + * @param stack The item to check + * @param force If true, always returns {@code run.get()} + * @param run Supplier to call if unlocked + * @param def Supplier to call if locked or stack is empty + * @param Return type + * @return Result from run or def supplier + */ + default T ifUnlockedReturn( + ItemStack stack, + boolean force, + Supplier run, + Supplier def + ) { + if (stack.isEmpty()) { + return def.get(); + } + if ( + stack.getItem() instanceof ILockable lockable && + lockable.isLocked(stack) && + !force + ) { + return def.get(); // Locked + } + return run.get(); // Unlocked + } + + /** + * Take a bondage item if it's unlocked, then drop it. + * + *

Helper method combining lock check + take + drop logic.

+ * + * @param stack The current item in slot + * @param takeOff Supplier that removes and returns the item + */ + default void takeBondageItemIfUnlocked( + ItemStack stack, + Supplier takeOff + ) { + if (stack.isEmpty()) return; + // Locked ILockable items cannot be removed + if (stack.getItem() instanceof ILockable lockable && lockable.isLocked(stack)) return; + // Non-ILockable items or unlocked items: remove and drop + ItemStack removed = takeOff.get(); + if (!removed.isEmpty()) { + kidnappedDropItem(removed); + } + } + + // ======================================== + // RESISTANCE SYSTEM (Phase 14.1.7) + // ======================================== + + /** + * Get current bind resistance value. + * + *

Resistance System:

+ * Higher resistance = harder to struggle out of restraints. + * Resistance decreases with each struggle attempt. + * When resistance reaches 0, the entity escapes. + * + *

For Players: Stored in NBT, persists across sessions + *

For NPCs: Default returns 0 (instant escape, can be overridden) + * + * Phase 14.1.7: Added to enable struggle system for NPCs + * + * @return Current resistance value (0 = can escape) + */ + default int getCurrentBindResistance() { + // Default for NPCs: 0 resistance (they escape instantly) + // PlayerBindState overrides this to return NBT-stored value + return 0; + } + + /** + * Set current bind resistance. + * + *

For Players: Updates NBT storage + *

For NPCs: Default no-op (no persistent resistance) + * + * Phase 14.1.7: Added to enable struggle system for NPCs + * + * @param resistance New resistance value + */ + default void setCurrentBindResistance(int resistance) { + // Default for NPCs: no-op (they don't store resistance) + // PlayerBindState overrides this to update NBT + } + + /** + * Get current collar resistance value. + * + *

Same as bind resistance but for collars. + * + * Phase 14.1.7: Added for collar struggle system + * + * @return Current collar resistance value + */ + default int getCurrentCollarResistance() { + // Default for NPCs: 0 resistance + return 0; + } + + /** + * Set current collar resistance. + * + * Phase 14.1.7: Added for collar struggle system + * + * @param resistance New resistance value + */ + default void setCurrentCollarResistance(int resistance) { + // Default for NPCs: no-op + } +} diff --git a/src/main/java/com/tiedup/remake/state/ICaptor.java b/src/main/java/com/tiedup/remake/state/ICaptor.java new file mode 100644 index 0000000..3309a1e --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/ICaptor.java @@ -0,0 +1,171 @@ +package com.tiedup.remake.state; + +import net.minecraft.world.entity.Entity; + +/** + * Phase 8: Master-Captive Relationships + * Phase 14.1.6: Refactored to use IRestrainable for NPC capture support + * Phase 17: Terminology refactoring - slave → captive + * C6-V2: Narrowed parameters from IRestrainable to IBondageState (minimum needed type) + * + * Interface for entities that can capture other entities (players or NPCs). + * + * Terminology (Phase 17): + * - "Captive" = Entity attached by leash (active physical control) + * - "Slave" = Entity wearing a collar owned by someone (passive ownership via CollarRegistry) + * + * Design Pattern: + * - Interface-based design allows both players (PlayerCaptorManager) + * and NPCs (EntityKidnapper - Phase 14.2+) to act as captors + * - Separates concerns: ICaptor manages captives, IBondageState is managed + * + * Implementation: + * - PlayerCaptorManager: For player captors (Phase 8) + * - EntityKidnapper: For NPC captors (Phase 14.2+) + * + * @see IBondageState + * @see PlayerCaptorManager + */ +public interface ICaptor { + // ======================================== + // Captive Management + // ======================================== + + /** + * Add a captive to this captor's captive list. + * Called when capture succeeds. + * + * Phase 14.1.6: Changed from PlayerBindState to IRestrainable + * Phase 17: Renamed from addSlave to addCaptive + * C6-V2: Narrowed from IRestrainable to IBondageState + * + * @param captive The IBondageState entity to capture + */ + void addCaptive(IBondageState captive); + + /** + * Remove a captive from this captor's captive list. + * Called when freeing a captive or when captive escapes. + * + * Phase 14.1.6: Changed from PlayerBindState to IRestrainable + * Phase 17: Renamed from removeSlave to removeCaptive + * C6-V2: Narrowed from IRestrainable to IBondageState + * + * @param captive The IBondageState captive to remove + * @param transportState If true, also despawn the transport entity + */ + void removeCaptive(IBondageState captive, boolean transportState); + + /** + * Check if this captor can capture the given target. + * + * Conditions (from original): + * - Target must be tied up OR have collar with this captor as owner + * - Target must not already be captured + * + * Phase 14.1.6: Changed from PlayerBindState to IRestrainable + * Phase 17: Renamed from canEnslave to canCapture + * C6-V2: Narrowed from IRestrainable to IBondageState + * + * @param target The potential IBondageState captive + * @return true if capture is allowed + */ + boolean canCapture(IBondageState target); + + /** + * Check if this captor can release the given captive. + * Only the current captor can release their captive. + * + * Phase 14.1.6: Changed from PlayerBindState to IRestrainable + * Phase 17: Renamed from canFree to canRelease + * C6-V2: Narrowed from IRestrainable to IBondageState + * + * @param captive The IBondageState captive to check + * @return true if this captor is the captive's captor + */ + boolean canRelease(IBondageState captive); + + // ======================================== + // Configuration + // ======================================== + + /** + * Whether this captor allows captives to be transferred to other captors. + * + * Phase 17: Renamed from allowSlaveTransfer to allowCaptiveTransfer + * + * @return true if captive transfer is allowed (default for players) + */ + boolean allowCaptiveTransfer(); + + /** + * Whether this captor can have multiple captives simultaneously. + * + * Phase 17: Renamed from allowMultipleSlaves to allowMultipleCaptives + * + * @return true if multiple captives allowed (default for players) + */ + boolean allowMultipleCaptives(); + + // ======================================== + // Event Callbacks + // ======================================== + + /** + * Called when a captive logs out while captured. + * Allows the captor to handle cleanup or persistence. + * + * Phase 14.1.6: Changed from PlayerBindState to IRestrainable + * Phase 17: Renamed from onSlaveLogout to onCaptiveLogout + * C6-V2: Narrowed from IRestrainable to IBondageState + * Note: For NPC captives, this may never be called (NPCs don't log out) + * + * @param captive The IBondageState captive that logged out + */ + void onCaptiveLogout(IBondageState captive); + + /** + * Called when a captive is released (freed). + * Allows the captor to react to losing a captive. + * + * Phase 14.1.6: Changed from PlayerBindState to IRestrainable + * Phase 17: Renamed from onSlaveReleased to onCaptiveReleased + * C6-V2: Narrowed from IRestrainable to IBondageState + * + * @param captive The IBondageState captive that was released + */ + void onCaptiveReleased(IBondageState captive); + + /** + * Called when a captive attempts to struggle. + * Allows the captor to react (e.g., shock collar activation). + * + * Phase 14.1.6: Changed from PlayerBindState to IRestrainable + * Phase 17: Renamed from onSlaveStruggle to onCaptiveStruggle + * C6-V2: Narrowed from IRestrainable to IBondageState + * + * @param captive The IBondageState captive that struggled + */ + void onCaptiveStruggle(IBondageState captive); + + // ======================================== + // Queries + // ======================================== + + /** + * Check if this captor currently has any captives. + * + * Phase 17: Renamed from hasSlaves to hasCaptives + * + * @return true if captive list is not empty + */ + boolean hasCaptives(); + + /** + * Get the entity representing this captor. + * Used for lead attachment and position queries. + * + * @return The entity (Player or custom entity) + */ + Entity getEntity(); +} diff --git a/src/main/java/com/tiedup/remake/state/ICapturable.java b/src/main/java/com/tiedup/remake/state/ICapturable.java new file mode 100644 index 0000000..9a7318d --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/ICapturable.java @@ -0,0 +1,166 @@ +package com.tiedup.remake.state; + +import org.jetbrains.annotations.Nullable; +import net.minecraft.world.entity.Entity; + +/** + * Capture, leash, and transport interface for kidnapped entities. + * + *

Covers the capture lifecycle: being captured by a captor, being freed, + * being transferred to another captor, and querying capture state.

+ * + * @see IRestrainableEntity + * @see IBondageState + * @see IRestrainable + */ +public interface ICapturable extends IRestrainableEntity { + + // ======================================== + // CAPTURE LIFECYCLE + // ======================================== + + /** + * Capture this entity by the given captor. + * + *

Prerequisites:

+ *
    + *
  • Must be tied up OR have collar with captor as owner
  • + *
  • Must not already be captured ({@link #isCaptive()} == false)
  • + *
  • Captor must allow capture ({@link ICaptor#canCapture(IRestrainable)})
  • + *
+ * + *

Process for Players:

+ *
    + *
  1. Validate conditions (tied up or has owner's collar)
  2. + *
  3. Transfer any existing captives to new captor (if allowed)
  4. + *
  5. Create LeashProxyEntity that follows the player
  6. + *
  7. Attach leash from proxy to captor entity
  8. + *
  9. Add captive to captor's captive list
  10. + *
+ * + *

Process for NPCs:

+ *
    + *
  1. Validate conditions
  2. + *
  3. Use vanilla setLeashedTo() directly on the NPC
  4. + *
+ * + * Phase 17: Renamed from getEnslavedBy to getCapturedBy + * + * @param captor The captor attempting to capture + * @return true if capture succeeded, false otherwise + */ + boolean getCapturedBy(ICaptor captor); + + /** + * Free this captive from capture. + * Equivalent to {@code free(true)}. + * + *

This will:

+ *
    + *
  • Despawn the transport entity
  • + *
  • Drop the lead item
  • + *
  • Remove from captor's captive list
  • + *
+ */ + void free(); + + /** + * Free this captive from capture with transport state option. + * + * @param transportState If true, despawn the transport entity. If false, keep it (for transfer). + */ + void free(boolean transportState); + + /** + * Transfer this captive to a new captor. + * Current captor loses the captive, new captor gains it. + * + *

Only works if current captor allows captive transfer + * ({@link ICaptor#allowCaptiveTransfer()} == true).

+ * + * Phase 17: Renamed from transferSlaveryTo to transferCaptivityTo + * + * @param newCaptor The new captor to transfer to + */ + void transferCaptivityTo(ICaptor newCaptor); + + // ======================================== + // STATE QUERIES - CAPTURE + // ======================================== + + /** + * Check if this entity can be captured. + * + *

From original code (PlayerBindState.java:195-225):

+ *
    + *
  • If tied up: Always capturable
  • + *
  • If NOT tied up: Only capturable if has collar AND collar has captor as owner
  • + *
+ * + * @return true if capturable + */ + boolean isEnslavable(); + + /** + * Check if this entity is currently captured (attached by leash). + * + *

For Players: Returns true when LeashProxyEntity is attached and leashed to captor

+ *

For NPCs: Returns true when vanilla leash is attached

+ * + * Phase 17: Renamed from isSlave to isCaptive + * + * @return true if captured (has leash holder) + */ + boolean isCaptive(); + + /** + * Check if this entity can be tied up (not already restrained). + * + * @return true if entity can accept bind items + */ + boolean canBeTiedUp(); + + /** + * Check if this entity is tied to a pole (immobilized). + * + * @return true if tied to a static pole entity + */ + boolean isTiedToPole(); + + /** + * Tie this entity to the closest fence/pole within range. + * Searches for fence blocks near the entity's current position. + * + * @param searchRadius The radius in blocks to search for fences + * @return true if successfully tied to a pole + */ + boolean tieToClosestPole(int searchRadius); + + /** + * Check if this entity can be auto-kidnapped by events. + * Used by EntityKidnapper AI to determine valid targets. + * + * @return true if can be targeted by kidnapping events + */ + boolean canBeKidnappedByEvents(); + + /** + * Get the current captor (the entity holding the leash). + * + * Phase 17: Renamed from getMaster to getCaptor + * + * @return The captor, or null if not captured + */ + ICaptor getCaptor(); + + /** + * Get the leash proxy or transport entity for this captive. + * + *

For Players: Returns the LeashProxyEntity following the player

+ *

For NPCs: Returns null (NPCs use vanilla leash directly)

+ * + * @return The proxy/transport entity, or null if not applicable + */ + @Nullable + Entity getTransport(); +} diff --git a/src/main/java/com/tiedup/remake/state/ICoercible.java b/src/main/java/com/tiedup/remake/state/ICoercible.java new file mode 100644 index 0000000..8a2068a --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/ICoercible.java @@ -0,0 +1,102 @@ +package com.tiedup.remake.state; + +import net.minecraft.world.entity.player.Player; + +/** + * Active coercion interface for kidnapped entities. + * + *

Covers tightening binds, chloroform, electric shocks, + * and forceful item removal by another entity.

+ * + * @see IRestrainableEntity + * @see IRestrainable + */ +// C6-V2: takeBondageItemBy narrowed from IRestrainable to IRestrainableEntity +public interface ICoercible extends IRestrainableEntity { + + // ======================================== + // SPECIAL INTERACTIONS + // ======================================== + + /** + * Tighten binds on this entity (increase resistance). + * Called when master uses paddle/whip on slave. + * + *

Effects:

+ *
    + *
  • Resets bind resistance to maximum
  • + *
  • Plays slap/whip sound
  • + *
  • Shows message to entity
  • + *
+ * + * @param tightener The player tightening the binds + */ + void tighten(Player tightener); + + /** + * Apply chloroform effect to this entity. + * + *

Effects (from original):

+ *
    + *
  • Slowness effect (duration from parameter)
  • + *
  • Weakness effect
  • + *
  • Prevents movement/interaction
  • + *
+ * + * @param duration Effect duration in ticks + */ + void applyChloroform(int duration); + + /** + * Shock this kidnapped entity. + * Uses default damage and no message. + * + *

Effects:

+ *
    + *
  • Plays electric shock sound
  • + *
  • Applies damage (default: 1.0F)
  • + *
  • Shows shock particles (client-side)
  • + *
+ */ + void shockKidnapped(); + + /** + * Shock this kidnapped entity with custom message and damage. + * + * @param messageAddon Additional message to send to the entity + * @param damage Damage amount to apply + */ + void shockKidnapped(String messageAddon, float damage); + + /** + * Another entity takes a bondage item from this entity. + * Used when master removes items from slave. + * + * Phase 14.1.7: Changed from PlayerBindState to IRestrainable for polymorphism + * C6-V2: Narrowed from IRestrainable to IRestrainableEntity (only uses identity methods) + * This allows NPCs to take items from Players or other NPCs + * + * @param taker The IRestrainableEntity taking the item + * @param slotIndex The slot index (0-5: bind, gag, blindfold, earplugs, collar, clothes) + */ + void takeBondageItemBy(IRestrainableEntity taker, int slotIndex); + + // ======================================== + // COLLAR TIMERS (Phase 14.1.4) + // ======================================== + + /** + * Force-stops and clears any active auto-shock collar timer. + * + *

Called when:

+ *
    + *
  • GPS collar is removed
  • + *
  • Auto-shock collar is removed
  • + *
  • Entity is freed from slavery
  • + *
+ */ + default void resetAutoShockTimer() { + // Default no-op (NPCs don't have timers by default) + // PlayerBindState overrides this to clear timer + } +} diff --git a/src/main/java/com/tiedup/remake/state/IPlayerLeashAccess.java b/src/main/java/com/tiedup/remake/state/IPlayerLeashAccess.java new file mode 100644 index 0000000..39cb677 --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/IPlayerLeashAccess.java @@ -0,0 +1,81 @@ +package com.tiedup.remake.state; + +import com.tiedup.remake.entities.LeashProxyEntity; +import net.minecraft.world.entity.Entity; + +/** + * Interface for accessing leash-related fields injected into ServerPlayer via mixin. + * Cast any ServerPlayer to this interface to access leash functionality. + * + * NOTE: This interface is in a separate package from mixins because mixin packages + * cannot be referenced directly from regular code. + * + * Usage: + *
+ * if (player instanceof IPlayerLeashAccess access) {
+ *     access.tiedup$attachLeash(masterEntity);
+ * }
+ * 
+ */ +public interface IPlayerLeashAccess { + /** + * Attach a leash from this player to a holder entity. + * Creates a LeashProxyEntity if not already present. + * + * @param holder The entity holding the leash (master player, NPC, or fence knot) + */ + void tiedup$attachLeash(Entity holder); + + /** + * Detach the leash from this player. + * Discards the LeashProxyEntity. + */ + void tiedup$detachLeash(); + + /** + * Drop a lead item when detaching. + */ + void tiedup$dropLeash(); + + /** + * Check if this player is currently leashed. + * + * @return true if leashed to an entity + */ + boolean tiedup$isLeashed(); + + /** + * Get the entity holding this player's leash. + * + * @return The leash holder, or null if not leashed + */ + Entity tiedup$getLeashHolder(); + + /** + * Get the proxy entity used for leash rendering. + * + * @return The LeashProxyEntity, or null if not leashed + */ + LeashProxyEntity tiedup$getLeashProxy(); + + /** + * Tick the leash system - check validity and apply traction. + * Called from Forge TickEvent.PlayerTickEvent. + */ + void tiedup$tickLeash(); + + /** + * Set extra slack on the leash (increases pull start and max distances). + * Used during "pet leads" dogwalk so the player can walk ahead without being yanked back. + * + * @param slack Extra distance in blocks (0.0 = no slack) + */ + void tiedup$setLeashSlack(double slack); + + /** + * Get the current leash slack value. + * + * @return Extra slack distance in blocks + */ + double tiedup$getLeashSlack(); +} diff --git a/src/main/java/com/tiedup/remake/state/IRestrainable.java b/src/main/java/com/tiedup/remake/state/IRestrainable.java new file mode 100644 index 0000000..4d72385 --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/IRestrainable.java @@ -0,0 +1,40 @@ +package com.tiedup.remake.state; + +/** + * Union interface for entities that participate in the full restraint system. + * + *

Prefer using the narrowest sub-interface for your needs:

+ *
    + *
  • {@link IBondageState} — equipment, state checks, capture, identity (most common)
  • + *
  • {@link ICoercible} — shock, chloroform, tighten
  • + *
  • {@link ISaleable} — sale system
  • + *
+ * + *

Use {@code IRestrainable} only when you genuinely need methods from multiple + * branches (e.g., both bondage state AND coercion, or both bondage AND sale).

+ * + *

Inheritance hierarchy:

+ *
+ * IRestrainableEntity (base)
+ *   +-- ICapturable extends IRestrainableEntity
+ *   |     +-- IBondageState extends ICapturable
+ *   +-- ICoercible extends IRestrainableEntity
+ *
+ * ISaleable (standalone)
+ *
+ * IRestrainable extends IBondageState, ICoercible, ISaleable
+ * 
+ * + *

Implementors: PlayerBindState, DamselBondageManager, + * MCAKidnappedAdapter

+ * + * @see IRestrainableEntity + * @see ICapturable + * @see IBondageState + * @see ICoercible + * @see ISaleable + * @see PlayerBindState + */ +public interface IRestrainable extends IBondageState, ICoercible, ISaleable { + // Empty union body — all methods inherited from sub-interfaces. +} diff --git a/src/main/java/com/tiedup/remake/state/IRestrainableEntity.java b/src/main/java/com/tiedup/remake/state/IRestrainableEntity.java new file mode 100644 index 0000000..289a1de --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/IRestrainableEntity.java @@ -0,0 +1,207 @@ +package com.tiedup.remake.state; + +import java.util.UUID; +import com.tiedup.remake.v2.BodyRegionV2; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * Base identity, lifecycle, and utility interface for kidnapped entities. + * + *

Provides the fundamental identity methods ({@link #asLivingEntity()}, + * {@link #getKidnappedUniqueId()}, etc.), death handling, and default + * helper methods for communication and movement queries.

+ * + *

Also hosts the V2 region-based equipment API defaults, which delegate + * to {@code V2EquipmentHelper} and work automatically for Players.

+ * + * @see ICapturable + * @see IBondageState + * @see ICoercible + * @see IRestrainable + */ +public interface IRestrainableEntity { + + // ======================================== + // ENTITY IDENTITY + // ======================================== + + /** + * Get the underlying LivingEntity. + * + *

For Players: return the player + *

For NPCs: return this (cast to LivingEntity) + * + * @return The LivingEntity backing this IRestrainable instance + */ + LivingEntity asLivingEntity(); + + /** + * Get the unique ID of this kidnapped entity. + * + * @return The entity's UUID + */ + UUID getKidnappedUniqueId(); + + /** + * Get the display name of this kidnapped entity. + * For players: player name. For NPCs: entity custom name or default name. + * + * @return The entity's name as string + */ + String getKidnappedName(); + + /** + * Get the name from the collar's NBT tag (nickname). + * If collar has no nickname, returns empty string or entity name. + * + * @return The collar nickname, or fallback name + */ + String getNameFromCollar(); + + // ======================================== + // LIFECYCLE + // ======================================== + + /** + * Called when this entity dies while kidnapped. + * + *

From original (PlayerBindState.java:1395-1461):

+ *
    + *
  • Unlock all locked items
  • + *
  • Drop bondage items (if configured)
  • + *
  • Free from slavery
  • + *
  • Reset state
  • + *
+ * + * @param world The world the entity died in + * @return true if death was handled as kidnapped entity + */ + boolean onDeathKidnapped(Level world); + + // ======================================== + // UTILITY + // ======================================== + + /** + * Drop an item at this entity's position. + * Helper method for dropping bondage items. + * + * @param stack The ItemStack to drop + */ + void kidnappedDropItem(ItemStack stack); + + /** + * Teleport this entity to a specific position. + * Used by collar teleport commands and warp points. + * + * @param position The target position (x, y, z, dimension) + */ + void teleportToPosition(com.tiedup.remake.util.teleport.Position position); + + // ======================================== + // DEFAULT HELPER METHODS - COMMUNICATION & STATE + // ======================================== + + /** + * Send a message to this entity (if it can receive messages). + * + *

For Players: Uses displayClientMessage() + *

For NPCs: No-op (NPCs don't need visual feedback) + * + * @param message The message component to send + * @param actionBar If true, shows message in action bar; if false, shows in chat + */ + default void sendMessage(Component message, boolean actionBar) { + LivingEntity entity = asLivingEntity(); + if (entity instanceof Player player) { + player.displayClientMessage(message, actionBar); + } + // NPCs: silent no-op (acceptable degradation) + } + + /** + * Check if entity is currently sprinting. + * + *

For Players: player.isSprinting() + *

For NPCs: Checks movement speed + * + * @return true if entity is sprinting + */ + default boolean isSprinting() { + LivingEntity entity = asLivingEntity(); + if (entity instanceof Player player) { + return player.isSprinting(); + } + // For NPCs: check movement speed + return entity.getDeltaMovement().horizontalDistanceSqr() > 0.1; + } + + /** + * Check if entity is currently swimming. + * + * @return true if entity is swimming + */ + default boolean isSwimming() { + return asLivingEntity().isSwimming(); + } + + // ==================== V2 REGION-BASED API ==================== + // These default methods delegate to V2EquipmentHelper. + // They work automatically for Players (V2 capability attached since Epic 0). + // For Damsels, requires IV2EquipmentHolder implementation (Epic 4B). + + /** + * Get all V2 equipped items (de-duplicated). + * @return Unmodifiable map of region to ItemStack, or empty map if no V2 support. + */ + default java.util.Map getAllEquippedV2() { + com.tiedup.remake.v2.bondage.IV2BondageEquipment equip = + com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getEquipment(asLivingEntity()); + return equip != null ? equip.getAllEquipped() : java.util.Map.of(); + } + + /** + * Get V2 item in a specific body region. + */ + default ItemStack getItemInRegion(com.tiedup.remake.v2.BodyRegionV2 region) { + return com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper + .getInRegion(asLivingEntity(), region); + } + + /** + * Equip a V2 item to its declared regions. Server-only. + * @return The equip result. + */ + default com.tiedup.remake.v2.bondage.V2EquipResult equipToRegion(ItemStack stack) { + return com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper + .equipItem(asLivingEntity(), stack); + } + + /** + * Unequip V2 item from a region. Server-only. + */ + default ItemStack unequipFromRegion(com.tiedup.remake.v2.BodyRegionV2 region) { + return com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper + .unequipFromRegion(asLivingEntity(), region); + } + + /** + * Unequip V2 item from a region with force option. Server-only. + */ + default ItemStack unequipFromRegion(com.tiedup.remake.v2.BodyRegionV2 region, boolean force) { + return com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper + .unequipFromRegion(asLivingEntity(), region, force); + } + + /** + * Whether this entity has V2 equipment support. + */ + default boolean hasV2Support() { + return com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper + .getEquipment(asLivingEntity()) != null; + } +} diff --git a/src/main/java/com/tiedup/remake/state/ISaleable.java b/src/main/java/com/tiedup/remake/state/ISaleable.java new file mode 100644 index 0000000..404330b --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/ISaleable.java @@ -0,0 +1,39 @@ +package com.tiedup.remake.state; + +import com.tiedup.remake.util.tasks.ItemTask; +import org.jetbrains.annotations.Nullable; + +/** + * Standalone sale state interface for entities that can be put up for sale. + * + * @see IRestrainable + */ +public interface ISaleable { + + /** + * Check if this entity is marked for sale by a captor. + * + * @return true if captive is being sold + */ + boolean isForSell(); + + /** + * Get the sale price for this captive. + * + * @return The sale price ItemTask, or null if not for sale + */ + @Nullable + ItemTask getSalePrice(); + + /** + * Mark this captive as for sale with the given price. + * + * @param price The sale price + */ + void putForSale(ItemTask price); + + /** + * Cancel the sale and reset sale state. + */ + void cancelSale(); +} diff --git a/src/main/java/com/tiedup/remake/state/PlayerBindState.java b/src/main/java/com/tiedup/remake/state/PlayerBindState.java new file mode 100644 index 0000000..f402813 --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/PlayerBindState.java @@ -0,0 +1,1283 @@ +package com.tiedup.remake.state; + +import com.tiedup.remake.core.ModSounds; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.LeashProxyEntity; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.network.sync.SyncManager; +import com.tiedup.remake.state.components.PlayerCaptivity; +import com.tiedup.remake.state.components.PlayerClothesPermission; +import com.tiedup.remake.state.components.PlayerDataRetrieval; +import com.tiedup.remake.state.components.PlayerEquipment; +import com.tiedup.remake.state.components.PlayerLifecycle; +import com.tiedup.remake.state.components.PlayerSale; +import com.tiedup.remake.state.components.PlayerShockCollar; +import com.tiedup.remake.state.components.PlayerSpecialActions; +import com.tiedup.remake.state.components.PlayerStateQuery; +import com.tiedup.remake.state.components.PlayerStruggle; +import com.tiedup.remake.state.components.PlayerTaskManagement; +import com.tiedup.remake.state.hosts.IPlayerBindStateHost; +import com.tiedup.remake.state.struggle.StruggleBinds; +import com.tiedup.remake.tasks.PlayerStateTask; +import com.tiedup.remake.tasks.TimedInteractTask; +import com.tiedup.remake.tasks.TyingTask; +import com.tiedup.remake.tasks.UntyingTask; +import com.tiedup.remake.util.RestraintEffectUtils; +import com.tiedup.remake.util.teleport.Position; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +/** + * Core state management for player restraints and bondage equipment. + * Singleton pattern - one instance per player, tracked by UUID. + * + * Based on original PlayerBindState from 1.12.2. + * + * Responsibilities: + * - Track player restraint states (tied, gagged, blindfolded, etc.) + * - Manage bondage equipment lifecycle (put on/take off items) + * - Lifecycle management (connection, death, respawn) + * - Phase 8: Enslavement lifecycle (can be enslaved, act as master) + * - Phase 13: Advanced collar features (shocks, GPS tracking) + * + * Thread Safety: This class is accessed from both server and client threads. + * Use appropriate synchronization when accessing the instances map. + * + * Refactoring: Component-Host pattern (Phase 1 in progress) + */ +public class PlayerBindState implements IRestrainable, IPlayerBindStateHost { + + // ========== Singleton Pattern ========== + + /** Server-side instances - one per player. Thread-safe map with atomic computeIfAbsent. */ + private static final Map instances = + new ConcurrentHashMap<>(); + /** Client-side instances - one per player. Thread-safe map with atomic computeIfAbsent. */ + private static final Map instancesClient = + new ConcurrentHashMap<>(); + + // Note: playersBeingFreed removed - no longer needed with proxy-based leash system + + /** + * Get or create a PlayerBindState instance for a player. + * Phase 15: Updates player reference when entity is recreated (e.g., after observer reconnects). + * + * @param player The player entity + * @return The state instance associated with this player + */ + @Nullable + public static PlayerBindState getInstance(Player player) { + if (player == null) return null; + Map map = player.level().isClientSide + ? instancesClient + : instances; + UUID uuid = player.getUUID(); + PlayerBindState state = map.computeIfAbsent(uuid, k -> + new PlayerBindState(player) + ); + + // Phase 15: Update player reference if entity was recreated (reconnection scenario) + // This fixes the bug where remote players' animations don't appear after observer reconnects + if (state.player != player) { + state.player = player; + } + + return state; + } + + /** + * Clears all client-side instances. + * Must be called on world unload to prevent memory leaks from stale player references. + */ + public static void clearClientInstances() { + instancesClient.clear(); + } + + /** + * Cleans up the instance map when a player leaves the server. + */ + public static void removeInstance(UUID uuid, boolean isClient) { + Map map = isClient + ? instancesClient + : instances; + map.remove(uuid); + } + + // ========== Instance Fields ========== + + private final UUID playerUUID; + // volatile: accessed from multiple threads (network, tick, render) + private volatile Player player; + private boolean online; + + // ========== Phase 1 Components ========== + private final PlayerTaskManagement taskManagement; + private final PlayerStateQuery stateQuery; + private final PlayerDataRetrieval dataRetrieval; + private final PlayerSale sale; + + // ========== Phase 2 Components ========== + private final PlayerSpecialActions specialActions; + private final PlayerClothesPermission clothesPermission; + + // ========== Phase 3 Components ========== + private final PlayerEquipment equipment; + private final PlayerStruggle struggle; + private final PlayerShockCollar shockCollar; + + // ========== Phase 4 Components ========== + private final PlayerLifecycle lifecycle; + private final PlayerCaptivity captivity; + + // Phase 8: Enslavement fields + // Phase 17: master → captor, slaveHolderManager → captorManager + private ICaptor captor; + private PlayerCaptorManager captorManager; + // Note: transport field removed - now using IPlayerLeashAccess mixin + + // Struggle animation state + // volatile: accessed from multiple threads (network, tick, render) + private volatile boolean isStruggling = false; + private volatile long struggleStartTick = 0; + + // ========== Movement Style State (Phase: Movement Styles) ========== + // Managed exclusively by MovementStyleManager. Stored here to piggyback + // on existing lifecycle cleanup hooks (death, logout, dimension change). + + /** Currently active movement style, or null if none. */ + @Nullable + private com.tiedup.remake.v2.bondage.movement.MovementStyle activeMovementStyle; + + /** Resolved speed multiplier for the active style (0.0-1.0). */ + private float resolvedMovementSpeed = 1.0f; + + /** Whether jumping is currently disabled by the active style. */ + private boolean resolvedJumpDisabled = false; + + @Nullable + public com.tiedup.remake.v2.bondage.movement.MovementStyle getActiveMovementStyle() { + return activeMovementStyle; + } + + public void setActiveMovementStyle(@Nullable com.tiedup.remake.v2.bondage.movement.MovementStyle style) { + this.activeMovementStyle = style; + } + + public float getResolvedMovementSpeed() { + return resolvedMovementSpeed; + } + + public void setResolvedMovementSpeed(float speed) { + this.resolvedMovementSpeed = speed; + } + + public boolean isResolvedJumpDisabled() { + return resolvedJumpDisabled; + } + + public void setResolvedJumpDisabled(boolean disabled) { + this.resolvedJumpDisabled = disabled; + } + + /** Ticks until next hop is allowed (HOP style). */ + public int hopCooldown = 0; + + /** True during the 4-tick startup delay before the first hop. */ + public boolean hopStartupPending = false; + + /** Countdown ticks for the hop startup delay. */ + public int hopStartupTicks = 0; + + /** True if crawl deactivated but player can't stand yet (space blocked). */ + public boolean pendingPoseRestore = false; + + /** Last known X position for movement detection (position delta). */ + public double lastX; + + /** Last known Y position for movement detection (position delta). */ + public double lastY; + + /** Last known Z position for movement detection (position delta). */ + public double lastZ; + + /** Consecutive non-moving ticks counter for hop startup reset. */ + public int hopNotMovingTicks = 0; + + /** + * Resets all movement style state to defaults. + * Called on death, logout, and dimension change to ensure clean re-activation. + */ + public void clearMovementState() { + this.activeMovementStyle = null; + this.resolvedMovementSpeed = 1.0f; + this.resolvedJumpDisabled = false; + this.hopCooldown = 0; + this.hopStartupPending = false; + this.hopStartupTicks = 0; + this.pendingPoseRestore = false; + this.hopNotMovingTicks = 0; + } + + // ========== Constructor ========== + + private PlayerBindState(Player player) { + this.playerUUID = player.getUUID(); + this.player = player; + this.online = true; + + // Initialize Phase 1 components + this.taskManagement = new PlayerTaskManagement(); + this.stateQuery = new PlayerStateQuery(this); + this.dataRetrieval = new PlayerDataRetrieval(this); + this.sale = new PlayerSale(); + + // Initialize Phase 2 components + this.specialActions = new PlayerSpecialActions(this); + this.clothesPermission = new PlayerClothesPermission(this); + + // Initialize Phase 3 components + this.equipment = new PlayerEquipment(this); + this.struggle = new PlayerStruggle(this); + this.shockCollar = new PlayerShockCollar(this); + + // Initialize Phase 4 components + this.lifecycle = new PlayerLifecycle(this); + this.captivity = new PlayerCaptivity(this); + + // Phase 17: Enslavement and captive management + this.captor = null; + this.captorManager = new PlayerCaptorManager(player); + } + + // ========== State Query Methods ========== + // Delegated to PlayerStateQuery component + + /** Check if player has ropes/ties equipped. */ + @Override + public boolean isTiedUp() { + return stateQuery.isTiedUp(); + } + + /** Check if player is currently gagged. */ + public boolean isGagged() { + return stateQuery.isGagged(); + } + + /** Check if player is blindfolded. */ + public boolean isBlindfolded() { + return stateQuery.isBlindfolded(); + } + + /** Check if player has earplugs. */ + public boolean hasEarplugs() { + return stateQuery.hasEarplugs(); + } + + public boolean isEarplugged() { + return stateQuery.isEarplugged(); + } + + /** Check if player is wearing a collar. */ + @Override + public boolean hasCollar() { + return stateQuery.hasCollar(); + } + + /** Returns the current collar ItemStack, or empty if none. */ + public ItemStack getCurrentCollar() { + return stateQuery.getCurrentCollar(); + } + + public boolean hasClothes() { + return stateQuery.hasClothes(); + } + + /** Check if player has mittens equipped. Phase 14.4: Mittens system */ + public boolean hasMittens() { + return stateQuery.hasMittens(); + } + + // ========== Item Management Methods ========== + // Delegated to PlayerEquipment component + + /** Equips a bind item and applies speed reduction. */ + public void putBindOn(ItemStack bind) { + equipment.putBindOn(bind); + } + + /** Equips a gag (enables gag talk if implemented). Issue #14 fix: now calls onEquipped. */ + public void putGagOn(ItemStack gag) { + equipment.putGagOn(gag); + } + + /** Equips a blindfold (restricts vision). Issue #14 fix: now calls onEquipped. */ + public void putBlindfoldOn(ItemStack blindfold) { + equipment.putBlindfoldOn(blindfold); + } + + /** Equips a collar (starts GPS/Shock monitoring). Issue #14 fix: now calls onEquipped. */ + public void putCollarOn(ItemStack collar) { + equipment.putCollarOn(collar); + } + + /** Issue #14 fix: now calls onEquipped. Syncs clothes config to all clients. */ + public void putClothesOn(ItemStack clothes) { + equipment.putClothesOn(clothes); + } + + /** Equips mittens (blocks hand interactions). Phase 14.4: Mittens system. Issue #14 fix: now calls onEquipped. */ + public void putMittensOn(ItemStack mittens) { + equipment.putMittensOn(mittens); + } + + /** Removes binds and restores speed. */ + public ItemStack takeBindOff() { + return equipment.takeBindOff(); + } + + public ItemStack takeGagOff() { + return equipment.takeGagOff(); + } + + public ItemStack takeBlindfoldOff() { + return equipment.takeBlindfoldOff(); + } + + /** Removes mittens. Phase 14.4: Mittens system */ + public ItemStack takeMittensOff() { + return equipment.takeMittensOff(); + } + + /** Helper to drop an item at the kidnapped player's feet. */ + public void kidnappedDropItem(ItemStack stack) { + equipment.kidnappedDropItem(stack); + } + + // ========== Lifecycle Methods ========== + + /** + * Resets the player instance upon reconnection or respawn. + * Delegated to PlayerLifecycle component. + * + * IMPORTANT: Handle transport restoration for pole binding. + * With proxy-based leash system, leashes are not persisted through disconnection. + * The leash proxy is ephemeral and will be recreated if needed. + * + * Leg Binding: Speed reduction based on hasLegsBound(), not isTiedUp(). + */ + public void resetNewConnection(Player player) { + // Update player reference (must be done here in host) + this.player = player; + + // Clear movement style state so it re-resolves on the new entity + // (transient AttributeModifiers are lost when the entity is recreated, + // e.g., after dimension change or respawn) + clearMovementState(); + + // Delegate to lifecycle component + lifecycle.resetNewConnection(player); + } + + // onDeathKidnapped() removed - consolidated into IRestrainable override version below + + // ========== IPlayerBindStateHost Implementation ========== + // Note: Some methods are implemented elsewhere in the class with @Override + + @Override + public boolean isOnline() { + return online; + } + + @Override + public void setOnline(boolean online) { + this.online = online; + } + + @Override + public Player getPlayer() { + return player; + } + + @Override + public UUID getPlayerUUID() { + return playerUUID; + } + + @Override + public Level getLevel() { + return player != null ? player.level() : null; + } + + @Override + public boolean isClientSide() { + Level level = getLevel(); + return level != null && level.isClientSide; + } + + @Override + public void syncClothesConfig() { + SyncManager.syncClothesConfig(player); + } + + @Override + public void syncEnslavement() { + SyncManager.syncEnslavement(player); + } + + @Override + public IRestrainable getKidnapped() { + return this; // PlayerBindState implements IRestrainable + } + + @Override + public void setCaptor(ICaptor captor) { + this.captor = captor; + } + + // Note: getCaptor(), getCaptorManager(), setStruggling(), setStrugglingClient(), + // isStruggling(), getStruggleStartTick() are implemented elsewhere in the class + + // ========== Phase 6: Tying/Untying Task Methods ========== + // Delegated to PlayerTaskManagement component + + public TyingTask getCurrentTyingTask() { + return taskManagement.getCurrentTyingTask(); + } + + public void setCurrentTyingTask(TyingTask task) { + taskManagement.setCurrentTyingTask(task); + } + + public UntyingTask getCurrentUntyingTask() { + return taskManagement.getCurrentUntyingTask(); + } + + public void setCurrentUntyingTask(UntyingTask task) { + taskManagement.setCurrentUntyingTask(task); + } + + public PlayerStateTask getClientTyingTask() { + return taskManagement.getClientTyingTask(); + } + + public void setClientTyingTask(PlayerStateTask task) { + taskManagement.setClientTyingTask(task); + } + + public PlayerStateTask getClientUntyingTask() { + return taskManagement.getClientUntyingTask(); + } + + public void setClientUntyingTask(PlayerStateTask task) { + taskManagement.setClientUntyingTask(task); + } + + public TimedInteractTask getCurrentFeedingTask() { + return taskManagement.getCurrentFeedingTask(); + } + + public void setCurrentFeedingTask(TimedInteractTask task) { + taskManagement.setCurrentFeedingTask(task); + } + + public PlayerStateTask getClientFeedingTask() { + return taskManagement.getClientFeedingTask(); + } + + public void setClientFeedingTask(PlayerStateTask task) { + taskManagement.setClientFeedingTask(task); + } + + public PlayerStateTask getRestrainedState() { + return taskManagement.getRestrainedState(); + } + + public void setRestrainedState(PlayerStateTask state) { + taskManagement.setRestrainedState(state); + } + + // ========== Phase 7: Struggle & Resistance Methods ========== + // Delegated to PlayerStruggle component + + /** + * Entry point for the Struggle logic (Key R). + * Distributes effort between Binds and Collar. + */ + public void struggle() { + struggle.struggle(); + } + + /** Restores resistance to base values when a master tightens the ties. */ + public void tighten(Player tightener) { + struggle.tighten(tightener); + } + + /** + * Get the StruggleBinds instance for external access (mini-game system). + */ + public StruggleBinds getStruggleBinds() { + return struggle.getStruggleBinds(); + } + + /** + * Set a cooldown on struggle attempts (used after mini-game exhaustion). + * @param seconds Cooldown duration in seconds + */ + public void setStruggleCooldown(int seconds) { + struggle.setStruggleCooldown(seconds); + } + + /** + * v2.5: Check if struggle cooldown is active. + * @return true if cooldown is active (cannot struggle yet) + */ + public boolean isStruggleCooldownActive() { + return struggle.isStruggleCooldownActive(); + } + + /** + * v2.5: Get remaining struggle cooldown in seconds. + * @return Remaining seconds, or 0 if no cooldown + */ + public int getStruggleCooldownRemaining() { + return struggle.getStruggleCooldownRemaining(); + } + + // ========== v2.5: Knife Cut Target Methods ========== + // Delegated to PlayerSpecialActions component + + /** + * Set the body region target for knife cutting. + * Used when player selects "Cut" from StruggleChoiceScreen. + * + * @param region The body region to cut (NECK, MOUTH, etc.) + */ + public void setKnifeCutTarget(BodyRegionV2 region) { + specialActions.setKnifeCutTarget(region); + } + + /** + * Get the current knife cut target region. + * + * @return The target region, or null if none + */ + public BodyRegionV2 getKnifeCutTarget() { + return specialActions.getKnifeCutTarget(); + } + + /** + * Clear the knife cut target. + */ + public void clearKnifeCutTarget() { + specialActions.clearKnifeCutTarget(); + } + + /** + * Check if player is currently playing struggle animation. + * Thread-safe (volatile field). + * IPlayerBindStateHost implementation. + */ + @Override + public boolean isStruggling() { + return isStruggling; + } + + /** + * Get the tick when struggle animation started. + * Thread-safe (volatile field). + * IPlayerBindStateHost implementation. + */ + @Override + public long getStruggleStartTick() { + return struggleStartTick; + } + + /** + * Set struggle animation state (server-side). + * IPlayerBindStateHost implementation. + * @param struggling True to start struggle animation, false to stop + * @param currentTick Current game time tick (for timer) + */ + @Override + public void setStruggling(boolean struggling, long currentTick) { + this.isStruggling = struggling; + if (struggling) { + this.struggleStartTick = currentTick; + } + } + + /** + * Set struggle animation flag (client-side only). + * IPlayerBindStateHost implementation. + * Used by network sync - does NOT update timer (server manages timer). + * @param struggling True if struggling, false otherwise + */ + @Override + public void setStrugglingClient(boolean struggling) { + this.isStruggling = struggling; + } + + /** + * Check if struggle animation should stop (duration expired). + * Delegated to PlayerStruggle component. + * @param currentTick Current game time tick + * @return True if animation has been playing for >= 80 ticks + */ + public boolean shouldStopStruggling(long currentTick) { + return struggle.shouldStopStruggling(currentTick); + } + + /** + * Phase 14.1.7: Now part of IRestrainable interface + * Delegated to PlayerEquipment component + */ + @Override + public synchronized int getCurrentBindResistance() { + return equipment.getCurrentBindResistance(); + } + + /** + * Phase 14.1.7: Now part of IRestrainable interface + * Delegated to PlayerEquipment component + */ + @Override + public synchronized void setCurrentBindResistance(int resistance) { + equipment.setCurrentBindResistance(resistance); + } + + /** + * Phase 14.1.7: Added for IRestrainable interface + * Delegated to PlayerEquipment component + */ + @Override + public synchronized int getCurrentCollarResistance() { + return equipment.getCurrentCollarResistance(); + } + + /** + * Phase 14.1.7: Added for IRestrainable interface + * Delegated to PlayerEquipment component + */ + @Override + public synchronized void setCurrentCollarResistance(int resistance) { + equipment.setCurrentCollarResistance(resistance); + } + + // ======================================== + // Phase 8: IRestrainable Implementation + // ======================================== + + /** + * Phase 17: Renamed from getEnslavedBy to getCapturedBy + * Initiates the capture process by a captor. + * Uses the proxy-based leash system (player is NOT mounted). + * Delegated to PlayerCaptivity component. + */ + @Override + public boolean getCapturedBy(ICaptor newCaptor) { + return captivity.getCapturedBy(newCaptor); + } + + @Override + public void free() { + captivity.free(); + } + + /** Phase 17: Ends captivity and detaches the leash proxy. + * Delegated to PlayerCaptivity component. */ + @Override + public void free(boolean dropLead) { + captivity.free(dropLead); + } + + /** + * Phase 17: Renamed from transferSlaveryTo to transferCaptivityTo + * Delegated to PlayerCaptivity component. + */ + @Override + public void transferCaptivityTo(ICaptor newCaptor) { + captivity.transferCaptivityTo(newCaptor); + } + + @Override + public boolean isEnslavable() { + return captivity.isEnslavable(); + } + + /** + * Phase 17: Renamed from isSlave to isCaptive + * Delegated to PlayerCaptivity component. + */ + @Override + public boolean isCaptive() { + return captivity.isCaptive(); + } + + /** + * Phase 17: Renamed from getMaster to getCaptor + * Also implements IPlayerBindStateHost. + */ + @Override + public ICaptor getCaptor() { + return captor; + } + + @Override + public LeashProxyEntity getTransport() { + return captivity.getTransport(); + } + + public ItemStack takeCollarOff() { + return takeCollarOff(false); + } + + /** + * Tries to remove the collar. Fails if locked unless forced. + * Delegated to PlayerEquipment component + */ + public ItemStack takeCollarOff(boolean force) { + return equipment.takeCollarOff(force); + } + + /** Checks if the wearer has a collar with the 'locked' NBT flag set. */ + @Override + public boolean hasLockedCollar() { + ItemStack collar = getCurrentCollar(); + return ( + !collar.isEmpty() && + collar.getItem() instanceof ItemCollar collarItem && + collarItem.isLocked(collar) + ); + } + + @Override + public UUID getKidnappedUniqueId() { + return playerUUID; + } + + @Override + public String getKidnappedName() { + return player.getName().getString(); + } + + // ======================================== + // Phase 13: Shock Functionality + // Delegated to PlayerShockCollar component + // ======================================== + + @Override + public void shockKidnapped() { + shockCollar.shockKidnapped(); + } + + /** + * Triggers a visual and auditory shock effect. + * Damage is applied (shock can kill). + */ + @Override + public void shockKidnapped(@Nullable String messageAddon, float damage) { + shockCollar.shockKidnapped(messageAddon, damage); + } + + /** + * Phase 17: Renamed from checkStillSlave to checkStillCaptive + * Periodically monitors captivity validity. + * Simplified: If any condition is invalid, free the captive immediately. + * Delegated to PlayerCaptivity component. + */ + public void checkStillCaptive() { + captivity.checkStillCaptive(); + } + + /** + * Periodic check for Auto-Shock intervals and GPS Safe Zones. + * Called from RestraintTaskTickHandler every player tick. + * Delegated to PlayerShockCollar component. + */ + public void checkAutoShockCollar() { + shockCollar.checkAutoShockCollar(); + } + + /** + * Force-stops and clears any active shock timers. + * Delegated to PlayerShockCollar component. + */ + public void resetAutoShockTimer() { + shockCollar.resetAutoShockTimer(); + } + + /** + * Phase 17: Renamed from getSlaveHolderManager to getCaptorManager + * Manager for capturing other entities (acting as captor). + * Also implements IPlayerBindStateHost. + */ + @Override + public PlayerCaptorManager getCaptorManager() { + return captorManager; + } + + // ======================================== + // IRestrainable Missing Methods + // ======================================== + + @Override + public void teleportToPosition(Position position) { + if (player == null || position == null) return; + + // Check if dimension change is needed + if (!player.level().dimension().equals(position.getDimension())) { + // Cross-dimension teleport + net.minecraft.server.level.ServerPlayer serverPlayer = + (net.minecraft.server.level.ServerPlayer) player; + net.minecraft.server.level.ServerLevel targetLevel = + serverPlayer.server.getLevel(position.getDimension()); + + if (targetLevel != null) { + serverPlayer.teleportTo( + targetLevel, + position.getX(), + position.getY(), + position.getZ(), + serverPlayer.getYRot(), + serverPlayer.getXRot() + ); + } + } else { + // Same dimension teleport + player.teleportTo( + position.getX(), + position.getY(), + position.getZ() + ); + } + + TiedUpMod.LOGGER.debug( + "[PlayerBindState] Teleported {} to {}", + player.getName().getString(), + position + ); + } + + @Override + public String getNameFromCollar() { + return dataRetrieval.getNameFromCollar(); + } + + // ======================================== + // V2 Region-Based Equipment Access + // ======================================== + + @Override + public ItemStack getEquipment(BodyRegionV2 region) { + return V2EquipmentHelper.getInRegion(player, region); + } + + @Override + public void equip(BodyRegionV2 region, ItemStack stack) { + switch (region) { + case ARMS -> equipment.putBindOn(stack); + case MOUTH -> equipment.putGagOn(stack); + case EYES -> equipment.putBlindfoldOn(stack); + case EARS -> equipment.putEarplugsOn(stack); + case NECK -> equipment.putCollarOn(stack); + case TORSO -> equipment.putClothesOn(stack); + case HANDS -> equipment.putMittensOn(stack); + default -> {} + } + } + + @Override + public ItemStack unequip(BodyRegionV2 region) { + return switch (region) { + case ARMS -> equipment.takeBindOff(); + case MOUTH -> equipment.takeGagOff(); + case EYES -> equipment.takeBlindfoldOff(); + case EARS -> equipment.takeEarplugsOff(); + case NECK -> equipment.takeCollarOff(false); + case TORSO -> equipment.takeClothesOff(); + case HANDS -> equipment.takeMittensOff(); + default -> ItemStack.EMPTY; + }; + } + + @Override + public ItemStack forceUnequip(BodyRegionV2 region) { + if (region == BodyRegionV2.NECK) { + return equipment.takeCollarOff(true); + } + if (region == BodyRegionV2.TORSO) { + // takeClothesOff has no lock check but has syncClothesConfig side effect + return equipment.takeClothesOff(); + } + // All other regions: bypass both V1 isLocked and V2 canUnequip checks + // by using V2EquipmentHelper directly with force=true + return V2EquipmentHelper.unequipFromRegion(player, region, true); + } + + // ======================================== + // IRestrainable State Queries + // Delegated to PlayerStateQuery component + // ======================================== + + @Override + public boolean canBeTiedUp() { + return stateQuery.canBeTiedUp(); + } + + @Override + public boolean isBoundAndGagged() { + return stateQuery.isBoundAndGagged(); + } + + @Override + public boolean hasKnives() { + return stateQuery.hasKnives(); + } + + // ======================================== + // Sale System - Delegated to PlayerSale component + // ======================================== + + @Override + public boolean isForSell() { + return sale.isForSell(); + } + + @Override + public com.tiedup.remake.util.tasks.ItemTask getSalePrice() { + return sale.getSalePrice(); + } + + @Override + public void putForSale(com.tiedup.remake.util.tasks.ItemTask price) { + sale.putForSale(price); + } + + @Override + public void cancelSale() { + sale.cancelSale(); + } + + @Override + public boolean isTiedToPole() { + // Check if leash proxy is attached to a fence knot (pole) + if (!(player instanceof IPlayerLeashAccess access)) return false; + if (!access.tiedup$isLeashed()) return false; + + net.minecraft.world.entity.Entity leashHolder = + access.tiedup$getLeashHolder(); + return ( + leashHolder instanceof + net.minecraft.world.entity.decoration.LeashFenceKnotEntity + ); + } + + @Override + public boolean tieToClosestPole(int searchRadius) { + if (player == null) return false; + return RestraintEffectUtils.tieToClosestPole(player, searchRadius); + } + + @Override + public boolean canBeKidnappedByEvents() { + return stateQuery.canBeKidnappedByEvents(); + } + + @Override + public boolean hasNamedCollar() { + return dataRetrieval.hasNamedCollar(); + } + + @Override + public boolean hasClothesWithSmallArms() { + return dataRetrieval.hasClothesWithSmallArms(); + } + + @Override + public boolean hasGaggingEffect() { + return stateQuery.hasGaggingEffect(); + } + + @Override + public boolean hasBlindingEffect() { + return stateQuery.hasBlindingEffect(); + } + + // ======================================== + // Equipment Take Off (local helpers) + // Delegated to PlayerEquipment component + // ======================================== + + public ItemStack takeEarplugsOff() { + return equipment.takeEarplugsOff(); + } + + public ItemStack takeClothesOff() { + return equipment.takeClothesOff(); + } + + // ======================================== + // IRestrainable Equipment Put On + // Delegated to PlayerEquipment component + // ======================================== + + /** Equips earplugs (muffles sounds). Issue #14 fix: now calls onEquipped. */ + public void putEarplugsOn(ItemStack earplugs) { + equipment.putEarplugsOn(earplugs); + } + + // ======================================== + // IRestrainable Equipment Replacement (V2 region-based) + // Delegated to PlayerEquipment component (except TORSO - handled by PlayerClothesPermission) + // ======================================== + + @Override + public synchronized ItemStack replaceEquipment(BodyRegionV2 region, ItemStack newStack, boolean force) { + // MEDIUM FIX: Synchronized to prevent race condition during equipment replacement + return switch (region) { + case ARMS -> equipment.replaceBind(newStack, force); + case MOUTH -> equipment.replaceGag(newStack, force); + case EYES -> equipment.replaceBlindfold(newStack, force); + case EARS -> equipment.replaceEarplugs(newStack, force); + case NECK -> equipment.replaceCollar(newStack, force); + case TORSO -> clothesPermission.replaceClothes(newStack, force); + case HANDS -> equipment.replaceMittens(newStack, force); + default -> ItemStack.EMPTY; + }; + } + + // ======================================== + // IRestrainable Bulk Operations + // ======================================== + + @Override + public void untie(boolean drop) { + if (drop) { + dropBondageItems(true); + } else { + // Clear all V2 equipment slots without dropping (no lifecycle hooks) + var v2Equip = V2EquipmentHelper.getEquipment(player); + if (v2Equip != null) { + v2Equip.clearAll(); + V2EquipmentHelper.sync(player); + } + } + + // V1 speed reduction handled by MovementStyleManager (V2 tick-based). + // See H6 fix — removing V1 calls prevents double stacking. + + // Phase 17: Free from captivity if applicable + if (isCaptive()) { + free(); + } + // Also detach leash if tied to pole (no captor but still leashed) + else if ( + player instanceof IPlayerLeashAccess access && + access.tiedup$isLeashed() + ) { + access.tiedup$detachLeash(); + access.tiedup$dropLeash(); + } + } + + @Override + public void dropBondageItems(boolean drop) { + if (!drop) return; + dropBondageItems(true, true, true, true, true, true, true); + } + + @Override + public void dropBondageItems(boolean drop, boolean dropBind) { + if (!drop) return; + if (dropBind) kidnappedDropItem(takeBindOff()); + } + + @Override + public void dropBondageItems( + boolean drop, + boolean dropBind, + boolean dropGag, + boolean dropBlindfold, + boolean dropEarplugs, + boolean dropCollar, + boolean dropClothes + ) { + if (!drop) return; + + if (dropBind) takeBondageItemIfUnlocked( + getEquipment(BodyRegionV2.ARMS), + this::takeBindOff + ); + if (dropGag) takeBondageItemIfUnlocked( + getEquipment(BodyRegionV2.MOUTH), + this::takeGagOff + ); + if (dropBlindfold) takeBondageItemIfUnlocked( + getEquipment(BodyRegionV2.EYES), + this::takeBlindfoldOff + ); + if (dropEarplugs) takeBondageItemIfUnlocked( + getEquipment(BodyRegionV2.EARS), + this::takeEarplugsOff + ); + if (dropCollar) takeBondageItemIfUnlocked( + getEquipment(BodyRegionV2.NECK), + this::takeCollarOff + ); + if (dropClothes) kidnappedDropItem(takeClothesOff()); + } + + @Override + public void dropClothes() { + ItemStack clothes = takeClothesOff(); + if (!clothes.isEmpty()) { + kidnappedDropItem(clothes); + } + } + + @Override + public void applyBondage( + ItemStack bind, + ItemStack gag, + ItemStack blindfold, + ItemStack earplugs, + ItemStack collar, + ItemStack clothes + ) { + if (!bind.isEmpty()) putBindOn(bind); + if (!gag.isEmpty()) putGagOn(gag); + if (!blindfold.isEmpty()) putBlindfoldOn(blindfold); + if (!earplugs.isEmpty()) putEarplugsOn(earplugs); + if (!collar.isEmpty()) putCollarOn(collar); + if (!clothes.isEmpty()) putClothesOn(clothes); + } + + @Override + public int getBondageItemsWhichCanBeRemovedCount() { + int count = 0; + if (!isLocked(getEquipment(BodyRegionV2.ARMS), false)) count++; + if (!isLocked(getEquipment(BodyRegionV2.MOUTH), false)) count++; + if (!isLocked(getEquipment(BodyRegionV2.EYES), false)) count++; + if (!isLocked(getEquipment(BodyRegionV2.EARS), false)) count++; + if (!isLocked(getEquipment(BodyRegionV2.NECK), false)) count++; + if (!getEquipment(BodyRegionV2.TORSO).isEmpty()) count++; + return count; + } + + // ======================================== + // IRestrainable Callbacks + // Delegated to PlayerEquipment component + // ======================================== + + @Override + public void checkGagAfterApply() { + equipment.checkGagAfterApply(); + } + + @Override + public void checkBlindfoldAfterApply() { + equipment.checkBlindfoldAfterApply(); + } + + @Override + public void checkEarplugsAfterApply() { + equipment.checkEarplugsAfterApply(); + } + + @Override + public void checkCollarAfterApply() { + equipment.checkCollarAfterApply(); + } + + // ======================================== + // IRestrainable Special Interactions + // Delegated to PlayerSpecialActions component + // ======================================== + + @Override + public void applyChloroform(int duration) { + specialActions.applyChloroform(duration); + } + + /** + * Phase 14.1.7: Updated to use IRestrainable parameter (was PlayerBindState) + * C6-V2: Narrowed to IRestrainableEntity + */ + @Override + public void takeBondageItemBy(IRestrainableEntity taker, int slotIndex) { + specialActions.takeBondageItemBy(taker, slotIndex); + } + + // ======================================== + // IRestrainable Clothes Permissions + // Delegated to PlayerClothesPermission component + // ======================================== + + @Override + public boolean canTakeOffClothes(Player player) { + return clothesPermission.canTakeOffClothes(player); + } + + @Override + public boolean canChangeClothes(Player player) { + return clothesPermission.canChangeClothes(player); + } + + @Override + public boolean canChangeClothes() { + return clothesPermission.canChangeClothes(); + } + + // ======================================== + // IRestrainable Lifecycle + // ======================================== + + @Override + public boolean onDeathKidnapped(net.minecraft.world.level.Level world) { + // Clear movement style state so onActivate() re-fires on respawn + // (PlayerBindState instance survives respawn via computeIfAbsent) + clearMovementState(); + + // Reset shock timers + resetAutoShockTimer(); + + // Unlock all locked items + ItemStack collar = getEquipment(BodyRegionV2.NECK); + if ( + !collar.isEmpty() && + collar.getItem() instanceof ItemCollar collarItem + ) { + collarItem.setLocked(collar, false); + } + + // Drop all items + dropBondageItems(true); + + // Phase 17: Free from captivity + if (isCaptive()) { + free(); + } + + // Delegate to lifecycle component for registry cleanup and offline status + return lifecycle.onDeathKidnapped(world); + } + + // ======================================== + // IRestrainable Entity Communication (Phase 14.1.3) + // Delegated to PlayerDataRetrieval component + // ======================================== + + @Override + public net.minecraft.world.entity.LivingEntity asLivingEntity() { + return dataRetrieval.asLivingEntity(); + } +} diff --git a/src/main/java/com/tiedup/remake/state/PlayerCaptorManager.java b/src/main/java/com/tiedup/remake/state/PlayerCaptorManager.java new file mode 100644 index 0000000..fc73c0a --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/PlayerCaptorManager.java @@ -0,0 +1,427 @@ +package com.tiedup.remake.state; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.items.base.ItemCollar; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +// C6-V2: IRestrainable → IBondageState (narrowed API) + +/** + * Phase 8: Master-Captive Relationships + * Phase 17: Renamed from PlayerKidnapperManager, terminology slave → captive + * + * Manages capture relationships for player captors. + * + * Terminology (Phase 17): + * - "Captive" = Entity attached by leash (active physical control) + * - "Slave" = Entity wearing a collar owned by someone (passive ownership via CollarRegistry) + * + * Features: + * - Supports multiple captives simultaneously + * - Allows captive transfer between captors + * - Tracks captive list persistently + * - Handles cleanup on captive logout/escape + * + * Thread Safety: + * - Uses CopyOnWriteArrayList to avoid ConcurrentModificationException + * - Safe for iteration during modification + * + * Design: + * - Each PlayerBindState has one PlayerCaptorManager + * - Manager tracks all captives owned by that player + * - Implements ICaptor interface for polymorphic usage + * + * @see ICaptor + * @see PlayerBindState + */ +public class PlayerCaptorManager implements ICaptor { + + /** + * The player who owns this manager (the captor). + */ + private final Player captor; + + /** + * List of all captives currently owned by this captor. + * Thread-safe to avoid concurrent modification during iteration. + * + * Phase 14.1.6: Changed from List to List + * Phase 17: Renamed from slaves to captives + */ + private final List captives; + + /** + * Create a new captor manager for the given player. + * + * @param captor The player who will be the captor + */ + public PlayerCaptorManager(Player captor) { + this.captor = captor; + this.captives = new CopyOnWriteArrayList<>(); + } + + // ======================================== + // ICaptor Implementation + // ======================================== + + /** + * Phase 14.1.6: Changed parameter from PlayerBindState to IBondageState + * Phase 17: Renamed from addSlave to addCaptive + */ + @Override + public synchronized void addCaptive(IBondageState captive) { + if (captive == null) { + TiedUpMod.LOGGER.warn( + "[PlayerCaptorManager] Attempted to add null captive" + ); + return; + } + + if (!captives.contains(captive)) { + captives.add(captive); + TiedUpMod.LOGGER.info( + "[PlayerCaptorManager] {} captured {} (total captives: {})", + captor.getName().getString(), + captive.getKidnappedName(), + captives.size() + ); + } + } + + /** + * Phase 14.1.6: Changed parameter from PlayerBindState to IBondageState + * Phase 17: Renamed from removeSlave to removeCaptive + * + * Thread Safety: Synchronized on 'this' to match addCaptive and freeAllCaptives. + */ + @Override + public synchronized void removeCaptive( + IBondageState captive, + boolean transportState + ) { + if (captive == null) { + TiedUpMod.LOGGER.warn( + "[PlayerCaptorManager] Attempted to remove null captive" + ); + return; + } + + if (captives.remove(captive)) { + TiedUpMod.LOGGER.info( + "[PlayerCaptorManager] {} freed {} (remaining captives: {})", + captor.getName().getString(), + captive.getKidnappedName(), + captives.size() + ); + + // If requested, also despawn the transport entity + if (transportState && captive.getTransport() != null) { + captive.getTransport().discard(); + } + } + } + + /** + * Phase 14.1.6: Changed parameter from PlayerBindState to IBondageState + * Phase 17: Renamed from canEnslave to canCapture + */ + @Override + public boolean canCapture(IBondageState target) { + if (target == null) { + return false; + } + + // From original code (PlayerBindState.java:195-225): + // Can capture if: + // - Target is tied up, OR + // - Target has collar AND collar has this captor as owner + + // Phase 14.1.6: Use asLivingEntity() instead of getPlayer() + net.minecraft.world.entity.LivingEntity targetEntity = + target.asLivingEntity(); + if (targetEntity == null) { + return false; + } + + // Check if target is tied up + if (target.isTiedUp()) { + return true; + } + + // Check if target has collar with this captor as owner + if (target.hasCollar()) { + ItemStack collar = target.getEquipment(BodyRegionV2.NECK); + if (collar.getItem() instanceof ItemCollar collarItem) { + if ( + collarItem.getOwners(collar).contains(this.captor.getUUID()) + ) { + return true; + } + } + } + + return false; + } + + /** + * Phase 14.1.6: Changed parameter from PlayerBindState to IBondageState + * Phase 17: Renamed from canFree to canRelease + */ + @Override + public boolean canRelease(IBondageState captive) { + if (captive == null) { + return false; + } + + // Can only release if this manager is the captive's captor + return captive.getCaptor() == this; + } + + /** + * Phase 17: Renamed from allowSlaveTransfer to allowCaptiveTransfer + */ + @Override + public boolean allowCaptiveTransfer() { + // Players always allow captive transfer + return true; + } + + /** + * Phase 17: Renamed from allowMultipleSlaves to allowMultipleCaptives + */ + @Override + public boolean allowMultipleCaptives() { + // Players can have multiple captives + return true; + } + + /** + * Phase 14.1.6: Changed parameter from PlayerBindState to IBondageState + * Phase 17: Renamed from onSlaveLogout to onCaptiveLogout + * Note: For NPC captives, this is never called (NPCs don't log out) + */ + @Override + public void onCaptiveLogout(IBondageState captive) { + if (captive == null) { + return; + } + + TiedUpMod.LOGGER.info( + "[PlayerCaptorManager] Captive {} logged out while captured by {}", + captive.getKidnappedName(), + captor.getName().getString() + ); + + // Keep captive in list - they might reconnect + // Transport entity will despawn after timeout + // On reconnect, checkStillCaptive() will clean up if needed + } + + /** + * Phase 14.1.6: Changed parameter from PlayerBindState to IBondageState + * Phase 17: Renamed from onSlaveReleased to onCaptiveReleased + */ + @Override + public void onCaptiveReleased(IBondageState captive) { + if (captive == null) { + return; + } + + TiedUpMod.LOGGER.info( + "[PlayerCaptorManager] Captive {} was released from {}", + captive.getKidnappedName(), + captor.getName().getString() + ); + + // No special action needed - already removed from list by removeCaptive() + } + + /** + * Phase 14.1.6: Changed parameter from PlayerBindState to IBondageState + * Phase 17: Renamed from onSlaveStruggle to onCaptiveStruggle + */ + @Override + public void onCaptiveStruggle(IBondageState captive) { + if (captive == null) { + return; + } + + TiedUpMod.LOGGER.debug( + "[PlayerCaptorManager] Captive {} struggled (captor: {})", + captive.getKidnappedName(), + captor.getName().getString() + ); + + // Phase 8: No action for basic struggle + // Phase 14: Shock collar would activate here + } + + /** + * Phase 17: Renamed from hasSlaves to hasCaptives + */ + @Override + public boolean hasCaptives() { + return !captives.isEmpty(); + } + + @Override + public Entity getEntity() { + return captor; + } + + // ======================================== + // Additional Methods + // ======================================== + + /** + * Frees all captives currently owned by this captor. + * + * Phase 17: Renamed from freeAllSlaves to freeAllCaptives + * + * Thread Safety: Synchronized on 'this' to match addCaptive and removeCaptive. + * + * @param transportState If true, destroy the transporter entities + */ + public synchronized void freeAllCaptives(boolean transportState) { + // Use a copy to avoid concurrent modification while iterating + List copy = new ArrayList<>(captives); + for (IBondageState captive : copy) { + captive.free(transportState); + } + captives.clear(); + } + + /** + * Frees all captives with default behavior (destroy transport entities). + */ + public void freeAllCaptives() { + freeAllCaptives(true); + } + + /** + * Get a copy of the captive list. + * Safe for iteration without concurrent modification issues. + * + * Phase 17: Renamed from getSlaves to getCaptives + * + * @return Copy of the current captive list + */ + public List getCaptives() { + return new ArrayList<>(captives); + } + + /** + * Get the number of captives currently owned. + * + * Phase 17: Renamed from getSlaveCount to getCaptiveCount + * + * @return Captive count + */ + public int getCaptiveCount() { + return captives.size(); + } + + /** + * Transfer all captives from this captor to a new captor. + * Used when this player gets captured themselves. + * + * From original code: + * - When a player gets captured, their captives transfer to new captor + * - Prevents circular capture issues + * + * Phase 17: Renamed from transferAllSlavesTo to transferAllCaptivesTo + * + * @param newCaptor The new captor to transfer captives to + */ + public void transferAllCaptivesTo(ICaptor newCaptor) { + if (newCaptor == null) { + TiedUpMod.LOGGER.warn( + "[PlayerCaptorManager] Attempted to transfer captives to null captor" + ); + return; + } + + if (captives.isEmpty()) { + return; + } + + TiedUpMod.LOGGER.info( + "[PlayerCaptorManager] Transferring {} captives from {} to {}", + captives.size(), + captor.getName().getString(), + newCaptor.getEntity().getName().getString() + ); + + // Create copy to avoid concurrent modification + List captivesToTransfer = new ArrayList<>(captives); + + for (IBondageState captive : captivesToTransfer) { + if (captive != null) { + captive.transferCaptivityTo(newCaptor); + } + } + + // All captives should now be removed from this manager's list + if (!captives.isEmpty()) { + TiedUpMod.LOGGER.warn( + "[PlayerCaptorManager] {} captives remain after transfer - cleaning up", + captives.size() + ); + captives.clear(); + } + } + + /** + * Get the captor player. + * + * @return The player who owns this manager + */ + public Player getCaptor() { + return captor; + } + + /** + * Clean up invalid captives from the list. + * Removes captives that are no longer valid (offline, transport gone, etc.). + * + * Phase 17: Renamed from cleanupInvalidSlaves to cleanupInvalidCaptives + * + * Should be called periodically (e.g., on tick). + */ + public void cleanupInvalidCaptives() { + captives.removeIf(captive -> { + if (captive == null) { + return true; + } + + // Remove if not actually captured anymore + if (!captive.isCaptive()) { + TiedUpMod.LOGGER.debug( + "[PlayerCaptorManager] Removing invalid captive {}", + captive.getKidnappedName() + ); + return true; + } + + // Remove if captured by different captor + if (captive.getCaptor() != this) { + TiedUpMod.LOGGER.debug( + "[PlayerCaptorManager] Removing captive {} (belongs to different captor)", + captive.getKidnappedName() + ); + return true; + } + + return false; + }); + } + + // ======================================== + // Backward Compatibility (Phase 17) + // ======================================== +} diff --git a/src/main/java/com/tiedup/remake/state/SocialData.java b/src/main/java/com/tiedup/remake/state/SocialData.java new file mode 100644 index 0000000..59c76de --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/SocialData.java @@ -0,0 +1,187 @@ +package com.tiedup.remake.state; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.*; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.saveddata.SavedData; + +/** + * Persistent storage for social command data. + * + * Stores: + * - Block lists (which players have blocked which other players) + * - Talk area settings (local chat distance per player) + * + * Phase 18: Social Commands persistence. + */ +public class SocialData extends SavedData { + + private static final String DATA_NAME = TiedUpMod.MOD_ID + "_social"; + + // Block lists per player UUID + private final Map> blockedPlayers = new HashMap<>(); + + // Talk area settings per player UUID + private final Map talkAreas = new HashMap<>(); + + public SocialData() {} + + /** + * Load from NBT. + */ + public static SocialData load(CompoundTag tag) { + SocialData data = new SocialData(); + + // Load blocked players + if (tag.contains("blocked")) { + ListTag blockedList = tag.getList("blocked", Tag.TAG_COMPOUND); + for (int i = 0; i < blockedList.size(); i++) { + CompoundTag entry = blockedList.getCompound(i); + UUID blocker = entry.getUUID("blocker"); + Set blocked = new HashSet<>(); + + ListTag blockedUUIDs = entry.getList( + "blocked_uuids", + Tag.TAG_COMPOUND + ); + for (int j = 0; j < blockedUUIDs.size(); j++) { + CompoundTag uuidTag = blockedUUIDs.getCompound(j); + blocked.add(uuidTag.getUUID("uuid")); + } + + if (!blocked.isEmpty()) { + data.blockedPlayers.put(blocker, blocked); + } + } + } + + // Load talk areas + if (tag.contains("talk_areas")) { + ListTag talkList = tag.getList("talk_areas", Tag.TAG_COMPOUND); + for (int i = 0; i < talkList.size(); i++) { + CompoundTag entry = talkList.getCompound(i); + UUID player = entry.getUUID("player"); + int distance = entry.getInt("distance"); + data.talkAreas.put(player, distance); + } + } + + TiedUpMod.LOGGER.debug( + "[SocialData] Loaded {} block lists, {} talk areas", + data.blockedPlayers.size(), + data.talkAreas.size() + ); + + return data; + } + + @Override + public CompoundTag save(CompoundTag tag) { + // Save blocked players + ListTag blockedList = new ListTag(); + for (Map.Entry> entry : blockedPlayers.entrySet()) { + if (entry.getValue().isEmpty()) continue; + + CompoundTag blockerTag = new CompoundTag(); + blockerTag.putUUID("blocker", entry.getKey()); + + ListTag blockedUUIDs = new ListTag(); + for (UUID blocked : entry.getValue()) { + CompoundTag uuidTag = new CompoundTag(); + uuidTag.putUUID("uuid", blocked); + blockedUUIDs.add(uuidTag); + } + blockerTag.put("blocked_uuids", blockedUUIDs); + blockedList.add(blockerTag); + } + tag.put("blocked", blockedList); + + // Save talk areas + ListTag talkList = new ListTag(); + for (Map.Entry entry : talkAreas.entrySet()) { + CompoundTag talkTag = new CompoundTag(); + talkTag.putUUID("player", entry.getKey()); + talkTag.putInt("distance", entry.getValue()); + talkList.add(talkTag); + } + tag.put("talk_areas", talkList); + + return tag; + } + + // ==================== Block List Methods ==================== + + /** + * Add a player to another player's block list. + */ + public void addBlock(UUID blocker, UUID blocked) { + blockedPlayers + .computeIfAbsent(blocker, k -> new HashSet<>()) + .add(blocked); + setDirty(); + } + + /** + * Remove a player from another player's block list. + */ + public void removeBlock(UUID blocker, UUID blocked) { + Set set = blockedPlayers.get(blocker); + if (set != null) { + set.remove(blocked); + if (set.isEmpty()) { + blockedPlayers.remove(blocker); + } + setDirty(); + } + } + + /** + * Check if a player has blocked another. + */ + public boolean isBlocked(UUID blocker, UUID blocked) { + Set set = blockedPlayers.get(blocker); + return set != null && set.contains(blocked); + } + + /** + * Get the set of players blocked by a player. + */ + public Set getBlockedPlayers(UUID blocker) { + return blockedPlayers.getOrDefault(blocker, Collections.emptySet()); + } + + // ==================== Talk Area Methods ==================== + + /** + * Set a player's talk area distance. + */ + public void setTalkArea(UUID player, int distance) { + if (distance <= 0) { + talkAreas.remove(player); + } else { + talkAreas.put(player, distance); + } + setDirty(); + } + + /** + * Get a player's talk area distance (0 = global chat). + */ + public int getTalkArea(UUID player) { + return talkAreas.getOrDefault(player, 0); + } + + // ==================== Static Access ==================== + + /** + * Get the SocialData for a server level. + */ + public static SocialData get(ServerLevel level) { + return level + .getDataStorage() + .computeIfAbsent(SocialData::load, SocialData::new, DATA_NAME); + } +} diff --git a/src/main/java/com/tiedup/remake/state/components/PlayerCaptivity.java b/src/main/java/com/tiedup/remake/state/components/PlayerCaptivity.java new file mode 100644 index 0000000..87eb1f7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/components/PlayerCaptivity.java @@ -0,0 +1,295 @@ +package com.tiedup.remake.state.components; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.LeashProxyEntity; +import com.tiedup.remake.state.IPlayerLeashAccess; +import com.tiedup.remake.state.ICaptor; +import com.tiedup.remake.state.hosts.IPlayerBindStateHost; +import net.minecraft.world.entity.player.Player; +import org.jetbrains.annotations.Nullable; + +/** + * Component responsible for captivity mechanics and leash proxy management. + * Phase 17: Advanced capture system (proxy-based leashing) + * + * Single Responsibility: Captivity lifecycle and transport management + * Complexity: VERY HIGH (mixin coupling, network sync, leash proxy coordination) + * Risk: VERY HIGH (critical path, mixin dependency, 4 network sync points) + * + * Mixin Dependency: IPlayerLeashAccess for leash proxy system + * Network Sync: 4 syncEnslavement() calls coordinated via host + * + * Captivity States: + * - Not Captive: No captor, no leash + * - Captive (by entity): Has captor, leashed to entity + * - Pole Binding: No captor, leashed to pole (LeashFenceKnotEntity) + */ +public class PlayerCaptivity { + + private final IPlayerBindStateHost host; + + public PlayerCaptivity(IPlayerBindStateHost host) { + this.host = host; + } + + // ========== Captivity Initiation ========== + + /** + * Phase 17: Renamed from getEnslavedBy to getCapturedBy + * Initiates the capture process by a captor. + * Uses the proxy-based leash system (player is NOT mounted). + * + * Thread Safety: Synchronized to prevent race condition where two kidnappers + * could both pass the isCaptive() check and attempt to capture simultaneously. + * + * @param newCaptor The entity attempting to capture this player + * @return true if capture succeeded + */ + public synchronized boolean getCapturedBy(ICaptor newCaptor) { + Player player = host.getPlayer(); + if (player == null || newCaptor == null) return false; + + // Must be enslavable (tied up) OR captor can capture (includes collar owner exception) + if ( + !isEnslavable() && !newCaptor.canCapture(host.getKidnapped()) + ) return false; + + // Check if already captured (atomic check under synchronization) + if (isCaptive()) return false; + + // Free all captives instead of transferring (until multi-captive is implemented) + if (host.getCaptorManager().hasCaptives()) { + TiedUpMod.LOGGER.debug( + "[PlayerCaptivity] {} is being captured - freeing their {} captives", + player.getName().getString(), + host.getCaptorManager().getCaptiveCount() + ); + host.getCaptorManager().freeAllCaptives(true); + } + + // Use new proxy-based leash system + if (player instanceof IPlayerLeashAccess access) { + access.tiedup$attachLeash(newCaptor.getEntity()); + newCaptor.addCaptive(host.getKidnapped()); + host.setCaptor(newCaptor); + + TiedUpMod.LOGGER.debug( + "[PlayerCaptivity] {} captured by {} (proxy leash)", + player.getName().getString(), + newCaptor.getEntity().getName().getString() + ); + + // Sync enslavement state to all clients + host.syncEnslavement(); + return true; + } + + TiedUpMod.LOGGER.error( + "[PlayerCaptivity] Player {} does not implement IPlayerLeashAccess!", + player.getName().getString() + ); + return false; + } + + // ========== Captivity Release ========== + + /** + * Ends captivity with default behavior (drops leash item). + */ + public void free() { + free(true); + } + + /** + * Phase 17: Ends captivity and detaches the leash proxy. + * + * @param dropLead Whether to drop the leash item + */ + public void free(boolean dropLead) { + Player player = host.getPlayer(); + if (player == null) return; + + if (!(player instanceof IPlayerLeashAccess access)) { + TiedUpMod.LOGGER.error( + "[PlayerCaptivity] Player {} does not implement IPlayerLeashAccess!", + player.getName().getString() + ); + return; + } + + ICaptor captor = host.getCaptor(); + + // Handle pole binding (no captor) - just detach leash + if (captor == null) { + if (access.tiedup$isLeashed()) { + TiedUpMod.LOGGER.debug( + "[PlayerCaptivity] Freeing {} from pole binding", + player.getName().getString() + ); + if (dropLead) { + access.tiedup$dropLeash(); + } + access.tiedup$detachLeash(); + host.syncEnslavement(); + } + return; + } + + TiedUpMod.LOGGER.debug( + "[PlayerCaptivity] Freeing {} from captivity", + player.getName().getString() + ); + + // 1. Remove from captor's tracking list + captor.removeCaptive(host.getKidnapped(), false); + + // 2. Detach leash proxy + if (dropLead) { + access.tiedup$dropLeash(); + } + access.tiedup$detachLeash(); + + // 3. Reset state + host.setCaptor(null); + + // 4. Sync freed state to all clients + host.syncEnslavement(); + } + + // ========== Captivity Transfer ========== + + /** + * Phase 17: Renamed from transferSlaveryTo to transferCaptivityTo + * Transfers captivity from current captor to a new captor. + * + * Thread Safety: Synchronized to prevent concurrent transfer attempts. + * + * @param newCaptor The new captor entity + */ + public synchronized void transferCaptivityTo(ICaptor newCaptor) { + Player player = host.getPlayer(); + ICaptor currentCaptor = host.getCaptor(); + + if ( + player == null || + newCaptor == null || + currentCaptor == null || + !currentCaptor.allowCaptiveTransfer() + ) return; + + currentCaptor.removeCaptive(host.getKidnapped(), false); + + // Re-attach leash to new captor + if (player instanceof IPlayerLeashAccess access) { + access.tiedup$detachLeash(); + access.tiedup$attachLeash(newCaptor.getEntity()); + } + + newCaptor.addCaptive(host.getKidnapped()); + host.setCaptor(newCaptor); + + // Sync new captor to all clients + host.syncEnslavement(); + } + + // ========== State Queries ========== + + /** + * Check if this player can be captured (leashed). + * Must be tied up to be leashed. + * Collar alone is NOT enough - collar owner exception is handled in canCapture(). + * + * @return true if player is tied up and can be leashed + */ + public boolean isEnslavable() { + return host.isTiedUp(); + } + + /** + * Phase 17: Renamed from isSlave to isCaptive + * Check if player is currently captured by an entity. + * + * @return true if player has a captor and is leashed + */ + public boolean isCaptive() { + Player player = host.getPlayer(); + if (host.getCaptor() == null) return false; + if (player instanceof IPlayerLeashAccess access) { + return access.tiedup$isLeashed(); + } + return false; + } + + /** + * Get the leash proxy entity (transport system). + * Phase 17: Proxy-based leashing (no mounting). + * + * @return The leash proxy, or null if not leashed + */ + @Nullable + public LeashProxyEntity getTransport() { + Player player = host.getPlayer(); + if (player instanceof IPlayerLeashAccess access) { + return access.tiedup$getLeashProxy(); + } + return null; + } + + // ========== Captivity Monitoring ========== + + /** + * Phase 17: Renamed from checkStillSlave to checkStillCaptive + * Periodically monitors captivity validity. + * Simplified: If any condition is invalid, free the captive immediately. + * + * Called from RestraintTaskTickHandler every player tick. + */ + public void checkStillCaptive() { + if (!isCaptive()) return; + + Player player = host.getPlayer(); + if (player == null) return; + + // Check if no longer tied/collared + if (!host.isTiedUp() && !host.hasCollar()) { + TiedUpMod.LOGGER.debug( + "[PlayerCaptivity] Auto-freeing {} - no restraints", + player.getName().getString() + ); + free(); + return; + } + + // Check leash proxy status + if (player instanceof IPlayerLeashAccess access) { + LeashProxyEntity proxy = access.tiedup$getLeashProxy(); + if (proxy == null || proxy.proxyIsRemoved()) { + TiedUpMod.LOGGER.debug( + "[PlayerCaptivity] Auto-freeing {} - proxy invalid", + player.getName().getString() + ); + // Notify captor BEFORE freeing (triggers retrieval behavior) + ICaptor captor = host.getCaptor(); + if (captor != null) { + captor.onCaptiveReleased(host.getKidnapped()); + } + free(); + return; + } + + // Check if leash holder is still valid + if (proxy.getLeashHolder() == null) { + TiedUpMod.LOGGER.debug( + "[PlayerCaptivity] Auto-freeing {} - leash holder gone", + player.getName().getString() + ); + // Notify captor BEFORE freeing (triggers retrieval behavior) + ICaptor captor = host.getCaptor(); + if (captor != null) { + captor.onCaptiveReleased(host.getKidnapped()); + } + free(); + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/state/components/PlayerClothesPermission.java b/src/main/java/com/tiedup/remake/state/components/PlayerClothesPermission.java new file mode 100644 index 0000000..fa67cb5 --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/components/PlayerClothesPermission.java @@ -0,0 +1,149 @@ +package com.tiedup.remake.state.components; + +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import com.tiedup.remake.state.hosts.IPlayerBindStateHost; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageEquipment; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * Component responsible for clothes permission and management. + * Handles who can remove/change clothes and manages clothes replacement with network sync. + * + * Single Responsibility: Clothes permissions and synchronization + * Complexity: MEDIUM (network sync coordination) + * Risk: MEDIUM (must coordinate SyncManager calls) + * + * Epic 5F: Uses V2EquipmentHelper/BodyRegionV2. + */ +public class PlayerClothesPermission { + + private final IPlayerBindStateHost host; + + public PlayerClothesPermission(IPlayerBindStateHost host) { + this.host = host; + } + + // ========== Permission Checks ========== + + /** + * Check if a specific player can take off this player's clothes. + * Currently permissive - anyone can take off clothes. + * Future: Could check if 'player' is owner/master when tied up. + * + * @param player The player attempting to remove clothes + * @return true if allowed + */ + public boolean canTakeOffClothes(Player player) { + // Currently permissive - anyone can take off clothes + // Future: Could check if 'player' is owner/master when tied up + return true; + } + + /** + * Check if a specific player can change this player's clothes. + * Currently permissive - anyone can change clothes. + * Future: Could check if 'player' is owner/master when tied up. + * + * @param player The player attempting to change clothes + * @return true if allowed + */ + public boolean canChangeClothes(Player player) { + // Currently permissive - anyone can change clothes + // Future: Could check if 'player' is owner/master when tied up + return true; + } + + /** + * Check if clothes can be changed (no specific player context). + * Checks if no clothes are equipped, or if clothes are not locked. + * + * @return true if clothes can be changed + */ + public boolean canChangeClothes() { + ItemStack clothes = V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.TORSO); + if (clothes.isEmpty()) return true; + + // Check if clothes are locked + return !isLocked(clothes, false); + } + + // ========== Clothes Management ========== + + /** + * Replace current clothes with new clothes. + * Removes old clothes and equips new ones. + * Syncs clothes config to all clients. + * + * @param newClothes The new clothes to equip + * @return The old clothes, or empty if none + */ + public ItemStack replaceClothes(ItemStack newClothes) { + return replaceClothes(newClothes, false); + } + + /** + * Replace current clothes with new clothes, with optional force. + * If force is true, bypasses lock checks. + * Syncs clothes config to all clients. + * + * @param newClothes The new clothes to equip + * @param force true to bypass lock checks + * @return The old clothes, or empty if none + */ + public ItemStack replaceClothes(ItemStack newClothes, boolean force) { + Player player = host.getPlayer(); + if (player == null || player.level().isClientSide) return ItemStack.EMPTY; + + IV2BondageEquipment equip = V2EquipmentHelper.getEquipment(player); + if (equip == null) return ItemStack.EMPTY; + + // Take off old clothes + ItemStack old = V2EquipmentHelper.unequipFromRegion(player, BodyRegionV2.TORSO); + + // Equip new clothes if we successfully removed old ones + if (!old.isEmpty()) { + equip.setInRegion(BodyRegionV2.TORSO, newClothes.copy()); + + // Fire lifecycle hook for new item + if (!newClothes.isEmpty() && newClothes.getItem() instanceof IV2BondageItem newItem) { + newItem.onEquipped(newClothes, player); + } + + V2EquipmentHelper.sync(player); + + // CRITICAL: Sync clothes config to all tracking clients + // This ensures dynamic textures and other clothes properties are synced + host.syncClothesConfig(); + } + + return old; + } + + // ========== Helper Methods ========== + + /** + * Check if an item is locked. + * Helper method for lock checking. + * + * @param stack The item to check + * @param force If true, returns false (bypasses lock) + * @return true if locked and not forced + */ + private boolean isLocked(ItemStack stack, boolean force) { + if (force) return false; + if (stack.isEmpty()) return false; + + // Check if item has locked property + if ( + stack.getItem() instanceof + com.tiedup.remake.items.base.ILockable lockable + ) { + return lockable.isLocked(stack); + } + + return false; + } +} diff --git a/src/main/java/com/tiedup/remake/state/components/PlayerDataRetrieval.java b/src/main/java/com/tiedup/remake/state/components/PlayerDataRetrieval.java new file mode 100644 index 0000000..f65c097 --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/components/PlayerDataRetrieval.java @@ -0,0 +1,113 @@ +package com.tiedup.remake.state.components; + +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.state.hosts.IPlayerBindStateHost; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * Component responsible for retrieving player data and equipment. + * Provides access to current bondage items and player information. + * + * Single Responsibility: Data retrieval + * Complexity: LOW (simple getters) + * Risk: LOW (read-only access) + * + * Epic 5F: Uses V2EquipmentHelper/BodyRegionV2. + */ +public class PlayerDataRetrieval { + + private final IPlayerBindStateHost host; + + public PlayerDataRetrieval(IPlayerBindStateHost host) { + this.host = host; + } + + // ========== Equipment Getters ========== + + public ItemStack getCurrentBind() { + return V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.ARMS); + } + + public ItemStack getCurrentGag() { + return V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.MOUTH); + } + + public ItemStack getCurrentBlindfold() { + return V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.EYES); + } + + public ItemStack getCurrentEarplugs() { + return V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.EARS); + } + + public ItemStack getCurrentClothes() { + return V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.TORSO); + } + + public ItemStack getCurrentMittens() { + return V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.HANDS); + } + + public ItemStack getCurrentCollar() { + return V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.NECK); + } + + // ========== Player Information ========== + + /** + * Get the player's display name, checking collar for nickname first. + * If collar has a nickname, returns that; otherwise returns player name. + */ + public String getNameFromCollar() { + Player player = host.getPlayer(); + ItemStack collar = getCurrentCollar(); + + if ( + !collar.isEmpty() && + collar.getItem() instanceof ItemCollar collarItem + ) { + // Try to get nickname from collar NBT + String nickname = collarItem.getNickname(collar); + if (nickname != null && !nickname.isEmpty()) { + return nickname; + } + } + + // Fallback to player name + return player.getName().getString(); + } + + /** + * Check if player has a named collar (collar with nickname). + */ + public boolean hasNamedCollar() { + ItemStack collar = getCurrentCollar(); + if (collar.isEmpty()) return false; + + if (collar.getItem() instanceof ItemCollar collarItem) { + String nickname = collarItem.getNickname(collar); + return nickname != null && !nickname.isEmpty(); + } + return false; + } + + /** + * Check if player has clothes with small arms flag. + * TODO Phase 14+: Check clothes NBT for small arms flag + */ + public boolean hasClothesWithSmallArms() { + return false; + } + + /** + * Get the player as a LivingEntity. + * Used for generic entity operations. + */ + public LivingEntity asLivingEntity() { + return host.getPlayer(); + } +} diff --git a/src/main/java/com/tiedup/remake/state/components/PlayerEquipment.java b/src/main/java/com/tiedup/remake/state/components/PlayerEquipment.java new file mode 100644 index 0000000..388f999 --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/components/PlayerEquipment.java @@ -0,0 +1,429 @@ +package com.tiedup.remake.state.components; + +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.state.hosts.IPlayerBindStateHost; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageEquipment; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.function.Supplier; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * Component responsible for bondage equipment management. + * Handles putting on, taking off, and replacing all bondage items. + * + * Single Responsibility: Equipment lifecycle management + * Complexity: MEDIUM (V2 equipment coupling) + * Risk: MEDIUM (core equipment system) + * + * Epic 5F: Uses V2EquipmentHelper. + * Uses low-level setInRegion for equips because V1 items (IBondageItem) do not + * implement IV2BondageItem, so V2EquipmentHelper.equipItem() would reject them. + */ +public class PlayerEquipment { + + private final IPlayerBindStateHost host; + + public PlayerEquipment(IPlayerBindStateHost host) { + this.host = host; + } + + // ========== Put On Methods ========== + + /** Equips a bind item and applies speed reduction. */ + public void putBindOn(ItemStack bind) { + equipInRegion(BodyRegionV2.ARMS, bind); + } + + /** Equips a gag (enables gag talk if implemented). Issue #14 fix: now calls onEquipped. */ + public void putGagOn(ItemStack gag) { + equipInRegion(BodyRegionV2.MOUTH, gag); + } + + /** Equips a blindfold (restricts vision). Issue #14 fix: now calls onEquipped. */ + public void putBlindfoldOn(ItemStack blindfold) { + equipInRegion(BodyRegionV2.EYES, blindfold); + } + + /** Equips a collar (starts GPS/Shock monitoring). Issue #14 fix: now calls onEquipped. */ + public void putCollarOn(ItemStack collar) { + equipInRegion(BodyRegionV2.NECK, collar); + } + + /** Issue #14 fix: now calls onEquipped. Syncs clothes config to all clients. */ + public void putClothesOn(ItemStack clothes) { + equipInRegion(BodyRegionV2.TORSO, clothes); + // Sync clothes config (dynamic textures, etc.) to all tracking clients + host.syncClothesConfig(); + } + + /** Equips mittens (blocks hand interactions). Phase 14.4: Mittens system. Issue #14 fix: now calls onEquipped. */ + public void putMittensOn(ItemStack mittens) { + equipInRegion(BodyRegionV2.HANDS, mittens); + checkMittensAfterApply(); + } + + /** Equips earplugs (muffles sounds). Issue #14 fix: now calls onEquipped. */ + public void putEarplugsOn(ItemStack earplugs) { + equipInRegion(BodyRegionV2.EARS, earplugs); + checkEarplugsAfterApply(); + } + + // ========== Take Off Methods ========== + + /** Removes binds and restores speed. */ + public ItemStack takeBindOff() { + return V2EquipmentHelper.unequipFromRegion(host.getPlayer(), BodyRegionV2.ARMS); + } + + public ItemStack takeGagOff() { + return V2EquipmentHelper.unequipFromRegion(host.getPlayer(), BodyRegionV2.MOUTH); + } + + public ItemStack takeBlindfoldOff() { + return V2EquipmentHelper.unequipFromRegion(host.getPlayer(), BodyRegionV2.EYES); + } + + public ItemStack takeCollarOff() { + return V2EquipmentHelper.unequipFromRegion(host.getPlayer(), BodyRegionV2.NECK); + } + + /** Removes mittens. Phase 14.4: Mittens system */ + public ItemStack takeMittensOff() { + ItemStack mittens = V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.HANDS); + if (isLocked(mittens, false)) { + return ItemStack.EMPTY; + } + return V2EquipmentHelper.unequipFromRegion(host.getPlayer(), BodyRegionV2.HANDS); + } + + public ItemStack takeEarplugsOff() { + ItemStack earplugs = V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.EARS); + if (isLocked(earplugs, false)) { + return ItemStack.EMPTY; + } + return V2EquipmentHelper.unequipFromRegion(host.getPlayer(), BodyRegionV2.EARS); + } + + public ItemStack takeClothesOff() { + ItemStack clothes = V2EquipmentHelper.unequipFromRegion(host.getPlayer(), BodyRegionV2.TORSO); + // Sync clothes removal to all tracking clients + host.syncClothesConfig(); + return clothes; + } + + /** + * Tries to remove the collar. Fails if locked unless forced. + */ + public ItemStack takeCollarOff(boolean force) { + Player player = host.getPlayer(); + ItemStack collar = V2EquipmentHelper.getInRegion(player, BodyRegionV2.NECK); + if (collar.isEmpty()) return ItemStack.EMPTY; + + if (collar.getItem() instanceof ItemCollar collarItem) { + if (!force && collarItem.isLocked(collar)) return ItemStack.EMPTY; + collarItem.setLocked(collar, false); + } + return V2EquipmentHelper.unequipFromRegion(player, BodyRegionV2.NECK); + } + + // ========== Replacement Methods ========== + + /** Replaces the blindfold and returns the old one. Issue #14 fix: now calls lifecycle hooks. */ + public ItemStack replaceBlindfold(ItemStack newBlindfold) { + ItemStack current = V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.EYES); + if (current.isEmpty()) return ItemStack.EMPTY; + return replaceInRegion(BodyRegionV2.EYES, newBlindfold); + } + + /** Replaces the gag and returns the old one. Issue #14 fix: now calls lifecycle hooks. */ + public ItemStack replaceGag(ItemStack newGag) { + ItemStack current = V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.MOUTH); + if (current.isEmpty()) return ItemStack.EMPTY; + return replaceInRegion(BodyRegionV2.MOUTH, newGag); + } + + /** Replaces the collar and returns the old one. Issue #14 fix: now calls lifecycle hooks. */ + public ItemStack replaceCollar(ItemStack newCollar) { + ItemStack current = V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.NECK); + if (current.isEmpty()) return ItemStack.EMPTY; + return replaceInRegion(BodyRegionV2.NECK, newCollar); + } + + /** + * Thread Safety: Synchronized to prevent inventory tearing (gap between remove and add). + */ + public synchronized ItemStack replaceBind(ItemStack newBind) { + ItemStack old = takeBindOff(); + if (!old.isEmpty()) { + putBindOn(newBind); + } + return old; + } + + /** + * Thread Safety: Synchronized to prevent inventory tearing (gap between remove and add). + */ + public synchronized ItemStack replaceBind( + ItemStack newBind, + boolean force + ) { + // Safety: Don't remove current bind if newBind is empty + if (newBind.isEmpty()) { + return ItemStack.EMPTY; + } + ItemStack current = V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.ARMS); + if (isLocked(current, force)) { + return ItemStack.EMPTY; + } + ItemStack old = takeBindOff(); + if (!old.isEmpty()) { + putBindOn(newBind); + } + return old; + } + + public ItemStack replaceGag(ItemStack newGag, boolean force) { + ItemStack current = V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.MOUTH); + if (isLocked(current, force)) { + return ItemStack.EMPTY; + } + ItemStack old = V2EquipmentHelper.unequipFromRegion(host.getPlayer(), BodyRegionV2.MOUTH); + putGagOn(newGag); + return old; + } + + public ItemStack replaceBlindfold(ItemStack newBlindfold, boolean force) { + ItemStack current = V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.EYES); + if (isLocked(current, force)) { + return ItemStack.EMPTY; + } + ItemStack old = V2EquipmentHelper.unequipFromRegion(host.getPlayer(), BodyRegionV2.EYES); + putBlindfoldOn(newBlindfold); + return old; + } + + public ItemStack replaceCollar(ItemStack newCollar, boolean force) { + ItemStack current = V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.NECK); + if (isLocked(current, force)) { + return ItemStack.EMPTY; + } + ItemStack old = takeCollarOff(force); + putCollarOn(newCollar); + return old; + } + + public ItemStack replaceEarplugs(ItemStack newEarplugs) { + return replaceEarplugs(newEarplugs, false); + } + + public ItemStack replaceEarplugs(ItemStack newEarplugs, boolean force) { + ItemStack current = V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.EARS); + if (isLocked(current, force)) { + return ItemStack.EMPTY; + } + ItemStack old = takeEarplugsOff(); + putEarplugsOn(newEarplugs); + return old; + } + + public ItemStack replaceMittens(ItemStack newMittens) { + return replaceMittens(newMittens, false); + } + + public ItemStack replaceMittens(ItemStack newMittens, boolean force) { + ItemStack current = V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.HANDS); + if (isLocked(current, force)) { + return ItemStack.EMPTY; + } + ItemStack old = takeMittensOff(); + if (!old.isEmpty() || !hasMittens()) { + putMittensOn(newMittens); + } + return old; + } + + // ========== Resistance Methods ========== + + /** + * Phase 14.1.7: Now part of IRestrainable interface + */ + public synchronized int getCurrentBindResistance() { + Player player = host.getPlayer(); + ItemStack stack = V2EquipmentHelper.getInRegion(player, BodyRegionV2.ARMS); + if ( + stack.isEmpty() || !(stack.getItem() instanceof ItemBind bind) + ) return 0; + return bind.getCurrentResistance(stack, player); + } + + /** + * Phase 14.1.7: Now part of IRestrainable interface + */ + public synchronized void setCurrentBindResistance(int resistance) { + Player player = host.getPlayer(); + ItemStack stack = V2EquipmentHelper.getInRegion(player, BodyRegionV2.ARMS); + if ( + stack.isEmpty() || !(stack.getItem() instanceof ItemBind bind) + ) return; + bind.setCurrentResistance(stack, resistance); + } + + /** + * Phase 14.1.7: Added for IRestrainable interface + */ + public synchronized int getCurrentCollarResistance() { + Player player = host.getPlayer(); + ItemStack stack = V2EquipmentHelper.getInRegion(player, BodyRegionV2.NECK); + if ( + stack.isEmpty() || !(stack.getItem() instanceof ItemCollar collar) + ) return 0; + return collar.getCurrentResistance(stack, player); + } + + /** + * Phase 14.1.7: Added for IRestrainable interface + */ + public synchronized void setCurrentCollarResistance(int resistance) { + Player player = host.getPlayer(); + ItemStack stack = V2EquipmentHelper.getInRegion(player, BodyRegionV2.NECK); + if ( + stack.isEmpty() || !(stack.getItem() instanceof ItemCollar collar) + ) return; + collar.setCurrentResistance(stack, resistance); + } + + // ========== Helper Methods ========== + + /** Helper to drop an item at the kidnapped player's feet. */ + public void kidnappedDropItem(ItemStack stack) { + if (stack.isEmpty() || host.getPlayer() == null) return; + host.getPlayer().drop(stack, false); + } + + /** + * Helper to take a bondage item if it's unlocked, and drop it. + */ + public void takeBondageItemIfUnlocked( + ItemStack item, + Supplier takeOffMethod + ) { + if (isLocked(item, false)) { + return; // Item is locked, cannot remove + } + ItemStack removed = takeOffMethod.get(); + if (!removed.isEmpty()) { + kidnappedDropItem(removed); + } + } + + /** + * Check if an item is locked. + * @param stack The item to check + * @param force If true, returns false (bypasses lock) + * @return true if locked and not forced + */ + public boolean isLocked(ItemStack stack, boolean force) { + if (force) return false; + if (stack.isEmpty()) return false; + + if (stack.getItem() instanceof ILockable lockable) { + return lockable.isLocked(stack); + } + + return false; + } + + private boolean hasMittens() { + return V2EquipmentHelper.isRegionOccupied(host.getPlayer(), BodyRegionV2.HANDS); + } + + // ========== Low-level V2 equipment operations ========== + + /** + * Low-level equip: writes to V2 capability, calls IBondageItem lifecycle hooks, syncs. + * Uses setInRegion directly because V1 items do not implement IV2BondageItem. + */ + private void equipInRegion(BodyRegionV2 region, ItemStack stack) { + Player player = host.getPlayer(); + if (player == null || player.level().isClientSide) return; + if (stack.isEmpty()) return; + + IV2BondageEquipment equip = V2EquipmentHelper.getEquipment(player); + if (equip == null) return; + + // Check if already occupied + if (equip.isRegionOccupied(region)) return; + + // Check canEquip via V1 IBondageItem interface + if (stack.getItem() instanceof IV2BondageItem bondageItem) { + if (!bondageItem.canEquip(stack, player)) return; + } + + equip.setInRegion(region, stack.copy()); + + // Fire lifecycle hook + if (stack.getItem() instanceof IV2BondageItem bondageItem) { + bondageItem.onEquipped(stack, player); + } + + V2EquipmentHelper.sync(player); + } + + /** + * Low-level replace: unequips old item with lifecycle hooks, equips new item. + * Unequips old item (with onUnequipped hook), sets new item, fires onEquipped. + */ + private ItemStack replaceInRegion(BodyRegionV2 region, ItemStack newStack) { + Player player = host.getPlayer(); + if (player == null || player.level().isClientSide) return ItemStack.EMPTY; + + IV2BondageEquipment equip = V2EquipmentHelper.getEquipment(player); + if (equip == null) return ItemStack.EMPTY; + + ItemStack oldStack = equip.getInRegion(region); + + // Call onUnequipped for the old item + if (!oldStack.isEmpty() && oldStack.getItem() instanceof IV2BondageItem oldItem) { + oldItem.onUnequipped(oldStack, player); + } + + // Set the new item + equip.setInRegion(region, newStack.copy()); + + // Call onEquipped for the new item + if (!newStack.isEmpty() && newStack.getItem() instanceof IV2BondageItem newItem) { + newItem.onEquipped(newStack, player); + } + + V2EquipmentHelper.sync(player); + return oldStack; + } + + // ========== Callbacks ========== + + public void checkGagAfterApply() { + // Gag talk handled via ChatEventHandler + GagTalkManager (event-based) + // No initialization needed here - effects apply automatically on chat + } + + public void checkBlindfoldAfterApply() { + // Effects applied client-side via rendering + } + + public void checkEarplugsAfterApply() { + // Effects applied client-side via sound system + } + + public void checkCollarAfterApply() { + // Collar timers/GPS handled elsewhere + } + + public void checkMittensAfterApply() { + // Mittens effects handled elsewhere + } +} diff --git a/src/main/java/com/tiedup/remake/state/components/PlayerLifecycle.java b/src/main/java/com/tiedup/remake/state/components/PlayerLifecycle.java new file mode 100644 index 0000000..1b9e764 --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/components/PlayerLifecycle.java @@ -0,0 +1,155 @@ +package com.tiedup.remake.state.components; + +import com.tiedup.remake.cells.CampOwnership; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.cells.CellRegistryV2; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.state.hosts.IPlayerBindStateHost; +import java.util.UUID; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * Component responsible for player lifecycle management. + * Handles connection, death, respawn scenarios. + * + * Single Responsibility: Lifecycle events and registry coordination + * Complexity: HIGH (coordinates 4+ registries on death) + * Risk: HIGH (critical path, must maintain registry cleanup order) + * + * Registry Cleanup Order (on death): + * 1. CellRegistryV2 - remove from cells + * 2. PrisonerManager - release prisoner (via PrisonerService) + */ +public class PlayerLifecycle { + + private final IPlayerBindStateHost host; + + public PlayerLifecycle(IPlayerBindStateHost host) { + this.host = host; + } + + // ========== Lifecycle Methods ========== + + /** + * Resets the player instance upon reconnection or respawn. + * + * IMPORTANT: Handle transport restoration for pole binding. + * With proxy-based leash system, leashes are not persisted through disconnection. + * The leash proxy is ephemeral and will be recreated if needed. + * + * Leg Binding: Speed reduction based on hasLegsBound(), not isTiedUp(). + */ + public void resetNewConnection(Player player) { + // Update player reference + host.setOnline(true); + + // Phase 17: Clear captor reference (enslavement ends on disconnect) + host.setCaptor(null); + + // Reset struggle animation state (prevent stuck animations) + host.setStrugglingClient(false); + + // Leash proxy doesn't persist through disconnection + // If player was leashed, they are now freed + + // H6 fix: V1 speed reduction re-application is no longer needed for players. + // MovementStyleManager (V2 tick-based system) re-resolves the active movement + // style on the first tick after login (clearMovementState() resets activeMovementStyle + // to null, triggering a fresh activation). The V1 RestraintEffectUtils call here would + // cause double stacking with the V2 MULTIPLY_BASE modifier. + } + + /** + * Called when the kidnapped player dies. + * Comprehensive cleanup: unlock items, drop items, free captivity, cleanup registries. + * + * @param world The world/level where death occurred + * @return true if death was handled + */ + public boolean onDeathKidnapped(Level world) { + Player player = host.getPlayer(); + + // Mark player as offline + host.setOnline(false); + + // Clean up all registries on death (server-side only) + if (world instanceof ServerLevel serverLevel) { + UUID playerId = player.getUUID(); + cleanupRegistries(serverLevel, playerId); + } + + TiedUpMod.LOGGER.debug( + "[PlayerLifecycle] {} died while kidnapped", + player.getName().getString() + ); + return true; + } + + /** + * Coordinates cleanup of all registries when player dies. + * Order matters: Cell → Camp → Ransom → Prisoner + * + * @param serverLevel The server level + * @param playerId The player's UUID + */ + private void cleanupRegistries(ServerLevel serverLevel, UUID playerId) { + String playerName = host.getPlayer().getName().getString(); + + // 1. Clean up CellRegistryV2 - remove from any cells + CellRegistryV2 cellRegistry = CellRegistryV2.get(serverLevel); + int cellsRemoved = cellRegistry.releasePrisonerFromAllCells(playerId); + if (cellsRemoved > 0) { + TiedUpMod.LOGGER.debug( + "[PlayerLifecycle] Removed {} from {} cells on death", + playerName, + cellsRemoved + ); + } + + // 2. Clean up prisoner state - release from imprisonment + com.tiedup.remake.prison.PrisonerManager manager = + com.tiedup.remake.prison.PrisonerManager.get(serverLevel); + com.tiedup.remake.prison.PrisonerState state = manager.getState( + playerId + ); + + // Release if imprisoned or working (player died) + if ( + state == com.tiedup.remake.prison.PrisonerState.IMPRISONED || + state == com.tiedup.remake.prison.PrisonerState.WORKING + ) { + // Use centralized escape service for complete cleanup + com.tiedup.remake.prison.service.PrisonerService.get().escape( + serverLevel, + playerId, + "player_death" + ); + } + } + + // ========== Helper Methods ========== + + /** + * Get the current bind ItemStack. + * Epic 5F: Migrated to V2EquipmentHelper. + */ + private ItemStack getCurrentBind() { + return com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getInRegion( + host.getPlayer(), + com.tiedup.remake.v2.BodyRegionV2.ARMS + ); + } + + /** + * Check if player has legs bound. + */ + private boolean hasLegsBound(ItemStack bind) { + if (bind.isEmpty()) return false; + if (!(bind.getItem() instanceof ItemBind)) return false; + return ItemBind.hasLegsBound(bind); + } +} diff --git a/src/main/java/com/tiedup/remake/state/components/PlayerSale.java b/src/main/java/com/tiedup/remake/state/components/PlayerSale.java new file mode 100644 index 0000000..69b89c5 --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/components/PlayerSale.java @@ -0,0 +1,54 @@ +package com.tiedup.remake.state.components; + +import com.tiedup.remake.util.tasks.ItemTask; + +/** + * Component responsible for player sale system. + * Phase 14.3.5: Sale system fields + * + * Single Responsibility: Sale state management + * Complexity: LOW (simple state tracking) + * Risk: LOW (isolated system) + */ +public class PlayerSale { + + // ========== Sale Fields ========== + + private boolean forSale = false; + private ItemTask salePrice = null; + + // ========== Sale Methods ========== + + /** + * Check if player is currently for sale. + */ + public boolean isForSell() { + return this.forSale && this.salePrice != null; + } + + /** + * Get the sale price. + * @return The price, or null if not for sale + */ + public ItemTask getSalePrice() { + return this.salePrice; + } + + /** + * Put player up for sale with the given price. + * @param price The sale price task + */ + public void putForSale(ItemTask price) { + if (price == null) return; + this.forSale = true; + this.salePrice = price; + } + + /** + * Cancel the sale and clear price. + */ + public void cancelSale() { + this.forSale = false; + this.salePrice = null; + } +} diff --git a/src/main/java/com/tiedup/remake/state/components/PlayerShockCollar.java b/src/main/java/com/tiedup/remake/state/components/PlayerShockCollar.java new file mode 100644 index 0000000..ae0f901 --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/components/PlayerShockCollar.java @@ -0,0 +1,260 @@ +package com.tiedup.remake.state.components; + +import com.tiedup.remake.core.ModSounds; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.SystemMessageManager.MessageCategory; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.ItemGpsCollar; +import com.tiedup.remake.items.ItemShockCollarAuto; +import com.tiedup.remake.state.hosts.IPlayerBindStateHost; +import com.tiedup.remake.util.GameConstants; +import com.tiedup.remake.util.time.Timer; +import java.util.List; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; + +/** + * Component responsible for shock collar mechanics and GPS tracking. + * Phase 13: Advanced collar features (shocks, GPS tracking) + * + * Single Responsibility: Collar automation and GPS monitoring + * Complexity: HIGH (synchronized timer, GPS zone checks, network messages) + * Risk: HIGH (timer thread management) + * + * Threading: Uses synchronized blocks for timer access (volatile Timer field) + */ +public class PlayerShockCollar { + + private final IPlayerBindStateHost host; + + // Phase 13: Collar automation fields + // volatile: accessed from synchronized blocks across multiple threads + private volatile Timer timerAutoShockCollar; + private final Object lockTimerAutoShock = new Object(); + + public PlayerShockCollar(IPlayerBindStateHost host) { + this.host = host; + } + + // ========== Shock Functionality ========== + + /** + * Triggers a shock with default damage. + */ + public void shockKidnapped() { + this.shockKidnapped(null, GameConstants.DEFAULT_SHOCK_DAMAGE); + } + + /** + * Triggers a visual and auditory shock effect. + * Damage is applied (shock can kill). + * + * @param messageAddon Optional message addon for HUD (e.g., GPS violation) + * @param damage Shock damage amount + */ + public void shockKidnapped(@Nullable String messageAddon, float damage) { + Player player = host.getPlayer(); + if (player == null || !player.isAlive()) return; + + // Sound effects + player + .level() + .playSound( + null, + player.blockPosition(), + ModSounds.ELECTRIC_SHOCK.get(), + net.minecraft.sounds.SoundSource.PLAYERS, + GameConstants.SHOCK_SOUND_VOLUME, + GameConstants.SHOCK_SOUND_PITCH + ); + + // Particle effects + if ( + player.level() instanceof + net.minecraft.server.level.ServerLevel serverLevel + ) { + serverLevel.sendParticles( + net.minecraft.core.particles.ParticleTypes.ELECTRIC_SPARK, + player.getX(), + player.getY() + GameConstants.SHOCK_PARTICLE_Y_OFFSET, + player.getZ(), + GameConstants.SHOCK_PARTICLE_COUNT, + GameConstants.SHOCK_PARTICLE_X_SPREAD, + GameConstants.SHOCK_PARTICLE_Y_SPREAD, + GameConstants.SHOCK_PARTICLE_Z_SPREAD, + GameConstants.SHOCK_PARTICLE_SPEED + ); + } + + // Damage logic - shock can kill + if (damage > 0) { + player.hurt(player.damageSources().magic(), damage); + } + + // HUD Message via SystemMessageManager + if (messageAddon != null) { + // Custom message with addon (e.g., GPS violation) + SystemMessageManager.sendToPlayer( + player, + MessageCategory.SLAVE_SHOCK, + SystemMessageManager.getTemplate(MessageCategory.SLAVE_SHOCK) + + messageAddon + ); + } else { + SystemMessageManager.sendToPlayer( + player, + MessageCategory.SLAVE_SHOCK + ); + } + } + + // ========== Auto-Shock & GPS Monitoring ========== + + /** + * Periodic check for Auto-Shock intervals and GPS Safe Zones. + * Called from RestraintTaskTickHandler every player tick. + * + * Thread Safety: Refactored to avoid alien method calls under lock. + * We compute shock decisions inside synchronized block, then execute + * outside the lock to prevent deadlock risk. + */ + public void checkAutoShockCollar() { + Player player = host.getPlayer(); + if (player == null || !player.isAlive()) return; + + // Flags set inside lock, actions performed outside + boolean shouldShockAuto = false; + boolean shouldShockGPS = false; + ItemGpsCollar gpsCollar = null; + ItemStack gpsStack = null; + + synchronized (lockTimerAutoShock) { + ItemStack collarStack = getCurrentCollar(); + if (collarStack.isEmpty()) return; + + // Auto-Shock Collar handling + if ( + collarStack.getItem() instanceof ItemShockCollarAuto collarShock + ) { + if ( + timerAutoShockCollar != null && + timerAutoShockCollar.isExpired() + ) { + shouldShockAuto = true; + } + + if ( + timerAutoShockCollar == null || + timerAutoShockCollar.isExpired() + ) { + timerAutoShockCollar = new Timer( + collarShock.getInterval() / + GameConstants.TICKS_PER_SECOND, + player.level() + ); + } + } + // GPS Collar handling + else if (collarStack.getItem() instanceof ItemGpsCollar gps) { + if ( + gps.isActive(collarStack) && + (timerAutoShockCollar == null || + timerAutoShockCollar.isExpired()) + ) { + List safeSpots = gps.getSafeSpots( + collarStack + ); + if (safeSpots != null && !safeSpots.isEmpty()) { + boolean isSafe = false; + for (ItemGpsCollar.SafeSpot spot : safeSpots) { + if (spot.isInside(player)) { + isSafe = true; + break; + } + } + if (!isSafe) { + timerAutoShockCollar = new Timer( + gps.getShockInterval(collarStack) / + GameConstants.TICKS_PER_SECOND, + player.level() + ); + shouldShockGPS = true; + gpsCollar = gps; + gpsStack = collarStack.copy(); + } + } + } + } + } + + // Execute shock actions OUTSIDE the lock (avoid alien method call under lock) + if (shouldShockAuto) { + this.shockKidnapped(); + } + + if (shouldShockGPS && gpsCollar != null) { + this.shockKidnapped( + " Return back to your allowed area!", + GameConstants.DEFAULT_SHOCK_DAMAGE + ); + warnOwnersGPSViolation(gpsCollar, gpsStack); + } + } + + /** + * Sends a global alert to masters when a slave violates their GPS zone. + * Private helper method. + */ + private void warnOwnersGPSViolation(ItemGpsCollar gps, ItemStack stack) { + Player player = host.getPlayer(); + if (player.getServer() == null) return; + + // Format: "ALERT: is outside the safe zone!" + String alertMessage = String.format( + SystemMessageManager.getTemplate(MessageCategory.GPS_OWNER_ALERT), + player.getName().getString() + ); + + for (UUID ownerId : gps.getOwners(stack)) { + ServerPlayer owner = player + .getServer() + .getPlayerList() + .getPlayer(ownerId); + if (owner != null) { + SystemMessageManager.sendChatToPlayer( + owner, + alertMessage, + ChatFormatting.RED + ); + } + } + } + + /** + * Force-stops and clears any active shock timers. + * Threading: Synchronized block protects timer access + */ + public void resetAutoShockTimer() { + synchronized (lockTimerAutoShock) { + this.timerAutoShockCollar = null; + } + } + + // ========== Helper Methods ========== + + /** + * Get the current collar ItemStack. + * Epic 5F: Migrated to V2EquipmentHelper. + */ + private ItemStack getCurrentCollar() { + return com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getInRegion( + host.getPlayer(), + com.tiedup.remake.v2.BodyRegionV2.NECK + ); + } +} diff --git a/src/main/java/com/tiedup/remake/state/components/PlayerSpecialActions.java b/src/main/java/com/tiedup/remake/state/components/PlayerSpecialActions.java new file mode 100644 index 0000000..323ea09 --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/components/PlayerSpecialActions.java @@ -0,0 +1,85 @@ +package com.tiedup.remake.state.components; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.IRestrainableEntity; +import com.tiedup.remake.state.hosts.IPlayerBindStateHost; +import com.tiedup.remake.util.RestraintEffectUtils; +import com.tiedup.remake.v2.BodyRegionV2; + +/** + * Component responsible for special player interactions. + * v2.5: Knife cut target for accessory cutting + * Phase 14.1.7: Item transfers between players + * + * Single Responsibility: Special action management + * Complexity: MEDIUM (external dependencies) + * Risk: LOW (well-defined interactions) + */ +public class PlayerSpecialActions { + + private final IPlayerBindStateHost host; + + // v2.5: Knife cut target region for accessory cutting (migrated to V2) + private BodyRegionV2 knifeCutRegion = null; + + public PlayerSpecialActions(IPlayerBindStateHost host) { + this.host = host; + } + + // ========== Knife Cut Target ========== + + /** + * Set the body region target for knife cutting. + * Used when player selects "Cut" from StruggleChoiceScreen. + * + * @param region The body region to cut (NECK, MOUTH, etc.) + */ + public void setKnifeCutTarget(BodyRegionV2 region) { + this.knifeCutRegion = region; + } + + /** + * Get the current knife cut target region. + * + * @return The target region, or null if none + */ + public BodyRegionV2 getKnifeCutTarget() { + return knifeCutRegion; + } + + /** + * Clear the knife cut target. + */ + public void clearKnifeCutTarget() { + this.knifeCutRegion = null; + } + + // ========== Special Interactions ========== + + /** + * Apply chloroform effects to the player. + * + * @param duration Duration in seconds + */ + public void applyChloroform(int duration) { + if (host.getPlayer() == null) return; + RestraintEffectUtils.applyChloroformEffects(host.getPlayer(), duration); + } + + /** + * Phase 14.1.7: Transfer bondage item from this player to another. + * Updated to use IRestrainable parameter (was PlayerBindState) + * + * @param taker The entity taking the item + * @param slotIndex The slot index to take from + */ + public void takeBondageItemBy(IRestrainableEntity taker, int slotIndex) { + // TODO Phase 14+: Transfer item from this player to taker + TiedUpMod.LOGGER.debug( + "[PlayerSpecialActions] {} taking bondage item from {} (slot {})", + taker.getKidnappedName(), + host.getPlayer().getName().getString(), + slotIndex + ); + } +} diff --git a/src/main/java/com/tiedup/remake/state/components/PlayerStateQuery.java b/src/main/java/com/tiedup/remake/state/components/PlayerStateQuery.java new file mode 100644 index 0000000..311e4d4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/components/PlayerStateQuery.java @@ -0,0 +1,148 @@ +package com.tiedup.remake.state.components; + +import com.tiedup.remake.state.hosts.IPlayerBindStateHost; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * Component responsible for querying player restraint state. + * Provides read-only checks for equipment status. + * + * Single Responsibility: State queries + * Complexity: LOW (read-only delegation) + * Risk: LOW (no state modification) + * + * Epic 5F: Uses V2EquipmentHelper/BodyRegionV2. + */ +public class PlayerStateQuery { + + private final IPlayerBindStateHost host; + + public PlayerStateQuery(IPlayerBindStateHost host) { + this.host = host; + } + + // ========== State Query Methods ========== + + /** Check if player has ropes/ties equipped. */ + public boolean isTiedUp() { + return V2EquipmentHelper.isRegionOccupied(host.getPlayer(), BodyRegionV2.ARMS); + } + + /** Check if player is currently gagged. */ + public boolean isGagged() { + return V2EquipmentHelper.isRegionOccupied(host.getPlayer(), BodyRegionV2.MOUTH); + } + + /** Check if player is blindfolded. */ + public boolean isBlindfolded() { + return V2EquipmentHelper.isRegionOccupied(host.getPlayer(), BodyRegionV2.EYES); + } + + /** Check if player has earplugs. */ + public boolean hasEarplugs() { + return V2EquipmentHelper.isRegionOccupied(host.getPlayer(), BodyRegionV2.EARS); + } + + public boolean isEarplugged() { + return hasEarplugs(); + } + + /** Check if player is wearing a collar. */ + public boolean hasCollar() { + return V2EquipmentHelper.isRegionOccupied(host.getPlayer(), BodyRegionV2.NECK); + } + + /** Returns the current collar ItemStack, or empty if none. */ + public ItemStack getCurrentCollar() { + if (!hasCollar()) return ItemStack.EMPTY; + return V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.NECK); + } + + public boolean hasClothes() { + return V2EquipmentHelper.isRegionOccupied(host.getPlayer(), BodyRegionV2.TORSO); + } + + /** Check if player has mittens equipped. Phase 14.4: Mittens system */ + public boolean hasMittens() { + return V2EquipmentHelper.isRegionOccupied(host.getPlayer(), BodyRegionV2.HANDS); + } + + /** Check if player can be tied up (not already tied). */ + public boolean canBeTiedUp() { + return !isTiedUp(); + } + + /** Check if player is both tied and gagged. */ + public boolean isBoundAndGagged() { + return isTiedUp() && isGagged(); + } + + /** Check if player has knives in inventory. */ + public boolean hasKnives() { + Player player = host.getPlayer(); + if (player == null) return false; + + // Check main inventory for knife items + for (int i = 0; i < player.getInventory().getContainerSize(); i++) { + ItemStack stack = player.getInventory().getItem(i); + if ( + !stack.isEmpty() && + stack.getItem() instanceof com.tiedup.remake.items.base.IKnife + ) { + return true; + } + } + return false; + } + + /** + * Check if player has a gagging effect enabled. + * Checks both if gagged AND if the gag item implements the effect. + */ + public boolean hasGaggingEffect() { + if (!isGagged()) return false; + ItemStack gag = V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.MOUTH); + if (gag.isEmpty()) return false; + return ( + gag.getItem() instanceof + com.tiedup.remake.items.base.IHasGaggingEffect + ); + } + + /** + * Check if player has a blinding effect enabled. + * Checks both if blindfolded AND if the blindfold item implements the effect. + */ + public boolean hasBlindingEffect() { + if (!isBlindfolded()) return false; + ItemStack blindfold = V2EquipmentHelper.getInRegion(host.getPlayer(), BodyRegionV2.EYES); + if (blindfold.isEmpty()) return false; + return ( + blindfold.getItem() instanceof + com.tiedup.remake.items.base.IHasBlindingEffect + ); + } + + /** + * Check if player can be kidnapped by random events. + * Checks game rules and current state. + */ + public boolean canBeKidnappedByEvents() { + Player player = host.getPlayer(); + // Check if kidnapper spawning is enabled + if (player != null && player.level() != null) { + if ( + !com.tiedup.remake.core.SettingsAccessor.doKidnappersSpawn( + player.level().getGameRules() + ) + ) { + return false; + } + } + // Can't be kidnapped if already tied up or captive (grace period protection) + return !isTiedUp() && !host.getKidnapped().isCaptive(); + } +} diff --git a/src/main/java/com/tiedup/remake/state/components/PlayerStruggle.java b/src/main/java/com/tiedup/remake/state/components/PlayerStruggle.java new file mode 100644 index 0000000..29d8280 --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/components/PlayerStruggle.java @@ -0,0 +1,128 @@ +package com.tiedup.remake.state.components; + +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.state.hosts.IPlayerBindStateHost; +import com.tiedup.remake.state.struggle.StruggleBinds; +import com.tiedup.remake.state.struggle.StruggleCollar; +import net.minecraft.world.entity.player.Player; + +/** + * Component responsible for struggle mechanics and resistance management. + * Phase 7: Struggle & Resistance Methods + * + * Single Responsibility: Struggle state and resistance tracking + * Complexity: MEDIUM (volatile fields, animation coordination) + * Risk: MEDIUM (thread-safety requirements) + * + * Threading: Uses host's volatile fields for animation state (isStruggling, struggleStartTick) + * + * Note: StruggleBinds and StruggleCollar require PlayerBindState parameter. + * Since PlayerBindState implements IPlayerBindStateHost, we can safely cast. + */ +public class PlayerStruggle { + + private final IPlayerBindStateHost host; + private final PlayerBindState state; // Cast reference for struggle system + + // Phase 7 & 8: Struggle state tracking + private final StruggleBinds struggleBindState; + private final StruggleCollar struggleCollarState; + + public PlayerStruggle(IPlayerBindStateHost host) { + this.host = host; + // Safe cast: PlayerBindState implements IPlayerBindStateHost + this.state = (PlayerBindState) host; + + // Initialize sub-states for struggling + this.struggleBindState = new StruggleBinds(); + this.struggleCollarState = new StruggleCollar(); + } + + // ========== Struggle Methods ========== + + /** + * Entry point for the Struggle logic (Key R). + * Distributes effort between Binds and Collar. + * + * Thread Safety: Synchronized to prevent lost updates when multiple struggle + * packets arrive simultaneously (e.g., from macro/rapid keypresses). + */ + public synchronized void struggle() { + if (struggleBindState != null) struggleBindState.struggle(state); + if (struggleCollarState != null) struggleCollarState.struggle(state); + } + + /** + * Restores resistance to base values when a master tightens the ties. + * + * Thread Safety: Synchronized to prevent race with struggle operations. + */ + public synchronized void tighten(Player tightener) { + if (struggleBindState != null) struggleBindState.tighten( + tightener, + state + ); + if (struggleCollarState != null) struggleCollarState.tighten( + tightener, + state + ); + } + + /** + * Get the StruggleBinds instance for external access (mini-game system). + */ + public StruggleBinds getStruggleBinds() { + return struggleBindState; + } + + /** + * Set a cooldown on struggle attempts (used after mini-game exhaustion). + * @param seconds Cooldown duration in seconds + */ + public void setStruggleCooldown(int seconds) { + if ( + struggleBindState != null && + host.getPlayer() != null && + host.getLevel() != null + ) { + struggleBindState.setExternalCooldown(seconds, host.getLevel()); + } + } + + /** + * v2.5: Check if struggle cooldown is active. + * @return true if cooldown is active (cannot struggle yet) + */ + public boolean isStruggleCooldownActive() { + return ( + struggleBindState != null && struggleBindState.isCooldownActive() + ); + } + + /** + * v2.5: Get remaining struggle cooldown in seconds. + * @return Remaining seconds, or 0 if no cooldown + */ + public int getStruggleCooldownRemaining() { + return struggleBindState != null + ? struggleBindState.getRemainingCooldownSeconds() + : 0; + } + + // ========== Animation Control ========== + // Note: Animation state (isStruggling, struggleStartTick) is managed by host + // via IPlayerBindStateHost interface (volatile fields for thread-safety) + + /** + * Check if struggle animation should stop (duration expired). + * @param currentTick Current game time tick + * @return True if animation has been playing for >= 80 ticks + */ + public boolean shouldStopStruggling(long currentTick) { + if (!host.isStruggling()) return false; + return ( + (currentTick - host.getStruggleStartTick()) >= + com.tiedup.remake.util.GameConstants.STRUGGLE_ANIMATION_DURATION_TICKS + ); + } +} diff --git a/src/main/java/com/tiedup/remake/state/components/PlayerTaskManagement.java b/src/main/java/com/tiedup/remake/state/components/PlayerTaskManagement.java new file mode 100644 index 0000000..bde52d1 --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/components/PlayerTaskManagement.java @@ -0,0 +1,85 @@ +package com.tiedup.remake.state.components; + +import com.tiedup.remake.tasks.PlayerStateTask; +import com.tiedup.remake.tasks.TimedInteractTask; +import com.tiedup.remake.tasks.TyingTask; +import com.tiedup.remake.tasks.UntyingTask; + +/** + * Component responsible for tracking tying/untying tasks. + * Phase 6: Tying/Untying task tracking (Phase 14.2.6: unified for Players + NPCs) + * + * Single Responsibility: Task state management + * Complexity: LOW (simple getters/setters) + * Risk: LOW (isolated, no external dependencies) + */ +public class PlayerTaskManagement { + + // ========== Task Fields ========== + + private TyingTask currentTyingTask; + private UntyingTask currentUntyingTask; + private PlayerStateTask clientTyingTask; + private PlayerStateTask clientUntyingTask; + private TimedInteractTask currentFeedingTask; + private PlayerStateTask clientFeedingTask; + private PlayerStateTask restrainedState; + + // ========== Task Getters/Setters ========== + + public TyingTask getCurrentTyingTask() { + return currentTyingTask; + } + + public void setCurrentTyingTask(TyingTask task) { + this.currentTyingTask = task; + } + + public UntyingTask getCurrentUntyingTask() { + return currentUntyingTask; + } + + public void setCurrentUntyingTask(UntyingTask task) { + this.currentUntyingTask = task; + } + + public PlayerStateTask getClientTyingTask() { + return clientTyingTask; + } + + public void setClientTyingTask(PlayerStateTask task) { + this.clientTyingTask = task; + } + + public PlayerStateTask getClientUntyingTask() { + return clientUntyingTask; + } + + public void setClientUntyingTask(PlayerStateTask task) { + this.clientUntyingTask = task; + } + + public TimedInteractTask getCurrentFeedingTask() { + return currentFeedingTask; + } + + public void setCurrentFeedingTask(TimedInteractTask task) { + this.currentFeedingTask = task; + } + + public PlayerStateTask getClientFeedingTask() { + return clientFeedingTask; + } + + public void setClientFeedingTask(PlayerStateTask task) { + this.clientFeedingTask = task; + } + + public PlayerStateTask getRestrainedState() { + return restrainedState; + } + + public void setRestrainedState(PlayerStateTask state) { + this.restrainedState = state; + } +} diff --git a/src/main/java/com/tiedup/remake/state/hosts/IPlayerBindStateHost.java b/src/main/java/com/tiedup/remake/state/hosts/IPlayerBindStateHost.java new file mode 100644 index 0000000..2ceeb6d --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/hosts/IPlayerBindStateHost.java @@ -0,0 +1,143 @@ +package com.tiedup.remake.state.hosts; + +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.ICaptor; +import com.tiedup.remake.state.PlayerCaptorManager; +import java.util.UUID; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; + +/** + * Core host interface for PlayerBindState components. + * Provides access to player data and coordination methods. + * + * Thread Safety: Methods accessing volatile fields are thread-safe. + */ +public interface IPlayerBindStateHost { + // ========== Entity Access ========== + + /** + * Get the player entity associated with this state. + * @return The player entity + */ + Player getPlayer(); + + /** + * Get the player's UUID. + * @return The player's unique identifier + */ + UUID getPlayerUUID(); + + /** + * Get the current level/world the player is in. + * @return The player's current level + */ + Level getLevel(); + + /** + * Check if this instance is on the client side. + * @return true if client-side, false if server-side + */ + boolean isClientSide(); + + /** + * Get the IBondageState interface for this player state. + * Used when components need to pass the player state to other systems. + * @return This instance cast to IBondageState + */ + IBondageState getKidnapped(); + + // ========== Lifecycle ========== + + /** + * Check if the player is currently online. + * @return true if player is online + */ + boolean isOnline(); + + /** + * Set the player's online status. + * @param online true if player is online + */ + void setOnline(boolean online); + + // ========== Network Sync (Centralized) ========== + + /** + * Sync clothes configuration to all tracking clients. + * Used when clothes are equipped/changed. + */ + void syncClothesConfig(); + + /** + * Sync enslavement/captivity state to all clients. + * Used when capture/free state changes. + */ + void syncEnslavement(); + + // ========== System Access ========== + + /** + * Get the current captor (master) of this player. + * @return The captor, or null if not captive + */ + ICaptor getCaptor(); + + /** + * Set the captor for this player. + * @param captor The new captor, or null to clear + */ + void setCaptor(ICaptor captor); + + /** + * Get the captor manager (for when player acts as captor). + * @return The captor manager + */ + PlayerCaptorManager getCaptorManager(); + + // ========== State Queries ========== + + /** + * Check if player is tied up (has bind equipment). + * @return true if player has bind + */ + boolean isTiedUp(); + + /** + * Check if player has a collar equipped. + * @return true if player has collar + */ + boolean hasCollar(); + + // ========== Struggle State (Volatile Wrapper) ========== + + /** + * Set struggle animation state (server-side). + * Thread-safe (volatile field). + * @param struggling True to start animation, false to stop + * @param tick Current game time tick + */ + void setStruggling(boolean struggling, long tick); + + /** + * Set struggle animation flag (client-side only). + * Thread-safe (volatile field). + * Does NOT update timer - server manages timer. + * @param struggling True if struggling + */ + void setStrugglingClient(boolean struggling); + + /** + * Check if player is currently playing struggle animation. + * Thread-safe (volatile field). + * @return true if struggling + */ + boolean isStruggling(); + + /** + * Get the tick when struggle animation started. + * Thread-safe (volatile field). + * @return Start tick + */ + long getStruggleStartTick(); +} diff --git a/src/main/java/com/tiedup/remake/state/struggle/StruggleAccessory.java b/src/main/java/com/tiedup/remake/state/struggle/StruggleAccessory.java new file mode 100644 index 0000000..0287a6f --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/struggle/StruggleAccessory.java @@ -0,0 +1,224 @@ +package com.tiedup.remake.state.struggle; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.SystemMessageManager.MessageCategory; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * Phase 21: Struggle implementation for accessory items (gag, blindfold, earplugs, collar). + * + * Accessories have NO base resistance - only locked items can be struggled. + * Lock adds 250 resistance. Struggle success destroys the padlock (lockable=false). + * + * Unlike binds, accessories are NOT automatically removed on struggle success. + * The item remains equipped but unlocked, allowing manual removal. + */ +public class StruggleAccessory extends StruggleState { + + /** The V2 body region of the accessory being struggled against. */ + private final BodyRegionV2 accessoryRegion; + + public StruggleAccessory(BodyRegionV2 accessoryRegion) { + if (accessoryRegion == BodyRegionV2.ARMS) { + throw new IllegalArgumentException( + "Use StruggleBinds for ARMS region, not StruggleAccessory" + ); + } + this.accessoryRegion = accessoryRegion; + } + + /** + * Get the current resistance for this accessory. + * Accessories have 0 base resistance - only lock resistance exists. + * Resistance is stored in the item's NBT to persist between attempts. + * + * @param state The player's bind state + * @return Current resistance value (0 if not locked, 250 if locked and not yet struggled) + */ + @Override + protected int getResistanceState(PlayerBindState state) { + ItemStack stack = getAccessoryStack(state); + if (stack.isEmpty()) return 0; + + if (stack.getItem() instanceof ILockable lockable) { + return lockable.getCurrentLockResistance(stack); + } + return 0; + } + + /** + * Set the current resistance for this accessory struggle. + * Stored in the item's NBT to persist between attempts. + * + * @param state The player's bind state + * @param resistance The new resistance value + */ + @Override + protected void setResistanceState(PlayerBindState state, int resistance) { + ItemStack stack = getAccessoryStack(state); + if (stack.isEmpty()) return; + + if (stack.getItem() instanceof ILockable lockable) { + lockable.setCurrentLockResistance(stack, resistance); + } + } + + /** + * Check if the player can struggle against this accessory. + * Only locked accessories can be struggled against. + * + * @param state The player's bind state + * @return True if the accessory is equipped and locked + */ + @Override + protected boolean canStruggle(PlayerBindState state) { + Player player = state.getPlayer(); + if (player == null) { + return false; + } + + ItemStack stack = getAccessoryStack(state); + if (stack.isEmpty()) { + return false; + } + + // Only locked accessories can be struggled against + if (!(stack.getItem() instanceof ILockable lockable)) { + return false; + } + + return lockable.isLocked(stack); + } + + /** + * Check if the accessory is locked. + * + * @param state The player's bind state + * @return true if the accessory is locked + */ + @Override + protected boolean isItemLocked(PlayerBindState state) { + ItemStack stack = getAccessoryStack(state); + if ( + stack.isEmpty() || !(stack.getItem() instanceof ILockable lockable) + ) { + return false; + } + return lockable.isLocked(stack); + } + + /** + * Called when the player successfully struggles against the accessory. + * Unlocks the item and DESTROYS the padlock (lockable=false). + * The item remains equipped but can now be removed manually. + * + * @param state The player's bind state + */ + @Override + protected void successAction(PlayerBindState state) { + Player player = state.getPlayer(); + if (player == null) return; + + ItemStack stack = getAccessoryStack(state); + if (stack.isEmpty()) return; + + // Destroy the padlock (unlock + lockable=false) + if (stack.getItem() instanceof ILockable lockable) { + lockable.breakLock(stack); + TiedUpMod.LOGGER.info( + "[STRUGGLE] {} destroyed padlock on {} (region: {})", + player.getName().getString(), + stack.getDisplayName().getString(), + accessoryRegion + ); + } + } + + @Override + protected MessageCategory getSuccessCategory() { + return MessageCategory.STRUGGLE_SUCCESS; // Used for color (green) + } + + @Override + protected MessageCategory getFailCategory() { + return MessageCategory.STRUGGLE_FAIL; // Used for color (gray) + } + + /** + * Override to send custom success message per accessory type. + */ + @Override + protected void notifySuccess(Player player, int newResistance) { + String message = switch (accessoryRegion) { + case MOUTH -> "You manage to loosen the gag's lock..."; + case EYES -> "The blindfold's lock gives way..."; + case EARS -> "You feel the earplug lock breaking..."; + case NECK -> "The collar's lock snaps open..."; + case TORSO -> "The clothing lock breaks..."; + case HANDS -> "The mitten lock comes undone..."; + default -> "The lock breaks!"; + }; + SystemMessageManager.sendToPlayer( + player, + getSuccessCategory(), + message + " (Resistance: " + newResistance + ")" + ); + } + + /** + * Override to send custom failure message per accessory type. + */ + @Override + protected void notifyFailure(Player player) { + String message = switch (accessoryRegion) { + case MOUTH -> "You struggle against the gag, but the lock holds."; + case EYES -> "The blindfold's lock refuses to budge."; + case EARS -> "The earplug lock stays firmly shut."; + case NECK -> "The collar's lock resists your efforts."; + case TORSO -> "The clothing lock holds tight."; + case HANDS -> "The mitten lock won't give in."; + default -> "The lock holds firm."; + }; + SystemMessageManager.sendToPlayer(player, getFailCategory(), message); + } + + /** + * Phase 13: Trigger shock collar check when struggling against accessories. + */ + @Override + protected boolean onAttempt(PlayerBindState state) { + Player player = state.getPlayer(); + ItemStack collar = V2EquipmentHelper.getInRegion(player, BodyRegionV2.NECK); + + if ( + !collar.isEmpty() && + collar.getItem() instanceof + com.tiedup.remake.items.ItemShockCollar shockCollar + ) { + return shockCollar.notifyStruggle(player, collar); + } + return true; // No collar, proceed normally + } + + /** + * Get the accessory ItemStack from the player. + */ + private ItemStack getAccessoryStack(PlayerBindState state) { + Player player = state.getPlayer(); + if (player == null) return ItemStack.EMPTY; + return V2EquipmentHelper.getInRegion(player, accessoryRegion); + } + + /** + * Get the V2 body region of the accessory this struggle targets. + */ + public BodyRegionV2 getAccessoryRegion() { + return accessoryRegion; + } +} diff --git a/src/main/java/com/tiedup/remake/state/struggle/StruggleBinds.java b/src/main/java/com/tiedup/remake/state/struggle/StruggleBinds.java new file mode 100644 index 0000000..cb8c1a9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/struggle/StruggleBinds.java @@ -0,0 +1,347 @@ +package com.tiedup.remake.state.struggle; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.SystemMessageManager.MessageCategory; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.state.IPlayerLeashAccess; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.core.SettingsAccessor; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * Phase 7: Struggle implementation for bind restraints (ropes). + * + * Handles struggling against binds: + * - Gets/sets resistance from equipped bind item + * - Drops bondage items when escaping + * - Unties the player when resistance reaches 0 + * + * Based on original StruggleBinds from 1.12.2 (lines 17-117) + */ +public class StruggleBinds extends StruggleState { + + /** + * Get the current bind resistance from the equipped bind item. + * + * Based on original StruggleBinds.getResistanceState() (lines 23-25) + * + * @param state The player's bind state + * @return Current resistance value + */ + @Override + protected int getResistanceState(PlayerBindState state) { + return state.getCurrentBindResistance(); + } + + /** + * Set the current bind resistance on the equipped bind item. + * + * Based on original StruggleBinds.setResistanceState() (lines 28-30) + * + * @param state The player's bind state + * @param resistance The new resistance value + */ + @Override + protected void setResistanceState(PlayerBindState state, int resistance) { + state.setCurrentBindResistance(resistance); + } + + /** + * Check if the player can struggle against their binds. + * Returns false if: + * - No bind item equipped + * - Bind item has struggle disabled + * + * Phase 20: Locked items can now be struggled, but with x10 resistance. + * The resistance penalty is applied in StruggleState.struggle(). + * + * Based on original StruggleBinds.canStruggle() (lines 33-41) + * + * @param state The player's bind state + * @return True if struggling is allowed + */ + @Override + protected boolean canStruggle(PlayerBindState state) { + Player player = state.getPlayer(); + if (player == null) { + return false; + } + + ItemStack bindStack = V2EquipmentHelper.getInRegion(player, BodyRegionV2.ARMS); + if ( + bindStack.isEmpty() || + !(bindStack.getItem() instanceof ItemBind bind) + ) { + return false; + } + + // Phase 20: Locked items can now be struggled (with x10 resistance) + // The locked check has been moved to struggle() where decrease is reduced + + return bind.canBeStruggledOut(bindStack); +} + + /** + * Check if the bind item is locked. + * Used by StruggleState to apply x10 resistance penalty. + * + * @param state The player's bind state + * @return true if the bind is locked + */ + @Override + protected boolean isItemLocked(PlayerBindState state) { + Player player = state.getPlayer(); + if (player == null) return false; + + ItemStack bindStack = V2EquipmentHelper.getInRegion(player, BodyRegionV2.ARMS); + if ( + bindStack.isEmpty() || + !(bindStack.getItem() instanceof ItemBind bind) + ) { + return false; + } + + return bind.isLocked(bindStack); + } + + /** + * Called when the player successfully escapes from their binds. + * Drops all bondage items and completely unties the player. + * + * Based on original StruggleBinds.successAction() (lines 44-47) + * + * @param state The player's bind state + */ + @Override + protected void successAction(PlayerBindState state) { + dropBondageItems(state); + untieTarget(state); + } + + @Override + protected MessageCategory getSuccessCategory() { + return MessageCategory.STRUGGLE_SUCCESS; + } + + @Override + protected MessageCategory getFailCategory() { + return MessageCategory.STRUGGLE_FAIL; + } + + /** + * Phase 13: Trigger shock collar check even when struggling against binds. + * If shocked, the attempt is missed. + */ + @Override + protected boolean onAttempt(PlayerBindState state) { + Player player = state.getPlayer(); + ItemStack collar = V2EquipmentHelper.getInRegion(player, BodyRegionV2.NECK); + + if ( + !collar.isEmpty() && + collar.getItem() instanceof + com.tiedup.remake.items.ItemShockCollar shockCollar + ) { + return shockCollar.notifyStruggle(player, collar); + } + return true; // No collar, proceed normally + } + + /** + * Drop the bind items to the ground. + * Accessories are NOT dropped during bind struggle. + * + * Phase 21: Struggle success destroys the padlock (lockable=false). + * + * Based on original StruggleBinds.dropBondageItems() (lines 50-69) + * + * @param state The player's bind state + */ + private void dropBondageItems(PlayerBindState state) { + Player player = state.getPlayer(); + if (player == null || player.level() == null) { + return; + } + + // ONLY drop the BIND slot + ItemStack stack = V2EquipmentHelper.getInRegion(player, BodyRegionV2.ARMS); + if (!stack.isEmpty()) { + // Phase 21: Struggle success DESTROYS the padlock + // The bind drops without its padlock (lockable=false) + ItemStack toDrop = stack.copy(); + if ( + toDrop.getItem() instanceof ILockable lockable && + lockable.isLockable(toDrop) + ) { + lockable.breakLock(toDrop); + TiedUpMod.LOGGER.info( + "[STRUGGLE] Padlock destroyed on {} during struggle", + toDrop.getDisplayName().getString() + ); + } + + ItemEntity itemEntity = new ItemEntity( + player.level(), + player.getX(), + player.getY(), + player.getZ(), + toDrop + ); + player.level().addFreshEntity(itemEntity); + + TiedUpMod.LOGGER.debug( + "[STRUGGLE] Dropped {} from {}", + toDrop.getDisplayName().getString(), + player.getName().getString() + ); + } + } + + /** + * Remove only the bind items from the player (untie). + * Accessories like gags, collars, and blindfolds remain equipped. + * + * Based on original StruggleBinds.untieTarget() (lines 72-78) + * + * @param state The player's bind state + */ + private void untieTarget(PlayerBindState state) { + Player player = state.getPlayer(); + if (player == null) { + return; + } + + // Phase 17: 1. Remove from captivity/leash + if (state.isCaptive()) { + state.free(); + } + // Also detach leash if tied to pole (no captor but still leashed) + else if ( + player instanceof IPlayerLeashAccess access && + access.tiedup$isLeashed() + ) { + access.tiedup$detachLeash(); + access.tiedup$dropLeash(); + } + + // 2. ONLY unequip the BIND slot (ropes, etc.) + V2EquipmentHelper.unequipFromRegion(player, BodyRegionV2.ARMS); + + TiedUpMod.LOGGER.info( + "[STRUGGLE] {} has struggled out of their binds and leash", + player.getName().getString() + ); + } + + /** + * Phase 2: External success action for mini-game system. + * Called when player completes struggle mini-game successfully. + * + * @param state The player's bind state + */ + public void successActionExternal(PlayerBindState state) { + successAction(state); + } + + /** + * Phase 2: Set external cooldown timer (e.g., from mini-game exhaustion). + * + * @param seconds Cooldown duration in seconds + * @param level The level for timer + */ + public void setExternalCooldown( + int seconds, + net.minecraft.world.level.Level level + ) { + this.struggleCooldownTimer = new com.tiedup.remake.util.time.Timer( + seconds, + level + ); + } + + /** + * v2.5: Check if struggle cooldown is active. + * Used by QTE mini-game to prevent starting a new session during cooldown. + * + * @return true if cooldown is active (cannot struggle yet) + */ + public boolean isCooldownActive() { + return ( + struggleCooldownTimer != null && !struggleCooldownTimer.isExpired() + ); + } + + /** + * v2.5: Get remaining cooldown time in seconds. + * + * @return Remaining seconds, or 0 if no cooldown + */ + public int getRemainingCooldownSeconds() { + if ( + struggleCooldownTimer == null || struggleCooldownTimer.isExpired() + ) { + return 0; + } + return struggleCooldownTimer.getSecondsRemaining(); + } + + /** + * Tighten the player's binds (restore resistance to base value). + * Called when another player right-clicks with a paddle/whip. + * + * Based on original StruggleBinds.tighten() (lines 81-101) + * + * @param tightener The player performing the tightening + * @param state The target player's bind state + */ + public void tighten(Player tightener, PlayerBindState state) { + if (state == null || !state.isTiedUp()) { + return; + } + + Player target = state.getPlayer(); + if (target == null || target.level() == null) { + return; + } + + ItemStack bindStack = V2EquipmentHelper.getInRegion(target, BodyRegionV2.ARMS); + if ( + bindStack.isEmpty() || + !(bindStack.getItem() instanceof ItemBind bind) + ) { + return; + } + + // Get base resistance from config (BUG-003 fix: was using ModGameRules which + // only knew 4 types and returned hardcoded 100 for the other 10) + int baseResistance = SettingsAccessor.getBindResistance( + bind.getItemName() + ); + + // Set current resistance to base (full restore) + setResistanceState(state, baseResistance); + + TiedUpMod.LOGGER.info( + "[STRUGGLE] {} tightened {}'s binds (resistance restored to {})", + tightener.getName().getString(), + target.getName().getString(), + baseResistance + ); + + // Send message to target: "X tightened your binds!" + SystemMessageManager.sendBindsTightened(tightener, target); + + // Send confirmation to tightener + SystemMessageManager.sendToPlayer( + tightener, + "You tightened " + target.getName().getString() + "'s binds!", + net.minecraft.ChatFormatting.YELLOW + ); + } +} diff --git a/src/main/java/com/tiedup/remake/state/struggle/StruggleCollar.java b/src/main/java/com/tiedup/remake/state/struggle/StruggleCollar.java new file mode 100644 index 0000000..48352e7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/struggle/StruggleCollar.java @@ -0,0 +1,261 @@ +package com.tiedup.remake.state.struggle; + +import com.tiedup.remake.core.SystemMessageManager.MessageCategory; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * Phase 8: Master-Slave Relationships + * + * Struggle mechanics specifically for locked collars. + * + * How it Works: + * - Only locked collars can be struggled against + * - Success: Collar becomes UNLOCKED (not removed) + * - No items are dropped (unlike bind struggle) + * - Uses collar-specific GameRules for probability and resistance + * + * Differences from StruggleBinds: + * - Condition: hasLockedCollar() instead of isTiedUp() + * - Success: unlock collar instead of untie + drop items + * - Resistance source: collar NBT instead of bind NBT + * - GameRules: STRUGGLE_COLLAR, PROBABILITY_STRUGGLE_COLLAR, etc. + * + * Usage: + * - Slave wears locked collar + * - Presses R (struggle key) + * - Eventually unlocks collar (can then remove it) + * + * @see StruggleBinds + * @see StruggleState + */ +public class StruggleCollar extends StruggleState { + + /** + * Get the current resistance from the equipped collar. + * + * @param state The PlayerBindState + * @return Current collar resistance, or 0 if no collar + */ + @Override + protected int getResistanceState(PlayerBindState state) { + Player player = state.getPlayer(); + ItemStack collar = V2EquipmentHelper.getInRegion(player, BodyRegionV2.NECK); + + if ( + collar.isEmpty() || + !(collar.getItem() instanceof ItemCollar collarItem) + ) { + return 0; + } + + return collarItem.getCurrentResistance(collar, player); + } + + /** + * Set the current resistance on the equipped collar. + * + * @param state The PlayerBindState + * @param resistance The new resistance value + */ + @Override + protected void setResistanceState(PlayerBindState state, int resistance) { + Player player = state.getPlayer(); + ItemStack collar = V2EquipmentHelper.getInRegion(player, BodyRegionV2.NECK); + + if ( + collar.isEmpty() || + !(collar.getItem() instanceof ItemCollar collarItem) + ) { + return; + } + + collarItem.setCurrentResistance(collar, resistance); + } + + /** + * Check if the collar can be struggled against. + * + * Conditions: + * - Must have collar equipped + * - Collar must be LOCKED + * - Collar must allow struggle (canBeStruggledOut) + * + * Note: Can struggle collar even if NOT tied up. + * + * @param state The PlayerBindState + * @return true if can struggle collar + */ + @Override + protected boolean canStruggle(PlayerBindState state) { + Player player = state.getPlayer(); + + // Phase 13 logic: Can only struggle against collar if NOT tied up + if (state.isTiedUp()) { + return false; + } + + ItemStack collar = V2EquipmentHelper.getInRegion(player, BodyRegionV2.NECK); + + if ( + collar.isEmpty() || + !(collar.getItem() instanceof ItemCollar collarItem) + ) { + TiedUpMod.LOGGER.debug("[StruggleCollar] No collar equipped"); + return false; + } + + // Check if locked + if (!collarItem.isLocked(collar)) { + TiedUpMod.LOGGER.debug("[StruggleCollar] Collar is not locked"); + return false; + } + + // Check if struggle is enabled + if (!collarItem.canBeStruggledOut(collar)) { + TiedUpMod.LOGGER.debug( + "[StruggleCollar] Collar struggle is disabled" + ); + return false; + } + + return true; + } + + /** + * Action to perform when struggle is attempted. + * Used for random shock chance on shock collars. + */ + @Override + protected boolean onAttempt(PlayerBindState state) { + Player player = state.getPlayer(); + ItemStack collar = V2EquipmentHelper.getInRegion(player, BodyRegionV2.NECK); + + if ( + !collar.isEmpty() && + collar.getItem() instanceof + com.tiedup.remake.items.ItemShockCollar shockCollar + ) { + return shockCollar.notifyStruggle(player, collar); + } + return true; + } + + /** + * Action to perform when struggle succeeds (resistance = 0). + * + * For collars: Unlock the collar. + * The player can then manually remove it. + * + * @param state The PlayerBindState + */ + @Override + protected void successAction(PlayerBindState state) { + Player player = state.getPlayer(); + ItemStack collar = V2EquipmentHelper.getInRegion(player, BodyRegionV2.NECK); + + if ( + collar.isEmpty() || + !(collar.getItem() instanceof ItemCollar collarItem) + ) { + TiedUpMod.LOGGER.warn( + "[StruggleCollar] successAction called but no collar equipped" + ); + return; + } + + // Unlock the collar + collarItem.setLocked(collar, false); + + TiedUpMod.LOGGER.info( + "[StruggleCollar] {} unlocked their collar!", + player.getName().getString() + ); + + // Note: Collar is NOT removed, just unlocked + // Player can now manually remove it + } + + @Override + protected MessageCategory getSuccessCategory() { + return MessageCategory.STRUGGLE_COLLAR_SUCCESS; + } + + @Override + protected MessageCategory getFailCategory() { + return MessageCategory.STRUGGLE_COLLAR_FAIL; + } + + /** + * Tighten the collar (restore resistance to base value). + * Called when a master tightens the slave's collar. + * + * Conditions: + * - Collar must be locked + * - Tightener must be different from wearer + * + * @param tightener The player tightening the collar + * @param state The PlayerBindState of the collar wearer + */ + public void tighten(Player tightener, PlayerBindState state) { + Player target = state.getPlayer(); + + if (tightener == null || target == null) { + return; + } + + // Can't tighten your own collar + if (tightener.getUUID().equals(target.getUUID())) { + TiedUpMod.LOGGER.debug( + "[StruggleCollar] Cannot tighten own collar" + ); + return; + } + + ItemStack collar = V2EquipmentHelper.getInRegion(target, BodyRegionV2.NECK); + + if ( + collar.isEmpty() || + !(collar.getItem() instanceof ItemCollar collarItem) + ) { + TiedUpMod.LOGGER.debug("[StruggleCollar] No collar to tighten"); + return; + } + + // Check if collar is locked + if (!collarItem.isLocked(collar)) { + TiedUpMod.LOGGER.debug( + "[StruggleCollar] Collar must be locked to tighten" + ); + return; + } + + // Get base resistance from GameRules + int baseResistance = collarItem.getBaseResistance(target); + int currentResistance = collarItem.getCurrentResistance(collar, target); + + // Only tighten if current resistance is lower than base + if (currentResistance >= baseResistance) { + TiedUpMod.LOGGER.debug( + "[StruggleCollar] Collar already at max resistance" + ); + return; + } + + // Restore to base resistance + collarItem.setCurrentResistance(collar, baseResistance); + + TiedUpMod.LOGGER.info( + "[StruggleCollar] {} tightened {}'s collar (resistance {} -> {})", + tightener.getName().getString(), + target.getName().getString(), + currentResistance, + baseResistance + ); + } +} diff --git a/src/main/java/com/tiedup/remake/state/struggle/StruggleState.java b/src/main/java/com/tiedup/remake/state/struggle/StruggleState.java new file mode 100644 index 0000000..abd637e --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/struggle/StruggleState.java @@ -0,0 +1,231 @@ +package com.tiedup.remake.state.struggle; + +import com.tiedup.remake.core.SystemMessageManager; +import com.tiedup.remake.core.SystemMessageManager.MessageCategory; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.core.SettingsAccessor; +import com.tiedup.remake.util.time.Timer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.GameRules; + +/** + * Phase 7: Base class for struggle mechanics. + * + * Handles the logic for players/NPCs struggling against restraints: + * - Cooldown timer between attempts + * - Probability-based success (dice roll) + * - Resistance decrease on success + * - Automatic removal when resistance reaches 0 + * + * v2.5: This is now used for NPC struggle only. + * Players use the continuous struggle mini-game (ContinuousStruggleMiniGameState). + * Knife bonuses have been removed - knives now work by active cutting. + * + * Architecture: + * - StruggleState (abstract base) + * ├─ StruggleBinds (struggle against bind restraints) + * └─ StruggleCollar (struggle against collar/ownership) + */ +public abstract class StruggleState { + + /** + * Cooldown timer for struggle attempts. + * Reset after each struggle attempt. + * Volatile for thread safety across network/tick threads. + */ + protected volatile Timer struggleCooldownTimer; + + /** + * Main struggle logic (dice roll based). + * Used for NPC struggle - players use QTE mini-game instead. + * + * Flow: + * 1. Check if struggle is enabled (GameRule) + * 2. Check if item can be struggled out of + * 3. Check cooldown timer + * 4. Roll dice for success (probability %) + * 5. If success: decrease resistance by random amount + * 6. If resistance <= 0: call successAction() + * 7. Set cooldown for next attempt + * + * @param state The player's bind state + */ + public synchronized void struggle(PlayerBindState state) { + Player player = state.getPlayer(); + if (player == null || player.level() == null) { + return; + } + + GameRules gameRules = player.level().getGameRules(); + + // 1. Check if struggle system is enabled + if (!SettingsAccessor.isStruggleEnabled(gameRules)) { + return; + } + + // 2. Check if item can be struggled out of (not locked or forced) + if (!canStruggle(state)) { + return; + } + + // 3. Check cooldown timer (prevent spamming) + if ( + struggleCooldownTimer != null && !struggleCooldownTimer.isExpired() + ) { + TiedUpMod.LOGGER.debug( + "[STRUGGLE] Cooldown not expired yet - ignoring struggle attempt" + ); + return; + } + + // Start struggle animation (after cooldown check passes) + if (!state.isStruggling()) { + state.setStruggling(true, player.level().getGameTime()); + com.tiedup.remake.network.sync.SyncManager.syncStruggleState( + player + ); + } + + // Phase 13: Trigger attempt effects (shock collar check) + if (!onAttempt(state)) { + return; // Interrupted by pain + } + + // 4. Roll for success + int probability = SettingsAccessor.getProbabilityStruggle(gameRules); + int roll = player.getRandom().nextInt(100) + 1; + boolean success = roll <= probability; + + if (success) { + // Calculate resistance decrease + int currentResistance = getResistanceState(state); + int minDecrease = SettingsAccessor.getStruggleMinDecrease(gameRules); + int maxDecrease = SettingsAccessor.getStruggleMaxDecrease(gameRules); + + int decrease = + minDecrease + + player.getRandom().nextInt(maxDecrease - minDecrease + 1); + + int newResistance = Math.max(0, currentResistance - decrease); + setResistanceState(state, newResistance); + + // Feedback + notifySuccess(player, newResistance); + + // Check for escape + if (newResistance <= 0) { + TiedUpMod.LOGGER.debug( + "[STRUGGLE] {} broke free! (resistance reached 0)", + player.getName().getString() + ); + successAction(state); + } + } else { + notifyFailure(player); + } + + // 5. Set cooldown + int cooldownTicks = SettingsAccessor.getStruggleTimer(gameRules); + struggleCooldownTimer = new Timer(cooldownTicks / 20, player.level()); + } + + /** + * Get the message category for successful struggle attempts. + */ + protected abstract MessageCategory getSuccessCategory(); + + /** + * Get the message category for failed struggle attempts. + */ + protected abstract MessageCategory getFailCategory(); + + /** + * Send success notification to the player. + * + * @param player The player to notify + * @param newResistance The new resistance value after decrease + */ + protected void notifySuccess(Player player, int newResistance) { + SystemMessageManager.sendWithResistance( + player, + getSuccessCategory(), + newResistance + ); + } + + /** + * Send failure notification to the player. + * + * @param player The player to notify + */ + protected void notifyFailure(Player player) { + SystemMessageManager.sendToPlayer(player, getFailCategory()); + } + + /** + * Called when the player successfully struggles free (resistance reaches 0). + * Subclasses implement this to handle the escape action. + * + * @param state The player's bind state + */ + protected abstract void successAction(PlayerBindState state); + + /** + * Called when a struggle attempt is performed (regardless of success/fail). + * Can be used for side effects like random shocks. + * + * @param state The player's bind state + * @return true to proceed with struggle, false to interrupt + */ + protected boolean onAttempt(PlayerBindState state) { + return true; // Default: proceed + } + + /** + * Get the current resistance value from the player's state. + * + * @param state The player's bind state + * @return Current resistance value + */ + protected abstract int getResistanceState(PlayerBindState state); + + /** + * Set the current resistance value in the player's state. + * + * @param state The player's bind state + * @param resistance The new resistance value + */ + protected abstract void setResistanceState( + PlayerBindState state, + int resistance + ); + + /** + * Check if the player can struggle. + * Returns false if the item cannot be struggled out of. + * + * @param state The player's bind state + * @return True if struggling is allowed + */ + protected abstract boolean canStruggle(PlayerBindState state); + + /** + * Check if the item being struggled against is locked. + * + * @param state The player's bind state + * @return true if the item is locked + */ + protected boolean isItemLocked(PlayerBindState state) { + return false; // Default: not locked, subclasses override + } + + /** + * Check if debug logging is enabled. + * + * @return True if debug logging should be printed + */ + protected boolean isDebugEnabled() { + return true; + } +} diff --git a/src/main/java/com/tiedup/remake/tasks/ForceFeedingTask.java b/src/main/java/com/tiedup/remake/tasks/ForceFeedingTask.java new file mode 100644 index 0000000..151f047 --- /dev/null +++ b/src/main/java/com/tiedup/remake/tasks/ForceFeedingTask.java @@ -0,0 +1,219 @@ +package com.tiedup.remake.tasks; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.action.PacketForceFeeding; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.PlayerBindState; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * Server-side task for force feeding a gagged entity. + * Player must keep looking at the target for 5 seconds to complete. + */ +public class ForceFeedingTask extends TimedInteractTask { + + private Player feeder; + private ItemStack foodStack; + private int sourceSlot; + + public ForceFeedingTask( + IBondageState targetState, + LivingEntity targetEntity, + int seconds, + Level level, + Player feeder, + ItemStack foodStack, + int sourceSlot + ) { + super(targetState, targetEntity, seconds, level); + this.feeder = feeder; + this.foodStack = foodStack.copy(); + this.sourceSlot = sourceSlot; + } + + public void setFeeder(Player feeder) { + this.feeder = feeder; + } + + @Override + public synchronized void update() { + // Cancel if feeder is gone or dead + if (feeder == null || !feeder.isAlive() || feeder.isRemoved()) { + stop(); + return; + } + + if (targetEntity != null) { + double distance = feeder.distanceTo(targetEntity); + if (distance > 4.0) { + TiedUpMod.LOGGER.debug( + "[ForceFeedingTask] Feeder {} moved too far from target ({} blocks), cancelling", + feeder.getName().getString(), + String.format("%.1f", distance) + ); + stop(); + return; + } + + if (!feeder.hasLineOfSight(targetEntity)) { + TiedUpMod.LOGGER.debug( + "[ForceFeedingTask] Feeder {} lost line of sight to target, cancelling", + feeder.getName().getString() + ); + stop(); + return; + } + } + + super.update(); + } + + @Override + protected void onComplete() { + if (!isTargetValid()) { + TiedUpMod.LOGGER.warn( + "[ForceFeedingTask] Target entity no longer valid, cancelling task" + ); + stop(); + return; + } + + if (feeder == null || !feeder.isAlive()) { + TiedUpMod.LOGGER.warn( + "[ForceFeedingTask] Feeder no longer valid, cancelling task" + ); + stop(); + return; + } + + // Validate the item in the source slot is still edible + ItemStack slotStack = feeder.getInventory().getItem(sourceSlot); + if (slotStack.isEmpty() || !slotStack.getItem().isEdible()) { + TiedUpMod.LOGGER.warn( + "[ForceFeedingTask] Food item no longer in slot {}, cancelling", + sourceSlot + ); + stop(); + return; + } + + TiedUpMod.LOGGER.info( + "[ForceFeedingTask] Force feeding complete for {}", + targetEntity.getName().getString() + ); + + if (targetEntity instanceof Player targetPlayer) { + // Feed the player using vanilla eat mechanics + targetPlayer.eat(targetPlayer.level(), slotStack.copy()); + slotStack.shrink(1); + } else if (targetEntity instanceof EntityDamsel damsel) { + // Use existing NPC feed method (handles shrink internally) + damsel.feedByPlayer(feeder, slotStack); + } + + // Play eating sound at target + targetEntity + .level() + .playSound( + null, + targetEntity.getX(), + targetEntity.getY(), + targetEntity.getZ(), + SoundEvents.GENERIC_EAT, + SoundSource.PLAYERS, + 1.0F, + 1.0F + ); + + // Send messages + String targetName = targetEntity.getName().getString(); + String feederName = feeder.getName().getString(); + + if (feeder instanceof ServerPlayer serverFeeder) { + serverFeeder.displayClientMessage( + Component.literal( + "You force fed " + targetName + "." + ).withStyle(ChatFormatting.GRAY), + true + ); + } + + if (targetEntity instanceof ServerPlayer serverTarget) { + serverTarget.displayClientMessage( + Component.literal("You have been force fed.").withStyle( + ChatFormatting.GRAY + ), + true + ); + } + + stop(); + + // Send completion packets (stateInfo = -1) + if (targetEntity instanceof ServerPlayer serverTarget) { + PacketForceFeeding completionPacket = new PacketForceFeeding( + -1, + this.getMaxSeconds(), + false, + feederName + ); + ModNetwork.sendToPlayer(completionPacket, serverTarget); + } + + if (feeder instanceof ServerPlayer serverFeeder) { + PacketForceFeeding completionPacket = new PacketForceFeeding( + -1, + this.getMaxSeconds(), + true, + targetName + ); + ModNetwork.sendToPlayer(completionPacket, serverFeeder); + } + } + + @Override + public void sendProgressPackets() { + if (stopped) return; + + String feederName = + feeder != null ? feeder.getName().getString() : "Someone"; + String targetName = targetEntity.getName().getString(); + + // Packet to target (if player): isActiveRole=false, shows feeder's name + if (targetEntity instanceof ServerPlayer serverTarget) { + PacketForceFeeding victimPacket = new PacketForceFeeding( + this.getState(), + this.getMaxSeconds(), + false, + feederName + ); + ModNetwork.sendToPlayer(victimPacket, serverTarget); + } + + // Packet to feeder: isActiveRole=true, shows target's name + if (feeder instanceof ServerPlayer serverFeeder) { + PacketForceFeeding feederPacket = new PacketForceFeeding( + this.getState(), + this.getMaxSeconds(), + true, + targetName + ); + ModNetwork.sendToPlayer(feederPacket, serverFeeder); + } + } + + @Override + public void setUpTargetState() { + // Server-side: nothing to do - client handles its own PlayerStateTask via packets + } +} diff --git a/src/main/java/com/tiedup/remake/tasks/PlayerStateTask.java b/src/main/java/com/tiedup/remake/tasks/PlayerStateTask.java new file mode 100644 index 0000000..af8101d --- /dev/null +++ b/src/main/java/com/tiedup/remake/tasks/PlayerStateTask.java @@ -0,0 +1,146 @@ +package com.tiedup.remake.tasks; + +import com.tiedup.remake.util.time.Timer; +import net.minecraft.client.Minecraft; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Phase 6: Lightweight client-side task state for displaying progress. + * + * Based on original PlayerStateTask from 1.12.2 + * + * This is used on the CLIENT SIDE ONLY to track the progress of + * tying/untying tasks for GUI display (progress bars, messages, etc.). + * + * The server sends progress updates via packets, and this class + * stores them and auto-expires if updates stop coming. + */ +@OnlyIn(Dist.CLIENT) +public class PlayerStateTask { + + private final int maxState; // Total time in seconds + private Timer timerOutdating; // Auto-expiration timer (3 seconds) + private int state; // Current elapsed time in seconds + + // Role info for progress bar display + private final boolean isKidnapper; // true = doing the tying, false = being tied + private final String otherEntityName; // Name of the other party + + // Outdating timeout: 3 seconds (60 ticks) + // Slightly longer than server's 2 seconds to account for network delay + private static final int OUTDATING_TIMEOUT_SECONDS = 3; + + /** + * Create a new client-side task state. + * + * @param maxState Total duration in seconds + */ + public PlayerStateTask(int maxState) { + this(maxState, false, null); + } + + /** + * Create a new client-side task state with role info. + * + * @param maxState Total duration in seconds + * @param isKidnapper true if this player is doing the tying + * @param otherEntityName Name of the other party + */ + public PlayerStateTask( + int maxState, + boolean isKidnapper, + String otherEntityName + ) { + this.maxState = maxState; + this.state = 0; + this.isKidnapper = isKidnapper; + this.otherEntityName = otherEntityName; + this.timerOutdating = new Timer( + OUTDATING_TIMEOUT_SECONDS, + Minecraft.getInstance().level + ); + } + + /** + * Update the task progress. + * Called when receiving a progress packet from the server. + * + * @param state Current elapsed time in seconds + */ + public synchronized void update(int state) { + this.state = state; + // Reset outdating timer (we received an update from server) + this.timerOutdating = new Timer( + OUTDATING_TIMEOUT_SECONDS, + Minecraft.getInstance().level + ); + } + + /** + * Check if this task state has become outdated. + * A task is outdated if we haven't received an update for 3 seconds. + * + * @return true if outdated + */ + public boolean isOutdated() { + return timerOutdating != null && timerOutdating.isExpired(); + } + + /** + * Get the current progress state (elapsed time). + * + * @return Elapsed seconds + */ + public int getState() { + return state; + } + + /** + * Get the maximum duration. + * + * @return Total seconds + */ + public int getMaxState() { + return maxState; + } + + /** + * Get progress as a percentage (0.0 to 1.0). + * + * @return Progress percentage + */ + public float getProgress() { + if (maxState <= 0) { + return 0.0f; + } + return Math.min(1.0f, (float) state / (float) maxState); + } + + /** + * Get remaining seconds. + * + * @return Remaining seconds + */ + public int getRemaining() { + return Math.max(0, maxState - state); + } + + /** + * Check if this player is the kidnapper (doing the tying). + * + * @return true if kidnapper, false if victim + */ + public boolean isKidnapper() { + return isKidnapper; + } + + /** + * Get the name of the other party (target if kidnapper, kidnapper if victim). + * + * @return Other entity name, or null if unknown + */ + public String getOtherEntityName() { + return otherEntityName; + } +} diff --git a/src/main/java/com/tiedup/remake/tasks/TimedInteractTask.java b/src/main/java/com/tiedup/remake/tasks/TimedInteractTask.java new file mode 100644 index 0000000..81e208b --- /dev/null +++ b/src/main/java/com/tiedup/remake/tasks/TimedInteractTask.java @@ -0,0 +1,121 @@ +package com.tiedup.remake.tasks; + +import com.tiedup.remake.state.IBondageState; +import java.util.UUID; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.level.Level; + +/** + * Phase 6: Timed task that involves interacting with a target entity. + * Phase 14.2.6: Refactored to support any IBondageState entity (Players + NPCs) + * + * Based on original TimedInteractTask from 1.12.2 + * + * Extends TimedTask with target tracking: + * - Tracks the target entity's IBondageState state + * - Can check if the same target is being interacted with (via UUID) + * - Abstract method to set up target state + */ +public abstract class TimedInteractTask extends TimedTask { + + protected final IBondageState targetState; // The target's kidnapped state + protected final LivingEntity targetEntity; // The target entity + protected final UUID targetUUID; // Target's UUID for comparison + + /** + * Create a new timed interaction task. + * + * @param targetState The target's IBondageState state + * @param targetEntity The target entity + * @param seconds Total duration in seconds + * @param level The world + */ + public TimedInteractTask( + IBondageState targetState, + LivingEntity targetEntity, + int seconds, + Level level + ) { + super(seconds, level); + this.targetState = targetState; + this.targetEntity = targetEntity; + this.targetUUID = targetEntity.getUUID(); + } + + /** + * Check if this task is targeting the same entity. + * Uses UUID comparison to ensure uniqueness. + * + * @param entity The entity to compare with + * @return true if same target + */ + public boolean isSameTarget(LivingEntity entity) { + if (entity == null) { + return false; + } + return targetUUID.equals(entity.getUUID()); + } + + /** + * Get the target's IBondageState state. + * + * @return The target's kidnapped state + */ + public IBondageState getTargetState() { + return targetState; + } + + /** + * Get the target entity. + * + * @return The target entity + */ + public LivingEntity getTargetEntity() { + return targetEntity; + } + + /** + * Get the target's UUID. + * + * @return The target's UUID + */ + public UUID getTargetUUID() { + return targetUUID; + } + + /** + * Check if the target entity is still valid (alive and exists). + * + * @return true if target is valid + */ + public boolean isTargetValid() { + return targetEntity != null && targetEntity.isAlive(); + } + + /** + * Set up the target's state for this task. + * This should be called when the task starts to initialize + * any necessary state on the target. + * + * Implementation is task-specific (tying vs untying). + */ + public abstract void setUpTargetState(); + + /** + * Called when the task completes successfully. + * Default implementation does nothing - subclasses should override. + */ + @Override + protected void onComplete() { + // Default: no-op, subclasses implement specific completion logic + } + + /** + * Send progress packets to relevant players. + * Default implementation does nothing - subclasses should override. + */ + @Override + public void sendProgressPackets() { + // Default: no-op, subclasses implement packet sending + } +} diff --git a/src/main/java/com/tiedup/remake/tasks/TimedTask.java b/src/main/java/com/tiedup/remake/tasks/TimedTask.java new file mode 100644 index 0000000..3cd75ef --- /dev/null +++ b/src/main/java/com/tiedup/remake/tasks/TimedTask.java @@ -0,0 +1,205 @@ +package com.tiedup.remake.tasks; + +import net.minecraft.world.level.Level; + +/** + * Phase 6: Base class for all progress-based tasks (tying, untying, etc.) + * + * Based on original TimedTask from 1.12.2, refactored for continuous click requirement. + * + * A TimedTask represents a progressive action that requires continuous clicking: + * - Progress increases when player clicks on target (update() called) + * - Progress decreases when player is not clicking (activeThisTick = false) + * - Task completes when progress reaches maxProgress + * - Task cancels when progress drops to 0 + * + * This ensures players must HOLD click on the target to complete the action. + */ +public abstract class TimedTask { + + protected int progress = 0; // Current progress (ticks) + protected int maxProgress; // Target progress (seconds * 20 ticks) + protected boolean activeThisTick = false; // Was update() called this tick? + protected boolean stopped = false; // Task has been stopped/completed + protected final Level level; // World reference + protected final int seconds; // Total duration in seconds (for display) + + /** + * Create a new timed task. + * + * @param seconds Total duration in seconds + * @param level The world (for game time) + */ + public TimedTask(int seconds, Level level) { + this.seconds = seconds; + this.maxProgress = seconds * 20; // Convert seconds to ticks + this.level = level; + this.stopped = false; + } + + /** + * Start the task. + * Resets progress and stopped state. + */ + public void start() { + this.progress = 0; + this.stopped = false; + this.activeThisTick = false; + } + + /** + * Called when player clicks on target. + * Marks this tick as "active" - progress will increase. + */ + public synchronized void update() { + activeThisTick = true; + } + + /** + * Called every tick to process progress. + * Increments if active (clicking), decrements if not. + */ + public void tick() { + if (stopped) return; + + if (activeThisTick) { + // Player is clicking on target - progress up + progress++; + if (progress >= maxProgress) { + // Task complete! + onComplete(); + } + } else { + // Player is not clicking - progress down + progress--; + if (progress <= 0) { + progress = 0; + stop(); + } + } + + // Reset for next tick + activeThisTick = false; + } + + /** + * Reset the task to its initial state. + */ + public void reset() { + this.progress = 0; + this.stopped = false; + this.activeThisTick = false; + } + + /** + * Stop/cancel the task. + * Marks the task as stopped. + */ + public void stop() { + this.stopped = true; + } + + /** + * Check if the task has been stopped. + * + * @return true if stopped + */ + public boolean isStopped() { + return stopped; + } + + /** + * Check if the task has become outdated. + * In the new progress-based system, a task is outdated if progress is 0. + * + * @return true if outdated (no progress) + */ + public boolean isOutdated() { + return progress <= 0; + } + + /** + * Get the current progress in ticks. + * + * @return Current progress ticks + */ + public int getProgress() { + return progress; + } + + /** + * Get the max progress in ticks. + * + * @return Max progress ticks + */ + public int getMaxProgress() { + return maxProgress; + } + + /** + * Get the current state (elapsed time in seconds). + * Calculates from progress ticks for display compatibility. + * + * @return Elapsed seconds (progress / 20) + */ + public int getState() { + return progress / 20; + } + + /** + * Get progress as a percentage (0.0 to 1.0). + * + * @return Progress percentage + */ + public float getProgressPercent() { + return (float) progress / maxProgress; + } + + /** + * Check if the task is complete (progress >= maxProgress). + * + * @return true if task is done + */ + public boolean isOver() { + return progress >= maxProgress; + } + + /** + * Check if the task is complete. + * + * @return true if complete + */ + public boolean isComplete() { + return progress >= maxProgress; + } + + /** + * Get the total duration in seconds. + * + * @return Total seconds + */ + public int getMaxSeconds() { + return seconds; + } + + /** + * Get the remaining seconds. + * + * @return Remaining seconds + */ + public int getSecondsRemaining() { + return Math.max(0, (maxProgress - progress) / 20); + } + + /** + * Called when the task completes successfully. + * Subclasses should override to implement completion logic. + */ + protected abstract void onComplete(); + + /** + * Send progress packets to relevant players. + * Subclasses should override to send UI updates. + */ + public abstract void sendProgressPackets(); +} diff --git a/src/main/java/com/tiedup/remake/tasks/TyingPlayerTask.java b/src/main/java/com/tiedup/remake/tasks/TyingPlayerTask.java new file mode 100644 index 0000000..dc6ac51 --- /dev/null +++ b/src/main/java/com/tiedup/remake/tasks/TyingPlayerTask.java @@ -0,0 +1,346 @@ +package com.tiedup.remake.tasks; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.action.PacketTying; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.PlayerBindState; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * Phase 6: Concrete tying task for tying up any IBondageState entity. + * Phase 14.2.6: Unified to work with both Players and NPCs. + * + * Based on original TyingPlayerTask from 1.12.2 + * + * This task: + * 1. Tracks progress as the kidnapper repeatedly right-clicks the target + * 2. Sends progress updates to kidnapper (and target if it's a player) + * 3. Applies the bind/gag when the timer completes + * 4. Works with any LivingEntity that has an IBondageState state + */ +public class TyingPlayerTask extends TyingTask { + + /** The player performing the tying action. */ + protected Player kidnapper; + + /** FIX: Source inventory slot to consume from when task completes */ + private int sourceSlot = -1; + + /** FIX: Source player whose inventory to consume from */ + private Player sourcePlayer; + + /** + * Create a new tying task. + * + * @param bind The bind/gag item to apply + * @param targetState The target's IBondageState state + * @param targetEntity The target entity (Player or NPC) + * @param seconds Total duration in seconds + * @param level The world + */ + public TyingPlayerTask( + ItemStack bind, + IBondageState targetState, + LivingEntity targetEntity, + int seconds, + Level level + ) { + super(bind, targetState, targetEntity, seconds, level); + } + + /** + * Create a new tying task with kidnapper reference. + * + * @param bind The bind/gag item to apply + * @param targetState The target's IBondageState state + * @param targetEntity The target entity (Player or NPC) + * @param seconds Total duration in seconds + * @param level The world + * @param kidnapper The player performing the tying + */ + public TyingPlayerTask( + ItemStack bind, + IBondageState targetState, + LivingEntity targetEntity, + int seconds, + Level level, + Player kidnapper + ) { + super(bind, targetState, targetEntity, seconds, level); + this.kidnapper = kidnapper; + } + + /** + * Set the kidnapper (the player doing the tying). + */ + public void setKidnapper(Player kidnapper) { + this.kidnapper = kidnapper; + } + + /** + * FIX: Set the source inventory slot for item consumption. + * Called when task starts to track which slot to consume from. + */ + public void setSourceSlot(int slot) { + this.sourceSlot = slot; + } + + /** + * FIX: Set the source player for item consumption. + * Called when task starts to track whose inventory to consume from. + */ + public void setSourcePlayer(Player player) { + this.sourcePlayer = player; + } + + /** + * Update the tying progress. + * Called each time the kidnapper right-clicks the target (or left-clicks for self-tying). + * + * In the new progress-based system, this method only: + * 1. Validates kidnapper is still close to target + * 2. Marks this tick as "active" (progress will increase in tick()) + * + * The actual progress increment and completion check happen in tick(). + */ + @Override + public synchronized void update() { + // Check if this is self-tying (target == kidnapper) + boolean isSelfTying = + kidnapper != null && kidnapper.equals(targetEntity); + + // ======================================== + // SECURITY: Validate kidnapper is still close to target (skip for self-tying) + // ======================================== + if (!isSelfTying && kidnapper != null && targetEntity != null) { + double distance = kidnapper.distanceTo(targetEntity); + if (distance > 4.0) { + TiedUpMod.LOGGER.debug( + "[TyingPlayerTask] Kidnapper {} moved too far from target ({} blocks), cancelling", + kidnapper.getName().getString(), + String.format("%.1f", distance) + ); + stop(); + return; + } + + // Check line-of-sight + if (!kidnapper.hasLineOfSight(targetEntity)) { + TiedUpMod.LOGGER.debug( + "[TyingPlayerTask] Kidnapper {} lost line of sight to target, cancelling", + kidnapper.getName().getString() + ); + stop(); + return; + } + } + + // Mark this tick as active (player is clicking on target) + super.update(); + } + + /** + * Send progress packets to both kidnapper and target (if players). + * Called every tick from ItemBind or RestraintTaskTickHandler. + */ + @Override + public void sendProgressPackets() { + if (stopped) return; + + // Check if this is self-tying (target == kidnapper) + boolean isSelfTying = + kidnapper != null && kidnapper.equals(targetEntity); + + String kidnapperName = + kidnapper != null ? kidnapper.getName().getString() : "Someone"; + String targetName = targetEntity.getName().getString(); + + if (isSelfTying) { + // Self-tying: Send single packet with self-tying message + if (kidnapper instanceof ServerPlayer serverPlayer) { + PacketTying selfPacket = new PacketTying( + this.getState(), + this.getMaxSeconds(), + true, // isKidnapper (shows "Tying..." message) + "yourself" // Special indicator for self-tying + ); + ModNetwork.sendToPlayer(selfPacket, serverPlayer); + } + } else { + // Normal tying: Send packets to both parties + // Packet to victim: isKidnapper=false, shows kidnapper's name + if (targetEntity instanceof ServerPlayer serverTarget) { + PacketTying victimPacket = new PacketTying( + this.getState(), + this.getMaxSeconds(), + false, + kidnapperName + ); + ModNetwork.sendToPlayer(victimPacket, serverTarget); + } + + // Packet to kidnapper: isKidnapper=true, shows target's name + if (kidnapper instanceof ServerPlayer serverKidnapper) { + PacketTying kidnapperPacket = new PacketTying( + this.getState(), + this.getMaxSeconds(), + true, + targetName + ); + ModNetwork.sendToPlayer(kidnapperPacket, serverKidnapper); + } + } + } + + /** + * Called when the task completes successfully. + * Applies the bind, consumes the item, and sends completion packets. + */ + @Override + protected void onComplete() { + // Verify target entity still exists and is alive + if (!isTargetValid()) { + TiedUpMod.LOGGER.warn( + "[TyingPlayerTask] Target entity no longer valid, cancelling task" + ); + stop(); + return; + } + + TiedUpMod.LOGGER.info( + "[TyingPlayerTask] Tying complete for {}", + targetEntity.getName().getString() + ); + + // Apply the bind/gag to the target + targetState.equip(BodyRegionV2.ARMS, bind); + + // FIX: Consume the item from the stored inventory slot + // This prevents duplication by consuming from the exact slot used to start the task + if (sourcePlayer != null && sourceSlot >= 0) { + ItemStack slotStack = sourcePlayer + .getInventory() + .getItem(sourceSlot); + if ( + !slotStack.isEmpty() && + ItemStack.isSameItemSameTags(slotStack, bind) + ) { + slotStack.shrink(1); + TiedUpMod.LOGGER.debug( + "[TyingPlayerTask] Consumed bind item from slot {}", + sourceSlot + ); + } else { + // Slot changed - try to find and consume from any matching slot + for ( + int i = 0; + i < sourcePlayer.getInventory().getContainerSize(); + i++ + ) { + ItemStack checkStack = sourcePlayer + .getInventory() + .getItem(i); + if ( + !checkStack.isEmpty() && + ItemStack.isSameItemSameTags(checkStack, bind) + ) { + checkStack.shrink(1); + TiedUpMod.LOGGER.debug( + "[TyingPlayerTask] Consumed bind item from fallback slot {}", + i + ); + break; + } + } + } + } + + // Track who tied this entity (for reward anti-abuse) + if ( + targetEntity instanceof + com.tiedup.remake.entities.EntityDamsel damsel + ) { + damsel.setTiedBy(kidnapper); + } + + // Mark task as stopped + stop(); + + sendCompletionPackets(); + } + + /** + * Send completion packets to kidnapper and/or target. + * Handles both self-tying and normal tying cases. + */ + protected void sendCompletionPackets() { + boolean isSelfTying = + kidnapper != null && kidnapper.equals(targetEntity); + + String kidnapperName = + kidnapper != null ? kidnapper.getName().getString() : "Someone"; + String targetName = targetEntity.getName().getString(); + + if (isSelfTying) { + if (kidnapper instanceof ServerPlayer serverPlayer) { + PacketTying completionPacket = new PacketTying( + -1, this.getMaxSeconds(), true, "yourself" + ); + ModNetwork.sendToPlayer(completionPacket, serverPlayer); + + PlayerBindState playerState = PlayerBindState.getInstance(serverPlayer); + if (playerState != null) { + playerState.setRestrainedState(null); + } + } + } else { + if (targetEntity instanceof ServerPlayer serverTarget) { + PacketTying completionPacket = new PacketTying( + -1, this.getMaxSeconds(), false, kidnapperName + ); + ModNetwork.sendToPlayer(completionPacket, serverTarget); + + PlayerBindState playerState = PlayerBindState.getInstance(serverTarget); + if (playerState != null) { + playerState.setRestrainedState(null); + } + } + + if (kidnapper instanceof ServerPlayer serverKidnapper) { + PacketTying completionPacket = new PacketTying( + -1, this.getMaxSeconds(), true, targetName + ); + ModNetwork.sendToPlayer(completionPacket, serverKidnapper); + } + } + } + + /** + * Set up the target's restraint state for client-side progress tracking. + * Only applies to player targets (NPCs don't need client-side state). + * + * Note: On dedicated servers, this is a no-op. The client receives + * progress packets (PacketTying) which create the PlayerStateTask locally. + */ + @Override + public void setUpTargetState() { + // Only set up state for player targets + if (!(targetEntity instanceof Player targetPlayer)) { + return; + } + + // Server-side: nothing to do - client handles its own PlayerStateTask via packets + if (!targetPlayer.level().isClientSide) { + return; + } + + // Client-side: set up local progress tracking (only reached in single-player/integrated server) + // Note: This code path is rarely used since packets handle client-side state + } +} diff --git a/src/main/java/com/tiedup/remake/tasks/TyingTask.java b/src/main/java/com/tiedup/remake/tasks/TyingTask.java new file mode 100644 index 0000000..9a9991d --- /dev/null +++ b/src/main/java/com/tiedup/remake/tasks/TyingTask.java @@ -0,0 +1,59 @@ +package com.tiedup.remake.tasks; + +import com.tiedup.remake.state.IBondageState; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * Phase 6: Abstract tying task (binding/gagging an entity). + * Phase 14.2.6: Refactored to support any IBondageState entity (Players + NPCs) + * + * Based on original TyingTask from 1.12.2 + * + * Extends TimedInteractTask with item tracking: + * - Holds a reference to the bind/gag item being applied + * - The item will be consumed when tying completes successfully + */ +public abstract class TyingTask extends TimedInteractTask { + + protected ItemStack bind; // The bind/gag item being applied + + /** + * Create a new tying task. + * + * @param bind The bind/gag item to apply + * @param targetState The target's IBondageState state + * @param targetEntity The target entity + * @param seconds Total duration in seconds + * @param level The world + */ + public TyingTask( + ItemStack bind, + IBondageState targetState, + LivingEntity targetEntity, + int seconds, + Level level + ) { + super(targetState, targetEntity, seconds, level); + this.bind = bind; + } + + /** + * Get the bind/gag item being applied. + * + * @return The item stack + */ + public ItemStack getBind() { + return bind; + } + + /** + * Set the bind/gag item. + * + * @param bind The item stack + */ + public void setBind(ItemStack bind) { + this.bind = bind; + } +} diff --git a/src/main/java/com/tiedup/remake/tasks/UntyingPlayerTask.java b/src/main/java/com/tiedup/remake/tasks/UntyingPlayerTask.java new file mode 100644 index 0000000..f681cdb --- /dev/null +++ b/src/main/java/com/tiedup/remake/tasks/UntyingPlayerTask.java @@ -0,0 +1,397 @@ +package com.tiedup.remake.tasks; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.entities.NpcTypeHelper; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.action.PacketUntying; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.Map; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * Phase 6: Concrete untying task for freeing a tied entity. + * Phase 14.2.6: Unified to work with both Players and NPCs. + * + * Based on original UntyingPlayerTask from 1.12.2 + * + * This task: + * 1. Tracks progress as the helper repeatedly right-clicks the tied target + * 2. Sends progress updates to both helper and target clients (if player) + * 3. Removes all restraints and drops items when the timer completes + * 4. Sets up target's restraint state for client-side visualization (if player) + * + * Epic 5F: Uses V2EquipmentHelper/BodyRegionV2. + */ +public class UntyingPlayerTask extends UntyingTask { + + /** The player performing the untying action. */ + private Player helper; + + /** + * Create a new untying task. + * + * @param targetState The target's IBondageState state + * @param targetEntity The target entity (Player or NPC) + * @param seconds Total duration in seconds + * @param level The world + */ + public UntyingPlayerTask( + IBondageState targetState, + LivingEntity targetEntity, + int seconds, + Level level + ) { + super(targetState, targetEntity, seconds, level); + } + + /** + * Create a new untying task with helper reference. + * + * @param targetState The target's IBondageState state + * @param targetEntity The target entity (Player or NPC) + * @param seconds Total duration in seconds + * @param level The world + * @param helper The player performing the untying + */ + public UntyingPlayerTask( + IBondageState targetState, + LivingEntity targetEntity, + int seconds, + Level level, + Player helper + ) { + super(targetState, targetEntity, seconds, level); + this.helper = helper; + } + + /** + * Set the helper (the player doing the untying). + */ + public void setHelper(Player helper) { + this.helper = helper; + } + + /** + * Update the untying progress. + * Called each time the helper right-clicks the tied target. + * + * In the new progress-based system, this method only: + * 1. Marks this tick as "active" (progress will increase in tick()) + * 2. Validates helper is still close to target + * + * The actual progress increment and completion check happen in tick(). + */ + @Override + public synchronized void update() { + // ======================================== + // SECURITY: Validate helper is still close to target + // ======================================== + if (helper != null && targetEntity != null) { + double distance = helper.distanceTo(targetEntity); + if (distance > 4.0) { + TiedUpMod.LOGGER.debug( + "[UntyingPlayerTask] Helper {} moved too far from target ({} blocks), cancelling", + helper.getName().getString(), + String.format("%.1f", distance) + ); + stop(); + return; + } + + // Check line-of-sight + if (!helper.hasLineOfSight(targetEntity)) { + TiedUpMod.LOGGER.debug( + "[UntyingPlayerTask] Helper {} lost line of sight to target, cancelling", + helper.getName().getString() + ); + stop(); + return; + } + } + + // Mark this tick as active (player is clicking on target) + super.update(); + } + + /** + * Send progress packets to both helper and target (if players). + * Called every tick from RestraintTaskTickHandler.onPlayerTick(). + */ + @Override + public void sendProgressPackets() { + if (stopped) return; + + String helperName = + helper != null ? helper.getName().getString() : "Someone"; + String targetName = targetEntity.getName().getString(); + + // Packet to victim: isHelper=false, shows helper's name + if (targetEntity instanceof ServerPlayer serverTarget) { + PacketUntying victimPacket = new PacketUntying( + this.getState(), + this.getMaxSeconds(), + false, + helperName + ); + ModNetwork.sendToPlayer(victimPacket, serverTarget); + } + + // Packet to helper: isHelper=true, shows target's name + if (helper instanceof ServerPlayer serverHelper) { + PacketUntying helperPacket = new PacketUntying( + this.getState(), + this.getMaxSeconds(), + true, + targetName + ); + ModNetwork.sendToPlayer(helperPacket, serverHelper); + } + } + + /** + * Called when the task completes successfully. + * Drops bondage items, frees the target, and sends completion packets. + */ + @Override + protected void onComplete() { + // Verify target entity still exists and is alive + if (!isTargetValid()) { + TiedUpMod.LOGGER.warn( + "[UntyingPlayerTask] Target entity no longer valid, cancelling task" + ); + stop(); + return; + } + + TiedUpMod.LOGGER.info( + "[UntyingPlayerTask] Untying complete for {}", + targetEntity.getName().getString() + ); + + // Drop all bondage items on the ground + dropBondageItems(); + + // Remove all restraints from target + untieTarget(); + + // Handle Damsel-specific rewards + if (targetEntity instanceof EntityDamsel damsel && NpcTypeHelper.isDamselOnly(targetEntity) && helper != null) { + // Reward the savior (gives emeralds and marks player as savior) + damsel.rewardSavior(helper); + } + + // Mark task as stopped + stop(); + + // Send completion packets to both parties + String helperName = + helper != null ? helper.getName().getString() : "Someone"; + String targetName = targetEntity.getName().getString(); + + if (targetEntity instanceof ServerPlayer serverTarget) { + PacketUntying completionPacket = new PacketUntying( + -1, + this.getMaxSeconds(), + false, + helperName + ); + ModNetwork.sendToPlayer(completionPacket, serverTarget); + + PlayerBindState playerState = PlayerBindState.getInstance( + serverTarget + ); + if (playerState != null) { + playerState.setRestrainedState(null); + } + } + + if (helper instanceof ServerPlayer serverHelper) { + PacketUntying completionPacket = new PacketUntying( + -1, + this.getMaxSeconds(), + true, + targetName + ); + ModNetwork.sendToPlayer(completionPacket, serverHelper); + } + } + + /** + * Set up the target's restraint state for client-side progress tracking. + * Only applies to player targets (NPCs don't need client-side state). + * + * Note: On dedicated servers, this is a no-op. The client receives + * progress packets (PacketUntying) which create the PlayerStateTask locally. + */ + @Override + public void setUpTargetState() { + // Only set up state for player targets + if (!(targetEntity instanceof Player targetPlayer)) { + return; + } + + // Server-side: nothing to do - client handles its own PlayerStateTask via packets + if (!targetPlayer.level().isClientSide) { + return; + } + + // Client-side: set up local progress tracking (only reached in single-player/integrated server) + // Note: This code path is rarely used since packets handle client-side state + } + + /** + * Drop all bondage items on the ground. + * Works for both players (via V2EquipmentHelper) and NPCs (via IBondageState). + */ + private void dropBondageItems() { + // For player targets: use V2EquipmentHelper to get all equipped items + if (targetEntity instanceof Player player) { + Map equipped = V2EquipmentHelper.getAllEquipped(player); + for (Map.Entry entry : equipped.entrySet()) { + ItemStack stack = entry.getValue(); + if (!stack.isEmpty()) { + // Drop item at player's position + ItemEntity itemEntity = new ItemEntity( + targetEntity.level(), + targetEntity.getX(), + targetEntity.getY(), + targetEntity.getZ(), + stack.copy() + ); + + targetEntity.level().addFreshEntity(itemEntity); + + TiedUpMod.LOGGER.debug( + "[UntyingPlayerTask] Dropped {} from region {}", + stack.getHoverName().getString(), + entry.getKey().name() + ); + } + } + } else { + // For NPC targets: use IBondageState interface + // Drop bind if present + ItemStack bind = targetState.getEquipment(BodyRegionV2.ARMS); + if (bind != null && !bind.isEmpty()) { + ItemEntity itemEntity = new ItemEntity( + targetEntity.level(), + targetEntity.getX(), + targetEntity.getY(), + targetEntity.getZ(), + bind.copy() + ); + targetEntity.level().addFreshEntity(itemEntity); + TiedUpMod.LOGGER.debug( + "[UntyingPlayerTask] Dropped bind: {}", + bind.getHoverName().getString() + ); + } + + // Drop gag if present + ItemStack gag = targetState.getEquipment(BodyRegionV2.MOUTH); + if (gag != null && !gag.isEmpty()) { + ItemEntity itemEntity = new ItemEntity( + targetEntity.level(), + targetEntity.getX(), + targetEntity.getY(), + targetEntity.getZ(), + gag.copy() + ); + targetEntity.level().addFreshEntity(itemEntity); + TiedUpMod.LOGGER.debug( + "[UntyingPlayerTask] Dropped gag: {}", + gag.getHoverName().getString() + ); + } + + // Drop blindfold if present + ItemStack blindfold = targetState.getEquipment(BodyRegionV2.EYES); + if (blindfold != null && !blindfold.isEmpty()) { + ItemEntity itemEntity = new ItemEntity( + targetEntity.level(), + targetEntity.getX(), + targetEntity.getY(), + targetEntity.getZ(), + blindfold.copy() + ); + targetEntity.level().addFreshEntity(itemEntity); + TiedUpMod.LOGGER.debug( + "[UntyingPlayerTask] Dropped blindfold: {}", + blindfold.getHoverName().getString() + ); + } + + // Drop earplugs if present + ItemStack earplugs = targetState.getEquipment(BodyRegionV2.EARS); + if (earplugs != null && !earplugs.isEmpty()) { + ItemEntity itemEntity = new ItemEntity( + targetEntity.level(), + targetEntity.getX(), + targetEntity.getY(), + targetEntity.getZ(), + earplugs.copy() + ); + targetEntity.level().addFreshEntity(itemEntity); + TiedUpMod.LOGGER.debug( + "[UntyingPlayerTask] Dropped earplugs: {}", + earplugs.getHoverName().getString() + ); + } + + // Drop mittens if present + ItemStack mittens = targetState.getEquipment(BodyRegionV2.HANDS); + if (mittens != null && !mittens.isEmpty()) { + ItemEntity itemEntity = new ItemEntity( + targetEntity.level(), + targetEntity.getX(), + targetEntity.getY(), + targetEntity.getZ(), + mittens.copy() + ); + targetEntity.level().addFreshEntity(itemEntity); + TiedUpMod.LOGGER.debug( + "[UntyingPlayerTask] Dropped mittens: {}", + mittens.getHoverName().getString() + ); + } + } + } + + /** + * Remove all restraints from the target entity. + * Works for both players and NPCs via IBondageState interface. + */ + private void untieTarget() { + // For player targets: clear all V2 equipment (fires onUnequipped callbacks) + if (targetEntity instanceof Player player) { + V2EquipmentHelper.clearAll(player); + + // Phase 17: Free from captivity/leash if applicable (player-specific) + PlayerBindState playerState = PlayerBindState.getInstance(player); + if (playerState != null && playerState.isCaptive()) { + playerState.free(); + } + } else { + // For NPC targets: use IBondageState interface directly + targetState.unequip(BodyRegionV2.ARMS); + targetState.unequip(BodyRegionV2.MOUTH); + targetState.unequip(BodyRegionV2.EYES); + targetState.unequip(BodyRegionV2.EARS); + targetState.unequip(BodyRegionV2.HANDS); + } + + TiedUpMod.LOGGER.info( + "[UntyingPlayerTask] Fully untied {}", + targetEntity.getName().getString() + ); + } +} diff --git a/src/main/java/com/tiedup/remake/tasks/UntyingTask.java b/src/main/java/com/tiedup/remake/tasks/UntyingTask.java new file mode 100644 index 0000000..5ceeb43 --- /dev/null +++ b/src/main/java/com/tiedup/remake/tasks/UntyingTask.java @@ -0,0 +1,35 @@ +package com.tiedup.remake.tasks; + +import com.tiedup.remake.state.IBondageState; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.level.Level; + +/** + * Phase 6: Abstract untying task (freeing a tied entity). + * Phase 14.2.6: Refactored to support any IBondageState entity (Players + NPCs) + * + * Based on original UntyingTask from 1.12.2 + * + * Extends TimedInteractTask for untying operations: + * - Frees a tied entity over time + * - Drops bondage items on the ground when complete + */ +public abstract class UntyingTask extends TimedInteractTask { + + /** + * Create a new untying task. + * + * @param targetState The target's IBondageState state + * @param targetEntity The target entity + * @param seconds Total duration in seconds + * @param level The world + */ + public UntyingTask( + IBondageState targetState, + LivingEntity targetEntity, + int seconds, + Level level + ) { + super(targetState, targetEntity, seconds, level); + } +} diff --git a/src/main/java/com/tiedup/remake/tasks/V2TyingPlayerTask.java b/src/main/java/com/tiedup/remake/tasks/V2TyingPlayerTask.java new file mode 100644 index 0000000..47f1f86 --- /dev/null +++ b/src/main/java/com/tiedup/remake/tasks/V2TyingPlayerTask.java @@ -0,0 +1,94 @@ +package com.tiedup.remake.tasks; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.v2.bondage.V2EquipResult; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * Tying task for V2 bondage items. + * + * Unlike {@link TyingPlayerTask} which calls {@code targetState.equip(BodyRegionV2.ARMS, bind)} (V1 equip), + * this task uses {@link V2EquipmentHelper#equipItem(LivingEntity, ItemStack)} on completion. + * + * Progress bar, duration, and packet flow are identical to V1 tying. + */ +public class V2TyingPlayerTask extends TyingPlayerTask { + + /** + * The live reference to the player's held ItemStack (for consumption on completion). + * The parent class's {@code bind} field holds a copy for display/matching. + */ + private final ItemStack heldStack; + + /** + * Create a V2 tying task. + * + * @param bind Copy of the item being equipped (for display/matching) + * @param heldStack Live reference to the player's held ItemStack (for consumption) + * @param targetState The target's IBondageState state + * @param targetEntity The target entity + * @param seconds Duration in seconds + * @param level The world + * @param kidnapper The player performing the tying (self for self-bondage) + */ + public V2TyingPlayerTask( + ItemStack bind, + ItemStack heldStack, + IBondageState targetState, + LivingEntity targetEntity, + int seconds, + Level level, + Player kidnapper + ) { + super(bind, targetState, targetEntity, seconds, level, kidnapper); + this.heldStack = heldStack; + } + + /** + * V2 completion: equip via V2EquipmentHelper instead of V1 putBindOn. + * + * This REPLACES the parent's onComplete entirely. The parent would call + * targetState.equip(BodyRegionV2.ARMS, bind) which is V1-only. + */ + @Override + protected void onComplete() { + if (!isTargetValid()) { + TiedUpMod.LOGGER.warn( + "[V2TyingPlayerTask] Target entity no longer valid, cancelling" + ); + stop(); + return; + } + + TiedUpMod.LOGGER.info( + "[V2TyingPlayerTask] Tying complete for {}", + targetEntity.getName().getString() + ); + + // Equip via V2 system + V2EquipResult result = V2EquipmentHelper.equipItem(targetEntity, bind); + if (result.isSuccess()) { + for (ItemStack displaced : result.displaced()) { + targetEntity.spawnAtLocation(displaced); + } + // Consume the held item + heldStack.shrink(1); + TiedUpMod.LOGGER.debug("[V2TyingPlayerTask] V2 equip succeeded, item consumed"); + } else { + TiedUpMod.LOGGER.warn( + "[V2TyingPlayerTask] V2 equip BLOCKED after tying — regions may have changed" + ); + } + + // Mark task as stopped + stop(); + + // Send completion packets (shared with V1 via parent) + sendCompletionPackets(); + } +} diff --git a/src/main/java/com/tiedup/remake/util/BondageItemLoaderUtility.java b/src/main/java/com/tiedup/remake/util/BondageItemLoaderUtility.java new file mode 100644 index 0000000..b6c8d8f --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/BondageItemLoaderUtility.java @@ -0,0 +1,125 @@ +package com.tiedup.remake.util; + +import com.tiedup.remake.blocks.entity.IBondageItemHolder; +import com.tiedup.remake.items.base.*; +import java.util.List; +import net.minecraft.ChatFormatting; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * Utility class for loading bondage items into block entity holders. + * + *

This centralizes the logic for: + *

    + *
  • Loading items into IBondageItemHolder (traps, bombs, chests)
  • + *
  • Checking if an item is a loadable bondage item
  • + *
  • Adding item tooltips from NBT data
  • + *
+ * + *

Used by BlockKidnapBomb, BlockRopeTrap, and BlockTrappedChest. + */ +public final class BondageItemLoaderUtility { + + private BondageItemLoaderUtility() {} + + /** + * Load a bondage item into the holder. + * + *

Checks the item type and loads it into the appropriate slot + * if that slot is empty. Consumes one item from the stack (unless creative). + * + * @param holder The bondage item holder (block entity) + * @param stack The item stack to load + * @param player The player loading the item + * @return true if item was loaded, false if slot was occupied or wrong item type + */ + public static boolean loadItemIntoHolder( + IBondageItemHolder holder, + ItemStack stack, + Player player + ) { + if (stack.getItem() instanceof ItemBind && holder.getBind().isEmpty()) { + holder.setBind(stack.copyWithCount(1)); + if (!player.isCreative()) stack.shrink(1); + return true; + } + if (stack.getItem() instanceof ItemGag && holder.getGag().isEmpty()) { + holder.setGag(stack.copyWithCount(1)); + if (!player.isCreative()) stack.shrink(1); + return true; + } + if ( + stack.getItem() instanceof ItemBlindfold && + holder.getBlindfold().isEmpty() + ) { + holder.setBlindfold(stack.copyWithCount(1)); + if (!player.isCreative()) stack.shrink(1); + return true; + } + if ( + stack.getItem() instanceof ItemEarplugs && + holder.getEarplugs().isEmpty() + ) { + holder.setEarplugs(stack.copyWithCount(1)); + if (!player.isCreative()) stack.shrink(1); + return true; + } + if ( + stack.getItem() instanceof ItemCollar && + holder.getCollar().isEmpty() + ) { + holder.setCollar(stack.copyWithCount(1)); + if (!player.isCreative()) stack.shrink(1); + return true; + } + return false; + } + + /** + * Check if an item is a loadable bondage item. + * + *

Returns true for: Bind, Gag, Blindfold, Earplugs, Collar. + * + * @param stack The item stack to check + * @return true if the item can be loaded into a bondage item holder + */ + public static boolean isLoadableBondageItem(ItemStack stack) { + return ( + (stack.getItem() instanceof ItemBind) || + (stack.getItem() instanceof ItemGag) || + (stack.getItem() instanceof ItemBlindfold) || + (stack.getItem() instanceof ItemEarplugs) || + (stack.getItem() instanceof ItemCollar) + ); + } + + /** + * Add item to tooltip if present in NBT. + * + *

Reads an item from the given NBT key and adds it to the tooltip + * with a "- ItemName" format in gold color. + * + * @param tooltip The tooltip list to add to + * @param beTag The BlockEntity NBT tag + * @param key The NBT key to read (e.g., "bind", "gag") + */ + public static void addItemToTooltip( + List tooltip, + CompoundTag beTag, + String key + ) { + if (beTag.contains(key)) { + ItemStack item = ItemStack.of(beTag.getCompound(key)); + if (!item.isEmpty()) { + tooltip.add( + Component.literal("- ") + .append(item.getHoverName()) + .withStyle(ChatFormatting.GOLD) + ); + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/util/EquipmentInteractionHelper.java b/src/main/java/com/tiedup/remake/util/EquipmentInteractionHelper.java new file mode 100644 index 0000000..fa1acde --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/EquipmentInteractionHelper.java @@ -0,0 +1,204 @@ +package com.tiedup.remake.util; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.sync.SyncManager; +import com.tiedup.remake.state.IBondageState; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Predicate; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; + +/** + * Helper class for common bondage equipment interaction logic. + * + * This class extracts the duplicated interactLivingEntity pattern + * from ItemGag, ItemBlindfold, ItemMittens, ItemEarplugs, and ItemCollar. + */ +public class EquipmentInteractionHelper { + + /** + * Standard equipment interaction for bondage items. + * + * Handles the common flow: check state → equip or replace equipment. + * + * @param stack The item stack being used + * @param player The player using the item + * @param target The entity being equipped + * @param isEquipped Predicate to check if target already has this equipment + * @param putOn Consumer to equip the item on target (state, itemCopy) + * @param replace Function to replace existing equipment (state, itemCopy) → oldItem + * @param sendMessage Consumer to send message to target (player, target) + * @param logPrefix Log prefix for this equipment type + * @return InteractionResult + */ + public static InteractionResult equipOnTarget( + ItemStack stack, + Player player, + LivingEntity target, + Predicate isEquipped, + BiConsumer putOn, + BiFunction replace, + BiConsumer sendMessage, + String logPrefix + ) { + return equipOnTarget( + stack, + player, + target, + isEquipped, + putOn, + replace, + sendMessage, + logPrefix, + null, + null, + null + ); + } + + /** + * Equipment interaction with additional hooks for special items (like Collar). + * + * @param stack The item stack being used + * @param player The player using the item + * @param target The entity being equipped + * @param isEquipped Predicate to check if target already has this equipment + * @param putOn Consumer to equip the item on target (state, itemCopy) + * @param replace Function to replace existing equipment (state, itemCopy) → oldItem + * @param sendMessage Consumer to send message to target (player, target) + * @param logPrefix Log prefix for this equipment type + * @param preEquipHook Optional hook called before equipping (for owner registration, etc.) + * @param postEquipHook Optional hook called after equipping (for sound, particles, etc.) + * @param canReplace Optional predicate to check if replacement is allowed (for lock check) + * @return InteractionResult + */ + public static InteractionResult equipOnTarget( + ItemStack stack, + Player player, + LivingEntity target, + Predicate isEquipped, + BiConsumer putOn, + BiFunction replace, + BiConsumer sendMessage, + String logPrefix, + @Nullable EquipContext preEquipHook, + @Nullable EquipContext postEquipHook, + @Nullable ReplacePredicate canReplace + ) { + // Server-side only + if (player.level().isClientSide) { + return InteractionResult.SUCCESS; + } + + // Get target state + IBondageState targetState = KidnappedHelper.getKidnappedState(target); + if (targetState == null) { + return InteractionResult.PASS; + } + + // Must be tied up + if (!targetState.isTiedUp()) { + return InteractionResult.PASS; + } + + // Pre-equip hook (e.g., register collar owner) + if (preEquipHook != null) { + preEquipHook.execute(stack, player, target, targetState); + } + + // Case 1: Not equipped - put on new + if (!isEquipped.test(targetState)) { + putOn.accept(targetState, stack.copy()); + stack.shrink(1); + + sendMessage.accept(player, target); + syncIfPlayer(target); + + // Post-equip hook (e.g., play sound) + if (postEquipHook != null) { + postEquipHook.execute(stack, player, target, targetState); + } + + TiedUpMod.LOGGER.info( + "[{}] {} put {} on {}", + logPrefix, + player.getName().getString(), + logPrefix.toLowerCase(), + target.getName().getString() + ); + + return InteractionResult.SUCCESS; + } + // Case 2: Already equipped - replace + else { + // Check if replacement is allowed (e.g., not locked) + if ( + canReplace != null && + !canReplace.canReplace(targetState, player) + ) { + return InteractionResult.PASS; + } + + ItemStack oldItem = replace.apply(targetState, stack.copy()); + if (oldItem != null && !oldItem.isEmpty()) { + stack.shrink(1); + targetState.kidnappedDropItem(oldItem); + + sendMessage.accept(player, target); + syncIfPlayer(target); + + // Post-equip hook + if (postEquipHook != null) { + postEquipHook.execute(stack, player, target, targetState); + } + + TiedUpMod.LOGGER.info( + "[{}] {} replaced {} on {}", + logPrefix, + player.getName().getString(), + logPrefix.toLowerCase(), + target.getName().getString() + ); + + return InteractionResult.SUCCESS; + } + } + + return InteractionResult.PASS; + } + + /** + * Sync inventory if target is a ServerPlayer. + */ + private static void syncIfPlayer(LivingEntity target) { + if (target instanceof ServerPlayer serverPlayer) { + SyncManager.syncInventory(serverPlayer); + } + } + + /** + * Functional interface for pre/post equip hooks. + */ + @FunctionalInterface + public interface EquipContext { + void execute( + ItemStack stack, + Player player, + LivingEntity target, + IBondageState state + ); + } + + /** + * Functional interface to check if replacement is allowed. + */ + @FunctionalInterface + public interface ReplacePredicate { + boolean canReplace(IBondageState state, Player player); + } +} diff --git a/src/main/java/com/tiedup/remake/util/FoodEffects.java b/src/main/java/com/tiedup/remake/util/FoodEffects.java new file mode 100644 index 0000000..a7c0e00 --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/FoodEffects.java @@ -0,0 +1,103 @@ +package com.tiedup.remake.util; + +import org.jetbrains.annotations.Nullable; +import net.minecraft.world.food.FoodProperties; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +/** + * Calculates the effects of feeding food items to NPCs. + * + * Effects include: + * - Hunger restoration (based on nutrition) + * - Mood boost (based on food quality) + * - HP healing (based on saturation) + * - Affinity increase (based on nutrition) + */ +public class FoodEffects { + + /** + * Calculate the effects of feeding a food item to an NPC. + * + * @param food The food item stack + * @return FeedResult with all effects, or null if not a food item + */ + @Nullable + public static FeedResult calculateFeedEffects(ItemStack food) { + if (food.isEmpty()) return null; + + FoodProperties props = food.getItem().getFoodProperties(); + if (props == null) return null; + + int nutrition = props.getNutrition(); + float saturation = props.getSaturationModifier(); + + // Base hunger restoration (nutrition value directly, feed() multiplies by 5) + float hungerRestore = nutrition; + + // Mood boost based on food quality (nutrition 1-8 maps to mood 3-20) + int moodBoost = Math.max(3, (int) (nutrition * 2.5f)); + + // Special items get bonus mood + if (food.is(Items.CAKE)) { + moodBoost = 25; + } else if (food.is(Items.GOLDEN_APPLE)) { + moodBoost = 30; + } else if (food.is(Items.ENCHANTED_GOLDEN_APPLE)) { + moodBoost = 50; + } else if (food.is(Items.COOKIE)) { + moodBoost = 15; + } else if (food.is(Items.HONEY_BOTTLE)) { + moodBoost = 20; + } else if (food.is(Items.PUMPKIN_PIE)) { + moodBoost = 18; + } else if ( + food.is(Items.SWEET_BERRIES) || food.is(Items.GLOW_BERRIES) + ) { + moodBoost = 12; + } else if (food.is(Items.CHORUS_FRUIT)) { + moodBoost = 8; // Weird taste + } else if (food.is(Items.ROTTEN_FLESH) || food.is(Items.SPIDER_EYE)) { + moodBoost = -5; // Disgusting + } else if (food.is(Items.POISONOUS_POTATO)) { + moodBoost = -10; // Very bad + } + + // Heal HP based on saturation (saturation * 2, min 1) + float healAmount = Math.max(1.0f, saturation * 2.0f); + + // Affinity boost based on nutrition (min 1, max 4) + int affinityBoost = Math.max(1, Math.min(4, nutrition / 2)); + + // Special items get bonus affinity + if ( + food.is(Items.GOLDEN_APPLE) || food.is(Items.ENCHANTED_GOLDEN_APPLE) + ) { + affinityBoost = 8; + } else if (food.is(Items.CAKE)) { + affinityBoost = 6; + } + + return new FeedResult( + hungerRestore, + moodBoost, + healAmount, + affinityBoost + ); + } + + /** + * Result of feeding calculation. + * + * @param hunger How much hunger to restore (passed to NpcNeeds.feed()) + * @param mood How much mood to add + * @param heal How much HP to heal + * @param affinity How much affinity to add with the feeding player + */ + public record FeedResult( + float hunger, + int mood, + float heal, + int affinity + ) {} +} diff --git a/src/main/java/com/tiedup/remake/util/GagMaterial.java b/src/main/java/com/tiedup/remake/util/GagMaterial.java new file mode 100644 index 0000000..6f2119f --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/GagMaterial.java @@ -0,0 +1,296 @@ +package com.tiedup.remake.util; + +import com.tiedup.remake.core.ModConfig; + +/** + * GagMaterial DNA - Defines the "sound" and behavior of different gag materials. + * + * Phase 15: Added PANEL, LATEX, RING, BITE, SPONGE, BAGUETTE + * Phase 16: Enhanced with phonetic properties for realistic speech transformation + */ +public enum GagMaterial { + // Original materials + CLOTH( + new String[] { "m", "h", "f", "p" }, + new String[] { "ph", "ff", "mm" }, + 0.4f, + 15.0, + "mm", + "ah", // dominant consonant, vowel + 0.3f, + 0.5f, + 0.9f, + 0.3f, + 0.6f // plosive, fricative, nasal, liquid, vowel bleed + ), + BALL( + new String[] { "b", "h", "m", "p" }, + new String[] { "oo", "uu", "mm", "ou" }, + 0.2f, + 10.0, + "mm", + "uu", + 0.1f, + 0.2f, + 0.9f, + 0.1f, + 0.4f + ), + TAPE( + new String[] { "m", "n" }, + new String[] { "mm", "nn", "mnm" }, + 0.05f, + 5.0, + "mm", + "mm", + 0.0f, + 0.1f, + 0.8f, + 0.0f, + 0.1f + ), + STUFFED( + new String[] { "m" }, + new String[] { "mm" }, + 0.0f, + 3.0, + "mm", + "mm", + 0.0f, + 0.0f, + 0.5f, + 0.0f, + 0.0f + ), + + // Phase 15: New materials + PANEL( + new String[] { "m", "n" }, + new String[] { "mm", "nn" }, + 0.05f, + 4.0, + "mm", + "mm", + 0.0f, + 0.1f, + 0.7f, + 0.0f, + 0.1f + ), + LATEX( + new String[] { "m", "h" }, + new String[] { "mm", "uu" }, + 0.1f, + 6.0, + "mm", + "uu", + 0.05f, + 0.15f, + 0.8f, + 0.05f, + 0.2f + ), + RING( + new String[] { "a", "h", "l" }, + new String[] { "aa", "ah", "la" }, + 0.5f, + 12.0, + "h", + "ah", + 0.7f, + 0.8f, + 0.95f, + 0.8f, + 0.9f // Very open, most sounds pass + ), + BITE( + new String[] { "h", "g", "n" }, + new String[] { "eh", "ah", "ng" }, + 0.3f, + 10.0, + "gh", + "eh", + 0.4f, + 0.6f, + 0.9f, + 0.5f, + 0.7f + ), + SPONGE( + new String[] { "m" }, + new String[] { "mm" }, + 0.0f, + 2.0, + "mm", + "mm", + 0.0f, + 0.0f, + 0.3f, + 0.0f, + 0.0f // Absorbs almost everything + ), + BAGUETTE( + new String[] { "b", "m", "f" }, + new String[] { "om", "am", "um" }, + 0.25f, + 8.0, + "mm", + "om", + 0.2f, + 0.3f, + 0.8f, + 0.2f, + 0.5f + ); + + public final String[] consonants; + public final String[] vowels; + private final float defaultComprehension; + private final double defaultTalkRange; + + // Phase 16: Phonetic properties + private final String dominantConsonant; + private final String dominantVowel; + private final float plosiveBleed; // b,d,g,k,p,t - require lip/tongue + private final float fricativeBleed; // f,h,s,v,z - air-based + private final float nasalBleed; // m,n - through nose + private final float liquidBleed; // l,r - tongue-dependent + private final float vowelBleed; // a,e,i,o,u,y - mouth shape + + GagMaterial( + String[] c, + String[] v, + float comp, + double range, + String domCons, + String domVowel, + float plosive, + float fricative, + float nasal, + float liquid, + float vowel + ) { + this.consonants = c; + this.vowels = v; + this.defaultComprehension = comp; + this.defaultTalkRange = range; + this.dominantConsonant = domCons; + this.dominantVowel = domVowel; + this.plosiveBleed = plosive; + this.fricativeBleed = fricative; + this.nasalBleed = nasal; + this.liquidBleed = liquid; + this.vowelBleed = vowel; + } + + public float getComprehension() { + String key = this.name().toLowerCase(); + if ( + ModConfig.SERVER != null && + ModConfig.SERVER.gagComprehension.containsKey(key) + ) { + return ModConfig.SERVER.gagComprehension + .get(key) + .get() + .floatValue(); + } + return defaultComprehension; + } + + public double getTalkRange() { + String key = this.name().toLowerCase(); + if ( + ModConfig.SERVER != null && + ModConfig.SERVER.gagRange.containsKey(key) + ) { + return ModConfig.SERVER.gagRange.get(key).get(); + } + return defaultTalkRange; + } + + /** + * Get the dominant consonant sound for this material. + * Used when a consonant is completely blocked. + */ + public String getDominantConsonant() { + return dominantConsonant; + } + + /** + * Get the dominant vowel sound for this material. + * Used when a vowel is completely blocked. + */ + public String getDominantVowel() { + return dominantVowel; + } + + /** + * Get bleed-through rate for plosive consonants (b,d,g,k,p,t). + */ + public float getPlosiveBleed() { + return plosiveBleed; + } + + /** + * Get bleed-through rate for fricative consonants (f,h,s,v,z). + */ + public float getFricativeBleed() { + return fricativeBleed; + } + + /** + * Get bleed-through rate for nasal consonants (m,n). + */ + public float getNasalBleed() { + return nasalBleed; + } + + /** + * Get bleed-through rate for liquid consonants (l,r). + */ + public float getLiquidBleed() { + return liquidBleed; + } + + /** + * Get bleed-through rate for vowels. + */ + public float getVowelBleed() { + return vowelBleed; + } + + /** + * Calculate the effective bleed rate for a specific character. + */ + public float getBleedRateFor(char c) { + char lower = Character.toLowerCase(c); + + // Vowels + if ("aeiouy".indexOf(lower) >= 0) { + return vowelBleed; + } + + // Nasals - almost always pass + if (lower == 'm' || lower == 'n') { + return nasalBleed; + } + + // Plosives - blocked by most gags + if ("bdgkpt".indexOf(lower) >= 0) { + return plosiveBleed; + } + + // Fricatives - air-based + if ("fhsvz".indexOf(lower) >= 0) { + return fricativeBleed; + } + + // Liquids - tongue-dependent + if (lower == 'l' || lower == 'r') { + return liquidBleed; + } + + // Default to average of consonant bleeds + return (plosiveBleed + fricativeBleed + nasalBleed + liquidBleed) / 4f; + } +} diff --git a/src/main/java/com/tiedup/remake/util/GameConstants.java b/src/main/java/com/tiedup/remake/util/GameConstants.java new file mode 100644 index 0000000..0b5f65d --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/GameConstants.java @@ -0,0 +1,119 @@ +package com.tiedup.remake.util; + +/** + * Phase 4 Refactoring: Centralized game constants. + * + * Contains all magic numbers extracted from across the codebase + * for better maintainability and easier tuning. + */ +public final class GameConstants { + + private GameConstants() { + // Utility class - no instantiation + } + + // ==================== Time Conversions ==================== + + /** Minecraft ticks per second */ + public static final int TICKS_PER_SECOND = 20; + + /** Minecraft ticks per second (float for calculations) */ + public static final float TICKS_PER_SECOND_F = 20.0f; + + // ==================== Shock System ==================== + + /** Default shock damage when no custom value specified */ + public static final float DEFAULT_SHOCK_DAMAGE = 3.0f; + + /** Number of particles spawned during shock effect */ + public static final int SHOCK_PARTICLE_COUNT = 20; + + /** Y offset for shock particle spawn position */ + public static final double SHOCK_PARTICLE_Y_OFFSET = 1.0; + + /** X spread for shock particles */ + public static final double SHOCK_PARTICLE_X_SPREAD = 0.3; + + /** Y spread for shock particles */ + public static final double SHOCK_PARTICLE_Y_SPREAD = 0.5; + + /** Z spread for shock particles */ + public static final double SHOCK_PARTICLE_Z_SPREAD = 0.3; + + /** Speed/motion for shock particles */ + public static final double SHOCK_PARTICLE_SPEED = 0.1; + + /** Volume for shock sound effect */ + public static final float SHOCK_SOUND_VOLUME = 1.0f; + + /** Pitch for shock sound effect */ + public static final float SHOCK_SOUND_PITCH = 1.0f; + + // ==================== Chloroform Effects ==================== + + /** Movement slowdown amplifier for chloroform (127 = max) */ + public static final int CHLOROFORM_SLOWDOWN_AMPLIFIER = 127; + + /** Dig slowdown amplifier for chloroform (127 = max) */ + public static final int CHLOROFORM_DIG_SLOWDOWN_AMPLIFIER = 127; + + /** Blindness amplifier for chloroform (127 = max) */ + public static final int CHLOROFORM_BLINDNESS_AMPLIFIER = 127; + + /** Jump amplifier for chloroform (negative jump) */ + public static final int CHLOROFORM_JUMP_AMPLIFIER = 150; + + // ==================== Safety Thresholds ==================== + + /** Minimum health threshold - damage won't reduce below this */ + public static final float MIN_HEALTH_THRESHOLD = 0.5f; + + // ==================== Movement Restrictions ==================== + + /** Break speed multiplier when player is tied */ + public static final float TIED_BREAK_SPEED_MULTIPLIER = 0.1f; + + // ==================== Tick Intervals ==================== + + /** Ticks between shock collar checks */ + public static final int SHOCK_COLLAR_CHECK_INTERVAL = 10; + + /** Ticks between movement restriction checks */ + public static final int MOVEMENT_CHECK_INTERVAL = 5; + + // ==================== Struggle System ==================== + + /** Duration of struggle animation in ticks (4 seconds) */ + public static final int STRUGGLE_ANIMATION_DURATION_TICKS = 80; + + // ==================== Gag Talk System ==================== + + /** Message length threshold that triggers suffocation effects */ + public static final int GAG_MAX_MESSAGE_LENGTH_BEFORE_SUFFOCATION = 40; + + /** Duration of slowness effect from suffocation (ticks) */ + public static final int GAG_SUFFOCATION_SLOWNESS_DURATION = 40; + + /** Chance of blindness when suffocating from long message */ + public static final float GAG_SUFFOCATION_BLINDNESS_CHANCE = 0.3f; + + /** Duration of blindness effect from suffocation (ticks) */ + public static final int GAG_SUFFOCATION_BLINDNESS_DURATION = 20; + + /** Base chance of critical fail when speaking through gag */ + public static final float GAG_BASE_CRITICAL_FAIL_CHANCE = 0.05f; + + /** Additional critical fail chance per character of message */ + public static final float GAG_LENGTH_CRITICAL_FACTOR = 0.005f; + + // ==================== Merchant Particles ==================== + + /** Chance per tick of spawning golden sparkle particles on merchant */ + public static final float MERCHANT_SPARKLE_PARTICLE_CHANCE = 0.15F; + + /** Horizontal spread (X/Z) for merchant sparkle particles */ + public static final double MERCHANT_PARTICLE_SPREAD_XZ = 0.6; + + /** Vertical spread (Y) for merchant sparkle particles */ + public static final double MERCHANT_PARTICLE_SPREAD_Y = 1.8; +} diff --git a/src/main/java/com/tiedup/remake/util/ItemNBTHelper.java b/src/main/java/com/tiedup/remake/util/ItemNBTHelper.java new file mode 100644 index 0000000..b7ee7fd --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/ItemNBTHelper.java @@ -0,0 +1,408 @@ +package com.tiedup.remake.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.world.item.ItemStack; + +/** + * Utility class for common NBT operations on ItemStacks. + * + *

Centralizes repetitive null-check patterns found throughout the codebase: + *

    + *
  • Safe getters with default values
  • + *
  • Safe setters that handle empty stacks
  • + *
  • UUID list operations for owner/key systems
  • + *
+ * + *

All methods are null-safe and handle empty ItemStacks gracefully. + */ +public final class ItemNBTHelper { + + private ItemNBTHelper() { + // Utility class - no instantiation + } + + // ======================================== + // BOOLEAN OPERATIONS + // ======================================== + + /** + * Get a boolean value from an ItemStack's NBT. + * + * @param stack The ItemStack to read from + * @param key The NBT key + * @param defaultValue Value to return if stack is empty or key doesn't exist + * @return The boolean value or defaultValue + */ + public static boolean getBoolean( + ItemStack stack, + String key, + boolean defaultValue + ) { + if (stack.isEmpty()) return defaultValue; + CompoundTag tag = stack.getTag(); + if (tag == null || !tag.contains(key, Tag.TAG_BYTE)) { + return defaultValue; + } + return tag.getBoolean(key); + } + + /** + * Get a boolean value from an ItemStack's NBT (defaults to false). + * + * @param stack The ItemStack to read from + * @param key The NBT key + * @return The boolean value or false + */ + public static boolean getBoolean(ItemStack stack, String key) { + return getBoolean(stack, key, false); + } + + /** + * Set a boolean value in an ItemStack's NBT. + * + * @param stack The ItemStack to modify + * @param key The NBT key + * @param value The boolean value to set + */ + public static void setBoolean(ItemStack stack, String key, boolean value) { + if (stack.isEmpty()) return; + stack.getOrCreateTag().putBoolean(key, value); + } + + // ======================================== + // INTEGER OPERATIONS + // ======================================== + + /** + * Get an integer value from an ItemStack's NBT. + * + * @param stack The ItemStack to read from + * @param key The NBT key + * @param defaultValue Value to return if stack is empty or key doesn't exist + * @return The integer value or defaultValue + */ + public static int getInt(ItemStack stack, String key, int defaultValue) { + if (stack.isEmpty()) return defaultValue; + CompoundTag tag = stack.getTag(); + if (tag == null || !tag.contains(key, Tag.TAG_INT)) { + return defaultValue; + } + return tag.getInt(key); + } + + /** + * Get an integer value from an ItemStack's NBT (defaults to 0). + * + * @param stack The ItemStack to read from + * @param key The NBT key + * @return The integer value or 0 + */ + public static int getInt(ItemStack stack, String key) { + return getInt(stack, key, 0); + } + + /** + * Set an integer value in an ItemStack's NBT. + * + * @param stack The ItemStack to modify + * @param key The NBT key + * @param value The integer value to set + */ + public static void setInt(ItemStack stack, String key, int value) { + if (stack.isEmpty()) return; + stack.getOrCreateTag().putInt(key, value); + } + + // ======================================== + // STRING OPERATIONS + // ======================================== + + /** + * Get a string value from an ItemStack's NBT. + * + * @param stack The ItemStack to read from + * @param key The NBT key + * @param defaultValue Value to return if stack is empty or key doesn't exist + * @return The string value or defaultValue + */ + @Nullable + public static String getString( + ItemStack stack, + String key, + @Nullable String defaultValue + ) { + if (stack.isEmpty()) return defaultValue; + CompoundTag tag = stack.getTag(); + if (tag == null || !tag.contains(key, Tag.TAG_STRING)) { + return defaultValue; + } + return tag.getString(key); + } + + /** + * Get a string value from an ItemStack's NBT (defaults to null). + * + * @param stack The ItemStack to read from + * @param key The NBT key + * @return The string value or null + */ + @Nullable + public static String getString(ItemStack stack, String key) { + return getString(stack, key, null); + } + + /** + * Set a string value in an ItemStack's NBT. + * + * @param stack The ItemStack to modify + * @param key The NBT key + * @param value The string value to set (null removes the key) + */ + public static void setString( + ItemStack stack, + String key, + @Nullable String value + ) { + if (stack.isEmpty()) return; + if (value == null) { + remove(stack, key); + } else { + stack.getOrCreateTag().putString(key, value); + } + } + + // ======================================== + // UUID OPERATIONS + // ======================================== + + /** + * Get a UUID value from an ItemStack's NBT. + * + * @param stack The ItemStack to read from + * @param key The NBT key + * @return The UUID value or null if not present + */ + @Nullable + public static UUID getUUID(ItemStack stack, String key) { + if (stack.isEmpty()) return null; + CompoundTag tag = stack.getTag(); + if (tag == null || !tag.hasUUID(key)) { + return null; + } + return tag.getUUID(key); + } + + /** + * Set a UUID value in an ItemStack's NBT. + * + * @param stack The ItemStack to modify + * @param key The NBT key + * @param value The UUID value to set (null removes the key) + */ + public static void setUUID( + ItemStack stack, + String key, + @Nullable UUID value + ) { + if (stack.isEmpty()) return; + if (value == null) { + remove(stack, key); + } else { + stack.getOrCreateTag().putUUID(key, value); + } + } + + /** + * Check if an ItemStack's NBT contains a UUID at the given key. + * + * @param stack The ItemStack to check + * @param key The NBT key + * @return true if the key exists and contains a UUID + */ + public static boolean hasUUID(ItemStack stack, String key) { + if (stack.isEmpty()) return false; + CompoundTag tag = stack.getTag(); + return tag != null && tag.hasUUID(key); + } + + // ======================================== + // UUID LIST OPERATIONS + // ======================================== + + /** + * Get a list of UUIDs from an ItemStack's NBT. + * UUIDs are stored as a ListTag of CompoundTags with "UUID" key. + * + * @param stack The ItemStack to read from + * @param key The NBT key for the list + * @return A list of UUIDs (empty list if not present) + */ + public static List getUUIDList(ItemStack stack, String key) { + List result = new ArrayList<>(); + if (stack.isEmpty()) return result; + + CompoundTag tag = stack.getTag(); + if (tag == null || !tag.contains(key, Tag.TAG_LIST)) { + return result; + } + + ListTag listTag = tag.getList(key, Tag.TAG_COMPOUND); + for (int i = 0; i < listTag.size(); i++) { + CompoundTag entry = listTag.getCompound(i); + if (entry.hasUUID("UUID")) { + result.add(entry.getUUID("UUID")); + } + } + return result; + } + + /** + * Set a list of UUIDs in an ItemStack's NBT. + * UUIDs are stored as a ListTag of CompoundTags with "UUID" key. + * + * @param stack The ItemStack to modify + * @param key The NBT key for the list + * @param uuids The list of UUIDs to store + */ + public static void setUUIDList( + ItemStack stack, + String key, + List uuids + ) { + if (stack.isEmpty()) return; + + if (uuids == null || uuids.isEmpty()) { + remove(stack, key); + return; + } + + ListTag listTag = new ListTag(); + for (UUID uuid : uuids) { + CompoundTag entry = new CompoundTag(); + entry.putUUID("UUID", uuid); + listTag.add(entry); + } + stack.getOrCreateTag().put(key, listTag); + } + + /** + * Add a UUID to a list stored in an ItemStack's NBT. + * + * @param stack The ItemStack to modify + * @param key The NBT key for the list + * @param uuid The UUID to add + * @return true if the UUID was added (not already present) + */ + public static boolean addToUUIDList( + ItemStack stack, + String key, + UUID uuid + ) { + if (stack.isEmpty() || uuid == null) return false; + + List list = getUUIDList(stack, key); + if (list.contains(uuid)) { + return false; + } + list.add(uuid); + setUUIDList(stack, key, list); + return true; + } + + /** + * Remove a UUID from a list stored in an ItemStack's NBT. + * + * @param stack The ItemStack to modify + * @param key The NBT key for the list + * @param uuid The UUID to remove + * @return true if the UUID was removed + */ + public static boolean removeFromUUIDList( + ItemStack stack, + String key, + UUID uuid + ) { + if (stack.isEmpty() || uuid == null) return false; + + List list = getUUIDList(stack, key); + if (list.remove(uuid)) { + setUUIDList(stack, key, list); + return true; + } + return false; + } + + /** + * Check if a UUID is present in a list stored in an ItemStack's NBT. + * + * @param stack The ItemStack to check + * @param key The NBT key for the list + * @param uuid The UUID to find + * @return true if the UUID is in the list + */ + public static boolean isInUUIDList(ItemStack stack, String key, UUID uuid) { + if (stack.isEmpty() || uuid == null) return false; + return getUUIDList(stack, key).contains(uuid); + } + + // ======================================== + // GENERIC OPERATIONS + // ======================================== + + /** + * Check if an ItemStack's NBT contains a key. + * + * @param stack The ItemStack to check + * @param key The NBT key + * @return true if the key exists + */ + public static boolean contains(ItemStack stack, String key) { + if (stack.isEmpty()) return false; + CompoundTag tag = stack.getTag(); + return tag != null && tag.contains(key); + } + + /** + * Remove a key from an ItemStack's NBT. + * + * @param stack The ItemStack to modify + * @param key The NBT key to remove + */ + public static void remove(ItemStack stack, String key) { + if (stack.isEmpty()) return; + CompoundTag tag = stack.getTag(); + if (tag != null) { + tag.remove(key); + } + } + + /** + * Get the CompoundTag from an ItemStack (may be null). + * + * @param stack The ItemStack to read from + * @return The CompoundTag or null + */ + @Nullable + public static CompoundTag getTag(ItemStack stack) { + if (stack.isEmpty()) return null; + return stack.getTag(); + } + + /** + * Get or create the CompoundTag for an ItemStack. + * + * @param stack The ItemStack to modify + * @return The CompoundTag (never null if stack is not empty) + */ + @Nullable + public static CompoundTag getOrCreateTag(ItemStack stack) { + if (stack.isEmpty()) return null; + return stack.getOrCreateTag(); + } +} diff --git a/src/main/java/com/tiedup/remake/util/KidnapExplosion.java b/src/main/java/com/tiedup/remake/util/KidnapExplosion.java new file mode 100644 index 0000000..4f60221 --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/KidnapExplosion.java @@ -0,0 +1,159 @@ +package com.tiedup.remake.util; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.PlayerBindState; +import java.util.List; +import org.jetbrains.annotations.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; + +/** + * Kidnap Explosion - Applies bondage to entities in an area. + * + * Phase 16: Blocks + * + * Used by EntityKidnapBomb when it explodes. + * Applies stored bondage items to all kidnappable entities in radius. + * + * Based on original KidnapExplosion from 1.12.2 + */ +public class KidnapExplosion { + + private final Level level; + private final BlockPos pos; + private final int radius; + private final ItemStack bind; + private final ItemStack gag; + private final ItemStack blindfold; + private final ItemStack earplugs; + private final ItemStack collar; + private final ItemStack clothes; + + public KidnapExplosion( + Level level, + BlockPos pos, + int radius, + ItemStack bind, + ItemStack gag, + ItemStack blindfold, + ItemStack earplugs, + ItemStack collar, + ItemStack clothes + ) { + this.level = level; + this.pos = pos; + this.radius = radius; + this.bind = bind; + this.gag = gag; + this.blindfold = blindfold; + this.earplugs = earplugs; + this.collar = collar; + this.clothes = clothes; + } + + /** + * Execute the explosion effect. + */ + public void explode() { + explode(null); + } + + /** + * Execute the explosion effect, optionally excluding a player. + * + * @param toExclude Player to exclude from effect (usually the one who placed the bomb) + */ + public void explode(@Nullable Player toExclude) { + if (level == null || level.isClientSide || pos == null) { + return; + } + + // Play explosion sound + level.playSound( + null, + pos, + SoundEvents.GENERIC_EXPLODE, + SoundSource.BLOCKS, + 4.0f, + (1.0f + + (level.random.nextFloat() - level.random.nextFloat()) * + 0.2f) * + 0.7f + ); + + // Spawn explosion particle + if (level instanceof ServerLevel serverLevel) { + serverLevel.sendParticles( + ParticleTypes.EXPLOSION, + pos.getX() + 0.5, + pos.getY() + 0.5, + pos.getZ() + 0.5, + 1, + 0, + 0, + 0, + 0 + ); + } + + // Find all kidnappable entities in radius + AABB area = new AABB(pos).inflate(radius); + List entities = level.getEntitiesOfClass( + LivingEntity.class, + area + ); + + int affected = 0; + for (LivingEntity entity : entities) { + // Skip excluded player + if (toExclude != null && entity.equals(toExclude)) { + continue; + } + + // Skip spectators + if (entity instanceof Player player && player.isSpectator()) { + continue; + } + + // Get kidnapped state + IBondageState kidnappedState = KidnappedHelper.getKidnappedState( + entity + ); + if (kidnappedState == null) { + continue; + } + + // Skip already tied entities + if (kidnappedState.isTiedUp()) { + continue; + } + + // Apply bondage + kidnappedState.applyBondage( + bind, + gag, + blindfold, + earplugs, + collar, + clothes + ); + affected++; + } + + TiedUpMod.LOGGER.info( + "[KidnapExplosion] Explosion at {} affected {} entities (radius: {})", + pos, + affected, + radius + ); + } +} diff --git a/src/main/java/com/tiedup/remake/util/KidnappedHelper.java b/src/main/java/com/tiedup/remake/util/KidnappedHelper.java new file mode 100644 index 0000000..2a90638 --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/KidnappedHelper.java @@ -0,0 +1,175 @@ +package com.tiedup.remake.util; + +import com.tiedup.remake.compat.mca.MCACompat; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.state.IRestrainable; +import com.tiedup.remake.state.PlayerBindState; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import org.jetbrains.annotations.Nullable; + +/** + * Phase 14.1: Helper utility for working with IRestrainable entities. + * + * Provides convenient methods for obtaining IRestrainable instances from various entity types. + * + *

Purpose

+ * This helper abstracts the complexity of determining whether an entity can be kidnapped/restrained + * and provides a unified way to access the IRestrainable interface across different entity types. + * + *

Supported Entity Types

+ *
    + *
  • Player: Uses {@link PlayerBindState} singleton
  • + *
  • EntityDamsel (Phase 14.2): Implements IRestrainable directly
  • + *
  • EntityKidnapper (Phase 14.2): Implements IRestrainable directly
  • + *
  • Other entities: Returns null (not kidnappable)
  • + *
+ * + *

Usage Example

+ *
{@code
+ * public InteractionResult interactLivingEntity(ItemStack stack, Player user,
+ *                                                LivingEntity target, InteractionHand hand) {
+ *     IRestrainable state = KidnappedHelper.getKidnappedState(target);
+ *     if (state == null) {
+ *         return InteractionResult.PASS; // Entity cannot be restrained
+ *     }
+ *
+ *     if (state.isTiedUp()) {
+ *         user.sendSystemMessage(Component.literal("Already tied up!"));
+ *         return InteractionResult.FAIL;
+ *     }
+ *
+ *     state.equip(BodyRegionV2.ARMS, stack.copy());
+ *     return InteractionResult.SUCCESS;
+ * }
+ * }
+ */ +public class KidnappedHelper { + + /** + * Get the IRestrainable instance for any living entity. + * + *

This method determines the appropriate IRestrainable implementation based on entity type: + *

    + *
  • Player: Returns {@link PlayerBindState#getInstance(Player)}
  • + *
  • IRestrainable implementer (NPCs): Returns the entity itself (cast to IRestrainable)
  • + *
  • Other entities: Returns null (entity cannot be kidnapped)
  • + *
+ * + * @param entity The living entity to check + * @return The IRestrainable instance, or null if entity cannot be kidnapped + */ + @Nullable + public static IRestrainable getKidnappedState(LivingEntity entity) { + if (entity == null) { + return null; + } + + // For Players: Use PlayerBindState singleton + if (entity instanceof Player player) { + return PlayerBindState.getInstance(player); + } + + // For NPCs that implement IRestrainable (EntityDamsel, EntityKidnapper, etc.) + if (entity instanceof IRestrainable kidnapped) { + return kidnapped; + } + + // MCA Compatibility: Check if entity is an MCA villager + if (MCACompat.isMCALoaded() && MCACompat.isMCAVillager(entity)) { + IRestrainable mcaState = MCACompat.getKidnappedState(entity); + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[KidnappedHelper] MCA villager {} -> state: {}", + entity.getName().getString(), + mcaState != null ? mcaState.getClass().getSimpleName() : "null" + ); + return mcaState; + } + + // Entity cannot be kidnapped + return null; + } + + /** + * Check if an entity can be kidnapped/restrained. + * + * @param entity The living entity to check + * @return true if the entity implements IRestrainable or is a Player + */ + public static boolean canBeKidnapped(LivingEntity entity) { + return getKidnappedState(entity) != null; + } + + /** + * Check if an entity is currently tied up. + * + *

Convenience method that combines {@link #getKidnappedState(LivingEntity)} + * and {@link IRestrainable#isTiedUp()}. + * + * @param entity The living entity to check + * @return true if the entity is tied up, false otherwise + */ + public static boolean isTiedUp(LivingEntity entity) { + IRestrainable state = getKidnappedState(entity); + return state != null && state.isTiedUp(); + } + + /** + * Check if an entity is currently gagged. + * + *

Convenience method that combines {@link #getKidnappedState(LivingEntity)} + * and {@link IRestrainable#isGagged()}. + * + * @param entity The living entity to check + * @return true if the entity is gagged, false otherwise + */ + public static boolean isGagged(LivingEntity entity) { + IRestrainable state = getKidnappedState(entity); + return state != null && state.isGagged(); + } + + /** + * Check if an entity is currently blindfolded. + * + *

Convenience method that combines {@link #getKidnappedState(LivingEntity)} + * and {@link IRestrainable#isBlindfolded()}. + * + * @param entity The living entity to check + * @return true if the entity is blindfolded, false otherwise + */ + public static boolean isBlindfolded(LivingEntity entity) { + IRestrainable state = getKidnappedState(entity); + return state != null && state.isBlindfolded(); + } + + /** + * Check if an entity has a collar. + * + *

Convenience method that combines {@link #getKidnappedState(LivingEntity)} + * and {@link IRestrainable#hasCollar()}. + * + * @param entity The living entity to check + * @return true if the entity has a collar, false otherwise + */ + public static boolean hasCollar(LivingEntity entity) { + IRestrainable state = getKidnappedState(entity); + return state != null && state.hasCollar(); + } + + /** + * Check if an entity is enslaved. + * + *

Convenience method that combines {@link #getKidnappedState(LivingEntity)} + * and {@link IRestrainable#isSlave()}. + * + * @param entity The living entity to check + * @return true if the entity is enslaved, false otherwise + */ + /** + * Phase 17: Renamed from isSlave to isCaptive + */ + public static boolean isCaptive(LivingEntity entity) { + IRestrainable state = getKidnappedState(entity); + return state != null && state.isCaptive(); + } +} diff --git a/src/main/java/com/tiedup/remake/util/KidnapperAIHelper.java b/src/main/java/com/tiedup/remake/util/KidnapperAIHelper.java new file mode 100644 index 0000000..b73fbfe --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/KidnapperAIHelper.java @@ -0,0 +1,374 @@ +package com.tiedup.remake.util; + +import com.tiedup.remake.entities.EntityKidnapper; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.levelgen.Heightmap; +import org.jetbrains.annotations.Nullable; + +/** + * Helper methods for Kidnapper AI goals. + * + * Phase: Code Quality Refactoring + * + * Consolidates duplicated logic from multiple AI goals: + * - Ground position finding (AbstractKidnapperFleeGoal, KidnapperPatrolGoal) + * - Common "can use" preconditions + */ +public final class KidnapperAIHelper { + + private KidnapperAIHelper() { + // Utility class - prevent instantiation + } + + // ==================== GROUND POSITION FINDING ==================== + + /** + * Check if a position is valid for walking (solid ground below, air at feet and head). + * + * @param level The world level + * @param pos The position to check (feet position) + * @return true if the position is walkable + */ + public static boolean isValidGroundPosition(Level level, BlockPos pos) { + // Need solid ground below + BlockPos below = pos.below(); + if (!level.getBlockState(below).isSolid()) { + return false; + } + + // Need air at feet and head level + if (!level.getBlockState(pos).isAir()) { + return false; + } + if (!level.getBlockState(pos.above()).isAir()) { + return false; + } + + return true; + } + + /** + * Find a valid ground position near a target position. + * Searches in a vertical range of -5 to +5 blocks. + * + * @param level The world level + * @param targetPos The target position to search around + * @return A valid ground position, or null if none found + */ + @Nullable + public static BlockPos findGroundPos(Level level, BlockPos targetPos) { + return findGroundPos(level, targetPos, 5); + } + + /** + * Find a valid ground position near a target position. + * + * @param level The world level + * @param targetPos The target position to search around + * @param ySearchRange Vertical search range (up and down) + * @return A valid ground position, or null if none found + */ + @Nullable + public static BlockPos findGroundPos( + Level level, + BlockPos targetPos, + int ySearchRange + ) { + for (int yOffset = -ySearchRange; yOffset <= ySearchRange; yOffset++) { + BlockPos checkPos = targetPos.offset(0, yOffset, 0); + if (isValidGroundPosition(level, checkPos)) { + return checkPos; + } + } + return null; + } + + /** + * Find a random ground position within a radius. + * + * @param level The world level + * @param center The center position + * @param radius The search radius + * @param random Random source + * @param attempts Maximum number of attempts + * @return A valid ground position, or null if none found + */ + @Nullable + public static BlockPos findRandomGroundPos( + Level level, + BlockPos center, + int radius, + RandomSource random, + int attempts + ) { + for (int i = 0; i < attempts; i++) { + int offsetX = random.nextInt(radius * 2 + 1) - radius; + int offsetZ = random.nextInt(radius * 2 + 1) - radius; + + BlockPos targetPos = center.offset(offsetX, 0, offsetZ); + BlockPos groundPos = findGroundPos(level, targetPos); + + if (groundPos != null) { + return groundPos; + } + } + return null; + } + + // ==================== SAFE TELEPORT POSITION FINDING ==================== + + /** + * Find a safe position for teleportation within a radius. + * Uses polar coordinates with bias towards farther positions. + * + *

Consolidated from EntityKidnapper, AbstractKidnapperFleeGoal, MaidExtractPrisonerGoal.

+ * + * @param level The server level + * @param center The center position to search from + * @param radius Maximum distance from center + * @param random Random source + * @return Safe block position, or null if none found after 20 attempts + */ + @Nullable + public static BlockPos findSafePosition( + ServerLevel level, + BlockPos center, + int radius, + RandomSource random + ) { + // Try up to 20 random positions + for (int attempt = 0; attempt < 20; attempt++) { + // Random angle + double angle = random.nextDouble() * Math.PI * 2; + // Random distance (bias towards farther) + double distance = radius * 0.7 + random.nextDouble() * radius * 0.3; + + int targetX = (int) (center.getX() + Math.cos(angle) * distance); + int targetZ = (int) (center.getZ() + Math.sin(angle) * distance); + + // Find ground level using heightmap + int targetY = level.getHeight( + Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, + targetX, + targetZ + ); + + BlockPos candidate = new BlockPos(targetX, targetY, targetZ); + + // Check if position is safe + if (isSafeForTeleport(level, candidate)) { + return candidate; + } + } + + return null; + } + + /** + * Find a safe position with a fallback if none found. + * + * @param level The server level + * @param center The center position to search from + * @param radius Maximum distance from center + * @param random Random source + * @return Safe block position, or fallback position if none found + */ + public static BlockPos findSafePositionOrFallback( + ServerLevel level, + BlockPos center, + int radius, + RandomSource random + ) { + BlockPos safe = findSafePosition(level, center, radius, random); + if (safe != null) { + return safe; + } + // Fallback: offset from center + return center.offset(radius, 0, 0); + } + + /** + * Check if a position is safe for teleporting an entity. + * + * @param level The level to check in + * @param pos The position to check (feet level) + * @return true if position is safe for teleportation + */ + public static boolean isSafeForTeleport(Level level, BlockPos pos) { + BlockState feetBlock = level.getBlockState(pos); + BlockState headBlock = level.getBlockState(pos.above()); + BlockState groundBlock = level.getBlockState(pos.below()); + + // Ground must be solid + if (!groundBlock.isSolid()) { + return false; + } + + // Feet and head must be passable (not solid) + if (feetBlock.isSolid() || headBlock.isSolid()) { + return false; + } + + // Check for dangerous blocks + var feetFluid = feetBlock.getFluidState(); + if (!feetFluid.isEmpty()) { + return false; // Water or lava + } + + // Check for fire, cactus, etc. + if ( + feetBlock.getBlock() instanceof + net.minecraft.world.level.block.FireBlock || + feetBlock.getBlock() instanceof + net.minecraft.world.level.block.CactusBlock || + feetBlock.getBlock() instanceof + net.minecraft.world.level.block.SweetBerryBushBlock || + feetBlock.getBlock() instanceof + net.minecraft.world.level.block.MagmaBlock + ) { + return false; + } + + return true; + } + + // ==================== COMMON PRECONDITIONS ==================== + + /** + * Check common preconditions for kidnapper goals that require an active kidnapper. + * Checks: not tied up. + * + * @param kidnapper The kidnapper entity + * @return true if the kidnapper can act + */ + public static boolean canKidnapperAct(EntityKidnapper kidnapper) { + return !kidnapper.isTiedUp(); + } + + /** + * Check common preconditions for kidnapper goals that require no captive. + * Checks: not tied up, no captive, no target. + * + * @param kidnapper The kidnapper entity + * @return true if the kidnapper can search for targets + */ + public static boolean canKidnapperSearch(EntityKidnapper kidnapper) { + if (kidnapper.isTiedUp()) return false; + if (kidnapper.hasCaptives()) return false; + if (kidnapper.getTarget() != null) return false; + return true; + } + + /** + * Check common preconditions for kidnapper goals that handle captives. + * Checks: not tied up, has captive, not in get-out state. + * + * @param kidnapper The kidnapper entity + * @return true if the kidnapper can handle their captive + */ + public static boolean canKidnapperHandleCaptive(EntityKidnapper kidnapper) { + if (kidnapper.isTiedUp()) return false; + if (!kidnapper.hasCaptives()) return false; + if (kidnapper.isGetOutState()) return false; + return true; + } + + /** + * Check common preconditions for kidnapper goals during captive transport. + * Checks: not tied up, has captive, not for sale, not waiting for job. + * + * @param kidnapper The kidnapper entity + * @return true if the kidnapper can transport their captive + */ + public static boolean canKidnapperTransport(EntityKidnapper kidnapper) { + if (!canKidnapperHandleCaptive(kidnapper)) return false; + + var captive = kidnapper.getCaptive(); + if (captive != null && captive.isForSell()) return false; + if (kidnapper.isWaitingForJobToBeCompleted()) return false; + + return true; + } + + // ==================== CELL NAVIGATION ==================== + + /** + * Get the best position to deliver/extract a prisoner at a cell. + * Prefers DELIVERY marker, falls back to spawn point. + * + *

Consolidated from KidnapperBringToCellGoal, KidnapperWalkPrisonerGoal, + * MaidExtractPrisonerGoal, MaidReturnPrisonerGoal.

+ * + * @param cell The cell data + * @param level The world level + * @return Best standing position for the cell + */ + public static BlockPos getDeliveryOrSpawnPoint( + com.tiedup.remake.cells.CellDataV2 cell, + Level level + ) { + BlockPos deliveryPoint = cell.getDeliveryPoint(); + if (deliveryPoint != null) { + return findStandablePosition(deliveryPoint, level); + } + return cell.getSpawnPoint() != null + ? cell.getSpawnPoint() + : cell.getCorePos().above(); + } + + /** + * Find a standable position near a marker. + * Handles both floor-placed markers (stand above) and air-placed markers (stand at marker). + * + * @param marker The marker position + * @param level The world level + * @return A valid standing position + */ + public static BlockPos findStandablePosition(BlockPos marker, Level level) { + // 1. Check if marker itself is standable (air/passable block, solid below) + if (isStandable(level, marker)) { + return marker; + } + + // 2. Check above - classic case where marker is the floor block + if (isStandable(level, marker.above())) { + return marker.above(); + } + + // 3. Search below - case where marker is floating/placed in air above the floor + for (int y = 1; y <= 5; y++) { + BlockPos below = marker.below(y); + if (isStandable(level, below)) { + return below; + } + } + + // Fallback: above the marker + return marker.above(); + } + + /** + * Check if a position is standable (entity can stand there). + * More permissive than isSafeForTeleport - uses blocksMotion() instead of isSolid(). + * + * @param level The world level + * @param pos The position to check (feet level) + * @return true if an entity can stand at this position + */ + public static boolean isStandable(Level level, BlockPos pos) { + net.minecraft.world.level.block.state.BlockState below = + level.getBlockState(pos.below()); + net.minecraft.world.level.block.state.BlockState atPos = + level.getBlockState(pos); + net.minecraft.world.level.block.state.BlockState above = + level.getBlockState(pos.above()); + + return ( + below.isSolid() && !atPos.blocksMotion() && !above.blocksMotion() + ); + } +} diff --git a/src/main/java/com/tiedup/remake/util/MessageDispatcher.java b/src/main/java/com/tiedup/remake/util/MessageDispatcher.java new file mode 100644 index 0000000..b2af84d --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/MessageDispatcher.java @@ -0,0 +1,350 @@ +package com.tiedup.remake.util; + +import com.tiedup.remake.dialogue.GagTalkManager; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.entities.EntityDamsel; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.PlayerBindState; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * Centralized message dispatcher with earplug awareness. + * + *

This utility ensures that all player messages respect the earplug system. + * Players with earplugs equipped will not receive messages sent through this dispatcher + * (unless using the "system" variants which bypass the check). + * + *

Use cases: + *

    + *
  • {@link #sendTo} - Direct message to a player (earplug-aware)
  • + *
  • {@link #sendFrom} - Message from one player to another (earplug-aware)
  • + *
  • {@link #broadcastToAll} - Broadcast to all players (earplug-aware per player)
  • + *
  • {@link #sendActionBar} - Action bar message (earplug-aware)
  • + *
  • {@link #sendSystemMessage} - Critical system message (bypasses earplugs)
  • + *
+ */ +public final class MessageDispatcher { + + private MessageDispatcher() { + // Utility class - no instantiation + } + + // ======================================== + // EARPLUG-AWARE METHODS + // ======================================== + + /** + * Send a message to a player (respects earplugs). + * + *

If the target player has earplugs equipped, the message will NOT be sent. + * + * @param target The player to send the message to + * @param message The message component to send + * @return true if the message was sent, false if blocked by earplugs + */ + public static boolean sendTo(Player target, Component message) { + if (target == null || message == null) { + return false; + } + if (hasEarplugs(target)) { + return false; // Blocked by earplugs + } + target.sendSystemMessage(message); + return true; + } + + /** + * Send a message from one player to another (respects receiver's earplugs). + * + *

If the receiver has earplugs equipped, the message will NOT be sent. + * The sender parameter is currently unused but available for future + * features like sender notification when message is blocked. + * + * @param sender The player sending the message (for future feedback features) + * @param receiver The player receiving the message + * @param message The message component to send + * @return true if the message was sent, false if blocked by earplugs + */ + public static boolean sendFrom( + Player sender, + Player receiver, + Component message + ) { + if (receiver == null || message == null) { + return false; + } + if (hasEarplugs(receiver)) { + // Future: Could notify sender that message was blocked + return false; + } + receiver.sendSystemMessage(message); + return true; + } + + /** + * Broadcast a message to all players on the server (respects individual earplugs). + * + *

Each player's earplug state is checked individually. Players with earplugs + * will NOT receive the message. + * + * @param server The Minecraft server + * @param message The message component to broadcast + */ + public static void broadcastToAll( + MinecraftServer server, + Component message + ) { + if (server == null || message == null) { + return; + } + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + sendTo(player, message); + } + } + + /** + * Send a message to the action bar (respects earplugs). + * + *

Action bar messages appear above the hotbar and fade after a few seconds. + * If the target has earplugs, the message will NOT be shown. + * + * @param target The player to send the action bar message to + * @param message The message component to display + * @return true if the message was sent, false if blocked by earplugs + */ + public static boolean sendActionBar(Player target, Component message) { + if (target == null || message == null) { + return false; + } + if (hasEarplugs(target)) { + return false; + } + target.displayClientMessage(message, true); + return true; + } + + /** + * Send a chat message (not action bar) that respects earplugs. + * + *

Chat messages appear in the chat window and persist in chat history. + * + * @param target The player to send the chat message to + * @param message The message component to display + * @return true if the message was sent, false if blocked by earplugs + */ + public static boolean sendChat(Player target, Component message) { + if (target == null || message == null) { + return false; + } + if (hasEarplugs(target)) { + return false; + } + target.displayClientMessage(message, false); + return true; + } + + // ======================================== + // BYPASS METHODS (ignore earplugs) + // ======================================== + + /** + * Send a critical system message that IGNORES earplugs. + * + *

Use this for important system notifications that the player MUST see, + * such as teleport warnings, server announcements, or error messages. + * + * @param target The player to send the message to + * @param message The message component to send + */ + public static void sendSystemMessage(Player target, Component message) { + if (target == null || message == null) { + return; + } + target.sendSystemMessage(message); + } + + /** + * Send a critical action bar message that IGNORES earplugs. + * + * @param target The player to send the action bar message to + * @param message The message component to display + */ + public static void sendSystemActionBar(Player target, Component message) { + if (target == null || message == null) { + return; + } + target.displayClientMessage(message, true); + } + + // ======================================== + // INTERNAL HELPERS + // ======================================== + + /** + * Check if a player has earplugs equipped. + * + * @param player The player to check + * @return true if the player has earplugs, false otherwise + */ + private static boolean hasEarplugs(Player player) { + if (player == null) { + return false; + } + PlayerBindState state = PlayerBindState.getInstance(player); + return state != null && state.hasEarplugs(); + } + + // ======================================== + // NPC DIALOGUE METHODS + // ======================================== + + /** + * Send a dialogue message from an entity to a player. + * Formatted as: " message" + * + * @param entity The entity speaking + * @param player The player receiving the message + * @param message The dialogue text + * @return true if message was sent, false if blocked by earplugs + */ + public static boolean talkTo( + LivingEntity entity, + Player player, + String message + ) { + if (entity == null || player == null || message == null) { + return false; + } + if (entity.level().isClientSide()) { + return false; + } + if (hasEarplugs(player)) { + return false; + } + + // Apply gag talk if entity is a gagged NPC + String finalMessage = message; + if (entity instanceof com.tiedup.remake.entities.AbstractTiedUpNpc npc && npc.isGagged()) { + ItemStack gag = npc.getEquipment(BodyRegionV2.MOUTH); + IBondageState state = KidnappedHelper.getKidnappedState(npc); + if (state != null && !gag.isEmpty()) { + Component gagged = GagTalkManager.processGagMessage( + state, + gag, + message + ); + finalMessage = gagged.getString(); + } + } + + Component formattedMessage = Component.literal("") + .append(Component.literal("<").withStyle(ChatFormatting.WHITE)) + .append(entity.getDisplayName().copy()) + .append(Component.literal("> ").withStyle(ChatFormatting.WHITE)) + .append( + Component.literal(finalMessage).withStyle(ChatFormatting.GRAY) + ); + + player.displayClientMessage(formattedMessage, false); + return true; + } + + /** + * Send an action message from an entity to a player. + * Formatted as: "* EntityName action *" + * + * @param entity The entity performing the action + * @param player The player receiving the message + * @param action The action text + * @return true if message was sent, false if blocked by earplugs + */ + public static boolean actionTo( + LivingEntity entity, + Player player, + String action + ) { + if (entity == null || player == null || action == null) { + return false; + } + if (entity.level().isClientSide()) { + return false; + } + if (hasEarplugs(player)) { + return false; + } + + Component formattedMessage = Component.literal("") + .append(Component.literal("* ").withStyle(ChatFormatting.GRAY)) + .append(entity.getDisplayName().copy()) + .append( + Component.literal(" " + action + " *").withStyle( + ChatFormatting.GRAY + ) + ); + + player.displayClientMessage(formattedMessage, false); + return true; + } + + /** + * Send a dialogue message to all players within a radius. + * + * @param entity The entity speaking + * @param message The dialogue text + * @param radius The broadcast radius in blocks + */ + public static void talkToNearby( + LivingEntity entity, + String message, + double radius + ) { + if (entity == null || message == null) { + return; + } + + var nearbyPlayers = entity + .level() + .getEntitiesOfClass( + Player.class, + entity.getBoundingBox().inflate(radius) + ); + + for (Player player : nearbyPlayers) { + talkTo(entity, player, message); + } + } + + /** + * Send an action message to all players within a radius. + * + * @param entity The entity performing the action + * @param action The action text + * @param radius The broadcast radius in blocks + */ + public static void actionToNearby( + LivingEntity entity, + String action, + double radius + ) { + if (entity == null || action == null) { + return; + } + + var nearbyPlayers = entity + .level() + .getEntitiesOfClass( + Player.class, + entity.getBoundingBox().inflate(radius) + ); + + for (Player player : nearbyPlayers) { + actionTo(entity, player, action); + } + } +} diff --git a/src/main/java/com/tiedup/remake/util/ModGameRules.java b/src/main/java/com/tiedup/remake/util/ModGameRules.java new file mode 100644 index 0000000..2c13e42 --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/ModGameRules.java @@ -0,0 +1,305 @@ +package com.tiedup.remake.util; + +import com.tiedup.remake.core.TiedUpMod; +import net.minecraft.world.level.GameRules; + +/** + * Phase 6: Custom GameRules for TiedUp mod. + * + * Manages configurable values for gameplay mechanics. + * GameRules can be changed via /gamerule command in-game. + * + * Based on original mod's configuration system. + */ +public class ModGameRules { + + // ===================================================== + // RESTRAINT MECHANICS + // ===================================================== + + /** Time (in seconds) required to tie up a player. Default: 5 */ + public static GameRules.Key TYING_PLAYER_TIME; + /** Time (in seconds) required to untie a tied player. Default: 10 */ + public static GameRules.Key UNTYING_PLAYER_TIME; + /** Whether gagged players can be heard within a short range. Default: true */ + public static GameRules.Key GAG_TALK_PROXIMITY; + /** Resistance added by a padlock when locked on an item. Default: 250 */ + public static GameRules.Key PADLOCK_RESISTANCE; + + // ===================================================== + // STRUGGLE SYSTEM + // ===================================================== + + /** Enable/disable struggle system. Default: true */ + public static GameRules.Key STRUGGLE; + /** Success probability for struggling (0-100). Default: 40% */ + public static GameRules.Key PROBABILITY_STRUGGLE; + /** Minimum resistance decrease on successful struggle. Default: 1 */ + public static GameRules.Key STRUGGLE_MIN_DECREASE; + /** Maximum resistance decrease on successful struggle. Default: 10 */ + public static GameRules.Key STRUGGLE_MAX_DECREASE; + /** Cooldown between struggle attempts (ticks, 20 = 1s). Default: 80 (4s) */ + public static GameRules.Key STRUGGLE_TIMER; + /** Ticks per 1 resistance point in continuous struggle. Default: 20 (1/s) */ + public static GameRules.Key STRUGGLE_CONTINUOUS_RATE; + /** Probability of random shock during collar struggle (0-100). Default: 20% */ + public static GameRules.Key STRUGGLE_COLLAR_RANDOM_SHOCK; + + // BUG-003: RESISTANCE_ROPE, RESISTANCE_GAG, RESISTANCE_BLINDFOLD, RESISTANCE_COLLAR + // removed. Bind resistance is now read from ModConfig via SettingsAccessor. + + // ===================================================== + // NPC STRUGGLE + // ===================================================== + + /** Enable/disable NPC struggle to escape restraints. Default: true */ + public static GameRules.Key NPC_STRUGGLE_ENABLED; + /** Base interval (ticks) between NPC struggle attempts. Default: 6000 (5 min) */ + public static GameRules.Key NPC_STRUGGLE_INTERVAL; + + // ===================================================== + // COLLAR & SHOCKER + // ===================================================== + + /** Enable/disable enslavement system. Default: true */ + public static GameRules.Key ENSLAVEMENT_ENABLED; + /** Base radius for shocker controller. Default: 50 blocks */ + public static GameRules.Key SHOCKER_CONTROLLER_BASE_RADIUS; + + // ===================================================== + // NPC SPAWNING + // ===================================================== + + /** Enable/disable damsel entity spawning. Default: true */ + public static GameRules.Key DAMSELS_SPAWN; + /** Enable/disable kidnapper entity spawning (all variants). Default: true */ + public static GameRules.Key KIDNAPPERS_SPAWN; + /** Damsel spawn rate (0-100). Default: 100 */ + public static GameRules.Key DAMSEL_SPAWN_RATE; + /** Kidnapper spawn rate (0-100). Default: 100 */ + public static GameRules.Key KIDNAPPER_SPAWN_RATE; + /** Kidnapper Archer spawn rate (0-100). Default: 100 */ + public static GameRules.Key KIDNAPPER_ARCHER_SPAWN_RATE; + /** Kidnapper Elite spawn rate (0-100). Default: 100 */ + public static GameRules.Key KIDNAPPER_ELITE_SPAWN_RATE; + /** Kidnapper Merchant spawn rate (0-100). Default: 100 */ + public static GameRules.Key KIDNAPPER_MERCHANT_SPAWN_RATE; + /** Master spawn rate (0-100). Default: 100 */ + public static GameRules.Key MASTER_SPAWN_RATE; + /** Spawn gender mode: 0=BOTH, 1=FEMALE_ONLY, 2=MALE_ONLY. Default: 0 */ + public static GameRules.Key SPAWN_GENDER_MODE; + + // ===================================================== + // BOUNTY SYSTEM + // ===================================================== + + /** Maximum bounties per player. Default: 5 */ + public static GameRules.Key MAX_BOUNTIES; + /** Duration of bounties in seconds. Default: 14400 (4 hours) */ + public static GameRules.Key BOUNTY_DURATION; + /** Radius for bounty delivery detection. Default: 5 blocks */ + public static GameRules.Key BOUNTY_DELIVERY_RADIUS; + + // ===================================================== + // MISCELLANEOUS + // ===================================================== + + /** Kidnap bomb explosion radius. Default: 5 blocks */ + public static GameRules.Key KIDNAP_BOMB_RADIUS; + + /** + * Register all custom GameRules. + * Called during mod initialization. + */ + public static void register() { + TiedUpMod.LOGGER.info("Registering TiedUp GameRules..."); + + // Phase 6: Tying/Untying times + // NOTE: Using hardcoded defaults because ModConfig isn't loaded yet during mod construction + GAG_TALK_PROXIMITY = GameRules.register( + "gagTalkProximity", + GameRules.Category.CHAT, + GameRules.BooleanValue.create(true) + ); + TYING_PLAYER_TIME = GameRules.register( + "tyingPlayerTime", + GameRules.Category.MISC, + GameRules.IntegerValue.create(5) + ); + + UNTYING_PLAYER_TIME = GameRules.register( + "untyingPlayerTime", + GameRules.Category.MISC, + GameRules.IntegerValue.create(10) + ); + + // Phase 7: Struggle/Resistance system + STRUGGLE = GameRules.register( + "struggle", + GameRules.Category.MISC, + GameRules.BooleanValue.create(true) + ); + + PROBABILITY_STRUGGLE = GameRules.register( + "probabilityStruggle", + GameRules.Category.MISC, + GameRules.IntegerValue.create(40) + ); + + STRUGGLE_MIN_DECREASE = GameRules.register( + "struggleMinDecrease", + GameRules.Category.MISC, + GameRules.IntegerValue.create(1) + ); + + STRUGGLE_MAX_DECREASE = GameRules.register( + "struggleMaxDecrease", + GameRules.Category.MISC, + GameRules.IntegerValue.create(10) + ); + + STRUGGLE_TIMER = GameRules.register( + "struggleTimer", + GameRules.Category.MISC, + GameRules.IntegerValue.create(80) + ); + + // BUG-003: Removed resistanceRope/Gag/Blindfold/Collar GameRule registrations. + // Bind resistance is now managed by ModConfig via SettingsAccessor. + + // Phase 13: Collar/Shocker features + STRUGGLE_COLLAR_RANDOM_SHOCK = GameRules.register( + "struggleCollarRandomShock", + GameRules.Category.MISC, + GameRules.IntegerValue.create(20) + ); + + SHOCKER_CONTROLLER_BASE_RADIUS = GameRules.register( + "shockerControllerBaseRadius", + GameRules.Category.MISC, + GameRules.IntegerValue.create(50) + ); + + // Phase 8: Enslavement + ENSLAVEMENT_ENABLED = GameRules.register( + "enslavementEnabled", + GameRules.Category.MISC, + GameRules.BooleanValue.create(true) + ); + + // Phase 14.2: Damsel spawning + DAMSELS_SPAWN = GameRules.register( + "damselsSpawn", + GameRules.Category.SPAWNING, + GameRules.BooleanValue.create(true) + ); + + // Kidnapper spawning (includes Elite, Archer, Merchant) + KIDNAPPERS_SPAWN = GameRules.register( + "kidnappersSpawn", + GameRules.Category.SPAWNING, + GameRules.BooleanValue.create(true) + ); + + // NPC spawn rates (0-100 percentage) + // BUG-001 FIX: Defaults now match ModConfig values instead of all being 100 + DAMSEL_SPAWN_RATE = GameRules.register( + "damselSpawnRate", + GameRules.Category.SPAWNING, + GameRules.IntegerValue.create(60) + ); + + KIDNAPPER_SPAWN_RATE = GameRules.register( + "kidnapperSpawnRate", + GameRules.Category.SPAWNING, + GameRules.IntegerValue.create(70) + ); + + KIDNAPPER_ARCHER_SPAWN_RATE = GameRules.register( + "kidnapperArcherSpawnRate", + GameRules.Category.SPAWNING, + GameRules.IntegerValue.create(70) + ); + + KIDNAPPER_ELITE_SPAWN_RATE = GameRules.register( + "kidnapperEliteSpawnRate", + GameRules.Category.SPAWNING, + GameRules.IntegerValue.create(70) + ); + + KIDNAPPER_MERCHANT_SPAWN_RATE = GameRules.register( + "kidnapperMerchantSpawnRate", + GameRules.Category.SPAWNING, + GameRules.IntegerValue.create(70) + ); + + MASTER_SPAWN_RATE = GameRules.register( + "masterSpawnRate", + GameRules.Category.SPAWNING, + GameRules.IntegerValue.create(100) + ); + + // Phase 16: Kidnap bomb radius + KIDNAP_BOMB_RADIUS = GameRules.register( + "kidnapBombRadius", + GameRules.Category.MISC, + GameRules.IntegerValue.create(5) + ); + + SPAWN_GENDER_MODE = GameRules.register( + "spawnGenderMode", + GameRules.Category.SPAWNING, + GameRules.IntegerValue.create(0) + ); + + // Phase 17: Bounty system + MAX_BOUNTIES = GameRules.register( + "maxBounties", + GameRules.Category.MISC, + GameRules.IntegerValue.create(5) + ); + + BOUNTY_DURATION = GameRules.register( + "bountyDuration", + GameRules.Category.MISC, + GameRules.IntegerValue.create(14400) + ); + + BOUNTY_DELIVERY_RADIUS = GameRules.register( + "bountyDeliveryRadius", + GameRules.Category.MISC, + GameRules.IntegerValue.create(5) + ); + + PADLOCK_RESISTANCE = GameRules.register( + "padlockResistance", + GameRules.Category.MISC, + GameRules.IntegerValue.create(250) + ); + + STRUGGLE_CONTINUOUS_RATE = GameRules.register( + "struggleContinuousRate", + GameRules.Category.MISC, + GameRules.IntegerValue.create(20) + ); + + NPC_STRUGGLE_ENABLED = GameRules.register( + "npcStruggleEnabled", + GameRules.Category.MISC, + GameRules.BooleanValue.create(true) + ); + + NPC_STRUGGLE_INTERVAL = GameRules.register( + "npcStruggleInterval", + GameRules.Category.MISC, + GameRules.IntegerValue.create(6000) + ); + + TiedUpMod.LOGGER.info("Registered {} TiedUp GameRules", 28); + } + + // All getter methods removed in H4-F. + // Call sites now use SettingsAccessor which reads GameRules fields directly. + // BUG-003: getResistance() was removed earlier. Use SettingsAccessor.getBindResistance(). + // Chloroform fake GameRule (just read ModConfig) replaced by SettingsAccessor.getChloroformDuration(). +} diff --git a/src/main/java/com/tiedup/remake/util/NameGenerator.java b/src/main/java/com/tiedup/remake/util/NameGenerator.java new file mode 100644 index 0000000..6080243 --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/NameGenerator.java @@ -0,0 +1,161 @@ +package com.tiedup.remake.util; + +import com.tiedup.remake.core.TiedUpMod; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import net.minecraft.util.RandomSource; + +/** + * Random name generator for NPCs (Damsels, Kidnappers). + * + * Phase 14.3.5: Name system + * + * Loads names from assets/tiedup/names/female_names.txt + * Contains 5001 female first names from the original mod. + */ +public class NameGenerator { + + private static final RandomSource RANDOM = RandomSource.create(); + + /** + * Female names loaded from file. + * Used for both Damsels and Kidnappers (all NPCs are female). + */ + private static List NAMES = null; + + /** + * Fallback names if file loading fails. + */ + private static final List FALLBACK_NAMES = List.of( + "Alice", + "Emma", + "Sophia", + "Olivia", + "Isabella", + "Mia", + "Charlotte", + "Amelia", + "Harper", + "Evelyn" + ); + + /** + * Load names from the resource file. + * Called lazily on first name request. + * Synchronized to prevent race conditions during parallel entity spawning. + */ + private static synchronized void loadNames() { + if (NAMES != null) return; + + NAMES = new ArrayList<>(); + String resourcePath = "/assets/tiedup/names/female_names.txt"; + + try ( + InputStream is = NameGenerator.class.getResourceAsStream( + resourcePath + ) + ) { + if (is == null) { + TiedUpMod.LOGGER.error( + "[NameGenerator] Could not find resource: {}", + resourcePath + ); + NAMES = new ArrayList<>(FALLBACK_NAMES); + return; + } + + try ( + BufferedReader reader = new BufferedReader( + new InputStreamReader(is, StandardCharsets.UTF_8) + ) + ) { + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (!line.isEmpty()) { + NAMES.add(line); + } + } + } + + TiedUpMod.LOGGER.info( + "[NameGenerator] Loaded {} names from file", + NAMES.size() + ); + } catch (IOException e) { + TiedUpMod.LOGGER.error( + "[NameGenerator] Failed to load names: {}", + e.getMessage() + ); + NAMES = new ArrayList<>(FALLBACK_NAMES); + } + + if (NAMES.isEmpty()) { + NAMES = new ArrayList<>(FALLBACK_NAMES); + } + } + + /** + * Get a random name from the loaded list. + * + * @return Random name + */ + public static String getRandomName() { + loadNames(); + return NAMES.get(RANDOM.nextInt(NAMES.size())); + } + + /** + * Get a random damsel name. + * + * @return Random damsel name + */ + public static String getRandomDamselName() { + return getRandomName(); + } + + /** + * Get a random kidnapper name. + * Same as damsel - all NPCs are female. + * + * @return Random kidnapper name + */ + public static String getRandomKidnapperName() { + return getRandomName(); + } + + /** + * Get a random maid name. + * Same as damsel - all NPCs are female. + * + * @return Random maid name + */ + public static String getRandomMaidName() { + return getRandomName(); + } + + /** + * Get a random slave trader name. + * Same as damsel - all NPCs are female. + * + * @return Random trader name + */ + public static String getRandomTraderName() { + return getRandomName(); + } + + /** + * Get the total number of available names. + * + * @return Number of names loaded + */ + public static int getNameCount() { + loadNames(); + return NAMES.size(); + } +} diff --git a/src/main/java/com/tiedup/remake/util/PatchouliProxy.java b/src/main/java/com/tiedup/remake/util/PatchouliProxy.java new file mode 100644 index 0000000..ada5409 --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/PatchouliProxy.java @@ -0,0 +1,18 @@ +package com.tiedup.remake.util; + +import net.minecraft.resources.ResourceLocation; + +/** + * Proxy class that directly uses Patchouli API. + * This class is only loaded if Patchouli is present. + */ +public class PatchouliProxy { + + /** + * Opens a Patchouli book GUI. + * This method uses Patchouli API directly (no reflection). + */ + public static void openBook(ResourceLocation bookId) { + vazkii.patchouli.api.PatchouliAPI.get().openBookGUI(bookId); + } +} diff --git a/src/main/java/com/tiedup/remake/util/PhoneticMapper.java b/src/main/java/com/tiedup/remake/util/PhoneticMapper.java new file mode 100644 index 0000000..93af878 --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/PhoneticMapper.java @@ -0,0 +1,507 @@ +package com.tiedup.remake.util; + +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; +import java.util.Random; +import java.util.Set; + +/** + * Phonetic transformation system for gagged speech. + * Maps original phonemes to muffled equivalents based on gag material. + */ +public class PhoneticMapper { + + private static final Random RANDOM = new Random(); + + // Phonetic categories + private static final Set VOWELS = Set.of( + 'a', + 'e', + 'i', + 'o', + 'u', + 'y' + ); + private static final Set PLOSIVES = Set.of( + 'b', + 'd', + 'g', + 'k', + 'p', + 't' + ); + private static final Set NASALS = Set.of('m', 'n'); + private static final Set FRICATIVES = Set.of( + 'f', + 'h', + 's', + 'v', + 'z' + ); + private static final Set LIQUIDS = Set.of('l', 'r'); + + // Material-specific phoneme mappings + private static final Map< + GagMaterial, + Map + > CONSONANT_MAPS = new EnumMap<>(GagMaterial.class); + private static final Map> VOWEL_MAPS = + new EnumMap<>(GagMaterial.class); + + static { + initializeClothMappings(); + initializeBallMappings(); + initializeTapeMappings(); + initializeStuffedMappings(); + initializePanelMappings(); + initializeLatexMappings(); + initializeRingMappings(); + initializeBiteMappings(); + initializeSpongeMappings(); + initializeBaguetteMappings(); + } + + private static void initializeClothMappings() { + Map consonants = new HashMap<>(); + consonants.put('b', new String[] { "m", "mph" }); + consonants.put('c', new String[] { "h", "kh" }); + consonants.put('d', new String[] { "n", "nd" }); + consonants.put('f', new String[] { "f", "ph" }); + consonants.put('g', new String[] { "ng", "gh" }); + consonants.put('h', new String[] { "h", "hh" }); + consonants.put('j', new String[] { "zh", "jh" }); + consonants.put('k', new String[] { "kh", "gh" }); + consonants.put('l', new String[] { "l", "hl" }); + consonants.put('m', new String[] { "m", "mm" }); + consonants.put('n', new String[] { "n", "nn" }); + consonants.put('p', new String[] { "mph", "m" }); + consonants.put('q', new String[] { "kh", "gh" }); + consonants.put('r', new String[] { "r", "hr" }); + consonants.put('s', new String[] { "s", "sh" }); + consonants.put('t', new String[] { "th", "n" }); + consonants.put('v', new String[] { "f", "vh" }); + consonants.put('w', new String[] { "wh", "u" }); + consonants.put('x', new String[] { "ks", "kh" }); + consonants.put('z', new String[] { "z", "s" }); + CONSONANT_MAPS.put(GagMaterial.CLOTH, consonants); + + Map vowels = new HashMap<>(); + vowels.put('a', new String[] { "ah", "a" }); + vowels.put('e', new String[] { "eh", "e" }); + vowels.put('i', new String[] { "ih", "e" }); + vowels.put('o', new String[] { "oh", "o" }); + vowels.put('u', new String[] { "uh", "u" }); + vowels.put('y', new String[] { "ih", "e" }); + VOWEL_MAPS.put(GagMaterial.CLOTH, vowels); + } + + private static void initializeBallMappings() { + Map consonants = new HashMap<>(); + // Ball gag forces mouth open around ball - tongue and lips blocked + consonants.put('b', new String[] { "m", "mm" }); + consonants.put('c', new String[] { "g", "kh" }); + consonants.put('d', new String[] { "n", "nn" }); + consonants.put('f', new String[] { "h", "hh" }); + consonants.put('g', new String[] { "ng", "g" }); + consonants.put('h', new String[] { "h", "hh" }); + consonants.put('j', new String[] { "g", "ng" }); + consonants.put('k', new String[] { "g", "gh" }); + consonants.put('l', new String[] { "u", "l" }); + consonants.put('m', new String[] { "m", "mm" }); + consonants.put('n', new String[] { "n", "nn" }); + consonants.put('p', new String[] { "m", "mm" }); + consonants.put('q', new String[] { "g", "gh" }); + consonants.put('r', new String[] { "u", "r" }); + consonants.put('s', new String[] { "h", "s" }); + consonants.put('t', new String[] { "n", "nn" }); + consonants.put('v', new String[] { "h", "f" }); + consonants.put('w', new String[] { "u", "w" }); + consonants.put('x', new String[] { "g", "ks" }); + consonants.put('z', new String[] { "s", "z" }); + CONSONANT_MAPS.put(GagMaterial.BALL, consonants); + + Map vowels = new HashMap<>(); + // Ball forces all vowels toward "oo/uu" (rounded) + vowels.put('a', new String[] { "a", "o" }); + vowels.put('e', new String[] { "u", "o" }); + vowels.put('i', new String[] { "u", "i" }); + vowels.put('o', new String[] { "o", "oo" }); + vowels.put('u', new String[] { "u", "uu" }); + vowels.put('y', new String[] { "u", "i" }); + VOWEL_MAPS.put(GagMaterial.BALL, vowels); + } + + private static void initializeTapeMappings() { + Map consonants = new HashMap<>(); + // Tape seals mouth almost completely - only nasal sounds + consonants.put('b', new String[] { "m", "mm" }); + consonants.put('c', new String[] { "n", "m" }); + consonants.put('d', new String[] { "n", "nn" }); + consonants.put('f', new String[] { "m", "hm" }); + consonants.put('g', new String[] { "n", "ng" }); + consonants.put('h', new String[] { "m", "hm" }); + consonants.put('j', new String[] { "n", "m" }); + consonants.put('k', new String[] { "n", "m" }); + consonants.put('l', new String[] { "n", "m" }); + consonants.put('m', new String[] { "m", "mm" }); + consonants.put('n', new String[] { "n", "nn" }); + consonants.put('p', new String[] { "m", "mm" }); + consonants.put('q', new String[] { "n", "m" }); + consonants.put('r', new String[] { "n", "m" }); + consonants.put('s', new String[] { "m", "s" }); + consonants.put('t', new String[] { "n", "nn" }); + consonants.put('v', new String[] { "m", "f" }); + consonants.put('w', new String[] { "m", "u" }); + consonants.put('x', new String[] { "n", "m" }); + consonants.put('z', new String[] { "n", "m" }); + CONSONANT_MAPS.put(GagMaterial.TAPE, consonants); + + Map vowels = new HashMap<>(); + // Tape muffles all vowels to near silence + vowels.put('a', new String[] { "m", "mm" }); + vowels.put('e', new String[] { "n", "m" }); + vowels.put('i', new String[] { "n", "m" }); + vowels.put('o', new String[] { "m", "mm" }); + vowels.put('u', new String[] { "m", "u" }); + vowels.put('y', new String[] { "n", "m" }); + VOWEL_MAPS.put(GagMaterial.TAPE, vowels); + } + + private static void initializeStuffedMappings() { + Map consonants = new HashMap<>(); + // Stuffed gag - nearly silent + for (char c = 'a'; c <= 'z'; c++) { + if (!VOWELS.contains(c)) { + consonants.put(c, new String[] { "mm", "m", "" }); + } + } + CONSONANT_MAPS.put(GagMaterial.STUFFED, consonants); + + Map vowels = new HashMap<>(); + for (char c : VOWELS) { + vowels.put(c, new String[] { "mm", "m", "" }); + } + VOWEL_MAPS.put(GagMaterial.STUFFED, vowels); + } + + private static void initializePanelMappings() { + // Panel gag - similar to tape but slightly more sound + Map consonants = new HashMap<>(); + consonants.put('b', new String[] { "m", "mph" }); + consonants.put('c', new String[] { "n", "m" }); + consonants.put('d', new String[] { "n", "nd" }); + consonants.put('f', new String[] { "m", "f" }); + consonants.put('g', new String[] { "n", "ng" }); + consonants.put('h', new String[] { "hm", "m" }); + consonants.put('j', new String[] { "n", "m" }); + consonants.put('k', new String[] { "n", "m" }); + consonants.put('l', new String[] { "n", "m" }); + consonants.put('m', new String[] { "m", "mm" }); + consonants.put('n', new String[] { "n", "nn" }); + consonants.put('p', new String[] { "m", "mph" }); + consonants.put('q', new String[] { "n", "m" }); + consonants.put('r', new String[] { "n", "m" }); + consonants.put('s', new String[] { "s", "m" }); + consonants.put('t', new String[] { "n", "m" }); + consonants.put('v', new String[] { "m", "f" }); + consonants.put('w', new String[] { "m", "u" }); + consonants.put('x', new String[] { "n", "m" }); + consonants.put('z', new String[] { "n", "m" }); + CONSONANT_MAPS.put(GagMaterial.PANEL, consonants); + + Map vowels = new HashMap<>(); + vowels.put('a', new String[] { "m", "ah" }); + vowels.put('e', new String[] { "n", "m" }); + vowels.put('i', new String[] { "n", "m" }); + vowels.put('o', new String[] { "m", "oh" }); + vowels.put('u', new String[] { "m", "u" }); + vowels.put('y', new String[] { "n", "m" }); + VOWEL_MAPS.put(GagMaterial.PANEL, vowels); + } + + private static void initializeLatexMappings() { + Map consonants = new HashMap<>(); + // Latex - tight seal, rubber sounds + consonants.put('b', new String[] { "m", "mm" }); + consonants.put('c', new String[] { "n", "m" }); + consonants.put('d', new String[] { "n", "nn" }); + consonants.put('f', new String[] { "h", "f" }); + consonants.put('g', new String[] { "ng", "m" }); + consonants.put('h', new String[] { "h", "hh" }); + consonants.put('j', new String[] { "n", "m" }); + consonants.put('k', new String[] { "n", "m" }); + consonants.put('l', new String[] { "n", "m" }); + consonants.put('m', new String[] { "m", "mm" }); + consonants.put('n', new String[] { "n", "nn" }); + consonants.put('p', new String[] { "m", "mm" }); + consonants.put('q', new String[] { "n", "m" }); + consonants.put('r', new String[] { "n", "m" }); + consonants.put('s', new String[] { "s", "h" }); + consonants.put('t', new String[] { "n", "nn" }); + consonants.put('v', new String[] { "f", "m" }); + consonants.put('w', new String[] { "m", "u" }); + consonants.put('x', new String[] { "n", "m" }); + consonants.put('z', new String[] { "s", "m" }); + CONSONANT_MAPS.put(GagMaterial.LATEX, consonants); + + Map vowels = new HashMap<>(); + vowels.put('a', new String[] { "u", "a" }); + vowels.put('e', new String[] { "u", "e" }); + vowels.put('i', new String[] { "u", "i" }); + vowels.put('o', new String[] { "u", "o" }); + vowels.put('u', new String[] { "u", "uu" }); + vowels.put('y', new String[] { "u", "i" }); + VOWEL_MAPS.put(GagMaterial.LATEX, vowels); + } + + private static void initializeRingMappings() { + Map consonants = new HashMap<>(); + // Ring gag - mouth forced open, tongue partially free + consonants.put('b', new String[] { "b", "bh" }); + consonants.put('c', new String[] { "k", "kh" }); + consonants.put('d', new String[] { "d", "dh" }); + consonants.put('f', new String[] { "f", "fh" }); + consonants.put('g', new String[] { "g", "gh" }); + consonants.put('h', new String[] { "h", "ah" }); + consonants.put('j', new String[] { "j", "zh" }); + consonants.put('k', new String[] { "k", "kh" }); + consonants.put('l', new String[] { "l", "lh" }); + consonants.put('m', new String[] { "m", "mh" }); + consonants.put('n', new String[] { "n", "nh" }); + consonants.put('p', new String[] { "p", "ph" }); + consonants.put('q', new String[] { "k", "kh" }); + consonants.put('r', new String[] { "r", "rh" }); + consonants.put('s', new String[] { "s", "sh" }); + consonants.put('t', new String[] { "t", "th" }); + consonants.put('v', new String[] { "v", "vh" }); + consonants.put('w', new String[] { "w", "wh" }); + consonants.put('x', new String[] { "ks", "kh" }); + consonants.put('z', new String[] { "z", "zh" }); + CONSONANT_MAPS.put(GagMaterial.RING, consonants); + + Map vowels = new HashMap<>(); + // Ring forces mouth open - vowels become "a/ah" + vowels.put('a', new String[] { "a", "ah" }); + vowels.put('e', new String[] { "eh", "a" }); + vowels.put('i', new String[] { "ih", "a" }); + vowels.put('o', new String[] { "oh", "a" }); + vowels.put('u', new String[] { "uh", "a" }); + vowels.put('y', new String[] { "ih", "a" }); + VOWEL_MAPS.put(GagMaterial.RING, vowels); + } + + private static void initializeBiteMappings() { + Map consonants = new HashMap<>(); + // Bite gag - teeth clenched on bar + consonants.put('b', new String[] { "bh", "ph" }); + consonants.put('c', new String[] { "kh", "gh" }); + consonants.put('d', new String[] { "dh", "th" }); + consonants.put('f', new String[] { "fh", "f" }); + consonants.put('g', new String[] { "gh", "ng" }); + consonants.put('h', new String[] { "h", "hh" }); + consonants.put('j', new String[] { "jh", "zh" }); + consonants.put('k', new String[] { "kh", "gh" }); + consonants.put('l', new String[] { "lh", "hl" }); + consonants.put('m', new String[] { "m", "mh" }); + consonants.put('n', new String[] { "n", "nh" }); + consonants.put('p', new String[] { "ph", "bh" }); + consonants.put('q', new String[] { "kh", "gh" }); + consonants.put('r', new String[] { "rh", "hr" }); + consonants.put('s', new String[] { "sh", "s" }); + consonants.put('t', new String[] { "th", "dh" }); + consonants.put('v', new String[] { "vh", "fh" }); + consonants.put('w', new String[] { "wh", "uh" }); + consonants.put('x', new String[] { "ksh", "kh" }); + consonants.put('z', new String[] { "zh", "sh" }); + CONSONANT_MAPS.put(GagMaterial.BITE, consonants); + + Map vowels = new HashMap<>(); + vowels.put('a', new String[] { "eh", "ah" }); + vowels.put('e', new String[] { "eh", "e" }); + vowels.put('i', new String[] { "ih", "eh" }); + vowels.put('o', new String[] { "oh", "eh" }); + vowels.put('u', new String[] { "uh", "eh" }); + vowels.put('y', new String[] { "ih", "eh" }); + VOWEL_MAPS.put(GagMaterial.BITE, vowels); + } + + private static void initializeSpongeMappings() { + // Sponge - absorbs almost all sound + Map consonants = new HashMap<>(); + for (char c = 'a'; c <= 'z'; c++) { + if (!VOWELS.contains(c)) { + consonants.put(c, new String[] { "mm", "" }); + } + } + CONSONANT_MAPS.put(GagMaterial.SPONGE, consonants); + + Map vowels = new HashMap<>(); + for (char c : VOWELS) { + vowels.put(c, new String[] { "mm", "" }); + } + VOWEL_MAPS.put(GagMaterial.SPONGE, vowels); + } + + private static void initializeBaguetteMappings() { + Map consonants = new HashMap<>(); + // Baguette - comedic, food-blocked + consonants.put('b', new String[] { "bm", "mm" }); + consonants.put('c', new String[] { "km", "gm" }); + consonants.put('d', new String[] { "dm", "nm" }); + consonants.put('f', new String[] { "fm", "hm" }); + consonants.put('g', new String[] { "gm", "ngm" }); + consonants.put('h', new String[] { "hm", "h" }); + consonants.put('j', new String[] { "jm", "zhm" }); + consonants.put('k', new String[] { "km", "gm" }); + consonants.put('l', new String[] { "lm", "mm" }); + consonants.put('m', new String[] { "mm", "m" }); + consonants.put('n', new String[] { "nm", "n" }); + consonants.put('p', new String[] { "pm", "mm" }); + consonants.put('q', new String[] { "km", "gm" }); + consonants.put('r', new String[] { "rm", "mm" }); + consonants.put('s', new String[] { "sm", "shm" }); + consonants.put('t', new String[] { "tm", "nm" }); + consonants.put('v', new String[] { "vm", "fm" }); + consonants.put('w', new String[] { "wm", "um" }); + consonants.put('x', new String[] { "ksm", "km" }); + consonants.put('z', new String[] { "zm", "sm" }); + CONSONANT_MAPS.put(GagMaterial.BAGUETTE, consonants); + + Map vowels = new HashMap<>(); + vowels.put('a', new String[] { "am", "om" }); + vowels.put('e', new String[] { "em", "um" }); + vowels.put('i', new String[] { "im", "um" }); + vowels.put('o', new String[] { "om", "o" }); + vowels.put('u', new String[] { "um", "u" }); + vowels.put('y', new String[] { "im", "um" }); + VOWEL_MAPS.put(GagMaterial.BAGUETTE, vowels); + } + + /** + * Map a single phoneme to its muffled equivalent. + * + * @param c The original character + * @param material The gag material + * @param bleedChance Chance (0-1) that the original sound passes through + * @return The muffled phoneme + */ + public static String mapPhoneme( + char c, + GagMaterial material, + float bleedChance + ) { + char lower = Character.toLowerCase(c); + + // Non-alphabetic characters pass through + if (!Character.isLetter(c)) { + return String.valueOf(c); + } + + // Bleed-through check: original sound passes + if (RANDOM.nextFloat() < bleedChance) { + return String.valueOf(c); + } + + // Get appropriate map + Map map = isVowel(lower) + ? VOWEL_MAPS.get(material) + : CONSONANT_MAPS.get(material); + + if (map == null) { + return String.valueOf(c); + } + + String[] options = map.get(lower); + if (options == null || options.length == 0) { + // Default fallback + return isVowel(lower) ? "mm" : "nn"; + } + + // Pick a random option + String result = options[RANDOM.nextInt(options.length)]; + + // Preserve case for first character + if (Character.isUpperCase(c) && !result.isEmpty()) { + return ( + Character.toUpperCase(result.charAt(0)) + result.substring(1) + ); + } + + return result; + } + + /** + * Check if a character is a vowel. + */ + public static boolean isVowel(char c) { + return VOWELS.contains(Character.toLowerCase(c)); + } + + /** + * Check if a character is a plosive consonant. + */ + public static boolean isPlosive(char c) { + return PLOSIVES.contains(Character.toLowerCase(c)); + } + + /** + * Check if a character is a nasal consonant. + */ + public static boolean isNasal(char c) { + return NASALS.contains(Character.toLowerCase(c)); + } + + /** + * Check if a character is a fricative consonant. + */ + public static boolean isFricative(char c) { + return FRICATIVES.contains(Character.toLowerCase(c)); + } + + /** + * Check if a character is a liquid consonant. + */ + public static boolean isLiquid(char c) { + return LIQUIDS.contains(Character.toLowerCase(c)); + } + + /** + * Check if a character can potentially bleed through for a given material. + * Nasals have higher bleed-through, plosives have lower. + */ + public static float getBleedModifier(char c, GagMaterial material) { + char lower = Character.toLowerCase(c); + + // Nasals almost always pass through + if (isNasal(lower)) { + return 2.0f; + } + + // Plosives are harder to pronounce with most gags + if (isPlosive(lower)) { + return material == GagMaterial.RING ? 1.5f : 0.3f; + } + + // Fricatives depend on whether air can escape + if (isFricative(lower)) { + return ( + material == GagMaterial.TAPE || + material == GagMaterial.STUFFED + ) + ? 0.1f + : 0.8f; + } + + // Liquids depend on tongue freedom + if (isLiquid(lower)) { + return ( + material == GagMaterial.RING || material == GagMaterial.BITE + ) + ? 1.2f + : 0.2f; + } + + return 1.0f; + } +} diff --git a/src/main/java/com/tiedup/remake/util/RestraintApplicator.java b/src/main/java/com/tiedup/remake/util/RestraintApplicator.java new file mode 100644 index 0000000..8fc3973 --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/RestraintApplicator.java @@ -0,0 +1,349 @@ +package com.tiedup.remake.util; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.items.base.IHasResistance; +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.state.IBondageState; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; + +/** + * Utility class for applying restraints to entities. + * + * Phase 3: Refactoring - Centralizes restraint application logic + * + * This class provides methods for applying various restraints (binds, gags, + * blindfolds, etc.) to targets with consistent validation. Used by: + * - KidnapperCaptureGoal + * - KidnapperPunishGoal + * - EntityRopeArrow + * - Other capture/restraint mechanics + */ +public final class RestraintApplicator { + + private RestraintApplicator() { + // Utility class - no instantiation + } + + // ==================== BIND ==================== + + /** + * Apply a bind or tighten existing binds. + * + * @param target The target entity + * @param bind The bind item to apply if needed + * @return true if bind was applied or tightened + */ + public static boolean applyOrTightenBind( + LivingEntity target, + ItemStack bind + ) { + IBondageState state = KidnappedHelper.getKidnappedState(target); + if (state == null) { + return false; + } + + ItemStack currentBind = state.getEquipment(BodyRegionV2.ARMS); + if (currentBind.isEmpty()) { + // No bind - apply new one + if (bind != null && !bind.isEmpty()) { + state.equip(BodyRegionV2.ARMS, bind.copy()); + return true; + } + return false; + } + + // Tighten existing bind - reset resistance to max + if (currentBind.getItem() instanceof ItemBind bindItem) { + int maxResistance = bindItem.getBaseResistance(target); + state.setCurrentBindResistance(maxResistance); + return true; + } + + return false; + } + + /** + * Tighten an entity's binds, resetting resistance to max. + * This happens when a guard catches someone struggling. + * + *

Consolidated from EntityKidnapper, EntityMaid, EntitySlaveTrader, KidnapperPunishGoal.

+ * + * @param state The prisoner's kidnapped state + * @param prisoner The prisoner entity + * @return true if binds were tightened, false if no binds equipped + */ + public static boolean tightenBind( + IBondageState state, + LivingEntity prisoner + ) { + if (state == null) return false; + + ItemStack bind = state.getEquipment(BodyRegionV2.ARMS); + if (bind.isEmpty()) { + return false; + } + + if (bind.getItem() instanceof IHasResistance resistItem) { + int baseResistance = resistItem.getBaseResistance(prisoner); + resistItem.setCurrentResistance(bind, baseResistance); + + // Notify the prisoner + if (prisoner instanceof ServerPlayer serverPlayer) { + serverPlayer.sendSystemMessage( + Component.literal( + "Your restraints have been tightened!" + ).withStyle(ChatFormatting.RED) + ); + } + + TiedUpMod.LOGGER.debug( + "[RestraintApplicator] Tightened {}'s binds (resistance reset to {})", + prisoner.getName().getString(), + baseResistance + ); + return true; + } + + return false; + } + + // ==================== GAG ==================== + + /** + * Apply a gag to the target. + * + * @param target The target entity + * @param gag The gag item to apply + * @return true if successfully applied, false if already gagged or invalid + */ + public static boolean applyGag(LivingEntity target, ItemStack gag) { + IBondageState state = KidnappedHelper.getKidnappedState(target); + if (state == null || gag == null || gag.isEmpty()) { + return false; + } + if (state.isGagged()) { + return false; // Already gagged + } + + state.equip(BodyRegionV2.MOUTH, gag.copy()); + return true; + } + + /** + * Apply a gag only if the target doesn't have one. + * + * @param target The target entity + * @param gag The gag item to apply + * @return true if gag was applied + */ + public static boolean applyGagIfMissing( + LivingEntity target, + ItemStack gag + ) { + IBondageState state = KidnappedHelper.getKidnappedState(target); + if (state == null || gag == null || gag.isEmpty()) { + return false; + } + + ItemStack currentGag = state.getEquipment(BodyRegionV2.MOUTH); + if (!currentGag.isEmpty()) { + return false; // Already has gag + } + + state.equip(BodyRegionV2.MOUTH, gag.copy()); + return true; + } + + // ==================== BLINDFOLD ==================== + + /** + * Apply a blindfold to the target. + * + * @param target The target entity + * @param blindfold The blindfold item to apply + * @return true if successfully applied, false if already blindfolded or invalid + */ + public static boolean applyBlindfold( + LivingEntity target, + ItemStack blindfold + ) { + IBondageState state = KidnappedHelper.getKidnappedState(target); + if (state == null || blindfold == null || blindfold.isEmpty()) { + return false; + } + if (state.isBlindfolded()) { + return false; // Already blindfolded + } + + state.equip(BodyRegionV2.EYES, blindfold.copy()); + return true; + } + + /** + * Apply a blindfold only if the target doesn't have one. + * + * @param target The target entity + * @param blindfold The blindfold item to apply + * @return true if blindfold was applied + */ + public static boolean applyBlindfoldIfMissing( + LivingEntity target, + ItemStack blindfold + ) { + IBondageState state = KidnappedHelper.getKidnappedState(target); + if (state == null || blindfold == null || blindfold.isEmpty()) { + return false; + } + + ItemStack currentBlindfold = state.getEquipment(BodyRegionV2.EYES); + if (!currentBlindfold.isEmpty()) { + return false; // Already has blindfold + } + + state.equip(BodyRegionV2.EYES, blindfold.copy()); + return true; + } + + // ==================== MITTENS ==================== + + /** + * Apply mittens to the target. + * + * @param target The target entity + * @param mittens The mittens item to apply + * @return true if successfully applied, false if already has mittens or invalid + */ + public static boolean applyMittens(LivingEntity target, ItemStack mittens) { + IBondageState state = KidnappedHelper.getKidnappedState(target); + if (state == null || mittens == null || mittens.isEmpty()) { + return false; + } + if (state.hasMittens()) { + return false; // Already has mittens + } + + state.equip(BodyRegionV2.HANDS, mittens.copy()); + return true; + } + + // ==================== EARPLUGS ==================== + + /** + * Apply earplugs to the target. + * + * @param target The target entity + * @param earplugs The earplugs item to apply + * @return true if successfully applied, false if already has earplugs or invalid + */ + public static boolean applyEarplugs( + LivingEntity target, + ItemStack earplugs + ) { + IBondageState state = KidnappedHelper.getKidnappedState(target); + if (state == null || earplugs == null || earplugs.isEmpty()) { + return false; + } + if (state.hasEarplugs()) { + return false; // Already has earplugs + } + + state.equip(BodyRegionV2.EARS, earplugs.copy()); + return true; + } + + // ==================== COLLAR ==================== + + /** + * Apply a collar to the target with owner information. + * + * @param target The target entity + * @param collar The collar item to apply + * @param ownerUUID The owner's UUID (can be null) + * @param ownerName The owner's name (can be null) + * @return true if successfully applied, false if already has collar or invalid + */ + public static boolean applyCollar( + LivingEntity target, + ItemStack collar, + @Nullable UUID ownerUUID, + @Nullable String ownerName + ) { + IBondageState state = KidnappedHelper.getKidnappedState(target); + if (state == null || collar == null || collar.isEmpty()) { + return false; + } + if (state.hasCollar()) { + return false; // Already has collar + } + + ItemStack collarCopy = collar.copy(); + + // Add owner if provided + if ( + ownerUUID != null && + collarCopy.getItem() instanceof ItemCollar collarItem + ) { + collarItem.addOwner( + collarCopy, + ownerUUID, + ownerName != null ? ownerName : "Unknown" + ); + } + + state.equip(BodyRegionV2.NECK, collarCopy); + return true; + } + + /** + * Apply a collar without owner information. + * + * @param target The target entity + * @param collar The collar item to apply + * @return true if successfully applied + */ + public static boolean applyCollar(LivingEntity target, ItemStack collar) { + return applyCollar(target, collar, null, null); + } + + // ==================== BULK OPERATIONS ==================== + + /** + * Check if target has any restraints. + * + * @param target The target entity + * @return true if target has any restraints + */ + public static boolean hasAnyRestraint(LivingEntity target) { + IBondageState state = KidnappedHelper.getKidnappedState(target); + if (state == null) { + return false; + } + return ( + state.isTiedUp() || + state.isGagged() || + state.isBlindfolded() || + state.hasMittens() || + state.hasEarplugs() || + state.hasCollar() + ); + } + + /** + * Get the kidnapped state for an entity. + * + * @param target The target entity + * @return The IBondageState state, or null if not available + */ + @Nullable + public static IBondageState getState(LivingEntity target) { + return KidnappedHelper.getKidnappedState(target); + } +} diff --git a/src/main/java/com/tiedup/remake/util/RestraintEffectUtils.java b/src/main/java/com/tiedup/remake/util/RestraintEffectUtils.java new file mode 100644 index 0000000..a981a0d --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/RestraintEffectUtils.java @@ -0,0 +1,447 @@ +package com.tiedup.remake.util; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.IPlayerLeashAccess; +import java.util.UUID; +import org.jetbrains.annotations.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.ai.attributes.AttributeInstance; +import net.minecraft.world.entity.ai.attributes.AttributeModifier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.decoration.LeashFenceKnotEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.FenceBlock; +import net.minecraft.world.level.block.WallBlock; +import net.minecraft.world.level.block.state.BlockState; + +/** + * Phase 5: Utility class for applying/removing restraint effects + * + * Manages attribute modifiers for movement speed reduction and other effects. + * Phase 14.1: Refactored to support LivingEntity (Player + NPCs) + */ +public class RestraintEffectUtils { + + // UUID for the movement speed modifier (must be consistent) + private static final UUID BIND_SPEED_MODIFIER_UUID = UUID.fromString( + "7f3c7c8e-9d4e-4c7a-8e5f-1a2b3c4d5e6f" + ); + private static final String BIND_SPEED_MODIFIER_NAME = "tiedup.bind_speed"; + + // Speed reduction: -0.09 (90% reduction) when tied up + // Player base speed is 0.10, so this reduces them to 0.01 (10% speed) + private static final double BIND_SPEED_REDUCTION = -0.09; + + // Full immobilization: -0.10 (100% reduction) for WRAP/LATEX_SACK + // Player can only move by jumping + private static final double FULL_IMMOBILIZATION_REDUCTION = -0.10; + + private static final boolean DEBUG = false; + + /** + * Apply movement speed reduction to a tied entity. + * + * @param entity The living entity to apply the effect to + * @deprecated For players, use {@link com.tiedup.remake.v2.bondage.movement.MovementStyleManager} + * which handles speed via tick-based resolution. This method remains for NPC entities + * (Damsel, MCA villagers) that are not managed by MovementStyleManager. + * Scheduled for removal once NPC movement is migrated to V2. + */ + @Deprecated(forRemoval = true) + public static void applyBindSpeedReduction(LivingEntity entity) { + applyBindSpeedReduction(entity, false); + } + + /** + * Apply movement speed reduction to a tied entity. + * + * @param entity The living entity to apply the effect to + * @param fullImmobilization If true, applies 100% speed reduction (for WRAP/LATEX_SACK) + * @deprecated For players, use {@link com.tiedup.remake.v2.bondage.movement.MovementStyleManager} + * which handles speed via tick-based resolution. This method remains for NPC entities + * (Damsel, MCA villagers) that are not managed by MovementStyleManager. + * Scheduled for removal once NPC movement is migrated to V2. + */ + @Deprecated(forRemoval = true) + public static void applyBindSpeedReduction( + LivingEntity entity, + boolean fullImmobilization + ) { + if (entity == null) { + TiedUpMod.LOGGER.warn( + "[RESTRAINT-UTIL] Cannot apply speed reduction - entity is null" + ); + return; + } + + AttributeInstance movementSpeed = entity.getAttribute( + Attributes.MOVEMENT_SPEED + ); + if (movementSpeed == null) { + TiedUpMod.LOGGER.error( + "[RESTRAINT-UTIL] Entity {} has no MOVEMENT_SPEED attribute!", + entity.getName().getString() + ); + return; + } + + // Remove existing modifier if present (to avoid duplicates) + removeBindSpeedReduction(entity); + + // Choose reduction amount based on immobilization type + double reduction = fullImmobilization + ? FULL_IMMOBILIZATION_REDUCTION + : BIND_SPEED_REDUCTION; + + // Create and apply new modifier + AttributeModifier modifier = new AttributeModifier( + BIND_SPEED_MODIFIER_UUID, + BIND_SPEED_MODIFIER_NAME, + reduction, + AttributeModifier.Operation.ADDITION + ); + + movementSpeed.addPermanentModifier(modifier); + + if (DEBUG) { + TiedUpMod.LOGGER.info( + "[RESTRAINT-UTIL] Applied speed reduction to {} (base: {}, modified: {}, full: {})", + entity.getName().getString(), + movementSpeed.getBaseValue(), + movementSpeed.getValue(), + fullImmobilization + ); + } + } + + /** + * Remove movement speed reduction from an entity. + * + * @param entity The living entity to remove the effect from + * @deprecated For players, use {@link com.tiedup.remake.v2.bondage.movement.MovementStyleManager} + * which handles speed cleanup via tick-based resolution. This method remains for + * NPC entities (Damsel, MCA villagers) that are not managed by MovementStyleManager. + * Scheduled for removal once NPC movement is migrated to V2. + */ + @Deprecated(forRemoval = true) + public static void removeBindSpeedReduction(LivingEntity entity) { + if (entity == null) { + TiedUpMod.LOGGER.warn( + "[RESTRAINT-UTIL] Cannot remove speed reduction - entity is null" + ); + return; + } + + AttributeInstance movementSpeed = entity.getAttribute( + Attributes.MOVEMENT_SPEED + ); + if (movementSpeed == null) { + TiedUpMod.LOGGER.error( + "[RESTRAINT-UTIL] Entity {} has no MOVEMENT_SPEED attribute!", + entity.getName().getString() + ); + return; + } + + // Remove modifier if present + if (movementSpeed.getModifier(BIND_SPEED_MODIFIER_UUID) != null) { + movementSpeed.removeModifier(BIND_SPEED_MODIFIER_UUID); + + if (DEBUG) { + TiedUpMod.LOGGER.info( + "[RESTRAINT-UTIL] Removed speed reduction from {} (restored speed: {})", + entity.getName().getString(), + movementSpeed.getValue() + ); + } + } else { + if (DEBUG) { + TiedUpMod.LOGGER.debug( + "[RESTRAINT-UTIL] No speed modifier found on {} (already removed or never applied)", + entity.getName().getString() + ); + } + } + } + + /** + * Check if an entity currently has the bind speed reduction applied. + * + * @param entity The living entity to check + * @return true if the modifier is active + * @deprecated For players, movement style is tracked by {@link com.tiedup.remake.v2.bondage.movement.MovementStyleManager}. + * Scheduled for removal once NPC movement is migrated to V2. + */ + @Deprecated(forRemoval = true) + public static boolean hasBindSpeedReduction(LivingEntity entity) { + if (entity == null) return false; + + AttributeInstance movementSpeed = entity.getAttribute( + Attributes.MOVEMENT_SPEED + ); + if (movementSpeed == null) return false; + + return movementSpeed.getModifier(BIND_SPEED_MODIFIER_UUID) != null; + } + + /** + * Re-apply speed reduction if needed (called on login/respawn). + * + * @param entity The living entity + * @param shouldBeSlowed Whether the entity should have reduced speed + * @deprecated For players, use {@link com.tiedup.remake.v2.bondage.movement.MovementStyleManager} + * which re-resolves on every tick. Scheduled for removal once NPC movement is migrated to V2. + */ + @Deprecated(forRemoval = true) + public static void updateBindSpeedReduction( + LivingEntity entity, + boolean shouldBeSlowed + ) { + updateBindSpeedReduction(entity, shouldBeSlowed, false); + } + + /** + * Re-apply speed reduction if needed (called on login/respawn). + * + * @param entity The living entity + * @param shouldBeSlowed Whether the entity should have reduced speed + * @param fullImmobilization If true, applies 100% speed reduction (for WRAP/LATEX_SACK) + * @deprecated For players, use {@link com.tiedup.remake.v2.bondage.movement.MovementStyleManager} + * which re-resolves on every tick. Scheduled for removal once NPC movement is migrated to V2. + */ + @Deprecated(forRemoval = true) + public static void updateBindSpeedReduction( + LivingEntity entity, + boolean shouldBeSlowed, + boolean fullImmobilization + ) { + boolean currentlySlowed = hasBindSpeedReduction(entity); + + if (shouldBeSlowed && !currentlySlowed) { + // Need to apply + applyBindSpeedReduction(entity, fullImmobilization); + if (DEBUG) { + TiedUpMod.LOGGER.info( + "[RESTRAINT-UTIL] Re-applied speed reduction to {} on login/respawn (full={})", + entity.getName().getString(), + fullImmobilization + ); + } + } else if (shouldBeSlowed && currentlySlowed) { + // Already slowed - but might need to change immobilization level + // Re-apply with correct level + applyBindSpeedReduction(entity, fullImmobilization); + } else if (!shouldBeSlowed && currentlySlowed) { + // Need to remove + removeBindSpeedReduction(entity); + if (DEBUG) { + TiedUpMod.LOGGER.info( + "[RESTRAINT-UTIL] Removed speed reduction from {} on login/respawn", + entity.getName().getString() + ); + } + } + } + + // ======================================== + // POLE BINDING UTILITIES + // ======================================== + + /** + * Find the closest fence or wall block within a radius. + * + * @param level The level to search in + * @param center The center position to search from + * @param radius The search radius in blocks + * @return The position of the closest fence/wall, or null if none found + */ + @Nullable + public static BlockPos findClosestFence( + Level level, + BlockPos center, + int radius + ) { + if (level == null || center == null) return null; + + BlockPos closestFence = null; + double closestDistance = Double.MAX_VALUE; + + for (int x = -radius; x <= radius; x++) { + for (int y = -radius; y <= radius; y++) { + for (int z = -radius; z <= radius; z++) { + BlockPos checkPos = center.offset(x, y, z); + BlockState state = level.getBlockState(checkPos); + + if ( + state.getBlock() instanceof FenceBlock || + state.getBlock() instanceof WallBlock + ) { + double dist = center.distSqr(checkPos); + if (dist < closestDistance) { + closestDistance = dist; + closestFence = checkPos; + } + } + } + } + } + + return closestFence; + } + + /** + * Tie an entity to the closest fence or wall block. + * Works for both Players (via LeashProxy) and NPCs (via vanilla leash). + * + * @param entity The entity to tie + * @param searchRadius The search radius for fence blocks + * @return true if successfully tied, false otherwise + */ + public static boolean tieToClosestPole( + LivingEntity entity, + int searchRadius + ) { + if (entity == null || entity.level().isClientSide) return false; + if (!(entity.level() instanceof ServerLevel serverLevel)) return false; + + BlockPos entityPos = entity.blockPosition(); + BlockPos closestFence = findClosestFence( + serverLevel, + entityPos, + searchRadius + ); + + if (closestFence == null) { + if (DEBUG) { + TiedUpMod.LOGGER.debug( + "[RESTRAINT-UTIL] No fence found within {} blocks of {}", + searchRadius, + entity.getName().getString() + ); + } + return false; + } + + // Get or create a LeashFenceKnotEntity at the fence position + LeashFenceKnotEntity fenceKnot = LeashFenceKnotEntity.getOrCreateKnot( + serverLevel, + closestFence + ); + + if (fenceKnot == null) { + TiedUpMod.LOGGER.warn( + "[RESTRAINT-UTIL] Failed to create fence knot at {}", + closestFence + ); + return false; + } + + // Handle differently based on entity type + if (entity instanceof Player player) { + // Player: use LeashProxy system + if (player instanceof IPlayerLeashAccess access) { + access.tiedup$attachLeash(fenceKnot); + TiedUpMod.LOGGER.debug( + "[RESTRAINT-UTIL] Tied player {} to pole at {}", + player.getName().getString(), + closestFence + ); + return true; + } else { + TiedUpMod.LOGGER.error( + "[RESTRAINT-UTIL] Player {} does not implement IPlayerLeashAccess!", + player.getName().getString() + ); + return false; + } + } else if (entity instanceof Mob mob) { + // NPC (Mob): use vanilla leash mechanics + mob.setLeashedTo(fenceKnot, true); + TiedUpMod.LOGGER.debug( + "[RESTRAINT-UTIL] Tied mob {} to pole at {}", + mob.getName().getString(), + closestFence + ); + return true; + } + + return false; + } + + // ======================================== + // CHLOROFORM UTILITIES + // ======================================== + + /** + * Apply chloroform effects to an entity. + * Effects: Slowness, Mining Fatigue, Blindness, Jump Boost (all at max amplifier). + * + * @param entity The entity to affect + * @param durationSeconds Duration in seconds + */ + public static void applyChloroformEffects( + LivingEntity entity, + int durationSeconds + ) { + if (entity == null || entity.level().isClientSide) return; + + int tickDuration = durationSeconds * GameConstants.TICKS_PER_SECOND; + + entity.addEffect( + new MobEffectInstance( + MobEffects.MOVEMENT_SLOWDOWN, + tickDuration, + GameConstants.CHLOROFORM_SLOWDOWN_AMPLIFIER, + false, + false + ) + ); + entity.addEffect( + new MobEffectInstance( + MobEffects.DIG_SLOWDOWN, + tickDuration, + GameConstants.CHLOROFORM_DIG_SLOWDOWN_AMPLIFIER, + false, + false + ) + ); + entity.addEffect( + new MobEffectInstance( + MobEffects.BLINDNESS, + tickDuration, + GameConstants.CHLOROFORM_BLINDNESS_AMPLIFIER, + false, + false + ) + ); + entity.addEffect( + new MobEffectInstance( + MobEffects.JUMP, + tickDuration, + GameConstants.CHLOROFORM_JUMP_AMPLIFIER, + false, + false + ) + ); + + // Stop navigation for mobs + if (entity instanceof Mob mob) { + mob.getNavigation().stop(); + } + + if (DEBUG) { + TiedUpMod.LOGGER.debug( + "[RESTRAINT-UTIL] Applied chloroform to {} for {} seconds", + entity.getName().getString(), + durationSeconds + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/util/RotationSmoother.java b/src/main/java/com/tiedup/remake/util/RotationSmoother.java new file mode 100644 index 0000000..3fa13c9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/RotationSmoother.java @@ -0,0 +1,115 @@ +package com.tiedup.remake.util; + +import net.minecraft.util.Mth; + +/** + * Utility class for smoothing rotation values. + * + *

Provides smooth interpolation between rotation angles, properly handling + * wraparound at +/-180 degrees. This prevents sudden jumps when rotating past the + * 180/-180 boundary. + * + *

Used by: + *

    + *
  • EntityDamsel - smoothing body Y rotation in DOG pose
  • + *
  • PlayerArmHideEventHandler - smoothing player body Y rotation in DOG pose
  • + *
+ * + *

Example usage: + *

+ * RotationSmoother smoother = new RotationSmoother();
+ * // In tick():
+ * float smoothedRot = smoother.smooth(targetRotation, 0.1f);
+ * entity.yBodyRot = smoothedRot;
+ * 
+ */ +public class RotationSmoother { + + /** Current smoothed rotation value. */ + private float current; + + /** Whether the smoother has been initialized with an initial value. */ + private boolean initialized = false; + + /** + * Create a new rotation smoother. + */ + public RotationSmoother() {} + + /** + * Create a rotation smoother with an initial value. + * + * @param initialValue Initial rotation value in degrees + */ + public RotationSmoother(float initialValue) { + this.current = initialValue; + this.initialized = true; + } + + /** + * Smooth rotation towards target. + * + *

Interpolates from current rotation towards target using the given speed. + * Properly handles wraparound at +/-180 degrees using {@link Mth#wrapDegrees}. + * + * @param target Target rotation in degrees + * @param speed Smoothing speed (0.0 = no change, 1.0 = instant snap) + * Typical values: 0.1 (slow), 0.2 (medium), 0.3 (fast) + * @return Smoothed rotation value in degrees + */ + public float smooth(float target, float speed) { + if (!initialized) { + // First call: snap to target + current = target; + initialized = true; + return current; + } + + // Calculate delta with wraparound handling + float delta = Mth.wrapDegrees(target - current); + + // Apply smoothing + current += delta * speed; + + return current; + } + + /** + * Get current smoothed rotation value. + * + * @return Current rotation in degrees + */ + public float getCurrent() { + return current; + } + + /** + * Set current rotation directly (no smoothing). + * + *

Use this to reset the smoother or initialize it to a specific value. + * + * @param value Rotation value in degrees + */ + public void setCurrent(float value) { + this.current = value; + this.initialized = true; + } + + /** + * Reset the smoother to uninitialized state. + * + *

The next call to {@link #smooth} will snap to the target value. + */ + public void reset() { + this.initialized = false; + } + + /** + * Check if the smoother has been initialized. + * + * @return true if initialized, false otherwise + */ + public boolean isInitialized() { + return initialized; + } +} diff --git a/src/main/java/com/tiedup/remake/util/SyllableAnalyzer.java b/src/main/java/com/tiedup/remake/util/SyllableAnalyzer.java new file mode 100644 index 0000000..e809d28 --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/SyllableAnalyzer.java @@ -0,0 +1,226 @@ +package com.tiedup.remake.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * Analyzes word structure to preserve rhythm in gagged speech. + */ +public class SyllableAnalyzer { + + private static final Set VOWELS = Set.of( + 'a', + 'e', + 'i', + 'o', + 'u', + 'y' + ); + + /** + * Count the number of syllables in a word. + * + * @param word The word to analyze + * @return The number of syllables (minimum 1) + */ + public static int countSyllables(String word) { + if (word == null || word.isEmpty()) { + return 0; + } + + String cleanWord = word.toLowerCase().replaceAll("[^a-z]", ""); + if (cleanWord.isEmpty()) { + return 1; + } + + int count = 0; + boolean prevVowel = false; + + for (int i = 0; i < cleanWord.length(); i++) { + boolean isVowel = VOWELS.contains(cleanWord.charAt(i)); + + if (isVowel && !prevVowel) { + count++; + } + prevVowel = isVowel; + } + + // Handle silent 'e' at end (common in English/French) + if (cleanWord.length() > 2 && cleanWord.endsWith("e") && count > 1) { + char beforeE = cleanWord.charAt(cleanWord.length() - 2); + // Silent 'e' after consonant + if (!VOWELS.contains(beforeE) && !cleanWord.endsWith("le")) { + count--; + } + } + + return Math.max(1, count); + } + + /** + * Split a word into approximate syllables. + * This is a simplified algorithm that works for most Western languages. + * + * @param word The word to split + * @return List of syllables + */ + public static List splitIntoSyllables(String word) { + List syllables = new ArrayList<>(); + + if (word == null || word.isEmpty()) { + return syllables; + } + + // Preserve original case and non-letter chars + StringBuilder current = new StringBuilder(); + boolean prevVowel = false; + int vowelGroups = 0; + + for (int i = 0; i < word.length(); i++) { + char c = word.charAt(i); + char lower = Character.toLowerCase(c); + boolean isVowel = VOWELS.contains(lower); + boolean isLetter = Character.isLetter(c); + + if (!isLetter) { + // Non-letter characters stick with current syllable + current.append(c); + continue; + } + + // Starting a new vowel group after consonants = potential syllable break + if ( + isVowel && !prevVowel && vowelGroups > 0 && current.length() > 0 + ) { + // Check if we should split before this vowel + // Split if we have at least 2 consonants between vowels + int consonantCount = countTrailingConsonants( + current.toString() + ); + if (consonantCount >= 2) { + // Keep one consonant with previous syllable, rest go with new + String syllable = current.substring( + 0, + current.length() - consonantCount + 1 + ); + String carry = current.substring( + current.length() - consonantCount + 1 + ); + if (!syllable.isEmpty()) { + syllables.add(syllable); + } + current = new StringBuilder(carry); + } else if (consonantCount == 1 && current.length() > 1) { + // Single consonant goes with new syllable + String syllable = current.substring( + 0, + current.length() - 1 + ); + String carry = current.substring(current.length() - 1); + if (!syllable.isEmpty()) { + syllables.add(syllable); + } + current = new StringBuilder(carry); + } + } + + current.append(c); + + if (isVowel && !prevVowel) { + vowelGroups++; + } + prevVowel = isVowel; + } + + // Add remaining + if (current.length() > 0) { + syllables.add(current.toString()); + } + + // If we somehow got no syllables, return the whole word + if (syllables.isEmpty()) { + syllables.add(word); + } + + return syllables; + } + + /** + * Count trailing consonants in a string. + */ + private static int countTrailingConsonants(String s) { + int count = 0; + for (int i = s.length() - 1; i >= 0; i--) { + char c = Character.toLowerCase(s.charAt(i)); + if (Character.isLetter(c) && !VOWELS.contains(c)) { + count++; + } else { + break; + } + } + return count; + } + + /** + * Check if a syllable position is typically stressed. + * Simple heuristic: first syllable and syllables with long vowels. + * + * @param syllable The syllable content + * @param position Position in word (0-indexed) + * @param total Total number of syllables + * @return true if likely stressed + */ + public static boolean isStressedSyllable( + String syllable, + int position, + int total + ) { + // First syllable is often stressed in English/Germanic + if (position == 0) { + return true; + } + + // Last syllable in short words + if (total <= 2 && position == total - 1) { + return true; + } + + // Syllables with double vowels or long vowels tend to be stressed + String lower = syllable.toLowerCase(); + if ( + lower.contains("aa") || + lower.contains("ee") || + lower.contains("oo") || + lower.contains("ii") || + lower.contains("uu") || + lower.contains("ou") || + lower.contains("ai") || + lower.contains("ei") + ) { + return true; + } + + return false; + } + + /** + * Get the primary vowel of a syllable. + * + * @param syllable The syllable to analyze + * @return The primary vowel character, or 'a' as default + */ + public static char getPrimaryVowel(String syllable) { + if (syllable == null || syllable.isEmpty()) { + return 'a'; + } + + for (char c : syllable.toCharArray()) { + if (VOWELS.contains(Character.toLowerCase(c))) { + return Character.toLowerCase(c); + } + } + + return 'a'; + } +} diff --git a/src/main/java/com/tiedup/remake/util/TiedUpSounds.java b/src/main/java/com/tiedup/remake/util/TiedUpSounds.java new file mode 100644 index 0000000..ec2a859 --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/TiedUpSounds.java @@ -0,0 +1,192 @@ +package com.tiedup.remake.util; + +import com.tiedup.remake.core.ModSounds; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.Level; + +/** + * Sound utility functions for TiedUp mod. + * + * Provides convenient methods for playing mod-specific sounds. + * Works for both Players and NPCs (EntityDamsel, etc.) + * + * Phase 14.2.5: Connected to actual ModSounds registry + */ +public class TiedUpSounds { + + /** + * Play a lock closing sound at entity's position. + * Used when locking collars, gags, blindfolds, etc. + * + * @param entity The entity at whose position to play the sound + */ + public static void playLockSound(Entity entity) { + playSound(entity, ModSounds.COLLAR_KEY_CLOSE.get(), 1.0f); + } + + /** + * Play an unlock sound at entity's position. + * Used when unlocking collars, gags, blindfolds, etc. + * + * @param entity The entity at whose position to play the sound + */ + public static void playUnlockSound(Entity entity) { + playSound(entity, ModSounds.COLLAR_KEY_OPEN.get(), 1.0f); + } + + /** + * Play a shock sound at entity's position. + * Used when shocking with collar or shocker controller. + * + * @param entity The entity being shocked + */ + public static void playShockSound(Entity entity) { + playSound(entity, ModSounds.ELECTRIC_SHOCK.get(), 1.0f); + } + + /** + * Play a binding sound at entity's position. + * Used when applying binds/ropes. + * + * @param entity The entity being bound + */ + public static void playBindSound(Entity entity) { + playSound(entity, ModSounds.CHAIN.get(), 0.8f); + } + + /** + * Play a struggle sound at entity's position. + * Used when struggling against restraints. + * + * @param entity The entity struggling + */ + public static void playStruggleSound(Entity entity) { + // Use chain sound with slightly higher pitch for struggle effect + playSound(entity, ModSounds.CHAIN.get(), 0.6f, 1.2f); + } + + /** + * Play a collar equip sound at entity's position. + * + * @param entity The entity receiving the collar + */ + public static void playCollarSound(Entity entity) { + playSound(entity, ModSounds.COLLAR_PUT.get(), 1.0f); + } + + /** + * Play a slap sound at entity's position. + * Used for paddle/discipline items. + * + * @param entity The entity being slapped + */ + public static void playSlapSound(Entity entity) { + playSound(entity, ModSounds.SLAP.get(), 1.0f); + } + + /** + * Play a whip sound at entity's position. + * + * @param entity The entity being whipped + */ + public static void playWhipSound(Entity entity) { + playSound(entity, ModSounds.WHIP.get(), 1.0f); + } + + /** + * Play shocker activation sound. + * + * @param entity The entity activating the shocker + */ + public static void playShockerActivatedSound(Entity entity) { + playSound(entity, ModSounds.SHOCKER_ACTIVATED.get(), 1.0f); + } + + /** + * Play earplugs equip sound at entity's position. + * Uses a soft variation of the collar sound. + * + * @param entity The entity receiving the earplugs + */ + public static void playEarplugsEquipSound(Entity entity) { + // Use collar_put with softer volume and higher pitch for earplugs + playSound(entity, ModSounds.COLLAR_PUT.get(), 0.5f, 1.3f); + } + + /** + * Play earplugs remove sound at entity's position. + * + * @param entity The entity having earplugs removed + */ + public static void playEarplugsRemoveSound(Entity entity) { + // Use collar_put with softer volume and lower pitch for removal + playSound(entity, ModSounds.COLLAR_PUT.get(), 0.4f, 0.9f); + } + + /** + * Play a generic sound at an entity's position. + * + * Based on original Utils.playSound() + * + * @param entity The entity at whose position to play the sound + * @param sound The sound event to play + * @param volume The volume (1.0f = 100%) + */ + public static void playSound( + Entity entity, + SoundEvent sound, + float volume + ) { + if (entity == null || entity.level() == null || sound == null) { + return; + } + + Level level = entity.level(); + + // Server-side only (will be synced to clients automatically) + if (!level.isClientSide) { + level.playSound( + null, // Player (null = everyone hears it) + entity.blockPosition(), // Position + sound, // Sound event + SoundSource.NEUTRAL, // Sound category (NEUTRAL for mod sounds) + volume, // Volume + 1.0f // Pitch + ); + } + } + + /** + * Play a sound with custom pitch. + * + * @param entity The entity at whose position to play the sound + * @param sound The sound event to play + * @param volume The volume (1.0f = 100%) + * @param pitch The pitch (1.0f = normal, 0.5f = lower, 2.0f = higher) + */ + public static void playSound( + Entity entity, + SoundEvent sound, + float volume, + float pitch + ) { + if (entity == null || entity.level() == null || sound == null) { + return; + } + + Level level = entity.level(); + + if (!level.isClientSide) { + level.playSound( + null, + entity.blockPosition(), + sound, + SoundSource.NEUTRAL, + volume, + pitch + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/util/TiedUpUtils.java b/src/main/java/com/tiedup/remake/util/TiedUpUtils.java new file mode 100644 index 0000000..c59a9fb --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/TiedUpUtils.java @@ -0,0 +1,51 @@ +package com.tiedup.remake.util; + +import java.util.ArrayList; +import java.util.List; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; + +/** + * General utility functions for TiedUp mod. + */ +public class TiedUpUtils { + + /** + * Get all players within a radius. + * + * @param level The world + * @param pos The center position + * @param distance The search radius + * @return List of ServerPlayer found + */ + public static List getPlayersAround( + Level level, + BlockPos pos, + double distance + ) { + List players = new ArrayList<>(); + + if (level == null || level.isClientSide) { + return players; + } + + AABB searchBox = new AABB( + pos.getX() - distance, + pos.getY() - distance, + pos.getZ() - distance, + pos.getX() + distance, + pos.getY() + distance, + pos.getZ() + distance + ); + + List allPlayers = level.getEntitiesOfClass( + ServerPlayer.class, + searchBox + ); + players.addAll(allPlayers); + + return players; + } +} diff --git a/src/main/java/com/tiedup/remake/util/ValidationHelper.java b/src/main/java/com/tiedup/remake/util/ValidationHelper.java new file mode 100644 index 0000000..8ae2534 --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/ValidationHelper.java @@ -0,0 +1,67 @@ +package com.tiedup.remake.util; + +import java.util.Optional; +import org.jetbrains.annotations.Nullable; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; + +/** + * Player type validation helpers. + * + * Provides methods for checking player side (client/server) and type casting. + * + * For kidnapped/bondage state checks, use {@link KidnappedHelper} directly. + */ +public final class ValidationHelper { + + private ValidationHelper() {} + + /** + * Check if the player is a server-side ServerPlayer. + * + * @param player The player to check + * @return true if player is non-null, server-side, and ServerPlayer instance + */ + public static boolean isServerPlayer(@Nullable Player player) { + return ( + player != null && + !player.level().isClientSide && + player instanceof ServerPlayer + ); + } + + /** + * Get the player as ServerPlayer if it's server-side. + * + * @param player The player to check + * @return Optional containing ServerPlayer, or empty if not server-side + */ + public static Optional asServerPlayer( + @Nullable Player player + ) { + if (isServerPlayer(player)) { + return Optional.of((ServerPlayer) player); + } + return Optional.empty(); + } + + /** + * Check if the player is client-side. + * + * @param player The player to check + * @return true if player is non-null and on client side + */ + public static boolean isClientSide(@Nullable Player player) { + return player != null && player.level().isClientSide; + } + + /** + * Check if we're on the server side for this player. + * + * @param player The player to check + * @return true if player is non-null and on server side + */ + public static boolean isServerSide(@Nullable Player player) { + return player != null && !player.level().isClientSide; + } +} diff --git a/src/main/java/com/tiedup/remake/util/tasks/ItemTask.java b/src/main/java/com/tiedup/remake/util/tasks/ItemTask.java new file mode 100644 index 0000000..88cf63d --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/tasks/ItemTask.java @@ -0,0 +1,231 @@ +package com.tiedup.remake.util.tasks; + +import javax.annotation.Nullable; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.registries.ForgeRegistries; + +/** + * Represents an item task (for jobs and sales). + * + * Phase 14.3.5: Task system for jobs and sales + * + * An ItemTask defines: + * - What item is needed (by registry name) + * - How many are needed + * + * Used by: + * - Sale system: Price to buy a slave + * - Job system: Items slave must fetch + * + * Based on original ItemTask from 1.12.2 + */ +public class ItemTask { + + /** The item registry name (e.g., "minecraft:iron_ingot") */ + private final String itemId; + + /** The amount required */ + private final int amount; + + /** Cached item reference */ + @Nullable + private transient Item cachedItem; + + /** + * Create a new item task. + * + * @param itemId The item registry name + * @param amount The amount required + */ + public ItemTask(String itemId, int amount) { + this.itemId = itemId; + this.amount = Math.max(1, amount); + this.cachedItem = null; + } + + /** + * Create a new item task from an Item. + * + * @param item The item + * @param amount The amount required + */ + public ItemTask(Item item, int amount) { + ResourceLocation key = ForgeRegistries.ITEMS.getKey(item); + this.itemId = key != null ? key.toString() : "minecraft:air"; + this.amount = Math.max(1, amount); + this.cachedItem = item; + } + + /** + * Create a new item task from an ItemStack. + * + * @param stack The item stack (amount is used) + */ + public ItemTask(ItemStack stack) { + this(stack.getItem(), stack.getCount()); + } + + // ======================================== + // GETTERS + // ======================================== + + public String getItemId() { + return itemId; + } + + public int getAmount() { + return amount; + } + + /** + * Get the Item instance. + * + * @return The Item or null if not found + */ + @Nullable + public Item getItem() { + if (this.cachedItem == null) { + ResourceLocation loc = ResourceLocation.tryParse(this.itemId); + if (loc != null) { + this.cachedItem = ForgeRegistries.ITEMS.getValue(loc); + } + } + return this.cachedItem; + } + + /** + * Create an ItemStack of the required amount. + * + * @return ItemStack or empty if item not found + */ + public ItemStack createStack() { + Item item = getItem(); + if (item == null) { + return ItemStack.EMPTY; + } + return new ItemStack(item, this.amount); + } + + /** + * Get display name for the item. + * + * @return Display name or item ID if not found + */ + public String getDisplayName() { + Item item = getItem(); + if (item == null) { + return this.itemId; + } + return new ItemStack(item).getHoverName().getString(); + } + + // ======================================== + // VALIDATION + // ======================================== + + /** + * Check if an ItemStack matches this task (same item type). + * + * @param stack The stack to check + * @return true if same item type + */ + public boolean matchesItem(ItemStack stack) { + if (stack.isEmpty()) return false; + + Item item = getItem(); + if (item == null) return false; + + return stack.getItem() == item; + } + + /** + * Check if an ItemStack completes this task (same item, enough amount). + * + * @param stack The stack to check + * @return true if completes the task + */ + public boolean isCompletedBy(ItemStack stack) { + return matchesItem(stack) && stack.getCount() >= this.amount; + } + + /** + * Consume the required items from a stack. + * + * @param stack The stack to consume from + * @return true if consumed successfully + */ + public boolean consumeFrom(ItemStack stack) { + if (!isCompletedBy(stack)) { + return false; + } + stack.shrink(this.amount); + return true; + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + /** + * Save this task to NBT. + * + * @return CompoundTag with task data + */ + public CompoundTag save() { + CompoundTag tag = new CompoundTag(); + tag.putString("item", this.itemId); + tag.putInt("amount", this.amount); + return tag; + } + + /** + * Load a task from NBT. + * + * @param tag The CompoundTag to load from + * @return ItemTask or null if invalid + */ + @Nullable + public static ItemTask load(CompoundTag tag) { + if (tag == null || !tag.contains("item")) { + return null; + } + + String itemId = tag.getString("item"); + int amount = tag.contains("amount") ? tag.getInt("amount") : 1; + + return new ItemTask(itemId, amount); + } + + // ======================================== + // DISPLAY + // ======================================== + + /** + * Get a display string for this task. + * + * @return String like "20x Iron Ingot" + */ + public String toDisplayString() { + return this.amount + "x " + getDisplayName(); + } + + @Override + public String toString() { + return "ItemTask{" + this.amount + "x " + this.itemId + "}"; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof ItemTask other)) return false; + return this.amount == other.amount && this.itemId.equals(other.itemId); + } + + @Override + public int hashCode() { + return 31 * this.itemId.hashCode() + this.amount; + } +} diff --git a/src/main/java/com/tiedup/remake/util/tasks/JobLoader.java b/src/main/java/com/tiedup/remake/util/tasks/JobLoader.java new file mode 100644 index 0000000..301d0a3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/tasks/JobLoader.java @@ -0,0 +1,148 @@ +package com.tiedup.remake.util.tasks; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/** + * Loads and manages job tasks for slave work system. + * + * Phase 14.3.5: Job system + * + * A job is an ItemTask that a slave must complete + * (fetch X items) within a time limit or face punishment. + * + * Based on original JobLoader from 1.12.2 + */ +public class JobLoader { + + private static final Random RANDOM = new Random(); + + /** List of available jobs */ + private static final List JOBS = new ArrayList<>(); + + /** Whether jobs have been initialized */ + private static boolean initialized = false; + + /** + * Initialize default jobs. + * Called on mod initialization. + */ + public static void init() { + if (initialized) return; + + // Default jobs - common gathering tasks + // Easy jobs + JOBS.add(new ItemTask("minecraft:cobblestone", 16)); + JOBS.add(new ItemTask("minecraft:dirt", 16)); + JOBS.add(new ItemTask("minecraft:oak_log", 8)); + JOBS.add(new ItemTask("minecraft:wheat", 10)); + JOBS.add(new ItemTask("minecraft:potato", 10)); + JOBS.add(new ItemTask("minecraft:carrot", 10)); + JOBS.add(new ItemTask("minecraft:apple", 5)); + + // Medium jobs + JOBS.add(new ItemTask("minecraft:iron_ore", 5)); + JOBS.add(new ItemTask("minecraft:coal", 10)); + JOBS.add(new ItemTask("minecraft:raw_iron", 5)); + JOBS.add(new ItemTask("minecraft:raw_copper", 10)); + JOBS.add(new ItemTask("minecraft:leather", 5)); + JOBS.add(new ItemTask("minecraft:string", 8)); + JOBS.add(new ItemTask("minecraft:paper", 10)); + JOBS.add(new ItemTask("minecraft:book", 3)); + + // Hard jobs + JOBS.add(new ItemTask("minecraft:raw_gold", 3)); + JOBS.add(new ItemTask("minecraft:diamond", 1)); + JOBS.add(new ItemTask("minecraft:emerald", 1)); + JOBS.add(new ItemTask("minecraft:blaze_rod", 2)); + JOBS.add(new ItemTask("minecraft:ender_pearl", 2)); + JOBS.add(new ItemTask("minecraft:obsidian", 4)); + + initialized = true; + + TiedUpMod.LOGGER.info( + "[JobLoader] Loaded {} default jobs", + JOBS.size() + ); + } + + /** + * Get a random job task. + * + * @return Random ItemTask for a job + */ + public static ItemTask getRandomJob() { + if (JOBS.isEmpty()) { + // Fallback if not initialized + return new ItemTask("minecraft:cobblestone", 16); + } + + return JOBS.get(RANDOM.nextInt(JOBS.size())); + } + + /** + * Get a random job from a difficulty tier. + * + * @param difficulty 0 = easy, 1 = medium, 2 = hard + * @return Random ItemTask from that tier + */ + public static ItemTask getRandomJob(int difficulty) { + if (JOBS.isEmpty()) { + return new ItemTask("minecraft:cobblestone", 16); + } + + // Simple tier system based on list indices + int tierSize = JOBS.size() / 3; + int startIndex = difficulty * tierSize; + int endIndex = Math.min(startIndex + tierSize, JOBS.size()); + + if (startIndex >= JOBS.size()) { + startIndex = 0; + endIndex = tierSize; + } + + int range = endIndex - startIndex; + if (range <= 0) range = 1; + + return JOBS.get(startIndex + RANDOM.nextInt(range)); + } + + /** + * Get all available jobs. + * + * @return List of all jobs + */ + public static List getAllJobs() { + return new ArrayList<>(JOBS); + } + + /** + * Check if jobs are available. + * + * @return true if at least one job is configured + */ + public static boolean hasJobs() { + return !JOBS.isEmpty(); + } + + /** + * Add a custom job. + * + * @param job The job to add + */ + public static void addJob(ItemTask job) { + if (job != null) { + JOBS.add(job); + } + } + + /** + * Clear all jobs (for reloading). + */ + public static void clear() { + JOBS.clear(); + initialized = false; + } +} diff --git a/src/main/java/com/tiedup/remake/util/tasks/SaleLoader.java b/src/main/java/com/tiedup/remake/util/tasks/SaleLoader.java new file mode 100644 index 0000000..9db6c54 --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/tasks/SaleLoader.java @@ -0,0 +1,103 @@ +package com.tiedup.remake.util.tasks; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/** + * Loads and manages sale prices for slave trading. + * + * Phase 14.3.5: Sale system + * + * Provides default sale prices and can be extended + * to load from config files in the future. + * + * Based on original SaleLoader from 1.12.2 + */ +public class SaleLoader { + + private static final Random RANDOM = new Random(); + + /** List of available sale prices */ + private static final List SALES = new ArrayList<>(); + + /** Whether sales have been initialized */ + private static boolean initialized = false; + + /** + * Initialize default sales. + * Called on mod initialization. + */ + public static void init() { + if (initialized) return; + + // Default sale prices + SALES.add(new ItemTask("minecraft:iron_ingot", 10)); + SALES.add(new ItemTask("minecraft:iron_ingot", 20)); + SALES.add(new ItemTask("minecraft:gold_ingot", 10)); + SALES.add(new ItemTask("minecraft:gold_ingot", 15)); + SALES.add(new ItemTask("minecraft:diamond", 3)); + SALES.add(new ItemTask("minecraft:diamond", 5)); + SALES.add(new ItemTask("minecraft:emerald", 5)); + SALES.add(new ItemTask("minecraft:emerald", 10)); + + initialized = true; + + TiedUpMod.LOGGER.info( + "[SaleLoader] Loaded {} default sales", + SALES.size() + ); + } + + /** + * Get a random sale price. + * + * @return Random ItemTask for sale price + */ + public static ItemTask getRandomSale() { + if (SALES.isEmpty()) { + // Fallback if not initialized + return new ItemTask("minecraft:iron_ingot", 10); + } + + return SALES.get(RANDOM.nextInt(SALES.size())); + } + + /** + * Get all available sale prices. + * + * @return List of all sales + */ + public static List getAllSales() { + return new ArrayList<>(SALES); + } + + /** + * Check if sales are available. + * + * @return true if at least one sale is configured + */ + public static boolean hasSales() { + return !SALES.isEmpty(); + } + + /** + * Add a custom sale price. + * + * @param sale The sale to add + */ + public static void addSale(ItemTask sale) { + if (sale != null) { + SALES.add(sale); + } + } + + /** + * Clear all sales (for reloading). + */ + public static void clear() { + SALES.clear(); + initialized = false; + } +} diff --git a/src/main/java/com/tiedup/remake/util/teleport/Position.java b/src/main/java/com/tiedup/remake/util/teleport/Position.java new file mode 100644 index 0000000..ee7c850 --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/teleport/Position.java @@ -0,0 +1,367 @@ +package com.tiedup.remake.util.teleport; + +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +/** + * Represents a teleport position with coordinates, rotation, and dimension. + * + *

Used by collar teleport commands and warp points in the original mod.

+ * + *

Use Cases

+ *
    + *
  • Collar warp points (home, prison, custom locations)
  • + *
  • EntityKidnapper prison teleportation
  • + *
  • Admin teleport commands
  • + *
+ * + *

NBT Structure

+ *
+ * position (CompoundTag)
+ *   ├── x (double)
+ *   ├── y (double)
+ *   ├── z (double)
+ *   ├── yaw (float)
+ *   ├── pitch (float)
+ *   └── dimension (string) - e.g., "minecraft:overworld"
+ * 
+ * + *

Original Reference

+ * Based on Original/com/yuti/kidnapmod/util/teleport/Position.java + */ +public class Position { + + private final double x; + private final double y; + private final double z; + private final float yaw; + private final float pitch; + private final ResourceKey dimension; + + /** + * Create a new position with full rotation. + * + * @param x X coordinate + * @param y Y coordinate + * @param z Z coordinate + * @param yaw Horizontal rotation (0-360) + * @param pitch Vertical rotation (-90 to 90) + * @param dimension Dimension key (e.g., Level.OVERWORLD, Level.NETHER) + */ + public Position( + double x, + double y, + double z, + float yaw, + float pitch, + ResourceKey dimension + ) { + this.x = x; + this.y = y; + this.z = z; + this.yaw = yaw; + this.pitch = pitch; + this.dimension = dimension; + } + + /** + * Create a new position without rotation (defaults to 0). + * + * @param x X coordinate + * @param y Y coordinate + * @param z Z coordinate + * @param dimension Dimension key + */ + public Position( + double x, + double y, + double z, + ResourceKey dimension + ) { + this(x, y, z, 0.0f, 0.0f, dimension); + } + + /** + * Create a position from BlockPos and dimension. + * + * @param pos Block position + * @param dimension Dimension key + */ + public Position(BlockPos pos, ResourceKey dimension) { + this( + pos.getX() + 0.5, + pos.getY(), + pos.getZ() + 0.5, + 0.0f, + 0.0f, + dimension + ); + } + + /** + * Create a position from Vec3 and dimension. + * + * @param vec Vector position + * @param dimension Dimension key + */ + public Position(Vec3 vec, ResourceKey dimension) { + this(vec.x, vec.y, vec.z, 0.0f, 0.0f, dimension); + } + + /** + * Create a position from an entity's current location. + * + * @param entity The entity + * @return Position at entity's location with rotation + */ + public static Position fromEntity(Entity entity) { + return new Position( + entity.getX(), + entity.getY(), + entity.getZ(), + entity.getYRot(), + entity.getXRot(), + entity.level().dimension() + ); + } + + // ======================================== + // NBT SERIALIZATION + // ======================================== + + /** + * Save this position to NBT. + * + * @return CompoundTag containing position data + */ + public CompoundTag save() { + CompoundTag tag = new CompoundTag(); + tag.putDouble("x", this.x); + tag.putDouble("y", this.y); + tag.putDouble("z", this.z); + tag.putFloat("yaw", this.yaw); + tag.putFloat("pitch", this.pitch); + tag.putString("dimension", this.dimension.location().toString()); + return tag; + } + + /** + * Save this position to an existing CompoundTag. + * + * @param tag The tag to save to + * @return The modified tag + */ + public CompoundTag save(CompoundTag tag) { + tag.putDouble("x", this.x); + tag.putDouble("y", this.y); + tag.putDouble("z", this.z); + tag.putFloat("yaw", this.yaw); + tag.putFloat("pitch", this.pitch); + tag.putString("dimension", this.dimension.location().toString()); + return tag; + } + + /** + * Load a position from NBT. + * + * @param tag The CompoundTag to load from + * @return Position or null if invalid + */ + @Nullable + public static Position load(CompoundTag tag) { + if ( + tag == null || + !tag.contains("x") || + !tag.contains("y") || + !tag.contains("z") + ) { + return null; + } + + double x = tag.getDouble("x"); + double y = tag.getDouble("y"); + double z = tag.getDouble("z"); + float yaw = tag.contains("yaw") ? tag.getFloat("yaw") : 0.0f; + float pitch = tag.contains("pitch") ? tag.getFloat("pitch") : 0.0f; + + // Parse dimension + ResourceKey dimension = Level.OVERWORLD; // Default + if (tag.contains("dimension")) { + String dimStr = tag.getString("dimension"); + ResourceLocation dimLoc = ResourceLocation.tryParse(dimStr); + if (dimLoc != null) { + dimension = ResourceKey.create(Registries.DIMENSION, dimLoc); + } + } + + return new Position(x, y, z, yaw, pitch, dimension); + } + + // ======================================== + // GETTERS + // ======================================== + + public double getX() { + return x; + } + + public double getY() { + return y; + } + + public double getZ() { + return z; + } + + public float getYaw() { + return yaw; + } + + public float getPitch() { + return pitch; + } + + public ResourceKey getDimension() { + return dimension; + } + + /** + * Convert to Vec3. + * @return Vector representation + */ + public Vec3 toVec3() { + return new Vec3(x, y, z); + } + + /** + * Convert to BlockPos. + * @return Block position (floored) + */ + public BlockPos toBlockPos() { + return new BlockPos( + (int) Math.floor(x), + (int) Math.floor(y), + (int) Math.floor(z) + ); + } + + /** + * Get dimension name for display. + * @return Dimension name (e.g., "overworld", "the_nether") + */ + public String getDimensionName() { + return dimension.location().getPath(); + } + + /** + * Check if this position is in the same dimension as another. + * @param other The other position + * @return true if same dimension + */ + public boolean isSameDimension(Position other) { + return other != null && this.dimension.equals(other.dimension); + } + + /** + * Check if this position is in the given dimension. + * @param dim The dimension to check + * @return true if in that dimension + */ + public boolean isInDimension(ResourceKey dim) { + return this.dimension.equals(dim); + } + + /** + * Calculate distance to another position (ignores dimension). + * @param other The other position + * @return Distance in blocks + */ + public double distanceTo(Position other) { + double dx = this.x - other.x; + double dy = this.y - other.y; + double dz = this.z - other.z; + return Math.sqrt(dx * dx + dy * dy + dz * dz); + } + + /** + * Calculate horizontal distance to another position (ignores Y and dimension). + * @param other The other position + * @return Horizontal distance in blocks + */ + public double horizontalDistanceTo(Position other) { + double dx = this.x - other.x; + double dz = this.z - other.z; + return Math.sqrt(dx * dx + dz * dz); + } + + // ======================================== + // OBJECT OVERRIDES + // ======================================== + + @Override + public String toString() { + return String.format( + "Position{x=%.2f, y=%.2f, z=%.2f, yaw=%.1f, pitch=%.1f, dim=%s}", + x, + y, + z, + yaw, + pitch, + dimension.location() + ); + } + + /** + * Get a short string for display (e.g., in tooltips). + * @return Short position string like "X: 100, Y: 64, Z: -200" + */ + public String toShortString() { + return String.format("X: %.0f, Y: %.0f, Z: %.0f", x, y, z); + } + + /** + * Get a display string with dimension for tooltips. + * @return Display string like "overworld - X: 100, Y: 64, Z: -200" + */ + public String toDisplayString() { + return String.format( + "%s - X: %.0f, Y: %.0f, Z: %.0f", + getDimensionName(), + x, + y, + z + ); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof Position other)) return false; + return ( + Double.compare(other.x, x) == 0 && + Double.compare(other.y, y) == 0 && + Double.compare(other.z, z) == 0 && + Float.compare(other.yaw, yaw) == 0 && + Float.compare(other.pitch, pitch) == 0 && + dimension.equals(other.dimension) + ); + } + + @Override + public int hashCode() { + int result = Double.hashCode(x); + result = 31 * result + Double.hashCode(y); + result = 31 * result + Double.hashCode(z); + result = 31 * result + Float.hashCode(yaw); + result = 31 * result + Float.hashCode(pitch); + result = 31 * result + dimension.hashCode(); + return result; + } +} diff --git a/src/main/java/com/tiedup/remake/util/teleport/TeleportHelper.java b/src/main/java/com/tiedup/remake/util/teleport/TeleportHelper.java new file mode 100644 index 0000000..4cb0b62 --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/teleport/TeleportHelper.java @@ -0,0 +1,393 @@ +package com.tiedup.remake.util.teleport; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.function.Function; +import org.jetbrains.annotations.Nullable; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.portal.PortalInfo; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.common.util.ITeleporter; + +/** + * Utility class for teleporting entities across dimensions. + * + *

Handles the complexity of cross-dimension teleportation in 1.20.1, + * including proper chunk loading, entity transfer, and rotation preservation.

+ * + *

Features

+ *
    + *
  • Same-dimension teleportation
  • + *
  • Cross-dimension teleportation
  • + *
  • Player-specific handling (smooth client sync)
  • + *
  • Rotation preservation
  • + *
  • Safe position validation
  • + *
+ * + *

Original Reference

+ * Based on Original/com/yuti/kidnapmod/util/teleport/TeleportHelper.java + */ +public class TeleportHelper { + + /** + * Teleport an entity to a position. + * Handles both same-dimension and cross-dimension teleportation. + * + * @param entity The entity to teleport + * @param position The target position + * @return The entity after teleportation (may be different instance after dimension change) + */ + @Nullable + public static Entity teleportEntity(Entity entity, Position position) { + if (entity == null || position == null) { + return entity; + } + + // Server-side only + if (entity.level().isClientSide) { + TiedUpMod.LOGGER.warn( + "[TeleportHelper] Attempted client-side teleport" + ); + return entity; + } + + // Get server and target level + MinecraftServer server = entity.getServer(); + if (server == null) { + TiedUpMod.LOGGER.error( + "[TeleportHelper] No server found for entity" + ); + return entity; + } + + ResourceKey targetDimension = position.getDimension(); + ServerLevel targetLevel = server.getLevel(targetDimension); + + if (targetLevel == null) { + TiedUpMod.LOGGER.error( + "[TeleportHelper] Target dimension not found: {}", + targetDimension + ); + return entity; + } + + // Check if same dimension or cross-dimension + if (entity.level().dimension().equals(targetDimension)) { + return teleportSameDimension(entity, position); + } else { + return teleportCrossDimension(entity, targetLevel, position); + } + } + + /** + * Teleport an entity within the same dimension. + * + * @param entity The entity to teleport + * @param position The target position + * @return The entity after teleportation + */ + private static Entity teleportSameDimension( + Entity entity, + Position position + ) { + if (entity == null || position == null) { + return entity; + } + + // For players, use the connection method for smooth sync + if (entity instanceof ServerPlayer player) { + player.connection.teleport( + position.getX(), + position.getY(), + position.getZ(), + position.getYaw(), + position.getPitch() + ); + + // Reset fall distance to prevent fall damage after teleport + player.fallDistance = 0; + player.resetFallDistance(); + + TiedUpMod.LOGGER.info( + "[TeleportHelper] Teleported player {} to {}", + player.getName().getString(), + position.toShortString() + ); + } else { + // For other entities, set position and rotation directly + entity.teleportTo( + position.getX(), + position.getY(), + position.getZ() + ); + entity.setYRot(position.getYaw()); + entity.setXRot(position.getPitch()); + entity.setYHeadRot(position.getYaw()); + + // Reset fall distance to prevent fall damage after teleport + entity.fallDistance = 0; + + TiedUpMod.LOGGER.info( + "[TeleportHelper] Teleported entity {} to {}", + entity.getName().getString(), + position.toShortString() + ); + } + + return entity; + } + + /** + * Teleport an entity across dimensions. + * + * @param entity The entity to teleport + * @param targetLevel The target dimension + * @param position The target position + * @return The entity after teleportation (may be different instance) + */ + @Nullable + public static Entity teleportCrossDimension( + Entity entity, + ServerLevel targetLevel, + Position position + ) { + if (entity == null || targetLevel == null || position == null) { + return entity; + } + + // For players, use changeDimension with custom teleporter + if (entity instanceof ServerPlayer player) { + return teleportPlayerCrossDimension(player, targetLevel, position); + } + + // For other entities, use changeDimension + Entity newEntity = entity.changeDimension( + targetLevel, + new PositionTeleporter(position) + ); + + if (newEntity != null) { + TiedUpMod.LOGGER.info( + "[TeleportHelper] Cross-dimension teleport: {} to {} in {}", + entity.getName().getString(), + position.toShortString(), + targetLevel.dimension().location() + ); + } else { + TiedUpMod.LOGGER.warn( + "[TeleportHelper] Cross-dimension teleport failed for {}", + entity.getName().getString() + ); + } + + return newEntity; + } + + /** + * Teleport a player across dimensions. + * + * @param player The player to teleport + * @param targetLevel The target dimension + * @param position The target position + * @return The player after teleportation + */ + public static ServerPlayer teleportPlayerCrossDimension( + ServerPlayer player, + ServerLevel targetLevel, + Position position + ) { + if (player == null || targetLevel == null || position == null) { + return player; + } + + // Use Forge's teleportTo method for cross-dimension + player.changeDimension(targetLevel, new PositionTeleporter(position)); + + TiedUpMod.LOGGER.info( + "[TeleportHelper] Cross-dimension player teleport: {} to {} in {}", + player.getName().getString(), + position.toShortString(), + targetLevel.dimension().location() + ); + + return player; + } + + /** + * Teleport a living entity to another entity's position. + * + * @param entity The entity to teleport + * @param target The target entity to teleport to + * @return The entity after teleportation + */ + @Nullable + public static Entity teleportToEntity(Entity entity, Entity target) { + if (entity == null || target == null) { + return entity; + } + + Position targetPos = Position.fromEntity(target); + return teleportEntity(entity, targetPos); + } + + /** + * Check if teleportation to a position is safe. + * Checks for solid ground and non-suffocating blocks. + * + * @param level The level to check in + * @param position The position to check + * @return true if position is safe for teleportation + */ + public static boolean isSafePosition(Level level, Position position) { + if (level == null || position == null) { + return false; + } + + // Check if there's solid ground below + var groundPos = position.toBlockPos().below(); + var groundState = level.getBlockState(groundPos); + + if (!groundState.isSolid()) { + return false; // No solid ground + } + + // Check if the position itself is not solid (entity can fit) + var feetPos = position.toBlockPos(); + var headPos = feetPos.above(); + + var feetState = level.getBlockState(feetPos); + var headState = level.getBlockState(headPos); + + return !feetState.isSolid() && !headState.isSolid(); + } + + /** + * Find a safe position near the target position. + * Searches in a small radius for a valid teleport location. + * + * @param level The level to search in + * @param position The target position + * @param radius Search radius + * @return Safe position or original if none found + */ + public static Position findSafePosition( + Level level, + Position position, + int radius + ) { + if (level == null || position == null) { + return position; + } + + // First check if original position is safe + if (isSafePosition(level, position)) { + return position; + } + + // Search in expanding circles + for (int r = 1; r <= radius; r++) { + for (int x = -r; x <= r; x++) { + for (int z = -r; z <= r; z++) { + // Only check edge of current radius + if (Math.abs(x) != r && Math.abs(z) != r) continue; + + Position testPos = new Position( + position.getX() + x, + position.getY(), + position.getZ() + z, + position.getYaw(), + position.getPitch(), + position.getDimension() + ); + + if (isSafePosition(level, testPos)) { + return testPos; + } + } + } + } + + // No safe position found, return original + TiedUpMod.LOGGER.warn( + "[TeleportHelper] No safe position found near {}", + position.toShortString() + ); + return position; + } + + // ======================================== + // CUSTOM TELEPORTER FOR FORGE + // ======================================== + + /** + * Custom teleporter that preserves exact position and rotation. + */ + private static class PositionTeleporter implements ITeleporter { + + private final Position position; + + public PositionTeleporter(Position position) { + this.position = position; + } + + @Override + public Entity placeEntity( + Entity entity, + ServerLevel currentWorld, + ServerLevel destWorld, + float yaw, + Function repositionEntity + ) { + // Reposition the entity first (handles mounting, leashing, etc.) + Entity repositioned = repositionEntity.apply(false); + + if (repositioned != null) { + // Set exact position and rotation + repositioned.teleportTo( + position.getX(), + position.getY(), + position.getZ() + ); + repositioned.setYRot(position.getYaw()); + repositioned.setXRot(position.getPitch()); + repositioned.setYHeadRot(position.getYaw()); + } + + return repositioned; + } + + @Nullable + @Override + public PortalInfo getPortalInfo( + Entity entity, + ServerLevel destWorld, + Function defaultPortalInfo + ) { + return new PortalInfo( + new Vec3(position.getX(), position.getY(), position.getZ()), + Vec3.ZERO, // No velocity + position.getYaw(), + position.getPitch() + ); + } + + @Override + public boolean isVanilla() { + return false; // Custom teleporter + } + + @Override + public boolean playTeleportSound( + ServerPlayer player, + ServerLevel sourceWorld, + ServerLevel destWorld + ) { + return false; // No teleport sound + } + } +} diff --git a/src/main/java/com/tiedup/remake/util/time/Timer.java b/src/main/java/com/tiedup/remake/util/time/Timer.java new file mode 100644 index 0000000..f150f12 --- /dev/null +++ b/src/main/java/com/tiedup/remake/util/time/Timer.java @@ -0,0 +1,90 @@ +package com.tiedup.remake.util.time; + +import net.minecraft.world.level.Level; + +/** + * Phase 6: Game tick-based timer for progressive tasks. + * + * Unlike the original mod which used wall-clock time (Date objects), + * this uses Minecraft game ticks for: + * - Better security (can't exploit by changing system time) + * - Respect for server TPS + * - Easier debugging and testing + * + * Based on original Timer from 1.12.2 but modernized for 1.20.1 + */ +public class Timer { + + private final long startTick; // Game time when timer started + private final int ticksToWait; // Total ticks to wait + private final Level level; // World reference for getting current game time + + /** + * Create a new timer that counts down from the specified number of seconds. + * + * @param seconds Number of seconds to wait + * @param level The world (used to get current game time) + */ + public Timer(int seconds, Level level) { + this.level = level; + this.startTick = level.getGameTime(); + this.ticksToWait = seconds * 20; // Convert seconds to ticks (20 ticks/second) + } + + /** + * Get the number of ticks remaining until the timer expires. + * + * @return Remaining ticks (0 or negative if expired) + */ + public int getTicksRemaining() { + long currentTick = level.getGameTime(); + long elapsed = currentTick - startTick; + return ticksToWait - (int) elapsed; + } + + /** + * Get the number of seconds remaining until the timer expires. + * + * @return Remaining seconds (0 or negative if expired) + */ + public int getSecondsRemaining() { + return getTicksRemaining() / 20; + } + + /** + * Check if the timer has expired. + * + * @return true if timer has run out + */ + public boolean isExpired() { + return getTicksRemaining() <= 0; + } + + /** + * Get elapsed time in ticks since timer started. + * + * @return Elapsed ticks + */ + public int getElapsedTicks() { + long currentTick = level.getGameTime(); + return (int) (currentTick - startTick); + } + + /** + * Get elapsed time in seconds since timer started. + * + * @return Elapsed seconds + */ + public int getElapsedSeconds() { + return getElapsedTicks() / 20; + } + + /** + * Get total duration in seconds. + * + * @return Total seconds + */ + public int getTotalSeconds() { + return ticksToWait / 20; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/BodyRegionV2.java b/src/main/java/com/tiedup/remake/v2/BodyRegionV2.java new file mode 100644 index 0000000..32019d1 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/BodyRegionV2.java @@ -0,0 +1,60 @@ +package com.tiedup.remake.v2; + +import org.jetbrains.annotations.Nullable; + +/** + * V2 body region system with 14 regions and global/sub hierarchy. + * + * Global regions (HEAD, ARMS, LEGS) have sub-regions for organizational purposes. + * Blocking is NOT automatic — each item declares which regions it blocks + * via {@link IV2BondageItem#getBlockedRegions()}. + */ +public enum BodyRegionV2 { + // Head regions + HEAD(true), + EYES(false), + EARS(false), + MOUTH(false), + + // Upper body + NECK(false), + TORSO(false), + ARMS(true), + HANDS(false), + FINGERS(false), + + // Lower body + WAIST(false), + LEGS(true), + FEET(false), + + // Special + TAIL(false), + WINGS(false); + + private final boolean global; + + BodyRegionV2(boolean global) { + this.global = global; + } + + /** + * Whether this region is a global region that blocks sub-regions. + */ + public boolean isGlobal() { + return global; + } + + /** + * Safe valueOf that returns null instead of throwing on unknown names. + */ + @Nullable + public static BodyRegionV2 fromName(String name) { + if (name == null) return null; + try { + return valueOf(name); + } catch (IllegalArgumentException e) { + return null; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/tiedup/remake/v2/V2BlockEntities.java b/src/main/java/com/tiedup/remake/v2/V2BlockEntities.java new file mode 100644 index 0000000..18864d0 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/V2BlockEntities.java @@ -0,0 +1,64 @@ +package com.tiedup.remake.v2; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.blocks.PetBedBlockEntity; +import com.tiedup.remake.v2.blocks.PetBowlBlockEntity; +import com.tiedup.remake.v2.blocks.PetCageBlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegistryObject; + +/** + * V2 Block Entity Registration. + */ +public class V2BlockEntities { + + public static final DeferredRegister> BLOCK_ENTITIES = + DeferredRegister.create( + ForgeRegistries.BLOCK_ENTITY_TYPES, + TiedUpMod.MOD_ID + ); + + public static final RegistryObject< + BlockEntityType + > PET_BOWL = BLOCK_ENTITIES.register("pet_bowl", () -> + BlockEntityType.Builder.of( + (pos, state) -> + new PetBowlBlockEntity( + V2BlockEntities.PET_BOWL.get(), + pos, + state + ), + V2Blocks.PET_BOWL.get() + ).build(null) + ); + + public static final RegistryObject< + BlockEntityType + > PET_BED = BLOCK_ENTITIES.register("pet_bed", () -> + BlockEntityType.Builder.of( + (pos, state) -> + new PetBedBlockEntity( + V2BlockEntities.PET_BED.get(), + pos, + state + ), + V2Blocks.PET_BED.get() + ).build(null) + ); + + public static final RegistryObject< + BlockEntityType + > PET_CAGE = BLOCK_ENTITIES.register("pet_cage", () -> + BlockEntityType.Builder.of( + (pos, state) -> + new PetCageBlockEntity( + V2BlockEntities.PET_CAGE.get(), + pos, + state + ), + V2Blocks.PET_CAGE.get() + ).build(null) + ); +} diff --git a/src/main/java/com/tiedup/remake/v2/V2Blocks.java b/src/main/java/com/tiedup/remake/v2/V2Blocks.java new file mode 100644 index 0000000..7a56b06 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/V2Blocks.java @@ -0,0 +1,77 @@ +package com.tiedup.remake.v2; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.blocks.PetBedBlock; +import com.tiedup.remake.v2.blocks.PetBowlBlock; +import com.tiedup.remake.v2.blocks.PetCageBlock; +import com.tiedup.remake.v2.blocks.PetCagePartBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.SoundType; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.level.material.MapColor; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegistryObject; + +/** + * V2 Block Registration. + * Registers OBJ-rendered blocks like pet bowls and beds. + */ +public class V2Blocks { + + public static final DeferredRegister BLOCKS = + DeferredRegister.create(ForgeRegistries.BLOCKS, TiedUpMod.MOD_ID); + + // ======================================== + // PET FURNITURE + // ======================================== + + public static final RegistryObject PET_BOWL = BLOCKS.register( + "pet_bowl", + () -> + new PetBowlBlock( + BlockBehaviour.Properties.of() + .mapColor(MapColor.METAL) + .strength(1.0f) + .sound(SoundType.METAL) + .noOcclusion() + ) + ); + + public static final RegistryObject PET_BED = BLOCKS.register( + "pet_bed", + () -> + new PetBedBlock( + BlockBehaviour.Properties.of() + .mapColor(MapColor.WOOL) + .strength(0.5f) + .sound(SoundType.WOOL) + .noOcclusion() + ) + ); + + public static final RegistryObject PET_CAGE = BLOCKS.register( + "pet_cage", + () -> + new PetCageBlock( + BlockBehaviour.Properties.of() + .mapColor(MapColor.METAL) + .strength(2.0f) + .sound(SoundType.METAL) + .noOcclusion() + ) + ); + + public static final RegistryObject PET_CAGE_PART = BLOCKS.register( + "pet_cage_part", + () -> + new PetCagePartBlock( + BlockBehaviour.Properties.of() + .mapColor(MapColor.METAL) + .strength(2.0f) + .sound(SoundType.METAL) + .noOcclusion() + .noLootTable() // Only master drops the cage item + ) + ); +} diff --git a/src/main/java/com/tiedup/remake/v2/V2Items.java b/src/main/java/com/tiedup/remake/v2/V2Items.java new file mode 100644 index 0000000..7f569c7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/V2Items.java @@ -0,0 +1,39 @@ +package com.tiedup.remake.v2; + +import com.tiedup.remake.core.TiedUpMod; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.Item; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegistryObject; + +/** + * V2 Item Registration. + * Block items for V2 blocks. + */ +public class V2Items { + + public static final DeferredRegister ITEMS = DeferredRegister.create( + ForgeRegistries.ITEMS, + TiedUpMod.MOD_ID + ); + + // ======================================== + // BLOCK ITEMS + // ======================================== + + public static final RegistryObject PET_BOWL = ITEMS.register( + "pet_bowl", + () -> new BlockItem(V2Blocks.PET_BOWL.get(), new Item.Properties()) + ); + + public static final RegistryObject PET_BED = ITEMS.register( + "pet_bed", + () -> new BlockItem(V2Blocks.PET_BED.get(), new Item.Properties()) + ); + + public static final RegistryObject PET_CAGE = ITEMS.register( + "pet_cage", + () -> new BlockItem(V2Blocks.PET_CAGE.get(), new Item.Properties()) + ); +} diff --git a/src/main/java/com/tiedup/remake/v2/blocks/ObjBlockEntity.java b/src/main/java/com/tiedup/remake/v2/blocks/ObjBlockEntity.java new file mode 100644 index 0000000..49df689 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/blocks/ObjBlockEntity.java @@ -0,0 +1,66 @@ +package com.tiedup.remake.v2.blocks; + +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; + +/** + * Base block entity for blocks that render with OBJ models. + * Subclasses define the model and texture locations. + */ +public abstract class ObjBlockEntity extends BlockEntity { + + public ObjBlockEntity( + BlockEntityType type, + BlockPos pos, + BlockState state + ) { + super(type, pos, state); + } + + /** + * Get the OBJ model resource location. + * Example: "tiedup:models/obj/blocks/bowl/model.obj" + */ + @Nullable + public abstract ResourceLocation getModelLocation(); + + /** + * Get the texture resource location. + * Example: "tiedup:textures/block/bowl.png" + */ + @Nullable + public abstract ResourceLocation getTextureLocation(); + + /** + * Get the model scale (default 1.0). + * Override to scale the model up or down. + */ + public float getModelScale() { + return 1.0f; + } + + /** + * Get the model position offset (x, y, z). + * Override to adjust model position within the block. + */ + public float[] getModelOffset() { + return new float[] { 0.0f, 0.0f, 0.0f }; + } + + @Override + protected void saveAdditional(CompoundTag tag) { + super.saveAdditional(tag); + // Subclasses can override to save additional data + } + + @Override + public void load(CompoundTag tag) { + super.load(tag); + // Subclasses can override to load additional data + } +} diff --git a/src/main/java/com/tiedup/remake/v2/blocks/PetBedBlock.java b/src/main/java/com/tiedup/remake/v2/blocks/PetBedBlock.java new file mode 100644 index 0000000..f205cfe --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/blocks/PetBedBlock.java @@ -0,0 +1,181 @@ +package com.tiedup.remake.v2.blocks; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.V2BlockEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.HorizontalDirectionalBlock; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.block.state.properties.BooleanProperty; +import net.minecraft.world.level.block.state.properties.DirectionProperty; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.VoxelShape; +import org.jetbrains.annotations.Nullable; + +/** + * Pet Bed Block - Used by Master to make pets sit and sleep. + * Features: + * - Right-click cycle: Stand -> SIT (wariza) -> SLEEP (curled) -> Stand + * - SIT: immobilized with animation, no night skip + * - SLEEP: vanilla sleeping + animation, night skips + * - Custom OBJ model rendering + */ +public class PetBedBlock extends Block implements EntityBlock { + + public static final DirectionProperty FACING = + HorizontalDirectionalBlock.FACING; + public static final BooleanProperty OCCUPIED = + BlockStateProperties.OCCUPIED; + + // Collision shape (rough approximation of pet bed) + private static final VoxelShape SHAPE = Block.box( + 1.0, + 0.0, + 1.0, + 15.0, + 6.0, + 15.0 + ); + + public PetBedBlock(Properties properties) { + super(properties); + this.registerDefaultState( + this.stateDefinition.any() + .setValue(FACING, Direction.NORTH) + .setValue(OCCUPIED, false) + ); + } + + @Override + protected void createBlockStateDefinition( + StateDefinition.Builder builder + ) { + builder.add(FACING, OCCUPIED); + } + + @Override + public BlockState getStateForPlacement(BlockPlaceContext context) { + return this.defaultBlockState().setValue( + FACING, + context.getHorizontalDirection().getOpposite() + ); + } + + @Override + public VoxelShape getShape( + BlockState state, + BlockGetter level, + BlockPos pos, + CollisionContext context + ) { + return SHAPE; + } + + @Override + public RenderShape getRenderShape(BlockState state) { + return RenderShape.ENTITYBLOCK_ANIMATED; // Use block entity renderer + } + + @Nullable + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new PetBedBlockEntity(V2BlockEntities.PET_BED.get(), pos, state); + } + + @Override + public InteractionResult use( + BlockState state, + Level level, + BlockPos pos, + Player player, + InteractionHand hand, + BlockHitResult hit + ) { + if (level.isClientSide) { + return InteractionResult.CONSUME; + } + + if (!(player instanceof ServerPlayer sp)) { + return InteractionResult.PASS; + } + + // SLEEP -> Stand: player is sleeping, wake them up + if (sp.isSleeping()) { + sp.stopSleeping(); + PetBedManager.clearPlayer(sp); + TiedUpMod.LOGGER.debug( + "[PetBedBlock] {} woke up from bed at {}", + player.getName().getString(), + pos + ); + return InteractionResult.SUCCESS; + } + + // SIT -> SLEEP: player is already sitting on this bed + if (PetBedManager.isOnBed(sp, pos)) { + sp.startSleeping(pos); + // FIX: notify server to count this player for night skip + if (level instanceof ServerLevel sl) { + sl.updateSleepingPlayerList(); + } + PetBedManager.setMode(sp, pos, PetBedManager.PetBedMode.SLEEP); + TiedUpMod.LOGGER.debug( + "[PetBedBlock] {} started sleeping in bed at {}", + player.getName().getString(), + pos + ); + return InteractionResult.SUCCESS; + } + + // Check if bed is already occupied by someone else + BlockEntity be = level.getBlockEntity(pos); + if (be instanceof PetBedBlockEntity petBed && petBed.isOccupied()) { + return InteractionResult.FAIL; + } + + // Stand -> SIT + PetBedManager.setMode(sp, pos, PetBedManager.PetBedMode.SIT); + TiedUpMod.LOGGER.debug( + "[PetBedBlock] {} sat down on bed at {}", + player.getName().getString(), + pos + ); + return InteractionResult.SUCCESS; + } + + @Override + public void setBedOccupied( + BlockState state, + Level level, + BlockPos pos, + net.minecraft.world.entity.LivingEntity sleeper, + boolean occupied + ) { + level.setBlock(pos, state.setValue(OCCUPIED, occupied), 3); + } + + @Override + public boolean isBed( + BlockState state, + BlockGetter level, + BlockPos pos, + @Nullable net.minecraft.world.entity.Entity player + ) { + return true; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/blocks/PetBedBlockEntity.java b/src/main/java/com/tiedup/remake/v2/blocks/PetBedBlockEntity.java new file mode 100644 index 0000000..1b15eca --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/blocks/PetBedBlockEntity.java @@ -0,0 +1,148 @@ +package com.tiedup.remake.v2.blocks; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; + +/** + * Block entity for Pet Bed. + * Used by Master to make pets sleep. Players must crouch to use it. + */ +public class PetBedBlockEntity extends ObjBlockEntity { + + public static final ResourceLocation MODEL_LOCATION = + ResourceLocation.fromNamespaceAndPath( + TiedUpMod.MOD_ID, + "models/obj/blocks/bed/model.obj" + ); + + public static final ResourceLocation TEXTURE_LOCATION = + ResourceLocation.fromNamespaceAndPath( + TiedUpMod.MOD_ID, + "textures/block/pet_bed.png" + ); + + /** UUID of the pet currently using this bed (null if empty) */ + @Nullable + private UUID occupantUUID = null; + + /** UUID of the Master who owns this bed (null = public) */ + @Nullable + private UUID ownerUUID = null; + + public PetBedBlockEntity( + BlockEntityType type, + BlockPos pos, + BlockState state + ) { + super(type, pos, state); + } + + @Override + public ResourceLocation getModelLocation() { + return MODEL_LOCATION; + } + + @Override + public ResourceLocation getTextureLocation() { + return TEXTURE_LOCATION; + } + + @Override + public float getModelScale() { + return 0.8f; // Scale down to fit in block + } + + @Override + public float[] getModelOffset() { + return new float[] { 0.0f, 0.0f, 0.0f }; + } + + // ======================================== + // OCCUPANCY + // ======================================== + + public boolean isOccupied() { + return occupantUUID != null; + } + + @Nullable + public UUID getOccupantUUID() { + return occupantUUID; + } + + /** + * Have a pet occupy this bed. + * + * @param petUUID The pet's UUID + * @return true if successful, false if already occupied + */ + public boolean occupy(UUID petUUID) { + if (isOccupied()) return false; + + this.occupantUUID = petUUID; + setChanged(); + return true; + } + + /** + * Release the current occupant. + */ + public void release() { + this.occupantUUID = null; + setChanged(); + } + + // ======================================== + // OWNERSHIP + // ======================================== + + @Nullable + public UUID getOwnerUUID() { + return ownerUUID; + } + + public void setOwner(@Nullable UUID masterUUID) { + this.ownerUUID = masterUUID; + setChanged(); + } + + public boolean isOwnedBy(UUID uuid) { + return ownerUUID != null && ownerUUID.equals(uuid); + } + + public boolean isPublic() { + return ownerUUID == null; + } + + // ======================================== + // NBT + // ======================================== + + @Override + protected void saveAdditional(CompoundTag tag) { + super.saveAdditional(tag); + if (occupantUUID != null) { + tag.putUUID("occupant", occupantUUID); + } + if (ownerUUID != null) { + tag.putUUID("owner", ownerUUID); + } + } + + @Override + public void load(CompoundTag tag) { + super.load(tag); + if (tag.hasUUID("occupant")) { + this.occupantUUID = tag.getUUID("occupant"); + } + if (tag.hasUUID("owner")) { + this.ownerUUID = tag.getUUID("owner"); + } + } +} diff --git a/src/main/java/com/tiedup/remake/v2/blocks/PetBedManager.java b/src/main/java/com/tiedup/remake/v2/blocks/PetBedManager.java new file mode 100644 index 0000000..9fc4eca --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/blocks/PetBedManager.java @@ -0,0 +1,278 @@ +package com.tiedup.remake.v2.blocks; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.sync.PacketSyncPetBedState; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.attributes.AttributeInstance; +import net.minecraft.world.entity.ai.attributes.AttributeModifier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +/** + * Server-side tracker for pet bed sit/sleep state per player. + * Manages immobilization, block entity occupancy, and client sync. + */ +public class PetBedManager { + + public enum PetBedMode { + SIT, + SLEEP, + } + + private static final UUID PET_BED_SPEED_MODIFIER_UUID = UUID.fromString( + "a1b2c3d4-e5f6-4789-abcd-ef0123456789" + ); + private static final String PET_BED_SPEED_MODIFIER_NAME = + "tiedup.pet_bed_immobilize"; + private static final double FULL_IMMOBILIZATION = -0.10; + + /** Active pet bed entries keyed by player UUID */ + private static final Map entries = + new ConcurrentHashMap<>(); + + private static class PetBedEntry { + + final BlockPos pos; + PetBedMode mode; + + PetBedEntry(BlockPos pos, PetBedMode mode) { + this.pos = pos; + this.mode = mode; + } + } + + /** + * Set a player's pet bed mode (SIT or SLEEP). + */ + public static void setMode( + ServerPlayer player, + BlockPos pos, + PetBedMode mode + ) { + UUID uuid = player.getUUID(); + PetBedEntry existing = entries.get(uuid); + + if (existing != null && mode == PetBedMode.SLEEP) { + // Transitioning from SIT to SLEEP — update mode + existing.mode = mode; + } else { + // New entry (Stand → SIT) + entries.put(uuid, new PetBedEntry(pos.immutable(), mode)); + } + + // Occupy the block entity + BlockEntity be = player.level().getBlockEntity(pos); + if (be instanceof PetBedBlockEntity petBed) { + petBed.occupy(uuid); + } + + // Teleport player to bed center and set rotation to bed facing + double cx = pos.getX() + 0.5; + double cy = pos.getY(); + double cz = pos.getZ() + 0.5; + + // Nudge player slightly backward on the bed + float bedYRot = getBedFacingAngle(player.level(), pos); + double rad = Math.toRadians(bedYRot); + double backOffset = 0.15; + cx += Math.sin(rad) * backOffset; + cz -= Math.cos(rad) * backOffset; + + player.teleportTo(cx, cy, cz); + + player.setYRot(bedYRot); + player.setYBodyRot(bedYRot); + player.setYHeadRot(bedYRot); + + // Immobilize the player (both SIT and SLEEP) + applySpeedModifier(player); + + // Sync to all clients + PacketSyncPetBedState packet = new PacketSyncPetBedState( + uuid, + mode == PetBedMode.SIT ? (byte) 1 : (byte) 2, + pos + ); + ModNetwork.sendToAllTrackingAndSelf(packet, player); + + TiedUpMod.LOGGER.debug( + "[PetBedManager] {} -> {} at {}", + player.getName().getString(), + mode, + pos + ); + } + + /** + * Clear a player from the pet bed system. + */ + public static void clearPlayer(ServerPlayer player) { + UUID uuid = player.getUUID(); + PetBedEntry entry = entries.remove(uuid); + if (entry == null) return; + + // Release block entity + BlockEntity be = player.level().getBlockEntity(entry.pos); + if (be instanceof PetBedBlockEntity petBed) { + petBed.release(); + } + + // Restore speed + removeSpeedModifier(player); + + // Sync clear to clients + PacketSyncPetBedState packet = new PacketSyncPetBedState( + uuid, + (byte) 0, + entry.pos + ); + ModNetwork.sendToAllTrackingAndSelf(packet, player); + + TiedUpMod.LOGGER.debug( + "[PetBedManager] {} cleared from pet bed at {}", + player.getName().getString(), + entry.pos + ); + } + + /** + * Check if a player is on a pet bed at the given position. + */ + public static boolean isOnBed(ServerPlayer player, BlockPos pos) { + PetBedEntry entry = entries.get(player.getUUID()); + return entry != null && entry.pos.equals(pos); + } + + /** + * Check if a player is on any pet bed. + */ + public static boolean isOnAnyBed(ServerPlayer player) { + return entries.containsKey(player.getUUID()); + } + + /** + * Get the current mode for a player, or null if not on a bed. + */ + @Nullable + public static PetBedMode getMode(ServerPlayer player) { + PetBedEntry entry = entries.get(player.getUUID()); + return entry != null ? entry.mode : null; + } + + /** + * Tick a player to check if pet bed state should be cancelled. + * Called from server-side tick handler. + */ + public static void tickPlayer(ServerPlayer player) { + PetBedEntry entry = entries.get(player.getUUID()); + if (entry == null) return; + + // SLEEP: detect vanilla wakeup (night skip, etc.) + if (entry.mode == PetBedMode.SLEEP && !player.isSleeping()) { + clearPlayer(player); + return; + } + + // Both modes: lock body rotation to bed facing (prevents camera from rotating model) + float bedYRot = getBedFacingAngle(player.level(), entry.pos); + player.setYBodyRot(bedYRot); + + // SLEEP: enforce correct Y position (vanilla startSleeping sets Y+0.2) + if (entry.mode == PetBedMode.SLEEP) { + double correctY = entry.pos.getY(); + if (Math.abs(player.getY() - correctY) > 0.01) { + player.teleportTo(player.getX(), correctY, player.getZ()); + } + return; + } + + // SIT: cancel on sneak (like dismounting a vehicle) + if (entry.mode != PetBedMode.SIT) return; + + if (player.isShiftKeyDown()) { + clearPlayer(player); + return; + } + + // Check if player moved too far from bed + double distSq = player.blockPosition().distSqr(entry.pos); + if (distSq > 2.25) { + // > 1.5 blocks + clearPlayer(player); + return; + } + + // Check if the block is still a pet bed + if ( + !(player.level().getBlockState(entry.pos).getBlock() instanceof + PetBedBlock) + ) { + clearPlayer(player); + } + } + + private static float getBedFacingAngle(Level level, BlockPos pos) { + BlockState state = level.getBlockState(pos); + if (state.hasProperty(PetBedBlock.FACING)) { + return state.getValue(PetBedBlock.FACING).toYRot(); + } + return 0f; + } + + /** + * Clean up when a player disconnects. + */ + public static void onPlayerDisconnect(UUID uuid) { + entries.remove(uuid); + } + + /** + * Remove any leftover pet bed speed modifier on login. + * The modifier persists on the entity through save/load, but the entries map doesn't, + * so we clean it up here to prevent the player from being stuck. + */ + public static void onPlayerLogin(ServerPlayer player) { + removeSpeedModifier(player); + } + + private static void applySpeedModifier(ServerPlayer player) { + AttributeInstance movementSpeed = player.getAttribute( + Attributes.MOVEMENT_SPEED + ); + if (movementSpeed == null) return; + + // Remove existing to avoid duplicates + if (movementSpeed.getModifier(PET_BED_SPEED_MODIFIER_UUID) != null) { + movementSpeed.removeModifier(PET_BED_SPEED_MODIFIER_UUID); + } + + AttributeModifier modifier = new AttributeModifier( + PET_BED_SPEED_MODIFIER_UUID, + PET_BED_SPEED_MODIFIER_NAME, + FULL_IMMOBILIZATION, + AttributeModifier.Operation.ADDITION + ); + movementSpeed.addPermanentModifier(modifier); + } + + private static void removeSpeedModifier(ServerPlayer player) { + AttributeInstance movementSpeed = player.getAttribute( + Attributes.MOVEMENT_SPEED + ); + if ( + movementSpeed != null && + movementSpeed.getModifier(PET_BED_SPEED_MODIFIER_UUID) != null + ) { + movementSpeed.removeModifier(PET_BED_SPEED_MODIFIER_UUID); + } + } +} diff --git a/src/main/java/com/tiedup/remake/v2/blocks/PetBowlBlock.java b/src/main/java/com/tiedup/remake/v2/blocks/PetBowlBlock.java new file mode 100644 index 0000000..a9e7fc2 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/blocks/PetBowlBlock.java @@ -0,0 +1,174 @@ +package com.tiedup.remake.v2.blocks; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.V2BlockEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.HorizontalDirectionalBlock; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.DirectionProperty; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.VoxelShape; +import org.jetbrains.annotations.Nullable; + +/** + * Pet Bowl Block - Used by Master to feed pets. + * Features: + * - Right-click with food to fill + * - Crouch + right-click to eat (only for pets) + * - Custom OBJ model rendering + */ +public class PetBowlBlock extends Block implements EntityBlock { + + public static final DirectionProperty FACING = + HorizontalDirectionalBlock.FACING; + + // Collision shape (rough approximation of bowl) + private static final VoxelShape SHAPE = Block.box( + 3.0, + 0.0, + 3.0, + 13.0, + 4.0, + 13.0 + ); + + public PetBowlBlock(Properties properties) { + super(properties); + this.registerDefaultState( + this.stateDefinition.any().setValue(FACING, Direction.NORTH) + ); + } + + @Override + protected void createBlockStateDefinition( + StateDefinition.Builder builder + ) { + builder.add(FACING); + } + + @Override + public BlockState getStateForPlacement(BlockPlaceContext context) { + return this.defaultBlockState().setValue( + FACING, + context.getHorizontalDirection().getOpposite() + ); + } + + @Override + public VoxelShape getShape( + BlockState state, + BlockGetter level, + BlockPos pos, + CollisionContext context + ) { + return SHAPE; + } + + @Override + public RenderShape getRenderShape(BlockState state) { + return RenderShape.ENTITYBLOCK_ANIMATED; // Use block entity renderer + } + + @Nullable + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new PetBowlBlockEntity( + V2BlockEntities.PET_BOWL.get(), + pos, + state + ); + } + + @Override + public InteractionResult use( + BlockState state, + Level level, + BlockPos pos, + Player player, + InteractionHand hand, + BlockHitResult hit + ) { + if (level.isClientSide) { + return InteractionResult.SUCCESS; + } + + BlockEntity be = level.getBlockEntity(pos); + if (!(be instanceof PetBowlBlockEntity bowl)) { + return InteractionResult.PASS; + } + + ItemStack heldItem = player.getItemInHand(hand); + + // Fill bowl with food items + if (isFood(heldItem)) { + int foodValue = getFoodValue(heldItem); + bowl.fillBowl(foodValue); + + if (!player.getAbilities().instabuild) { + heldItem.shrink(1); + } + + TiedUpMod.LOGGER.debug( + "[PetBowlBlock] {} filled bowl at {} with {}", + player.getName().getString(), + pos, + heldItem.getItem() + ); + + return InteractionResult.CONSUME; + } + + // Eat from bowl (empty hand) + if (heldItem.isEmpty() && bowl.hasFood()) { + // TODO: Check if player is a pet and allowed to eat + int consumed = bowl.eatFromBowl(); + if (consumed > 0) { + player.getFoodData().eat(consumed, 0.6f); + + TiedUpMod.LOGGER.debug( + "[PetBowlBlock] {} ate {} from bowl at {}", + player.getName().getString(), + consumed, + pos + ); + } + return InteractionResult.CONSUME; + } + + return InteractionResult.PASS; + } + + private boolean isFood(ItemStack stack) { + if (stack.isEmpty()) return false; + return ( + stack.getItem().isEdible() || + stack.is(Items.WHEAT) || + stack.is(Items.CARROT) || + stack.is(Items.APPLE) + ); + } + + private int getFoodValue(ItemStack stack) { + if (stack.getItem().isEdible()) { + var food = stack.getItem().getFoodProperties(); + return food != null ? food.getNutrition() : 2; + } + return 2; // Default for non-standard foods + } +} diff --git a/src/main/java/com/tiedup/remake/v2/blocks/PetBowlBlockEntity.java b/src/main/java/com/tiedup/remake/v2/blocks/PetBowlBlockEntity.java new file mode 100644 index 0000000..5c38ec3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/blocks/PetBowlBlockEntity.java @@ -0,0 +1,122 @@ +package com.tiedup.remake.v2.blocks; + +import com.tiedup.remake.core.TiedUpMod; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; + +/** + * Block entity for Pet Bowl. + * Used by Master to feed pets. Players must crouch to eat from it. + */ +public class PetBowlBlockEntity extends ObjBlockEntity { + + public static final ResourceLocation MODEL_LOCATION = + ResourceLocation.fromNamespaceAndPath( + TiedUpMod.MOD_ID, + "models/obj/blocks/bowl/model.obj" + ); + + public static final ResourceLocation TEXTURE_LOCATION = + ResourceLocation.fromNamespaceAndPath( + TiedUpMod.MOD_ID, + "textures/block/pet_bowl.png" + ); + + /** Whether the bowl currently has food */ + private boolean hasFood = false; + + /** Food saturation level (0-20) */ + private int foodLevel = 0; + + public PetBowlBlockEntity( + BlockEntityType type, + BlockPos pos, + BlockState state + ) { + super(type, pos, state); + } + + @Override + public ResourceLocation getModelLocation() { + return MODEL_LOCATION; + } + + @Override + public ResourceLocation getTextureLocation() { + return TEXTURE_LOCATION; + } + + @Override + public float getModelScale() { + return 1.0f; // Adjust based on model size + } + + // ======================================== + // FOOD MANAGEMENT + // ======================================== + + public boolean hasFood() { + return hasFood && foodLevel > 0; + } + + public int getFoodLevel() { + return foodLevel; + } + + /** + * Fill the bowl with food. + * + * @param amount Amount of food to add (saturation points) + */ + public void fillBowl(int amount) { + this.foodLevel = Math.min(20, this.foodLevel + amount); + this.hasFood = this.foodLevel > 0; + setChanged(); + } + + /** + * Pet eats from the bowl. + * + * @return Amount of food consumed (0 if empty) + */ + public int eatFromBowl() { + if (!hasFood()) return 0; + + int consumed = Math.min(4, foodLevel); // Eat up to 4 at a time + foodLevel -= consumed; + hasFood = foodLevel > 0; + setChanged(); + + return consumed; + } + + /** + * Empty the bowl completely. + */ + public void emptyBowl() { + this.foodLevel = 0; + this.hasFood = false; + setChanged(); + } + + // ======================================== + // NBT + // ======================================== + + @Override + protected void saveAdditional(CompoundTag tag) { + super.saveAdditional(tag); + tag.putBoolean("hasFood", hasFood); + tag.putInt("foodLevel", foodLevel); + } + + @Override + public void load(CompoundTag tag) { + super.load(tag); + this.hasFood = tag.getBoolean("hasFood"); + this.foodLevel = tag.getInt("foodLevel"); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/blocks/PetCageBlock.java b/src/main/java/com/tiedup/remake/v2/blocks/PetCageBlock.java new file mode 100644 index 0000000..24c838b --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/blocks/PetCageBlock.java @@ -0,0 +1,538 @@ +package com.tiedup.remake.v2.blocks; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.v2.V2BlockEntities; +import com.tiedup.remake.v2.V2Blocks; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.HorizontalDirectionalBlock; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.DirectionProperty; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.Shapes; +import net.minecraft.world.phys.shapes.VoxelShape; +import org.jetbrains.annotations.Nullable; + +/** + * Pet Cage Block (Master) - Multi-block cage (3x3x2). + * + *

Collision is computed from the actual OBJ model dimensions. Each block in + * the 3x3x2 grid gets collision shapes for any cage walls that pass through it, + * clipped to the block boundaries. This gives pixel-accurate cage collision + * that matches the visual model. + */ +public class PetCageBlock extends Block implements EntityBlock { + + public static final DirectionProperty FACING = + HorizontalDirectionalBlock.FACING; + + // ======================================== + // OBJ MODEL DIMENSIONS (in model space, before rotation/translation) + // ======================================== + private static final double MODEL_MIN_X = -1.137; + private static final double MODEL_MAX_X = 1.0; + private static final double MODEL_MIN_Z = -1.122; + private static final double MODEL_MAX_Z = 1.0; + private static final double MODEL_MAX_Y = 2.218; + + /** Wall thickness in blocks. */ + private static final double WALL_T = 0.125; // 2 pixels + + private static final VoxelShape OUTLINE_SHAPE = Block.box( + 0, + 0, + 0, + 16, + 16, + 16 + ); + + public PetCageBlock(Properties properties) { + super(properties); + this.registerDefaultState( + this.stateDefinition.any().setValue(FACING, Direction.NORTH) + ); + } + + @Override + protected void createBlockStateDefinition( + StateDefinition.Builder builder + ) { + builder.add(FACING); + } + + @Override + public BlockState getStateForPlacement(BlockPlaceContext context) { + Direction facing = context.getHorizontalDirection().getOpposite(); + BlockPos pos = context.getClickedPos(); + + for (BlockPos partPos : getPartPositions(pos, facing)) { + if ( + !context + .getLevel() + .getBlockState(partPos) + .canBeReplaced(context) + ) { + return null; + } + } + + return this.defaultBlockState().setValue(FACING, facing); + } + + @Override + public void onPlace( + BlockState state, + Level level, + BlockPos pos, + BlockState oldState, + boolean isMoving + ) { + super.onPlace(state, level, pos, oldState, isMoving); + if (level.isClientSide || oldState.is(this)) return; + + Direction facing = state.getValue(FACING); + BlockState partState = V2Blocks.PET_CAGE_PART.get() + .defaultBlockState() + .setValue(PetCagePartBlock.FACING, facing); + + for (BlockPos partPos : getPartPositions(pos, facing)) { + level.setBlock(partPos, partState, 3); + } + } + + @Override + public VoxelShape getShape( + BlockState state, + BlockGetter level, + BlockPos pos, + CollisionContext context + ) { + return OUTLINE_SHAPE; + } + + @Override + public VoxelShape getCollisionShape( + BlockState state, + BlockGetter level, + BlockPos pos, + CollisionContext context + ) { + Direction facing = state.getValue(FACING); + return computeCageCollision(pos, facing, pos); + } + + @Override + public RenderShape getRenderShape(BlockState state) { + return RenderShape.ENTITYBLOCK_ANIMATED; + } + + @Nullable + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new PetCageBlockEntity( + V2BlockEntities.PET_CAGE.get(), + pos, + state + ); + } + + @Override + public InteractionResult use( + BlockState state, + Level level, + BlockPos pos, + Player player, + InteractionHand hand, + BlockHitResult hit + ) { + if (level.isClientSide) { + return InteractionResult.CONSUME; + } + + if (!(player instanceof ServerPlayer sp)) { + return InteractionResult.PASS; + } + + BlockEntity be = level.getBlockEntity(pos); + if (!(be instanceof PetCageBlockEntity cageBE)) { + return InteractionResult.PASS; + } + + if (cageBE.hasOccupant()) { + ServerPlayer occupant = cageBE.getOccupant(level.getServer()); + if (occupant != null) { + PetCageManager.releasePlayer(occupant); + TiedUpMod.LOGGER.debug( + "[PetCageBlock] {} released {} from cage at {}", + player.getName().getString(), + occupant.getName().getString(), + pos + ); + } else { + cageBE.clearOccupant(); + } + return InteractionResult.SUCCESS; + } + + double cx = pos.getX() + 0.5; + double cy = pos.getY(); + double cz = pos.getZ() + 0.5; + + for (ServerPlayer nearby : level + .getServer() + .getPlayerList() + .getPlayers()) { + if (nearby == player) continue; + if (nearby.distanceToSqr(cx, cy, cz) > 9.0) continue; + + IBondageState kidState = KidnappedHelper.getKidnappedState(nearby); + if (kidState != null && kidState.isTiedUp()) { + PetCageManager.cagePlayer(nearby, pos); + cageBE.setOccupant(nearby.getUUID()); + TiedUpMod.LOGGER.debug( + "[PetCageBlock] {} caged {} at {}", + player.getName().getString(), + nearby.getName().getString(), + pos + ); + return InteractionResult.SUCCESS; + } + } + + return InteractionResult.PASS; + } + + @Override + public void onRemove( + BlockState state, + Level level, + BlockPos pos, + BlockState newState, + boolean isMoving + ) { + if (!state.is(newState.getBlock())) { + BlockEntity be = level.getBlockEntity(pos); + if ( + be instanceof PetCageBlockEntity cageBE && + cageBE.hasOccupant() && + level.getServer() != null + ) { + ServerPlayer occupant = cageBE.getOccupant(level.getServer()); + if (occupant != null) { + PetCageManager.releasePlayer(occupant); + } + } + + Direction facing = state.getValue(FACING); + for (BlockPos partPos : getPartPositions(pos, facing)) { + BlockState partState = level.getBlockState(partPos); + if (partState.getBlock() instanceof PetCagePartBlock) { + level.setBlock( + partPos, + Blocks.AIR.defaultBlockState(), + 3 | 64 + ); + } + } + } + super.onRemove(state, level, pos, newState, isMoving); + } + + // ======================================== + // CAGE COLLISION (model-accurate) + // ======================================== + + /** + * Compute the cage AABB in world coordinates for a given master position and facing. + * The renderer does translate(0.5, 0, 0.5) then rotate, so vertices are + * first rotated then translated. + * + * @return {minX, minZ, maxX, maxZ, maxY} + */ + public static double[] getCageWorldBounds( + BlockPos masterPos, + Direction facing + ) { + double cx = masterPos.getX() + 0.5; + double cz = masterPos.getZ() + 0.5; + + double wMinX, wMaxX, wMinZ, wMaxZ; + + switch (facing) { + case SOUTH -> { + // 180°: (x,z) → (-x,-z) + wMinX = cx - MODEL_MAX_X; + wMaxX = cx - MODEL_MIN_X; + wMinZ = cz - MODEL_MAX_Z; + wMaxZ = cz - MODEL_MIN_Z; + } + case WEST -> { + // 90°: (x,z) → (z,-x) + wMinX = cx + MODEL_MIN_Z; + wMaxX = cx + MODEL_MAX_Z; + wMinZ = cz - MODEL_MAX_X; + wMaxZ = cz - MODEL_MIN_X; + } + case EAST -> { + // -90°: (x,z) → (-z,x) + wMinX = cx - MODEL_MAX_Z; + wMaxX = cx - MODEL_MIN_Z; + wMinZ = cz + MODEL_MIN_X; + wMaxZ = cz + MODEL_MAX_X; + } + default -> { + // NORTH, 0°: (x,z) → (x,z) + wMinX = cx + MODEL_MIN_X; + wMaxX = cx + MODEL_MAX_X; + wMinZ = cz + MODEL_MIN_Z; + wMaxZ = cz + MODEL_MAX_Z; + } + } + + return new double[] { + wMinX, + wMinZ, + wMaxX, + wMaxZ, + masterPos.getY() + MODEL_MAX_Y, + }; + } + + /** + * Compute the cage collision VoxelShape for any block position within the cage grid. + * Clips the 4 cage walls + floor to the block's boundaries. + */ + public static VoxelShape computeCageCollision( + BlockPos masterPos, + Direction facing, + BlockPos blockPos + ) { + double[] bounds = getCageWorldBounds(masterPos, facing); + double cMinX = bounds[0], + cMinZ = bounds[1]; + double cMaxX = bounds[2], + cMaxZ = bounds[3]; + double cMaxY = bounds[4]; + double cMinY = masterPos.getY(); // Cage base = master Y + + double bx = blockPos.getX(); + double by = blockPos.getY(); + double bz = blockPos.getZ(); + + VoxelShape shape = Shapes.empty(); + + // Left wall (at cMinX, perpendicular to X) — full height from base to ceiling + shape = addClippedWall( + shape, + cMinX - WALL_T / 2, + cMinY, + cMinZ, + cMinX + WALL_T / 2, + cMaxY, + cMaxZ, + bx, + by, + bz + ); + + // Right wall (at cMaxX) + shape = addClippedWall( + shape, + cMaxX - WALL_T / 2, + cMinY, + cMinZ, + cMaxX + WALL_T / 2, + cMaxY, + cMaxZ, + bx, + by, + bz + ); + + // Front wall (at cMinZ, perpendicular to Z) + shape = addClippedWall( + shape, + cMinX, + cMinY, + cMinZ - WALL_T / 2, + cMaxX, + cMaxY, + cMinZ + WALL_T / 2, + bx, + by, + bz + ); + + // Back wall (at cMaxZ) + shape = addClippedWall( + shape, + cMinX, + cMinY, + cMaxZ - WALL_T / 2, + cMaxX, + cMaxY, + cMaxZ + WALL_T / 2, + bx, + by, + bz + ); + + // Floor (at cage base only) + shape = addClippedWall( + shape, + cMinX, + cMinY, + cMinZ, + cMaxX, + cMinY + WALL_T, + cMaxZ, + bx, + by, + bz + ); + + // Ceiling: 1px thin at [1.9375, 2.0] — player head at 1.925 < 1.9375, works from both sides + double ceilY = cMinY + 2.0; + double ceilT = 0.0625; // 1 pixel thin so player (1.8 tall) fits under it + shape = addClippedWall( + shape, + cMinX, + ceilY - ceilT, + cMinZ, + cMaxX, + ceilY, + cMaxZ, + bx, + by, + bz + ); + + return shape; + } + + /** + * Clip a world-space AABB to a block's local bounds and add it as a VoxelShape. + * X and Z are clipped to [0,1] (entities in other blocks won't query this one). + * Y max is allowed to extend above 1.0 (like fences) since entities below + * will still query this block and collide with the extended shape. + */ + private static VoxelShape addClippedWall( + VoxelShape existing, + double wMinX, + double wMinY, + double wMinZ, + double wMaxX, + double wMaxY, + double wMaxZ, + double blockX, + double blockY, + double blockZ + ) { + double lMinX = Math.max(0, wMinX - blockX); + double lMinY = Math.max(0, wMinY - blockY); + double lMinZ = Math.max(0, wMinZ - blockZ); + double lMaxX = Math.min(1, wMaxX - blockX); + double lMaxY = Math.min(1.5, wMaxY - blockY); // Allow extending up to 0.5 above block (like fences) + double lMaxZ = Math.min(1, wMaxZ - blockZ); + + if (lMinX >= lMaxX || lMinY >= lMaxY || lMinZ >= lMaxZ || lMaxY <= 0) { + return existing; + } + + return Shapes.or( + existing, + Shapes.box(lMinX, lMinY, lMinZ, lMaxX, lMaxY, lMaxZ) + ); + } + + // ======================================== + // MULTI-BLOCK LAYOUT (3x3x2) + // ======================================== + + private static final int[][] GRID_OFFSETS; + + static { + java.util.List offsets = new java.util.ArrayList<>(); + for (int dy = 0; dy <= 1; dy++) { + for (int dl = -1; dl <= 1; dl++) { + for (int df = -1; df <= 1; df++) { + if (dl == 0 && dy == 0 && df == 0) continue; + offsets.add(new int[] { dl, dy, df }); + } + } + } + GRID_OFFSETS = offsets.toArray(new int[0][]); + } + + public static BlockPos[] getPartPositions( + BlockPos masterPos, + Direction facing + ) { + Direction left = facing.getCounterClockWise(); + + int lx = left.getStepX(); + int lz = left.getStepZ(); + int fx = facing.getStepX(); + int fz = facing.getStepZ(); + + BlockPos[] positions = new BlockPos[GRID_OFFSETS.length]; + for (int i = 0; i < GRID_OFFSETS.length; i++) { + int dl = GRID_OFFSETS[i][0]; + int dy = GRID_OFFSETS[i][1]; + int df = GRID_OFFSETS[i][2]; + + int wx = dl * lx + df * fx; + int wz = dl * lz + df * fz; + positions[i] = masterPos.offset(wx, dy, wz); + } + return positions; + } + + @Nullable + public static int[] getPartLocalOffset( + BlockPos masterPos, + Direction facing, + BlockPos partPos + ) { + Direction left = facing.getCounterClockWise(); + + int dx = partPos.getX() - masterPos.getX(); + int dy = partPos.getY() - masterPos.getY(); + int dz = partPos.getZ() - masterPos.getZ(); + + int lx = left.getStepX(), + lz = left.getStepZ(); + int fx = facing.getStepX(), + fz = facing.getStepZ(); + + int det = lx * fz - fx * lz; + if (det == 0) return null; + + int dl = (dx * fz - dz * fx) / det; + int df = (dz * lx - dx * lz) / det; + + if ( + dl < -1 || dl > 1 || df < -1 || df > 1 || dy < 0 || dy > 1 + ) return null; + if (dl == 0 && dy == 0 && df == 0) return null; + + return new int[] { dl, dy, df }; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/blocks/PetCageBlockEntity.java b/src/main/java/com/tiedup/remake/v2/blocks/PetCageBlockEntity.java new file mode 100644 index 0000000..c90badb --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/blocks/PetCageBlockEntity.java @@ -0,0 +1,129 @@ +package com.tiedup.remake.v2.blocks; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.AABB; +import org.jetbrains.annotations.Nullable; + +/** + * Block entity for Pet Cage. + * Tracks the occupant UUID and provides OBJ model rendering data. + */ +public class PetCageBlockEntity extends ObjBlockEntity { + + public static final ResourceLocation MODEL_LOCATION = + ResourceLocation.fromNamespaceAndPath( + TiedUpMod.MOD_ID, + "models/obj/blocks/cage/model.obj" + ); + + public static final ResourceLocation TEXTURE_LOCATION = + ResourceLocation.fromNamespaceAndPath( + TiedUpMod.MOD_ID, + "textures/block/pet_cage.png" + ); + + @Nullable + private UUID occupantUUID; + + public PetCageBlockEntity( + BlockEntityType type, + BlockPos pos, + BlockState state + ) { + super(type, pos, state); + } + + @Override + public ResourceLocation getModelLocation() { + return MODEL_LOCATION; + } + + @Override + public ResourceLocation getTextureLocation() { + return TEXTURE_LOCATION; + } + + @Override + public float getModelScale() { + return 1.0f; + } + + /** + * Large render bounding box so the cage doesn't disappear when the camera + * is inside the model (e.g. player caged) or looking away from the master block. + */ + @Override + public AABB getRenderBoundingBox() { + BlockPos p = getBlockPos(); + return new AABB( + p.getX() - 2, + p.getY(), + p.getZ() - 2, + p.getX() + 3, + p.getY() + 3, + p.getZ() + 3 + ); + } + + // ======================================== + // OCCUPANT MANAGEMENT + // ======================================== + + public boolean hasOccupant() { + return occupantUUID != null; + } + + @Nullable + public UUID getOccupantUUID() { + return occupantUUID; + } + + public void setOccupant(UUID uuid) { + this.occupantUUID = uuid; + setChanged(); + } + + public void clearOccupant() { + this.occupantUUID = null; + setChanged(); + } + + /** + * Get the occupant ServerPlayer if online. + */ + @Nullable + public ServerPlayer getOccupant(@Nullable MinecraftServer server) { + if (server == null || occupantUUID == null) return null; + return server.getPlayerList().getPlayer(occupantUUID); + } + + // ======================================== + // NBT + // ======================================== + + @Override + protected void saveAdditional(CompoundTag tag) { + super.saveAdditional(tag); + if (occupantUUID != null) { + tag.putUUID("occupantUUID", occupantUUID); + } + } + + @Override + public void load(CompoundTag tag) { + super.load(tag); + if (tag.hasUUID("occupantUUID")) { + this.occupantUUID = tag.getUUID("occupantUUID"); + } else { + this.occupantUUID = null; + } + } +} diff --git a/src/main/java/com/tiedup/remake/v2/blocks/PetCageManager.java b/src/main/java/com/tiedup/remake/v2/blocks/PetCageManager.java new file mode 100644 index 0000000..63cfb50 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/blocks/PetCageManager.java @@ -0,0 +1,189 @@ +package com.tiedup.remake.v2.blocks; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.attributes.AttributeInstance; +import net.minecraft.world.entity.ai.attributes.AttributeModifier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.level.block.entity.BlockEntity; + +/** + * Server-side manager for pet cage confinement. + * Tracks which players are caged, immobilizes them, and handles cleanup. + */ +public class PetCageManager { + + private static final UUID PET_CAGE_SPEED_MODIFIER_UUID = UUID.fromString( + "b2c3d4e5-f6a7-4890-bcde-f01234567890" + ); + private static final String PET_CAGE_SPEED_MODIFIER_NAME = + "tiedup.pet_cage_immobilize"; + private static final double FULL_IMMOBILIZATION = -0.10; + + /** Active cage entries keyed by player UUID */ + private static final Map entries = + new ConcurrentHashMap<>(); + + private static class CageEntry { + + final BlockPos pos; + + CageEntry(BlockPos pos) { + this.pos = pos; + } + } + + /** + * Cage a player at the given master block position. + * Teleports them to the center of the 3x3x3 cage and immobilizes. + */ + public static void cagePlayer(ServerPlayer player, BlockPos masterPos) { + UUID uuid = player.getUUID(); + entries.put(uuid, new CageEntry(masterPos.immutable())); + + // Teleport to cage center (master is at center of 3x3x3 grid) + double[] center = getCageCenter(player, masterPos); + player.teleportTo(center[0], center[1], center[2]); + + // Immobilize + applySpeedModifier(player); + + TiedUpMod.LOGGER.debug( + "[PetCageManager] {} caged at {}", + player.getName().getString(), + masterPos + ); + } + + /** + * Release a player from cage confinement. + */ + public static void releasePlayer(ServerPlayer player) { + UUID uuid = player.getUUID(); + CageEntry entry = entries.remove(uuid); + if (entry == null) return; + + // Release block entity + BlockEntity be = player.level().getBlockEntity(entry.pos); + if (be instanceof PetCageBlockEntity cageBE) { + cageBE.clearOccupant(); + } + + // Restore speed + removeSpeedModifier(player); + + TiedUpMod.LOGGER.debug( + "[PetCageManager] {} released from cage at {}", + player.getName().getString(), + entry.pos + ); + } + + /** + * Check if a player is in a cage. + */ + public static boolean isInCage(ServerPlayer player) { + return entries.containsKey(player.getUUID()); + } + + /** + * Get the cage position for a player, or null if not caged. + */ + @Nullable + public static BlockPos getCagePos(ServerPlayer player) { + CageEntry entry = entries.get(player.getUUID()); + return entry != null ? entry.pos : null; + } + + /** + * Tick a caged player to verify cage still exists. + * Called from server-side tick handler. + */ + public static void tickPlayer(ServerPlayer player) { + CageEntry entry = entries.get(player.getUUID()); + if (entry == null) return; + + // Check if the block is still a cage + if ( + !(player.level().getBlockState(entry.pos).getBlock() instanceof + PetCageBlock) + ) { + releasePlayer(player); + return; + } + + // Keep player at cage center (prevent movement exploits) + double[] center = getCageCenter(player, entry.pos); + double distSq = player.distanceToSqr(center[0], center[1], center[2]); + if (distSq > 0.25) { + // > 0.5 blocks + player.teleportTo(center[0], center[1], center[2]); + } + } + + /** + * Calculate the center of the 3x3x3 cage from the master block position. + * The master is at the center of the grid, so the cage center is the master block center. + */ + private static double[] getCageCenter( + ServerPlayer player, + BlockPos masterPos + ) { + return new double[] { + masterPos.getX() + 0.5, + masterPos.getY(), + masterPos.getZ() + 0.5, + }; + } + + /** + * Clean up when a player disconnects. + */ + public static void onPlayerDisconnect(UUID uuid) { + entries.remove(uuid); + } + + /** + * Remove any leftover cage speed modifier on login. + */ + public static void onPlayerLogin(ServerPlayer player) { + removeSpeedModifier(player); + } + + private static void applySpeedModifier(ServerPlayer player) { + AttributeInstance movementSpeed = player.getAttribute( + Attributes.MOVEMENT_SPEED + ); + if (movementSpeed == null) return; + + // Remove existing to avoid duplicates + if (movementSpeed.getModifier(PET_CAGE_SPEED_MODIFIER_UUID) != null) { + movementSpeed.removeModifier(PET_CAGE_SPEED_MODIFIER_UUID); + } + + AttributeModifier modifier = new AttributeModifier( + PET_CAGE_SPEED_MODIFIER_UUID, + PET_CAGE_SPEED_MODIFIER_NAME, + FULL_IMMOBILIZATION, + AttributeModifier.Operation.ADDITION + ); + movementSpeed.addPermanentModifier(modifier); + } + + private static void removeSpeedModifier(ServerPlayer player) { + AttributeInstance movementSpeed = player.getAttribute( + Attributes.MOVEMENT_SPEED + ); + if ( + movementSpeed != null && + movementSpeed.getModifier(PET_CAGE_SPEED_MODIFIER_UUID) != null + ) { + movementSpeed.removeModifier(PET_CAGE_SPEED_MODIFIER_UUID); + } + } +} diff --git a/src/main/java/com/tiedup/remake/v2/blocks/PetCagePartBlock.java b/src/main/java/com/tiedup/remake/v2/blocks/PetCagePartBlock.java new file mode 100644 index 0000000..3ec60e4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/blocks/PetCagePartBlock.java @@ -0,0 +1,169 @@ +package com.tiedup.remake.v2.blocks; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.HorizontalDirectionalBlock; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.DirectionProperty; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.Shapes; +import net.minecraft.world.phys.shapes.VoxelShape; +import org.jetbrains.annotations.Nullable; + +/** + * Pet Cage Part Block (Slave) - Invisible structural part of a 3x3x2 multi-block cage. + * + *

No visible model, no selection outline. Provides collision shapes computed + * from the actual OBJ model dimensions (delegated to {@link PetCageBlock#computeCageCollision}). + * Interactions and breaking are delegated to the master. + */ +public class PetCagePartBlock extends Block { + + public static final DirectionProperty FACING = + HorizontalDirectionalBlock.FACING; + + public PetCagePartBlock(Properties properties) { + super(properties); + this.registerDefaultState( + this.stateDefinition.any().setValue(FACING, Direction.NORTH) + ); + } + + @Override + protected void createBlockStateDefinition( + StateDefinition.Builder builder + ) { + builder.add(FACING); + } + + @Override + public VoxelShape getShape( + BlockState state, + BlockGetter level, + BlockPos pos, + CollisionContext context + ) { + // Invisible: no outline, no selection box + return Shapes.empty(); + } + + @Override + public VoxelShape getCollisionShape( + BlockState state, + BlockGetter level, + BlockPos pos, + CollisionContext context + ) { + Direction facing = state.getValue(FACING); + BlockPos masterPos = findMasterPos(state, level, pos); + if (masterPos == null) return Shapes.empty(); + return PetCageBlock.computeCageCollision(masterPos, facing, pos); + } + + @Override + public RenderShape getRenderShape(BlockState state) { + return RenderShape.INVISIBLE; + } + + @Override + public InteractionResult use( + BlockState state, + Level level, + BlockPos pos, + Player player, + InteractionHand hand, + BlockHitResult hit + ) { + BlockPos masterPos = findMasterPos(state, level, pos); + if (masterPos != null) { + BlockState masterState = level.getBlockState(masterPos); + if (masterState.getBlock() instanceof PetCageBlock cage) { + return cage.use( + masterState, + level, + masterPos, + player, + hand, + hit + ); + } + } + return InteractionResult.PASS; + } + + @Override + public void onRemove( + BlockState state, + Level level, + BlockPos pos, + BlockState newState, + boolean isMoving + ) { + if (!state.is(newState.getBlock())) { + BlockPos masterPos = findMasterPos(state, level, pos); + if (masterPos != null) { + BlockState masterState = level.getBlockState(masterPos); + if (masterState.getBlock() instanceof PetCageBlock) { + level.destroyBlock(masterPos, true); + } + } + } + super.onRemove(state, level, pos, newState, isMoving); + } + + @Override + public void playerWillDestroy( + Level level, + BlockPos pos, + BlockState state, + Player player + ) { + // Don't call super to prevent double drops + } + + @Nullable + private BlockPos findMasterPos( + BlockState partState, + BlockGetter level, + BlockPos partPos + ) { + Direction facing = partState.getValue(FACING); + Direction left = facing.getCounterClockWise(); + + int lx = left.getStepX(), + lz = left.getStepZ(); + int fx = facing.getStepX(), + fz = facing.getStepZ(); + + for (int dy = 0; dy <= 1; dy++) { + for (int dl = -1; dl <= 1; dl++) { + for (int df = -1; df <= 1; df++) { + if (dl == 0 && dy == 0 && df == 0) continue; + + int wx = -(dl * lx + df * fx); + int wz = -(dl * lz + df * fz); + BlockPos candidate = partPos.offset(wx, -dy, wz); + + BlockState candidateState = level.getBlockState(candidate); + if ( + candidateState.getBlock() instanceof PetCageBlock && + candidateState.getValue(PetCageBlock.FACING) == facing + ) { + return candidate; + } + } + } + } + + return null; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/IV2BondageEquipment.java b/src/main/java/com/tiedup/remake/v2/bondage/IV2BondageEquipment.java new file mode 100644 index 0000000..30528f2 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/IV2BondageEquipment.java @@ -0,0 +1,127 @@ +package com.tiedup.remake.v2.bondage; + +import com.tiedup.remake.v2.BodyRegionV2; + +import java.util.Map; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraftforge.common.capabilities.AutoRegisterCapability; +import org.jetbrains.annotations.Nullable; + +/** + * Capability interface for V2 bondage equipment storage. + * + * Stores ItemStacks keyed by {@link BodyRegionV2}. Multi-region items + * share the same ItemStack reference across all occupied regions. + * + * CRITICAL: The {@link AutoRegisterCapability} annotation is required + * for Forge to register this capability automatically. + */ +@AutoRegisterCapability +public interface IV2BondageEquipment { + + /** + * Get the item equipped in the given region. + * @return The ItemStack in that region, or {@link ItemStack#EMPTY} — never null. + */ + ItemStack getInRegion(BodyRegionV2 region); + + /** + * Set the item in a specific region. Pass {@link ItemStack#EMPTY} to clear. + */ + void setInRegion(BodyRegionV2 region, ItemStack stack); + + /** + * Get all equipped items, de-duplicated. Multi-region items appear once. + * @return Unmodifiable map from one representative region to the ItemStack. + */ + Map getAllEquipped(); + + /** + * Check if a region has an item directly equipped in it. + */ + boolean isRegionOccupied(BodyRegionV2 region); + + /** + * Check if a region is blocked by any equipped item's {@link IV2BondageItem#getBlockedRegions()}. + * For example, if a Hood (blocks EYES/EARS/MOUTH) is equipped, EYES is blocked. + */ + boolean isRegionBlocked(BodyRegionV2 region); + + /** + * Count distinct equipped items (de-duplicated for multi-region items). + */ + int getEquippedCount(); + + /** + * Clear all regions, removing all equipped items. + */ + void clearAll(); + + /** + * Serialize all equipped items to NBT. + */ + CompoundTag serializeNBT(); + + /** + * Deserialize equipped items from NBT, replacing current state. + */ + void deserializeNBT(CompoundTag tag); + + // ======================================== + // Pole leash persistence + // ======================================== + + /** + * Whether the player was leashed to a pole when they disconnected. + */ + boolean wasLeashedToPole(); + + /** + * Get the saved pole position, or null if none. + */ + @Nullable BlockPos getSavedPolePosition(); + + /** + * Get the saved pole dimension, or null if none. + */ + @Nullable ResourceKey getSavedPoleDimension(); + + /** + * Save the pole leash state for restoration on reconnect. + */ + void savePoleLeash(BlockPos pos, ResourceKey dimension); + + /** + * Clear saved pole leash state. + */ + void clearSavedPoleLeash(); + + // ======================================== + // Captor persistence + // ======================================== + + /** + * Whether the player had a saved captor when they disconnected. + */ + boolean hasSavedCaptor(); + + /** + * Get the saved captor UUID, or null if none. + */ + @Nullable UUID getSavedCaptorUUID(); + + /** + * Save the captor UUID for restoration on reconnect. + */ + void saveCaptorUUID(UUID uuid); + + /** + * Clear saved captor state. + */ + void clearSavedCaptor(); +} \ No newline at end of file diff --git a/src/main/java/com/tiedup/remake/v2/bondage/IV2BondageItem.java b/src/main/java/com/tiedup/remake/v2/bondage/IV2BondageItem.java new file mode 100644 index 0000000..cd32486 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/IV2BondageItem.java @@ -0,0 +1,167 @@ +package com.tiedup.remake.v2.bondage; + +import com.tiedup.remake.v2.BodyRegionV2; + +import java.util.Set; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; + +/** + * Core interface for V2 bondage items. + * + * Implemented by Item classes that can be equipped into V2 body regions. + * Provides region occupation, 3D model info, pose/animation data, + * and lifecycle hooks. + */ +public interface IV2BondageItem { + + // ===== REGIONS ===== + + /** + * Which body regions this item occupies. + * Example: Armbinder returns {ARMS, HANDS, TORSO}. + */ + Set getOccupiedRegions(); + + /** + * Stack-aware variant. Data-driven items override this to read regions from NBT/registry. + * Default delegates to the no-arg version (backward compatible for hardcoded items). + */ + default Set getOccupiedRegions(ItemStack stack) { + return getOccupiedRegions(); + } + + /** + * Which regions this item blocks from having other items. + * Usually same as occupied, but could differ. + * Example: Hood occupies HEAD but blocks {HEAD, EYES, EARS, MOUTH}. + * Defaults to same as {@link #getOccupiedRegions()}. + */ + default Set getBlockedRegions() { + return getOccupiedRegions(); + } + + /** + * Stack-aware variant. Data-driven items override this to read blocked regions from NBT/registry. + */ + default Set getBlockedRegions(ItemStack stack) { + return getBlockedRegions(); + } + + // ===== 3D MODELS ===== + + /** + * Get the glTF model location (.glb file). + * Returns null for items without a 3D model (e.g., clothes-only items). + */ + @Nullable + ResourceLocation getModelLocation(); + + /** + * Stack-aware variant. Data-driven items override this to read model from NBT/registry. + */ + @Nullable + default ResourceLocation getModelLocation(ItemStack stack) { + return getModelLocation(); + } + + // ===== POSES & ANIMATIONS ===== + + /** + * Priority for pose conflicts. Higher value wins. + * Example: Fullbind (100) > Armbinder (50) > Handcuffs (30) > Collar (10) > None (0). + */ + int getPosePriority(); + + /** + * Stack-aware variant for pose priority. + */ + default int getPosePriority(ItemStack stack) { + return getPosePriority(); + } + + // ===== ITEM STATE ===== + + /** + * Difficulty to escape (for struggle minigame). Higher = harder. + */ + int getEscapeDifficulty(); + + /** + * Stack-aware variant for escape difficulty. + */ + default int getEscapeDifficulty(ItemStack stack) { + return getEscapeDifficulty(); + } + + // ===== RENDERING ===== + + /** + * Whether this item supports color variants (texture_red.png, etc.). + */ + boolean supportsColor(); + + /** + * Stack-aware variant for color support. + */ + default boolean supportsColor(ItemStack stack) { + return supportsColor(); + } + + /** + * Whether this item supports a slim model variant (Alex-style 3px arms). + */ + boolean supportsSlimModel(); + + /** + * Stack-aware variant for slim model support. + */ + default boolean supportsSlimModel(ItemStack stack) { + return supportsSlimModel(); + } + + /** + * Get the slim model location (.glb) for Alex-style players. + * Only meaningful if {@link #supportsSlimModel()} returns true. + */ + @Nullable + default ResourceLocation getSlimModelLocation() { + return null; + } + + /** + * Stack-aware variant for slim model location. + */ + @Nullable + default ResourceLocation getSlimModelLocation(ItemStack stack) { + return getSlimModelLocation(); + } + + // ===== LIFECYCLE HOOKS ===== + + /** + * Called when this item is equipped onto an entity. + */ + default void onEquipped(ItemStack stack, LivingEntity entity) {} + + /** + * Called when this item is unequipped from an entity. + */ + default void onUnequipped(ItemStack stack, LivingEntity entity) {} + + /** + * Whether this item can be equipped on the given entity right now. + */ + default boolean canEquip(ItemStack stack, LivingEntity entity) { + return true; + } + + /** + * Whether this item can be unequipped from the given entity right now. + */ + default boolean canUnequip(ItemStack stack, LivingEntity entity) { + return true; + } +} \ No newline at end of file diff --git a/src/main/java/com/tiedup/remake/v2/bondage/IV2EquipmentHolder.java b/src/main/java/com/tiedup/remake/v2/bondage/IV2EquipmentHolder.java new file mode 100644 index 0000000..d887d6c --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/IV2EquipmentHolder.java @@ -0,0 +1,26 @@ +package com.tiedup.remake.v2.bondage; + +/** + * Interface for entities that hold V2 bondage equipment internally + * (not via Forge capabilities). + * + * Implemented by EntityDamsel (Epic 4B) to allow V2EquipmentHelper + * to dispatch equipment operations without a circular dependency + * between v2.bondage and entities packages. + * + * Players use Forge capabilities instead — they do NOT implement this. + */ +public interface IV2EquipmentHolder { + + /** + * Get the V2 equipment storage for this entity. + * @return The equipment instance, never null. + */ + IV2BondageEquipment getV2Equipment(); + + /** + * Sync the internal V2 equipment state to the entity's SynchedEntityData. + * Called by V2EquipmentHelper after write operations (equip/unequip/clear). + */ + void syncEquipmentToData(); +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/V2BondageItems.java b/src/main/java/com/tiedup/remake/v2/bondage/V2BondageItems.java new file mode 100644 index 0000000..563ec4d --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/V2BondageItems.java @@ -0,0 +1,43 @@ +package com.tiedup.remake.v2.bondage; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem; +import com.tiedup.remake.v2.bondage.items.V2Handcuffs; +import com.tiedup.remake.v2.furniture.FurniturePlacerItem; +import net.minecraft.world.item.Item; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegistryObject; + +/** + * DeferredRegister for V2 bondage items. + * Separate from V2Items (which registers block items). + */ +public class V2BondageItems { + + public static final DeferredRegister ITEMS = DeferredRegister.create( + ForgeRegistries.ITEMS, TiedUpMod.MOD_ID + ); + + public static final RegistryObject V2_HANDCUFFS = ITEMS.register( + "v2_handcuffs", V2Handcuffs::new + ); + + /** + * Generic data-driven bondage item. A single registered Item whose + * behavior varies per-stack via the {@code tiedup_item_id} NBT tag. + */ + public static final RegistryObject DATA_DRIVEN_ITEM = ITEMS.register( + "data_driven_item", DataDrivenBondageItem::new + ); + + /** + * Furniture placer item. A single registered Item that spawns + * {@link com.tiedup.remake.v2.furniture.EntityFurniture} on right-click. + * The specific furniture type is determined by the {@code tiedup_furniture_id} + * NBT tag on each stack. + */ + public static final RegistryObject FURNITURE_PLACER = ITEMS.register( + "furniture_placer", FurniturePlacerItem::new + ); +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/V2EquipResult.java b/src/main/java/com/tiedup/remake/v2/bondage/V2EquipResult.java new file mode 100644 index 0000000..243bbae --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/V2EquipResult.java @@ -0,0 +1,41 @@ +package com.tiedup.remake.v2.bondage; + +import java.util.List; +import net.minecraft.world.item.ItemStack; + +/** + * Result of attempting to equip a V2 bondage item. + * Carries displaced stacks for swap/supersede cases. + */ +public record V2EquipResult(Type type, List displaced) { + + public enum Type { + /** Item equipped successfully into empty regions. */ + SUCCESS, + /** Single conflicting item was swapped out. */ + SWAPPED, + /** Global item superseded multiple sub-region items. */ + SUPERSEDED, + /** Item could not be equipped due to unresolvable conflicts. */ + BLOCKED + } + + /** Convenience: check if equip was blocked. */ + public boolean isBlocked() { return type == Type.BLOCKED; } + + /** Convenience: check if equip succeeded (any non-blocked result). */ + public boolean isSuccess() { return type != Type.BLOCKED; } + + // ===== Factory methods ===== + + public static final V2EquipResult SUCCESS = new V2EquipResult(Type.SUCCESS, List.of()); + public static final V2EquipResult BLOCKED = new V2EquipResult(Type.BLOCKED, List.of()); + + public static V2EquipResult swapped(ItemStack displaced) { + return new V2EquipResult(Type.SWAPPED, List.of(displaced)); + } + + public static V2EquipResult superseded(List displaced) { + return new V2EquipResult(Type.SUPERSEDED, List.copyOf(displaced)); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/V2EquipmentManager.java b/src/main/java/com/tiedup/remake/v2/bondage/V2EquipmentManager.java new file mode 100644 index 0000000..a0306c1 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/V2EquipmentManager.java @@ -0,0 +1,211 @@ +package com.tiedup.remake.v2.bondage; + +import com.tiedup.remake.v2.BodyRegionV2; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.ApiStatus; + +/** + * Static utility for V2 equipment conflict resolution and pose management. + * + * Conflict rules: + * 1. One item per region — cannot equip two items on the same region + * 2. Items declare which regions they block via getBlockedRegions() + * 3. Swap: single conflict -> auto-swap if canUnequip + * 4. Supersede: new item with a global region replaces all conflicting items + * + * NOTE: Global regions do NOT automatically block sub-regions. + * Blocking is always explicit via getBlockedRegions(). + * Example: Hood blocks {HEAD, EYES, EARS, MOUTH}. Handcuffs block {ARMS} only. + */ +public final class V2EquipmentManager { + + private V2EquipmentManager() {} + + /** + * A conflict between a new item and an existing equipped item. + */ + public record ConflictEntry(BodyRegionV2 region, ItemStack stack) {} + + /** + * Check if an item can be equipped without any conflicts (stack-aware). + */ + public static boolean canEquip(IV2BondageEquipment equip, IV2BondageItem item, ItemStack newStack) { + return findAllConflicts(equip, item, newStack).isEmpty(); + } + + /** + * Find all conflicts that would occur if the given item were equipped. + * Checks direct occupation, existing items' blocked regions, and new item's blocked regions. + * + * @param equip The equipment capability + * @param item The V2 bondage item interface + * @param newStack The ItemStack being equipped (used for stack-aware property lookups) + */ + public static List findAllConflicts( + IV2BondageEquipment equip, + IV2BondageItem item, + ItemStack newStack + ) { + List conflicts = new ArrayList<>(); + IdentityHashMap seen = new IdentityHashMap<>(); + + // 1. Direct occupation conflict: new item's regions vs existing items + for (BodyRegionV2 region : item.getOccupiedRegions(newStack)) { + ItemStack existing = equip.getInRegion(region); + if (!existing.isEmpty() && !seen.containsKey(existing)) { + seen.put(existing, Boolean.TRUE); + conflicts.add(new ConflictEntry(region, existing)); + } + } + + // 2. Existing items' getBlockedRegions() block new item's regions + for (Map.Entry entry : equip.getAllEquipped().entrySet()) { + ItemStack equipped = entry.getValue(); + if (seen.containsKey(equipped)) continue; + if (equipped.getItem() instanceof IV2BondageItem equippedItem) { + for (BodyRegionV2 newRegion : item.getOccupiedRegions(newStack)) { + if (equippedItem.getBlockedRegions(equipped).contains(newRegion)) { + seen.put(equipped, Boolean.TRUE); + conflicts.add(new ConflictEntry(entry.getKey(), equipped)); + break; // One conflict per item is enough + } + } + } + } + + // 3. New item's getBlockedRegions() conflict with existing items + for (BodyRegionV2 blocked : item.getBlockedRegions(newStack)) { + ItemStack existing = equip.getInRegion(blocked); + if (!existing.isEmpty() && !seen.containsKey(existing)) { + seen.put(existing, Boolean.TRUE); + conflicts.add(new ConflictEntry(blocked, existing)); + } + } + + return conflicts; + } + + /** + * Attempt to equip an item, handling conflicts via swap or supersede. + * + * @param equip The equipment capability + * @param item The V2 bondage item interface + * @param stack The ItemStack being equipped + * @param entity The entity being equipped (never null) + * @return The result of the equip attempt + */ + @ApiStatus.Internal + public static V2EquipResult tryEquip( + IV2BondageEquipment equip, + IV2BondageItem item, + ItemStack stack, + LivingEntity entity + ) { + // Fast path: no conflicts + List conflicts = findAllConflicts(equip, item, stack); + if (conflicts.isEmpty()) { + doEquip(equip, item, stack); + return V2EquipResult.SUCCESS; + } + + // De-duplicate conflicts by stack identity + IdentityHashMap uniqueConflicts = new IdentityHashMap<>(); + for (ConflictEntry c : conflicts) { + uniqueConflicts.putIfAbsent(c.stack(), c); + } + + // Single conflict -> attempt swap + if (uniqueConflicts.size() == 1) { + ConflictEntry conflict = uniqueConflicts.values().iterator().next(); + ItemStack conflictStack = conflict.stack(); + if (conflictStack.getItem() instanceof IV2BondageItem conflictItem) { + if (conflictItem.canUnequip(conflictStack, entity)) { + removeAllRegionsOf(equip, conflictStack); + conflictItem.onUnequipped(conflictStack, entity); + doEquip(equip, item, stack); + return V2EquipResult.swapped(conflictStack); + } + } else { + // Non-V2 item in region — log warning and remove + TiedUpMod.LOGGER.warn( + "V2EquipmentManager: swapping out non-V2 item {} from equipment", + conflictStack + ); + removeAllRegionsOf(equip, conflictStack); + doEquip(equip, item, stack); + return V2EquipResult.swapped(conflictStack); + } + return V2EquipResult.BLOCKED; + } + + // Multiple conflicts + new item occupies a global region -> supersede + boolean newItemHasGlobal = false; + for (BodyRegionV2 region : item.getOccupiedRegions(stack)) { + if (region.isGlobal()) { + newItemHasGlobal = true; + break; + } + } + + if (newItemHasGlobal) { + // Check all conflicting items can be unequipped + for (ConflictEntry c : uniqueConflicts.values()) { + if (c.stack().getItem() instanceof IV2BondageItem ci) { + if (!ci.canUnequip(c.stack(), entity)) { + return V2EquipResult.BLOCKED; + } + } + } + // Collect displaced stacks, then unequip all conflicts + List displaced = new ArrayList<>(); + for (ConflictEntry c : uniqueConflicts.values()) { + ItemStack cs = c.stack(); + removeAllRegionsOf(equip, cs); + if (cs.getItem() instanceof IV2BondageItem ci) { + ci.onUnequipped(cs, entity); + } else { + TiedUpMod.LOGGER.warn("[V2] Supersede removed non-V2 item {} from equipment", cs); + } + displaced.add(cs); + } + doEquip(equip, item, stack); + return V2EquipResult.superseded(displaced); + } + + return V2EquipResult.BLOCKED; + } + + /** + * Place an item into all its occupied regions. + */ + private static void doEquip( + IV2BondageEquipment equip, + IV2BondageItem item, + ItemStack stack + ) { + for (BodyRegionV2 region : item.getOccupiedRegions(stack)) { + equip.setInRegion(region, stack); + } + } + + /** + * Remove an item from all regions by identity scan. + * Uses full BodyRegionV2.values() scan to prevent orphan stacks. + */ + public static void removeAllRegionsOf(IV2BondageEquipment equip, ItemStack stack) { + for (BodyRegionV2 region : BodyRegionV2.values()) { + //noinspection ObjectEquality — intentional identity comparison + if (equip.getInRegion(region) == stack) { + equip.setInRegion(region, ItemStack.EMPTY); + } + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/tiedup/remake/v2/bondage/capability/V2BondageEquipment.java b/src/main/java/com/tiedup/remake/v2/bondage/capability/V2BondageEquipment.java new file mode 100644 index 0000000..2f24871 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/capability/V2BondageEquipment.java @@ -0,0 +1,321 @@ +package com.tiedup.remake.v2.bondage.capability; + +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageEquipment; +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import java.util.Collections; +import java.util.EnumMap; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.StringTag; +import net.minecraft.nbt.Tag; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +/** + * Concrete implementation of {@link IV2BondageEquipment}. + * + * Storage: EnumMap with 14 regions, each initialized to {@link ItemStack#EMPTY}. + * Multi-region items share the same ItemStack reference across all occupied regions. + * + * NBT format: + * - Root key: "V2BondageRegions" CompoundTag + * - Per item: "REGION_NAME" -> ItemStack NBT + * - Multi-region secondary slots: "REGION_NAME_also" -> ListTag of StringTag (other region names) + * - Only non-empty regions are persisted + */ +public class V2BondageEquipment implements IV2BondageEquipment { + + private static final String NBT_ROOT_KEY = "V2BondageRegions"; + private static final String NBT_ALSO_SUFFIX = "_also"; + + private final EnumMap regions; + + // Pole leash persistence + @Nullable private BlockPos savedPolePosition; + @Nullable private ResourceKey savedPoleDimension; + + // Captor persistence + @Nullable private UUID savedCaptorUUID; + + public V2BondageEquipment() { + this.regions = new EnumMap<>(BodyRegionV2.class); + for (BodyRegionV2 region : BodyRegionV2.values()) { + regions.put(region, ItemStack.EMPTY); + } + } + + @Override + public ItemStack getInRegion(BodyRegionV2 region) { + if (region == null) return ItemStack.EMPTY; + ItemStack stack = regions.get(region); + return stack != null ? stack : ItemStack.EMPTY; + } + + @Override + public void setInRegion(BodyRegionV2 region, ItemStack stack) { + if (region == null) return; + regions.put(region, stack == null ? ItemStack.EMPTY : stack); + } + + @Override + public Map getAllEquipped() { + // De-duplicate: multi-region items should appear only once. + // Uses IdentityHashMap to track already-seen stack references. + IdentityHashMap seen = new IdentityHashMap<>(); + Map result = new LinkedHashMap<>(); + + for (BodyRegionV2 region : BodyRegionV2.values()) { + ItemStack stack = regions.get(region); + if (stack != null && !stack.isEmpty() && !seen.containsKey(stack)) { + seen.put(stack, region); + result.put(region, stack); + } + } + + return Collections.unmodifiableMap(result); + } + + @Override + public boolean isRegionOccupied(BodyRegionV2 region) { + if (region == null) return false; + ItemStack stack = regions.get(region); + return stack != null && !stack.isEmpty(); + } + + /** + * Check if a region is blocked by any equipped item's {@link IV2BondageItem#getBlockedRegions()}. + *

+ * Scans all equipped items and returns true if any item blocks this region + * (excluding self-blocking via the item's own occupied regions). + * This replaces the old O(1) parent-global hierarchy check. + */ + @Override + public boolean isRegionBlocked(BodyRegionV2 region) { + if (region == null) return false; + // Check if any equipped item's getBlockedRegions() includes this region + for (Map.Entry entry : getAllEquipped().entrySet()) { + ItemStack stack = entry.getValue(); + if (stack.getItem() instanceof IV2BondageItem item) { + if (item.getBlockedRegions(stack).contains(region) + && !item.getOccupiedRegions(stack).contains(region)) { + // Blocked by another item (not self-blocking via occupation) + return true; + } + } + } + return false; + } + + @Override + public int getEquippedCount() { + // Count unique non-empty stacks directly, avoiding the 2-map allocation + // of getAllEquipped(). Uses identity-based dedup for multi-region items. + IdentityHashMap seen = new IdentityHashMap<>(); + for (ItemStack stack : regions.values()) { + if (stack != null && !stack.isEmpty()) { + seen.put(stack, Boolean.TRUE); + } + } + return seen.size(); + } + + @Override + public void clearAll() { + for (BodyRegionV2 region : BodyRegionV2.values()) { + regions.put(region, ItemStack.EMPTY); + } + } + + // ======================================== + // Pole leash persistence + // ======================================== + + @Override + public boolean wasLeashedToPole() { + return savedPolePosition != null && savedPoleDimension != null; + } + + @Override + @Nullable + public BlockPos getSavedPolePosition() { + return savedPolePosition; + } + + @Override + @Nullable + public ResourceKey getSavedPoleDimension() { + return savedPoleDimension; + } + + @Override + public void savePoleLeash(BlockPos pos, ResourceKey dimension) { + this.savedPolePosition = pos; + this.savedPoleDimension = dimension; + } + + @Override + public void clearSavedPoleLeash() { + this.savedPolePosition = null; + this.savedPoleDimension = null; + } + + // ======================================== + // Captor persistence + // ======================================== + + @Override + public boolean hasSavedCaptor() { + return savedCaptorUUID != null; + } + + @Override + @Nullable + public UUID getSavedCaptorUUID() { + return savedCaptorUUID; + } + + @Override + public void saveCaptorUUID(UUID uuid) { + this.savedCaptorUUID = uuid; + } + + @Override + public void clearSavedCaptor() { + this.savedCaptorUUID = null; + } + + // ======================================== + // NBT serialization + // ======================================== + + @Override + public CompoundTag serializeNBT() { + CompoundTag root = new CompoundTag(); + CompoundTag regionsTag = new CompoundTag(); + + // Track which stacks we've already serialized (identity-based) + IdentityHashMap serialized = new IdentityHashMap<>(); + + for (BodyRegionV2 region : BodyRegionV2.values()) { + ItemStack stack = regions.get(region); + if (stack == null || stack.isEmpty()) continue; + + String regionName = region.name(); + + if (serialized.containsKey(stack)) { + // This stack was already serialized under another region. + // Add this region to the _also list of the primary region. + String primaryRegion = serialized.get(stack); + String alsoKey = primaryRegion + NBT_ALSO_SUFFIX; + ListTag alsoList; + if (regionsTag.contains(alsoKey, Tag.TAG_LIST)) { + alsoList = regionsTag.getList(alsoKey, Tag.TAG_STRING); + } else { + alsoList = new ListTag(); + regionsTag.put(alsoKey, alsoList); + } + alsoList.add(StringTag.valueOf(regionName)); + } else { + // First time seeing this stack — serialize it + regionsTag.put(regionName, stack.save(new CompoundTag())); + serialized.put(stack, regionName); + } + } + + root.put(NBT_ROOT_KEY, regionsTag); + + // Pole leash persistence + if (savedPolePosition != null && savedPoleDimension != null) { + root.putLong("pole_position", savedPolePosition.asLong()); + root.putString("pole_dimension", savedPoleDimension.location().toString()); + } + + // Captor persistence + if (savedCaptorUUID != null) { + root.putUUID("captor_uuid", savedCaptorUUID); + } + + return root; + } + + @Override + public void deserializeNBT(CompoundTag tag) { + clearAll(); + + if (!tag.contains(NBT_ROOT_KEY, Tag.TAG_COMPOUND)) return; + CompoundTag regionsTag = tag.getCompound(NBT_ROOT_KEY); + + // First pass: load primary region stacks + Set allKeys = regionsTag.getAllKeys(); + Map loadedStacks = new LinkedHashMap<>(); + + for (String key : allKeys) { + if (key.endsWith(NBT_ALSO_SUFFIX)) continue; // Handle in second pass + + BodyRegionV2 region = BodyRegionV2.fromName(key); + if (region == null) continue; + + CompoundTag stackTag = regionsTag.getCompound(key); + ItemStack stack = ItemStack.of(stackTag); + if (stack.isEmpty()) continue; + + regions.put(region, stack); + loadedStacks.put(key, stack); + } + + // Second pass: process _also entries to share the same ItemStack reference + for (String key : allKeys) { + if (!key.endsWith(NBT_ALSO_SUFFIX)) continue; + + String primaryRegionName = key.substring( + 0, key.length() - NBT_ALSO_SUFFIX.length() + ); + ItemStack primaryStack = loadedStacks.get(primaryRegionName); + if (primaryStack == null) continue; + + ListTag alsoList = regionsTag.getList(key, Tag.TAG_STRING); + for (int i = 0; i < alsoList.size(); i++) { + String alsoRegionName = alsoList.getString(i); + BodyRegionV2 alsoRegion = BodyRegionV2.fromName(alsoRegionName); + if (alsoRegion != null) { + regions.put(alsoRegion, primaryStack); // Same reference + } + } + } + + // Pole leash persistence + if (tag.contains("pole_position") && tag.contains("pole_dimension")) { + try { + savedPolePosition = BlockPos.of(tag.getLong("pole_position")); + savedPoleDimension = ResourceKey.create(Registries.DIMENSION, + new ResourceLocation(tag.getString("pole_dimension"))); + } catch (net.minecraft.ResourceLocationException e) { + com.tiedup.remake.core.TiedUpMod.LOGGER.warn( + "Invalid pole dimension in NBT, clearing saved pole data: {}", e.getMessage()); + savedPolePosition = null; + savedPoleDimension = null; + } + } else { + savedPolePosition = null; + savedPoleDimension = null; + } + + // Captor persistence + if (tag.hasUUID("captor_uuid")) { + savedCaptorUUID = tag.getUUID("captor_uuid"); + } else { + savedCaptorUUID = null; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/tiedup/remake/v2/bondage/capability/V2BondageEquipmentProvider.java b/src/main/java/com/tiedup/remake/v2/bondage/capability/V2BondageEquipmentProvider.java new file mode 100644 index 0000000..53e4cf4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/capability/V2BondageEquipmentProvider.java @@ -0,0 +1,62 @@ +package com.tiedup.remake.v2.bondage.capability; + +import com.tiedup.remake.v2.bondage.IV2BondageEquipment; +import net.minecraft.core.Direction; +import net.minecraft.nbt.CompoundTag; +import net.minecraftforge.common.capabilities.Capability; +import net.minecraftforge.common.capabilities.CapabilityManager; +import net.minecraftforge.common.capabilities.CapabilityToken; +import net.minecraftforge.common.capabilities.ICapabilitySerializable; +import net.minecraftforge.common.util.LazyOptional; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Forge capability provider for V2 bondage equipment. + * Handles capability token, lazy optional lifecycle, and NBT serialization. + */ +public class V2BondageEquipmentProvider + implements ICapabilitySerializable +{ + + public static final Capability V2_BONDAGE_EQUIPMENT = + CapabilityManager.get(new CapabilityToken<>() {}); + + private final V2BondageEquipment equipment; + private final LazyOptional optional; + + public V2BondageEquipmentProvider() { + this.equipment = new V2BondageEquipment(); + this.optional = LazyOptional.of(() -> equipment); + } + + @Override + public @NotNull LazyOptional getCapability( + @NotNull Capability cap, + @Nullable Direction side + ) { + if (cap == V2_BONDAGE_EQUIPMENT) { + return optional.cast(); + } + return LazyOptional.empty(); + } + + @Override + @SuppressWarnings("null") + public @NotNull CompoundTag serializeNBT() { + return equipment.serializeNBT(); + } + + @Override + public void deserializeNBT(CompoundTag tag) { + equipment.deserializeNBT(tag); + } + + /** + * Invalidate the LazyOptional. Call when the entity is removed + * or during player clone to prevent memory leaks. + */ + public void invalidate() { + optional.invalidate(); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiedup/remake/v2/bondage/capability/V2EquipmentHelper.java b/src/main/java/com/tiedup/remake/v2/bondage/capability/V2EquipmentHelper.java new file mode 100644 index 0000000..35b0fbc --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/capability/V2EquipmentHelper.java @@ -0,0 +1,257 @@ +package com.tiedup.remake.v2.bondage.capability; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageEquipment; +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import com.tiedup.remake.v2.bondage.V2EquipResult; +import com.tiedup.remake.v2.bondage.V2EquipmentManager; +import com.tiedup.remake.v2.bondage.IV2EquipmentHolder; +import com.tiedup.remake.v2.bondage.network.PacketSyncV2Equipment; +import java.util.Map; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; + +/** + * Static API for V2 bondage equipment operations. + * + * READ methods work on any side. WRITE methods are server-only and return + * early if called on the client. This prevents desync and ensures the server + * is the authority for equipment state. + * + * Currently dispatches only for Players (via Forge capability). + * Phase 6 will add Damsel/ArmorStand support. + */ +public final class V2EquipmentHelper { + + private V2EquipmentHelper() {} + + // ==================== READ ==================== + + /** + * Get the V2 equipment capability for an entity. + * @return The capability, or null if the entity doesn't support V2 equipment. + */ + @Nullable + public static IV2BondageEquipment getEquipment(LivingEntity entity) { + if (entity == null) return null; + if (entity instanceof Player player) { + return player.getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT) + .orElse(null); + } + // V2 equipment holders (EntityDamsel, etc.) + if (entity instanceof IV2EquipmentHolder holder) { + return holder.getV2Equipment(); + } + return null; + } + + /** + * Get the item in a specific region for an entity. + * @return The ItemStack, or {@link ItemStack#EMPTY} if empty or entity unsupported. + */ + public static ItemStack getInRegion(LivingEntity entity, BodyRegionV2 region) { + IV2BondageEquipment equip = getEquipment(entity); + if (equip == null) return ItemStack.EMPTY; + return equip.getInRegion(region); + } + + /** + * Check if a region is directly occupied on the given entity. + */ + public static boolean isRegionOccupied(LivingEntity entity, BodyRegionV2 region) { + IV2BondageEquipment equip = getEquipment(entity); + if (equip == null) return false; + return equip.isRegionOccupied(region); + } + + /** + * Check if a region is blocked by any equipped item's {@link IV2BondageItem#getBlockedRegions()}. + */ + public static boolean isRegionBlocked(LivingEntity entity, BodyRegionV2 region) { + IV2BondageEquipment equip = getEquipment(entity); + if (equip == null) return false; + return equip.isRegionBlocked(region); + } + + /** + * Get all equipped items (de-duplicated) for an entity. + * @return Unmodifiable map, or empty map if entity unsupported. + */ + public static Map getAllEquipped(LivingEntity entity) { + IV2BondageEquipment equip = getEquipment(entity); + if (equip == null) return Map.of(); + return equip.getAllEquipped(); + } + + /** + * Check if the entity has any V2 equipment at all. + */ + public static boolean hasAnyEquipment(LivingEntity entity) { + IV2BondageEquipment equip = getEquipment(entity); + if (equip == null) return false; + return equip.getEquippedCount() > 0; + } + + // ==================== WRITE (server-only) ==================== + + /** + * Equip a V2 bondage item onto an entity. + * + * Validates the item implements {@link IV2BondageItem}, copies the stack, + * runs conflict resolution via {@link V2EquipmentManager#tryEquip}, + * and fires lifecycle hooks. + * + * @param entity The target entity (must be server-side) + * @param stack The ItemStack to equip (must implement IV2BondageItem) + * @return The equip result, or {@link V2EquipResult#BLOCKED} if invalid + */ + public static V2EquipResult equipItem(LivingEntity entity, ItemStack stack) { + if (entity.level().isClientSide) return V2EquipResult.BLOCKED; + if (stack == null || stack.isEmpty()) return V2EquipResult.BLOCKED; + if (!(stack.getItem() instanceof IV2BondageItem item)) return V2EquipResult.BLOCKED; + + IV2BondageEquipment equip = getEquipment(entity); + if (equip == null) return V2EquipResult.BLOCKED; + + if (!item.canEquip(stack, entity)) return V2EquipResult.BLOCKED; + + // Copy the stack so the original isn't mutated + ItemStack equipCopy = stack.copy(); + V2EquipResult result = V2EquipmentManager.tryEquip(equip, item, equipCopy, entity); + + if (result.isSuccess()) { + item.onEquipped(equipCopy, entity); + sync(entity); + } + + return result; + } + + /** + * Unequip the item from a region, respecting canUnequip. + * + * @param entity The target entity (must be server-side) + * @param region The region to unequip from + * @return The removed ItemStack, or {@link ItemStack#EMPTY} if nothing removed + */ + public static ItemStack unequipFromRegion(LivingEntity entity, BodyRegionV2 region) { + return unequipFromRegion(entity, region, false); + } + + /** + * Unequip the item from a region, optionally forcing removal. + * + * @param entity The target entity (must be server-side) + * @param region The region to unequip from + * @param force If true, bypass canUnequip check + * @return The removed ItemStack, or {@link ItemStack#EMPTY} if nothing removed + */ + public static ItemStack unequipFromRegion( + LivingEntity entity, + BodyRegionV2 region, + boolean force + ) { + if (entity.level().isClientSide) return ItemStack.EMPTY; + + IV2BondageEquipment equip = getEquipment(entity); + if (equip == null) return ItemStack.EMPTY; + + ItemStack stack = equip.getInRegion(region); + if (stack.isEmpty()) return ItemStack.EMPTY; + + if (!force && stack.getItem() instanceof IV2BondageItem item) { + if (!item.canUnequip(stack, entity)) { + return ItemStack.EMPTY; + } + } + + // Full scan to remove all region references to this stack (identity-based) + V2EquipmentManager.removeAllRegionsOf(equip, stack); + + if (stack.getItem() instanceof IV2BondageItem item) { + item.onUnequipped(stack, entity); + } + + sync(entity); + return stack; + } + + /** + * Clear all V2 equipment from an entity, firing onUnequipped for each. + * + * @param entity The target entity (must be server-side) + */ + public static void clearAll(LivingEntity entity) { + if (entity.level().isClientSide) return; + + IV2BondageEquipment equip = getEquipment(entity); + if (equip == null) return; + + // Fire lifecycle hooks for each unique item before clearing + Map equipped = equip.getAllEquipped(); + for (Map.Entry entry : equipped.entrySet()) { + ItemStack stack = entry.getValue(); + if (stack.getItem() instanceof IV2BondageItem item) { + item.onUnequipped(stack, entity); + } + } + + equip.clearAll(); + sync(entity); + } + + // ==================== SYNC ==================== + + /** + * Sync V2 equipment state to tracking clients. + * Sends the full serialized capability to the player and all trackers. + */ + public static void sync(LivingEntity entity) { + if (entity.level().isClientSide) return; + + IV2BondageEquipment equip = getEquipment(entity); + if (equip == null) return; + + // IV2EquipmentHolder entities (Damsels) sync via EntityDataAccessor, + // not via packet. Trigger their EntityData sync instead. + if (entity instanceof IV2EquipmentHolder holder) { + holder.syncEquipmentToData(); + return; + } + + PacketSyncV2Equipment packet = new PacketSyncV2Equipment( + entity.getId(), equip.serializeNBT() + ); + + if (entity instanceof ServerPlayer serverPlayer) { + ModNetwork.sendToAllTrackingAndSelf(packet, serverPlayer); + } else { + // Phase 6: NPC support — send to all tracking the entity + ModNetwork.sendToAllTrackingEntity(packet, entity); + } + } + + /** + * Sync V2 equipment to a specific player (used on login/start-tracking). + */ + public static void syncTo(LivingEntity entity, ServerPlayer target) { + // IV2EquipmentHolder entities sync via SynchedEntityData, not packets + if (entity instanceof IV2EquipmentHolder holder) { + holder.syncEquipmentToData(); + return; + } + + IV2BondageEquipment equip = getEquipment(entity); + if (equip == null) return; + + PacketSyncV2Equipment packet = new PacketSyncV2Equipment( + entity.getId(), equip.serializeNBT() + ); + ModNetwork.sendToPlayer(packet, target); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiedup/remake/v2/bondage/client/TintColorResolver.java b/src/main/java/com/tiedup/remake/v2/bondage/client/TintColorResolver.java new file mode 100644 index 0000000..e904af4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/client/TintColorResolver.java @@ -0,0 +1,56 @@ +package com.tiedup.remake.v2.bondage.client; + +import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition; +import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry; +import java.util.LinkedHashMap; +import java.util.Map; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.Tag; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Resolves per-item tint colors by merging definition defaults with NBT overrides. + * + *

Priority (highest wins): + *

    + *
  1. NBT tag {@code tint_colors} on the ItemStack (player dye overrides)
  2. + *
  3. Default tint channels from the {@link DataDrivenItemDefinition}
  4. + *
+ * + *

Returns a map of tint channel name (e.g. "tintable_0") to RGB int (0xRRGGBB). + * An empty map means no tint — the renderer should use white (no color modification).

+ */ +@OnlyIn(Dist.CLIENT) +public final class TintColorResolver { + + private TintColorResolver() {} + + /** + * Resolve tint colors for an ItemStack. + * + * @param stack the equipped bondage item + * @return channel-to-color map; empty if no tint channels defined or found + */ + public static Map resolve(ItemStack stack) { + Map result = new LinkedHashMap<>(); + + // 1. Load defaults from DataDrivenItemDefinition + DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack); + if (def != null && def.tintChannels() != null) { + result.putAll(def.tintChannels()); + } + + // 2. Override with NBT "tint_colors" (player dye overrides) + CompoundTag tag = stack.getTag(); + if (tag != null && tag.contains("tint_colors", Tag.TAG_COMPOUND)) { + CompoundTag tints = tag.getCompound("tint_colors"); + for (String key : tints.getAllKeys()) { + result.put(key, tints.getInt(key)); + } + } + + return result; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/client/V2BondageRenderLayer.java b/src/main/java/com/tiedup/remake/v2/bondage/client/V2BondageRenderLayer.java new file mode 100644 index 0000000..1141bdd --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/client/V2BondageRenderLayer.java @@ -0,0 +1,188 @@ +package com.tiedup.remake.v2.bondage.client; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.tiedup.remake.client.gltf.GltfCache; +import com.tiedup.remake.client.gltf.GltfData; +import com.tiedup.remake.client.gltf.GltfLiveBoneReader; +import com.tiedup.remake.client.gltf.GltfMeshRenderer; +import com.tiedup.remake.client.gltf.GltfSkinningEngine; +import com.tiedup.remake.entities.AbstractTiedUpNpc; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageEquipment; +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import com.tiedup.remake.v2.bondage.IV2EquipmentHolder; +import com.tiedup.remake.v2.bondage.capability.V2BondageEquipmentProvider; +import com.tiedup.remake.v2.furniture.ISeatProvider; +import com.tiedup.remake.v2.furniture.SeatDefinition; +import java.util.Map; +import java.util.Set; +import net.minecraft.client.model.HumanoidModel; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.entity.LivingEntityRenderer; +import net.minecraft.client.renderer.entity.RenderLayerParent; +import net.minecraft.client.renderer.entity.layers.RenderLayer; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.joml.Matrix4f; + +/** + * Production render layer for V2 bondage equipment. + * Renders ALL equipped V2 bondage items as GLB meshes on any entity + * with a {@link HumanoidModel}. + * + *

Works for both players and NPCs. For players, reads the V2 bondage + * equipment capability. For NPCs implementing {@link IV2EquipmentHolder} + * (e.g., EntityDamsel), reads directly from the holder. + * + *

Each equipped item with a non-null model location gets its own + * pushPose/popPose pair. Joint matrices are computed per-GLB-model + * because different GLB models have different skeletons. + * + *

Unlike {@link com.tiedup.remake.client.gltf.GltfRenderLayer}, + * this layer is always active (no F9 toggle guard) and renders on + * ALL entities (no local-player-only guard). + */ +@OnlyIn(Dist.CLIENT) +public class V2BondageRenderLayer> + extends RenderLayer { + + private static final Logger LOGGER = LogManager.getLogger("GltfPipeline"); + + /** + * Y alignment offset to place glTF meshes in the MC PoseStack. + * After LivingEntityRenderer's scale(-1,-1,1) + translate(0,-1.501,0), + * the PoseStack origin is at model top (1.501 blocks above feet), Y-down. + * Translating by 1.501 maps glTF feet to PoseStack feet. + */ + private static final float ALIGNMENT_Y = 1.501f; + + public V2BondageRenderLayer(RenderLayerParent renderer) { + super(renderer); + } + + @Override + public void render( + PoseStack poseStack, + MultiBufferSource buffer, + int packedLight, + T entity, + float limbSwing, + float limbSwingAmount, + float partialTick, + float ageInTicks, + float netHeadYaw, + float headPitch + ) { + // Get V2 equipment via capability (Players) or IV2EquipmentHolder (Damsels) + IV2BondageEquipment equipment = null; + if (entity instanceof Player player) { + equipment = player.getCapability( + V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT + ).orElse(null); + } else if (entity instanceof IV2EquipmentHolder holder) { + equipment = holder.getV2Equipment(); + } + if (equipment == null) { + return; + } + + // Get all equipped items (de-duplicated map) + Map equipped = equipment.getAllEquipped(); + if (equipped.isEmpty()) { + return; + } + + // Skip rendering items in regions blocked by furniture/seat provider + Set furnitureBlocked = Set.of(); + if (entity.isPassenger() && entity.getVehicle() instanceof ISeatProvider provider) { + SeatDefinition seat = provider.getSeatForPassenger(entity); + if (seat != null) { + furnitureBlocked = seat.blockedRegions(); + } + } + + M parentModel = this.getParentModel(); + int packedOverlay = LivingEntityRenderer.getOverlayCoords(entity, 0.0f); + + for (Map.Entry entry : equipped.entrySet()) { + ItemStack stack = entry.getValue(); + if (stack.isEmpty()) continue; + + // Furniture blocks this region — skip rendering + if (furnitureBlocked.contains(entry.getKey())) continue; + + // Check if the item implements IV2BondageItem + if (!(stack.getItem() instanceof IV2BondageItem bondageItem)) { + continue; + } + + // Select slim model variant for Alex-style players or slim Damsels + boolean isSlim; + if (entity instanceof AbstractClientPlayer acp) { + isSlim = "slim".equals(acp.getModelName()); + } else if (entity instanceof AbstractTiedUpNpc npc) { + isSlim = npc.hasSlimArms(); + } else { + isSlim = false; + } + ResourceLocation modelLocation = (isSlim && bondageItem.supportsSlimModel(stack)) + ? bondageItem.getSlimModelLocation(stack) + : null; + if (modelLocation == null) { + modelLocation = bondageItem.getModelLocation(stack); + } + if (modelLocation == null) { + continue; + } + + // Load GLB data from cache + GltfData data = GltfCache.get(modelLocation); + if (data == null) { + LOGGER.debug("[V2Render] Failed to load GLB for item {}: {}", + stack.getItem(), modelLocation); + continue; + } + + // Compute joint matrices for this specific GLB model + // Each GLB has its own skeleton, so matrices are per-item + Matrix4f[] joints = GltfLiveBoneReader.computeJointMatricesFromModel( + parentModel, data, entity + ); + if (joints == null) { + // Fallback to GLB-internal skinning + joints = GltfSkinningEngine.computeJointMatrices(data); + } + + // Render this item's mesh + poseStack.pushPose(); + poseStack.translate(0, ALIGNMENT_Y, 0); + + // Check for tint channels — use per-primitive tinted rendering if present + Map tintColors = TintColorResolver.resolve(stack); + if (!tintColors.isEmpty() && data.primitives().size() > 1) { + // Multi-primitive mesh with tint data: render per-primitive with colors + RenderType renderType = GltfMeshRenderer.getRenderTypeForDefaultTexture(); + GltfMeshRenderer.renderSkinnedTinted( + data, joints, poseStack, buffer, + packedLight, packedOverlay, renderType, tintColors + ); + } else { + // Standard path: single primitive or no tint data + GltfMeshRenderer.renderSkinned( + data, joints, poseStack, buffer, + packedLight, packedOverlay + ); + } + + poseStack.popPose(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenBondageItem.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenBondageItem.java new file mode 100644 index 0000000..99af898 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenBondageItem.java @@ -0,0 +1,195 @@ +package com.tiedup.remake.v2.bondage.datadriven; + +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageEquipment; +import com.tiedup.remake.v2.bondage.V2BondageItems; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import com.tiedup.remake.v2.bondage.items.AbstractV2BondageItem; +import java.util.Map; +import java.util.Set; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; + +/** + * Generic Item class for all data-driven bondage items. + * + *

A single Forge-registered Item. Each ItemStack carries a {@code tiedup_item_id} + * NBT tag that points to a {@link DataDrivenItemDefinition} in the + * {@link DataDrivenItemRegistry}. All property methods are overridden to read + * from the definition via the stack-aware interface methods.

+ * + *

The no-arg methods return safe defaults because the singleton item cannot + * know which definition to use without an ItemStack. The real values come + * exclusively from the stack-aware overrides.

+ */ +public class DataDrivenBondageItem extends AbstractV2BondageItem { + + public DataDrivenBondageItem() { + super(new Properties().stacksTo(1)); + } + + // ===== REGIONS (stack-aware overrides) ===== + + @Override + public Set getOccupiedRegions() { + // Safe default for the singleton — real value comes from stack-aware override + return Set.of(); + } + + @Override + public Set getOccupiedRegions(ItemStack stack) { + DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack); + return def != null ? def.occupiedRegions() : Set.of(); + } + + @Override + public Set getBlockedRegions(ItemStack stack) { + DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack); + return def != null ? def.blockedRegions() : Set.of(); + } + + // ===== 3D MODELS (stack-aware overrides) ===== + + @Override + @Nullable + public ResourceLocation getModelLocation() { + return null; // Safe default + } + + @Override + @Nullable + public ResourceLocation getModelLocation(ItemStack stack) { + DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack); + return def != null ? def.modelLocation() : null; + } + + @Override + @Nullable + public ResourceLocation getSlimModelLocation(ItemStack stack) { + DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack); + return def != null ? def.slimModelLocation() : null; + } + + @Override + public boolean supportsSlimModel(ItemStack stack) { + DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack); + return def != null && def.slimModelLocation() != null; + } + + // ===== POSES & ANIMATIONS (stack-aware overrides) ===== + + @Override + public int getPosePriority() { + return 0; // Safe default + } + + @Override + public int getPosePriority(ItemStack stack) { + DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack); + return def != null ? def.posePriority() : 0; + } + + // ===== ITEM STATE (stack-aware overrides) ===== + + @Override + public int getEscapeDifficulty(ItemStack stack) { + DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack); + return def != null ? def.escapeDifficulty() : 0; + } + + @Override + public boolean supportsColor(ItemStack stack) { + DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack); + return def != null && def.supportsColor(); + } + + // ===== IHasResistance IMPLEMENTATION ===== + + @Override + public String getResistanceId() { + // Safe default for the singleton -- the real resistance comes from + // getBaseResistance() which bypasses the GameRules switch entirely. + return "data_driven"; + } + + /** + * Bypass the GameRules switch lookup entirely for data-driven items. + * + *

The default IHasResistance implementation calls + * {@code ModGameRules.getResistance(gameRules, getResistanceId())} which has + * a hardcoded switch for "rope", "gag", "blindfold", "collar" and defaults + * to 100 for everything else. This makes the JSON {@code escape_difficulty} + * field useless.

+ * + *

Instead, we scan the entity's equipped items to find ALL data-driven items + * and return the MAX escape difficulty. This is because IHasResistance has no + * ItemStack parameter, so we cannot distinguish which specific data-driven item + * is being queried when multiple are equipped (they all share the same Item + * singleton). Returning the MAX is the safe choice: it prevents the struggle + * system from underestimating resistance.

+ */ + @Override + public int getBaseResistance(LivingEntity entity) { + if (entity != null) { + IV2BondageEquipment equip = V2EquipmentHelper.getEquipment(entity); + if (equip != null) { + int maxDifficulty = -1; + for (Map.Entry entry : equip.getAllEquipped().entrySet()) { + ItemStack stack = entry.getValue(); + if (stack.getItem() == this) { + DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack); + if (def != null) { + maxDifficulty = Math.max(maxDifficulty, def.escapeDifficulty()); + } + } + } + if (maxDifficulty >= 0) { + return maxDifficulty; + } + } + } + return 100; // safe fallback + } + + @Override + public void notifyStruggle(LivingEntity entity) { + // Play a generic chain sound for data-driven items + entity.level().playSound( + null, entity.getX(), entity.getY(), entity.getZ(), + net.minecraft.sounds.SoundEvents.CHAIN_STEP, + net.minecraft.sounds.SoundSource.PLAYERS, + 0.4f, 1.0f + ); + } + + // ===== DISPLAY NAME ===== + + @Override + public Component getName(ItemStack stack) { + DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack); + if (def == null) return super.getName(stack); + if (def.translationKey() != null) { + return Component.translatable(def.translationKey()); + } + return Component.literal(def.displayName()); + } + + // ===== FACTORY ===== + + /** + * Create an ItemStack for a data-driven bondage item. + * + * @param itemId the definition ID (must exist in {@link DataDrivenItemRegistry}) + * @return a new ItemStack with the {@code tiedup_item_id} NBT tag set, + * or {@link ItemStack#EMPTY} if the item is not registered in Forge + */ + public static ItemStack createStack(ResourceLocation itemId) { + if (V2BondageItems.DATA_DRIVEN_ITEM == null) return ItemStack.EMPTY; + ItemStack stack = new ItemStack(V2BondageItems.DATA_DRIVEN_ITEM.get()); + stack.getOrCreateTag().putString(DataDrivenItemRegistry.NBT_ITEM_ID, itemId.toString()); + return stack; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java new file mode 100644 index 0000000..eaa8700 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java @@ -0,0 +1,97 @@ +package com.tiedup.remake.v2.bondage.datadriven; + +import com.tiedup.remake.v2.BodyRegionV2; +import java.util.Map; +import java.util.Set; +import net.minecraft.resources.ResourceLocation; +import org.jetbrains.annotations.Nullable; + +/** + * Immutable definition for a data-driven bondage item. + * + *

Loaded from JSON files in {@code assets//tiedup_items/}. + * Each definition describes the properties of a bondage item variant + * that can be instantiated as an ItemStack with the {@code tiedup_item_id} NBT tag.

+ * + *

All rendering and gameplay properties are read from this record at runtime + * via {@link DataDrivenBondageItem}'s stack-aware method overrides.

+ */ +public record DataDrivenItemDefinition( + /** Unique identifier for this item definition (e.g., "tiedup:leather_armbinder"). */ + ResourceLocation id, + + /** Human-readable display name (fallback if no translation key). */ + String displayName, + + /** Optional translation key for localized display name. */ + @Nullable String translationKey, + + /** Resource location of the GLB model file. */ + ResourceLocation modelLocation, + + /** Optional slim (Alex-style) model variant. */ + @Nullable ResourceLocation slimModelLocation, + + /** Optional base texture path for color variant resolution. */ + @Nullable ResourceLocation texturePath, + + /** Optional separate GLB for animations (shared template). */ + @Nullable ResourceLocation animationSource, + + /** Body regions this item occupies. Never empty. */ + Set occupiedRegions, + + /** Body regions this item blocks. Defaults to occupiedRegions if not specified. */ + Set blockedRegions, + + /** Pose priority for conflict resolution. Higher wins. */ + int posePriority, + + /** Escape difficulty for the struggle minigame. */ + int escapeDifficulty, + + /** Whether this item can be locked with a padlock. */ + boolean lockable, + + /** Whether this item supports color variants. */ + boolean supportsColor, + + /** Default tint colors per channel (e.g. "tintable_0" -> 0x8B4513). Empty map if none. */ + Map tintChannels, + + /** + * Optional inventory icon model location (e.g., "tiedup:item/armbinder"). + * + *

Points to a standard {@code item/generated} model JSON that will be used + * as the inventory sprite for this data-driven item variant. When null, the + * default {@code tiedup:item/data_driven_item} model is used.

+ */ + @Nullable ResourceLocation icon, + + /** + * Optional movement style that changes how a bound player physically moves. + * Determines server-side speed reduction, jump suppression, and client animation. + */ + @Nullable com.tiedup.remake.v2.bondage.movement.MovementStyle movementStyle, + + /** + * Optional per-item overrides for the movement style's default values. + * Requires {@code movementStyle} to be non-null (ignored otherwise). + */ + @Nullable com.tiedup.remake.v2.bondage.movement.MovementModifier movementModifier, + + /** + * Per-animation bone whitelist. Maps animation name (e.g. "idle", "struggle") + * to the set of PlayerAnimator bone names this item is allowed to animate. + * + *

Valid bone names: head, body, rightArm, leftArm, rightLeg, leftLeg.

+ * + *

At animation time, the effective parts for a given clip are computed as + * {@code intersection(animationBones[clipName], ownedParts)}. If the clip name + * is not present in this map (or null), the item falls back to its full + * {@code ownedParts}.

+ * + *

This field is required in the JSON definition. Never null, never empty.

+ */ + Map> animationBones +) {} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java new file mode 100644 index 0000000..94aca0c --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java @@ -0,0 +1,422 @@ +package com.tiedup.remake.v2.bondage.datadriven; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.movement.MovementModifier; +import com.tiedup.remake.v2.bondage.movement.MovementStyle; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import net.minecraft.resources.ResourceLocation; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.Nullable; + +/** + * Parses JSON files into {@link DataDrivenItemDefinition} instances. + * + *

Uses manual field extraction (not Gson deserialization) for validation control. + * Invalid fields are logged as warnings; critical errors (missing type, empty regions, + * missing model) cause the entire definition to be skipped.

+ * + *

Expected JSON format: + *

{@code
+ * {
+ *   "type": "tiedup:bondage_item",
+ *   "display_name": "Leather Armbinder",
+ *   "translation_key": "item.tiedup.leather_armbinder",
+ *   "model": "tiedup:models/gltf/v2/armbinder/armbinder.glb",
+ *   "slim_model": "tiedup:models/gltf/v2/armbinder/armbinder_slim.glb",
+ *   "texture": "tiedup:textures/item/armbinder",
+ *   "animation_source": "tiedup:models/gltf/v2/armbinder/armbinder_anim.glb",
+ *   "regions": ["ARMS", "HANDS", "TORSO"],
+ *   "blocked_regions": ["ARMS", "HANDS", "TORSO", "FINGERS"],
+ *   "pose_type": "STANDARD",
+ *   "pose_priority": 50,
+ *   "escape_difficulty": 150,
+ *   "resistance_id": "armbinder",
+ *   "lockable": true,
+ *   "supports_color": false,
+ *   "color_variants": []
+ * }
+ * }
+ */ +public final class DataDrivenItemParser { + + private static final Logger LOGGER = LogManager.getLogger("DataDrivenItems"); + + private DataDrivenItemParser() {} + + /** + * Parse a JSON input stream into a DataDrivenItemDefinition. + * + * @param input the JSON input stream + * @param fileId the resource location of the file (for error messages) + * @return the parsed definition, or null if the file is invalid + */ + @Nullable + public static DataDrivenItemDefinition parse(InputStream input, ResourceLocation fileId) { + try { + JsonObject root = JsonParser.parseReader( + new InputStreamReader(input, StandardCharsets.UTF_8) + ).getAsJsonObject(); + + return parseObject(root, fileId); + } catch (Exception e) { + LOGGER.error("[DataDrivenItems] Failed to parse JSON {}: {}", fileId, e.getMessage()); + return null; + } + } + + /** + * Parse a JsonObject into a DataDrivenItemDefinition. + * + * @param root the parsed JSON object + * @param fileId the resource location of the file (for error messages) + * @return the parsed definition, or null if validation fails + */ + @Nullable + public static DataDrivenItemDefinition parseObject(JsonObject root, ResourceLocation fileId) { + // Validate type field + String type = getStringOrNull(root, "type"); + if (!"tiedup:bondage_item".equals(type)) { + LOGGER.error("[DataDrivenItems] Skipping {}: invalid or missing type '{}' (expected 'tiedup:bondage_item')", + fileId, type); + return null; + } + + // Required: display_name + String displayName = getStringOrNull(root, "display_name"); + if (displayName == null || displayName.isEmpty()) { + LOGGER.error("[DataDrivenItems] Skipping {}: missing 'display_name'", fileId); + return null; + } + + // Optional: translation_key + String translationKey = getStringOrNull(root, "translation_key"); + + // Required: model + String modelStr = getStringOrNull(root, "model"); + if (modelStr == null || modelStr.isEmpty()) { + LOGGER.error("[DataDrivenItems] Skipping {}: missing 'model'", fileId); + return null; + } + ResourceLocation modelLocation = ResourceLocation.tryParse(modelStr); + if (modelLocation == null) { + LOGGER.error("[DataDrivenItems] Skipping {}: invalid model ResourceLocation '{}'", fileId, modelStr); + return null; + } + + // Optional: slim_model + ResourceLocation slimModelLocation = parseOptionalResourceLocation(root, "slim_model", fileId); + + // Optional: texture + ResourceLocation texturePath = parseOptionalResourceLocation(root, "texture", fileId); + + // Optional: animation_source + ResourceLocation animationSource = parseOptionalResourceLocation(root, "animation_source", fileId); + + // Required: regions (non-empty) + Set occupiedRegions = parseRegions(root, "regions", fileId); + if (occupiedRegions == null || occupiedRegions.isEmpty()) { + LOGGER.error("[DataDrivenItems] Skipping {}: missing or empty 'regions'", fileId); + return null; + } + + // Optional: blocked_regions (defaults to regions) + Set blockedRegions = parseRegions(root, "blocked_regions", fileId); + if (blockedRegions == null || blockedRegions.isEmpty()) { + blockedRegions = occupiedRegions; + } + + // Optional: pose_priority (default 0) + int posePriority = getIntOrDefault(root, "pose_priority", 0); + + // Optional: escape_difficulty (default 0) + int escapeDifficulty = getIntOrDefault(root, "escape_difficulty", 0); + + // Optional: lockable (default true) + boolean lockable = getBooleanOrDefault(root, "lockable", true); + + // Optional: supports_color (default false) + boolean supportsColor = getBooleanOrDefault(root, "supports_color", false); + + // Optional: tint_channels (default empty) + Map tintChannels = parseTintChannels(root, "tint_channels", fileId); + + // Optional: icon (item model ResourceLocation for inventory sprite) + ResourceLocation icon = parseOptionalResourceLocation(root, "icon", fileId); + + // Optional: movement_style (requires valid MovementStyle name) + MovementStyle movementStyle = null; + String movementStyleStr = getStringOrNull(root, "movement_style"); + if (movementStyleStr != null && !movementStyleStr.isEmpty()) { + movementStyle = MovementStyle.fromName(movementStyleStr); + if (movementStyle == null) { + LOGGER.warn("[DataDrivenItems] In {}: unknown movement_style '{}', ignoring", + fileId, movementStyleStr); + } + } + + // Optional: movement_modifier (requires movement_style to be set) + MovementModifier movementModifier = null; + if (movementStyle != null && root.has("movement_modifier") && root.get("movement_modifier").isJsonObject()) { + JsonObject modObj = root.getAsJsonObject("movement_modifier"); + Float speedMul = getFloatOrNull(modObj, "speed_multiplier"); + Boolean jumpDis = getBooleanOrNull(modObj, "jump_disabled"); + if (speedMul != null || jumpDis != null) { + movementModifier = new MovementModifier(speedMul, jumpDis); + } + } else if (movementStyle == null && root.has("movement_modifier")) { + LOGGER.warn("[DataDrivenItems] In {}: movement_modifier ignored because movement_style is absent", + fileId); + } + + // Required: animation_bones (per-animation bone whitelist) + Map> animationBones = parseAnimationBones(root, fileId); + if (animationBones == null) { + LOGGER.error("[DataDrivenItems] Skipping {}: missing or invalid 'animation_bones'", fileId); + return null; + } + + // Build the item ID from the file path + // fileId is like "tiedup:tiedup_items/leather_armbinder.json" + // We want "tiedup:leather_armbinder" + String idPath = fileId.getPath(); + // Strip "tiedup_items/" prefix + if (idPath.startsWith("tiedup_items/")) { + idPath = idPath.substring("tiedup_items/".length()); + } + // Strip ".json" suffix + if (idPath.endsWith(".json")) { + idPath = idPath.substring(0, idPath.length() - 5); + } + ResourceLocation id = new ResourceLocation(fileId.getNamespace(), idPath); + + return new DataDrivenItemDefinition( + id, displayName, translationKey, modelLocation, slimModelLocation, + texturePath, animationSource, occupiedRegions, blockedRegions, + posePriority, escapeDifficulty, + lockable, supportsColor, tintChannels, icon, + movementStyle, movementModifier, animationBones + ); + } + + // ===== Helper Methods ===== + + @Nullable + private static String getStringOrNull(JsonObject obj, String key) { + if (!obj.has(key) || obj.get(key).isJsonNull()) return null; + try { + return obj.get(key).getAsString(); + } catch (Exception e) { + return null; + } + } + + private static int getIntOrDefault(JsonObject obj, String key, int defaultValue) { + if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue; + try { + return obj.get(key).getAsInt(); + } catch (Exception e) { + return defaultValue; + } + } + + private static boolean getBooleanOrDefault(JsonObject obj, String key, boolean defaultValue) { + if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue; + try { + return obj.get(key).getAsBoolean(); + } catch (Exception e) { + return defaultValue; + } + } + + @Nullable + private static Float getFloatOrNull(JsonObject obj, String key) { + if (!obj.has(key) || obj.get(key).isJsonNull()) return null; + try { + return obj.get(key).getAsFloat(); + } catch (Exception e) { + return null; + } + } + + @Nullable + private static Boolean getBooleanOrNull(JsonObject obj, String key) { + if (!obj.has(key) || obj.get(key).isJsonNull()) return null; + try { + return obj.get(key).getAsBoolean(); + } catch (Exception e) { + return null; + } + } + + @Nullable + private static ResourceLocation parseOptionalResourceLocation( + JsonObject obj, String key, ResourceLocation fileId + ) { + String value = getStringOrNull(obj, key); + if (value == null || value.isEmpty()) return null; + ResourceLocation loc = ResourceLocation.tryParse(value); + if (loc == null) { + LOGGER.warn("[DataDrivenItems] In {}: invalid ResourceLocation for '{}': '{}'", fileId, key, value); + } + return loc; + } + + /** + * Parse a JSON string array into an EnumSet of BodyRegionV2. + * Unknown region names are logged as warnings and skipped. + */ + @Nullable + private static Set parseRegions(JsonObject obj, String key, ResourceLocation fileId) { + if (!obj.has(key) || !obj.get(key).isJsonArray()) return null; + + JsonArray arr = obj.getAsJsonArray(key); + if (arr.isEmpty()) return null; + + EnumSet regions = EnumSet.noneOf(BodyRegionV2.class); + for (JsonElement elem : arr) { + try { + String name = elem.getAsString().toUpperCase(); + BodyRegionV2 region = BodyRegionV2.fromName(name); + if (region != null) { + regions.add(region); + } else { + LOGGER.warn("[DataDrivenItems] In {}: unknown region '{}' in '{}', skipping", + fileId, name, key); + } + } catch (Exception e) { + LOGGER.warn("[DataDrivenItems] In {}: invalid element in '{}': {}", + fileId, key, e.getMessage()); + } + } + + return regions.isEmpty() ? null : Collections.unmodifiableSet(regions); + } + + /** + * Parse a tint_channels JSON object mapping channel names to hex color strings. + * + *

Example JSON: + *

{@code
+     * "tint_channels": {
+     *   "tintable_0": "#8B4513",
+     *   "tintable_1": "#FF0000"
+     * }
+     * }
+ * + * @param obj the parent JSON object + * @param key the field name to parse + * @param fileId the source file for error messages + * @return an unmodifiable map of channel names to RGB ints, or empty map if absent + */ + private static Map parseTintChannels(JsonObject obj, String key, ResourceLocation fileId) { + if (!obj.has(key) || !obj.get(key).isJsonObject()) return Map.of(); + JsonObject channels = obj.getAsJsonObject(key); + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : channels.entrySet()) { + try { + String hex = entry.getValue().getAsString(); + int color = Integer.parseInt(hex.startsWith("#") ? hex.substring(1) : hex, 16); + result.put(entry.getKey(), color); + } catch (NumberFormatException e) { + LOGGER.warn("[DataDrivenItems] In {}: invalid hex color '{}' for tint channel '{}'", + fileId, entry.getValue(), entry.getKey()); + } + } + return Collections.unmodifiableMap(result); + } + + /** Valid PlayerAnimator bone names for animation_bones validation. */ + private static final Set VALID_BONE_NAMES = Set.of( + "head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg" + ); + + /** + * Parse the {@code animation_bones} JSON object. + * + *

Format: + *

{@code
+     * "animation_bones": {
+     *   "idle": ["rightArm", "leftArm"],
+     *   "struggle": ["rightArm", "leftArm", "body"]
+     * }
+     * }
+ * + *

Each key is an animation name, each value is a JSON array of bone name strings. + * Bone names are validated against the 6 PlayerAnimator parts. Invalid bone names + * are logged as warnings and skipped. Empty arrays or unknown-only arrays cause the + * entire animation entry to be skipped.

+ * + * @param obj the parent JSON object + * @param fileId the source file for error messages + * @return unmodifiable map of animation name to bone set, or null if absent/invalid + */ + @Nullable + private static Map> parseAnimationBones(JsonObject obj, ResourceLocation fileId) { + if (!obj.has("animation_bones") || !obj.get("animation_bones").isJsonObject()) { + return null; + } + + JsonObject bonesObj = obj.getAsJsonObject("animation_bones"); + if (bonesObj.size() == 0) { + LOGGER.error("[DataDrivenItems] In {}: 'animation_bones' is empty", fileId); + return null; + } + + Map> result = new LinkedHashMap<>(); + for (Map.Entry entry : bonesObj.entrySet()) { + String animName = entry.getKey(); + JsonElement value = entry.getValue(); + + if (!value.isJsonArray()) { + LOGGER.warn("[DataDrivenItems] In {}: animation_bones['{}'] is not an array, skipping", + fileId, animName); + continue; + } + + JsonArray boneArray = value.getAsJsonArray(); + Set bones = new HashSet<>(); + for (JsonElement boneElem : boneArray) { + try { + String boneName = boneElem.getAsString(); + if (VALID_BONE_NAMES.contains(boneName)) { + bones.add(boneName); + } else { + LOGGER.warn("[DataDrivenItems] In {}: animation_bones['{}'] contains unknown bone '{}', skipping", + fileId, animName, boneName); + } + } catch (Exception e) { + LOGGER.warn("[DataDrivenItems] In {}: invalid element in animation_bones['{}']", + fileId, animName); + } + } + + if (!bones.isEmpty()) { + result.put(animName, Collections.unmodifiableSet(bones)); + } else { + LOGGER.warn("[DataDrivenItems] In {}: animation_bones['{}'] resolved to empty set, skipping", + fileId, animName); + } + } + + if (result.isEmpty()) { + LOGGER.error("[DataDrivenItems] In {}: 'animation_bones' has no valid entries", fileId); + return null; + } + + return Collections.unmodifiableMap(result); + } + +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemRegistry.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemRegistry.java new file mode 100644 index 0000000..bc6bc23 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemRegistry.java @@ -0,0 +1,104 @@ +package com.tiedup.remake.v2.bondage.datadriven; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; + +/** + * Thread-safe registry for data-driven bondage item definitions. + * + *

Populated by the reload listener that scans {@code tiedup_items/} JSON files. + * Uses volatile atomic swap (same pattern as {@link + * com.tiedup.remake.client.animation.context.ContextGlbRegistry}) to ensure + * the render thread always sees a consistent snapshot.

+ * + *

Lookup methods accept either a {@link ResourceLocation} ID directly + * or an {@link ItemStack} (reads the {@code tiedup_item_id} NBT tag).

+ */ +public final class DataDrivenItemRegistry { + + /** NBT key storing the data-driven item ID on ItemStacks. */ + public static final String NBT_ITEM_ID = "tiedup_item_id"; + + /** + * Volatile reference to an unmodifiable map. Reload builds a new map + * and swaps atomically; consumer threads always see a consistent snapshot. + */ + private static volatile Map DEFINITIONS = Map.of(); + + private DataDrivenItemRegistry() {} + + /** + * Atomically replace all definitions with a new set. + * Called by the reload listener after parsing all JSON files. + * + * @param newDefs the new definitions map (will be defensively copied) + */ + public static void reload(Map newDefs) { + DEFINITIONS = Collections.unmodifiableMap(new HashMap<>(newDefs)); + } + + /** + * Atomically merge new definitions into the existing registry. + * + *

On an integrated server, both the client (assets/) and server (data/) reload + * listeners populate this registry. Using {@link #reload} would cause the second + * listener to overwrite the first's definitions. This method builds a new map + * from the existing snapshot + the new entries, then swaps atomically.

+ * + * @param newDefs the definitions to merge (will overwrite existing entries with same key) + */ + public static void mergeAll(Map newDefs) { + Map merged = new HashMap<>(DEFINITIONS); + merged.putAll(newDefs); + DEFINITIONS = Collections.unmodifiableMap(merged); + } + + /** + * Get a definition by its unique ID. + * + * @param id the definition ID (e.g., "tiedup:leather_armbinder") + * @return the definition, or null if not found + */ + @Nullable + public static DataDrivenItemDefinition get(ResourceLocation id) { + return DEFINITIONS.get(id); + } + + /** + * Get a definition from an ItemStack by reading the {@code tiedup_item_id} NBT tag. + * + * @param stack the ItemStack to inspect + * @return the definition, or null if the stack is empty, has no tag, or the ID is unknown + */ + @Nullable + public static DataDrivenItemDefinition get(ItemStack stack) { + if (stack.isEmpty()) return null; + CompoundTag tag = stack.getTag(); + if (tag == null || !tag.contains(NBT_ITEM_ID)) return null; + ResourceLocation id = ResourceLocation.tryParse(tag.getString(NBT_ITEM_ID)); + if (id == null) return null; + return DEFINITIONS.get(id); + } + + /** + * Get all registered definitions. + * + * @return unmodifiable collection of all definitions + */ + public static Collection getAll() { + return DEFINITIONS.values(); + } + + /** + * Clear all definitions. Called on world unload or for testing. + */ + public static void clear() { + DEFINITIONS = Map.of(); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemReloadListener.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemReloadListener.java new file mode 100644 index 0000000..cbf21b3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemReloadListener.java @@ -0,0 +1,79 @@ +package com.tiedup.remake.v2.bondage.datadriven; + +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.SimplePreparableReloadListener; +import net.minecraft.util.profiling.ProfilerFiller; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Resource reload listener that scans {@code assets//tiedup_items/} + * for JSON files and populates the {@link DataDrivenItemRegistry}. + * + *

Registered via {@link net.minecraftforge.client.event.RegisterClientReloadListenersEvent} + * in {@link com.tiedup.remake.client.gltf.GltfClientSetup}.

+ * + *

Follows the same pattern as {@link com.tiedup.remake.client.animation.context.ContextGlbRegistry}: + * prepare phase is a no-op, apply phase scans + parses + atomic-swaps the registry.

+ */ +public class DataDrivenItemReloadListener extends SimplePreparableReloadListener { + + private static final Logger LOGGER = LogManager.getLogger("DataDrivenItems"); + + /** Resource directory containing item definition JSON files. */ + private static final String DIRECTORY = "tiedup_items"; + + @Override + protected Void prepare(ResourceManager resourceManager, ProfilerFiller profiler) { + // No preparation needed — parsing happens in apply phase + return null; + } + + @Override + protected void apply(Void nothing, ResourceManager resourceManager, ProfilerFiller profiler) { + Map newDefs = new HashMap<>(); + + Map resources = resourceManager.listResources( + DIRECTORY, loc -> loc.getPath().endsWith(".json") + ); + + int skipped = 0; + + for (Map.Entry entry : resources.entrySet()) { + ResourceLocation fileId = entry.getKey(); + Resource resource = entry.getValue(); + + try (InputStream input = resource.open()) { + DataDrivenItemDefinition def = DataDrivenItemParser.parse(input, fileId); + + if (def != null) { + // Check for duplicate IDs + if (newDefs.containsKey(def.id())) { + LOGGER.warn("[DataDrivenItems] Duplicate item ID '{}' from file '{}' — overwriting previous definition", + def.id(), fileId); + } + + newDefs.put(def.id(), def); + LOGGER.debug("[DataDrivenItems] Loaded: {} -> '{}'", def.id(), def.displayName()); + } else { + skipped++; + } + } catch (Exception e) { + LOGGER.error("[DataDrivenItems] Failed to read resource {}: {}", fileId, e.getMessage()); + skipped++; + } + } + + // Merge into the registry (not replace) so the server listener doesn't + // overwrite client-only definitions on integrated server + DataDrivenItemRegistry.mergeAll(newDefs); + + LOGGER.info("[DataDrivenItems] Loaded {} item definitions ({} skipped) from {} JSON files", + newDefs.size(), skipped, resources.size()); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemServerReloadListener.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemServerReloadListener.java new file mode 100644 index 0000000..b05b741 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemServerReloadListener.java @@ -0,0 +1,81 @@ +package com.tiedup.remake.v2.bondage.datadriven; + +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.SimplePreparableReloadListener; +import net.minecraft.util.profiling.ProfilerFiller; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Server-side resource reload listener that scans {@code data//tiedup_items/} + * for JSON files and populates the {@link DataDrivenItemRegistry}. + * + *

This is the server counterpart to {@link DataDrivenItemReloadListener} (client-side, + * which scans {@code assets/}). On a dedicated server, only this listener runs. + * On an integrated server (singleplayer), both listeners run -- the last one to apply + * wins the atomic swap, but they parse identical JSON content so the result is the same.

+ * + *

Registered via {@link net.minecraftforge.event.AddReloadListenerEvent} in + * {@link com.tiedup.remake.core.TiedUpMod.ForgeEvents}.

+ */ +public class DataDrivenItemServerReloadListener extends SimplePreparableReloadListener { + + private static final Logger LOGGER = LogManager.getLogger("DataDrivenItems"); + + /** Resource directory containing item definition JSON files (under data/). */ + private static final String DIRECTORY = "tiedup_items"; + + @Override + protected Void prepare(ResourceManager resourceManager, ProfilerFiller profiler) { + // No preparation needed -- parsing happens in apply phase + return null; + } + + @Override + protected void apply(Void nothing, ResourceManager resourceManager, ProfilerFiller profiler) { + Map newDefs = new HashMap<>(); + + Map resources = resourceManager.listResources( + DIRECTORY, loc -> loc.getPath().endsWith(".json") + ); + + int skipped = 0; + + for (Map.Entry entry : resources.entrySet()) { + ResourceLocation fileId = entry.getKey(); + Resource resource = entry.getValue(); + + try (InputStream input = resource.open()) { + DataDrivenItemDefinition def = DataDrivenItemParser.parse(input, fileId); + + if (def != null) { + // Check for duplicate IDs + if (newDefs.containsKey(def.id())) { + LOGGER.warn("[DataDrivenItems] Server: Duplicate item ID '{}' from file '{}' -- overwriting previous definition", + def.id(), fileId); + } + + newDefs.put(def.id(), def); + LOGGER.debug("[DataDrivenItems] Server loaded: {} -> '{}'", def.id(), def.displayName()); + } else { + skipped++; + } + } catch (Exception e) { + LOGGER.error("[DataDrivenItems] Server: Failed to read resource {}: {}", fileId, e.getMessage()); + skipped++; + } + } + + // Merge into the registry (not replace) so the client listener's + // definitions aren't overwritten on integrated server + DataDrivenItemRegistry.mergeAll(newDefs); + + LOGGER.info("[DataDrivenItems] Server loaded {} item definitions ({} skipped) from {} JSON files", + newDefs.size(), skipped, resources.size()); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/items/AbstractV2BondageItem.java b/src/main/java/com/tiedup/remake/v2/bondage/items/AbstractV2BondageItem.java new file mode 100644 index 0000000..19384e2 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/items/AbstractV2BondageItem.java @@ -0,0 +1,120 @@ +package com.tiedup.remake.v2.bondage.items; + +import com.tiedup.remake.items.base.IHasResistance; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import com.tiedup.remake.v2.bondage.V2EquipResult; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.List; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +/** + * Base class for V2 bondage items. + * + * Provides: + * - Self-equip via right-click in air (use()) + * - Equip on target via right-click on entity (interactLivingEntity()) + * - Lock-aware canUnequip() bridging IV2BondageItem and ILockable + * - Lock/resistance tooltips + * + * Subclasses implement: getOccupiedRegions(), getModelLocation(), getPosePriority(), + * getResistanceId(), notifyStruggle(). + */ +public abstract class AbstractV2BondageItem extends Item + implements IV2BondageItem, ILockable, IHasResistance { + + protected AbstractV2BondageItem(Properties properties) { + super(properties); + } + + // ===== EQUIP: SELF (left-click hold with tying duration) ===== + // Self-equip is handled by SelfBondageInputHandler (left-click hold) which sends + // PacketSelfBondage, routed to handleV2SelfBondage() with tying progress bar. + // Right-click in air does nothing for self-equip — consistent with V1 behavior. + + @Override + public InteractionResultHolder use(Level level, Player player, InteractionHand hand) { + return InteractionResultHolder.pass(player.getItemInHand(hand)); + } + + // ===== EQUIP: ON TARGET (right-click on entity) ===== + + @Override + public InteractionResult interactLivingEntity( + ItemStack stack, Player player, LivingEntity target, InteractionHand hand + ) { + // Client returns SUCCESS for arm swing animation. Server may reject — + // minor visual desync is accepted Forge pattern (same as vanilla food/bow). + if (player.level().isClientSide) { + return InteractionResult.SUCCESS; + } + + // Cannot equip if player's arms are restrained + if (V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS)) { + return InteractionResult.PASS; + } + + // Distance + line-of-sight validation + if (player.distanceTo(target) > 4.0 || !player.hasLineOfSight(target)) { + return InteractionResult.PASS; + } + + V2EquipResult result = V2EquipmentHelper.equipItem(target, stack); + if (result.isSuccess()) { + // Drop displaced items at target's feet + for (ItemStack displaced : result.displaced()) { + target.spawnAtLocation(displaced); + } + stack.shrink(1); + return InteractionResult.SUCCESS; + } + return InteractionResult.PASS; + } + + // ===== LOCK-AWARE CANUNEQUIP ===== + + @Override + public boolean canUnequip(ItemStack stack, LivingEntity entity) { + return !isLocked(stack); + } + + // ===== TOOLTIPS ===== + + @Override + public void appendHoverText( + ItemStack stack, @Nullable Level level, List tooltip, TooltipFlag flag + ) { + super.appendHoverText(stack, level, tooltip, flag); + // Lock status from ILockable + appendLockTooltip(stack, tooltip); + // Escape difficulty + int difficulty = getEscapeDifficulty(stack); + if (difficulty > 0) { + tooltip.add(Component.translatable("item.tiedup.tooltip.escape_difficulty", difficulty) + .withStyle(ChatFormatting.GRAY)); + } + } + + // ===== IV2BondageItem DEFAULTS ===== + + @Override + public int getEscapeDifficulty() { return 0; } + + @Override + public boolean supportsColor() { return false; } + + @Override + public boolean supportsSlimModel() { return false; } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/items/V2Handcuffs.java b/src/main/java/com/tiedup/remake/v2/bondage/items/V2Handcuffs.java new file mode 100644 index 0000000..d846940 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/items/V2Handcuffs.java @@ -0,0 +1,54 @@ +package com.tiedup.remake.v2.bondage.items; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.LivingEntity; + +/** + * V2 Handcuffs — first V2 bondage item. + * + * Occupies ARMS only. Mittens (HANDS) can coexist on top. + * Uses existing cuffs_prototype.glb for 3D rendering. + */ +public class V2Handcuffs extends AbstractV2BondageItem { + + private static final Set REGIONS = + Collections.unmodifiableSet(EnumSet.of(BodyRegionV2.ARMS)); + + private static final ResourceLocation MODEL = new ResourceLocation( + TiedUpMod.MOD_ID, "models/gltf/v2/handcuffs/cuffs_prototype.glb" + ); + + public V2Handcuffs() { + super(new Properties().stacksTo(1)); + } + + @Override + public Set getOccupiedRegions() { return REGIONS; } + + @Override + public ResourceLocation getModelLocation() { return MODEL; } + + @Override + public int getPosePriority() { return 30; } + + @Override + public int getEscapeDifficulty() { return 100; } + + @Override + public String getResistanceId() { return "handcuffs"; } + + @Override + public void notifyStruggle(LivingEntity entity) { + entity.level().playSound( + null, entity.getX(), entity.getY(), entity.getZ(), + net.minecraft.sounds.SoundEvents.CHAIN_STEP, + net.minecraft.sounds.SoundSource.PLAYERS, + 0.4f, 1.0f + ); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/movement/MovementModifier.java b/src/main/java/com/tiedup/remake/v2/bondage/movement/MovementModifier.java new file mode 100644 index 0000000..7ec8740 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/movement/MovementModifier.java @@ -0,0 +1,21 @@ +package com.tiedup.remake.v2.bondage.movement; + +import org.jetbrains.annotations.Nullable; + +/** + * Optional per-item overrides for movement style defaults. + * Parsed from the {@code "movement_modifier"} JSON object. + * + *

Null fields fall back to the style's defaults. Only the winning item's + * modifier is used (lower-severity items' modifiers are ignored).

+ * + *

Requires a {@code movement_style} to be set on the same item definition. + * The parser ignores {@code movement_modifier} if {@code movement_style} is absent.

+ */ +public record MovementModifier( + /** Override speed multiplier, or null to use style default. */ + @Nullable Float speedMultiplier, + + /** Override jump disabled flag, or null to use style default. */ + @Nullable Boolean jumpDisabled +) {} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/movement/MovementStyle.java b/src/main/java/com/tiedup/remake/v2/bondage/movement/MovementStyle.java new file mode 100644 index 0000000..420a9fa --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/movement/MovementStyle.java @@ -0,0 +1,72 @@ +package com.tiedup.remake.v2.bondage.movement; + +import org.jetbrains.annotations.Nullable; +import com.tiedup.remake.v2.BodyRegionV2; + +/** + * Movement styles that change how a bound player physically moves. + * Each style has a severity (higher = more constraining), default speed multiplier, + * and default jump-disabled flag. + * + *

When multiple styled items are worn, the style with the highest severity wins. + * If two items share the same severity, the item on the region with the lowest + * {@link com.tiedup.remake.v2.BodyRegionV2#ordinal()} wins.

+ * + *

This enum is shared (server + client). It does NOT contain handler references + * to avoid pulling server-only classes into client code.

+ */ +public enum MovementStyle { + + /** Swaying side-to-side gait, visual zigzag via animation. Jump allowed. */ + WADDLE(1, 0.6f, false), + + /** Tiny dragging steps, heavy speed reduction. Jump disabled. */ + SHUFFLE(2, 0.4f, true), + + /** Automatic small hops when moving forward. Jump disabled (auto-hop replaces it). */ + HOP(3, 0.35f, true), + + /** On all fours, swim-like hitbox (0.6 high). Jump disabled. */ + CRAWL(4, 0.2f, true); + + private final int severity; + private final float defaultSpeedMultiplier; + private final boolean defaultJumpDisabled; + + MovementStyle(int severity, float defaultSpeedMultiplier, boolean defaultJumpDisabled) { + this.severity = severity; + this.defaultSpeedMultiplier = defaultSpeedMultiplier; + this.defaultJumpDisabled = defaultJumpDisabled; + } + + /** Higher severity = more constraining. Used for resolution tiebreaking. */ + public int getSeverity() { + return severity; + } + + /** Default speed multiplier (0.0-1.0) applied via MULTIPLY_BASE AttributeModifier. */ + public float getDefaultSpeedMultiplier() { + return defaultSpeedMultiplier; + } + + /** Whether jumping is disabled by default for this style. */ + public boolean isDefaultJumpDisabled() { + return defaultJumpDisabled; + } + + /** + * Safe valueOf that returns null instead of throwing on unknown names. + * + * @param name the style name (case-insensitive) + * @return the style, or null if not recognized + */ + @Nullable + public static MovementStyle fromName(String name) { + if (name == null) return null; + try { + return valueOf(name.toUpperCase()); + } catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/movement/MovementStyleManager.java b/src/main/java/com/tiedup/remake/v2/bondage/movement/MovementStyleManager.java new file mode 100644 index 0000000..275cb5a --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/movement/MovementStyleManager.java @@ -0,0 +1,507 @@ +package com.tiedup.remake.v2.bondage.movement; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.sync.PacketSyncMovementStyle; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageEquipment; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.Map; +import java.util.UUID; +import net.minecraft.network.protocol.game.ClientboundSetEntityMotionPacket; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.Pose; +import net.minecraft.world.entity.ai.attributes.AttributeInstance; +import net.minecraft.world.entity.ai.attributes.AttributeModifier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.event.entity.living.LivingEvent; +import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Server-side manager for movement style mechanics. + * + *

Hooks into two events: + *

    + *
  • {@code PlayerTickEvent(Phase.END)} at HIGH priority -- resolves style, + * manages lifecycle transitions, dispatches per-style tick logic. Runs after + * vanilla {@code travel()} so velocity modifications apply correctly.
  • + *
  • {@code LivingJumpEvent} -- suppresses jump for styles with jump disabled. + * {@code LivingJumpEvent} is NOT cancelable; jump is neutralized by subtracting + * the jump impulse from Y velocity.
  • + *
+ * + *

Per-player state lives on {@link PlayerBindState} to piggyback on existing + * lifecycle cleanup hooks (death, logout, dimension change).

+ * + * @see MovementStyleResolver for resolution logic + * @see MovementStyle for style definitions + */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE +) +public class MovementStyleManager { + + private static final Logger LOGGER = LogManager.getLogger("MovementStyles"); + + // --- V1 legacy modifier UUID (H6 cleanup) --- + // Source of truth: RestraintEffectUtils.BIND_SPEED_MODIFIER_UUID (same value). + // RestraintEffectUtils used this UUID with ADDITION operation and addPermanentModifier(). + // Players upgrading from V1 may still have this modifier saved in their NBT. + // Removed on tick to prevent double stacking with V2 MULTIPLY_BASE modifiers. + private static final UUID V1_BIND_SPEED_MODIFIER_UUID = + UUID.fromString("7f3c7c8e-9d4e-4c7a-8e5f-1a2b3c4d5e6f"); + + // --- Unique UUIDs for AttributeModifiers (one per style to allow clean removal) --- + private static final UUID WADDLE_SPEED_UUID = + UUID.fromString("d7a1c001-0000-0000-0000-000000000001"); + private static final UUID SHUFFLE_SPEED_UUID = + UUID.fromString("d7a1c001-0000-0000-0000-000000000002"); + private static final UUID HOP_SPEED_UUID = + UUID.fromString("d7a1c001-0000-0000-0000-000000000003"); + private static final UUID CRAWL_SPEED_UUID = + UUID.fromString("d7a1c001-0000-0000-0000-000000000004"); + + // --- Hop tuning constants --- + private static final double HOP_Y_IMPULSE = 0.28; + private static final double HOP_FORWARD_IMPULSE = 0.18; + private static final int HOP_COOLDOWN_TICKS = 10; + private static final int HOP_STARTUP_DELAY_TICKS = 4; + + // --- Movement detection threshold (squared distance) --- + private static final double MOVEMENT_THRESHOLD_SQ = 0.001; + + // --- Number of consecutive non-moving ticks before hop startup resets --- + private static final int HOP_STARTUP_RESET_TICKS = 2; + + // ==================== Tick Event ==================== + + /** + * Per-tick movement style processing. Runs at HIGH priority at Phase.END + * so it executes before {@code BondageItemRestrictionHandler} (default priority). + * + *

Tick flow: + *

    + *
  1. Skip conditions: passenger, dead, struggling
  2. + *
  3. Pending pose restore (crawl deactivated but can't stand yet)
  4. + *
  5. Resolve current style from equipped items
  6. + *
  7. Compare with active style, handle transitions
  8. + *
  9. Dispatch to style-specific tick (unless on ladder)
  10. + *
  11. Update last position for next tick's movement detection
  12. + *
+ */ + @SubscribeEvent(priority = EventPriority.HIGH) + public static void onPlayerTick(TickEvent.PlayerTickEvent event) { + if (event.side.isClient() || event.phase != TickEvent.Phase.END) { + return; + } + if (!(event.player instanceof ServerPlayer player)) { + return; + } + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null) { + return; + } + + // --- Skip conditions --- + // Update last position even when suspended to prevent false movement + // detection on resume (e.g., teleport while riding) + if (player.isPassenger() || player.isDeadOrDying() || state.isStruggling()) { + state.lastX = player.getX(); + state.lastY = player.getY(); + state.lastZ = player.getZ(); + return; + } + + // --- Pending pose restore (crawl deactivated but can't stand) --- + if (state.pendingPoseRestore) { + tryRestoreStandingPose(player, state); + } + + // --- H6: Remove stale V1 permanent modifier if present --- + // Players upgrading from V1 may have a permanent ADDITION modifier saved in NBT. + // This one-time cleanup prevents double stacking with the V2 MULTIPLY_BASE modifier. + cleanupV1Modifier(player); + + // --- Resolve current style from equipped items --- + IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(player); + Map equipped = equipment != null + ? equipment.getAllEquipped() : Map.of(); + ResolvedMovement resolved = MovementStyleResolver.resolve(equipped); + + // --- Compare with current active style --- + MovementStyle newStyle = resolved.style(); + MovementStyle oldStyle = state.getActiveMovementStyle(); + + if (newStyle != oldStyle) { + // Style changed: deactivate old, activate new + if (oldStyle != null) { + onDeactivate(player, state, oldStyle); + } + if (newStyle != null) { + state.setResolvedMovementSpeed(resolved.speedMultiplier()); + state.setResolvedJumpDisabled(resolved.jumpDisabled()); + onActivate(player, state, newStyle); + } else { + state.setResolvedMovementSpeed(1.0f); + state.setResolvedJumpDisabled(false); + } + state.setActiveMovementStyle(newStyle); + + // Sync to all tracking clients (animation + crawl pose) + ModNetwork.sendToAllTrackingAndSelf( + new PacketSyncMovementStyle(player.getUUID(), newStyle), player); + } + + // --- Per-style tick --- + if (state.getActiveMovementStyle() != null) { + // Ladder suspension: skip style tick when on ladder + // (ladder movement is controlled by BondageItemRestrictionHandler) + if (player.onClimbable()) { + state.lastX = player.getX(); + state.lastY = player.getY(); + state.lastZ = player.getZ(); + return; + } + + tickStyle(player, state); + } + + // Update last position for next tick's movement detection + state.lastX = player.getX(); + state.lastY = player.getY(); + state.lastZ = player.getZ(); + } + + // ==================== Jump Suppression ==================== + + /** + * Suppress jumps for styles with jump disabled. + * + *

{@code LivingJumpEvent} is NOT cancelable. Standard approach: subtract + * the known jump impulse from Y velocity, preserving knockback and other + * sources of Y motion.

+ * + *

A {@link ClientboundSetEntityMotionPacket} is sent to minimize the + * client-side 1-frame bounce artifact.

+ */ + @SubscribeEvent + public static void onLivingJump(LivingEvent.LivingJumpEvent event) { + if (!(event.getEntity() instanceof ServerPlayer player)) { + return; + } + + PlayerBindState state = PlayerBindState.getInstance(player); + if (state == null || !state.isResolvedJumpDisabled()) { + return; + } + + // Subtract vanilla jump impulse, preserving other Y velocity (knockback, etc.) + // Vanilla: jumpPower = 0.42 + (amplifier + 1) * 0.1 = 0.42 * factor + double jumpVelocity = 0.42 * getJumpBoostFactor(player); + Vec3 motion = player.getDeltaMovement(); + player.setDeltaMovement(motion.x, motion.y - jumpVelocity, motion.z); + + // Sync to client to minimize visual bounce artifact + player.connection.send(new ClientboundSetEntityMotionPacket(player)); + } + + /** + * Calculate the Jump Boost potion factor. + * Vanilla adds {@code (amplifier + 1) * 0.1} to the base 0.42 jump height. + * We express this as a multiplicative factor on 0.42 for clean subtraction. + * + * @return 1.0 with no Jump Boost, higher with Jump Boost active + */ + private static double getJumpBoostFactor(Player player) { + var jumpBoost = player.getEffect(MobEffects.JUMP); + if (jumpBoost != null) { + return 1.0 + (jumpBoost.getAmplifier() + 1) * 0.1 / 0.42; + } + return 1.0; + } + + // ==================== Lifecycle ==================== + + private static void onActivate(ServerPlayer player, PlayerBindState state, + MovementStyle style) { + switch (style) { + case WADDLE -> activateWaddle(player, state); + case SHUFFLE -> activateShuffle(player, state); + case HOP -> activateHop(player, state); + case CRAWL -> activateCrawl(player, state); + } + } + + private static void onDeactivate(ServerPlayer player, PlayerBindState state, + MovementStyle style) { + switch (style) { + case WADDLE -> deactivateWaddle(player, state); + case SHUFFLE -> deactivateShuffle(player, state); + case HOP -> deactivateHop(player, state); + case CRAWL -> deactivateCrawl(player, state); + } + } + + private static void tickStyle(ServerPlayer player, PlayerBindState state) { + switch (state.getActiveMovementStyle()) { + case WADDLE -> tickWaddle(player, state); + case SHUFFLE -> tickShuffle(player, state); + case HOP -> tickHop(player, state); + case CRAWL -> tickCrawl(player, state); + } + } + + // ==================== Waddle ==================== + + private static void activateWaddle(ServerPlayer player, PlayerBindState state) { + applySpeedModifier(player, WADDLE_SPEED_UUID, "tiedup.waddle_speed", + state.getResolvedMovementSpeed()); + } + + private static void deactivateWaddle(ServerPlayer player, PlayerBindState state) { + removeSpeedModifier(player, WADDLE_SPEED_UUID); + } + + private static void tickWaddle(ServerPlayer player, PlayerBindState state) { + // Waddle is animation-only on the server. No velocity manipulation. + // The visual zigzag is handled by the context animation on the client. + } + + // ==================== Shuffle ==================== + + private static void activateShuffle(ServerPlayer player, PlayerBindState state) { + applySpeedModifier(player, SHUFFLE_SPEED_UUID, "tiedup.shuffle_speed", + state.getResolvedMovementSpeed()); + } + + private static void deactivateShuffle(ServerPlayer player, PlayerBindState state) { + removeSpeedModifier(player, SHUFFLE_SPEED_UUID); + } + + private static void tickShuffle(ServerPlayer player, PlayerBindState state) { + // Shuffle: speed reduction via attribute is sufficient. No per-tick work. + } + + // ==================== Hop ==================== + + private static void activateHop(ServerPlayer player, PlayerBindState state) { + // Apply base speed reduction (~15% base speed between hops) + applySpeedModifier(player, HOP_SPEED_UUID, "tiedup.hop_speed", + state.getResolvedMovementSpeed()); + state.hopCooldown = 0; + state.hopStartupPending = true; + state.hopStartupTicks = HOP_STARTUP_DELAY_TICKS; + } + + private static void deactivateHop(ServerPlayer player, PlayerBindState state) { + removeSpeedModifier(player, HOP_SPEED_UUID); + state.hopCooldown = 0; + state.hopStartupPending = false; + state.hopStartupTicks = 0; + state.hopNotMovingTicks = 0; + } + + /** + * Hop tick logic: + *
    + *
  • Detect movement via position delta (not player.zza/xxa)
  • + *
  • If moving + on ground + cooldown expired: execute hop (with startup delay on first hop)
  • + *
  • If not moving for >= 2 ticks: reset startup pending
  • + *
  • Decrement cooldown each tick
  • + *
+ */ + private static void tickHop(ServerPlayer player, PlayerBindState state) { + boolean isMoving = player.distanceToSqr(state.lastX, state.lastY, state.lastZ) + > MOVEMENT_THRESHOLD_SQ; + + // Decrement cooldown + if (state.hopCooldown > 0) { + state.hopCooldown--; + } + + if (isMoving && player.onGround() && state.hopCooldown <= 0) { + if (state.hopStartupPending) { + // Startup delay: decrement and wait (latched: completes even if + // player briefly releases input during these 4 ticks) + state.hopStartupTicks--; + if (state.hopStartupTicks <= 0) { + // Startup complete: execute first hop + state.hopStartupPending = false; + executeHop(player, state); + } + } else { + // Normal hop + executeHop(player, state); + } + state.hopNotMovingTicks = 0; + } else if (!isMoving) { + state.hopNotMovingTicks++; + // Reset startup if not moving for >= 2 consecutive ticks + if (state.hopNotMovingTicks >= HOP_STARTUP_RESET_TICKS + && !state.hopStartupPending) { + state.hopStartupPending = true; + state.hopStartupTicks = HOP_STARTUP_DELAY_TICKS; + } + } else { + // Moving but not on ground or cooldown active — reset not-moving counter + state.hopNotMovingTicks = 0; + } + } + + /** + * Execute a single hop: apply Y impulse + forward impulse along look direction. + * Sends {@link ClientboundSetEntityMotionPacket} to sync velocity to client. + */ + private static void executeHop(ServerPlayer player, PlayerBindState state) { + Vec3 look = player.getLookAngle(); + // Project look onto horizontal plane and normalize (safe: zero vec normalizes to zero) + Vec3 forward = new Vec3(look.x, 0, look.z).normalize(); + Vec3 currentMotion = player.getDeltaMovement(); + + player.setDeltaMovement( + currentMotion.x + forward.x * HOP_FORWARD_IMPULSE, + HOP_Y_IMPULSE, + currentMotion.z + forward.z * HOP_FORWARD_IMPULSE + ); + + state.hopCooldown = HOP_COOLDOWN_TICKS; + + // Sync velocity to client to prevent rubber-banding + player.connection.send(new ClientboundSetEntityMotionPacket(player)); + } + + // ==================== Crawl ==================== + + private static void activateCrawl(ServerPlayer player, PlayerBindState state) { + applySpeedModifier(player, CRAWL_SPEED_UUID, "tiedup.crawl_speed", + state.getResolvedMovementSpeed()); + player.setForcedPose(Pose.SWIMMING); + player.refreshDimensions(); + state.pendingPoseRestore = false; + } + + private static void deactivateCrawl(ServerPlayer player, PlayerBindState state) { + removeSpeedModifier(player, CRAWL_SPEED_UUID); + + // Space check: can the player stand up? + EntityDimensions standDims = player.getDimensions(Pose.STANDING); + AABB standBox = standDims.makeBoundingBox(player.position()); + boolean canStand = player.level().noCollision(player, standBox); + + if (canStand) { + player.setForcedPose(null); + player.refreshDimensions(); + } else { + // Can't stand yet -- flag for periodic retry in tick flow (step 2) + state.pendingPoseRestore = true; + } + } + + private static void tickCrawl(ServerPlayer player, PlayerBindState state) { + // Guard re-assertion: only re-apply if something cleared the forced pose + // (avoids unnecessary per-tick SynchedEntityData dirty-marking) + if (player.getForcedPose() != Pose.SWIMMING) { + player.setForcedPose(Pose.SWIMMING); + player.refreshDimensions(); + } + } + + // ==================== Pending Pose Restore ==================== + + /** + * Try to restore standing pose after crawl deactivation. + * Called every tick regardless of active style (step 2 in tick flow). + * Retries until space is available for the player to stand. + */ + private static void tryRestoreStandingPose(ServerPlayer player, + PlayerBindState state) { + EntityDimensions standDims = player.getDimensions(Pose.STANDING); + AABB standBox = standDims.makeBoundingBox(player.position()); + boolean canStand = player.level().noCollision(player, standBox); + + if (canStand) { + player.setForcedPose(null); + player.refreshDimensions(); + state.pendingPoseRestore = false; + LOGGER.debug("Restored standing pose for {} (pending pose restore cleared)", + player.getName().getString()); + } + } + + // ==================== V1 Legacy Cleanup (H6) ==================== + + /** + * Remove the legacy V1 {@code RestraintEffectUtils} speed modifier if present. + * + *

V1 used {@code addPermanentModifier()} with UUID {@code 7f3c7c8e-...} and + * {@link AttributeModifier.Operation#ADDITION}. Because permanent modifiers are + * serialized to player NBT, players upgrading mid-session or loading old saves + * may still carry this modifier. Removing it here ensures only the V2 + * {@code MULTIPLY_BASE} modifier is active.

+ * + *

This is a no-op if the modifier is not present (cheap UUID lookup).

+ */ + private static void cleanupV1Modifier(ServerPlayer player) { + AttributeInstance attr = player.getAttribute(Attributes.MOVEMENT_SPEED); + if (attr != null && attr.getModifier(V1_BIND_SPEED_MODIFIER_UUID) != null) { + attr.removeModifier(V1_BIND_SPEED_MODIFIER_UUID); + LOGGER.info("Removed stale V1 speed modifier from player {}", + player.getName().getString()); + } + } + + // ==================== Attribute Modifier Helpers ==================== + + /** + * Apply a transient {@code MULTIPLY_BASE} speed modifier. + * Always removes any existing modifier with the same UUID first, because + * {@code addTransientModifier} throws {@link IllegalArgumentException} + * if a modifier with the same UUID already exists. + * + *

{@code MULTIPLY_BASE} means the modifier value is added to 1.0 and + * multiplied with the base value. A multiplier of 0.4 requires a modifier + * value of -0.6: {@code base * (1 + (-0.6)) = base * 0.4}.

+ * + * @param player the target player + * @param uuid unique modifier UUID per style + * @param name modifier name (for debugging in F3 screen) + * @param multiplier the desired speed fraction (0.0-1.0) + */ + private static void applySpeedModifier(ServerPlayer player, UUID uuid, String name, + float multiplier) { + AttributeInstance attr = player.getAttribute(Attributes.MOVEMENT_SPEED); + if (attr == null) return; + + // Remove existing modifier first (no-op if not present) + attr.removeModifier(uuid); + + // MULTIPLY_BASE: value of -(1 - multiplier) reduces base speed to multiplier fraction + double value = -(1.0 - multiplier); + attr.addTransientModifier(new AttributeModifier(uuid, name, + value, AttributeModifier.Operation.MULTIPLY_BASE)); + } + + /** + * Remove a speed modifier by UUID. Safe to call even if no modifier + * with this UUID is present. + */ + private static void removeSpeedModifier(ServerPlayer player, UUID uuid) { + AttributeInstance attr = player.getAttribute(Attributes.MOVEMENT_SPEED); + if (attr == null) return; + attr.removeModifier(uuid); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/movement/MovementStyleResolver.java b/src/main/java/com/tiedup/remake/v2/bondage/movement/MovementStyleResolver.java new file mode 100644 index 0000000..432fd2d --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/movement/MovementStyleResolver.java @@ -0,0 +1,154 @@ +package com.tiedup.remake.v2.bondage.movement; + +import com.tiedup.remake.items.base.ItemBind; +import com.tiedup.remake.items.base.PoseType; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition; +import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry; +import java.util.Map; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; + +/** + * Resolves the winning movement style from a player's equipped bondage items. + * + *

Shared class (client + server). Deterministic: same items produce the same result. + * The highest-severity style wins. Tiebreaker: lowest {@link BodyRegionV2#ordinal()}.

+ * + *

The winning item's {@link MovementModifier} (if present) overrides the style's + * default speed/jump values. Modifiers from lower-severity items are ignored.

+ * + *

V1 Compatibility (H6 fix)

+ *

V1 items ({@link ItemBind}) stored in V2 capability + * do not have data-driven definitions. This resolver provides a fallback that + * maps V1 bind mode + pose type to a {@link MovementStyle} with speed values matching + * the original V1 behavior, preventing double stacking between the legacy + * {@code RestraintEffectUtils} attribute modifier and the V2 modifier.

+ */ +public final class MovementStyleResolver { + + private MovementStyleResolver() {} + + // --- V1 fallback speed values --- + // V1 used ADDITION(-0.09) on base 0.10 = 0.01 effective = 10% speed + // Expressed as MULTIPLY_BASE fraction: 0.10 + private static final float V1_STANDARD_SPEED = 0.10f; + + // V1 used ADDITION(-0.10) on base 0.10 = 0.00 effective = 0% speed + // Expressed as MULTIPLY_BASE fraction: 0.0 (fully immobile) + private static final float V1_IMMOBILIZED_SPEED = 0.0f; + + /** + * Resolve the winning movement style from all equipped items. + * + *

Checks V2 data-driven definitions first, then falls back to V1 {@link ItemBind} + * introspection for items without data-driven definitions.

+ * + * @param equipped map of region to ItemStack (from {@code IV2BondageEquipment.getAllEquipped()}) + * @return the resolved movement, or {@link ResolvedMovement#NONE} if no styled items + */ + public static ResolvedMovement resolve(Map equipped) { + if (equipped == null || equipped.isEmpty()) { + return ResolvedMovement.NONE; + } + + MovementStyle bestStyle = null; + float bestSpeed = 1.0f; + boolean bestJumpDisabled = false; + int bestSeverity = -1; + int bestRegionOrdinal = Integer.MAX_VALUE; + + for (Map.Entry entry : equipped.entrySet()) { + BodyRegionV2 region = entry.getKey(); + ItemStack stack = entry.getValue(); + if (stack.isEmpty()) continue; + + // --- Try V2 data-driven definition first --- + DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack); + if (def != null && def.movementStyle() != null) { + MovementStyle style = def.movementStyle(); + int severity = style.getSeverity(); + int regionOrdinal = region.ordinal(); + + if (severity > bestSeverity + || (severity == bestSeverity && regionOrdinal < bestRegionOrdinal)) { + bestStyle = style; + MovementModifier mod = def.movementModifier(); + bestSpeed = (mod != null && mod.speedMultiplier() != null) + ? mod.speedMultiplier() + : style.getDefaultSpeedMultiplier(); + bestJumpDisabled = (mod != null && mod.jumpDisabled() != null) + ? mod.jumpDisabled() + : style.isDefaultJumpDisabled(); + bestSeverity = severity; + bestRegionOrdinal = regionOrdinal; + } + continue; + } + + // --- V1 fallback: ItemBind without data-driven definition --- + V1Fallback fallback = resolveV1Fallback(stack); + if (fallback != null) { + int severity = fallback.style.getSeverity(); + int regionOrdinal = region.ordinal(); + + if (severity > bestSeverity + || (severity == bestSeverity && regionOrdinal < bestRegionOrdinal)) { + bestStyle = fallback.style; + bestSpeed = fallback.speed; + bestJumpDisabled = fallback.jumpDisabled; + bestSeverity = severity; + bestRegionOrdinal = regionOrdinal; + } + } + } + + if (bestStyle == null) { + return ResolvedMovement.NONE; + } + + return new ResolvedMovement(bestStyle, bestSpeed, bestJumpDisabled); + } + + // ==================== V1 Fallback ==================== + + /** + * Attempt to derive a movement style from a V1 {@link ItemBind} item. + * + *

Only items with legs bound produce a movement style. The mapping preserves + * the original V1 speed values:

+ *
    + *
  • WRAP / LATEX_SACK: SHUFFLE at 0% speed (full immobilization), jump disabled
  • + *
  • DOG / HUMAN_CHAIR: CRAWL at V1 standard speed (10%), jump disabled
  • + *
  • STANDARD / STRAITJACKET: SHUFFLE at 10% speed, jump disabled
  • + *
+ * + * @param stack the ItemStack to inspect + * @return fallback resolution, or null if the item is not a V1 bind or legs are not bound + */ + @Nullable + private static V1Fallback resolveV1Fallback(ItemStack stack) { + if (!(stack.getItem() instanceof ItemBind bindItem)) { + return null; + } + + if (!ItemBind.hasLegsBound(stack)) { + return null; + } + + PoseType poseType = bindItem.getPoseType(); + + return switch (poseType) { + case WRAP, LATEX_SACK -> + new V1Fallback(MovementStyle.SHUFFLE, V1_IMMOBILIZED_SPEED, true); + case DOG, HUMAN_CHAIR -> + new V1Fallback(MovementStyle.CRAWL, V1_STANDARD_SPEED, true); + default -> + // STANDARD, STRAITJACKET: shuffle at V1 standard speed + new V1Fallback(MovementStyle.SHUFFLE, V1_STANDARD_SPEED, true); + }; + } + + /** Internal holder for V1 fallback resolution result. */ + private record V1Fallback(MovementStyle style, float speed, boolean jumpDisabled) {} +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/movement/ResolvedMovement.java b/src/main/java/com/tiedup/remake/v2/bondage/movement/ResolvedMovement.java new file mode 100644 index 0000000..a337cce --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/movement/ResolvedMovement.java @@ -0,0 +1,24 @@ +package com.tiedup.remake.v2.bondage.movement; + +import org.jetbrains.annotations.Nullable; + +/** + * Result of resolving the winning movement style from all equipped items. + * Contains the final computed values (style defaults merged with item overrides). + * + *

A null instance or null style means no movement restriction applies.

+ */ +public record ResolvedMovement( + /** The winning movement style, or null if no styled items are equipped. */ + @Nullable MovementStyle style, + + /** Final speed multiplier (style default or item override). */ + float speedMultiplier, + + /** Final jump-disabled flag (style default or item override). */ + boolean jumpDisabled +) { + + /** Sentinel for "no movement style active". */ + public static final ResolvedMovement NONE = new ResolvedMovement(null, 1.0f, false); +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/network/PacketSyncV2Equipment.java b/src/main/java/com/tiedup/remake/v2/bondage/network/PacketSyncV2Equipment.java new file mode 100644 index 0000000..efd8254 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/network/PacketSyncV2Equipment.java @@ -0,0 +1,81 @@ +package com.tiedup.remake.v2.bondage.network; + +import com.tiedup.remake.v2.bondage.IV2EquipmentHolder; +import com.tiedup.remake.v2.bondage.capability.V2BondageEquipmentProvider; +import java.util.function.Supplier; +import net.minecraft.client.Minecraft; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.level.Level; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.fml.loading.FMLEnvironment; +import net.minecraftforge.network.NetworkEvent; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Server-to-client packet that syncs V2 bondage equipment state. + * + * Sent when equipment changes (equip/unequip/clear) and on player login + * or start-tracking. The client deserializes into its local capability + * so the render layer has data to display. + */ +public class PacketSyncV2Equipment { + + private static final Logger LOGGER = LogManager.getLogger("PacketSyncV2Equipment"); + + private final int entityId; + private final CompoundTag data; + + public PacketSyncV2Equipment(int entityId, CompoundTag data) { + this.entityId = entityId; + this.data = data != null ? data : new CompoundTag(); + } + + // ==================== Codec ==================== + + public static void encode(PacketSyncV2Equipment msg, FriendlyByteBuf buf) { + buf.writeInt(msg.entityId); + buf.writeNbt(msg.data); + } + + public static PacketSyncV2Equipment decode(FriendlyByteBuf buf) { + int entityId = buf.readInt(); + CompoundTag data = buf.readNbt(); + return new PacketSyncV2Equipment(entityId, data != null ? data : new CompoundTag()); + } + + // ==================== Handler ==================== + + public static void handle(PacketSyncV2Equipment msg, Supplier ctxSupplier) { + NetworkEvent.Context ctx = ctxSupplier.get(); + ctx.enqueueWork(() -> { + if (FMLEnvironment.dist == Dist.CLIENT) { + handleOnClient(msg); + } + }); + ctx.setPacketHandled(true); + } + + @OnlyIn(Dist.CLIENT) + private static void handleOnClient(PacketSyncV2Equipment msg) { + Level level = Minecraft.getInstance().level; + if (level == null) return; + + Entity entity = level.getEntity(msg.entityId); + if (entity instanceof LivingEntity living) { + // IV2EquipmentHolder entities (e.g., Damsels) sync via SynchedEntityData, + // not via this packet. If we receive one for such an entity, deserialize + // directly into their internal equipment storage. + if (living instanceof IV2EquipmentHolder holder) { + holder.getV2Equipment().deserializeNBT(msg.data); + return; + } + living.getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT) + .ifPresent(equip -> equip.deserializeNBT(msg.data)); + } + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/network/PacketV2LockToggle.java b/src/main/java/com/tiedup/remake/v2/bondage/network/PacketV2LockToggle.java new file mode 100644 index 0000000..e628258 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/network/PacketV2LockToggle.java @@ -0,0 +1,143 @@ +package com.tiedup.remake.v2.bondage.network; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.ItemKey; +import com.tiedup.remake.items.ItemMasterKey; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.network.PacketRateLimiter; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.network.NetworkEvent; + +/** + * C2S packet: player locks or unlocks a V2 bondage item on a target entity. + * + *

The server checks the player's hands for a key -- no key data is sent by + * the client. This prevents spoofed key UUIDs. + * + *

Security model: + *

    + *
  • Distance check: sender must be within 4 blocks
  • + *
  • Line-of-sight check: sender must see the target
  • + *
  • Key authority: server reads key from sender's hands, never from packet
  • + *
  • Master key can only UNLOCK (not lock)
  • + *
  • Regular key must match the lock UUID to unlock
  • + *
+ * + *

Rate limited under the "action" bucket (10 tokens, 2/sec refill). + */ +public class PacketV2LockToggle { + + public enum Action { LOCK, UNLOCK } + + private final int targetEntityId; + private final BodyRegionV2 region; + private final Action action; + + public PacketV2LockToggle(int targetEntityId, BodyRegionV2 region, Action action) { + this.targetEntityId = targetEntityId; + this.region = region; + this.action = action; + } + + public static void encode(PacketV2LockToggle msg, FriendlyByteBuf buf) { + buf.writeInt(msg.targetEntityId); + buf.writeEnum(msg.region); + buf.writeEnum(msg.action); + } + + public static PacketV2LockToggle decode(FriendlyByteBuf buf) { + return new PacketV2LockToggle( + buf.readInt(), + buf.readEnum(BodyRegionV2.class), + buf.readEnum(Action.class) + ); + } + + public static void handle(PacketV2LockToggle msg, Supplier ctxSupplier) { + NetworkEvent.Context ctx = ctxSupplier.get(); + ctx.enqueueWork(() -> { + ServerPlayer sender = ctx.getSender(); + if (sender == null) return; + if (!PacketRateLimiter.allowPacket(sender, "action")) return; + + handleServer(sender, msg.targetEntityId, msg.region, msg.action); + }); + ctx.setPacketHandled(true); + } + + private static void handleServer( + ServerPlayer sender, int targetEntityId, BodyRegionV2 region, Action action + ) { + Entity rawTarget = sender.level().getEntity(targetEntityId); + if (!(rawTarget instanceof LivingEntity target)) return; + + // Distance + line-of-sight validation + if (sender.distanceTo(target) > 4.0 || !sender.hasLineOfSight(target)) return; + + ItemStack stack = V2EquipmentHelper.getInRegion(target, region); + if (stack.isEmpty()) return; + if (!(stack.getItem() instanceof ILockable lockable)) return; + + // Find key in sender's hands -- server-authoritative, never from packet + ItemStack mainHand = sender.getItemInHand(InteractionHand.MAIN_HAND); + ItemStack offHand = sender.getItemInHand(InteractionHand.OFF_HAND); + + boolean hasMasterKey = mainHand.getItem() instanceof ItemMasterKey + || offHand.getItem() instanceof ItemMasterKey; + + ItemKey heldKey = null; + ItemStack heldKeyStack = ItemStack.EMPTY; + if (mainHand.getItem() instanceof ItemKey key) { + heldKey = key; + heldKeyStack = mainHand; + } else if (offHand.getItem() instanceof ItemKey key) { + heldKey = key; + heldKeyStack = offHand; + } + + switch (action) { + case LOCK -> { + if (lockable.isLocked(stack)) return; + if (!lockable.isLockable(stack)) return; + if (hasMasterKey) return; // master key cannot lock + if (heldKey == null) return; + + UUID keyUUID = heldKey.getKeyUUID(heldKeyStack); + lockable.setLockedByKeyUUID(stack, keyUUID); + lockable.initializeLockResistance(stack); + + TiedUpMod.LOGGER.debug("[V2LockToggle] Locked region {} on entity {}", + region.name(), target.getName().getString()); + } + case UNLOCK -> { + if (!lockable.isLocked(stack)) return; + + if (hasMasterKey) { + lockable.setLockedByKeyUUID(stack, null); + lockable.clearLockResistance(stack); + } else if (heldKey != null) { + UUID keyUUID = heldKey.getKeyUUID(heldKeyStack); + if (!lockable.matchesKey(stack, keyUUID)) return; + lockable.setLockedByKeyUUID(stack, null); + lockable.clearLockResistance(stack); + } else { + return; + } + + TiedUpMod.LOGGER.debug("[V2LockToggle] Unlocked region {} on entity {}", + region.name(), target.getName().getString()); + } + } + + V2EquipmentHelper.sync(target); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/network/PacketV2SelfEquip.java b/src/main/java/com/tiedup/remake/v2/bondage/network/PacketV2SelfEquip.java new file mode 100644 index 0000000..79f5a85 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/network/PacketV2SelfEquip.java @@ -0,0 +1,91 @@ +package com.tiedup.remake.v2.bondage.network; + +import com.tiedup.remake.network.PacketRateLimiter; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import com.tiedup.remake.v2.bondage.V2EquipResult; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem; +import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.network.NetworkEvent; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Client→Server: Player equips a bondage item from their own inventory onto a body region. + */ +public class PacketV2SelfEquip { + + private static final Logger LOGGER = LogManager.getLogger("PacketV2SelfEquip"); + + private final BodyRegionV2 region; + private final int inventorySlot; + + public PacketV2SelfEquip(BodyRegionV2 region, int inventorySlot) { + this.region = region; + this.inventorySlot = inventorySlot; + } + + public static void encode(PacketV2SelfEquip msg, FriendlyByteBuf buf) { + buf.writeEnum(msg.region); + buf.writeVarInt(msg.inventorySlot); + } + + public static PacketV2SelfEquip decode(FriendlyByteBuf buf) { + return new PacketV2SelfEquip(buf.readEnum(BodyRegionV2.class), buf.readVarInt()); + } + + public static void handle(PacketV2SelfEquip msg, Supplier ctxSupplier) { + NetworkEvent.Context ctx = ctxSupplier.get(); + ctx.enqueueWork(() -> { + ServerPlayer player = ctx.getSender(); + if (player == null) return; + if (!PacketRateLimiter.allowPacket(player, "action")) return; + + // Validate slot index + if (msg.inventorySlot < 0 || msg.inventorySlot >= player.getInventory().getContainerSize()) return; + + ItemStack stack = player.getInventory().getItem(msg.inventorySlot); + if (stack.isEmpty()) return; + if (!(stack.getItem() instanceof IV2BondageItem bondageItem)) return; + + // Warn if data-driven item has no definition (missing JSON or reload issue) + if (bondageItem instanceof DataDrivenBondageItem && DataDrivenItemRegistry.get(stack) == null) { + LOGGER.warn("[V2SelfEquip] Data-driven item in slot {} has no definition — equip blocked. Stack NBT: {}", + msg.inventorySlot, stack.getTag()); + return; + } + + // Validate item targets this region + if (!bondageItem.getOccupiedRegions(stack).contains(msg.region)) return; + + // Furniture seat blocks this region + if (player.isPassenger() && player.getVehicle() instanceof com.tiedup.remake.v2.furniture.ISeatProvider provider) { + com.tiedup.remake.v2.furniture.SeatDefinition seat = provider.getSeatForPassenger(player); + if (seat != null && seat.blockedRegions().contains(msg.region)) { + return; // Region blocked by furniture + } + } + + // Try equip (handles conflict resolution) + V2EquipResult result = V2EquipmentHelper.equipItem(player, stack); + if (result.isSuccess()) { + // Remove from inventory (or reduce count) + player.getInventory().removeItem(msg.inventorySlot, 1); + // Return any displaced items to inventory + if (result.displaced() != null) { + for (ItemStack displaced : result.displaced()) { + if (!displaced.isEmpty()) { + player.getInventory().placeItemBackInInventory(displaced); + } + } + } + } + }); + ctx.setPacketHandled(true); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/network/PacketV2SelfLock.java b/src/main/java/com/tiedup/remake/v2/bondage/network/PacketV2SelfLock.java new file mode 100644 index 0000000..8a3fe26 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/network/PacketV2SelfLock.java @@ -0,0 +1,79 @@ +package com.tiedup.remake.v2.bondage.network; + +import com.tiedup.remake.items.ItemKey; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.network.PacketRateLimiter; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.network.NetworkEvent; + +/** + * Client→Server: Player locks their own equipped item using a key from inventory. + */ +public class PacketV2SelfLock { + + private final BodyRegionV2 region; + + public PacketV2SelfLock(BodyRegionV2 region) { + this.region = region; + } + + public static void encode(PacketV2SelfLock msg, FriendlyByteBuf buf) { + buf.writeEnum(msg.region); + } + + public static PacketV2SelfLock decode(FriendlyByteBuf buf) { + return new PacketV2SelfLock(buf.readEnum(BodyRegionV2.class)); + } + + public static void handle(PacketV2SelfLock msg, Supplier ctxSupplier) { + NetworkEvent.Context ctx = ctxSupplier.get(); + ctx.enqueueWork(() -> { + ServerPlayer player = ctx.getSender(); + if (player == null) return; + if (!PacketRateLimiter.allowPacket(player, "action")) return; + + // Arms must be free to self-lock + if (V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS)) return; + + ItemStack equipped = V2EquipmentHelper.getInRegion(player, msg.region); + if (equipped.isEmpty()) return; + if (!(equipped.getItem() instanceof ILockable lockable)) return; + if (!lockable.isLockable(equipped) || lockable.isLocked(equipped)) return; + + // Furniture seat blocks this region + if (player.isPassenger() && player.getVehicle() instanceof com.tiedup.remake.v2.furniture.ISeatProvider provider) { + com.tiedup.remake.v2.furniture.SeatDefinition seat = provider.getSeatForPassenger(player); + if (seat != null && seat.blockedRegions().contains(msg.region)) { + return; // Region blocked by furniture + } + } + + // Find a key in inventory + ItemStack keyStack = findKeyInInventory(player); + if (keyStack.isEmpty()) return; + if (!(keyStack.getItem() instanceof ItemKey key)) return; + + UUID keyUUID = key.getKeyUUID(keyStack); + lockable.setLockedByKeyUUID(equipped, keyUUID); + V2EquipmentHelper.sync(player); + }); + ctx.setPacketHandled(true); + } + + /** Find the first ItemKey in the player's inventory. Returns ItemStack.EMPTY if none. */ + private static ItemStack findKeyInInventory(ServerPlayer player) { + for (int i = 0; i < player.getInventory().getContainerSize(); i++) { + ItemStack s = player.getInventory().getItem(i); + if (!s.isEmpty() && s.getItem() instanceof ItemKey) { + return s; + } + } + return ItemStack.EMPTY; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/network/PacketV2SelfRemove.java b/src/main/java/com/tiedup/remake/v2/bondage/network/PacketV2SelfRemove.java new file mode 100644 index 0000000..559096a --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/network/PacketV2SelfRemove.java @@ -0,0 +1,90 @@ +package com.tiedup.remake.v2.bondage.network; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.network.PacketRateLimiter; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.network.NetworkEvent; + +/** + * C2S packet: player requests removal of a V2 bondage item from themselves. + * + * Validates: + * - Region is occupied + * - Item canUnequip (not locked) + * - ARMS not occupied (can't manipulate buckles with bound arms) + * - Item does not occupy ARMS (arm restraints require struggle, not manual removal) + */ +public class PacketV2SelfRemove { + + private final BodyRegionV2 region; + + public PacketV2SelfRemove(BodyRegionV2 region) { + this.region = region; + } + + public static void encode(PacketV2SelfRemove msg, FriendlyByteBuf buf) { + buf.writeEnum(msg.region); + } + + public static PacketV2SelfRemove decode(FriendlyByteBuf buf) { + return new PacketV2SelfRemove(buf.readEnum(BodyRegionV2.class)); + } + + public static void handle(PacketV2SelfRemove msg, Supplier ctxSupplier) { + NetworkEvent.Context ctx = ctxSupplier.get(); + ctx.enqueueWork(() -> { + ServerPlayer player = ctx.getSender(); + if (player == null) return; + if (!PacketRateLimiter.allowPacket(player, "action")) return; + + handleServer(player, msg.region); + }); + ctx.setPacketHandled(true); + } + + private static void handleServer(ServerPlayer player, BodyRegionV2 region) { + ItemStack stack = V2EquipmentHelper.getInRegion(player, region); + if (stack.isEmpty()) return; + + if (!(stack.getItem() instanceof IV2BondageItem item)) return; + + // Arm restraints cannot be self-removed — must use struggle + if (item.getOccupiedRegions(stack).contains(BodyRegionV2.ARMS)) { + TiedUpMod.LOGGER.debug("[V2SelfRemove] Blocked: item occupies ARMS, must struggle"); + return; + } + + // Cannot manipulate buckles/clasps with bound arms + if (V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS)) { + TiedUpMod.LOGGER.debug("[V2SelfRemove] Blocked: player's ARMS are occupied"); + return; + } + + // Cannot manipulate buckles/clasps with covered hands + if (V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.HANDS)) { + TiedUpMod.LOGGER.debug("[V2SelfRemove] Blocked: player's HANDS are occupied"); + return; + } + + // Check item allows unequip (not locked) + if (!item.canUnequip(stack, player)) { + TiedUpMod.LOGGER.debug("[V2SelfRemove] Blocked: item canUnequip=false (locked?)"); + return; + } + + // Remove and give to inventory + ItemStack removed = V2EquipmentHelper.unequipFromRegion(player, region); + if (!removed.isEmpty()) { + if (!player.getInventory().add(removed)) { + player.drop(removed, false); + } + } + // sync() is called inside unequipFromRegion + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/network/PacketV2SelfUnlock.java b/src/main/java/com/tiedup/remake/v2/bondage/network/PacketV2SelfUnlock.java new file mode 100644 index 0000000..3cdf5ce --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/network/PacketV2SelfUnlock.java @@ -0,0 +1,88 @@ +package com.tiedup.remake.v2.bondage.network; + +import com.tiedup.remake.items.ItemKey; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.network.PacketRateLimiter; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.network.NetworkEvent; + +/** + * Client→Server: Player unlocks their own equipped item using the matching key. + */ +public class PacketV2SelfUnlock { + + private final BodyRegionV2 region; + + public PacketV2SelfUnlock(BodyRegionV2 region) { + this.region = region; + } + + public static void encode(PacketV2SelfUnlock msg, FriendlyByteBuf buf) { + buf.writeEnum(msg.region); + } + + public static PacketV2SelfUnlock decode(FriendlyByteBuf buf) { + return new PacketV2SelfUnlock(buf.readEnum(BodyRegionV2.class)); + } + + public static void handle(PacketV2SelfUnlock msg, Supplier ctxSupplier) { + NetworkEvent.Context ctx = ctxSupplier.get(); + ctx.enqueueWork(() -> { + ServerPlayer player = ctx.getSender(); + if (player == null) return; + if (!PacketRateLimiter.allowPacket(player, "action")) return; + + // Arms must be free to self-unlock + if (V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS)) return; + + ItemStack equipped = V2EquipmentHelper.getInRegion(player, msg.region); + if (equipped.isEmpty()) return; + if (!(equipped.getItem() instanceof ILockable lockable)) return; + if (!lockable.isLocked(equipped)) return; + + // Furniture seat blocks this region + if (player.isPassenger() && player.getVehicle() instanceof com.tiedup.remake.v2.furniture.ISeatProvider provider) { + com.tiedup.remake.v2.furniture.SeatDefinition seat = provider.getSeatForPassenger(player); + if (seat != null && seat.blockedRegions().contains(msg.region)) { + return; // Region blocked by furniture + } + } + + // Find matching key in inventory + UUID lockedByUUID = lockable.getLockedByKeyUUID(equipped); + ItemStack keyStack = findMatchingKeyInInventory(player, lockedByUUID); + if (keyStack.isEmpty()) return; + + lockable.setLockedByKeyUUID(equipped, null); + V2EquipmentHelper.sync(player); + }); + ctx.setPacketHandled(true); + } + + /** + * Find a key in the player's inventory that matches the lock UUID, + * or a master key that unlocks anything. + */ + private static ItemStack findMatchingKeyInInventory(ServerPlayer player, UUID lockedByUUID) { + for (int i = 0; i < player.getInventory().getContainerSize(); i++) { + ItemStack s = player.getInventory().getItem(i); + if (s.isEmpty()) continue; + // Master key unlocks everything + if (s.is(ModItems.MASTER_KEY.get())) return s; + // Regular key: must match the lock UUID + if (s.getItem() instanceof ItemKey key) { + if (lockedByUUID != null && lockedByUUID.equals(key.getKeyUUID(s))) { + return s; + } + } + } + return ItemStack.EMPTY; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/network/PacketV2StruggleStart.java b/src/main/java/com/tiedup/remake/v2/bondage/network/PacketV2StruggleStart.java new file mode 100644 index 0000000..8c46302 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/network/PacketV2StruggleStart.java @@ -0,0 +1,108 @@ +package com.tiedup.remake.v2.bondage.network; + +import com.tiedup.remake.items.base.IHasResistance; +import com.tiedup.remake.items.base.ILockable; +import com.tiedup.remake.minigame.ContinuousStruggleMiniGameState; +import com.tiedup.remake.minigame.StruggleSessionManager; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.PacketRateLimiter; +import com.tiedup.remake.network.minigame.PacketContinuousStruggleState; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.network.NetworkEvent; + +/** + * C2S packet: player starts a struggle minigame against a V2 item in a region. + * + *

Flow: + *

    + *
  1. Client sends this packet with the target {@link BodyRegionV2}
  2. + *
  3. Server validates: region occupied, item has resistance
  4. + *
  5. Server creates a {@link ContinuousStruggleMiniGameState} via + * {@link MiniGameSessionManager#startV2StruggleSession}
  6. + *
  7. Server sends {@link PacketContinuousStruggleState}(START) back to open the minigame GUI
  8. + *
+ * + *

Rate limited under the "ui" bucket (3 tokens, 0.5/sec refill) since this is + * a screen-opening action, not a per-tick input. + */ +public class PacketV2StruggleStart { + + private final BodyRegionV2 region; + + public PacketV2StruggleStart(BodyRegionV2 region) { + this.region = region; + } + + public BodyRegionV2 getRegion() { + return region; + } + + public static void encode(PacketV2StruggleStart msg, FriendlyByteBuf buf) { + buf.writeEnum(msg.region); + } + + public static PacketV2StruggleStart decode(FriendlyByteBuf buf) { + return new PacketV2StruggleStart(buf.readEnum(BodyRegionV2.class)); + } + + public static void handle(PacketV2StruggleStart msg, Supplier ctxSupplier) { + NetworkEvent.Context ctx = ctxSupplier.get(); + ctx.enqueueWork(() -> { + ServerPlayer player = ctx.getSender(); + if (player == null) return; + if (!PacketRateLimiter.allowPacket(player, "ui")) return; + + handleServer(player, msg.region); + }); + ctx.setPacketHandled(true); + } + + private static void handleServer(ServerPlayer player, BodyRegionV2 region) { + ItemStack stack = V2EquipmentHelper.getInRegion(player, region); + if (stack.isEmpty()) return; + + if (!(stack.getItem() instanceof IHasResistance resistanceItem)) return; + + // BUG-002 fix: respect canBeStruggledOut flag + if (!resistanceItem.canBeStruggledOut(stack)) return; + + // RISK-002 fix: respect server config + if (!com.tiedup.remake.core.ModConfig.SERVER.struggleMiniGameEnabled.get()) return; + + int resistance = resistanceItem.getCurrentResistance(stack, player); + boolean isLocked = false; + if (stack.getItem() instanceof ILockable lockable) { + isLocked = lockable.isLocked(stack); + if (isLocked) { + resistance += lockable.getCurrentLockResistance(stack); + } + } + + // RISK-003 fix: no point starting a session with 0 resistance + if (resistance <= 0) return; + + StruggleSessionManager manager = StruggleSessionManager.getInstance(); + ContinuousStruggleMiniGameState session = manager.startV2StruggleSession( + player, region, resistance, isLocked + ); + + if (session != null) { + ModNetwork.sendToPlayer( + new PacketContinuousStruggleState( + session.getSessionId(), + ContinuousStruggleMiniGameState.UpdateType.START, + session.getCurrentDirection().getIndex(), + session.getCurrentResistance(), + session.getMaxResistance(), + isLocked + ), + player + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/v2/client/DataDrivenIconBakedModel.java b/src/main/java/com/tiedup/remake/v2/client/DataDrivenIconBakedModel.java new file mode 100644 index 0000000..5a25262 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/client/DataDrivenIconBakedModel.java @@ -0,0 +1,95 @@ +package com.tiedup.remake.v2.client; + +import java.util.List; +import net.minecraft.client.renderer.block.model.BakedQuad; +import net.minecraft.client.renderer.block.model.ItemOverrides; +import net.minecraft.client.renderer.block.model.ItemTransforms; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.client.resources.model.BakedModel; +import net.minecraft.core.Direction; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.jetbrains.annotations.Nullable; + +/** + * Wrapper around a standard {@link BakedModel} that replaces {@link #getOverrides()} + * with a {@link DataDrivenIconOverrides} instance for NBT-based icon switching. + * + *

All other model properties (quads, ambient occlusion, transforms, particle icon) + * are delegated to the wrapped original model. This ensures the default appearance + * is preserved when no icon override is active.

+ * + *

Used for both {@code tiedup:data_driven_item} and {@code tiedup:furniture_placer} + * item models.

+ */ +@OnlyIn(Dist.CLIENT) +public class DataDrivenIconBakedModel implements BakedModel { + + private final BakedModel original; + private final DataDrivenIconOverrides overrides; + + /** + * @param original the original baked model to wrap (provides quads, transforms, etc.) + * @param overrides the custom overrides that resolve icon models from NBT + */ + public DataDrivenIconBakedModel(BakedModel original, DataDrivenIconOverrides overrides) { + this.original = original; + this.overrides = overrides; + } + + /** + * Get the custom icon overrides. This is the key method that enables + * per-stack model switching. + */ + @Override + public ItemOverrides getOverrides() { + return overrides; + } + + /** + * Get the custom overrides for cache management (clearing on reload). + */ + public DataDrivenIconOverrides getIconOverrides() { + return overrides; + } + + // ===== Delegated methods ===== + + @Override + public List getQuads(@Nullable BlockState state, @Nullable Direction direction, RandomSource random) { + return original.getQuads(state, direction, random); + } + + @Override + public boolean useAmbientOcclusion() { + return original.useAmbientOcclusion(); + } + + @Override + public boolean isGui3d() { + return original.isGui3d(); + } + + @Override + public boolean usesBlockLight() { + return original.usesBlockLight(); + } + + @Override + public boolean isCustomRenderer() { + return original.isCustomRenderer(); + } + + @Override + public TextureAtlasSprite getParticleIcon() { + return original.getParticleIcon(); + } + + @SuppressWarnings("deprecation") + @Override + public ItemTransforms getTransforms() { + return original.getTransforms(); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/client/DataDrivenIconOverrides.java b/src/main/java/com/tiedup/remake/v2/client/DataDrivenIconOverrides.java new file mode 100644 index 0000000..22238e4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/client/DataDrivenIconOverrides.java @@ -0,0 +1,217 @@ +package com.tiedup.remake.v2.client; + +import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition; +import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry; +import com.tiedup.remake.v2.furniture.FurnitureDefinition; +import com.tiedup.remake.v2.furniture.FurniturePlacerItem; +import com.tiedup.remake.v2.furniture.FurnitureRegistry; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.Nullable; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.block.model.ItemOverrides; +import net.minecraft.client.resources.model.BakedModel; +import net.minecraft.client.resources.model.ModelResourceLocation; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Custom {@link ItemOverrides} that switches the rendered item model based on + * NBT-driven icon definitions. + * + *

Both {@link com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem} and + * {@link com.tiedup.remake.v2.furniture.FurniturePlacerItem} are singleton Items + * where each stack's behavior varies by NBT. This override reads the item/furniture + * ID from NBT, looks up the corresponding definition's {@code icon} field, and + * resolves the matching {@link BakedModel} from the model registry.

+ * + *

Icon model resolution strategy (tried in order): + *

    + *
  1. Plain {@link ResourceLocation} lookup (for models registered via + * {@link net.minecraftforge.client.event.ModelEvent.RegisterAdditional})
  2. + *
  3. {@link ModelResourceLocation} with "inventory" variant (for models baked + * from registered items, e.g., "tiedup:armbinder#inventory")
  4. + *
+ * Falls back to the original model if no icon is defined or the icon model is not found.

+ */ +@OnlyIn(Dist.CLIENT) +public class DataDrivenIconOverrides extends ItemOverrides { + + private static final Logger LOGGER = LogManager.getLogger("DataDrivenIcons"); + + /** + * Identifies which type of NBT-driven item this override handles. + */ + public enum Mode { + /** Data-driven bondage items (reads {@code tiedup_item_id} NBT). */ + BONDAGE_ITEM, + /** Furniture placer items (reads {@code tiedup_furniture_id} NBT). */ + FURNITURE_PLACER + } + + private final Mode mode; + + /** + * Cache of resolved icon ResourceLocations to their BakedModels. + * Cleared on resource reload (when ModifyBakingResult fires again). + * Uses ConcurrentHashMap because resolve() is called from the render thread. + * + *

Values are never null (ConcurrentHashMap forbids nulls). Missing icons + * are tracked separately in {@link #knownMissing}.

+ */ + private final Map iconModelCache = new ConcurrentHashMap<>(); + + /** + * Set of icon ResourceLocations that were looked up but not found. + * Prevents repeated failed lookups from hitting the model registry every frame. + */ + private final Map knownMissing = new ConcurrentHashMap<>(); + + /** + * Set of icon ResourceLocations that we already logged a warning for (to avoid log spam). + */ + private final Map warnedMissing = new ConcurrentHashMap<>(); + + public DataDrivenIconOverrides(Mode mode) { + super(); + this.mode = mode; + } + + @Override + @Nullable + public BakedModel resolve( + BakedModel originalModel, + ItemStack stack, + @Nullable ClientLevel level, + @Nullable LivingEntity entity, + int seed + ) { + ResourceLocation iconRL = getIconFromStack(stack); + if (iconRL == null) { + // No icon defined for this variant — use the default model + return originalModel; + } + + // Check if already known to be missing + if (knownMissing.containsKey(iconRL)) { + return originalModel; + } + + // Check cache for resolved model + BakedModel cached = iconModelCache.get(iconRL); + if (cached != null) { + return cached; + } + + // Try to resolve the icon model + BakedModel resolved = lookupIconModel(iconRL); + if (resolved != null) { + iconModelCache.put(iconRL, resolved); + return resolved; + } + + // Mark as known missing to avoid repeated lookups + knownMissing.put(iconRL, Boolean.TRUE); + return originalModel; + } + + /** + * Read the icon ResourceLocation from the stack's NBT by looking up the definition. + */ + @Nullable + private ResourceLocation getIconFromStack(ItemStack stack) { + if (stack.isEmpty()) return null; + + switch (mode) { + case BONDAGE_ITEM: { + DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack); + return def != null ? def.icon() : null; + } + case FURNITURE_PLACER: { + String furnitureIdStr = FurniturePlacerItem.getFurnitureIdFromStack(stack); + if (furnitureIdStr == null) return null; + FurnitureDefinition def = FurnitureRegistry.get(furnitureIdStr); + return def != null ? def.icon() : null; + } + default: + return null; + } + } + + /** + * Look up an icon model from the model registry using multiple resolution strategies. + * + *

Strategy: + *

    + *
  1. Try the icon as a plain ResourceLocation (for RegisterAdditional models)
  2. + *
  3. If the icon path starts with "item/", strip it and try as a + * ModelResourceLocation with "inventory" variant
  4. + *
+ * + * @param iconRL the icon model ResourceLocation + * @return the resolved BakedModel, or null if not found + */ + @Nullable + private BakedModel lookupIconModel(ResourceLocation iconRL) { + Minecraft mc = Minecraft.getInstance(); + if (mc.getModelManager() == null) return null; + + BakedModel missingModel = mc.getModelManager().getMissingModel(); + + // Strategy 1: Plain ResourceLocation lookup + BakedModel model = mc.getModelManager().getModel(iconRL); + if (model != missingModel) { + return model; + } + + // Strategy 2: Derive ModelResourceLocation with "inventory" variant + // e.g., "tiedup:item/armbinder" → ModelResourceLocation("tiedup:armbinder", "inventory") + String path = iconRL.getPath(); + if (path.startsWith("item/")) { + String itemPath = path.substring("item/".length()); + ModelResourceLocation mrl = new ModelResourceLocation( + new ResourceLocation(iconRL.getNamespace(), itemPath), + "inventory" + ); + model = mc.getModelManager().getModel(mrl); + if (model != missingModel) { + return model; + } + } + + // Strategy 3: Try as-is with "inventory" variant + // e.g., "tiedup:armbinder" → ModelResourceLocation("tiedup:armbinder", "inventory") + if (!path.contains("/")) { + ModelResourceLocation mrl = new ModelResourceLocation(iconRL, "inventory"); + model = mc.getModelManager().getModel(mrl); + if (model != missingModel) { + return model; + } + } + + // Not found — log once + if (!warnedMissing.containsKey(iconRL)) { + warnedMissing.put(iconRL, Boolean.TRUE); + LOGGER.warn("[DataDrivenIcons] Icon model not found for '{}' (mode={}). " + + "Ensure a model JSON exists at assets/{}/models/{}.json or the item is registered.", + iconRL, mode, iconRL.getNamespace(), iconRL.getPath()); + } + + return null; + } + + /** + * Clear the icon model cache. Called when models are re-baked (on resource reload). + */ + public void clearCache() { + iconModelCache.clear(); + knownMissing.clear(); + warnedMissing.clear(); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/client/ObjBlockRenderer.java b/src/main/java/com/tiedup/remake/v2/client/ObjBlockRenderer.java new file mode 100644 index 0000000..b747db5 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/client/ObjBlockRenderer.java @@ -0,0 +1,99 @@ +package com.tiedup.remake.v2.client; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.tiedup.remake.client.renderer.obj.ObjModel; +import com.tiedup.remake.client.renderer.obj.ObjModelRegistry; +import com.tiedup.remake.client.renderer.obj.ObjModelRenderer; +import com.tiedup.remake.v2.blocks.ObjBlockEntity; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.core.Direction; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.block.HorizontalDirectionalBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Block entity renderer for OBJ model blocks. + * Uses the existing OBJ rendering system from client/renderer/obj/. + */ +@OnlyIn(Dist.CLIENT) +public class ObjBlockRenderer implements BlockEntityRenderer { + + public ObjBlockRenderer(BlockEntityRendererProvider.Context context) {} + + @Override + public void render( + ObjBlockEntity blockEntity, + float partialTick, + PoseStack poseStack, + MultiBufferSource buffer, + int packedLight, + int packedOverlay + ) { + ResourceLocation modelLoc = blockEntity.getModelLocation(); + if (modelLoc == null) return; + + ObjModel model = ObjModelRegistry.get(modelLoc); + if (model == null) return; + + poseStack.pushPose(); + + // Center on block + poseStack.translate(0.5, 0.0, 0.5); + + // Apply block rotation based on facing direction + applyBlockRotation(poseStack, blockEntity.getBlockState()); + + // Apply custom scale if specified + float scale = blockEntity.getModelScale(); + if (scale != 1.0f) { + poseStack.scale(scale, scale, scale); + } + + // Apply custom offset if specified + float[] offset = blockEntity.getModelOffset(); + if (offset[0] != 0 || offset[1] != 0 || offset[2] != 0) { + poseStack.translate(offset[0], offset[1], offset[2]); + } + + // Render using the existing OBJ renderer system + ObjModelRenderer.render( + model, + poseStack, + buffer, + packedLight, + packedOverlay + ); + + poseStack.popPose(); + } + + private void applyBlockRotation( + PoseStack poseStack, + BlockState blockState + ) { + if (blockState.hasProperty(HorizontalDirectionalBlock.FACING)) { + Direction facing = blockState.getValue( + HorizontalDirectionalBlock.FACING + ); + float rotation = switch (facing) { + case NORTH -> 0f; + case SOUTH -> 180f; + case WEST -> 90f; + case EAST -> -90f; + default -> 0f; + }; + poseStack.mulPose( + com.mojang.math.Axis.YP.rotationDegrees(rotation) + ); + } + } + + @Override + public boolean shouldRenderOffScreen(ObjBlockEntity blockEntity) { + return true; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/client/V2ClientSetup.java b/src/main/java/com/tiedup/remake/v2/client/V2ClientSetup.java new file mode 100644 index 0000000..0c7adcd --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/client/V2ClientSetup.java @@ -0,0 +1,153 @@ +package com.tiedup.remake.v2.client; + +import com.tiedup.remake.blocks.entity.ModBlockEntities; +import com.tiedup.remake.client.model.CellCoreBakedModel; +import com.tiedup.remake.client.renderer.CellCoreRenderer; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.V2BlockEntities; +import java.util.Map; +import net.minecraft.client.resources.model.BakedModel; +import net.minecraft.client.resources.model.ModelResourceLocation; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.client.event.EntityRenderersEvent; +import net.minecraftforge.client.event.ModelEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * V2 Client-side setup. + * Registers block entity renderers, model replacements, and icon model overrides. + * + *

The icon override system allows data-driven bondage items and furniture placers + * to display per-variant inventory sprites. Each JSON definition can specify an + * optional {@code icon} field pointing to a model ResourceLocation. The model + * is resolved at render time from the baked model registry.

+ * + *

Icon models that correspond to existing registered items (e.g., "tiedup:item/armbinder") + * are automatically available. For custom icon models that don't correspond to a registered + * item, place the model JSON under {@code assets//models/item/icons/} and it + * will be registered for baking via {@link ModelEvent.RegisterAdditional}.

+ */ +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.MOD, + value = Dist.CLIENT +) +public class V2ClientSetup { + + @SubscribeEvent + public static void registerRenderers( + EntityRenderersEvent.RegisterRenderers event + ) { + // Register OBJ block renderers + event.registerBlockEntityRenderer( + V2BlockEntities.PET_BOWL.get(), + ObjBlockRenderer::new + ); + event.registerBlockEntityRenderer( + V2BlockEntities.PET_BED.get(), + ObjBlockRenderer::new + ); + event.registerBlockEntityRenderer( + V2BlockEntities.PET_CAGE.get(), + ObjBlockRenderer::new + ); + + // Register Cell Core pulsing indicator renderer + event.registerBlockEntityRenderer( + ModBlockEntities.CELL_CORE.get(), + CellCoreRenderer::new + ); + + TiedUpMod.LOGGER.info( + "[V2ClientSetup] Registered block entity renderers" + ); + } + + @SubscribeEvent + public static void onModifyBakingResult( + ModelEvent.ModifyBakingResult event + ) { + // Block model path key (used in model JSON references) + ResourceLocation blockModelLoc = ResourceLocation.fromNamespaceAndPath( + TiedUpMod.MOD_ID, + "block/cell_core" + ); + // Blockstate variant key (used by the block renderer to look up models) + ModelResourceLocation stateModelLoc = new ModelResourceLocation( + ResourceLocation.fromNamespaceAndPath( + TiedUpMod.MOD_ID, + "cell_core" + ), + "" + ); + + // Find the original model from either key + BakedModel original = event.getModels().get(stateModelLoc); + if (original == null) { + original = event.getModels().get(blockModelLoc); + } + + if (original != null) { + CellCoreBakedModel wrapper = new CellCoreBakedModel(original); + event.getModels().put(blockModelLoc, wrapper); + event.getModels().put(stateModelLoc, wrapper); + TiedUpMod.LOGGER.info( + "[V2ClientSetup] Replaced cell_core BakedModel at both model keys" + ); + } + + // ===== Data-driven item icon overrides ===== + wrapItemModelWithIconOverrides( + event.getModels(), + "data_driven_item", + DataDrivenIconOverrides.Mode.BONDAGE_ITEM + ); + + wrapItemModelWithIconOverrides( + event.getModels(), + "furniture_placer", + DataDrivenIconOverrides.Mode.FURNITURE_PLACER + ); + } + + /** + * Wrap an item's baked model with a {@link DataDrivenIconBakedModel} that + * switches the rendered model based on NBT icon definitions. + * + * @param models the mutable model registry from {@link ModelEvent.ModifyBakingResult} + * @param itemName the item registry name (e.g., "data_driven_item") + * @param mode the icon override mode (determines which NBT key to read) + */ + private static void wrapItemModelWithIconOverrides( + Map models, + String itemName, + DataDrivenIconOverrides.Mode mode + ) { + ModelResourceLocation itemModelLoc = new ModelResourceLocation( + new ResourceLocation(TiedUpMod.MOD_ID, itemName), + "inventory" + ); + + BakedModel originalItemModel = models.get(itemModelLoc); + if (originalItemModel == null) { + TiedUpMod.LOGGER.warn( + "[V2ClientSetup] Could not find baked model for {} — icon overrides not applied", + itemModelLoc + ); + return; + } + + DataDrivenIconOverrides overrides = new DataDrivenIconOverrides(mode); + DataDrivenIconBakedModel wrapped = new DataDrivenIconBakedModel( + originalItemModel, overrides + ); + models.put(itemModelLoc, wrapped); + + TiedUpMod.LOGGER.info( + "[V2ClientSetup] Wrapped {} model with icon overrides (mode={})", + itemName, mode + ); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/EntityFurniture.java b/src/main/java/com/tiedup/remake/v2/furniture/EntityFurniture.java new file mode 100644 index 0000000..d3c6f8e --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/EntityFurniture.java @@ -0,0 +1,1041 @@ +package com.tiedup.remake.v2.furniture; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.ItemMasterKey; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.state.PlayerBindState; +import com.tiedup.remake.state.PlayerCaptorManager; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.furniture.network.PacketSyncFurnitureState; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientGamePacketListener; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Pose; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.entity.IEntityAdditionalSpawnData; +import net.minecraftforge.network.NetworkHooks; +import org.jetbrains.annotations.Nullable; + +/** + * Core furniture entity for all data-driven furniture pieces. + * + *

Extends {@link Entity} (not LivingEntity) and implements {@link ISeatProvider} + * for seat management plus {@link IEntityAdditionalSpawnData} for syncing the + * furniture definition ID to clients on spawn.

+ * + *

Behavior varies per instance based on the {@code tiedup_furniture_id} stored + * in SynchedEntityData, which maps to a {@link FurnitureDefinition} in + * {@link FurnitureRegistry}. All ISeatProvider calls delegate to the definition + * with null-safety: if the definition is removed by a data pack reload, the + * entity degrades gracefully (no seats, default hitbox, no interaction).

+ * + *

{@code getAddEntityPacket()} is overridden to return + * {@link NetworkHooks#getEntitySpawningPacket(Entity)}, which is required for + * Forge to include the {@code IEntityAdditionalSpawnData} buffer in the spawn + * packet. Without this override, the entity would be invisible on clients.

+ */ +public class EntityFurniture extends Entity + implements ISeatProvider, IEntityAdditionalSpawnData { + + // ========== SynchedEntityData Accessors ========== + + /** The furniture definition ID (ResourceLocation string form). */ + private static final EntityDataAccessor FURNITURE_ID = + SynchedEntityData.defineId(EntityFurniture.class, EntityDataSerializers.STRING); + + /** + * Bitmask of locked seats. Bit N corresponds to seat index N + * in {@link FurnitureDefinition#seats()}. Max 8 seats per furniture. + */ + private static final EntityDataAccessor SEAT_LOCK_BITS = + SynchedEntityData.defineId(EntityFurniture.class, EntityDataSerializers.BYTE); + + /** Current animation state (idle, occupied, locking, struggle). */ + private static final EntityDataAccessor ANIM_STATE = + SynchedEntityData.defineId(EntityFurniture.class, EntityDataSerializers.BYTE); + + // ========== Animation State Constants ========== + + public static final byte STATE_IDLE = 0; + public static final byte STATE_OCCUPIED = 1; + public static final byte STATE_LOCKING = 2; + public static final byte STATE_STRUGGLE = 3; + public static final byte STATE_UNLOCKING = 4; + public static final byte STATE_ENTERING = 5; + public static final byte STATE_EXITING = 6; + + // ========== Server-side State ========== + + /** + * Maps passenger UUID to their assigned seat ID. Server-authoritative. + * Persisted in NBT so seat assignments survive chunk unload/reload. + */ + private final Map seatAssignments = new HashMap<>(); + + /** + * Accumulated damage from player attacks. Decays slowly per tick. + * When this reaches the definition's {@code breakResistance}, the furniture breaks. + */ + private float currentDamage = 0f; + + /** + * Ticks remaining for a transitional animation state (ENTERING, EXITING, LOCKING, UNLOCKING). + * When this reaches 0, the animation state is reset to {@link #transitionTargetState}. + */ + private int transitionTicksLeft = 0; + + /** The state to transition to after the current animation completes. */ + private byte transitionTargetState = STATE_IDLE; + + // ========== Constructor ========== + + public EntityFurniture(EntityType type, Level level) { + super(type, level); + // Prevent players from placing blocks inside the furniture's hitbox + this.blocksBuilding = true; + } + + // ========== SynchedEntityData Registration ========== + + @Override + protected void defineSynchedData() { + // Entity base class registers its own fields before calling this. + // Direct Entity subclasses do NOT call super (it is abstract). + this.entityData.define(FURNITURE_ID, ""); + this.entityData.define(SEAT_LOCK_BITS, (byte) 0); + this.entityData.define(ANIM_STATE, STATE_IDLE); + } + + // ========== IEntityAdditionalSpawnData ========== + + /** + * Server-side: write furniture ID and facing to the spawn packet. + * This is the only reliable way to sync the definition ID to the client + * before the entity's first render frame. + */ + @Override + public void writeSpawnData(FriendlyByteBuf buffer) { + buffer.writeUtf(getFurnitureId()); + buffer.writeFloat(this.getYRot()); + } + + /** + * Client-side: read furniture ID and facing from the spawn packet. + * Called before the entity is added to the client world. + */ + @Override + public void readSpawnData(FriendlyByteBuf additionalData) { + setFurnitureId(additionalData.readUtf()); + this.setYRot(additionalData.readFloat()); + // Recalculate bounding box with the correct definition dimensions + this.refreshDimensions(); + } + + // ========== Variable Dimensions ========== + + /** + * Returns dimensions from the furniture definition, falling back to 1x1 + * if the definition is missing (e.g., data pack removed the furniture type). + */ + @Override + public EntityDimensions getDimensions(Pose pose) { + FurnitureDefinition def = getDefinition(); + if (def != null) { + return EntityDimensions.fixed(def.hitboxWidth(), def.hitboxHeight()); + } + return EntityDimensions.fixed(1.0f, 1.0f); + } + + // ========== ISeatProvider Implementation ========== + + @Override + public List getSeats() { + FurnitureDefinition def = getDefinition(); + return def != null ? def.seats() : Collections.emptyList(); + } + + @Override + @Nullable + public SeatDefinition getSeatForPassenger(Entity passenger) { + String seatId = seatAssignments.get(passenger.getUUID()); + if (seatId == null) return null; + FurnitureDefinition def = getDefinition(); + return def != null ? def.getSeat(seatId) : null; + } + + @Override + public void assignSeat(Entity passenger, String seatId) { + seatAssignments.put(passenger.getUUID(), seatId); + } + + @Override + public void releaseSeat(Entity passenger) { + seatAssignments.remove(passenger.getUUID()); + } + + @Override + public boolean isSeatLocked(String seatId) { + FurnitureDefinition def = getDefinition(); + if (def == null) return false; + int idx = def.getSeatIndex(seatId); + if (idx < 0) return false; + return (this.entityData.get(SEAT_LOCK_BITS) & (1 << idx)) != 0; + } + + @Override + public void setSeatLocked(String seatId, boolean locked) { + FurnitureDefinition def = getDefinition(); + if (def == null) return; + int idx = def.getSeatIndex(seatId); + if (idx < 0) return; + byte bits = this.entityData.get(SEAT_LOCK_BITS); + if (locked) { + bits |= (byte) (1 << idx); + } else { + bits &= (byte) ~(1 << idx); + } + this.entityData.set(SEAT_LOCK_BITS, bits); + + // Update persistent data for reconnection system on the seated player + if (!this.level().isClientSide) { + Entity passenger = findPassengerInSeat(seatId); + if (passenger instanceof ServerPlayer serverPlayer) { + if (locked) { + writeFurnitureReconnectionTag(serverPlayer, seatId); + } else { + serverPlayer.getPersistentData().remove("tiedup_locked_furniture"); + } + } + } + } + + /** + * Find the passenger entity sitting in a specific seat. + * + * @param seatId the seat identifier to search for + * @return the passenger entity, or null if the seat is unoccupied + */ + @Nullable + public Entity findPassengerInSeat(String seatId) { + for (Entity passenger : this.getPassengers()) { + String assignedSeat = seatAssignments.get(passenger.getUUID()); + if (seatId.equals(assignedSeat)) { + return passenger; + } + } + return null; + } + + /** + * Write the reconnection tag to a player's persistent data so they can be + * re-mounted on login if they disconnect while locked in a furniture seat. + * + * @param player the server player locked in this furniture + * @param seatId the seat ID the player is locked in + */ + private void writeFurnitureReconnectionTag(ServerPlayer player, String seatId) { + CompoundTag tag = new CompoundTag(); + BlockPos pos = this.blockPosition(); + tag.putInt("x", pos.getX()); + tag.putInt("y", pos.getY()); + tag.putInt("z", pos.getZ()); + tag.putString("dim", this.level().dimension().location().toString()); + tag.putString("furniture_uuid", this.getStringUUID()); + tag.putString("seat_id", seatId); + player.getPersistentData().put("tiedup_locked_furniture", tag); + } + + @Override + public int getLockedDifficulty(String seatId) { + FurnitureDefinition def = getDefinition(); + if (def == null) return 0; + SeatDefinition seat = def.getSeat(seatId); + return seat != null ? seat.lockedDifficulty() : 0; + } + + @Override + public Set getBlockedRegions(String seatId) { + FurnitureDefinition def = getDefinition(); + if (def == null) return Collections.emptySet(); + SeatDefinition seat = def.getSeat(seatId); + return seat != null ? seat.blockedRegions() : Collections.emptySet(); + } + + @Override + public boolean hasItemDifficultyBonus(String seatId) { + FurnitureDefinition def = getDefinition(); + if (def == null) return false; + SeatDefinition seat = def.getSeat(seatId); + return seat != null && seat.itemDifficultyBonus(); + } + + // ========== Vanilla Riding System ========== + + @Override + protected boolean canAddPassenger(Entity passenger) { + FurnitureDefinition def = getDefinition(); + return def != null && this.getPassengers().size() < def.seats().size(); + } + + /** + * Position the passenger at their assigned seat's world position. + * + *

On the client side, the exact seat transform is read from the parsed GLB + * data via {@code FurnitureSeatPositionHelper} (client-only). On the server + * side (or when GLB data is unavailable), an approximate position is computed + * by spacing seats evenly along the furniture's local right axis.

+ */ + @Override + protected void positionRider(Entity passenger, Entity.MoveFunction moveFunction) { + if (!this.hasPassenger(passenger)) return; + + SeatDefinition seat = getSeatForPassenger(passenger); + if (seat == null) { + // Fallback: center of entity at base height + moveFunction.accept(passenger, this.getX(), this.getY(), this.getZ()); + return; + } + + // Client-side: try to use real seat transforms parsed from the GLB model. + // FurnitureSeatPositionHelper is @OnlyIn(Dist.CLIENT) so we guard with isClientSide. + if (this.level().isClientSide) { + FurnitureDefinition def = getDefinition(); + if (def != null) { + double[] pos = com.tiedup.remake.v2.furniture.client.FurnitureSeatPositionHelper + .getSeatWorldPosition( + def, seat.id(), + this.getX(), this.getY(), this.getZ(), + this.getYRot() + ); + if (pos != null) { + moveFunction.accept(passenger, pos[0], pos[1], pos[2]); + return; + } + } + } + + // Server-side fallback (or missing GLB data): approximate positioning. + // Seats are spaced evenly along the entity's local right axis. + FurnitureDefinition def = getDefinition(); + int seatCount = def != null ? def.seats().size() : 1; + int seatIdx = def != null ? def.getSeatIndex(seat.id()) : 0; + if (seatIdx < 0) seatIdx = 0; + + float yawRad = (float) Math.toRadians(this.getYRot()); + double rightX = -Math.sin(yawRad + Math.PI / 2.0); + double rightZ = Math.cos(yawRad + Math.PI / 2.0); + double offset = seatCount == 1 ? 0.0 : (seatIdx - (seatCount - 1) / 2.0); + + moveFunction.accept( + passenger, + this.getX() + rightX * offset, + this.getY() + 0.5, + this.getZ() + rightZ * offset + ); + } + + /** + * Called after a passenger is added to the entity. + * Assigns the passenger to the nearest available seat based on the passenger's + * look direction, and updates animation state. + */ + @Override + protected void addPassenger(Entity passenger) { + super.addPassenger(passenger); + + SeatDefinition nearest = findNearestAvailableSeat(passenger); + if (nearest != null) { + assignSeat(passenger, nearest.id()); + } + + // Play entering transition: furniture shows "Occupied" clip while player settles in. + // After 20 ticks (1 second), state stabilizes to OCCUPIED. + if (!this.level().isClientSide) { + this.entityData.set(ANIM_STATE, STATE_ENTERING); + this.transitionTicksLeft = 20; + this.transitionTargetState = STATE_OCCUPIED; + } + } + + /** + * Find the available seat whose approximate world position has the smallest + * angle to the passenger's look direction. + * + *

Since we don't have GLB armature transforms yet (Task 14), seats are + * approximated as evenly spaced along the entity's local right axis. + * Seat 0 is at entity center, seat 1 is offset +1 block on the right, etc. + * The entity's Y rotation determines the right axis.

+ * + * @param passenger the entity being seated + * @return the best available seat, or null if no seats are available + */ + @Nullable + private SeatDefinition findNearestAvailableSeat(Entity passenger) { + FurnitureDefinition def = getDefinition(); + if (def == null || def.seats().isEmpty()) return null; + + Vec3 passengerPos = passenger.getEyePosition(); + Vec3 lookDir = passenger.getLookAngle(); + float yawRad = (float) Math.toRadians(this.getYRot()); + + // Entity-local right axis (perpendicular to facing direction in the XZ plane) + double rightX = -Math.sin(yawRad + Math.PI / 2.0); + double rightZ = Math.cos(yawRad + Math.PI / 2.0); + + SeatDefinition best = null; + double bestScore = Double.MAX_VALUE; + + List seats = def.seats(); + int seatCount = seats.size(); + + for (int i = 0; i < seatCount; i++) { + SeatDefinition seat = seats.get(i); + + // Skip already-occupied seats + if (seatAssignments.containsValue(seat.id())) continue; + + // Approximate seat world position: entity origin + offset along right axis. + // For a single seat, it's at center. For multiple, spread evenly. + double offset = seatCount == 1 ? 0.0 : (i - (seatCount - 1) / 2.0); + Vec3 seatWorldPos = new Vec3( + this.getX() + rightX * offset, + this.getY() + 0.5, + this.getZ() + rightZ * offset + ); + + // Score: angle between passenger look direction and direction to seat. + // Use negative dot product of normalized vectors (smaller angle = lower score). + Vec3 toSeat = seatWorldPos.subtract(passengerPos); + double distSq = toSeat.lengthSqr(); + if (distSq < 1e-6) { + // Passenger is essentially on top of the seat — best possible score + best = seat; + bestScore = -1.0; + continue; + } + + Vec3 toSeatNorm = toSeat.normalize(); + // Dot product: 1.0 = looking directly at seat, -1.0 = looking away. + // We want the highest dot product, so use negative as score. + double dot = lookDir.dot(toSeatNorm); + double score = -dot; + + if (score < bestScore) { + bestScore = score; + best = seat; + } + } + + return best; + } + + /** + * Find the occupied, lockable seat whose approximate world position is + * closest to the player's look direction. Used for key interactions so + * that multi-seat furniture targets the seat the player is looking at. + * + * @param player the player performing the lock/unlock interaction + * @param def the furniture definition (must not be null) + * @return the best matching seat, or null if no occupied lockable seats exist + */ + @Nullable + private SeatDefinition findNearestOccupiedLockableSeat(Player player, FurnitureDefinition def) { + Vec3 playerPos = player.getEyePosition(); + Vec3 lookDir = player.getLookAngle(); + float yawRad = (float) Math.toRadians(this.getYRot()); + + double rightX = -Math.sin(yawRad + Math.PI / 2.0); + double rightZ = Math.cos(yawRad + Math.PI / 2.0); + + SeatDefinition best = null; + double bestScore = Double.MAX_VALUE; + + List seats = def.seats(); + int seatCount = seats.size(); + + for (int i = 0; i < seatCount; i++) { + SeatDefinition seat = seats.get(i); + + // Only consider occupied, lockable seats + if (!seat.lockable() || !seatAssignments.containsValue(seat.id())) continue; + + double offset = seatCount == 1 ? 0.0 : (i - (seatCount - 1) / 2.0); + Vec3 seatWorldPos = new Vec3( + this.getX() + rightX * offset, + this.getY() + 0.5, + this.getZ() + rightZ * offset + ); + + Vec3 toSeat = seatWorldPos.subtract(playerPos); + double distSq = toSeat.lengthSqr(); + if (distSq < 1e-6) { + best = seat; + bestScore = -1.0; + continue; + } + + Vec3 toSeatNorm = toSeat.normalize(); + double dot = lookDir.dot(toSeatNorm); + double score = -dot; + + if (score < bestScore) { + bestScore = score; + best = seat; + } + } + + return best; + } + + /** + * Called when a passenger is being removed from the entity. + * + *

If the passenger's seat is locked and neither the entity nor passenger + * is being removed/killed, the dismount is cancelled by re-mounting the + * passenger on the next server tick via {@code getServer().execute()}.

+ * + *

This deferred re-mount pattern is the same one used by + * {@link com.tiedup.remake.events.captivity.ForcedSeatingHandler} for forced seating. + * We cannot call {@code startRiding()} during the removePassenger call chain + * because the passenger list is being mutated.

+ */ + @Override + protected void removePassenger(Entity passenger) { + SeatDefinition seat = getSeatForPassenger(passenger); + + if (seat != null && isSeatLocked(seat.id()) && !this.isRemoved()) { + // Play denied sound to the passenger attempting to leave a locked seat + if (passenger instanceof ServerPlayer sp) { + FurnitureDefinition def = getDefinition(); + if (def != null && def.feedback().deniedSound() != null) { + sp.level().playSound(null, sp.getX(), sp.getY(), sp.getZ(), + SoundEvent.createVariableRangeEvent(def.feedback().deniedSound()), + SoundSource.PLAYERS, 0.5f, 1.0f); + } + } + + // Locked seat: prevent voluntary dismount by re-mounting next tick + super.removePassenger(passenger); + + if (this.getServer() != null) { + String lockedSeatId = seat.id(); + Entity self = this; + this.getServer().execute(() -> { + if (passenger.isAlive() && self.isAlive() && !self.isRemoved()) { + passenger.startRiding(self, true); + // Re-assign the specific seat (not just first available) + assignSeat(passenger, lockedSeatId); + } else { + // Passenger disconnected or entity removed — clean up the orphaned seat + releaseSeat(passenger); + updateAnimState(); + } + }); + } + return; + } + + // Normal dismount + super.removePassenger(passenger); + if (seat != null) { + releaseSeat(passenger); + } + // Clear reconnection tag on normal dismount + if (passenger instanceof ServerPlayer serverPlayer) { + serverPlayer.getPersistentData().remove("tiedup_locked_furniture"); + } + + // Play exiting transition: furniture transitions to Idle over 20 ticks. + // If other passengers remain, target state stays OCCUPIED. + if (!this.level().isClientSide) { + this.entityData.set(ANIM_STATE, STATE_EXITING); + this.transitionTicksLeft = 20; + this.transitionTargetState = this.getPassengers().isEmpty() ? STATE_IDLE : STATE_OCCUPIED; + } + + // Client-side: immediately stop the furniture pose animation on the dismounting player. + // BondageAnimationManager is @OnlyIn(Dist.CLIENT), so we guard with isClientSide. + // The safety tick in AnimationTickHandler also catches this after a 3-tick grace period, + // but stopping immediately avoids a visible animation glitch during dismount. + if (this.level().isClientSide && passenger instanceof Player) { + com.tiedup.remake.client.animation.BondageAnimationManager + .stopFurniture((Player) passenger); + } + } + + /** + * Update the synched animation state based on passenger occupancy. + * More complex states (LOCKING, STRUGGLE, ENTERING, EXITING, UNLOCKING) are set + * explicitly by other systems. This method does NOT overwrite an active transition + * to avoid cutting short one-shot animations. + */ + private void updateAnimState() { + // Don't clobber active transitions — let the tick timer handle the reset + if (this.transitionTicksLeft > 0) return; + + byte state = this.getPassengers().isEmpty() ? STATE_IDLE : STATE_OCCUPIED; + this.entityData.set(ANIM_STATE, state); + } + + // ========== Interaction ========== + + /** + * Handle right-click interactions with the furniture. + * + *

Priority order:

+ *
    + *
  1. Force-mount a leashed captive (collar ownership required)
  2. + *
  3. Key item + occupied lockable seat: toggle lock
  4. + *
  5. Empty hand + available seat: self-mount
  6. + *
+ */ + @Override + public InteractionResult interact(Player player, InteractionHand hand) { + if (this.level().isClientSide) { + return InteractionResult.SUCCESS; + } + + FurnitureDefinition def = getDefinition(); + if (def == null) { + return InteractionResult.PASS; + } + + // Priority 1: Force-mount a leashed captive onto an available seat. + // The interacting player must be a captor with at least one leashed captive, + // and the captive must wear a collar owned by this player (or player is OP). + if (player instanceof ServerPlayer serverPlayer + && this.getPassengers().size() < def.seats().size()) { + PlayerBindState ownerState = PlayerBindState.getInstance(serverPlayer); + if (ownerState != null) { + PlayerCaptorManager captorManager = ownerState.getCaptorManager(); + if (captorManager != null && captorManager.hasCaptives()) { + for (IBondageState captive : captorManager.getCaptives()) { + LivingEntity captiveEntity = captive.asLivingEntity(); + + // Skip captives that are already riding something + if (captiveEntity.isPassenger()) continue; + // Must be tied (leashed) and alive + if (!captive.isTiedUp()) continue; + if (!captiveEntity.isAlive()) continue; + // Must be within 5 blocks of the furniture + if (captiveEntity.distanceTo(this) > 5.0) continue; + + // Verify collar ownership + if (!captive.hasCollar()) continue; + ItemStack collarStack = captive.getEquipment(BodyRegionV2.NECK); + if (collarStack.isEmpty() + || !(collarStack.getItem() instanceof ItemCollar collar)) continue; + if (!collar.isOwner(collarStack, serverPlayer) + && !serverPlayer.hasPermissions(2)) continue; + + // Detach leash only (drop the lead, keep tied-up status) + captive.free(false); + + // Force-mount: startRiding triggers addPassenger which assigns nearest seat + boolean mounted = captiveEntity.startRiding(this, true); + if (mounted) { + // Play mount sound + FurnitureFeedback feedback = def.feedback(); + if (feedback.mountSound() != null) { + this.level().playSound(null, + this.getX(), this.getY(), this.getZ(), + SoundEvent.createVariableRangeEvent(feedback.mountSound()), + SoundSource.BLOCKS, 1.0f, 1.0f); + } + + // Broadcast updated state to tracking clients + PacketSyncFurnitureState.sendToTracking(this); + + TiedUpMod.LOGGER.debug( + "[EntityFurniture] {} force-mounted captive {} onto furniture {}", + serverPlayer.getName().getString(), + captiveEntity.getName().getString(), + getFurnitureId() + ); + } + return InteractionResult.CONSUME; + } + } + } + } + + // Priority 2: Key + occupied seat -> lock/unlock + // Use look direction to pick the nearest occupied, lockable seat. + ItemStack heldItem = player.getItemInHand(hand); + if (isKeyItem(heldItem) && !this.getPassengers().isEmpty()) { + SeatDefinition targetSeat = findNearestOccupiedLockableSeat(player, def); + if (targetSeat != null) { + boolean wasLocked = isSeatLocked(targetSeat.id()); + setSeatLocked(targetSeat.id(), !wasLocked); + + // Play lock/unlock sound + FurnitureFeedback feedback = def.feedback(); + ResourceLocation soundRL = wasLocked + ? feedback.unlockSound() + : feedback.lockSound(); + if (soundRL != null) { + this.level().playSound(null, this.getX(), this.getY(), this.getZ(), + SoundEvent.createVariableRangeEvent(soundRL), + SoundSource.BLOCKS, 1.0f, 1.0f); + } + + // Set lock/unlock animation with transition timer + boolean nowLocked = !wasLocked; + this.entityData.set(ANIM_STATE, + nowLocked ? STATE_LOCKING : STATE_UNLOCKING); + this.transitionTicksLeft = 20; + this.transitionTargetState = STATE_OCCUPIED; + + TiedUpMod.LOGGER.debug( + "[EntityFurniture] {} {} seat '{}' on furniture {}", + player.getName().getString(), + wasLocked ? "unlocked" : "locked", + targetSeat.id(), + getFurnitureId() + ); + return InteractionResult.CONSUME; + } + } + + // Priority 3: Empty hand + available seat -> self mount + if (heldItem.isEmpty() && this.getPassengers().size() < def.seats().size()) { + player.startRiding(this); + return InteractionResult.CONSUME; + } + + // No valid action — play denied sound if configured + FurnitureFeedback feedback = def.feedback(); + if (feedback != null && feedback.deniedSound() != null) { + this.level().playSound(null, this.getX(), this.getY(), this.getZ(), + SoundEvent.createVariableRangeEvent(feedback.deniedSound()), + SoundSource.BLOCKS, 0.5f, 1.0f); + } + + return InteractionResult.PASS; + } + + /** + * Check if the given item is a key that can lock/unlock furniture seats. + * Currently only {@link ItemMasterKey} qualifies. + */ + private boolean isKeyItem(ItemStack stack) { + return !stack.isEmpty() && stack.getItem() instanceof ItemMasterKey; + } + + // ========== Damage ========== + + /** + * Handle left-click (attack) interactions. + * + *

Creative mode: instant discard. Survival mode: accumulate damage + * until it reaches the definition's {@code breakResistance}, then break + * the furniture, eject all passengers, and optionally drop an item.

+ * + * @return true if the attack was consumed, false to pass to vanilla handling + */ + @Override + public boolean skipAttackInteraction(Entity attacker) { + if (this.level().isClientSide) { + return true; + } + + if (attacker instanceof Player player) { + if (player.isCreative()) { + this.ejectPassengers(); + this.discard(); + return true; + } + + // Cannot break occupied furniture in survival — must remove passengers first. + // Creative mode bypasses this check (handled above with instant discard). + if (!this.getPassengers().isEmpty()) { + FurnitureDefinition occupiedDef = getDefinition(); + if (occupiedDef != null && occupiedDef.feedback().deniedSound() != null) { + this.level().playSound(null, this.getX(), this.getY(), this.getZ(), + SoundEvent.createVariableRangeEvent(occupiedDef.feedback().deniedSound()), + SoundSource.BLOCKS, 0.5f, 1.0f); + } + return true; // Consume the attack but accumulate no damage + } + + FurnitureDefinition def = getDefinition(); + float resistance = def != null ? def.breakResistance() : 100f; + this.currentDamage += 1.0f; + + if (this.currentDamage >= resistance) { + this.ejectPassengers(); + if (def != null && def.dropOnBreak()) { + ItemStack dropStack = FurniturePlacerItem.createStack(def.id()); + if (!dropStack.isEmpty()) { + this.spawnAtLocation(dropStack); + } + } + this.discard(); + } + + return true; + } + + return false; + } + + // ========== Tick ========== + + /** + * Per-tick logic. Currently only handles damage decay on the server. + * Future tasks may add animation ticking or sound loops here. + */ + @Override + public void tick() { + super.tick(); + + if (!this.level().isClientSide) { + // Transition animation timer: count down and reset state when complete + if (this.transitionTicksLeft > 0) { + this.transitionTicksLeft--; + if (this.transitionTicksLeft == 0) { + this.entityData.set(ANIM_STATE, this.transitionTargetState); + } + } + + // Damage decay: ~20 seconds to fully heal 1 point of damage + if (this.currentDamage > 0) { + this.currentDamage = Math.max(0f, this.currentDamage - 0.05f); + } + + // Periodic cleanup: remove stale seat assignments for passengers + // that are no longer riding this entity (e.g., disconnected players). + if (this.tickCount % 100 == 0) { + cleanupStaleSeatAssignments(); + } + } + } + + /** + * Remove seat assignments whose UUID does not match any current passenger. + * This catches edge cases where a player disconnects or is removed without + * going through the normal removePassenger path (e.g., server crash recovery, + * /kick, or chunk unload races). + */ + private void cleanupStaleSeatAssignments() { + if (seatAssignments.isEmpty()) return; + + Set passengerUuids = new java.util.HashSet<>(); + for (Entity passenger : this.getPassengers()) { + passengerUuids.add(passenger.getUUID()); + } + + boolean removed = seatAssignments.keySet().removeIf( + uuid -> !passengerUuids.contains(uuid) + ); + + if (removed) { + TiedUpMod.LOGGER.debug( + "[EntityFurniture] Cleaned up stale seat assignments on {}", + getFurnitureId() + ); + updateAnimState(); + } + } + + // ========== NBT Persistence ========== + + @Override + protected void addAdditionalSaveData(CompoundTag tag) { + tag.putString(FurnitureRegistry.NBT_FURNITURE_ID, getFurnitureId()); + tag.putFloat("facing", this.getYRot()); + tag.putByte("seat_locks", this.entityData.get(SEAT_LOCK_BITS)); + tag.putFloat("current_damage", this.currentDamage); + + // Persist transition state so animations don't get stuck after server restart + if (this.transitionTicksLeft > 0) { + tag.putInt("transition_ticks", this.transitionTicksLeft); + tag.putByte("transition_target", this.transitionTargetState); + } + + // Persist seat assignments so locked passengers keep their seat after reload + CompoundTag assignments = new CompoundTag(); + for (Map.Entry entry : seatAssignments.entrySet()) { + assignments.putString(entry.getKey().toString(), entry.getValue()); + } + tag.put("seat_assignments", assignments); + } + + @Override + protected void readAdditionalSaveData(CompoundTag tag) { + if (tag.contains(FurnitureRegistry.NBT_FURNITURE_ID, 8)) { // 8 = TAG_String + setFurnitureId(tag.getString(FurnitureRegistry.NBT_FURNITURE_ID)); + } + if (tag.contains("facing")) { + this.setYRot(tag.getFloat("facing")); + } + if (tag.contains("seat_locks")) { + this.entityData.set(SEAT_LOCK_BITS, tag.getByte("seat_locks")); + } + if (tag.contains("current_damage")) { + this.currentDamage = tag.getFloat("current_damage"); + } + + // Restore transition state so in-progress animations resume after reload + if (tag.contains("transition_ticks")) { + this.transitionTicksLeft = tag.getInt("transition_ticks"); + this.transitionTargetState = tag.getByte("transition_target"); + } + + // Restore seat assignments + seatAssignments.clear(); + if (tag.contains("seat_assignments", 10)) { // 10 = TAG_Compound + CompoundTag assignments = tag.getCompound("seat_assignments"); + for (String key : assignments.getAllKeys()) { + try { + UUID uuid = UUID.fromString(key); + seatAssignments.put(uuid, assignments.getString(key)); + } catch (IllegalArgumentException e) { + TiedUpMod.LOGGER.warn( + "[EntityFurniture] Skipping invalid UUID in seat assignments: {}", + key + ); + } + } + } + + this.refreshDimensions(); + } + + // ========== Getters / Setters ========== + + /** + * Get the furniture definition ID string (ResourceLocation form). + * May be empty if the entity has not been initialized yet. + */ + public String getFurnitureId() { + return this.entityData.get(FURNITURE_ID); + } + + /** + * Set the furniture definition ID. Triggers a dimension refresh so the + * bounding box updates to match the new definition's hitbox. + */ + public void setFurnitureId(String id) { + this.entityData.set(FURNITURE_ID, id != null ? id : ""); + this.refreshDimensions(); + } + + /** + * Resolve the current furniture definition from the registry. + * + * @return the definition, or null if the ID is empty, unparseable, + * or no longer registered (e.g., data pack was removed) + */ + @Nullable + public FurnitureDefinition getDefinition() { + return FurnitureRegistry.get(getFurnitureId()); + } + + /** + * Get the raw seat lock bitmask. Bit N corresponds to seat index N in the + * furniture definition's seat list. + * + * @return the lock bitmask byte (0 = all unlocked) + */ + public byte getSeatLockBits() { + return this.entityData.get(SEAT_LOCK_BITS); + } + + /** + * Set the raw seat lock bitmask directly. Used by the network sync packet + * ({@link com.tiedup.remake.v2.furniture.network.PacketSyncFurnitureState}) + * to apply server-authoritative state on the client without per-seat iteration. + * + *

Server code should prefer {@link #setSeatLocked(String, boolean)} for + * individual seat lock changes, as it validates the seat ID against the + * furniture definition.

+ * + * @param bits the raw lock bitmask + */ + public void setSeatLockBitsRaw(byte bits) { + this.entityData.set(SEAT_LOCK_BITS, bits); + } + + /** + * Get the current animation state byte. Use the STATE_ constants to compare. + */ + public byte getAnimState() { + return this.entityData.get(ANIM_STATE); + } + + /** + * Explicitly set the animation state. Used by external systems (e.g., struggle + * minigame) to signal visual state changes beyond simple occupancy. + */ + public void setAnimState(byte state) { + this.entityData.set(ANIM_STATE, state); + } + + // ========== Entity Behavior Overrides ========== + + /** Furniture can be targeted by the crosshair (for interaction and attack). */ + @Override + public boolean isPickable() { + return true; + } + + /** Furniture cannot be pushed by entities or pistons. */ + @Override + public boolean isPushable() { + return false; + } + + /** Furniture has solid collision (entities cannot walk through it). */ + @Override + public boolean canBeCollidedWith() { + return true; + } + + /** Furniture does not emit movement sounds or events. */ + @Override + protected Entity.MovementEmission getMovementEmission() { + return Entity.MovementEmission.NONE; + } + + // ========== Spawn Packet ========== + + /** + * Return the Forge-aware spawn packet so that {@link IEntityAdditionalSpawnData} + * fields ({@code writeSpawnData}/{@code readSpawnData}) are included. + * + *

This override is required per the Forge Community Wiki. Without it, + * Forge sends a vanilla {@code ClientboundAddEntityPacket} which does NOT + * include the additional spawn buffer, causing the entity to be invisible + * or have missing data on the client.

+ */ + @Override + public Packet getAddEntityPacket() { + return NetworkHooks.getEntitySpawningPacket(this); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/FurnitureDefinition.java b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureDefinition.java new file mode 100644 index 0000000..2237e4f --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureDefinition.java @@ -0,0 +1,103 @@ +package com.tiedup.remake.v2.furniture; + +import java.util.List; +import java.util.Map; +import net.minecraft.resources.ResourceLocation; +import org.jetbrains.annotations.Nullable; + +/** + * Immutable definition for a data-driven furniture piece. + * + *

Loaded from JSON files in {@code data//tiedup_furniture/}. + * Synced to clients via {@code PacketSyncFurnitureDefinitions}. + * Each definition describes placement rules, visual properties, + * seat layout, and interaction feedback for a furniture type.

+ * + *

All rendering and gameplay properties are read from this record at runtime + * via the furniture entity and renderer.

+ */ +public record FurnitureDefinition( + /** Unique identifier (e.g., "tiedup:wooden_stocks"). */ + ResourceLocation id, + + /** Human-readable display name (fallback if no translation key). */ + String displayName, + + /** Optional translation key for localized display name. */ + @Nullable String translationKey, + + /** Resource location of the GLB model file. */ + ResourceLocation modelLocation, + + /** Tint channel defaults: channel name to ARGB color (e.g., "tintable_0" -> 0x8B4513). */ + Map tintChannels, + + /** Whether this furniture supports player-applied color customization. */ + boolean supportsColor, + + /** Collision box width in blocks (X/Z axis). */ + float hitboxWidth, + + /** Collision box height in blocks (Y axis). */ + float hitboxHeight, + + /** Whether this furniture snaps to adjacent walls on placement. */ + boolean snapToWall, + + /** Whether this furniture can only be placed on solid ground. */ + boolean floorOnly, + + /** Whether this furniture can be locked with a key item. */ + boolean lockable, + + /** Resistance to breaking (higher = harder to destroy). */ + float breakResistance, + + /** Whether the furniture drops as an item when broken. */ + boolean dropOnBreak, + + /** Ordered list of seat definitions. Index is used for bitmask operations. */ + List seats, + + /** Optional sound overrides for interactions. */ + FurnitureFeedback feedback, + + /** Grouping category for creative menu / UI filtering (e.g., "restraint", "decoration"). */ + String category, + + /** + * Optional inventory icon model location (e.g., "tiedup:item/wooden_stocks"). + * + *

Points to a standard {@code item/generated} model JSON that will be used + * as the inventory sprite for this furniture variant when held as a placer item. + * When null, the default {@code tiedup:item/furniture_placer} model is used.

+ */ + @Nullable ResourceLocation icon +) { + /** + * Find a seat definition by its unique ID. + * + * @param seatId the seat identifier to search for + * @return the matching {@link SeatDefinition}, or null if not found + */ + @Nullable + public SeatDefinition getSeat(String seatId) { + for (SeatDefinition seat : seats) { + if (seat.id().equals(seatId)) return seat; + } + return null; + } + + /** + * Get the positional index of a seat (for bitmask operations on lock state, occupancy, etc.). + * + * @param seatId the seat identifier to search for + * @return the zero-based index, or -1 if not found + */ + public int getSeatIndex(String seatId) { + for (int i = 0; i < seats.size(); i++) { + if (seats.get(i).id().equals(seatId)) return i; + } + return -1; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/FurnitureFeedback.java b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureFeedback.java new file mode 100644 index 0000000..01daad3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureFeedback.java @@ -0,0 +1,36 @@ +package com.tiedup.remake.v2.furniture; + +import net.minecraft.resources.ResourceLocation; +import org.jetbrains.annotations.Nullable; + +/** + * Sound events for furniture interactions. All fields are optional + * -- null means "use the default sound" at runtime. + * + *

Loaded from the {@code "feedback"} block of a furniture JSON definition. + * When the entire block is absent, {@link #EMPTY} is used.

+ */ +public record FurnitureFeedback( + /** Sound played when a player mounts the furniture. */ + @Nullable ResourceLocation mountSound, + + /** Sound played when a seat is locked. */ + @Nullable ResourceLocation lockSound, + + /** Sound played when a seat is unlocked. */ + @Nullable ResourceLocation unlockSound, + + /** Looping sound played while a player struggles in a locked seat. */ + @Nullable ResourceLocation struggleLoopSound, + + /** Sound played on successful escape. */ + @Nullable ResourceLocation escapeSound, + + /** Sound played when an action is denied (e.g., locked seat interaction). */ + @Nullable ResourceLocation deniedSound +) { + /** Empty feedback -- all sounds null (use defaults). */ + public static final FurnitureFeedback EMPTY = new FurnitureFeedback( + null, null, null, null, null, null + ); +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/FurnitureParser.java b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureParser.java new file mode 100644 index 0000000..e5dc3a7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureParser.java @@ -0,0 +1,412 @@ +package com.tiedup.remake.v2.furniture; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.tiedup.remake.v2.BodyRegionV2; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import net.minecraft.resources.ResourceLocation; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.Nullable; + +/** + * Parses JSON files into {@link FurnitureDefinition} instances. + * + *

Uses manual field extraction (not Gson deserialization) for strict + * validation control. Invalid required fields cause the entire definition + * to be rejected; optional fields use safe defaults.

+ * + *

Expected JSON files in {@code data//tiedup_furniture/}.

+ */ +public final class FurnitureParser { + + private static final Logger LOGGER = LogManager.getLogger("TiedUpFurniture"); + private static final String TAG = "[FurnitureParser]"; + + /** Strict hex color pattern: # followed by exactly 6 hex digits. */ + private static final Pattern HEX_COLOR = Pattern.compile("^#[0-9A-Fa-f]{6}$"); + + /** Maximum number of seats per furniture (bitmask limit: 8 bits). */ + private static final int MAX_SEATS = 8; + + private FurnitureParser() {} + + /** + * Parse a JSON input stream into a FurnitureDefinition. + * + * @param input the JSON input stream + * @param fileId the resource location of the source file (for error messages) + * @return the parsed definition, or null if the file is invalid + */ + @Nullable + public static FurnitureDefinition parse(InputStream input, ResourceLocation fileId) { + try { + JsonObject root = JsonParser.parseReader( + new InputStreamReader(input, StandardCharsets.UTF_8) + ).getAsJsonObject(); + + return parseObject(root, fileId); + } catch (Exception e) { + LOGGER.error("{} Failed to parse JSON {}: {}", TAG, fileId, e.getMessage()); + return null; + } + } + + /** + * Parse a JsonObject into a FurnitureDefinition. + * + * @param root the parsed JSON object + * @param fileId the resource location of the source file (for error messages) + * @return the parsed definition, or null if validation fails + */ + @Nullable + public static FurnitureDefinition parseObject(JsonObject root, ResourceLocation fileId) { + // --- Required: id --- + String idStr = getStringOrNull(root, "id"); + if (idStr == null || idStr.isEmpty()) { + LOGGER.error("{} Skipping {}: missing 'id'", TAG, fileId); + return null; + } + ResourceLocation id = ResourceLocation.tryParse(idStr); + if (id == null) { + LOGGER.error("{} Skipping {}: invalid id ResourceLocation '{}'", TAG, fileId, idStr); + return null; + } + + // --- Required: display_name --- + String displayName = getStringOrNull(root, "display_name"); + if (displayName == null || displayName.isEmpty()) { + LOGGER.error("{} Skipping {}: missing 'display_name'", TAG, fileId); + return null; + } + + // --- Optional: translation_key --- + String translationKey = getStringOrNull(root, "translation_key"); + + // --- Required: model --- + String modelStr = getStringOrNull(root, "model"); + if (modelStr == null || modelStr.isEmpty()) { + LOGGER.error("{} Skipping {}: missing 'model'", TAG, fileId); + return null; + } + ResourceLocation modelLocation = ResourceLocation.tryParse(modelStr); + if (modelLocation == null) { + LOGGER.error("{} Skipping {}: invalid model ResourceLocation '{}'", TAG, fileId, modelStr); + return null; + } + + // --- Optional: tint_channels (strict hex validation) --- + Map tintChannels = parseTintChannels(root, fileId); + if (tintChannels == null) { + // parseTintChannels returns null on invalid hex -> reject entire furniture + return null; + } + + // --- Optional: supports_color (default false) --- + boolean supportsColor = getBooleanOrDefault(root, "supports_color", false); + + // --- Optional: hitbox (defaults: 1.0 x 1.0, clamped [0.1, 5.0]) --- + float hitboxWidth = 1.0f; + float hitboxHeight = 1.0f; + if (root.has("hitbox") && root.get("hitbox").isJsonObject()) { + JsonObject hitbox = root.getAsJsonObject("hitbox"); + hitboxWidth = clamp(getFloatOrDefault(hitbox, "width", 1.0f), 0.1f, 5.0f); + hitboxHeight = clamp(getFloatOrDefault(hitbox, "height", 1.0f), 0.1f, 5.0f); + } + + // --- Optional: placement --- + boolean snapToWall = false; + boolean floorOnly = true; + if (root.has("placement") && root.get("placement").isJsonObject()) { + JsonObject placement = root.getAsJsonObject("placement"); + snapToWall = getBooleanOrDefault(placement, "snap_to_wall", false); + floorOnly = getBooleanOrDefault(placement, "floor_only", true); + } + + // --- Optional: lockable (default false) --- + boolean lockable = getBooleanOrDefault(root, "lockable", false); + + // --- Optional: break_resistance (default 100, clamped [1, 10000]) --- + float breakResistance = clamp(getFloatOrDefault(root, "break_resistance", 100.0f), 1.0f, 10000.0f); + + // --- Optional: drop_on_break (default true) --- + boolean dropOnBreak = getBooleanOrDefault(root, "drop_on_break", true); + + // --- Required: seats (non-empty array, size [1, 8]) --- + if (!root.has("seats") || !root.get("seats").isJsonArray()) { + LOGGER.error("{} Skipping {}: missing or invalid 'seats' array", TAG, fileId); + return null; + } + JsonArray seatsArray = root.getAsJsonArray("seats"); + if (seatsArray.isEmpty()) { + LOGGER.error("{} Skipping {}: 'seats' array is empty", TAG, fileId); + return null; + } + if (seatsArray.size() > MAX_SEATS) { + LOGGER.error("{} Skipping {}: 'seats' array has {} entries (max {})", + TAG, fileId, seatsArray.size(), MAX_SEATS); + return null; + } + + List seats = new ArrayList<>(seatsArray.size()); + for (int i = 0; i < seatsArray.size(); i++) { + if (!seatsArray.get(i).isJsonObject()) { + LOGGER.error("{} Skipping {}: seats[{}] is not a JSON object", TAG, fileId, i); + return null; + } + SeatDefinition seat = parseSeat(seatsArray.get(i).getAsJsonObject(), i, lockable, fileId); + if (seat == null) { + // parseSeat already logged the error + return null; + } + seats.add(seat); + } + + // --- Optional: feedback --- + FurnitureFeedback feedback = FurnitureFeedback.EMPTY; + if (root.has("feedback") && root.get("feedback").isJsonObject()) { + feedback = parseFeedback(root.getAsJsonObject("feedback"), fileId); + } + + // --- Optional: category (default "furniture") --- + String category = getStringOrDefault(root, "category", "furniture"); + + // --- Optional: icon (item model ResourceLocation for inventory sprite) --- + ResourceLocation icon = parseOptionalResourceLocation(root, "icon", fileId); + + return new FurnitureDefinition( + id, displayName, translationKey, modelLocation, + tintChannels, supportsColor, + hitboxWidth, hitboxHeight, + snapToWall, floorOnly, + lockable, breakResistance, dropOnBreak, + seats, feedback, category, icon + ); + } + + // ===== Seat Parsing ===== + + /** + * Parse a single seat JSON object. + * + * @param obj the seat JSON object + * @param index the seat index (for error messages) + * @param parentLockable the top-level lockable value (used as default) + * @param fileId the source file (for error messages) + * @return the parsed seat, or null on validation failure + */ + @Nullable + private static SeatDefinition parseSeat(JsonObject obj, int index, + boolean parentLockable, + ResourceLocation fileId) { + // Required: id (must not contain ':') + String seatId = getStringOrNull(obj, "id"); + if (seatId == null || seatId.isEmpty()) { + LOGGER.error("{} Skipping {}: seats[{}] missing 'id'", TAG, fileId, index); + return null; + } + if (seatId.contains(":")) { + LOGGER.error("{} Skipping {}: seats[{}] id '{}' must not contain ':'", + TAG, fileId, index, seatId); + return null; + } + + // Required: armature + String armature = getStringOrNull(obj, "armature"); + if (armature == null || armature.isEmpty()) { + LOGGER.error("{} Skipping {}: seats[{}] missing 'armature'", TAG, fileId, index); + return null; + } + + // Optional: blocked_regions (unknown region = fatal for entire furniture) + Set blockedRegions = parseBlockedRegions(obj, index, fileId); + if (blockedRegions == null) { + // parseBlockedRegions returns null ONLY on unknown region name (fatal) + return null; + } + + // Optional: lockable (inherits from top-level) + boolean seatLockable = getBooleanOrDefault(obj, "lockable", parentLockable); + + // Optional: locked_difficulty (clamped [1, 10000], default 1) + int lockedDifficulty = clampInt(getIntOrDefault(obj, "locked_difficulty", 1), 1, 10000); + + // Optional: item_difficulty_bonus (default false) + boolean itemDifficultyBonus = getBooleanOrDefault(obj, "item_difficulty_bonus", false); + + return new SeatDefinition( + seatId, armature, blockedRegions, + seatLockable, lockedDifficulty, itemDifficultyBonus + ); + } + + /** + * Parse blocked_regions for a seat. Returns empty set if field is absent. + * Returns null (fatal) if any region name is unknown. + */ + @Nullable + private static Set parseBlockedRegions(JsonObject obj, int seatIndex, + ResourceLocation fileId) { + if (!obj.has("blocked_regions") || !obj.get("blocked_regions").isJsonArray()) { + return Collections.unmodifiableSet(EnumSet.noneOf(BodyRegionV2.class)); + } + + JsonArray arr = obj.getAsJsonArray("blocked_regions"); + if (arr.isEmpty()) { + return Collections.unmodifiableSet(EnumSet.noneOf(BodyRegionV2.class)); + } + + EnumSet regions = EnumSet.noneOf(BodyRegionV2.class); + for (JsonElement elem : arr) { + String name; + try { + name = elem.getAsString().toUpperCase(); + } catch (Exception e) { + LOGGER.error("{} Skipping {}: seats[{}] invalid element in 'blocked_regions': {}", + TAG, fileId, seatIndex, e.getMessage()); + return null; + } + + BodyRegionV2 region = BodyRegionV2.fromName(name); + if (region == null) { + LOGGER.error("{} Skipping {}: seats[{}] unknown body region '{}'", + TAG, fileId, seatIndex, name); + return null; + } + regions.add(region); + } + + return Collections.unmodifiableSet(regions); + } + + // ===== Feedback Parsing ===== + + private static FurnitureFeedback parseFeedback(JsonObject obj, ResourceLocation fileId) { + return new FurnitureFeedback( + parseOptionalResourceLocation(obj, "mount_sound", fileId), + parseOptionalResourceLocation(obj, "lock_sound", fileId), + parseOptionalResourceLocation(obj, "unlock_sound", fileId), + parseOptionalResourceLocation(obj, "struggle_loop_sound", fileId), + parseOptionalResourceLocation(obj, "escape_sound", fileId), + parseOptionalResourceLocation(obj, "denied_sound", fileId) + ); + } + + // ===== Tint Channel Parsing ===== + + /** + * Parse tint_channels with strict hex validation. + * Returns empty map if field is absent. Returns null if any value is invalid hex. + */ + @Nullable + private static Map parseTintChannels(JsonObject root, ResourceLocation fileId) { + if (!root.has("tint_channels") || !root.get("tint_channels").isJsonObject()) { + return Map.of(); + } + + JsonObject channels = root.getAsJsonObject("tint_channels"); + Map result = new LinkedHashMap<>(); + + for (Map.Entry entry : channels.entrySet()) { + String hex; + try { + hex = entry.getValue().getAsString(); + } catch (Exception e) { + LOGGER.error("{} Skipping {}: tint_channels '{}' value is not a string", + TAG, fileId, entry.getKey()); + return null; + } + + if (!HEX_COLOR.matcher(hex).matches()) { + LOGGER.error("{} Skipping {}: tint_channels '{}' has invalid hex color '{}' " + + "(expected '#' followed by 6 hex digits)", + TAG, fileId, entry.getKey(), hex); + return null; + } + + int color = Integer.parseInt(hex.substring(1), 16); + result.put(entry.getKey(), color); + } + + return Collections.unmodifiableMap(result); + } + + // ===== Primitive Helpers ===== + + @Nullable + private static String getStringOrNull(JsonObject obj, String key) { + if (!obj.has(key) || obj.get(key).isJsonNull()) return null; + try { + return obj.get(key).getAsString(); + } catch (Exception e) { + return null; + } + } + + private static String getStringOrDefault(JsonObject obj, String key, String defaultValue) { + String value = getStringOrNull(obj, key); + return (value != null && !value.isEmpty()) ? value : defaultValue; + } + + private static int getIntOrDefault(JsonObject obj, String key, int defaultValue) { + if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue; + try { + return obj.get(key).getAsInt(); + } catch (Exception e) { + return defaultValue; + } + } + + private static float getFloatOrDefault(JsonObject obj, String key, float defaultValue) { + if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue; + try { + return obj.get(key).getAsFloat(); + } catch (Exception e) { + return defaultValue; + } + } + + private static boolean getBooleanOrDefault(JsonObject obj, String key, boolean defaultValue) { + if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue; + try { + return obj.get(key).getAsBoolean(); + } catch (Exception e) { + return defaultValue; + } + } + + @Nullable + private static ResourceLocation parseOptionalResourceLocation( + JsonObject obj, String key, ResourceLocation fileId + ) { + String value = getStringOrNull(obj, key); + if (value == null || value.isEmpty()) return null; + ResourceLocation loc = ResourceLocation.tryParse(value); + if (loc == null) { + LOGGER.warn("{} In {}: invalid ResourceLocation for '{}': '{}'", TAG, fileId, key, value); + } + return loc; + } + + // ===== Clamping Helpers ===== + + private static float clamp(float value, float min, float max) { + return Math.max(min, Math.min(max, value)); + } + + private static int clampInt(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/FurniturePlacerItem.java b/src/main/java/com/tiedup/remake/v2/furniture/FurniturePlacerItem.java new file mode 100644 index 0000000..0e9113c --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/FurniturePlacerItem.java @@ -0,0 +1,180 @@ +package com.tiedup.remake.v2.furniture; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.ModEntities; +import com.tiedup.remake.v2.bondage.V2BondageItems; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.context.UseOnContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; +import org.jetbrains.annotations.Nullable; + +/** + * Singleton item that spawns {@link EntityFurniture} on right-click. + * + *

Each ItemStack carries a {@link FurnitureRegistry#NBT_FURNITURE_ID} NBT tag + * that determines which furniture definition to use. This follows the same pattern + * as {@link com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem} where + * a single registered Item serves all data-driven variants.

+ * + *

On use, the item reads the furniture ID from NBT, validates that a + * {@link FurnitureDefinition} exists in the registry, spawns an + * {@link EntityFurniture} at the clicked position, and consumes the item + * (unless in creative mode).

+ */ +public class FurniturePlacerItem extends Item { + + public FurniturePlacerItem() { + super(new Properties().stacksTo(1)); + } + + // ===== PLACEMENT ===== + + @Override + public InteractionResult useOn(UseOnContext context) { + Level level = context.getLevel(); + if (level.isClientSide) { + return InteractionResult.SUCCESS; + } + + ItemStack stack = context.getItemInHand(); + String furnitureIdStr = getFurnitureIdFromStack(stack); + if (furnitureIdStr == null) { + return InteractionResult.FAIL; + } + + // Validate definition exists + FurnitureDefinition def = FurnitureRegistry.get(furnitureIdStr); + if (def == null) { + TiedUpMod.LOGGER.warn( + "[FurniturePlacerItem] Unknown furniture ID '{}', cannot place", + furnitureIdStr + ); + return InteractionResult.FAIL; + } + + // Calculate placement position based on clicked face + BlockPos clickedPos = context.getClickedPos(); + Direction face = context.getClickedFace(); + Vec3 spawnPos; + + if (face == Direction.UP) { + // Clicked top of a block: place on top of it + spawnPos = Vec3.atBottomCenterOf(clickedPos.above()); + } else if (face == Direction.DOWN) { + // Clicked bottom of a block: place below it + spawnPos = Vec3.atBottomCenterOf(clickedPos.below()); + } else { + // Clicked a side: place adjacent to the clicked face + spawnPos = Vec3.atBottomCenterOf(clickedPos.relative(face)); + } + + // Check floor_only placement restriction: must have solid ground below + BlockPos spawnBlockPos = BlockPos.containing(spawnPos); + if (def.floorOnly()) { + BlockPos below = spawnBlockPos.below(); + if (!level.getBlockState(below).isSolidRender(level, below)) { + return InteractionResult.FAIL; + } + } + + // Spawn the furniture entity + EntityFurniture furniture = new EntityFurniture( + ModEntities.FURNITURE.get(), level + ); + furniture.setFurnitureId(furnitureIdStr); + furniture.moveTo(spawnPos.x, spawnPos.y, spawnPos.z); + + // Face the same direction as the player (rounded to nearest 90 degrees) + float yaw = 0f; + if (context.getPlayer() != null) { + float playerYaw = context.getPlayer().getYRot(); + yaw = Math.round(playerYaw / 90.0f) * 90.0f; + } + + // Snap to wall: if enabled, check 4 cardinal directions for an adjacent wall + // and rotate the furniture to face it (back against wall), overriding player yaw. + if (def.snapToWall()) { + Direction[] directions = {Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST}; + for (Direction dir : directions) { + BlockPos wallPos = spawnBlockPos.relative(dir); + if (level.getBlockState(wallPos).isFaceSturdy(level, wallPos, dir.getOpposite())) { + yaw = dir.toYRot(); + break; + } + } + } + + furniture.setYRot(yaw); + + level.addFreshEntity(furniture); + + // Consume the item (unless creative) + if (context.getPlayer() != null && !context.getPlayer().isCreative()) { + stack.shrink(1); + } + + return InteractionResult.CONSUME; + } + + // ===== DISPLAY NAME ===== + + @Override + public Component getName(ItemStack stack) { + String furnitureIdStr = getFurnitureIdFromStack(stack); + if (furnitureIdStr == null) { + return super.getName(stack); + } + + FurnitureDefinition def = FurnitureRegistry.get(furnitureIdStr); + if (def == null) { + return super.getName(stack); + } + + if (def.translationKey() != null) { + return Component.translatable(def.translationKey()); + } + return Component.literal(def.displayName()); + } + + // ===== FACTORY ===== + + /** + * Create an ItemStack for a specific furniture type. + * + * @param furnitureId the definition ID (must exist in {@link FurnitureRegistry}) + * @return a new ItemStack with the {@link FurnitureRegistry#NBT_FURNITURE_ID} NBT tag set, + * or {@link ItemStack#EMPTY} if the placer item is not yet registered + */ + public static ItemStack createStack(ResourceLocation furnitureId) { + if (V2BondageItems.FURNITURE_PLACER == null) return ItemStack.EMPTY; + ItemStack stack = new ItemStack(V2BondageItems.FURNITURE_PLACER.get()); + stack.getOrCreateTag().putString( + FurnitureRegistry.NBT_FURNITURE_ID, furnitureId.toString() + ); + return stack; + } + + // ===== HELPERS ===== + + /** + * Read the furniture definition ID string from an ItemStack's NBT. + * + * @param stack the item stack to read from + * @return the furniture ID string, or null if the tag is missing or empty + */ + @Nullable + public static String getFurnitureIdFromStack(ItemStack stack) { + if (stack.isEmpty() || !stack.hasTag()) return null; + // noinspection DataFlowIssue -- hasTag() guarantees non-null + if (!stack.getTag().contains(FurnitureRegistry.NBT_FURNITURE_ID, 8)) return null; + String id = stack.getTag().getString(FurnitureRegistry.NBT_FURNITURE_ID); + return id.isEmpty() ? null : id; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/FurnitureRegistry.java b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureRegistry.java new file mode 100644 index 0000000..80ea795 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureRegistry.java @@ -0,0 +1,99 @@ +package com.tiedup.remake.v2.furniture; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import net.minecraft.resources.ResourceLocation; +import org.jetbrains.annotations.Nullable; + +/** + * Thread-safe registry for data-driven furniture definitions. + * + *

Server-authoritative: definitions are loaded from {@code data//tiedup_furniture/} + * JSON files by the server reload listener, then synced to clients via + * {@code PacketSyncFurnitureDefinitions}. Unlike {@link + * com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry}, there is no {@code mergeAll()} + * because furniture definitions have a single source of truth (server data pack).

+ * + *

Uses volatile atomic swap to ensure the render thread and network threads + * always see a consistent snapshot of definitions.

+ * + * @see FurnitureDefinition + */ +public final class FurnitureRegistry { + + /** NBT key storing the furniture definition ID on furniture entities. */ + public static final String NBT_FURNITURE_ID = "tiedup_furniture_id"; + + /** + * Volatile reference to an unmodifiable map. {@link #reload} builds a new map + * and swaps atomically; consumer threads always see a consistent snapshot. + */ + private static volatile Map DEFINITIONS = Map.of(); + + private FurnitureRegistry() {} + + /** + * Atomically replace all definitions with a new set. + * Called by the reload listener after parsing all JSON files, + * and by the client sync packet handler after receiving server definitions. + * + * @param newDefs the new definitions map (will be defensively copied) + */ + public static void reload(Map newDefs) { + DEFINITIONS = Collections.unmodifiableMap(new HashMap<>(newDefs)); + } + + /** + * Get a definition by its unique ID. + * + * @param id the definition ID (e.g., "tiedup:wooden_stocks") + * @return the definition, or null if not found + */ + @Nullable + public static FurnitureDefinition get(ResourceLocation id) { + return DEFINITIONS.get(id); + } + + /** + * Lookup a definition by string ID (from SyncedEntityData or NBT). + * + * @param furnitureIdStr the string form of the ResourceLocation, or null/empty + * @return the definition, or null if the string is blank, unparseable, or unknown + */ + @Nullable + public static FurnitureDefinition get(String furnitureIdStr) { + if (furnitureIdStr == null || furnitureIdStr.isEmpty()) return null; + ResourceLocation id = ResourceLocation.tryParse(furnitureIdStr); + return id != null ? DEFINITIONS.get(id) : null; + } + + /** + * Get all registered definitions. + * + * @return unmodifiable collection of all definitions + */ + public static Collection getAll() { + return DEFINITIONS.values(); + } + + /** + * Full map snapshot for packet serialization. + * + *

The returned map is the same unmodifiable reference held internally, + * so it is safe to iterate during packet encoding without copying.

+ * + * @return unmodifiable map of all definitions keyed by ID + */ + public static Map getAllMap() { + return DEFINITIONS; + } + + /** + * Clear all definitions. Called on world unload or for testing. + */ + public static void clear() { + DEFINITIONS = Map.of(); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/FurnitureServerReloadListener.java b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureServerReloadListener.java new file mode 100644 index 0000000..b3eabc3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureServerReloadListener.java @@ -0,0 +1,88 @@ +package com.tiedup.remake.v2.furniture; + +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.SimplePreparableReloadListener; +import net.minecraft.util.profiling.ProfilerFiller; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Server-side resource reload listener that scans {@code data//tiedup_furniture/} + * for JSON files and populates the {@link FurnitureRegistry}. + * + *

Unlike the data-driven item system (which has both client and server listeners), + * furniture definitions are server-authoritative only. The registry is atomically + * replaced via {@link FurnitureRegistry#reload(Map)} on each reload.

+ * + *

Registered via {@link net.minecraftforge.event.AddReloadListenerEvent} in + * {@link com.tiedup.remake.core.TiedUpMod.ForgeEvents}.

+ */ +public class FurnitureServerReloadListener extends SimplePreparableReloadListener { + + private static final Logger LOGGER = LogManager.getLogger("TiedUpFurniture"); + + /** Resource directory containing furniture definition JSON files (under data/). */ + private static final String DIRECTORY = "tiedup_furniture"; + + @Override + protected Void prepare(ResourceManager resourceManager, ProfilerFiller profiler) { + // No preparation needed -- parsing happens in apply phase + return null; + } + + @Override + protected void apply(Void nothing, ResourceManager resourceManager, ProfilerFiller profiler) { + Map newDefs = new HashMap<>(); + + Map resources = resourceManager.listResources( + DIRECTORY, loc -> loc.getPath().endsWith(".json") + ); + + int skipped = 0; + + for (Map.Entry entry : resources.entrySet()) { + ResourceLocation fileId = entry.getKey(); + Resource resource = entry.getValue(); + + try (InputStream input = resource.open()) { + FurnitureDefinition def = FurnitureParser.parse(input, fileId); + + if (def != null) { + // Check for duplicate IDs + if (newDefs.containsKey(def.id())) { + LOGGER.warn("[TiedUpFurniture] Server: Duplicate furniture ID '{}' from file '{}' -- overwriting previous definition", + def.id(), fileId); + } + + newDefs.put(def.id(), def); + LOGGER.debug("[TiedUpFurniture] Server loaded: {} -> '{}'", def.id(), def.displayName()); + } else { + skipped++; + } + } catch (Exception e) { + LOGGER.error("[TiedUpFurniture] Server: Failed to read resource {}: {}", fileId, e.getMessage()); + skipped++; + } + } + + // Atomically replace all definitions in the registry + FurnitureRegistry.reload(newDefs); + + // Broadcast updated definitions to all connected players + net.minecraft.server.MinecraftServer server = + net.minecraftforge.server.ServerLifecycleHooks.getCurrentServer(); + if (server != null) { + for (net.minecraft.server.level.ServerPlayer p : server.getPlayerList().getPlayers()) { + com.tiedup.remake.v2.furniture.network.PacketSyncFurnitureDefinitions.sendToPlayer(p); + } + } + + LOGGER.info("[TiedUpFurniture] Server loaded {} furniture definitions ({} skipped) from {} JSON files", + newDefs.size(), skipped, resources.size()); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/ISeatProvider.java b/src/main/java/com/tiedup/remake/v2/furniture/ISeatProvider.java new file mode 100644 index 0000000..f82c3ea --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/ISeatProvider.java @@ -0,0 +1,50 @@ +package com.tiedup.remake.v2.furniture; + +import com.tiedup.remake.v2.BodyRegionV2; +import java.util.List; +import java.util.Set; +import net.minecraft.world.entity.Entity; +import org.jetbrains.annotations.Nullable; + +/** + * Universal interface for entities that hold players in constrained poses. + * + *

Implemented by EntityFurniture (static) and optionally by monsters/NPCs. + * All downstream systems (packets, animation, rendering) check ISeatProvider, + * never EntityFurniture directly.

+ */ +public interface ISeatProvider { + + /** All seat definitions for this entity. */ + List getSeats(); + + /** Which seat is this passenger in? Null if not seated. */ + @Nullable + SeatDefinition getSeatForPassenger(Entity passenger); + + /** Assign a passenger to a specific seat. */ + void assignSeat(Entity passenger, String seatId); + + /** Release a passenger's seat assignment. */ + void releaseSeat(Entity passenger); + + /** Is this specific seat locked? */ + boolean isSeatLocked(String seatId); + + /** Lock/unlock a specific seat. */ + void setSeatLocked(String seatId, boolean locked); + + /** The locked difficulty for this seat. */ + int getLockedDifficulty(String seatId); + + /** Blocked body regions for a specific seat. */ + Set getBlockedRegions(String seatId); + + /** Whether items on non-blocked regions add to escape difficulty. */ + boolean hasItemDifficultyBonus(String seatId); + + /** Convenience: get the entity this interface is attached to. */ + default Entity asEntity() { + return (Entity) this; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/SeatDefinition.java b/src/main/java/com/tiedup/remake/v2/furniture/SeatDefinition.java new file mode 100644 index 0000000..984180d --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/SeatDefinition.java @@ -0,0 +1,31 @@ +package com.tiedup.remake.v2.furniture; + +import com.tiedup.remake.v2.BodyRegionV2; +import java.util.Set; + +/** + * Immutable definition for a single seat on a furniture piece or monster. + * + *

Loaded from JSON (furniture) or defined programmatically (monsters). + * Each seat describes the physical constraints imposed on a seated player, + * including which body regions are blocked and escape difficulty.

+ */ +public record SeatDefinition( + /** Unique seat identifier within this furniture (e.g., "main", "left"). */ + String id, + + /** GLB armature name (e.g., "Player_main"). */ + String armatureName, + + /** Body regions physically controlled by this seat. */ + Set blockedRegions, + + /** Whether this seat can be locked with a key. */ + boolean lockable, + + /** Struggle difficulty when locked (raw resistance, range 1-10000). */ + int lockedDifficulty, + + /** Whether items on non-blocked regions add to escape difficulty. */ + boolean itemDifficultyBonus +) {} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureAnimationContext.java b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureAnimationContext.java new file mode 100644 index 0000000..ebf5359 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureAnimationContext.java @@ -0,0 +1,146 @@ +package com.tiedup.remake.v2.furniture.client; + +import com.tiedup.remake.client.animation.context.RegionBoneMapper; +import com.tiedup.remake.client.gltf.GltfData; +import com.tiedup.remake.client.gltf.GltfPoseConverter; +import com.tiedup.remake.v2.BodyRegionV2; +import dev.kosmx.playerAnim.core.data.KeyframeAnimation; +import java.util.HashSet; +import java.util.Set; +import org.jetbrains.annotations.Nullable; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Builds a {@link KeyframeAnimation} for a player seated on furniture. + * + *

The furniture animation layer sits at priority 43 (above the item layer at 42), + * so it wins on shared bones. To allow bondage items on non-blocked regions to still + * animate (e.g., a gag on the head while the chair blocks arms+legs), this factory + * enables ONLY the bones corresponding to the seat's blocked regions and disables + * all others. Disabled parts pass through to the lower-priority item layer.

+ * + *

Conversion flow: + *

    + *
  1. Convert the raw glTF animation clip to a PlayerAnimator {@link KeyframeAnimation} + * using {@link GltfPoseConverter#convertWithSkeleton}
  2. + *
  3. Create a mutable copy of the animation
  4. + *
  5. Map blocked {@link BodyRegionV2}s to PlayerAnimator bone names via + * {@link RegionBoneMapper#getPartsForRegion}
  6. + *
  7. Disable all bones NOT in the blocked set
  8. + *
  9. Build and return the immutable result
  10. + *
+ * + *

In V1 (seat skeleton not yet parsed), returns null. Furniture animation + * requires V2 skeleton parsing to provide the rest pose data needed by + * {@link GltfPoseConverter}.

+ * + * @see com.tiedup.remake.client.animation.BondageAnimationManager#playFurniture + * @see RegionBoneMapper + */ +@OnlyIn(Dist.CLIENT) +public final class FurnitureAnimationContext { + + private static final Logger LOGGER = LogManager.getLogger("FurnitureAnimation"); + + private FurnitureAnimationContext() {} + + /** + * Create a KeyframeAnimation for a player seated on furniture. + * Enables ONLY bones in blocked regions, disables all others. + * + * @param seatClip the seat animation clip (from + * {@link FurnitureGltfData#seatAnimations()}) + * @param seatSkeleton the seat skeleton data providing rest pose and joint names + * (from {@link FurnitureGltfData#seatSkeletons()}); null in V1 + * @param blockedRegions the body regions the furniture controls for this seat + * @return a KeyframeAnimation with only blocked-region bones enabled, or null if + * the skeleton is unavailable (V1) or conversion fails + */ + @Nullable + public static KeyframeAnimation create( + GltfData.AnimationClip seatClip, + @Nullable GltfData seatSkeleton, + Set blockedRegions) { + + if (seatClip == null) { + LOGGER.warn("[FurnitureAnim] Cannot create animation: seatClip is null"); + return null; + } + + if (seatSkeleton == null) { + // V1: skeleton parsing not yet implemented. Furniture animation requires + // rest pose data for glTF-to-PlayerAnimator conversion. + LOGGER.debug("[FurnitureAnim] Seat skeleton unavailable (V1), skipping animation"); + return null; + } + + if (blockedRegions == null || blockedRegions.isEmpty()) { + LOGGER.debug("[FurnitureAnim] No blocked regions, skipping animation"); + return null; + } + + // Step 1: Convert the raw clip to a full KeyframeAnimation using skeleton data. + // convertWithSkeleton returns an animation with ALL parts enabled. + KeyframeAnimation fullAnim; + try { + fullAnim = GltfPoseConverter.convertWithSkeleton( + seatSkeleton, seatClip, "furniture_seat"); + } catch (Exception e) { + LOGGER.error("[FurnitureAnim] Failed to convert seat animation clip", e); + return null; + } + + // Step 2: Compute which PlayerAnimator parts correspond to blocked regions. + Set blockedParts = new HashSet<>(); + for (BodyRegionV2 region : blockedRegions) { + blockedParts.addAll(RegionBoneMapper.getPartsForRegion(region)); + } + + if (blockedParts.isEmpty()) { + // Blocked regions don't map to any animation bones (e.g., only NECK/FINGERS/TAIL/WINGS) + LOGGER.debug("[FurnitureAnim] Blocked regions {} map to zero bones, skipping", blockedRegions); + return null; + } + + // Step 3: Compute disabled parts = ALL parts MINUS blocked parts. + Set disabledParts = new HashSet<>(RegionBoneMapper.ALL_PARTS); + disabledParts.removeAll(blockedParts); + + if (disabledParts.isEmpty()) { + // All parts are blocked by the furniture -- return the full animation as-is. + return fullAnim; + } + + // Step 4: Create a mutable copy and disable non-blocked bones. + KeyframeAnimation.AnimationBuilder builder = fullAnim.mutableCopy(); + disableParts(builder, disabledParts); + + KeyframeAnimation result = builder.build(); + LOGGER.debug("[FurnitureAnim] Created animation: blocked={}, enabled={}, disabled={}", + blockedRegions, blockedParts, disabledParts); + return result; + } + + /** + * Disable all animation axes on the specified parts. + * + *

Uses the same pattern as + * {@link com.tiedup.remake.client.animation.context.ContextAnimationFactory}. + * Unknown part names are silently ignored.

+ * + * @param builder the mutable animation builder + * @param disabledParts set of PlayerAnimator part names to disable + */ + private static void disableParts( + KeyframeAnimation.AnimationBuilder builder, Set disabledParts) { + for (String partName : disabledParts) { + KeyframeAnimation.StateCollection part = builder.getPart(partName); + if (part != null) { + part.setEnabled(false); + } + } + } +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureEntityRenderer.java b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureEntityRenderer.java new file mode 100644 index 0000000..5641bb6 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureEntityRenderer.java @@ -0,0 +1,177 @@ +package com.tiedup.remake.v2.furniture.client; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import com.tiedup.remake.client.gltf.GltfData; +import com.tiedup.remake.client.gltf.GltfMeshRenderer; +import com.tiedup.remake.client.gltf.GltfSkinningEngine; +import com.tiedup.remake.v2.furniture.EntityFurniture; +import com.tiedup.remake.v2.furniture.FurnitureDefinition; +import java.util.Map; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.joml.Matrix4f; + +/** + * EntityRenderer for data-driven furniture entities. + * + *

Renders the furniture mesh from GLB data loaded via {@link FurnitureGltfCache}, + * using the existing {@link GltfMeshRenderer} pipeline for CPU-skinned GLTF rendering. + * Supports both static rest-pose and animated rendering based on the entity's + * synched animation state ({@link EntityFurniture#getAnimState()}).

+ * + *

Tint channels from the {@link FurnitureDefinition} are applied via + * {@link GltfMeshRenderer#renderSkinnedTinted} when the definition specifies + * non-empty tint channel defaults and the mesh has multiple primitives.

+ * + *

This is a non-living entity renderer (extends {@code EntityRenderer}, not + * {@code LivingEntityRenderer}), so there is no hurt overlay or death animation. + * Overlay coords use {@link OverlayTexture#NO_OVERLAY} instead of the + * living-entity overlay that reads entity health (which would NPE).

+ * + * @see EntityFurniture + * @see FurnitureGltfCache + * @see GltfMeshRenderer + */ +@OnlyIn(Dist.CLIENT) +public class FurnitureEntityRenderer extends EntityRenderer { + + public FurnitureEntityRenderer(EntityRendererProvider.Context ctx) { + super(ctx); + } + + @Override + public void render( + EntityFurniture entity, + float yaw, + float partialTick, + PoseStack poseStack, + MultiBufferSource buffer, + int packedLight + ) { + FurnitureDefinition def = entity.getDefinition(); + if (def == null) return; + + FurnitureGltfData data = FurnitureGltfCache.get(def.modelLocation()); + if (data == null || data.furnitureMesh() == null) return; + + GltfData meshData = data.furnitureMesh(); + + // Compute joint matrices: animated if there is an active clip, static otherwise + GltfData.AnimationClip activeClip = resolveActiveAnimation(entity, meshData); + Matrix4f[] joints; + if (activeClip != null) { + float time = computeAnimationTime(entity, activeClip, partialTick); + joints = GltfSkinningEngine.computeJointMatricesAnimated(meshData, activeClip, time); + } else { + joints = GltfSkinningEngine.computeJointMatrices(meshData); + } + + poseStack.pushPose(); + + // Apply entity yaw rotation around the Y axis. + // The entity's yaw is set during placement and synced via IEntityAdditionalSpawnData. + poseStack.mulPose(Axis.YP.rotationDegrees(-yaw)); + + // Non-living entities use NO_OVERLAY (no red hurt flash, no death tint). + // LivingEntityRenderer.getOverlayCoords(null, ...) would NPE because it + // accesses entity health. + int packedOverlay = OverlayTexture.NO_OVERLAY; + + // Render with tint support if the definition has tint channels and the mesh + // has multiple primitives (tintable and non-tintable parts). + Map tintColors = def.tintChannels(); + if (!tintColors.isEmpty() && meshData.primitives().size() > 1) { + RenderType renderType = GltfMeshRenderer.getRenderTypeForDefaultTexture(); + GltfMeshRenderer.renderSkinnedTinted( + meshData, joints, poseStack, buffer, + packedLight, packedOverlay, renderType, tintColors + ); + } else { + GltfMeshRenderer.renderSkinned( + meshData, joints, poseStack, buffer, + packedLight, packedOverlay + ); + } + + poseStack.popPose(); + + // super.render() handles debug hitbox rendering and name tag display + super.render(entity, yaw, partialTick, poseStack, buffer, packedLight); + } + + /** + * Map the entity's synched animation state to a named animation clip from the GLB. + * + *

Falls back to "Idle" if the specific state animation is not found in the mesh. + * Returns null if the mesh has no animations at all (static furniture).

+ * + * @param entity the furniture entity + * @param meshData the parsed GLB mesh data + * @return the resolved animation clip, or null for static rendering + */ + private GltfData.AnimationClip resolveActiveAnimation( + EntityFurniture entity, GltfData meshData + ) { + String animName = switch (entity.getAnimState()) { + case EntityFurniture.STATE_OCCUPIED -> "Occupied"; + case EntityFurniture.STATE_LOCKING -> "LockClose"; + case EntityFurniture.STATE_STRUGGLE -> "Shake"; + case EntityFurniture.STATE_UNLOCKING -> "LockOpen"; + case EntityFurniture.STATE_ENTERING -> "Occupied"; // furniture plays Occupied during player enter transition + case EntityFurniture.STATE_EXITING -> "Idle"; // furniture transitions to Idle during player exit + default -> "Idle"; + }; + GltfData.AnimationClip clip = meshData.getAnimation(animName); + if (clip == null && entity.getAnimState() != EntityFurniture.STATE_IDLE) { + // Specific state animation missing: fall back to Idle + clip = meshData.getAnimation("Idle"); + } + // Returns null if there are no animations at all (static mesh) + return clip; + } + + /** + * Compute the animation time in frame-space for the skinning engine. + * + *

Uses the entity's tick count plus partial tick for smooth interpolation. + * The time is looped via modulo against the clip's frame count. A playback speed + * of 1.0 means one frame per tick (20 FPS, matching Minecraft's tick rate).

+ * + * @param entity the furniture entity (provides tick count) + * @param clip the active animation clip (provides frame count) + * @param partialTick fractional tick for interpolation (0.0 to 1.0) + * @return time in frame-space, suitable for {@link GltfSkinningEngine#computeJointMatricesAnimated} + */ + private float computeAnimationTime( + EntityFurniture entity, GltfData.AnimationClip clip, float partialTick + ) { + int frameCount = clip.frameCount(); + if (frameCount <= 1) return 0f; + + // 1 frame per tick = 20 FPS playback, matching Minecraft tick rate. + // partialTick smooths between ticks for frame-rate-independent display. + float time = (entity.tickCount + partialTick); + + // Loop within the valid frame range [0, frameCount - 1] + return time % frameCount; + } + + /** + * GLB pipeline does not use the vanilla texture atlas system. + * Textures are baked into the GLB file and applied via the custom RenderType + * in {@link GltfMeshRenderer}. + * + * @return null because the GLB pipeline manages its own textures + */ + @Override + public ResourceLocation getTextureLocation(EntityFurniture entity) { + return null; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGlbParser.java b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGlbParser.java new file mode 100644 index 0000000..c41aa32 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGlbParser.java @@ -0,0 +1,1395 @@ +package com.tiedup.remake.v2.furniture.client; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.tiedup.remake.client.gltf.GlbParserUtils; +import com.tiedup.remake.client.gltf.GltfBoneMapper; +import com.tiedup.remake.client.gltf.GltfData; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.jetbrains.annotations.Nullable; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.joml.Matrix4f; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +/** + * Parser for multi-armature .glb files used by the furniture system. + * + *

Unlike {@link com.tiedup.remake.client.gltf.GlbParser GlbParser} which assumes a single + * skin/armature, this parser handles GLBs containing multiple armatures: + *

    + *
  • Furniture armature -- the furniture mesh and bones
  • + *
  • Player_* armatures -- player skeletons that define seat positions
  • + *
+ * + *

Armatures are identified by node name convention: nodes whose names start with + * {@code "Player_"} are treated as seat armatures. The seat ID is extracted from the + * suffix (e.g., {@code "Player_main"} yields seat ID {@code "main"}). + * + *

The furniture mesh is parsed fully (geometry, skinning, materials). For Player_* + * armatures, the root transform (position + rotation) is extracted as a + * {@link FurnitureGltfData.SeatTransform}, and the full player skeleton is parsed + * with bone filtering via {@link GltfBoneMapper#isKnownBone(String)}. + * Animations are partitioned by Blender prefix convention + * ({@code "ArmatureName|AnimSuffix"}) and remapped to match filtered joint indices. + * + *

This class is client-only and must never be referenced from server code. + */ +@OnlyIn(Dist.CLIENT) +public final class FurnitureGlbParser { + + private static final Logger LOGGER = LogManager.getLogger("FurnitureGltf"); + + private static final int GLB_MAGIC = 0x46546C67; // "glTF" + private static final int GLB_VERSION = 2; + private static final int CHUNK_JSON = 0x4E4F534A; // "JSON" + private static final int CHUNK_BIN = 0x004E4942; // "BIN\0" + + private static final String PLAYER_PREFIX = "Player_"; + + private FurnitureGlbParser() {} + + /** + * Parse a multi-armature .glb file into a {@link FurnitureGltfData}. + * + * @param input the input stream (read fully, not closed by this method) + * @param debugName human-readable name for log messages + * @return parsed furniture data + * @throws IOException if the file is malformed or I/O fails + */ + public static FurnitureGltfData parse(InputStream input, String debugName) throws IOException { + byte[] allBytes = input.readAllBytes(); + ByteBuffer buf = ByteBuffer.wrap(allBytes).order(ByteOrder.LITTLE_ENDIAN); + + // ---- GLB header ---- + int magic = buf.getInt(); + if (magic != GLB_MAGIC) { + throw new IOException("Not a GLB file: " + debugName); + } + int version = buf.getInt(); + if (version != GLB_VERSION) { + throw new IOException("Unsupported GLB version " + version + " in " + debugName); + } + buf.getInt(); // total file length — not needed, we already have all bytes + + // ---- JSON chunk ---- + int jsonChunkLength = buf.getInt(); + int jsonChunkType = buf.getInt(); + if (jsonChunkType != CHUNK_JSON) { + throw new IOException("Expected JSON chunk in " + debugName); + } + byte[] jsonBytes = new byte[jsonChunkLength]; + buf.get(jsonBytes); + String jsonStr = new String(jsonBytes, StandardCharsets.UTF_8); + JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject(); + + // ---- BIN chunk ---- + ByteBuffer binData = null; + if (buf.hasRemaining()) { + int binChunkLength = buf.getInt(); + int binChunkType = buf.getInt(); + if (binChunkType != CHUNK_BIN) { + throw new IOException("Expected BIN chunk in " + debugName); + } + byte[] binBytes = new byte[binChunkLength]; + buf.get(binBytes); + binData = ByteBuffer.wrap(binBytes).order(ByteOrder.LITTLE_ENDIAN); + } + if (binData == null) { + throw new IOException("No BIN chunk in " + debugName); + } + + JsonArray accessors = root.getAsJsonArray("accessors"); + JsonArray bufferViews = root.getAsJsonArray("bufferViews"); + JsonArray nodes = root.getAsJsonArray("nodes"); + JsonArray meshes = root.getAsJsonArray("meshes"); + JsonArray skins = root.getAsJsonArray("skins"); + + // ---- Identify Player_* armature root nodes ---- + // A "Player_*" armature root is any node whose name starts with "Player_". + // Its name suffix is the seat ID. + Map seatIdToRootNode = new LinkedHashMap<>(); // seatId -> node index + Set playerRootNodes = new HashSet<>(); + + if (nodes != null) { + for (int ni = 0; ni < nodes.size(); ni++) { + JsonObject node = nodes.get(ni).getAsJsonObject(); + String name = node.has("name") ? node.get("name").getAsString() : ""; + if (name.startsWith(PLAYER_PREFIX) && name.length() > PLAYER_PREFIX.length()) { + String seatId = name.substring(PLAYER_PREFIX.length()); + seatIdToRootNode.put(seatId, ni); + playerRootNodes.add(ni); + LOGGER.debug("[FurnitureGltf] Found Player armature: '{}' -> seat '{}'", name, seatId); + } + } + } + + // ---- Classify skins ---- + // For each skin, check if its skeleton node (or the first joint's parent) is a Player_* root. + // The "skeleton" field in a glTF skin points to the root bone node of that armature. + int furnitureSkinIdx = -1; + Map seatIdToSkinIdx = new LinkedHashMap<>(); // seatId -> skin index + + if (skins != null) { + for (int si = 0; si < skins.size(); si++) { + JsonObject skin = skins.get(si).getAsJsonObject(); + int skeletonNode = skin.has("skeleton") ? skin.get("skeleton").getAsInt() : -1; + + // Check if the skeleton root node is a Player_* armature + String matchedSeatId = null; + if (skeletonNode >= 0 && playerRootNodes.contains(skeletonNode)) { + // Direct match: skeleton field points to a Player_* node + for (Map.Entry entry : seatIdToRootNode.entrySet()) { + if (entry.getValue() == skeletonNode) { + matchedSeatId = entry.getKey(); + break; + } + } + } + + // Fallback: check if any joint in this skin is a child of a Player_* root + if (matchedSeatId == null && skin.has("joints")) { + matchedSeatId = matchSkinToPlayerArmature( + skin.getAsJsonArray("joints"), nodes, seatIdToRootNode + ); + } + + if (matchedSeatId != null) { + seatIdToSkinIdx.put(matchedSeatId, si); + LOGGER.debug("[FurnitureGltf] Skin {} -> seat '{}'", si, matchedSeatId); + } else if (furnitureSkinIdx < 0) { + furnitureSkinIdx = si; + LOGGER.debug("[FurnitureGltf] Skin {} -> furniture", si); + } else { + LOGGER.warn("[FurnitureGltf] Extra non-Player skin {} ignored in '{}'", si, debugName); + } + } + } + + // ---- Extract seat transforms from Player_* root nodes ---- + Map seatTransforms = new LinkedHashMap<>(); + for (Map.Entry entry : seatIdToRootNode.entrySet()) { + String seatId = entry.getKey(); + int nodeIdx = entry.getValue(); + JsonObject node = nodes.get(nodeIdx).getAsJsonObject(); + + Vector3f position = new Vector3f(); + if (node.has("translation")) { + JsonArray t = node.getAsJsonArray("translation"); + position.set(t.get(0).getAsFloat(), t.get(1).getAsFloat(), t.get(2).getAsFloat()); + } + + Quaternionf rotation = new Quaternionf(); + if (node.has("rotation")) { + JsonArray r = node.getAsJsonArray("rotation"); + rotation.set(r.get(0).getAsFloat(), r.get(1).getAsFloat(), + r.get(2).getAsFloat(), r.get(3).getAsFloat()); + } + + seatTransforms.put(seatId, new FurnitureGltfData.SeatTransform(seatId, position, rotation)); + LOGGER.debug("[FurnitureGltf] Seat '{}' transform: pos=({},{},{}), rot=({},{},{},{})", + seatId, position.x, position.y, position.z, + rotation.x, rotation.y, rotation.z, rotation.w); + } + + // ---- Parse furniture mesh (full GltfData from the furniture skin) ---- + GltfData furnitureMesh = parseFurnitureSkin( + furnitureSkinIdx, root, accessors, bufferViews, nodes, meshes, skins, binData, debugName + ); + + // ---- Partition animations by Blender prefix convention ---- + // Blender exports animations as "ArmatureName|AnimSuffix". + // For Player_* armatures: key by seatId, strip to AnimSuffix. + // For furniture armature: strip to AnimSuffix. + Map> seatAnimations = new LinkedHashMap<>(); + Map furnitureAnimClips = new LinkedHashMap<>(); + + // Build set of known Player_* armature names for prefix matching + Map armatureNameToSeatId = new LinkedHashMap<>(); + for (Map.Entry entry : seatIdToRootNode.entrySet()) { + String seatId = entry.getKey(); + int nodeIdx = entry.getValue(); + JsonObject node = nodes.get(nodeIdx).getAsJsonObject(); + String armatureName = node.has("name") ? node.get("name").getAsString() : ""; + armatureNameToSeatId.put(armatureName, seatId); + } + + // Also detect the furniture armature name (for stripping its prefix) + String furnitureArmatureName = detectFurnitureArmatureName( + furnitureSkinIdx, skins, nodes + ); + + JsonArray animations = root.getAsJsonArray("animations"); + if (animations != null) { + for (int ai = 0; ai < animations.size(); ai++) { + JsonObject anim = animations.get(ai).getAsJsonObject(); + String rawName = anim.has("name") ? anim.get("name").getAsString() : "animation_" + ai; + + // Parse the "ArmatureName|AnimSuffix" convention + String armaturePrefix = null; + String animSuffix = rawName; + if (rawName.contains("|")) { + int pipeIdx = rawName.lastIndexOf('|'); + armaturePrefix = rawName.substring(0, pipeIdx); + animSuffix = rawName.substring(pipeIdx + 1); + } + + // Determine which armature this animation belongs to + String matchedSeatId = (armaturePrefix != null) + ? armatureNameToSeatId.get(armaturePrefix) : null; + + if (matchedSeatId != null) { + // Player animation: parse with the seat's joint mapping + GltfData.AnimationClip clip = parseAnimationRaw( + anim, accessors, bufferViews, binData, nodes + ); + if (clip != null) { + seatAnimations + .computeIfAbsent(matchedSeatId, k -> new LinkedHashMap<>()) + .put(animSuffix, clip); + LOGGER.debug("[FurnitureGltf] Seat '{}' animation: '{}'", matchedSeatId, animSuffix); + } + } else { + // Furniture animation (or unmatched prefix -- treat as furniture) + // Parse using the furniture joint mapping + GltfData.AnimationClip clip = parseFurnitureAnimation( + anim, accessors, bufferViews, binData, nodes, furnitureSkinIdx, skins + ); + if (clip != null) { + furnitureAnimClips.put(animSuffix, clip); + LOGGER.debug("[FurnitureGltf] Furniture animation: '{}'", animSuffix); + } + } + } + } + + // ---- Build furniture GltfData with animations injected ---- + // The furnitureMesh was parsed without animations; now rebuild it with the furniture clips. + // If there are furniture animations, create a new GltfData with them. + if (!furnitureAnimClips.isEmpty() && furnitureMesh != null) { + GltfData.AnimationClip defaultClip = furnitureAnimClips.values().iterator().next(); + // Build raw copies for the raw-space maps + Map rawFurnitureClips = new LinkedHashMap<>(); + for (Map.Entry e : furnitureAnimClips.entrySet()) { + rawFurnitureClips.put(e.getKey(), GlbParserUtils.deepCopyClip(e.getValue())); + } + GltfData.AnimationClip rawDefaultClip = rawFurnitureClips.values().iterator().next(); + + // Convert furniture animations to MC space + for (GltfData.AnimationClip clip : furnitureAnimClips.values()) { + GlbParserUtils.convertAnimationToMinecraftSpace(clip, furnitureMesh.jointCount()); + } + + furnitureMesh = new GltfData( + furnitureMesh.positions(), furnitureMesh.normals(), furnitureMesh.texCoords(), + furnitureMesh.indices(), furnitureMesh.joints(), furnitureMesh.weights(), + furnitureMesh.jointNames(), furnitureMesh.parentJointIndices(), + furnitureMesh.inverseBindMatrices(), + furnitureMesh.restRotations(), furnitureMesh.restTranslations(), + furnitureMesh.rawGltfRestRotations(), + rawDefaultClip, defaultClip, + furnitureAnimClips, rawFurnitureClips, + furnitureMesh.primitives(), + furnitureMesh.vertexCount(), furnitureMesh.jointCount() + ); + } + + // ---- Parse Player_* seat skeletons (V2: full skeleton for player animation) ---- + // For each Player_* skin, parse joints filtered through GltfBoneMapper.isKnownBone(), + // build rest poses, inverse bind matrices, and bone hierarchy. Re-parse the seat + // animations with the filtered joint mapping so clip indices match skeleton joints. + Map seatSkeletons = new LinkedHashMap<>(); + + for (Map.Entry entry : seatIdToSkinIdx.entrySet()) { + String seatId = entry.getKey(); + int skinIdx = entry.getValue(); + + try { + GltfData seatSkel = parseSeatSkeleton( + skinIdx, root, accessors, bufferViews, nodes, skins, binData, + seatId, debugName + ); + + if (seatSkel != null && seatSkel.jointCount() > 0) { + // Re-parse this seat's animations with the skeleton's joint mapping + // so animation clip indices match the filtered skeleton joints. + Map remappedClips = + remapSeatAnimations( + skinIdx, skins, nodes, seatId, + armatureNameToSeatId, animations, + accessors, bufferViews, binData + ); + + if (!remappedClips.isEmpty()) { + // Build raw copies before any conversion + Map rawRemappedClips = new LinkedHashMap<>(); + for (Map.Entry clipEntry : remappedClips.entrySet()) { + rawRemappedClips.put(clipEntry.getKey(), GlbParserUtils.deepCopyClip(clipEntry.getValue())); + } + + // Rebuild the GltfData with the remapped animations as rawNamedAnimations + GltfData.AnimationClip defaultRawClip = rawRemappedClips.values().iterator().next(); + seatSkel = new GltfData( + seatSkel.positions(), seatSkel.normals(), seatSkel.texCoords(), + seatSkel.indices(), seatSkel.joints(), seatSkel.weights(), + seatSkel.jointNames(), seatSkel.parentJointIndices(), + seatSkel.inverseBindMatrices(), + seatSkel.restRotations(), seatSkel.restTranslations(), + seatSkel.rawGltfRestRotations(), + defaultRawClip, null, + new LinkedHashMap<>(), rawRemappedClips, + seatSkel.primitives(), + seatSkel.vertexCount(), seatSkel.jointCount() + ); + + // Replace the raw-indexed clips in seatAnimations with properly-mapped ones + seatAnimations.put(seatId, remappedClips); + } + + seatSkeletons.put(seatId, seatSkel); + LOGGER.debug("[FurnitureGltf] Parsed seat skeleton '{}': {} joints, {} animations", + seatId, seatSkel.jointCount(), remappedClips.size()); + } else { + LOGGER.warn("[FurnitureGltf] Seat '{}' skeleton has no known bones in '{}'", + seatId, debugName); + } + } catch (Exception e) { + LOGGER.error("[FurnitureGltf] Failed to parse seat skeleton '{}' in '{}'", + seatId, debugName, e); + } + } + + LOGGER.info("[FurnitureGltf] Parsed '{}': furnitureSkin={}, seats={}, seatSkeletons={}, furnitureAnims={}, seatAnimSets={}", + debugName, + furnitureSkinIdx >= 0 ? "yes" : "no", + seatTransforms.size(), + seatSkeletons.size(), + furnitureAnimClips.size(), + seatAnimations.size()); + + return new FurnitureGltfData( + furnitureMesh, + Collections.unmodifiableMap(seatTransforms), + Collections.unmodifiableMap(seatAnimations), + Collections.unmodifiableMap(seatSkeletons) + ); + } + + // ======================================================================== + // Skin classification helpers + // ======================================================================== + + /** + * Try to match a skin's joints to a Player_* armature by checking whether + * any joint node is a descendant of a Player_* root node. + */ + @Nullable + private static String matchSkinToPlayerArmature( + JsonArray skinJoints, JsonArray nodes, Map seatIdToRootNode + ) { + for (JsonElement jointElem : skinJoints) { + int jointNodeIdx = jointElem.getAsInt(); + for (Map.Entry entry : seatIdToRootNode.entrySet()) { + if (isDescendantOf(jointNodeIdx, entry.getValue(), nodes)) { + return entry.getKey(); + } + } + } + return null; + } + + /** + * Check if nodeIdx is a descendant of ancestorIdx via the node children hierarchy. + * Also returns true if nodeIdx == ancestorIdx. + */ + private static boolean isDescendantOf(int nodeIdx, int ancestorIdx, JsonArray nodes) { + if (nodeIdx == ancestorIdx) return true; + JsonObject ancestor = nodes.get(ancestorIdx).getAsJsonObject(); + if (!ancestor.has("children")) return false; + for (JsonElement child : ancestor.getAsJsonArray("children")) { + if (isDescendantOf(nodeIdx, child.getAsInt(), nodes)) return true; + } + return false; + } + + /** + * Detect the name of the furniture armature's skeleton root node. + */ + @Nullable + private static String detectFurnitureArmatureName( + int furnitureSkinIdx, @Nullable JsonArray skins, JsonArray nodes + ) { + if (skins == null || furnitureSkinIdx < 0) return null; + JsonObject skin = skins.get(furnitureSkinIdx).getAsJsonObject(); + if (!skin.has("skeleton")) return null; + int skelNode = skin.get("skeleton").getAsInt(); + JsonObject node = nodes.get(skelNode).getAsJsonObject(); + return node.has("name") ? node.get("name").getAsString() : null; + } + + // ======================================================================== + // Furniture skin parsing (produces a GltfData) + // ======================================================================== + + /** + * Parse the furniture skin into a full {@link GltfData}. + * This mirrors the logic in GlbParser but operates on a specific skin index + * and does NOT filter bones via GltfBoneMapper (furniture has its own bone names). + * + * @return parsed GltfData, or a minimal empty GltfData if no furniture skin exists + */ + private static GltfData parseFurnitureSkin( + int skinIdx, JsonObject root, + JsonArray accessors, JsonArray bufferViews, + JsonArray nodes, @Nullable JsonArray meshes, @Nullable JsonArray skins, + ByteBuffer binData, String debugName + ) throws IOException { + + // If no furniture skin, return minimal GltfData (mesh-only GLB?) + if (skins == null || skinIdx < 0) { + LOGGER.warn("[FurnitureGltf] No furniture skin found in '{}', attempting mesh-only parse", debugName); + return buildMeshOnlyGltfData(root, accessors, bufferViews, nodes, meshes, binData, debugName); + } + + JsonObject skin = skins.get(skinIdx).getAsJsonObject(); + JsonArray skinJoints = skin.getAsJsonArray("joints"); + + // ---- Parse ALL joints from this skin (no bone filtering for furniture) ---- + int jointCount = skinJoints.size(); + String[] jointNames = new String[jointCount]; + int[] parentJointIndices = new int[jointCount]; + Quaternionf[] restRotations = new Quaternionf[jointCount]; + Vector3f[] restTranslations = new Vector3f[jointCount]; + + // Map node index -> joint index for this skin + int[] nodeToJoint = new int[nodes.size()]; + java.util.Arrays.fill(nodeToJoint, -1); + + List jointNodeIndices = new ArrayList<>(jointCount); + for (int j = 0; j < jointCount; j++) { + int nodeIdx = skinJoints.get(j).getAsInt(); + jointNodeIndices.add(nodeIdx); + nodeToJoint[nodeIdx] = j; + } + + // Read joint names and rest poses + java.util.Arrays.fill(parentJointIndices, -1); + for (int j = 0; j < jointCount; j++) { + int nodeIdx = jointNodeIndices.get(j); + JsonObject node = nodes.get(nodeIdx).getAsJsonObject(); + + jointNames[j] = node.has("name") ? node.get("name").getAsString() : "joint_" + j; + + if (node.has("rotation")) { + JsonArray r = node.getAsJsonArray("rotation"); + restRotations[j] = new Quaternionf( + r.get(0).getAsFloat(), r.get(1).getAsFloat(), + r.get(2).getAsFloat(), r.get(3).getAsFloat() + ); + } else { + restRotations[j] = new Quaternionf(); + } + + if (node.has("translation")) { + JsonArray t = node.getAsJsonArray("translation"); + restTranslations[j] = new Vector3f( + t.get(0).getAsFloat(), t.get(1).getAsFloat(), t.get(2).getAsFloat() + ); + } else { + restTranslations[j] = new Vector3f(); + } + } + + // Build parent indices by traversing node children + for (int ni = 0; ni < nodes.size(); ni++) { + JsonObject node = nodes.get(ni).getAsJsonObject(); + if (node.has("children")) { + int parentJoint = nodeToJoint[ni]; + for (JsonElement child : node.getAsJsonArray("children")) { + int childNodeIdx = child.getAsInt(); + if (childNodeIdx < nodeToJoint.length) { + int childJoint = nodeToJoint[childNodeIdx]; + if (childJoint >= 0 && parentJoint >= 0) { + parentJointIndices[childJoint] = parentJoint; + } + } + } + } + } + + // ---- Inverse bind matrices ---- + Matrix4f[] inverseBindMatrices = new Matrix4f[jointCount]; + if (skin.has("inverseBindMatrices")) { + int ibmAccessor = skin.get("inverseBindMatrices").getAsInt(); + float[] ibmData = GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, ibmAccessor); + for (int j = 0; j < jointCount; j++) { + inverseBindMatrices[j] = new Matrix4f(); + inverseBindMatrices[j].set(ibmData, j * 16); + } + } else { + for (int j = 0; j < jointCount; j++) { + inverseBindMatrices[j] = new Matrix4f(); + } + } + + // ---- Find the mesh associated with this skin ---- + // Walk nodes to find one that references this skin index AND has a mesh + int targetMeshIdx = findMeshForSkin(skinIdx, nodes, meshes); + + // Fallback: take the last non-Player mesh (same heuristic as GlbParser) + if (targetMeshIdx < 0 && meshes != null) { + for (int mi = 0; mi < meshes.size(); mi++) { + JsonObject mesh = meshes.get(mi).getAsJsonObject(); + String meshName = mesh.has("name") ? mesh.get("name").getAsString() : ""; + if (!meshName.startsWith("Player")) { + targetMeshIdx = mi; + } + } + } + + // ---- Parse material names ---- + String[] materialNames = GlbParserUtils.parseMaterialNames(root); + + // ---- Parse mesh geometry ---- + float[] positions; + float[] normals; + float[] texCoords; + int[] indices; + int vertexCount; + int[] meshJoints; + float[] weights; + List parsedPrimitives = new ArrayList<>(); + + if (targetMeshIdx >= 0) { + JsonObject mesh = meshes.get(targetMeshIdx).getAsJsonObject(); + JsonArray primitives = mesh.getAsJsonArray("primitives"); + + List allPositions = new ArrayList<>(); + List allNormals = new ArrayList<>(); + List allTexCoords = new ArrayList<>(); + List allJoints = new ArrayList<>(); + List allWeights = new ArrayList<>(); + int cumulativeVertexCount = 0; + + for (int pi = 0; pi < primitives.size(); pi++) { + JsonObject primitive = primitives.get(pi).getAsJsonObject(); + JsonObject attributes = primitive.getAsJsonObject("attributes"); + + float[] primPositions = GlbParserUtils.readFloatAccessor( + accessors, bufferViews, binData, attributes.get("POSITION").getAsInt() + ); + float[] primNormals = attributes.has("NORMAL") + ? GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, attributes.get("NORMAL").getAsInt()) + : new float[primPositions.length]; + float[] primTexCoords = attributes.has("TEXCOORD_0") + ? GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, attributes.get("TEXCOORD_0").getAsInt()) + : new float[primPositions.length / 3 * 2]; + + int primVertexCount = primPositions.length / 3; + + int[] primIndices; + if (primitive.has("indices")) { + primIndices = GlbParserUtils.readIntAccessor( + accessors, bufferViews, binData, primitive.get("indices").getAsInt() + ); + } else { + primIndices = new int[primVertexCount]; + for (int i = 0; i < primVertexCount; i++) primIndices[i] = i; + } + + if (cumulativeVertexCount > 0) { + for (int i = 0; i < primIndices.length; i++) { + primIndices[i] += cumulativeVertexCount; + } + } + + // Skinning (no remap needed -- furniture keeps all joints) + int[] primJoints = new int[primVertexCount * 4]; + float[] primWeights = new float[primVertexCount * 4]; + if (attributes.has("JOINTS_0")) { + primJoints = GlbParserUtils.readIntAccessor( + accessors, bufferViews, binData, attributes.get("JOINTS_0").getAsInt() + ); + } + if (attributes.has("WEIGHTS_0")) { + primWeights = GlbParserUtils.readFloatAccessor( + accessors, bufferViews, binData, attributes.get("WEIGHTS_0").getAsInt() + ); + } + + // Material / tint channel + String matName = null; + if (primitive.has("material")) { + int matIdx = primitive.get("material").getAsInt(); + if (matIdx >= 0 && matIdx < materialNames.length) { + matName = materialNames[matIdx]; + } + } + boolean isTintable = matName != null && matName.startsWith("tintable_"); + String tintChannel = isTintable ? matName : null; + + parsedPrimitives.add(new GltfData.Primitive(primIndices, matName, isTintable, tintChannel)); + + allPositions.add(primPositions); + allNormals.add(primNormals); + allTexCoords.add(primTexCoords); + allJoints.add(primJoints); + allWeights.add(primWeights); + cumulativeVertexCount += primVertexCount; + } + + vertexCount = cumulativeVertexCount; + positions = GlbParserUtils.flattenFloats(allPositions); + normals = GlbParserUtils.flattenFloats(allNormals); + texCoords = GlbParserUtils.flattenFloats(allTexCoords); + meshJoints = GlbParserUtils.flattenInts(allJoints); + weights = GlbParserUtils.flattenFloats(allWeights); + + int totalIndices = 0; + for (GltfData.Primitive p : parsedPrimitives) totalIndices += p.indices().length; + indices = new int[totalIndices]; + int offset = 0; + for (GltfData.Primitive p : parsedPrimitives) { + System.arraycopy(p.indices(), 0, indices, offset, p.indices().length); + offset += p.indices().length; + } + } else { + LOGGER.info("[FurnitureGltf] No furniture mesh found in '{}'", debugName); + positions = new float[0]; + normals = new float[0]; + texCoords = new float[0]; + indices = new int[0]; + vertexCount = 0; + meshJoints = new int[0]; + weights = new float[0]; + } + + // ---- Save raw rest rotations before MC conversion ---- + Quaternionf[] rawRestRotations = new Quaternionf[jointCount]; + for (int j = 0; j < jointCount; j++) { + rawRestRotations[j] = new Quaternionf(restRotations[j]); + } + + // ---- Convert to Minecraft space ---- + convertToMinecraftSpace(positions, normals, restTranslations, restRotations, + inverseBindMatrices, jointCount); + + return new GltfData( + positions, normals, texCoords, + indices, meshJoints, weights, + jointNames, parentJointIndices, + inverseBindMatrices, + restRotations, restTranslations, + rawRestRotations, + null, null, // animations injected later by the caller + new LinkedHashMap<>(), new LinkedHashMap<>(), + parsedPrimitives, + vertexCount, jointCount + ); + } + + /** + * Build a minimal GltfData for a mesh-only GLB (no skin/armature). + * Used as fallback when the furniture has no skeleton. + */ + private static GltfData buildMeshOnlyGltfData( + JsonObject root, + JsonArray accessors, JsonArray bufferViews, + JsonArray nodes, @Nullable JsonArray meshes, + ByteBuffer binData, String debugName + ) { + String[] materialNames = GlbParserUtils.parseMaterialNames(root); + List parsedPrimitives = new ArrayList<>(); + + // Find the first non-Player mesh + int targetMeshIdx = -1; + if (meshes != null) { + for (int mi = 0; mi < meshes.size(); mi++) { + JsonObject mesh = meshes.get(mi).getAsJsonObject(); + String meshName = mesh.has("name") ? mesh.get("name").getAsString() : ""; + if (!meshName.startsWith("Player")) { + targetMeshIdx = mi; + break; + } + } + } + + float[] positions; + float[] normals; + float[] texCoords; + int[] indices; + int vertexCount; + + if (targetMeshIdx >= 0) { + JsonObject mesh = meshes.get(targetMeshIdx).getAsJsonObject(); + JsonArray primitives = mesh.getAsJsonArray("primitives"); + + List allPositions = new ArrayList<>(); + List allNormals = new ArrayList<>(); + List allTexCoords = new ArrayList<>(); + int cumulativeVertexCount = 0; + + for (int pi = 0; pi < primitives.size(); pi++) { + JsonObject primitive = primitives.get(pi).getAsJsonObject(); + JsonObject attributes = primitive.getAsJsonObject("attributes"); + + float[] primPositions = GlbParserUtils.readFloatAccessor( + accessors, bufferViews, binData, attributes.get("POSITION").getAsInt() + ); + float[] primNormals = attributes.has("NORMAL") + ? GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, attributes.get("NORMAL").getAsInt()) + : new float[primPositions.length]; + float[] primTexCoords = attributes.has("TEXCOORD_0") + ? GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, attributes.get("TEXCOORD_0").getAsInt()) + : new float[primPositions.length / 3 * 2]; + + int primVertexCount = primPositions.length / 3; + + int[] primIndices; + if (primitive.has("indices")) { + primIndices = GlbParserUtils.readIntAccessor( + accessors, bufferViews, binData, primitive.get("indices").getAsInt() + ); + } else { + primIndices = new int[primVertexCount]; + for (int i = 0; i < primVertexCount; i++) primIndices[i] = i; + } + + if (cumulativeVertexCount > 0) { + for (int i = 0; i < primIndices.length; i++) { + primIndices[i] += cumulativeVertexCount; + } + } + + String matName = null; + if (primitive.has("material")) { + int matIdx = primitive.get("material").getAsInt(); + if (matIdx >= 0 && matIdx < materialNames.length) { + matName = materialNames[matIdx]; + } + } + boolean isTintable = matName != null && matName.startsWith("tintable_"); + parsedPrimitives.add(new GltfData.Primitive( + primIndices, matName, isTintable, isTintable ? matName : null + )); + + allPositions.add(primPositions); + allNormals.add(primNormals); + allTexCoords.add(primTexCoords); + cumulativeVertexCount += primVertexCount; + } + + vertexCount = cumulativeVertexCount; + positions = GlbParserUtils.flattenFloats(allPositions); + normals = GlbParserUtils.flattenFloats(allNormals); + texCoords = GlbParserUtils.flattenFloats(allTexCoords); + + int totalIndices = 0; + for (GltfData.Primitive p : parsedPrimitives) totalIndices += p.indices().length; + indices = new int[totalIndices]; + int offset = 0; + for (GltfData.Primitive p : parsedPrimitives) { + System.arraycopy(p.indices(), 0, indices, offset, p.indices().length); + offset += p.indices().length; + } + + // Convert positions and normals to MC space + for (int i = 0; i < positions.length; i += 3) { + positions[i] = -positions[i]; + positions[i + 1] = -positions[i + 1]; + } + for (int i = 0; i < normals.length; i += 3) { + normals[i] = -normals[i]; + normals[i + 1] = -normals[i + 1]; + } + } else { + positions = new float[0]; + normals = new float[0]; + texCoords = new float[0]; + indices = new int[0]; + vertexCount = 0; + } + + return new GltfData( + positions, normals, texCoords, + indices, new int[0], new float[0], + new String[0], new int[0], + new Matrix4f[0], + new Quaternionf[0], new Vector3f[0], + new Quaternionf[0], + null, null, + new LinkedHashMap<>(), new LinkedHashMap<>(), + parsedPrimitives, + vertexCount, 0 + ); + } + + /** + * Find the mesh index associated with a given skin index by scanning nodes. + * A node that has both a "skin" field matching skinIdx and a "mesh" field + * links the two. + */ + private static int findMeshForSkin(int skinIdx, JsonArray nodes, @Nullable JsonArray meshes) { + if (meshes == null || skinIdx < 0) return -1; + for (int ni = 0; ni < nodes.size(); ni++) { + JsonObject node = nodes.get(ni).getAsJsonObject(); + if (node.has("skin") && node.get("skin").getAsInt() == skinIdx && node.has("mesh")) { + return node.get("mesh").getAsInt(); + } + } + return -1; + } + + // ======================================================================== + // Seat skeleton parsing (Player_* armatures for player animation) + // ======================================================================== + + /** + * Parse a Player_* skin into a skeleton-only {@link GltfData} for player animation. + * + *

Mirrors {@link #parseFurnitureSkin} but filters joints through + * {@link GltfBoneMapper#isKnownBone(String)}, keeping only player-skeleton bones. + * Mesh data is empty (we never render the player mesh from GLB data). + * Animations are NOT included here; they are re-parsed separately with the + * filtered joint mapping by {@link #remapSeatAnimations}.

+ * + * @param skinIdx the skin index for this Player_* armature + * @param root the root glTF JSON object + * @param accessors glTF accessors array + * @param bufferViews glTF bufferViews array + * @param nodes glTF nodes array + * @param skins glTF skins array + * @param binData the binary buffer + * @param seatId seat identifier for logging + * @param debugName file name for logging + * @return a skeleton-only GltfData with filtered player bones, or null on failure + */ + @Nullable + private static GltfData parseSeatSkeleton( + int skinIdx, JsonObject root, + JsonArray accessors, JsonArray bufferViews, + JsonArray nodes, @Nullable JsonArray skins, + ByteBuffer binData, String seatId, String debugName + ) throws IOException { + + if (skins == null || skinIdx < 0) { + LOGGER.warn("[FurnitureGltf] No skin for seat '{}' in '{}'", seatId, debugName); + return null; + } + + JsonObject skin = skins.get(skinIdx).getAsJsonObject(); + JsonArray skinJoints = skin.getAsJsonArray("joints"); + + // ---- Filter joints to known player bones (like GlbParser) ---- + List filteredJointNodes = new ArrayList<>(); + int[] skinJointRemap = new int[skinJoints.size()]; // old skin index -> new filtered index + java.util.Arrays.fill(skinJointRemap, -1); + + for (int j = 0; j < skinJoints.size(); j++) { + int nodeIdx = skinJoints.get(j).getAsInt(); + JsonObject node = nodes.get(nodeIdx).getAsJsonObject(); + String name = node.has("name") ? node.get("name").getAsString() : "joint_" + j; + if (GltfBoneMapper.isKnownBone(name)) { + skinJointRemap[j] = filteredJointNodes.size(); + filteredJointNodes.add(nodeIdx); + } else { + LOGGER.debug("[FurnitureGltf] Seat '{}': skipping non-player bone '{}'", seatId, name); + } + } + + int jointCount = filteredJointNodes.size(); + if (jointCount == 0) { + LOGGER.warn("[FurnitureGltf] Seat '{}' skin has no known player bones in '{}'", seatId, debugName); + return null; + } + + String[] jointNames = new String[jointCount]; + int[] parentJointIndices = new int[jointCount]; + Quaternionf[] restRotations = new Quaternionf[jointCount]; + Vector3f[] restTranslations = new Vector3f[jointCount]; + + // Map node index -> filtered joint index + int[] nodeToJoint = new int[nodes.size()]; + java.util.Arrays.fill(nodeToJoint, -1); + for (int j = 0; j < jointCount; j++) { + int nodeIdx = filteredJointNodes.get(j); + nodeToJoint[nodeIdx] = j; + } + + // Read joint names and rest poses + java.util.Arrays.fill(parentJointIndices, -1); + for (int j = 0; j < jointCount; j++) { + int nodeIdx = filteredJointNodes.get(j); + JsonObject node = nodes.get(nodeIdx).getAsJsonObject(); + + jointNames[j] = node.has("name") ? node.get("name").getAsString() : "joint_" + j; + + if (node.has("rotation")) { + JsonArray r = node.getAsJsonArray("rotation"); + restRotations[j] = new Quaternionf( + r.get(0).getAsFloat(), r.get(1).getAsFloat(), + r.get(2).getAsFloat(), r.get(3).getAsFloat() + ); + } else { + restRotations[j] = new Quaternionf(); + } + + if (node.has("translation")) { + JsonArray t = node.getAsJsonArray("translation"); + restTranslations[j] = new Vector3f( + t.get(0).getAsFloat(), t.get(1).getAsFloat(), t.get(2).getAsFloat() + ); + } else { + restTranslations[j] = new Vector3f(); + } + } + + // Build parent indices by traversing node children + for (int ni = 0; ni < nodes.size(); ni++) { + JsonObject node = nodes.get(ni).getAsJsonObject(); + if (node.has("children")) { + int parentJoint = nodeToJoint[ni]; + for (JsonElement child : node.getAsJsonArray("children")) { + int childNodeIdx = child.getAsInt(); + if (childNodeIdx < nodeToJoint.length) { + int childJoint = nodeToJoint[childNodeIdx]; + if (childJoint >= 0 && parentJoint >= 0) { + parentJointIndices[childJoint] = parentJoint; + } + } + } + } + } + + // ---- Inverse bind matrices ---- + // IBM accessor is indexed by original skin joint order, pick filtered entries + Matrix4f[] inverseBindMatrices = new Matrix4f[jointCount]; + if (skin.has("inverseBindMatrices")) { + int ibmAccessor = skin.get("inverseBindMatrices").getAsInt(); + float[] ibmData = GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, ibmAccessor); + for (int origJ = 0; origJ < skinJoints.size(); origJ++) { + int newJ = skinJointRemap[origJ]; + if (newJ >= 0) { + inverseBindMatrices[newJ] = new Matrix4f(); + inverseBindMatrices[newJ].set(ibmData, origJ * 16); + } + } + } else { + for (int j = 0; j < jointCount; j++) { + inverseBindMatrices[j] = new Matrix4f(); + } + } + + // ---- Save raw rest rotations BEFORE MC conversion ---- + Quaternionf[] rawRestRotations = new Quaternionf[jointCount]; + for (int j = 0; j < jointCount; j++) { + rawRestRotations[j] = new Quaternionf(restRotations[j]); + } + + // ---- Convert to Minecraft space ---- + // Empty arrays for positions/normals (skeleton-only, no mesh) + float[] emptyPositions = new float[0]; + float[] emptyNormals = new float[0]; + convertToMinecraftSpace(emptyPositions, emptyNormals, restTranslations, restRotations, + inverseBindMatrices, jointCount); + + LOGGER.debug("[FurnitureGltf] Seat '{}' skeleton: {} filtered joints from {} total in '{}'", + seatId, jointCount, skinJoints.size(), debugName); + for (int j = 0; j < jointCount; j++) { + LOGGER.debug("[FurnitureGltf] seat '{}' joint[{}] = '{}', parent={}", + seatId, j, jointNames[j], parentJointIndices[j]); + } + + // Return skeleton-only GltfData (no mesh, no animations yet) + return new GltfData( + new float[0], new float[0], new float[0], // positions, normals, texCoords + new int[0], new int[0], new float[0], // indices, joints, weights + jointNames, parentJointIndices, + inverseBindMatrices, + restRotations, restTranslations, + rawRestRotations, + null, null, // animations added later + new LinkedHashMap<>(), new LinkedHashMap<>(), + List.of(), // no primitives + 0, jointCount + ); + } + + /** + * Re-parse a seat's animations using the filtered joint mapping from the seat skeleton. + * + *

The original {@code seatAnimations} map contains clips parsed by + * {@link #parseAnimationRaw} where indices correspond to raw node indices. + * These need to be re-parsed with the seat skin's filtered {@code nodeToJoint} + * mapping so clip rotation/translation arrays are indexed by filtered joint index, + * matching the seat skeleton's joint ordering.

+ * + * @param skinIdx the skin index for this Player_* armature + * @param skins glTF skins array + * @param nodes glTF nodes array + * @param seatId seat identifier for matching animation prefixes + * @param armatureNameToSeatId maps armature names to seat IDs + * @param animations the root glTF animations array (may be null) + * @param accessors glTF accessors array + * @param bufferViews glTF bufferViews array + * @param binData the binary buffer + * @return map of animation name to properly-indexed clip, or empty map if none found + */ + private static Map remapSeatAnimations( + int skinIdx, @Nullable JsonArray skins, JsonArray nodes, + String seatId, Map armatureNameToSeatId, + @Nullable JsonArray animations, + JsonArray accessors, JsonArray bufferViews, ByteBuffer binData + ) { + Map result = new LinkedHashMap<>(); + + if (animations == null || skins == null || skinIdx < 0) return result; + + // Build the filtered nodeToJoint mapping for this seat's skin + JsonObject skin = skins.get(skinIdx).getAsJsonObject(); + JsonArray skinJoints = skin.getAsJsonArray("joints"); + + List filteredJointNodes = new ArrayList<>(); + for (int j = 0; j < skinJoints.size(); j++) { + int nodeIdx = skinJoints.get(j).getAsInt(); + JsonObject node = nodes.get(nodeIdx).getAsJsonObject(); + String name = node.has("name") ? node.get("name").getAsString() : "joint_" + j; + if (GltfBoneMapper.isKnownBone(name)) { + filteredJointNodes.add(nodeIdx); + } + } + + int jointCount = filteredJointNodes.size(); + if (jointCount == 0) return result; + + int[] nodeToJoint = new int[nodes.size()]; + java.util.Arrays.fill(nodeToJoint, -1); + for (int j = 0; j < jointCount; j++) { + nodeToJoint[filteredJointNodes.get(j)] = j; + } + + // Find this seat's armature name for prefix matching + String targetArmatureName = null; + for (Map.Entry entry : armatureNameToSeatId.entrySet()) { + if (seatId.equals(entry.getValue())) { + targetArmatureName = entry.getKey(); + break; + } + } + + // Re-parse each animation that belongs to this seat + for (int ai = 0; ai < animations.size(); ai++) { + JsonObject anim = animations.get(ai).getAsJsonObject(); + String rawName = anim.has("name") ? anim.get("name").getAsString() : "animation_" + ai; + + // Parse "ArmatureName|AnimSuffix" convention + String armaturePrefix = null; + String animSuffix = rawName; + if (rawName.contains("|")) { + int pipeIdx = rawName.lastIndexOf('|'); + armaturePrefix = rawName.substring(0, pipeIdx); + animSuffix = rawName.substring(pipeIdx + 1); + } + + // Only process animations belonging to this seat's armature + if (armaturePrefix == null || !armaturePrefix.equals(targetArmatureName)) { + continue; + } + + // Parse with the filtered joint mapping + GltfData.AnimationClip clip = parseAnimationWithMapping( + anim, accessors, bufferViews, binData, nodeToJoint, jointCount + ); + if (clip != null) { + result.put(animSuffix, clip); + LOGGER.debug("[FurnitureGltf] Remapped seat '{}' animation: '{}' ({} joints)", + seatId, animSuffix, jointCount); + } + } + + return result; + } + + // ======================================================================== + // Animation parsing + // ======================================================================== + + /** + * Parse a raw animation clip without any joint index mapping. + * Used for seat (Player_*) animations where we store them in raw glTF space + * for later conversion by GltfPoseConverter. + * + *

Returns an AnimationClip where joint indices correspond to animation channel + * target node indices (NOT skin joint indices). The clip is stored as-is in the + * seatAnimations map for downstream processing. + * + * @return the parsed clip, or null if no rotation/translation channels found + */ + @Nullable + private static GltfData.AnimationClip parseAnimationRaw( + JsonObject animation, + JsonArray accessors, JsonArray bufferViews, + ByteBuffer binData, JsonArray nodes + ) { + JsonArray channels = animation.getAsJsonArray("channels"); + JsonArray samplers = animation.getAsJsonArray("samplers"); + + // Determine the max node index referenced by this animation's channels + int maxNodeIdx = 0; + for (JsonElement chElem : channels) { + JsonObject channel = chElem.getAsJsonObject(); + JsonObject target = channel.getAsJsonObject("target"); + if (target.has("node")) { + maxNodeIdx = Math.max(maxNodeIdx, target.get("node").getAsInt()); + } + } + int slotCount = maxNodeIdx + 1; + + List rotJoints = new ArrayList<>(); + List rotTimestamps = new ArrayList<>(); + List rotValues = new ArrayList<>(); + + List transJoints = new ArrayList<>(); + List transTimestamps = new ArrayList<>(); + List transValues = new ArrayList<>(); + + for (JsonElement chElem : channels) { + JsonObject channel = chElem.getAsJsonObject(); + JsonObject target = channel.getAsJsonObject("target"); + if (!target.has("node")) continue; + String path = target.get("path").getAsString(); + int nodeIdx = target.get("node").getAsInt(); + + int samplerIdx = channel.get("sampler").getAsInt(); + JsonObject sampler = samplers.get(samplerIdx).getAsJsonObject(); + float[] times = GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, sampler.get("input").getAsInt()); + + if ("rotation".equals(path)) { + float[] quats = GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, sampler.get("output").getAsInt()); + Quaternionf[] qArr = new Quaternionf[times.length]; + for (int i = 0; i < times.length; i++) { + qArr[i] = new Quaternionf( + quats[i * 4], quats[i * 4 + 1], quats[i * 4 + 2], quats[i * 4 + 3] + ); + } + rotJoints.add(nodeIdx); + rotTimestamps.add(times); + rotValues.add(qArr); + } else if ("translation".equals(path)) { + float[] vecs = GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, sampler.get("output").getAsInt()); + Vector3f[] tArr = new Vector3f[times.length]; + for (int i = 0; i < times.length; i++) { + tArr[i] = new Vector3f(vecs[i * 3], vecs[i * 3 + 1], vecs[i * 3 + 2]); + } + transJoints.add(nodeIdx); + transTimestamps.add(times); + transValues.add(tArr); + } + } + + if (rotJoints.isEmpty() && transJoints.isEmpty()) return null; + + float[] timestamps = !rotTimestamps.isEmpty() ? rotTimestamps.get(0) : transTimestamps.get(0); + int frameCount = timestamps.length; + + Quaternionf[][] rotations = new Quaternionf[slotCount][]; + for (int i = 0; i < rotJoints.size(); i++) { + int jIdx = rotJoints.get(i); + Quaternionf[] vals = rotValues.get(i); + rotations[jIdx] = new Quaternionf[frameCount]; + for (int f = 0; f < frameCount; f++) { + rotations[jIdx][f] = f < vals.length ? vals[f] : vals[vals.length - 1]; + } + } + + Vector3f[][] translations = new Vector3f[slotCount][]; + for (int i = 0; i < transJoints.size(); i++) { + int jIdx = transJoints.get(i); + Vector3f[] vals = transValues.get(i); + translations[jIdx] = new Vector3f[frameCount]; + for (int f = 0; f < frameCount; f++) { + translations[jIdx][f] = f < vals.length ? new Vector3f(vals[f]) : new Vector3f(vals[vals.length - 1]); + } + } + + return new GltfData.AnimationClip(timestamps, rotations, translations, frameCount); + } + + /** + * Parse a furniture animation clip using the furniture skin's joint mapping. + * Channels targeting nodes outside the furniture skin are ignored. + */ + @Nullable + private static GltfData.AnimationClip parseFurnitureAnimation( + JsonObject animation, + JsonArray accessors, JsonArray bufferViews, + ByteBuffer binData, JsonArray nodes, + int furnitureSkinIdx, @Nullable JsonArray skins + ) { + if (skins == null || furnitureSkinIdx < 0) return null; + + JsonObject skin = skins.get(furnitureSkinIdx).getAsJsonObject(); + JsonArray skinJoints = skin.getAsJsonArray("joints"); + int jointCount = skinJoints.size(); + + // Build nodeToJoint mapping for this skin + int[] nodeToJoint = new int[nodes.size()]; + java.util.Arrays.fill(nodeToJoint, -1); + for (int j = 0; j < jointCount; j++) { + int nodeIdx = skinJoints.get(j).getAsInt(); + nodeToJoint[nodeIdx] = j; + } + + // Delegate to the standard animation parsing logic + return parseAnimationWithMapping( + animation, accessors, bufferViews, binData, nodeToJoint, jointCount + ); + } + + /** + * Parse an animation clip using a provided node-to-joint index mapping. + * Mirrors GlbParser.parseAnimation exactly. + */ + @Nullable + private static GltfData.AnimationClip parseAnimationWithMapping( + JsonObject animation, + JsonArray accessors, JsonArray bufferViews, + ByteBuffer binData, + int[] nodeToJoint, int jointCount + ) { + JsonArray channels = animation.getAsJsonArray("channels"); + JsonArray samplers = animation.getAsJsonArray("samplers"); + + List rotJoints = new ArrayList<>(); + List rotTimestamps = new ArrayList<>(); + List rotValues = new ArrayList<>(); + + List transJoints = new ArrayList<>(); + List transTimestamps = new ArrayList<>(); + List transValues = new ArrayList<>(); + + for (JsonElement chElem : channels) { + JsonObject channel = chElem.getAsJsonObject(); + JsonObject target = channel.getAsJsonObject("target"); + if (!target.has("node")) continue; + String path = target.get("path").getAsString(); + + int nodeIdx = target.get("node").getAsInt(); + if (nodeIdx >= nodeToJoint.length || nodeToJoint[nodeIdx] < 0) continue; + int jointIdx = nodeToJoint[nodeIdx]; + + int samplerIdx = channel.get("sampler").getAsInt(); + JsonObject sampler = samplers.get(samplerIdx).getAsJsonObject(); + float[] times = GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, sampler.get("input").getAsInt()); + + if ("rotation".equals(path)) { + float[] quats = GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, sampler.get("output").getAsInt()); + Quaternionf[] qArr = new Quaternionf[times.length]; + for (int i = 0; i < times.length; i++) { + qArr[i] = new Quaternionf( + quats[i * 4], quats[i * 4 + 1], quats[i * 4 + 2], quats[i * 4 + 3] + ); + } + rotJoints.add(jointIdx); + rotTimestamps.add(times); + rotValues.add(qArr); + } else if ("translation".equals(path)) { + float[] vecs = GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, sampler.get("output").getAsInt()); + Vector3f[] tArr = new Vector3f[times.length]; + for (int i = 0; i < times.length; i++) { + tArr[i] = new Vector3f(vecs[i * 3], vecs[i * 3 + 1], vecs[i * 3 + 2]); + } + transJoints.add(jointIdx); + transTimestamps.add(times); + transValues.add(tArr); + } + } + + if (rotJoints.isEmpty() && transJoints.isEmpty()) return null; + + float[] timestamps = !rotTimestamps.isEmpty() ? rotTimestamps.get(0) : transTimestamps.get(0); + int frameCount = timestamps.length; + + Quaternionf[][] rotations = new Quaternionf[jointCount][]; + for (int i = 0; i < rotJoints.size(); i++) { + int jIdx = rotJoints.get(i); + Quaternionf[] vals = rotValues.get(i); + rotations[jIdx] = new Quaternionf[frameCount]; + for (int f = 0; f < frameCount; f++) { + rotations[jIdx][f] = f < vals.length ? vals[f] : vals[vals.length - 1]; + } + } + + Vector3f[][] translations = new Vector3f[jointCount][]; + for (int i = 0; i < transJoints.size(); i++) { + int jIdx = transJoints.get(i); + Vector3f[] vals = transValues.get(i); + translations[jIdx] = new Vector3f[frameCount]; + for (int f = 0; f < frameCount; f++) { + translations[jIdx][f] = f < vals.length ? new Vector3f(vals[f]) : new Vector3f(vals[vals.length - 1]); + } + } + + return new GltfData.AnimationClip(timestamps, rotations, translations, frameCount); + } + + // ======================================================================== + // Coordinate conversion + // ======================================================================== + + /** + * Convert spatial data from glTF space to Minecraft model-def space. + * Same transform as GlbParser: 180 degrees around Z, negating X and Y. + */ + private static void convertToMinecraftSpace( + float[] positions, float[] normals, + Vector3f[] restTranslations, Quaternionf[] restRotations, + Matrix4f[] inverseBindMatrices, int jointCount + ) { + // Vertex positions: negate X and Y + for (int i = 0; i < positions.length; i += 3) { + positions[i] = -positions[i]; + positions[i + 1] = -positions[i + 1]; + } + // Vertex normals: negate X and Y + for (int i = 0; i < normals.length; i += 3) { + normals[i] = -normals[i]; + normals[i + 1] = -normals[i + 1]; + } + // Rest translations: negate X and Y + for (Vector3f t : restTranslations) { + t.x = -t.x; + t.y = -t.y; + } + // Rest rotations: conjugate by 180 deg Z = negate qx and qy + for (Quaternionf q : restRotations) { + q.x = -q.x; + q.y = -q.y; + } + // Inverse bind matrices: C * M * C where C = diag(-1, -1, 1) + Matrix4f C = new Matrix4f().scaling(-1, -1, 1); + Matrix4f temp = new Matrix4f(); + for (Matrix4f ibm : inverseBindMatrices) { + temp.set(C).mul(ibm).mul(C); + ibm.set(temp); + } + } + +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGltfCache.java b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGltfCache.java new file mode 100644 index 0000000..debadd3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGltfCache.java @@ -0,0 +1,95 @@ +package com.tiedup.remake.v2.furniture.client; + +import java.io.InputStream; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.Nullable; +import net.minecraft.client.Minecraft; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.Resource; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Lazy-loading cache for parsed multi-armature furniture GLB data. + * + *

Loads .glb files via Minecraft's ResourceManager on first access and parses them + * with {@link FurnitureGlbParser}. Thread-safe via {@link ConcurrentHashMap}. + * + *

Call {@link #clear()} on resource reload (e.g., F3+T) to invalidate stale entries. + * + *

This class is client-only and must never be referenced from server code. + */ +@OnlyIn(Dist.CLIENT) +public final class FurnitureGltfCache { + + private static final Logger LOGGER = LogManager.getLogger("FurnitureGltf"); + + /** + * Sentinel value stored in the cache when loading fails, to avoid retrying + * broken resources on every frame. + */ + private static final FurnitureGltfData FAILED_SENTINEL = new FurnitureGltfData( + null, + Map.of(), + Map.of(), + Map.of() + ); + + private static final Map CACHE = new ConcurrentHashMap<>(); + + private FurnitureGltfCache() {} + + /** + * Get parsed furniture GLB data for a resource, loading and parsing on first access. + * + * @param modelLocation resource location of the .glb file + * (e.g., {@code tiedup:models/furniture/wooden_stocks.glb}) + * @return parsed {@link FurnitureGltfData}, or {@code null} if loading/parsing failed + */ + @Nullable + public static FurnitureGltfData get(ResourceLocation modelLocation) { + FurnitureGltfData cached = CACHE.computeIfAbsent(modelLocation, FurnitureGltfCache::load); + return cached == FAILED_SENTINEL ? null : cached; + } + + /** + * Load and parse a furniture GLB from the resource manager. + * + * @return parsed data, or the {@link #FAILED_SENTINEL} on failure + */ + private static FurnitureGltfData load(ResourceLocation loc) { + try { + Resource resource = Minecraft.getInstance() + .getResourceManager() + .getResource(loc) + .orElse(null); + if (resource == null) { + LOGGER.error("[FurnitureGltf] Resource not found: {}", loc); + return FAILED_SENTINEL; + } + + try (InputStream is = resource.open()) { + FurnitureGltfData data = FurnitureGlbParser.parse(is, loc.toString()); + LOGGER.debug("[FurnitureGltf] Cached: {}", loc); + return data; + } + } catch (Exception e) { + LOGGER.error("[FurnitureGltf] Failed to load furniture GLB: {}", loc, e); + return FAILED_SENTINEL; + } + } + + /** + * Clear all cached data. Call on resource reload (F3+T) or dimension change. + */ + public static void clear() { + int size = CACHE.size(); + CACHE.clear(); + if (size > 0) { + LOGGER.info("[FurnitureGltf] Cache cleared ({} entries)", size); + } + } +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGltfData.java b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGltfData.java new file mode 100644 index 0000000..f732da6 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGltfData.java @@ -0,0 +1,56 @@ +package com.tiedup.remake.v2.furniture.client; + +import com.tiedup.remake.client.gltf.GltfData; +import java.util.Map; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +/** + * Parsed multi-armature GLB data for a furniture piece. + * + *

A furniture GLB may contain: + *

    + *
  • A furniture armature with mesh/skeleton for the furniture itself
  • + *
  • Zero or more Player_* armatures defining seat positions and player animations
  • + *
+ * + *

The furniture mesh is parsed into a standard {@link GltfData} for rendering via the + * existing {@code GltfMeshRenderer}. Seat transforms and animations are extracted from + * Player_* armatures and keyed by seat ID (derived from armature name, e.g. + * {@code "Player_main"} becomes seat ID {@code "main"}). + */ +@OnlyIn(Dist.CLIENT) +public record FurnitureGltfData( + /** Furniture mesh data (renderable via GltfMeshRenderer). */ + GltfData furnitureMesh, + + /** Per-seat root transforms from Player_* armatures: seatId to transform. */ + Map seatTransforms, + + /** Per-seat player animations: seatId to (animName to clip). */ + Map> seatAnimations, + + /** + * Per-seat player skeleton data (for GltfPoseConverter). + * Joints are filtered through {@code GltfBoneMapper.isKnownBone()}, + * rest poses are converted to Minecraft space, and animations are + * remapped to match the filtered joint indices. + */ + Map seatSkeletons +) { + /** + * Root transform of a Player_* armature, defining where a seated player is + * positioned and oriented relative to the furniture origin. + * + * @param seatId seat identifier (e.g., "main", "left") + * @param position translation offset in glTF space (meters, Y-up) + * @param rotation orientation quaternion in glTF space + */ + public record SeatTransform( + String seatId, + Vector3f position, + Quaternionf rotation + ) {} +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureSeatPositionHelper.java b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureSeatPositionHelper.java new file mode 100644 index 0000000..a181774 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureSeatPositionHelper.java @@ -0,0 +1,73 @@ +package com.tiedup.remake.v2.furniture.client; + +import com.tiedup.remake.v2.furniture.FurnitureDefinition; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import org.jetbrains.annotations.Nullable; +import org.joml.Vector3f; + +/** + * Client-only helper to resolve seat world positions from parsed GLB data. + * + *

This class exists to isolate the {@link FurnitureGltfCache} dependency + * from {@link com.tiedup.remake.v2.furniture.EntityFurniture EntityFurniture}, + * which is loaded on both client and dedicated server. The dedicated server + * never touches this class, preventing classloader errors from the + * {@code @OnlyIn(Dist.CLIENT)} cache/parser classes.

+ */ +@OnlyIn(Dist.CLIENT) +public final class FurnitureSeatPositionHelper { + + private FurnitureSeatPositionHelper() {} + + /** + * Look up the seat transform from the parsed GLB data and compute + * the world position for a passenger in that seat. + * + * @param def the furniture definition (provides modelLocation) + * @param seatId the seat identifier to look up + * @param furnitureX furniture entity X position + * @param furnitureY furniture entity Y position + * @param furnitureZ furniture entity Z position + * @param furnitureYRot furniture entity Y rotation in degrees + * @return the world position [x, y, z] for the passenger, or null if + * the GLB data or seat transform is unavailable + */ + @Nullable + public static double[] getSeatWorldPosition( + FurnitureDefinition def, + String seatId, + double furnitureX, double furnitureY, double furnitureZ, + float furnitureYRot + ) { + ResourceLocation modelLoc = def.modelLocation(); + if (modelLoc == null) return null; + + FurnitureGltfData gltfData = FurnitureGltfCache.get(modelLoc); + if (gltfData == null) return null; + + FurnitureGltfData.SeatTransform transform = gltfData.seatTransforms().get(seatId); + if (transform == null) return null; + + // The seat transform position is in Minecraft model space (post-conversion): + // X and Y are negated from glTF. We need to rotate by the entity's yaw. + Vector3f pos = transform.position(); + float yawRad = (float) Math.toRadians(-furnitureYRot); + float cos = (float) Math.cos(yawRad); + float sin = (float) Math.sin(yawRad); + + // Rotate the local seat offset by the furniture's yaw around the Y axis + float sx = pos.x(); + float sy = pos.y(); + float sz = pos.z(); + float rx = sx * cos - sz * sin; + float rz = sx * sin + sz * cos; + + return new double[] { + furnitureX + rx, + furnitureY + sy, + furnitureZ + rz + }; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureEscape.java b/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureEscape.java new file mode 100644 index 0000000..bf82958 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureEscape.java @@ -0,0 +1,536 @@ +package com.tiedup.remake.v2.furniture.network; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.ItemLockpick; +import com.tiedup.remake.minigame.ContinuousStruggleMiniGameState; +import com.tiedup.remake.minigame.LockpickMiniGameState; +import com.tiedup.remake.minigame.LockpickSessionManager; +import com.tiedup.remake.minigame.StruggleSessionManager; +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.network.PacketRateLimiter; +import com.tiedup.remake.network.minigame.PacketContinuousStruggleState; +import com.tiedup.remake.network.minigame.PacketLockpickMiniGameState; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.IV2BondageItem; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import com.tiedup.remake.v2.furniture.EntityFurniture; +import com.tiedup.remake.v2.furniture.ISeatProvider; +import com.tiedup.remake.v2.furniture.SeatDefinition; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.network.NetworkEvent; + +/** + * Client-to-server packet: seated player initiates a struggle escape, or a + * third party standing nearby initiates a lockpick escape. + * + *

Escape methods: + *

    + *
  • STRUGGLE (0): Sender must BE the seated passenger. Always + * allowed regardless of blocked regions.
  • + *
  • LOCKPICK (1): Sender must NOT be the seated passenger (third + * party standing nearby). Must have a lockpick in their inventory.
  • + *
+ * + *

Difficulty is computed as: {@code total = min(seat.lockedDifficulty + itemBonus, 600)}, + * where {@code itemBonus} is the sum of {@link IV2BondageItem#getEscapeDifficulty(ItemStack)} + * for all equipped items on NON-blocked body regions.

+ * + *

Wire format: int furnitureEntityId (4) + byte escapeMethod (1)

+ * + *

Direction: Client to Server (C2S)

+ */ +public class PacketFurnitureEscape { + + /** Escape method: the seated player struggles to break free. */ + public static final byte METHOD_STRUGGLE = 0; + /** Escape method: a third party lockpicks the seat lock. */ + public static final byte METHOD_LOCKPICK = 1; + + /** Maximum total escape difficulty (cap). */ + private static final int MAX_DIFFICULTY = 600; + + private final int furnitureEntityId; + private final byte escapeMethod; + + public PacketFurnitureEscape(int furnitureEntityId, byte escapeMethod) { + this.furnitureEntityId = furnitureEntityId; + this.escapeMethod = escapeMethod; + } + + // ==================== Codec ==================== + + public static void encode(PacketFurnitureEscape msg, FriendlyByteBuf buf) { + buf.writeInt(msg.furnitureEntityId); + buf.writeByte(msg.escapeMethod); + } + + public static PacketFurnitureEscape decode(FriendlyByteBuf buf) { + return new PacketFurnitureEscape(buf.readInt(), buf.readByte()); + } + + // ==================== Handler ==================== + + public static void handle( + PacketFurnitureEscape msg, + Supplier ctxSupplier + ) { + NetworkEvent.Context ctx = ctxSupplier.get(); + ctx.enqueueWork(() -> handleOnServer(msg, ctx)); + ctx.setPacketHandled(true); + } + + private static void handleOnServer(PacketFurnitureEscape msg, NetworkEvent.Context ctx) { + ServerPlayer sender = ctx.getSender(); + if (sender == null) return; + + // Rate limit: prevent escape spam + if (!PacketRateLimiter.allowPacket(sender, "struggle")) return; + + // Resolve the furniture entity + Entity entity = sender.level().getEntity(msg.furnitureEntityId); + if (entity == null) return; + if (!(entity instanceof ISeatProvider provider)) return; + if (sender.distanceTo(entity) > 5.0) return; + if (!entity.isAlive() || entity.isRemoved()) return; + + // Validate escape method + if (msg.escapeMethod != METHOD_STRUGGLE && msg.escapeMethod != METHOD_LOCKPICK) { + TiedUpMod.LOGGER.warn( + "[PacketFurnitureEscape] Invalid escape method {} from {}", + msg.escapeMethod, sender.getName().getString() + ); + return; + } + + if (msg.escapeMethod == METHOD_STRUGGLE) { + handleStruggle(sender, entity, provider); + } else { + handleLockpick(sender, entity, provider); + } + } + + // ==================== Struggle ==================== + + /** + * Sender must BE the seated passenger. Always allowed regardless of blocked + * regions. + */ + private static void handleStruggle( + ServerPlayer sender, + Entity furnitureEntity, + ISeatProvider provider + ) { + // Sender must be riding this furniture + if (!furnitureEntity.hasPassenger(sender)) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureEscape] Struggle: {} is not a passenger of furniture {}", + sender.getName().getString(), furnitureEntity.getId() + ); + return; + } + + // Find sender's seat + SeatDefinition seat = provider.getSeatForPassenger(sender); + if (seat == null) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureEscape] Struggle: {} has no assigned seat", + sender.getName().getString() + ); + return; + } + + // Seat must be locked (no point struggling if unlocked — just dismount) + if (!provider.isSeatLocked(seat.id())) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureEscape] Struggle: seat '{}' is not locked, no struggle needed", + seat.id() + ); + return; + } + + // Compute difficulty + int baseDifficulty = provider.getLockedDifficulty(seat.id()); + int itemBonus = computeItemDifficultyBonus(sender, provider, seat); + int totalDifficulty = Math.min(baseDifficulty + itemBonus, MAX_DIFFICULTY); + + TiedUpMod.LOGGER.debug( + "[PacketFurnitureEscape] Struggle: {} on seat '{}' — difficulty {} (base {} + items {})", + sender.getName().getString(), seat.id(), + totalDifficulty, baseDifficulty, itemBonus + ); + + // Difficulty 0: immediate escape (no minigame needed) + if (totalDifficulty == 0) { + provider.setSeatLocked(seat.id(), false); + sender.getPersistentData().remove("tiedup_locked_furniture"); + sender.stopRiding(); + + // Broadcast updated state + if (furnitureEntity instanceof EntityFurniture furniture) { + PacketSyncFurnitureState.sendToTracking(furniture); + } + + TiedUpMod.LOGGER.info( + "[PacketFurnitureEscape] {} escaped furniture {} (difficulty was 0)", + sender.getName().getString(), furnitureEntity.getId() + ); + return; + } + + // Respect server config: if struggle minigame is disabled, skip + if (!com.tiedup.remake.core.ModConfig.SERVER.struggleMiniGameEnabled.get()) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureEscape] Struggle minigame disabled by server config" + ); + return; + } + + // Launch struggle minigame session via StruggleSessionManager + StruggleSessionManager manager = StruggleSessionManager.getInstance(); + ContinuousStruggleMiniGameState session = manager.startFurnitureStruggleSession( + sender, furnitureEntity.getId(), seat.id(), totalDifficulty + ); + + if (session != null) { + // Send START packet to open the struggle GUI on the client + ModNetwork.sendToPlayer( + new PacketContinuousStruggleState( + session.getSessionId(), + ContinuousStruggleMiniGameState.UpdateType.START, + session.getCurrentDirection().getIndex(), + session.getCurrentResistance(), + session.getMaxResistance(), + true // locked context + ), + sender + ); + + // Set furniture animation state to STRUGGLE + if (furnitureEntity instanceof EntityFurniture furniture) { + furniture.setAnimState(EntityFurniture.STATE_STRUGGLE); + PacketSyncFurnitureState.sendToTracking(furniture); + } + } + } + + // ==================== Lockpick ==================== + + /** + * Sender must NOT be the seated passenger (third party standing nearby). + * Must have a lockpick item in inventory. Targets the locked occupied seat + * closest to the sender's look direction. + */ + private static void handleLockpick( + ServerPlayer sender, + Entity furnitureEntity, + ISeatProvider provider + ) { + // Sender must NOT be riding this furniture (third party assistance) + if (furnitureEntity.hasPassenger(sender)) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureEscape] Lockpick: {} cannot lockpick while seated", + sender.getName().getString() + ); + return; + } + + // Sender must have a lockpick in their inventory + if (!hasLockpickInInventory(sender)) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureEscape] Lockpick: {} has no lockpick", + sender.getName().getString() + ); + return; + } + + // Use look-direction-based seat targeting (same vector math as + // EntityFurniture.findNearestOccupiedLockableSeat) instead of + // blindly picking the first locked seat. + SeatDefinition targetSeat = findNearestLockedOccupiedSeat(sender, provider, furnitureEntity); + if (targetSeat == null) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureEscape] Lockpick: no locked occupied seat found" + ); + return; + } + + // Find the passenger in this seat (needed for item bonus computation) + Entity passenger = findPassengerInSeat(provider, furnitureEntity, targetSeat.id()); + + // Compute difficulty + int baseDifficulty = provider.getLockedDifficulty(targetSeat.id()); + int itemBonus = 0; + if (passenger instanceof LivingEntity livingPassenger) { + itemBonus = computeItemDifficultyBonus(livingPassenger, provider, targetSeat); + } + int totalDifficulty = Math.min(baseDifficulty + itemBonus, MAX_DIFFICULTY); + + TiedUpMod.LOGGER.debug( + "[PacketFurnitureEscape] Lockpick: {} on seat '{}' -- difficulty {} (base {} + items {})", + sender.getName().getString(), targetSeat.id(), + totalDifficulty, baseDifficulty, itemBonus + ); + + // Difficulty 0: immediate success — unlock + dismount + consume lockpick + if (totalDifficulty == 0) { + completeLockpickSuccess(sender, furnitureEntity, provider, targetSeat, passenger); + return; + } + + // Respect server config: if lockpick minigame is disabled, skip + if (!com.tiedup.remake.core.ModConfig.SERVER.lockpickMiniGameEnabled.get()) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureEscape] Lockpick minigame disabled by server config" + ); + return; + } + + // Find the lockpick to determine remaining uses and sweet spot width + ItemStack lockpickStack = ItemLockpick.findLockpickInInventory(sender); + if (lockpickStack.isEmpty()) return; // double-check; hasLockpickInInventory passed above + + int remainingUses = lockpickStack.getMaxDamage() - lockpickStack.getDamageValue(); + + // Sweet spot width scales inversely with difficulty: harder locks = narrower sweet spot. + // Base width 0.15 at difficulty 1, down to 0.03 at MAX_DIFFICULTY. + float sweetSpotWidth = Math.max(0.03f, 0.15f - (totalDifficulty / (float) MAX_DIFFICULTY) * 0.12f); + + // Start lockpick session via LockpickSessionManager. + // The existing lockpick session uses a targetSlot (BodyRegionV2 ordinal) for + // bondage items. For furniture, we repurpose targetSlot as the furniture entity ID + // and store the seat ID in a context tag so the completion callback can find it. + // For now, we use the simplified approach: start the session and let the existing + // PacketLockpickAttempt handler manage the sweet-spot interaction. On success, + // the furniture-specific completion is handled by a post-session check. + LockpickSessionManager lockpickManager = LockpickSessionManager.getInstance(); + LockpickMiniGameState session = lockpickManager.startLockpickSession( + sender, + furnitureEntity.getId(), // repurpose targetSlot as entity ID + sweetSpotWidth + ); + + if (session == null) { + TiedUpMod.LOGGER.warn( + "[PacketFurnitureEscape] Failed to create lockpick session for {}", + sender.getName().getString() + ); + return; + } + + session.setRemainingUses(remainingUses); + + // Store furniture context in the sender's persistent data so the + // lockpick attempt handler can resolve the furniture on success. + // This is cleaned up when the session ends. + net.minecraft.nbt.CompoundTag ctx = new net.minecraft.nbt.CompoundTag(); + ctx.putInt("furniture_id", furnitureEntity.getId()); + ctx.putString("seat_id", targetSeat.id()); + sender.getPersistentData().put("tiedup_furniture_lockpick_ctx", ctx); + + // Send initial lockpick state to open the minigame GUI on the client + ModNetwork.sendToPlayer( + new PacketLockpickMiniGameState( + session.getSessionId(), + session.getSweetSpotCenter(), + session.getSweetSpotWidth(), + session.getCurrentPosition(), + session.getRemainingUses() + ), + sender + ); + + TiedUpMod.LOGGER.info( + "[PacketFurnitureEscape] {} started lockpick on seat '{}' of furniture {} (difficulty {}, sweet spot width {})", + sender.getName().getString(), targetSeat.id(), + furnitureEntity.getId(), totalDifficulty, sweetSpotWidth + ); + } + + /** + * Complete a successful lockpick: unlock the seat, clear reconnection tag, + * dismount the passenger, consume/damage the lockpick, and broadcast state. + */ + private static void completeLockpickSuccess( + ServerPlayer sender, + Entity furnitureEntity, + ISeatProvider provider, + SeatDefinition targetSeat, + Entity passenger + ) { + provider.setSeatLocked(targetSeat.id(), false); + if (passenger instanceof ServerPlayer passengerPlayer) { + passengerPlayer.getPersistentData().remove("tiedup_locked_furniture"); + } + if (passenger != null) { + passenger.stopRiding(); + } + + // Damage the lockpick (1 durability per successful pick) + ItemStack lockpickStack = ItemLockpick.findLockpickInInventory(sender); + if (!lockpickStack.isEmpty()) { + lockpickStack.setDamageValue(lockpickStack.getDamageValue() + 1); + if (lockpickStack.getDamageValue() >= lockpickStack.getMaxDamage()) { + lockpickStack.shrink(1); + } + } + + // Broadcast updated state + if (furnitureEntity instanceof EntityFurniture furniture) { + PacketSyncFurnitureState.sendToTracking(furniture); + } + + TiedUpMod.LOGGER.info( + "[PacketFurnitureEscape] {} lockpicked seat '{}' on furniture {} (difficulty was 0)", + sender.getName().getString(), targetSeat.id(), furnitureEntity.getId() + ); + } + + // ==================== Helpers ==================== + + /** + * Compute the item difficulty bonus: sum of {@code getEscapeDifficulty(stack)} + * for all V2 bondage items equipped on NON-blocked body regions. + * + *

Only applies if the seat's {@code itemDifficultyBonus} flag is true.

+ */ + private static int computeItemDifficultyBonus( + LivingEntity passenger, + ISeatProvider provider, + SeatDefinition seat + ) { + if (!provider.hasItemDifficultyBonus(seat.id())) { + return 0; + } + + Set blockedRegions = provider.getBlockedRegions(seat.id()); + Map equipped = V2EquipmentHelper.getAllEquipped(passenger); + + int bonus = 0; + for (Map.Entry entry : equipped.entrySet()) { + // Skip items on blocked regions (those are "held" by the furniture) + if (blockedRegions.contains(entry.getKey())) continue; + + ItemStack stack = entry.getValue(); + if (stack.getItem() instanceof IV2BondageItem bondageItem) { + bonus += bondageItem.getEscapeDifficulty(stack); + } + } + + return bonus; + } + + /** + * Check if the sender has a lockpick item anywhere in their inventory. + */ + private static boolean hasLockpickInInventory(ServerPlayer player) { + for (ItemStack stack : player.getInventory().items) { + if (stack.getItem() instanceof ItemLockpick) return true; + } + // Also check offhand + if (player.getOffhandItem().getItem() instanceof ItemLockpick) return true; + return false; + } + + /** + * Find the locked and occupied seat whose approximate world position has the + * smallest angle to the player's look direction. + * + *

Uses the same vector math as + * {@link EntityFurniture#findNearestOccupiedLockableSeat} to ensure + * consistent targeting between key interactions and lockpick attempts.

+ * + * @param player the player performing the lockpick (provides look direction) + * @param provider the seat provider (furniture entity) + * @param furnitureEntity the furniture entity (provides position and yaw) + * @return the best matching seat, or null if no locked occupied seats exist + */ + private static SeatDefinition findNearestLockedOccupiedSeat( + ServerPlayer player, + ISeatProvider provider, + Entity furnitureEntity + ) { + List seats = provider.getSeats(); + if (seats.isEmpty()) return null; + + Vec3 playerPos = player.getEyePosition(); + Vec3 lookDir = player.getLookAngle(); + float yawRad = (float) Math.toRadians(furnitureEntity.getYRot()); + + // Entity-local right axis (perpendicular to facing in the XZ plane) + double rightX = -Math.sin(yawRad + Math.PI / 2.0); + double rightZ = Math.cos(yawRad + Math.PI / 2.0); + + SeatDefinition best = null; + double bestScore = Double.MAX_VALUE; + int seatCount = seats.size(); + + for (int i = 0; i < seatCount; i++) { + SeatDefinition seat = seats.get(i); + + // Only consider locked seats that have a passenger + if (!provider.isSeatLocked(seat.id())) continue; + boolean hasPassenger = false; + for (Entity p : furnitureEntity.getPassengers()) { + SeatDefinition ps = provider.getSeatForPassenger(p); + if (ps != null && ps.id().equals(seat.id())) { + hasPassenger = true; + break; + } + } + if (!hasPassenger) continue; + + // Approximate seat world position along the right axis + double offset = seatCount == 1 ? 0.0 : (i - (seatCount - 1) / 2.0); + Vec3 seatWorldPos = new Vec3( + furnitureEntity.getX() + rightX * offset, + furnitureEntity.getY() + 0.5, + furnitureEntity.getZ() + rightZ * offset + ); + + Vec3 toSeat = seatWorldPos.subtract(playerPos); + double distSq = toSeat.lengthSqr(); + if (distSq < 1e-6) { + best = seat; + bestScore = -1.0; + continue; + } + + Vec3 toSeatNorm = toSeat.normalize(); + double dot = lookDir.dot(toSeatNorm); + double score = -dot; // lower = better (looking more directly at seat) + + if (score < bestScore) { + bestScore = score; + best = seat; + } + } + + return best; + } + + /** + * Find the passenger entity sitting in a specific seat. + */ + private static Entity findPassengerInSeat( + ISeatProvider provider, + Entity furnitureEntity, + String seatId + ) { + for (Entity passenger : furnitureEntity.getPassengers()) { + SeatDefinition passengerSeat = provider.getSeatForPassenger(passenger); + if (passengerSeat != null && passengerSeat.id().equals(seatId)) { + return passenger; + } + } + return null; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureForcemount.java b/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureForcemount.java new file mode 100644 index 0000000..ff0f1ac --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureForcemount.java @@ -0,0 +1,241 @@ +package com.tiedup.remake.v2.furniture.network; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.items.base.ItemCollar; +import com.tiedup.remake.network.PacketRateLimiter; +import com.tiedup.remake.state.IBondageState; +import com.tiedup.remake.util.KidnappedHelper; +import com.tiedup.remake.v2.furniture.EntityFurniture; +import com.tiedup.remake.v2.furniture.FurnitureDefinition; +import com.tiedup.remake.v2.furniture.FurnitureFeedback; +import com.tiedup.remake.v2.furniture.ISeatProvider; +import com.tiedup.remake.v2.furniture.SeatDefinition; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.AABB; +import net.minecraftforge.network.NetworkEvent; + +/** + * Client-to-server packet: master forces a captive onto a furniture seat. + * + *

The sender must own the captive's collar (verified via + * {@link ItemCollar#isOwner(ItemStack, net.minecraft.world.entity.player.Player)}), + * the captive must be alive and within 5 blocks of both sender and furniture, + * and the furniture must have an available seat.

+ * + *

Wire format: int furnitureEntityId (4) + UUID captiveUUID (16)

+ * + *

Direction: Client to Server (C2S)

+ */ +public class PacketFurnitureForcemount { + + private final int furnitureEntityId; + private final UUID captiveUUID; + + public PacketFurnitureForcemount(int furnitureEntityId, UUID captiveUUID) { + this.furnitureEntityId = furnitureEntityId; + this.captiveUUID = captiveUUID; + } + + // ==================== Codec ==================== + + public static void encode(PacketFurnitureForcemount msg, FriendlyByteBuf buf) { + buf.writeInt(msg.furnitureEntityId); + buf.writeUUID(msg.captiveUUID); + } + + public static PacketFurnitureForcemount decode(FriendlyByteBuf buf) { + return new PacketFurnitureForcemount(buf.readInt(), buf.readUUID()); + } + + // ==================== Handler ==================== + + public static void handle( + PacketFurnitureForcemount msg, + Supplier ctxSupplier + ) { + NetworkEvent.Context ctx = ctxSupplier.get(); + ctx.enqueueWork(() -> handleOnServer(msg, ctx)); + ctx.setPacketHandled(true); + } + + private static void handleOnServer(PacketFurnitureForcemount msg, NetworkEvent.Context ctx) { + ServerPlayer sender = ctx.getSender(); + if (sender == null) return; + + // Rate limit: prevent force-mount spam + if (!PacketRateLimiter.allowPacket(sender, "action")) return; + + // Resolve the furniture entity + Entity entity = sender.level().getEntity(msg.furnitureEntityId); + if (entity == null) return; + if (!(entity instanceof ISeatProvider provider)) return; + if (sender.distanceTo(entity) > 5.0) return; + if (!entity.isAlive() || entity.isRemoved()) return; + + // Look up captive by UUID in the sender's level + LivingEntity captive = findCaptiveByUUID(sender, msg.captiveUUID); + if (captive == null) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureForcemount] Captive not found: {}", + msg.captiveUUID + ); + return; + } + + // Captive must be alive + if (!captive.isAlive() || captive.isRemoved()) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureForcemount] Captive is not alive: {}", + captive.getName().getString() + ); + return; + } + + // Captive must be within 5 blocks of both sender and furniture + if (sender.distanceTo(captive) > 5.0) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureForcemount] Captive too far from sender" + ); + return; + } + if (captive.distanceTo(entity) > 5.0) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureForcemount] Captive too far from furniture" + ); + return; + } + + // Verify collar ownership: captive must have a collar owned by sender + IBondageState captiveState = KidnappedHelper.getKidnappedState(captive); + if (captiveState == null || !captiveState.hasCollar()) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureForcemount] Captive has no collar: {}", + captive.getName().getString() + ); + return; + } + + ItemStack collarStack = captiveState.getEquipment(BodyRegionV2.NECK); + if (collarStack.isEmpty() + || !(collarStack.getItem() instanceof ItemCollar collar)) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureForcemount] Invalid collar item on captive" + ); + return; + } + + // Collar must be owned by sender (or sender has admin permission) + if (!collar.isOwner(collarStack, sender) && !sender.hasPermissions(2)) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureForcemount] {} is not the collar owner of {}", + sender.getName().getString(), + captive.getName().getString() + ); + return; + } + + // Find the first available (unoccupied) seat + String availableSeatId = findFirstAvailableSeat(provider, entity); + if (availableSeatId == null) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureForcemount] No available seat on furniture entity {}", + msg.furnitureEntityId + ); + return; + } + + // Force-mount: startRiding triggers EntityFurniture.addPassenger which + // assigns the first available seat automatically. We use force=true to + // bypass any canRide checks on the captive. + boolean success = captive.startRiding(entity, true); + + if (success) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureForcemount] {} force-mounted {} onto furniture {}", + sender.getName().getString(), + captive.getName().getString(), + msg.furnitureEntityId + ); + + // Play mount sound from FurnitureFeedback + if (entity instanceof EntityFurniture furniture) { + FurnitureDefinition def = furniture.getDefinition(); + if (def != null) { + FurnitureFeedback feedback = def.feedback(); + ResourceLocation mountSoundRL = feedback.mountSound(); + if (mountSoundRL != null) { + SoundEvent sound = SoundEvent.createVariableRangeEvent(mountSoundRL); + entity.level().playSound( + null, + entity.getX(), entity.getY(), entity.getZ(), + sound, SoundSource.BLOCKS, + 1.0f, 1.0f + ); + } + } + + // Broadcast updated state to tracking clients + PacketSyncFurnitureState.sendToTracking(furniture); + } + } else { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureForcemount] startRiding failed for {} on furniture {}", + captive.getName().getString(), + msg.furnitureEntityId + ); + } + } + + // ==================== Helpers ==================== + + /** + * Find a living entity by UUID within a reasonable range of the sender. + * Checks players first (O(1) lookup), then falls back to entity search. + */ + private static LivingEntity findCaptiveByUUID(ServerPlayer sender, UUID uuid) { + // Try player lookup first (fast) + net.minecraft.world.entity.player.Player player = + sender.level().getPlayerByUUID(uuid); + if (player != null) return player; + + // Search nearby entities (64 block radius) + AABB searchBox = sender.getBoundingBox().inflate(64); + for (LivingEntity nearby : sender.level() + .getEntitiesOfClass(LivingEntity.class, searchBox)) { + if (nearby.getUUID().equals(uuid)) { + return nearby; + } + } + return null; + } + + /** + * Find the first available (unoccupied) seat on the furniture. + * + * @return the seat ID, or null if all seats are occupied + */ + private static String findFirstAvailableSeat(ISeatProvider provider, Entity furniture) { + for (SeatDefinition seat : provider.getSeats()) { + boolean occupied = false; + for (Entity passenger : furniture.getPassengers()) { + SeatDefinition passengerSeat = provider.getSeatForPassenger(passenger); + if (passengerSeat != null && passengerSeat.id().equals(seat.id())) { + occupied = true; + break; + } + } + if (!occupied) return seat.id(); + } + return null; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureLock.java b/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureLock.java new file mode 100644 index 0000000..c6cfda1 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureLock.java @@ -0,0 +1,188 @@ +package com.tiedup.remake.v2.furniture.network; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.ItemMasterKey; +import com.tiedup.remake.network.PacketRateLimiter; +import com.tiedup.remake.v2.furniture.EntityFurniture; +import com.tiedup.remake.v2.furniture.FurnitureDefinition; +import com.tiedup.remake.v2.furniture.FurnitureFeedback; +import com.tiedup.remake.v2.furniture.ISeatProvider; +import com.tiedup.remake.v2.furniture.SeatDefinition; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.Entity; +import net.minecraftforge.network.NetworkEvent; + +/** + * Client-to-server packet: toggle lock/unlock on a specific furniture seat. + * + *

The sender must hold a key item (ItemMasterKey) in their main hand, + * the seat must be lockable and occupied (someone sitting in it), and the + * sender must be within 5 blocks of the furniture entity.

+ * + *

On success, toggles the lock state and broadcasts a + * {@link PacketSyncFurnitureState} to all tracking clients.

+ * + *

Wire format: int entityId (4) + utf seatId (variable)

+ * + *

Direction: Client to Server (C2S)

+ */ +public class PacketFurnitureLock { + + private final int entityId; + private final String seatId; + + public PacketFurnitureLock(int entityId, String seatId) { + this.entityId = entityId; + this.seatId = seatId; + } + + // ==================== Codec ==================== + + public static void encode(PacketFurnitureLock msg, FriendlyByteBuf buf) { + buf.writeInt(msg.entityId); + buf.writeUtf(msg.seatId); + } + + public static PacketFurnitureLock decode(FriendlyByteBuf buf) { + return new PacketFurnitureLock(buf.readInt(), buf.readUtf()); + } + + // ==================== Handler ==================== + + public static void handle( + PacketFurnitureLock msg, + Supplier ctxSupplier + ) { + NetworkEvent.Context ctx = ctxSupplier.get(); + ctx.enqueueWork(() -> handleOnServer(msg, ctx)); + ctx.setPacketHandled(true); + } + + private static void handleOnServer(PacketFurnitureLock msg, NetworkEvent.Context ctx) { + ServerPlayer sender = ctx.getSender(); + if (sender == null) return; + + // Rate limit: prevent lock toggle spam + if (!PacketRateLimiter.allowPacket(sender, "action")) return; + + // Resolve the target entity + Entity entity = sender.level().getEntity(msg.entityId); + if (entity == null) return; + if (!(entity instanceof ISeatProvider provider)) return; + if (sender.distanceTo(entity) > 5.0) return; + if (!entity.isAlive() || entity.isRemoved()) return; + + // Sender must hold a key item in either hand + boolean hasKey = (sender.getMainHandItem().getItem() instanceof ItemMasterKey) + || (sender.getOffhandItem().getItem() instanceof ItemMasterKey); + if (!hasKey) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureLock] {} does not hold a key item in either hand", + sender.getName().getString() + ); + return; + } + + // Validate the seat exists and is lockable + SeatDefinition seat = findSeatById(provider, msg.seatId); + if (seat == null) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureLock] Seat '{}' not found on entity {}", + msg.seatId, msg.entityId + ); + return; + } + if (!seat.lockable()) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureLock] Seat '{}' is not lockable", + msg.seatId + ); + return; + } + + // Seat must be occupied (someone sitting in it) + if (!isSeatOccupied(provider, entity, msg.seatId)) { + TiedUpMod.LOGGER.debug( + "[PacketFurnitureLock] Seat '{}' is not occupied", + msg.seatId + ); + return; + } + + // Toggle the lock state + boolean wasLocked = provider.isSeatLocked(msg.seatId); + provider.setSeatLocked(msg.seatId, !wasLocked); + + TiedUpMod.LOGGER.debug( + "[PacketFurnitureLock] {} {} seat '{}' on furniture entity {}", + sender.getName().getString(), + wasLocked ? "unlocked" : "locked", + msg.seatId, + msg.entityId + ); + + // Play lock/unlock sound and set animation state + if (entity instanceof EntityFurniture furniture) { + FurnitureDefinition def = furniture.getDefinition(); + if (def != null) { + FurnitureFeedback feedback = def.feedback(); + ResourceLocation soundRL = wasLocked + ? feedback.unlockSound() + : feedback.lockSound(); + if (soundRL != null) { + SoundEvent sound = SoundEvent.createVariableRangeEvent(soundRL); + entity.level().playSound( + null, // null = play for all nearby players + entity.getX(), entity.getY(), entity.getZ(), + sound, SoundSource.BLOCKS, + 1.0f, 1.0f + ); + } + } + + // Set lock/unlock animation state. The next updateAnimState() call + // (from tick or passenger change) will reset it to OCCUPIED/IDLE. + boolean nowLocked = !wasLocked; + furniture.setAnimState(nowLocked + ? EntityFurniture.STATE_LOCKING + : EntityFurniture.STATE_UNLOCKING); + + // Broadcast updated state to all tracking clients + PacketSyncFurnitureState.sendToTracking(furniture); + } + } + + // ==================== Helpers ==================== + + /** + * Find a SeatDefinition by ID from the provider's seat list. + */ + private static SeatDefinition findSeatById(ISeatProvider provider, String seatId) { + for (SeatDefinition seat : provider.getSeats()) { + if (seat.id().equals(seatId)) return seat; + } + return null; + } + + /** + * Check if a seat is occupied by any passenger. + */ + private static boolean isSeatOccupied( + ISeatProvider provider, + Entity furnitureEntity, + String seatId + ) { + for (Entity passenger : furnitureEntity.getPassengers()) { + SeatDefinition passengerSeat = provider.getSeatForPassenger(passenger); + if (passengerSeat != null && passengerSeat.id().equals(seatId)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/network/PacketSyncFurnitureDefinitions.java b/src/main/java/com/tiedup/remake/v2/furniture/network/PacketSyncFurnitureDefinitions.java new file mode 100644 index 0000000..55e4392 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/network/PacketSyncFurnitureDefinitions.java @@ -0,0 +1,308 @@ +package com.tiedup.remake.v2.furniture.network; + +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.furniture.FurnitureDefinition; +import com.tiedup.remake.v2.furniture.FurnitureFeedback; +import com.tiedup.remake.v2.furniture.FurnitureRegistry; +import com.tiedup.remake.v2.furniture.SeatDefinition; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.fml.loading.FMLEnvironment; +import net.minecraftforge.network.NetworkEvent; +import net.minecraftforge.network.PacketDistributor; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Server-to-client packet that syncs ALL furniture definitions from the + * {@link FurnitureRegistry} to a client. + * + *

Sent on player login and after {@code /reload}. The client handler + * calls {@link FurnitureRegistry#reload(Map)} with the deserialized + * definitions, replacing its entire local cache.

+ * + *

Wire format: varint definition count, then for each definition + * the full set of fields including nested {@link SeatDefinition} list + * and {@link FurnitureFeedback} optional sounds.

+ * + *

Direction: Server to Client (S2C)

+ */ +public class PacketSyncFurnitureDefinitions { + + private static final Logger LOGGER = LogManager.getLogger("PacketSyncFurnitureDefinitions"); + + /** + * Safety cap on the number of definitions to prevent memory exhaustion + * from malformed or malicious packets. + */ + private static final int MAX_DEFINITIONS = 10_000; + + /** + * Safety cap on the number of seats per furniture definition. + */ + private static final int MAX_SEATS = 64; + + /** + * Safety cap on the number of tint channels per definition. + */ + private static final int MAX_TINT_CHANNELS = 32; + + /** + * Safety cap on the number of blocked regions per seat. + */ + private static final int MAX_BLOCKED_REGIONS = BodyRegionV2.values().length; + + private final Map definitions; + + public PacketSyncFurnitureDefinitions(Map definitions) { + this.definitions = definitions; + } + + // ==================== Codec ==================== + + public static void encode(PacketSyncFurnitureDefinitions msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.definitions.size()); + + for (FurnitureDefinition def : msg.definitions.values()) { + // Identity + buf.writeResourceLocation(def.id()); + buf.writeUtf(def.displayName()); + + // Optional translation key + boolean hasTranslationKey = def.translationKey() != null; + buf.writeBoolean(hasTranslationKey); + if (hasTranslationKey) { + buf.writeUtf(def.translationKey()); + } + + // Model + buf.writeResourceLocation(def.modelLocation()); + + // Tint channels + buf.writeVarInt(def.tintChannels().size()); + for (Map.Entry entry : def.tintChannels().entrySet()) { + buf.writeUtf(entry.getKey()); + buf.writeInt(entry.getValue()); + } + + // Booleans and floats for placement/physics + buf.writeBoolean(def.supportsColor()); + buf.writeFloat(def.hitboxWidth()); + buf.writeFloat(def.hitboxHeight()); + buf.writeBoolean(def.snapToWall()); + buf.writeBoolean(def.floorOnly()); + buf.writeBoolean(def.lockable()); + buf.writeFloat(def.breakResistance()); + buf.writeBoolean(def.dropOnBreak()); + + // Seats + buf.writeVarInt(def.seats().size()); + for (SeatDefinition seat : def.seats()) { + encodeSeat(seat, buf); + } + + // Feedback (6 optional ResourceLocations) + encodeFeedback(def.feedback(), buf); + + // Category + buf.writeUtf(def.category()); + + // Icon (optional model ResourceLocation) + writeOptionalRL(buf, def.icon()); + } + } + + private static void encodeSeat(SeatDefinition seat, FriendlyByteBuf buf) { + buf.writeUtf(seat.id()); + buf.writeUtf(seat.armatureName()); + + // Blocked regions as string names + buf.writeVarInt(seat.blockedRegions().size()); + for (BodyRegionV2 region : seat.blockedRegions()) { + buf.writeUtf(region.name()); + } + + buf.writeBoolean(seat.lockable()); + buf.writeVarInt(seat.lockedDifficulty()); + buf.writeBoolean(seat.itemDifficultyBonus()); + } + + private static void encodeFeedback(FurnitureFeedback feedback, FriendlyByteBuf buf) { + writeOptionalRL(buf, feedback.mountSound()); + writeOptionalRL(buf, feedback.lockSound()); + writeOptionalRL(buf, feedback.unlockSound()); + writeOptionalRL(buf, feedback.struggleLoopSound()); + writeOptionalRL(buf, feedback.escapeSound()); + writeOptionalRL(buf, feedback.deniedSound()); + } + + private static void writeOptionalRL(FriendlyByteBuf buf, ResourceLocation rl) { + buf.writeBoolean(rl != null); + if (rl != null) { + buf.writeResourceLocation(rl); + } + } + + public static PacketSyncFurnitureDefinitions decode(FriendlyByteBuf buf) { + int count = Math.min(buf.readVarInt(), MAX_DEFINITIONS); + Map defs = new HashMap<>(count); + + for (int i = 0; i < count; i++) { + // Identity + ResourceLocation id = buf.readResourceLocation(); + String displayName = buf.readUtf(); + + // Optional translation key + String translationKey = buf.readBoolean() ? buf.readUtf() : null; + + // Model + ResourceLocation modelLocation = buf.readResourceLocation(); + + // Tint channels + int tintCount = Math.min(buf.readVarInt(), MAX_TINT_CHANNELS); + Map tintChannels = new HashMap<>(tintCount); + for (int t = 0; t < tintCount; t++) { + tintChannels.put(buf.readUtf(), buf.readInt()); + } + + // Booleans and floats + boolean supportsColor = buf.readBoolean(); + float hitboxWidth = buf.readFloat(); + float hitboxHeight = buf.readFloat(); + boolean snapToWall = buf.readBoolean(); + boolean floorOnly = buf.readBoolean(); + boolean lockable = buf.readBoolean(); + float breakResistance = buf.readFloat(); + boolean dropOnBreak = buf.readBoolean(); + + // Seats + int seatCount = Math.min(buf.readVarInt(), MAX_SEATS); + List seats = new ArrayList<>(seatCount); + for (int s = 0; s < seatCount; s++) { + seats.add(decodeSeat(buf)); + } + + // Feedback + FurnitureFeedback feedback = decodeFeedback(buf); + + // Category + String category = buf.readUtf(); + + // Icon (optional model ResourceLocation) + ResourceLocation icon = readOptionalRL(buf); + + FurnitureDefinition def = new FurnitureDefinition( + id, displayName, translationKey, modelLocation, + Map.copyOf(tintChannels), supportsColor, + hitboxWidth, hitboxHeight, snapToWall, floorOnly, + lockable, breakResistance, dropOnBreak, + List.copyOf(seats), feedback, category, icon + ); + + defs.put(id, def); + } + + return new PacketSyncFurnitureDefinitions(defs); + } + + private static SeatDefinition decodeSeat(FriendlyByteBuf buf) { + String id = buf.readUtf(); + String armatureName = buf.readUtf(); + + int regionCount = Math.min(buf.readVarInt(), MAX_BLOCKED_REGIONS); + Set blockedRegions = new HashSet<>(regionCount); + for (int r = 0; r < regionCount; r++) { + BodyRegionV2 region = BodyRegionV2.fromName(buf.readUtf()); + if (region != null) { + blockedRegions.add(region); + } + // Silently skip unknown region names for forward compatibility + } + + boolean lockable = buf.readBoolean(); + int lockedDifficulty = buf.readVarInt(); + boolean itemDifficultyBonus = buf.readBoolean(); + + return new SeatDefinition( + id, armatureName, Set.copyOf(blockedRegions), + lockable, lockedDifficulty, itemDifficultyBonus + ); + } + + private static FurnitureFeedback decodeFeedback(FriendlyByteBuf buf) { + ResourceLocation mountSound = readOptionalRL(buf); + ResourceLocation lockSound = readOptionalRL(buf); + ResourceLocation unlockSound = readOptionalRL(buf); + ResourceLocation struggleLoopSound = readOptionalRL(buf); + ResourceLocation escapeSound = readOptionalRL(buf); + ResourceLocation deniedSound = readOptionalRL(buf); + + return new FurnitureFeedback( + mountSound, lockSound, unlockSound, + struggleLoopSound, escapeSound, deniedSound + ); + } + + private static ResourceLocation readOptionalRL(FriendlyByteBuf buf) { + return buf.readBoolean() ? buf.readResourceLocation() : null; + } + + // ==================== Handler ==================== + + public static void handle( + PacketSyncFurnitureDefinitions msg, + Supplier ctxSupplier + ) { + NetworkEvent.Context ctx = ctxSupplier.get(); + ctx.enqueueWork(() -> { + if (FMLEnvironment.dist == Dist.CLIENT) { + handleOnClient(msg); + } + }); + ctx.setPacketHandled(true); + } + + @OnlyIn(Dist.CLIENT) + private static void handleOnClient(PacketSyncFurnitureDefinitions msg) { + FurnitureRegistry.reload(msg.definitions); + LOGGER.debug("Client received {} furniture definitions from server", + msg.definitions.size()); + } + + // ==================== Server-side Helpers ==================== + + /** + * Send all current furniture definitions to a single player. + * Call this on player login (PlayerLoggedInEvent) and after /reload. + * + * @param player the player to send definitions to + */ + public static void sendToPlayer(ServerPlayer player) { + ModNetwork.CHANNEL.send( + PacketDistributor.PLAYER.with(() -> player), + new PacketSyncFurnitureDefinitions(FurnitureRegistry.getAllMap()) + ); + } + + /** + * Send all current furniture definitions to every connected player. + * Call this after /reload completes. + */ + public static void sendToAll() { + ModNetwork.CHANNEL.send( + PacketDistributor.ALL.noArg(), + new PacketSyncFurnitureDefinitions(FurnitureRegistry.getAllMap()) + ); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/network/PacketSyncFurnitureState.java b/src/main/java/com/tiedup/remake/v2/furniture/network/PacketSyncFurnitureState.java new file mode 100644 index 0000000..9d7dcd5 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/network/PacketSyncFurnitureState.java @@ -0,0 +1,104 @@ +package com.tiedup.remake.v2.furniture.network; + +import com.tiedup.remake.network.ModNetwork; +import com.tiedup.remake.v2.furniture.EntityFurniture; +import java.util.function.Supplier; +import net.minecraft.client.Minecraft; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.Level; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.fml.loading.FMLEnvironment; +import net.minecraftforge.network.NetworkEvent; + +/** + * Server-to-client packet that syncs a furniture entity's lock bitmask and + * animation state. + * + *

While {@link EntityFurniture} uses {@code SynchedEntityData} for these + * fields (which handles baseline sync), this packet provides an immediate, + * explicit update when the server modifies lock state or animation state + * (e.g., after a lock toggle interaction or a struggle minigame state change). + * SynchedEntityData batches dirty entries once per tick, so this packet + * ensures clients see the change within the same network flush.

+ * + *

Wire format (6 bytes): int entityId (4) + byte lockBits (1) + byte animState (1)

+ * + *

Direction: Server to Client (S2C)

+ */ +public class PacketSyncFurnitureState { + + private final int entityId; + private final byte lockBits; + private final byte animState; + + public PacketSyncFurnitureState(int entityId, byte lockBits, byte animState) { + this.entityId = entityId; + this.lockBits = lockBits; + this.animState = animState; + } + + // ==================== Codec ==================== + + public static void encode(PacketSyncFurnitureState msg, FriendlyByteBuf buf) { + buf.writeInt(msg.entityId); + buf.writeByte(msg.lockBits); + buf.writeByte(msg.animState); + } + + public static PacketSyncFurnitureState decode(FriendlyByteBuf buf) { + return new PacketSyncFurnitureState( + buf.readInt(), + buf.readByte(), + buf.readByte() + ); + } + + // ==================== Handler ==================== + + public static void handle( + PacketSyncFurnitureState msg, + Supplier ctxSupplier + ) { + NetworkEvent.Context ctx = ctxSupplier.get(); + ctx.enqueueWork(() -> { + if (FMLEnvironment.dist == Dist.CLIENT) { + handleOnClient(msg); + } + }); + ctx.setPacketHandled(true); + } + + @OnlyIn(Dist.CLIENT) + private static void handleOnClient(PacketSyncFurnitureState msg) { + Level level = Minecraft.getInstance().level; + if (level == null) return; + + Entity entity = level.getEntity(msg.entityId); + if (entity instanceof EntityFurniture furniture) { + furniture.setSeatLockBitsRaw(msg.lockBits); + furniture.setAnimState(msg.animState); + } + } + + // ==================== Server-side Helper ==================== + + /** + * Send the current lock + anim state of the given furniture entity to all + * players tracking it. Call this from the server after modifying lock bits + * or animation state. + * + * @param furniture the furniture entity whose state changed (must be server-side) + */ + public static void sendToTracking(EntityFurniture furniture) { + ModNetwork.sendToTracking( + new PacketSyncFurnitureState( + furniture.getId(), + furniture.getSeatLockBits(), + furniture.getAnimState() + ), + furniture + ); + } +} diff --git a/src/main/java/com/tiedup/remake/worldgen/AbstractKidnapperStructure.java b/src/main/java/com/tiedup/remake/worldgen/AbstractKidnapperStructure.java new file mode 100644 index 0000000..5a7a223 --- /dev/null +++ b/src/main/java/com/tiedup/remake/worldgen/AbstractKidnapperStructure.java @@ -0,0 +1,318 @@ +package com.tiedup.remake.worldgen; + +import com.tiedup.remake.core.TiedUpMod; +import java.util.Optional; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePiecesBuilder; + +/** + * Abstract base class for kidnapper structure types. + * + * Phase 3: Refactoring - Consolidates common structure generation logic + * + * Subclasses: + * - KidnapperCampStructure: Small, 2 tents + * - KidnapperOutpostStructure: Medium, 2-3 cells around HQ + * - KidnapperFortressStructure: Large, 4-6 cells with corridors + * + * All structures share terrain validation: + * - Must be above sea level (Y >= 63) + * - Terrain must be flat enough (configurable per type) + * - No water at the surface + */ +public abstract class AbstractKidnapperStructure extends Structure { + + /** Standard Y offset for most kidnapper structures */ + protected static final int DEFAULT_Y_OFFSET = -2; + + /** Sea level - structures below this are rejected */ + private static final int SEA_LEVEL = 63; + + protected AbstractKidnapperStructure(StructureSettings settings) { + super(settings); + } + + /** + * Get the radius (in blocks) to check for terrain flatness. + * Larger structures need larger check areas. + */ + protected abstract int getCheckRadius(); + + /** + * Get the maximum allowed height difference across sample points. + * Stricter values reject more uneven terrain. + */ + protected abstract int getMaxHeightDifference(); + + /** + * Get the Y offset for this structure type. + * Override for structures with underground sections. + */ + protected int getYOffset() { + return DEFAULT_Y_OFFSET; + } + + @Override + protected Optional findGenerationPoint( + GenerationContext context + ) { + ChunkPos chunkPos = context.chunkPos(); + + int x = chunkPos.getMiddleBlockX(); + int z = chunkPos.getMiddleBlockZ(); + int y = context + .chunkGenerator() + .getFirstOccupiedHeight( + x, + z, + Heightmap.Types.WORLD_SURFACE_WG, + context.heightAccessor(), + context.randomState() + ); + + BlockPos centerPos = new BlockPos(x, y, z); + + // Check 1: Not underground (above sea level) + if (y < SEA_LEVEL) { + TiedUpMod.LOGGER.debug( + "[{}] Skipping at {} - below sea level (y={})", + getClass().getSimpleName(), + centerPos.toShortString(), + y + ); + return Optional.empty(); + } + + // Check 2: Terrain is flat enough + if (!isTerrainFlat(context, centerPos)) { + TiedUpMod.LOGGER.debug( + "[{}] Skipping at {} - terrain not flat enough", + getClass().getSimpleName(), + centerPos.toShortString() + ); + return Optional.empty(); + } + + // Check 3: No water at the surface + if (hasWater(context, centerPos)) { + TiedUpMod.LOGGER.debug( + "[{}] Skipping at {} - water detected", + getClass().getSimpleName(), + centerPos.toShortString() + ); + return Optional.empty(); + } + + return Optional.of( + new GenerationStub(centerPos, builder -> { + Rotation rotation = Rotation.getRandom(context.random()); + generatePieces(builder, context, centerPos, rotation); + }) + ); + } + + /** + * Check if the terrain is flat enough for this structure. + * Samples heights at corners, edges, and midpoints of the footprint. + */ + private boolean isTerrainFlat( + GenerationContext context, + BlockPos centerPos + ) { + int checkRadius = getCheckRadius(); + int centerY = centerPos.getY(); + int minY = centerY; + int maxY = centerY; + + int[][] sampleOffsets = { + { 0, 0 }, // Center + { checkRadius, 0 }, // East + { -checkRadius, 0 }, // West + { 0, checkRadius }, // South + { 0, -checkRadius }, // North + { checkRadius, checkRadius }, // SE + { checkRadius, -checkRadius }, // NE + { -checkRadius, checkRadius }, // SW + { -checkRadius, -checkRadius }, // NW + { checkRadius / 2, checkRadius / 2 }, // Mid SE + { checkRadius / 2, -checkRadius / 2 }, // Mid NE + { -checkRadius / 2, checkRadius / 2 }, // Mid SW + { -checkRadius / 2, -checkRadius / 2 }, // Mid NW + }; + + for (int[] offset : sampleOffsets) { + int sampleX = centerPos.getX() + offset[0]; + int sampleZ = centerPos.getZ() + offset[1]; + + int sampleY = context + .chunkGenerator() + .getFirstOccupiedHeight( + sampleX, + sampleZ, + Heightmap.Types.WORLD_SURFACE_WG, + context.heightAccessor(), + context.randomState() + ); + + minY = Math.min(minY, sampleY); + maxY = Math.max(maxY, sampleY); + } + + return (maxY - minY) <= getMaxHeightDifference(); + } + + /** + * Check if there is water at the structure location. + * Compares WORLD_SURFACE_WG with OCEAN_FLOOR_WG - if the difference > 1, there's water. + * Uses 13 sample points (same as isTerrainFlat) to catch diagonal rivers and edges. + */ + private boolean hasWater(GenerationContext context, BlockPos centerPos) { + int checkRadius = getCheckRadius(); + + // Same 13-point sampling as isTerrainFlat — catches diagonal rivers + int[][] sampleOffsets = { + { 0, 0 }, // Center + { checkRadius, 0 }, // East + { -checkRadius, 0 }, // West + { 0, checkRadius }, // South + { 0, -checkRadius }, // North + { checkRadius, checkRadius }, // SE + { checkRadius, -checkRadius }, // NE + { -checkRadius, checkRadius }, // SW + { -checkRadius, -checkRadius }, // NW + { checkRadius / 2, checkRadius / 2 }, // Mid SE + { checkRadius / 2, -checkRadius / 2 }, // Mid NE + { -checkRadius / 2, checkRadius / 2 }, // Mid SW + { -checkRadius / 2, -checkRadius / 2 }, // Mid NW + }; + + for (int[] offset : sampleOffsets) { + int sampleX = centerPos.getX() + offset[0]; + int sampleZ = centerPos.getZ() + offset[1]; + + int surfaceY = context + .chunkGenerator() + .getFirstOccupiedHeight( + sampleX, + sampleZ, + Heightmap.Types.WORLD_SURFACE_WG, + context.heightAccessor(), + context.randomState() + ); + + int oceanFloorY = context + .chunkGenerator() + .getFirstOccupiedHeight( + sampleX, + sampleZ, + Heightmap.Types.OCEAN_FLOOR_WG, + context.heightAccessor(), + context.randomState() + ); + + if (surfaceY - oceanFloorY > 1) { + return true; + } + } + + return false; + } + + /** + * Generate the structure pieces. + * Subclasses implement this to define their specific layout. + */ + protected abstract void generatePieces( + StructurePiecesBuilder builder, + GenerationContext context, + BlockPos centerPos, + Rotation rotation + ); + + /** + * Get the ground height at a specific position with Y offset applied. + */ + protected int getGenerationHeight(GenerationContext context, int x, int z) { + return ( + context + .chunkGenerator() + .getFirstOccupiedHeight( + x, + z, + Heightmap.Types.WORLD_SURFACE_WG, + context.heightAccessor(), + context.randomState() + ) + + getYOffset() + ); + } + + /** + * Rotate an offset position based on structure rotation. + */ + protected int[] rotateOffset(int x, int z, Rotation rotation) { + return switch (rotation) { + case NONE -> new int[] { x, z }; + case CLOCKWISE_90 -> new int[] { -z, x }; + case CLOCKWISE_180 -> new int[] { -x, -z }; + case COUNTERCLOCKWISE_90 -> new int[] { z, -x }; + }; + } + + /** + * Calculate position for a piece with rotated offset. + */ + protected BlockPos calculatePiecePosition( + GenerationContext context, + BlockPos centerPos, + int offsetX, + int offsetZ, + Rotation rotation + ) { + int[] rotatedOffset = rotateOffset(offsetX, offsetZ, rotation); + int pieceX = centerPos.getX() + rotatedOffset[0]; + int pieceZ = centerPos.getZ() + rotatedOffset[1]; + int pieceY = getGenerationHeight(context, pieceX, pieceZ); + return new BlockPos(pieceX, pieceY, pieceZ); + } + + /** + * Add surrounding pieces around a center position. + * Common pattern for placing cells around a main building. + */ + protected void addSurroundingPieces( + StructurePiecesBuilder builder, + GenerationContext context, + BlockPos centerPos, + Rotation rotation, + int[][] offsets, + String pieceName, + int count + ) { + for (int i = 0; i < count && i < offsets.length; i++) { + int offsetX = offsets[i][0]; + int offsetZ = offsets[i][1]; + + BlockPos piecePos = calculatePiecePosition( + context, + centerPos, + offsetX, + offsetZ, + rotation + ); + builder.addPiece( + new KidnapperCampPiece( + context.structureTemplateManager(), + ResourceLocation.fromNamespaceAndPath("tiedup", pieceName), + piecePos, + rotation + ) + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/worldgen/HangingCagePiece.java b/src/main/java/com/tiedup/remake/worldgen/HangingCagePiece.java new file mode 100644 index 0000000..bc6c82f --- /dev/null +++ b/src/main/java/com/tiedup/remake/worldgen/HangingCagePiece.java @@ -0,0 +1,656 @@ +package com.tiedup.remake.worldgen; + +import com.tiedup.remake.blocks.ModBlocks; +import com.tiedup.remake.blocks.entity.TrappedChestBlockEntity; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.items.ModItems; +import com.tiedup.remake.items.base.BindVariant; +import com.tiedup.remake.items.base.BlindfoldVariant; +import com.tiedup.remake.items.base.GagVariant; +import com.tiedup.remake.v2.V2Blocks; +import com.tiedup.remake.v2.blocks.PetCageBlock; +import com.tiedup.remake.v2.blocks.PetCagePartBlock; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.DoubleTag; +import net.minecraft.nbt.FloatTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.util.RandomSource; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.ChestBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.StructurePiece; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext; +import net.minecraft.world.level.storage.loot.BuiltInLootTables; + +/** + * Structure piece for the hanging cage. + * + * Scans the cave geometry in postProcess() and places: + * - Iron bars column from ceiling down to the top of the cage + * - A Pet Cage (3x3x2 multi-block) suspended in the cave + * - A Damsel entity inside the cage (25% shiny) + */ +public class HangingCagePiece extends StructurePiece { + + private final BlockPos candidatePos; + private final Direction facing; + private boolean placed = false; + + /** Minimum air gap between cave floor and bottom of cage. */ + private static final int MIN_GAP = 4; + /** Height of the cage in blocks. */ + private static final int CAGE_HEIGHT = 2; + /** Minimum cave height: gap + cage + at least 1 iron bar. */ + private static final int MIN_CAVE_HEIGHT = MIN_GAP + CAGE_HEIGHT + 1; + /** Max scan distance up/down from candidate pos. */ + private static final int SCAN_RANGE = 64; + + /** X/Z offsets to try around chunk center. Cage is 3x3 so we space by 4 to avoid overlap. */ + private static final int[][] COLUMN_OFFSETS = { + { 0, 0 }, + { 4, 0 }, + { -4, 0 }, + { 0, 4 }, + { 0, -4 }, + { 4, 4 }, + { -4, 4 }, + { 4, -4 }, + { -4, -4 }, + { 2, 2 }, + { -2, 2 }, + { 2, -2 }, + { -2, -2 }, + }; + + public HangingCagePiece(BlockPos candidatePos, RandomSource random) { + super( + ModStructures.HANGING_CAGE_PIECE.get(), + 0, + makeBoundingBox(candidatePos) + ); + this.candidatePos = candidatePos; + this.facing = Direction.Plane.HORIZONTAL.getRandomDirection(random); + } + + public HangingCagePiece(CompoundTag tag) { + super(ModStructures.HANGING_CAGE_PIECE.get(), tag); + this.candidatePos = new BlockPos( + tag.getInt("CandX"), + tag.getInt("CandY"), + tag.getInt("CandZ") + ); + this.facing = Direction.from2DDataValue(tag.getInt("Facing")); + } + + private static BoundingBox makeBoundingBox(BlockPos pos) { + // Generous bounding box: ±7 covers 13-wide room (±6) + column offsets, vertical covers scan + room + return new BoundingBox( + pos.getX() - 7, + pos.getY() - SCAN_RANGE, + pos.getZ() - 7, + pos.getX() + 7, + pos.getY() + SCAN_RANGE, + pos.getZ() + 7 + ); + } + + @Override + protected void addAdditionalSaveData( + StructurePieceSerializationContext context, + CompoundTag tag + ) { + tag.putInt("CandX", candidatePos.getX()); + tag.putInt("CandY", candidatePos.getY()); + tag.putInt("CandZ", candidatePos.getZ()); + tag.putInt("Facing", facing.get2DDataValue()); + } + + @Override + public void postProcess( + WorldGenLevel level, + net.minecraft.world.level.StructureManager structureManager, + net.minecraft.world.level.chunk.ChunkGenerator chunkGenerator, + RandomSource random, + BoundingBox chunkBB, + ChunkPos chunkPos, + BlockPos pivot + ) { + if (placed) return; + + // Try multiple columns around chunk center to maximize chance of finding a cave. + // A single column has low odds of hitting a cave; 13 columns covers a wide area. + for (int[] offset : COLUMN_OFFSETS) { + int x = candidatePos.getX() + offset[0]; + int z = candidatePos.getZ() + offset[1]; + + if (scanColumnAndPlace(level, random, x, z, chunkBB)) { + placed = true; + return; // Success + } + } + + // Fallback: carve a dungeon room and place the cage inside + carveAndPlaceRoom( + level, + random, + candidatePos.getX(), + candidatePos.getZ(), + chunkBB + ); + placed = true; + } + + /** + * Scan a single column from Y=50 down to Y=-50, looking for caves tall enough. + * @return true if a cage was placed + */ + private boolean scanColumnAndPlace( + WorldGenLevel level, + RandomSource random, + int x, + int z, + BoundingBox chunkBB + ) { + int scanTop = 50; + int scanBottom = -50; + + int ceilingY = -1; + boolean inAir = false; + + for (int y = scanTop; y >= scanBottom; y--) { + BlockState state = level.getBlockState(new BlockPos(x, y, z)); + boolean solid = !state.isAir() && !state.liquid(); + + if (!inAir && !solid) { + // Transition solid -> air: ceiling is at y+1 + ceilingY = y + 1; + inAir = true; + } else if (inAir && solid) { + // Transition air -> solid: floor is at y + int floorY = y; + inAir = false; + + int caveHeight = ceilingY - floorY - 1; + if (caveHeight >= MIN_CAVE_HEIGHT) { + if ( + tryPlaceCage( + level, + random, + x, + z, + floorY, + ceilingY, + chunkBB + ) + ) { + return true; + } + } + + ceilingY = -1; + } + } + return false; + } + + /** + * Attempt to place the cage in a cave with the given floor and ceiling Y. + * @return true if placement succeeded + */ + private boolean tryPlaceCage( + WorldGenLevel level, + RandomSource random, + int x, + int z, + int floorY, + int ceilingY, + BoundingBox chunkBB + ) { + int cageMasterY = floorY + 1 + MIN_GAP; + int cageTopY = cageMasterY + CAGE_HEIGHT; + int numBars = ceilingY - cageTopY; + if (numBars < 1) return false; + + BlockPos masterPos = new BlockPos(x, cageMasterY, z); + + // Validate 3x3x2 footprint is all air + if (!level.getBlockState(masterPos).isAir()) return false; + + BlockPos[] partPositions = PetCageBlock.getPartPositions( + masterPos, + facing + ); + for (BlockPos partPos : partPositions) { + if (!level.getBlockState(partPos).isAir()) return false; + } + + // Place iron bars from ceiling down to top of cage + for (int y = cageTopY; y < ceilingY; y++) { + safeSetBlock( + level, + new BlockPos(x, y, z), + Blocks.IRON_BARS.defaultBlockState(), + chunkBB + ); + } + + // Place Pet Cage master block + BlockState masterState = V2Blocks.PET_CAGE.get() + .defaultBlockState() + .setValue(PetCageBlock.FACING, facing); + safeSetBlock(level, masterPos, masterState, chunkBB); + + // Place Pet Cage part blocks + BlockState partState = V2Blocks.PET_CAGE_PART.get() + .defaultBlockState() + .setValue(PetCagePartBlock.FACING, facing); + for (BlockPos partPos : partPositions) { + safeSetBlock(level, partPos, partState, chunkBB); + } + + if (chunkBB.isInside(masterPos)) { + scheduleDamselSpawn(level, masterPos, random); + } + + TiedUpMod.LOGGER.info( + "[HangingCage] Placed cage in cave at {}", + masterPos.toShortString() + ); + return true; + } + + // RoomLayout and RoomTheme extracted to separate files in this package. + + // ── Fallback room generation ───────────────────────────────────── + + /** + * Fallback: carve a 13x13x12 dungeon room and place the cage inside. + * Randomly selects a theme (Oubliette/Inferno) and layout (Square/Octagonal). + */ + private void carveAndPlaceRoom( + WorldGenLevel level, + RandomSource random, + int centerX, + int centerZ, + BoundingBox chunkBB + ) { + RoomTheme theme = RoomTheme.values()[random.nextInt( + RoomTheme.values().length + )]; + RoomLayout layout = RoomLayout.values()[random.nextInt( + RoomLayout.values().length + )]; + + int ROOM = 13; + int HEIGHT = 12; + int baseX = centerX - 6; + int baseZ = centerZ - 6; + int floorY = candidatePos.getY() - 5; + + // Phase 1: Shell — walls, floor, ceiling, air interior + for (int rx = 0; rx < ROOM; rx++) { + for (int rz = 0; rz < ROOM; rz++) { + if (!layout.isInShape(rx, rz)) continue; + + for (int ry = 0; ry < HEIGHT; ry++) { + BlockPos pos = new BlockPos( + baseX + rx, + floorY + ry, + baseZ + rz + ); + boolean isWall = layout.isWall(rx, rz); + boolean isFloor = ry == 0; + boolean isCeiling = ry == 11; + + if (isFloor) { + if (isWall) { + safeSetBlock( + level, + pos, + theme.wallShellBlock(), + chunkBB + ); + } else { + boolean isEdge = layout.isWallAdjacent(rx, rz); + safeSetBlock( + level, + pos, + theme.floorBlock(random, rx, rz, isEdge), + chunkBB + ); + } + } else if (isCeiling) { + safeSetBlock( + level, + pos, + theme.ceilingBlock(random), + chunkBB + ); + } else if (isWall) { + safeSetBlock( + level, + pos, + theme.wallBlock(random, ry), + chunkBB + ); + } else { + safeSetBlock( + level, + pos, + Blocks.AIR.defaultBlockState(), + chunkBB + ); + } + } + } + } + + // Phase 2: Theme-specific decorations + theme.placeDecorations( + level, + random, + baseX, + baseZ, + floorY, + layout, + chunkBB + ); + + // Phase 2b: Shared structural features + RoomTheme.placeSharedPillars( + level, + random, + baseX, + baseZ, + floorY, + layout, + theme, + chunkBB + ); + RoomTheme.placeSharedFloorScatter( + level, + random, + baseX, + baseZ, + floorY, + layout, + theme, + chunkBB + ); + RoomTheme.placeSharedCeilingDecor( + level, + random, + baseX, + baseZ, + floorY, + layout, + chunkBB + ); + RoomTheme.placeSharedWallLighting( + level, + random, + baseX, + baseZ, + floorY, + layout, + chunkBB + ); + RoomTheme.placeSharedWallBands( + level, + baseX, + baseZ, + floorY, + layout, + theme, + chunkBB + ); + + // Phase 2c: Chests + placeVanillaChest(level, random, baseX, baseZ, floorY, layout, chunkBB); + placeTrappedChest(level, random, baseX, baseZ, floorY, layout, chunkBB); + + // Phase 3: Iron bars from ry=7 to ry=10 at center [6,6] + for (int ry = 7; ry <= 10; ry++) { + safeSetBlock( + level, + new BlockPos(centerX, floorY + ry, centerZ), + Blocks.IRON_BARS.defaultBlockState(), + chunkBB + ); + } + + // Phase 4: Pet Cage at [6,6] ry=5 + BlockPos masterPos = new BlockPos(centerX, floorY + 5, centerZ); + + BlockState masterState = V2Blocks.PET_CAGE.get() + .defaultBlockState() + .setValue(PetCageBlock.FACING, facing); + safeSetBlock(level, masterPos, masterState, chunkBB); + + BlockState partState = V2Blocks.PET_CAGE_PART.get() + .defaultBlockState() + .setValue(PetCagePartBlock.FACING, facing); + for (BlockPos partPos : PetCageBlock.getPartPositions( + masterPos, + facing + )) { + safeSetBlock(level, partPos, partState, chunkBB); + } + + if (chunkBB.isInside(masterPos)) { + scheduleDamselSpawn(level, masterPos, random); + } + + TiedUpMod.LOGGER.info( + "[HangingCage] Placed cage in carved room (theme={}, layout={}) at {}", + theme.name(), + layout.name(), + masterPos.toShortString() + ); + } + + /** + * Place a vanilla dungeon chest in the 3rd inner corner. + * Uses BuiltInLootTables.SIMPLE_DUNGEON for standard dungeon loot. + */ + private void placeVanillaChest( + WorldGenLevel level, + RandomSource random, + int baseX, + int baseZ, + int floorY, + RoomLayout layout, + BoundingBox chunkBB + ) { + int[][] corners = layout.innerCorners(); + if (corners.length < 3) return; + int[] c = corners[2]; + if (!layout.isInShape(c[0], c[1]) || layout.isWall(c[0], c[1])) return; + + BlockPos chestPos = new BlockPos( + baseX + c[0], + floorY + 1, + baseZ + c[1] + ); + if (!chunkBB.isInside(chestPos)) return; + + // Face chest toward room center + Direction chestFacing; + if (c[0] < 6) chestFacing = Direction.EAST; + else if (c[0] > 6) chestFacing = Direction.WEST; + else if (c[1] < 6) chestFacing = Direction.SOUTH; + else chestFacing = Direction.NORTH; + + BlockState chestState = Blocks.CHEST.defaultBlockState().setValue( + ChestBlock.FACING, + chestFacing + ); + level.setBlock(chestPos, chestState, 2); + + BlockEntity be = level.getBlockEntity(chestPos); + if (be instanceof RandomizableContainerBlockEntity container) { + container.setLootTable( + BuiltInLootTables.SIMPLE_DUNGEON, + random.nextLong() + ); + } + } + + /** + * Place a TiedUp trapped chest in the 4th inner corner. + * Filled with random bondage items (bind, gag, blindfold). + */ + private void placeTrappedChest( + WorldGenLevel level, + RandomSource random, + int baseX, + int baseZ, + int floorY, + RoomLayout layout, + BoundingBox chunkBB + ) { + int[][] corners = layout.innerCorners(); + if (corners.length < 4) return; + int[] c = corners[3]; + if (!layout.isInShape(c[0], c[1]) || layout.isWall(c[0], c[1])) return; + + BlockPos chestPos = new BlockPos( + baseX + c[0], + floorY + 1, + baseZ + c[1] + ); + if (!chunkBB.isInside(chestPos)) return; + + // Face chest toward room center + Direction chestFacing; + if (c[0] < 6) chestFacing = Direction.EAST; + else if (c[0] > 6) chestFacing = Direction.WEST; + else if (c[1] < 6) chestFacing = Direction.SOUTH; + else chestFacing = Direction.NORTH; + + BlockState chestState = ModBlocks.TRAPPED_CHEST.get() + .defaultBlockState() + .setValue(ChestBlock.FACING, chestFacing); + level.setBlock(chestPos, chestState, 2); + + BlockEntity be = level.getBlockEntity(chestPos); + if (be instanceof TrappedChestBlockEntity trappedChest) { + // Random bind + BindVariant[] bindVariants = BindVariant.values(); + BindVariant chosenBind = bindVariants[random.nextInt( + bindVariants.length + )]; + ItemStack bindStack = new ItemStack(ModItems.getBind(chosenBind)); + trappedChest.setBind(bindStack); + + // Random gag (50% chance) + if (random.nextFloat() < 0.50f) { + GagVariant[] gagVariants = GagVariant.values(); + GagVariant chosenGag = gagVariants[random.nextInt( + gagVariants.length + )]; + ItemStack gagStack = new ItemStack(ModItems.getGag(chosenGag)); + trappedChest.setGag(gagStack); + } + + // Random blindfold (30% chance) + if (random.nextFloat() < 0.30f) { + BlindfoldVariant[] bfVariants = BlindfoldVariant.values(); + BlindfoldVariant chosenBf = bfVariants[random.nextInt( + bfVariants.length + )]; + ItemStack bfStack = new ItemStack( + ModItems.getBlindfold(chosenBf) + ); + trappedChest.setBlindfold(bfStack); + } + } + } + + static void safeSetBlock( + WorldGenLevel level, + BlockPos pos, + BlockState state, + BoundingBox chunkBB + ) { + if (chunkBB.isInside(pos)) { + level.setBlock(pos, state, 2); + } + } + + /** + * Write damsel entity NBT directly into the ProtoChunk, avoiding entity + * construction on the worker thread (which deadlocks due to PlayerAnimator). + * The entity will be created naturally when the chunk is promoted to a LevelChunk. + */ + private void scheduleDamselSpawn( + WorldGenLevel level, + BlockPos masterPos, + RandomSource random + ) { + boolean shiny = random.nextFloat() < 0.25f; + String entityId = shiny + ? TiedUpMod.MOD_ID + ":damsel_shiny" + : TiedUpMod.MOD_ID + ":damsel"; + + CompoundTag entityTag = new CompoundTag(); + entityTag.putString("id", entityId); + + // Position: +1Y to be inside cage (above the thin 2px cage floor) + ListTag posList = new ListTag(); + posList.add(DoubleTag.valueOf(masterPos.getX() + 0.5)); + posList.add(DoubleTag.valueOf(masterPos.getY() + 1.0)); + posList.add(DoubleTag.valueOf(masterPos.getZ() + 0.5)); + entityTag.put("Pos", posList); + + // Motion (stationary) + ListTag motionList = new ListTag(); + motionList.add(DoubleTag.valueOf(0.0)); + motionList.add(DoubleTag.valueOf(0.0)); + motionList.add(DoubleTag.valueOf(0.0)); + entityTag.put("Motion", motionList); + + // Rotation (random yaw) + ListTag rotList = new ListTag(); + rotList.add(FloatTag.valueOf(random.nextFloat() * 360F)); + rotList.add(FloatTag.valueOf(0.0F)); + entityTag.put("Rotation", rotList); + + // Persistence + prevent fall death + entityTag.putBoolean("PersistenceRequired", true); + entityTag.putBoolean("OnGround", true); + entityTag.putFloat("FallDistance", 0.0F); + entityTag.putFloat("AbsorptionAmount", 20.0F); + entityTag.putUUID("UUID", java.util.UUID.randomUUID()); + + // Random bind item — the damsel spawns already restrained + BindVariant[] variants = BindVariant.values(); + BindVariant chosenBind = variants[random.nextInt(variants.length)]; + ItemStack bindStack = new ItemStack(ModItems.getBind(chosenBind)); + bindStack.getOrCreateTag().putString("bindMode", "full"); + entityTag.put("Bind", bindStack.save(new CompoundTag())); + + // Add directly to chunk's pending entity list + ChunkAccess chunk = level.getChunk(masterPos); + if (chunk instanceof ProtoChunk protoChunk) { + protoChunk.addEntity(entityTag); + TiedUpMod.LOGGER.info( + "[HangingCage] Scheduled {} damsel with {} at {}", + shiny ? "shiny" : "regular", + chosenBind.getRegistryName(), + masterPos.toShortString() + ); + } + } +} diff --git a/src/main/java/com/tiedup/remake/worldgen/HangingCageStructure.java b/src/main/java/com/tiedup/remake/worldgen/HangingCageStructure.java new file mode 100644 index 0000000..40e5bab --- /dev/null +++ b/src/main/java/com/tiedup/remake/worldgen/HangingCageStructure.java @@ -0,0 +1,64 @@ +package com.tiedup.remake.worldgen; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import java.util.Optional; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureType; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePiecesBuilder; + +/** + * Hanging Cage Structure - Underground cage suspended from cave ceiling. + * + * Places a Pet Cage hanging from iron bars in a cave, with a Damsel inside. + * 25% chance of shiny damsel variant. + * The cage must be at least 4 blocks above the cave floor. + */ +public class HangingCageStructure extends Structure { + + public static final Codec CODEC = + RecordCodecBuilder.create(instance -> + instance + .group(settingsCodec(instance)) + .apply(instance, HangingCageStructure::new) + ); + + public HangingCageStructure(StructureSettings settings) { + super(settings); + } + + @Override + protected Optional findGenerationPoint( + GenerationContext context + ) { + // Choose a random Y between -20 and 40 for cave search + int y = context.random().nextIntBetweenInclusive(-20, 40); + + // Center of the chunk + BlockPos pos = new BlockPos( + context.chunkPos().getMiddleBlockX(), + y, + context.chunkPos().getMiddleBlockZ() + ); + + return Optional.of( + new GenerationStub(pos, builder -> { + generatePieces(builder, context, pos); + }) + ); + } + + private void generatePieces( + StructurePiecesBuilder builder, + GenerationContext context, + BlockPos pos + ) { + builder.addPiece(new HangingCagePiece(pos, context.random())); + } + + @Override + public StructureType type() { + return ModStructures.HANGING_CAGE.get(); + } +} diff --git a/src/main/java/com/tiedup/remake/worldgen/KidnapperCampPiece.java b/src/main/java/com/tiedup/remake/worldgen/KidnapperCampPiece.java new file mode 100644 index 0000000..6a34788 --- /dev/null +++ b/src/main/java/com/tiedup/remake/worldgen/KidnapperCampPiece.java @@ -0,0 +1,399 @@ +package com.tiedup.remake.worldgen; + +import com.tiedup.remake.cells.CampOwnership; +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.EntityKidnapper; +import com.tiedup.remake.entities.EntityKidnapperElite; +import com.tiedup.remake.entities.EntityMaid; +import com.tiedup.remake.entities.EntitySlaveTrader; +import com.tiedup.remake.entities.ModEntities; +import java.util.UUID; +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.MobSpawnType; +import net.minecraft.world.level.ServerLevelAccessor; +import net.minecraft.world.level.block.Mirror; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.TemplateStructurePiece; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceType; +import net.minecraft.world.level.levelgen.structure.templatesystem.BlockIgnoreProcessor; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; + +/** + * Structure piece for kidnapper camp tents. + * + * Phase: Kidnapper Revamp - Cell System + * + * Handles data markers: + * - "kidnapper" - Spawns a regular kidnapper + * - "kidnapper_elite" - Spawns an elite kidnapper + * - "slave_trader" - Spawns a slave trader (brain of the camp) + * - "maid" - Spawns a maid (linked to trader) + * - "loot" - Places a loot chest + */ +public class KidnapperCampPiece extends TemplateStructurePiece { + + /** Temporary storage for trader UUID during structure generation */ + @Nullable + private UUID spawnedTraderUUID; + + /** Temporary storage for camp data during structure generation */ + @Nullable + private CampOwnership.CampData currentCamp; + + public KidnapperCampPiece( + StructureTemplateManager templateManager, + ResourceLocation templateLocation, + BlockPos pos, + Rotation rotation + ) { + super( + ModStructures.CAMP_PIECE.get(), + 0, + templateManager, + templateLocation, + templateLocation.toString(), + makeSettings(rotation), + pos + ); + } + + public KidnapperCampPiece( + StructureTemplateManager templateManager, + CompoundTag tag + ) { + super(ModStructures.CAMP_PIECE.get(), tag, templateManager, location -> + makeSettings(Rotation.NONE) + ); + } + + private static StructurePlaceSettings makeSettings(Rotation rotation) { + return new StructurePlaceSettings() + .setRotation(rotation) + .setMirror(Mirror.NONE) + .setRotationPivot(BlockPos.ZERO) + .addProcessor(BlockIgnoreProcessor.STRUCTURE_BLOCK) + .addProcessor(new MarkerProcessor()); + } + + @Override + protected void handleDataMarker( + String marker, + BlockPos pos, + ServerLevelAccessor level, + RandomSource random, + BoundingBox box + ) { + // Handle data markers from structure blocks + // Data markers are placed using jigsaw blocks or structure blocks with "Data" mode + + if (!(level instanceof ServerLevel serverLevel)) { + return; + } + + switch (marker.toLowerCase()) { + case "kidnapper" -> { + // Spawn a kidnapper at this position + spawnKidnapper(serverLevel, pos, random, false); + } + case "kidnapper_elite" -> { + // Spawn elite kidnapper at this position + spawnKidnapper(serverLevel, pos, random, true); + } + case "slave_trader" -> { + // Spawn slave trader (brain of camp) + spawnSlaveTrader(serverLevel, pos, random); + } + case "maid" -> { + // Spawn maid (linked to trader) + spawnMaid(serverLevel, pos, random); + } + case "loot" -> { + // TODO: Place loot chest + } + default -> { + TiedUpMod.LOGGER.debug( + "[KidnapperCampPiece] Unknown data marker: {} at {}", + marker, + pos.toShortString() + ); + } + } + } + + /** + * Spawn a kidnapper at the given position. + * + * @param level The server level + * @param pos Position to spawn at + * @param random Random source + * @param elite Whether to spawn an elite kidnapper + */ + private void spawnKidnapper( + ServerLevel level, + BlockPos pos, + RandomSource random, + boolean elite + ) { + EntityKidnapper kidnapper; + + if (elite) { + kidnapper = ModEntities.KIDNAPPER_ELITE.get().create(level); + } else { + kidnapper = ModEntities.KIDNAPPER.get().create(level); + } + + if (kidnapper != null) { + kidnapper.moveTo( + pos.getX() + 0.5, + pos.getY(), + pos.getZ() + 0.5, + random.nextFloat() * 360F, + 0.0F + ); + kidnapper.finalizeSpawn( + level, + level.getCurrentDifficultyAt(pos), + MobSpawnType.STRUCTURE, + null, + null + ); + level.addFreshEntity(kidnapper); + + TiedUpMod.LOGGER.info( + "[KidnapperCampPiece] Spawned {} at {} from data marker", + elite ? "elite kidnapper" : "kidnapper", + pos.toShortString() + ); + } + } + + /** + * Spawn a slave trader at the given position. + * Creates/updates the camp data in CampOwnership. + * + * @param level The server level + * @param pos Position to spawn at + * @param random Random source + */ + private void spawnSlaveTrader( + ServerLevel level, + BlockPos pos, + RandomSource random + ) { + EntitySlaveTrader trader = ModEntities.SLAVE_TRADER.get().create(level); + if (trader == null) { + TiedUpMod.LOGGER.warn( + "[KidnapperCampPiece] Failed to create SlaveTrader entity" + ); + return; + } + + trader.moveTo( + pos.getX() + 0.5, + pos.getY(), + pos.getZ() + 0.5, + random.nextFloat() * 360F, + 0.0F + ); + trader.finalizeSpawn( + level, + level.getCurrentDifficultyAt(pos), + MobSpawnType.STRUCTURE, + null, + null + ); + + // Create or get the camp in CampOwnership registry + CampOwnership registry = CampOwnership.get(level); + CampOwnership.CampData camp = registry.findNearestAliveCamp(pos, 50); + + if (camp == null) { + // Create a new camp + UUID campId = UUID.randomUUID(); + registry.registerCamp(campId, trader.getUUID(), null, pos); + camp = registry.getCamp(campId); + } else { + // Update existing camp with this trader + camp.setTraderUUID(trader.getUUID()); + camp.setCenter(pos); + registry.setDirty(); + } + + // Link trader to camp + if (camp != null) { + trader.setCampUUID(camp.getCampId()); + this.currentCamp = camp; + } + + // Store for maid linking + this.spawnedTraderUUID = trader.getUUID(); + + level.addFreshEntity(trader); + + TiedUpMod.LOGGER.info( + "[KidnapperCampPiece] Spawned slave trader {} at {} from data marker, camp={}", + trader.getNpcName(), + pos.toShortString(), + camp != null ? camp.getCampId().toString().substring(0, 8) : "null" + ); + } + + /** + * Spawn a maid at the given position. + * Must be called AFTER trader spawns to establish link. + * + * @param level The server level + * @param pos Position to spawn at + * @param random Random source + */ + private void spawnMaid( + ServerLevel level, + BlockPos pos, + RandomSource random + ) { + EntityMaid maid = ModEntities.MAID.get().create(level); + if (maid == null) { + TiedUpMod.LOGGER.warn( + "[KidnapperCampPiece] Failed to create Maid entity" + ); + return; + } + + maid.moveTo( + pos.getX() + 0.5, + pos.getY(), + pos.getZ() + 0.5, + random.nextFloat() * 360F, + 0.0F + ); + maid.finalizeSpawn( + level, + level.getCurrentDifficultyAt(pos), + MobSpawnType.STRUCTURE, + null, + null + ); + + // Link to trader (must be spawned after trader) + boolean linkedSuccessfully = false; + + if (this.spawnedTraderUUID != null) { + maid.setMasterTraderUUID(this.spawnedTraderUUID); + + // Find the trader entity and update its maid reference + Entity traderEntity = level.getEntity(this.spawnedTraderUUID); + if (traderEntity instanceof EntitySlaveTrader trader) { + trader.setMaidUUID(maid.getUUID()); + linkedSuccessfully = true; + + TiedUpMod.LOGGER.debug( + "[KidnapperCampPiece] Linked maid {} to trader {}", + maid.getNpcName(), + trader.getNpcName() + ); + } + + // Link maid to camp + if (this.currentCamp != null) { + this.currentCamp.setMaidUUID(maid.getUUID()); + CampOwnership.get(level).setDirty(); + } + } + + // Fallback: If no trader link established, search for nearby trader/camp + if (!linkedSuccessfully) { + TiedUpMod.LOGGER.debug( + "[KidnapperCampPiece] Maid at {} - no direct trader link, searching nearby...", + pos.toShortString() + ); + + // Try to find a nearby camp first + CampOwnership registry = CampOwnership.get(level); + CampOwnership.CampData camp = registry.findNearestAliveCamp( + pos, + 50 + ); + + if (camp != null && camp.getTraderUUID() != null) { + // Found a camp with a trader - link to it + maid.setMasterTraderUUID(camp.getTraderUUID()); + camp.setMaidUUID(maid.getUUID()); + registry.setDirty(); + + // Try to update the trader entity too + Entity traderEntity = level.getEntity(camp.getTraderUUID()); + if (traderEntity instanceof EntitySlaveTrader trader) { + trader.setMaidUUID(maid.getUUID()); + linkedSuccessfully = true; + + TiedUpMod.LOGGER.info( + "[KidnapperCampPiece] Maid {} linked to trader {} via camp fallback", + maid.getNpcName(), + trader.getNpcName() + ); + } + } + + // Last resort: search for nearby trader entities + if (!linkedSuccessfully) { + java.util.List nearbyTraders = + level.getEntitiesOfClass( + EntitySlaveTrader.class, + maid.getBoundingBox().inflate(50) + ); + + if (!nearbyTraders.isEmpty()) { + EntitySlaveTrader trader = nearbyTraders.get(0); + maid.setMasterTraderUUID(trader.getUUID()); + trader.setMaidUUID(maid.getUUID()); + linkedSuccessfully = true; + + // Update camp if trader has one + if (trader.getCampUUID() != null) { + CampOwnership.CampData traderCamp = registry.getCamp( + trader.getCampUUID() + ); + if (traderCamp != null) { + traderCamp.setMaidUUID(maid.getUUID()); + registry.setDirty(); + } + } + + TiedUpMod.LOGGER.info( + "[KidnapperCampPiece] Maid {} linked to nearby trader {} via entity search fallback", + maid.getNpcName(), + trader.getNpcName() + ); + } + } + } + + if (!linkedSuccessfully) { + TiedUpMod.LOGGER.warn( + "[KidnapperCampPiece] Maid spawned at {} but no trader found! Maid will be orphaned.", + pos.toShortString() + ); + } + + level.addFreshEntity(maid); + + TiedUpMod.LOGGER.info( + "[KidnapperCampPiece] Spawned maid {} at {} from data marker, linked to trader={}", + maid.getNpcName(), + pos.toShortString(), + this.spawnedTraderUUID != null + ? this.spawnedTraderUUID.toString().substring(0, 8) + : "null" + ); + } +} diff --git a/src/main/java/com/tiedup/remake/worldgen/KidnapperCampStructure.java b/src/main/java/com/tiedup/remake/worldgen/KidnapperCampStructure.java new file mode 100644 index 0000000..40fecb3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/worldgen/KidnapperCampStructure.java @@ -0,0 +1,122 @@ +package com.tiedup.remake.worldgen; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.levelgen.structure.StructureType; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePiecesBuilder; + +/** + * Custom structure that places kidnapper camp tents together. + * + * Phase: Kidnapper Revamp - Cell System + * + * Places three tents: + * - kidnap_tent (with spawner and loot) + * - cell_tent (prisoner cell) + * - kidnap_tent_trader (with trader and maid spawn) + */ +public class KidnapperCampStructure extends AbstractKidnapperStructure { + + public static final Codec CODEC = + RecordCodecBuilder.create(instance -> + instance + .group(settingsCodec(instance)) + .apply(instance, KidnapperCampStructure::new) + ); + + public KidnapperCampStructure(StructureSettings settings) { + super(settings); + } + + @Override + protected int getCheckRadius() { + return 8; + } + + @Override + protected int getMaxHeightDifference() { + return 4; + } + + @Override + protected void generatePieces( + StructurePiecesBuilder builder, + GenerationContext context, + BlockPos centerPos, + Rotation rotation + ) { + // Calculate offsets for tent placement (triangular layout) + int tentOffset = 10; + boolean xFirst = context.random().nextBoolean(); + + // Cell tent offset + int cellOffsetX = xFirst ? tentOffset : 0; + int cellOffsetZ = xFirst ? 0 : tentOffset; + + // Trader tent offset (opposite side or diagonal) + int traderOffsetX = xFirst ? 0 : tentOffset; + int traderOffsetZ = xFirst ? tentOffset : 0; + + // Place kidnap_tent at center + BlockPos kidnapTentPos = calculatePiecePosition( + context, + centerPos, + 0, + 0, + rotation + ); + builder.addPiece( + new KidnapperCampPiece( + context.structureTemplateManager(), + ResourceLocation.fromNamespaceAndPath("tiedup", "kidnap_tent"), + kidnapTentPos, + rotation + ) + ); + + // Place cell_tent with offset + BlockPos cellTentPos = calculatePiecePosition( + context, + centerPos, + cellOffsetX, + cellOffsetZ, + rotation + ); + builder.addPiece( + new KidnapperCampPiece( + context.structureTemplateManager(), + ResourceLocation.fromNamespaceAndPath("tiedup", "cell_tent"), + cellTentPos, + rotation + ) + ); + + // Place kidnap_tent_trader with offset (trader and maid spawn) + BlockPos traderTentPos = calculatePiecePosition( + context, + centerPos, + traderOffsetX, + traderOffsetZ, + rotation + ); + builder.addPiece( + new KidnapperCampPiece( + context.structureTemplateManager(), + ResourceLocation.fromNamespaceAndPath( + "tiedup", + "kidnap_tent_trader" + ), + traderTentPos, + rotation + ) + ); + } + + @Override + public StructureType type() { + return ModStructures.KIDNAPPER_CAMP.get(); + } +} diff --git a/src/main/java/com/tiedup/remake/worldgen/KidnapperFortressStructure.java b/src/main/java/com/tiedup/remake/worldgen/KidnapperFortressStructure.java new file mode 100644 index 0000000..1cc5d21 --- /dev/null +++ b/src/main/java/com/tiedup/remake/worldgen/KidnapperFortressStructure.java @@ -0,0 +1,96 @@ +package com.tiedup.remake.worldgen; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.structure.StructureType; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePiecesBuilder; + +/** + * Kidnapper Fortress - Large, rare structure. + * + * Phase 4: Kidnapper Revamp - Varied Structures + * + * The largest kidnapper structure type. + * Contains: + * - Main fortress building (kidnap_fortress) with elite kidnapper boss, cells, etc. + * + * Very rare spawn, contains valuable loot and dangerous enemies. + * Structure has 9 blocks underground, so Y offset is -9. + * Only spawns on flat terrain (max 3 blocks height difference). + */ +public class KidnapperFortressStructure extends AbstractKidnapperStructure { + + public static final Codec CODEC = + RecordCodecBuilder.create(instance -> + instance + .group(settingsCodec(instance)) + .apply(instance, KidnapperFortressStructure::new) + ); + + public KidnapperFortressStructure(StructureSettings settings) { + super(settings); + } + + @Override + protected int getCheckRadius() { + return 20; + } + + @Override + protected int getMaxHeightDifference() { + return 3; + } + + @Override + protected int getYOffset() { + return -9; + } + + @Override + protected void generatePieces( + StructurePiecesBuilder builder, + GenerationContext context, + BlockPos centerPos, + Rotation rotation + ) { + // Calculate fortress position with custom Y offset (9 blocks underground) + int y = + context + .chunkGenerator() + .getFirstOccupiedHeight( + centerPos.getX(), + centerPos.getZ(), + Heightmap.Types.WORLD_SURFACE_WG, + context.heightAccessor(), + context.randomState() + ) + + getYOffset(); + + BlockPos fortressPos = new BlockPos( + centerPos.getX(), + y, + centerPos.getZ() + ); + + builder.addPiece( + new KidnapperCampPiece( + context.structureTemplateManager(), + ResourceLocation.fromNamespaceAndPath( + "tiedup", + "kidnap_fortress" + ), + fortressPos, + rotation + ) + ); + } + + @Override + public StructureType type() { + return ModStructures.KIDNAPPER_FORTRESS.get(); + } +} diff --git a/src/main/java/com/tiedup/remake/worldgen/KidnapperOutpostStructure.java b/src/main/java/com/tiedup/remake/worldgen/KidnapperOutpostStructure.java new file mode 100644 index 0000000..6cdd23a --- /dev/null +++ b/src/main/java/com/tiedup/remake/worldgen/KidnapperOutpostStructure.java @@ -0,0 +1,78 @@ +package com.tiedup.remake.worldgen; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.levelgen.structure.StructureType; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePiecesBuilder; + +/** + * Kidnapper Outpost - Medium-sized structure. + * + * Phase 4: Kidnapper Revamp - Varied Structures + * + * Single-piece structure that spawns on flat terrain (like fortress). + * Uses kidnap_outpost.nbt template. + * + * Spawns in plains, forests, and other flat biomes. + * Structure is lowered by 2 blocks (default Y_OFFSET). + */ +public class KidnapperOutpostStructure extends AbstractKidnapperStructure { + + public static final Codec CODEC = + RecordCodecBuilder.create(instance -> + instance + .group(settingsCodec(instance)) + .apply(instance, KidnapperOutpostStructure::new) + ); + + public KidnapperOutpostStructure(StructureSettings settings) { + super(settings); + } + + @Override + protected int getCheckRadius() { + return 12; + } + + @Override + protected int getMaxHeightDifference() { + return 3; + } + + @Override + protected void generatePieces( + StructurePiecesBuilder builder, + GenerationContext context, + BlockPos centerPos, + Rotation rotation + ) { + // Single-piece structure: kidnap_outpost.nbt + // Y offset of -2 is applied via calculatePiecePosition + BlockPos outpostPos = calculatePiecePosition( + context, + centerPos, + 0, + 0, + rotation + ); + builder.addPiece( + new KidnapperCampPiece( + context.structureTemplateManager(), + ResourceLocation.fromNamespaceAndPath( + "tiedup", + "kidnap_outpost" + ), + outpostPos, + rotation + ) + ); + } + + @Override + public StructureType type() { + return ModStructures.KIDNAPPER_OUTPOST.get(); + } +} diff --git a/src/main/java/com/tiedup/remake/worldgen/MarkerProcessor.java b/src/main/java/com/tiedup/remake/worldgen/MarkerProcessor.java new file mode 100644 index 0000000..a875c38 --- /dev/null +++ b/src/main/java/com/tiedup/remake/worldgen/MarkerProcessor.java @@ -0,0 +1,238 @@ +package com.tiedup.remake.worldgen; + +import com.mojang.serialization.Codec; +import com.tiedup.remake.blocks.ModBlocks; +import com.tiedup.remake.cells.MarkerType; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtUtils; +import net.minecraft.nbt.Tag; +import net.minecraft.world.level.LevelReader; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessor; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessorType; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; +import org.jetbrains.annotations.Nullable; + +/** + * Structure processor that handles MarkerBlockEntity and CellCoreBlockEntity + * when structures are placed. + * + * Phase: Kidnapper Revamp - Cell System + * + * When a structure containing cell markers or Cell Cores is placed: + * 1. Remaps cellId UUIDs to fresh ones (prevents collisions between structures) + * 2. Clears cellIds from structure markers (ENTRANCE, PATROL, LOOT, SPAWNER) + * 3. Handles Cell Core blocks: remaps cellId, rotates spawnPoint/deliveryPoint offsets + */ +public class MarkerProcessor extends StructureProcessor { + + public static final Codec CODEC = Codec.unit( + MarkerProcessor::new + ); + + /** + * Track cell ID mappings during structure placement. + * Old UUID -> New UUID. + * + * Made instance-based instead of static ThreadLocal to prevent memory leaks. + * Each structure placement gets its own MarkerProcessor instance, so mappings + * are naturally scoped to a single structure and garbage collected when done. + */ + private final Map cellIdMappings = new HashMap<>(); + + public MarkerProcessor() {} + + @Nullable + @Override + public StructureTemplate.StructureBlockInfo processBlock( + LevelReader level, + BlockPos offset, + BlockPos pos, + StructureTemplate.StructureBlockInfo blockInfo, + StructureTemplate.StructureBlockInfo relativeBlockInfo, + StructurePlaceSettings settings + ) { + // Process Cell Core blocks (V2) + if (relativeBlockInfo.state().is(ModBlocks.CELL_CORE.get())) { + return processCellCore(relativeBlockInfo, settings); + } + + // Process marker blocks (V1 + structure markers) + if (!relativeBlockInfo.state().is(ModBlocks.MARKER.get())) { + return relativeBlockInfo; + } + + CompoundTag nbt = relativeBlockInfo.nbt(); + if (nbt == null) { + return relativeBlockInfo; + } + + // Get the marker type + MarkerType markerType = MarkerType.WALL; + if (nbt.contains("markerType")) { + markerType = MarkerType.fromString(nbt.getString("markerType")); + } + + // Structure markers don't need cell IDs - just clear any old one + if (markerType.isStructureMarker()) { + CompoundTag newNbt = nbt.copy(); + newNbt.remove("cellId"); + return new StructureTemplate.StructureBlockInfo( + relativeBlockInfo.pos(), + relativeBlockInfo.state(), + newNbt + ); + } + + // Cell markers need special handling + // If this marker has a cellId, we need to map it to a new one + if (nbt.contains("cellId")) { + UUID oldCellId = nbt.getUUID("cellId"); + + UUID newCellId; + if (cellIdMappings.containsKey(oldCellId)) { + // Use existing mapping + newCellId = cellIdMappings.get(oldCellId); + } else { + // Create new cell ID (actual cell will be created when block entity loads) + newCellId = UUID.randomUUID(); + cellIdMappings.put(oldCellId, newCellId); + } + + // Update the NBT with new cell ID + CompoundTag newNbt = nbt.copy(); + newNbt.putUUID("cellId", newCellId); + + // Rotate cell positions if present (important for structure rotation) + if (newNbt.contains("cellPositions")) { + CompoundTag cellPositions = newNbt.getCompound("cellPositions"); + CompoundTag rotatedPositions = new CompoundTag(); + + for (String key : cellPositions.getAllKeys()) { + ListTag posList = cellPositions.getList( + key, + Tag.TAG_COMPOUND + ); + ListTag rotatedList = new ListTag(); + + for (int i = 0; i < posList.size(); i++) { + BlockPos posOffset = NbtUtils.readBlockPos( + posList.getCompound(i) + ); + BlockPos rotated = posOffset.rotate( + settings.getRotation() + ); + rotatedList.add(NbtUtils.writeBlockPos(rotated)); + } + rotatedPositions.put(key, rotatedList); + } + newNbt.put("cellPositions", rotatedPositions); + + com.tiedup.remake.core.TiedUpMod.LOGGER.info( + "[MarkerProcessor] Rotated cellPositions for cell {}, rotation={}", + newCellId.toString().substring(0, 8), + settings.getRotation() + ); + } + + return new StructureTemplate.StructureBlockInfo( + relativeBlockInfo.pos(), + relativeBlockInfo.state(), + newNbt + ); + } + + return relativeBlockInfo; + } + + /** + * Process a Cell Core block from a structure template. + * Remaps the cellId UUID and rotates relative position offsets. + */ + private StructureTemplate.StructureBlockInfo processCellCore( + StructureTemplate.StructureBlockInfo blockInfo, + StructurePlaceSettings settings + ) { + CompoundTag nbt = blockInfo.nbt(); + if (nbt == null || !nbt.contains("cellId")) { + return blockInfo; + } + + UUID oldCellId = nbt.getUUID("cellId"); + UUID newCellId; + if (cellIdMappings.containsKey(oldCellId)) { + newCellId = cellIdMappings.get(oldCellId); + } else { + newCellId = UUID.randomUUID(); + cellIdMappings.put(oldCellId, newCellId); + } + + CompoundTag newNbt = nbt.copy(); + newNbt.putUUID("cellId", newCellId); + + // Remove old absolute position keys (from pre-offset saves) + newNbt.remove("spawnPoint"); + newNbt.remove("deliveryPoint"); + + // Rotate relative offset positions for structure rotation + rotateOffset(newNbt, "spawnOffset", settings); + rotateOffset(newNbt, "deliveryOffset", settings); + + // Rotate pathWaypoint offsets + if (newNbt.contains("pathWaypointOffsets")) { + ListTag list = newNbt.getList( + "pathWaypointOffsets", + Tag.TAG_COMPOUND + ); + ListTag rotated = new ListTag(); + for (int i = 0; i < list.size(); i++) { + BlockPos offset = NbtUtils.readBlockPos(list.getCompound(i)); + rotated.add( + NbtUtils.writeBlockPos( + offset.rotate(settings.getRotation()) + ) + ); + } + newNbt.put("pathWaypointOffsets", rotated); + } + + com.tiedup.remake.core.TiedUpMod.LOGGER.debug( + "[MarkerProcessor] Remapped Cell Core cellId {} -> {} at {}, rotation={}", + oldCellId.toString().substring(0, 8), + newCellId.toString().substring(0, 8), + blockInfo.pos().toShortString(), + settings.getRotation() + ); + + return new StructureTemplate.StructureBlockInfo( + blockInfo.pos(), + blockInfo.state(), + newNbt + ); + } + + /** + * Rotate a single BlockPos offset stored in NBT for structure rotation. + */ + private static void rotateOffset( + CompoundTag nbt, + String key, + StructurePlaceSettings settings + ) { + if (nbt.contains(key)) { + BlockPos offset = NbtUtils.readBlockPos(nbt.getCompound(key)); + BlockPos rotated = offset.rotate(settings.getRotation()); + nbt.put(key, NbtUtils.writeBlockPos(rotated)); + } + } + + @Override + protected StructureProcessorType getType() { + return ModProcessors.MARKER_PROCESSOR.get(); + } +} diff --git a/src/main/java/com/tiedup/remake/worldgen/ModProcessors.java b/src/main/java/com/tiedup/remake/worldgen/ModProcessors.java new file mode 100644 index 0000000..2d653e3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/worldgen/ModProcessors.java @@ -0,0 +1,28 @@ +package com.tiedup.remake.worldgen; + +import com.tiedup.remake.core.TiedUpMod; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessorType; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.RegistryObject; + +/** + * Registry for custom structure processors. + * + * Phase: Kidnapper Revamp - Cell System + */ +public class ModProcessors { + + public static final DeferredRegister> PROCESSORS = + DeferredRegister.create( + Registries.STRUCTURE_PROCESSOR, + TiedUpMod.MOD_ID + ); + + public static final RegistryObject< + StructureProcessorType + > MARKER_PROCESSOR = PROCESSORS.register( + "marker_processor", + () -> () -> MarkerProcessor.CODEC + ); +} diff --git a/src/main/java/com/tiedup/remake/worldgen/ModStructures.java b/src/main/java/com/tiedup/remake/worldgen/ModStructures.java new file mode 100644 index 0000000..2ea3396 --- /dev/null +++ b/src/main/java/com/tiedup/remake/worldgen/ModStructures.java @@ -0,0 +1,84 @@ +package com.tiedup.remake.worldgen; + +import com.tiedup.remake.core.TiedUpMod; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.level.levelgen.structure.StructureType; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceType; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.RegistryObject; + +/** + * Registry for custom structures. + * + * Phase: Kidnapper Revamp - Cell System + * Phase 4: Added Outpost and Fortress structures + */ +public class ModStructures { + + // Structure Types + public static final DeferredRegister> STRUCTURE_TYPES = + DeferredRegister.create(Registries.STRUCTURE_TYPE, TiedUpMod.MOD_ID); + + // Structure Piece Types + public static final DeferredRegister< + StructurePieceType + > STRUCTURE_PIECE_TYPES = DeferredRegister.create( + Registries.STRUCTURE_PIECE, + TiedUpMod.MOD_ID + ); + + // === Structure Types === + + // Kidnapper Camp - Small structure (2 tents) + public static final RegistryObject< + StructureType + > KIDNAPPER_CAMP = STRUCTURE_TYPES.register( + "kidnapper_camp", + () -> () -> KidnapperCampStructure.CODEC + ); + + // Kidnapper Outpost - Medium structure (main building + 2-3 cells) + public static final RegistryObject< + StructureType + > KIDNAPPER_OUTPOST = STRUCTURE_TYPES.register( + "kidnapper_outpost", + () -> () -> KidnapperOutpostStructure.CODEC + ); + + // Kidnapper Fortress - Large structure (keep + 4-6 cells + corridors, with elite) + public static final RegistryObject< + StructureType + > KIDNAPPER_FORTRESS = STRUCTURE_TYPES.register( + "kidnapper_fortress", + () -> () -> KidnapperFortressStructure.CODEC + ); + + // Hanging Cage - Underground cage suspended from cave ceiling + public static final RegistryObject< + StructureType + > HANGING_CAGE = STRUCTURE_TYPES.register( + "hanging_cage", + () -> () -> HangingCageStructure.CODEC + ); + + // === Structure Piece Types === + + // Shared piece type for all kidnapper structures + public static final RegistryObject CAMP_PIECE = + STRUCTURE_PIECE_TYPES.register( + "camp_piece", + () -> + (context, tag) -> + new KidnapperCampPiece( + context.structureTemplateManager(), + tag + ) + ); + + // Hanging cage piece (programmatic, no NBT template) + public static final RegistryObject HANGING_CAGE_PIECE = + STRUCTURE_PIECE_TYPES.register( + "hanging_cage_piece", + () -> (context, tag) -> new HangingCagePiece(tag) + ); +} diff --git a/src/main/java/com/tiedup/remake/worldgen/RoomLayout.java b/src/main/java/com/tiedup/remake/worldgen/RoomLayout.java new file mode 100644 index 0000000..32cc584 --- /dev/null +++ b/src/main/java/com/tiedup/remake/worldgen/RoomLayout.java @@ -0,0 +1,94 @@ +package com.tiedup.remake.worldgen; + +enum RoomLayout { + SQUARE { + @Override + public boolean isInShape(int rx, int rz) { + return rx >= 0 && rx <= 12 && rz >= 0 && rz <= 12; + } + }, + OCTAGONAL { + @Override + public boolean isInShape(int rx, int rz) { + if (rx < 0 || rx > 12 || rz < 0 || rz > 12) return false; + if (rx + rz <= 2) return false; + if ((12 - rx) + rz <= 2) return false; + if (rx + (12 - rz) <= 2) return false; + if ((12 - rx) + (12 - rz) <= 2) return false; + return true; + } + }, + CIRCULAR { + @Override + public boolean isInShape(int rx, int rz) { + if (rx < 0 || rx > 12 || rz < 0 || rz > 12) return false; + int dx = rx - 6; + int dz = rz - 6; + return dx * dx + dz * dz <= 36; + } + + @Override + public int[][] innerCorners() { + return new int[][] { { 6, 1 }, { 6, 11 }, { 1, 6 }, { 11, 6 } }; + } + }, + CROSS { + @Override + public boolean isInShape(int rx, int rz) { + if (rx < 0 || rx > 12 || rz < 0 || rz > 12) return false; + return (rx >= 3 && rx <= 9) || (rz >= 3 && rz <= 9); + } + + @Override + public int[][] innerCorners() { + return new int[][] { { 3, 3 }, { 3, 9 }, { 9, 3 }, { 9, 9 } }; + } + }; + + public abstract boolean isInShape(int rx, int rz); + + public boolean isWall(int rx, int rz) { + if (!isInShape(rx, rz)) return false; + return ( + !isInShape(rx - 1, rz) || + !isInShape(rx + 1, rz) || + !isInShape(rx, rz - 1) || + !isInShape(rx, rz + 1) + ); + } + + /** Is this interior position adjacent to a wall? */ + public boolean isWallAdjacent(int rx, int rz) { + if (!isInShape(rx, rz) || isWall(rx, rz)) return false; + return ( + isWall(rx - 1, rz) || + isWall(rx + 1, rz) || + isWall(rx, rz - 1) || + isWall(rx, rz + 1) + ); + } + + /** Returns the 4 innermost corner positions for this layout. */ + public int[][] innerCorners() { + return switch (this) { + case SQUARE -> new int[][] { + { 1, 1 }, + { 1, 11 }, + { 11, 1 }, + { 11, 11 }, + }; + case OCTAGONAL -> new int[][] { + { 3, 1 }, + { 1, 9 }, + { 11, 3 }, + { 9, 11 }, + }; + default -> new int[][] { + { 1, 1 }, + { 1, 11 }, + { 11, 1 }, + { 11, 11 }, + }; + }; + } +} diff --git a/src/main/java/com/tiedup/remake/worldgen/RoomTheme.java b/src/main/java/com/tiedup/remake/worldgen/RoomTheme.java new file mode 100644 index 0000000..67e9af0 --- /dev/null +++ b/src/main/java/com/tiedup/remake/worldgen/RoomTheme.java @@ -0,0 +1,1516 @@ +package com.tiedup.remake.worldgen; + +import javax.annotation.Nullable; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.LanternBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.levelgen.structure.BoundingBox; + +enum RoomTheme { + OUBLIETTE { + @Override + public BlockState wallBlock(RandomSource r, int ry) { + if ( + ry == 1 && r.nextFloat() < 0.30f + ) return Blocks.MOSSY_COBBLESTONE.defaultBlockState(); + return r.nextFloat() < 0.20f + ? Blocks.CRACKED_DEEPSLATE_BRICKS.defaultBlockState() + : Blocks.DEEPSLATE_BRICKS.defaultBlockState(); + } + + @Override + public BlockState floorBlock( + RandomSource r, + int rx, + int rz, + boolean isEdge + ) { + boolean isCorner = + (rx == 1 || rx == 11) && (rz == 1 || rz == 11); + if ( + isCorner + ) return Blocks.MOSSY_COBBLESTONE.defaultBlockState(); + return r.nextFloat() < 0.15f + ? Blocks.COBBLESTONE.defaultBlockState() + : Blocks.DEEPSLATE_TILES.defaultBlockState(); + } + + @Override + public BlockState ceilingBlock(RandomSource r) { + return r.nextFloat() < 0.20f + ? Blocks.CRACKED_DEEPSLATE_BRICKS.defaultBlockState() + : Blocks.DEEPSLATE_BRICKS.defaultBlockState(); + } + + @Override + public BlockState wallShellBlock() { + return Blocks.DEEPSLATE_BRICKS.defaultBlockState(); + } + + @Override + public void placeDecorations( + WorldGenLevel level, + RandomSource random, + int baseX, + int baseZ, + int floorY, + RoomLayout layout, + BoundingBox chunkBB + ) { + int[][] corners = layout.innerCorners(); + // Cobwebs at corners (low + high) + for (int[] c : corners) { + if ( + layout.isInShape(c[0], c[1]) && + !layout.isWall(c[0], c[1]) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + c[0], + floorY + 1, + baseZ + c[1] + ), + Blocks.COBWEB.defaultBlockState(), + chunkBB + ); + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + c[0], + floorY + 9, + baseZ + c[1] + ), + Blocks.COBWEB.defaultBlockState(), + chunkBB + ); + } + } + // Water cauldron near first corner + int cx = corners[0][0] + 1, + cz = corners[0][1] + 1; + if (layout.isInShape(cx, cz) && !layout.isWall(cx, cz)) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos(baseX + cx, floorY + 1, baseZ + cz), + Blocks.WATER_CAULDRON.defaultBlockState().setValue( + BlockStateProperties.LEVEL_CAULDRON, + 3 + ), + chunkBB + ); + } + // Soul lanterns on wall midpoints + for (int[] wallPos : new int[][] { + { 6, 1 }, + { 6, 11 }, + { 1, 6 }, + { 11, 6 }, + }) { + if (layout.isWall(wallPos[0], wallPos[1])) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + wallPos[0], + floorY + 3, + baseZ + wallPos[1] + ), + Blocks.SOUL_LANTERN.defaultBlockState(), + chunkBB + ); + } + } + // Corner furniture: barrel + brewing_stand + chain + int[] fc = corners[1]; + int fcx = fc[0] + (fc[0] < 6 ? 1 : -1); + int fcz = fc[1] + (fc[1] < 6 ? 1 : -1); + if (layout.isInShape(fcx, fcz) && !layout.isWall(fcx, fcz)) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos(baseX + fcx, floorY + 1, baseZ + fcz), + Blocks.BARREL.defaultBlockState(), + chunkBB + ); + int fcx2 = fcx + (fcx < 6 ? 1 : -1); + if ( + layout.isInShape(fcx2, fcz) && !layout.isWall(fcx2, fcz) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos(baseX + fcx2, floorY + 1, baseZ + fcz), + Blocks.BREWING_STAND.defaultBlockState(), + chunkBB + ); + } + // Chain above barrel + for (int cy = 8; cy <= 10; cy++) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos(baseX + fcx, floorY + cy, baseZ + fcz), + Blocks.CHAIN.defaultBlockState(), + chunkBB + ); + } + } + // Side chains + placeSharedChains(level, baseX, baseZ, floorY, chunkBB); + // Hanging lanterns + placeSharedHangingLanterns( + level, + baseX, + baseZ, + floorY, + chunkBB + ); + } + + @Override + public BlockState pillarBlock(RandomSource r, int ry) { + if ( + ry == 1 || ry == 10 + ) return Blocks.POLISHED_DEEPSLATE.defaultBlockState(); + return Blocks.DEEPSLATE_BRICK_WALL.defaultBlockState(); + } + + @Override + @Nullable + public BlockState scatterBlock(RandomSource r) { + float f = r.nextFloat(); + if (f < 0.40f) return Blocks.COBWEB.defaultBlockState(); + if (f < 0.70f) return Blocks.CANDLE.defaultBlockState() + .setValue(BlockStateProperties.CANDLES, 1 + r.nextInt(3)) + .setValue(BlockStateProperties.LIT, true); + return Blocks.MOSS_CARPET.defaultBlockState(); + } + + @Override + public BlockState wallAccentBlock() { + return Blocks.POLISHED_DEEPSLATE.defaultBlockState(); + } + }, + INFERNO { + @Override + public BlockState wallBlock(RandomSource r, int ry) { + return r.nextFloat() < 0.20f + ? Blocks.CRACKED_NETHER_BRICKS.defaultBlockState() + : Blocks.NETHER_BRICKS.defaultBlockState(); + } + + @Override + public BlockState floorBlock( + RandomSource r, + int rx, + int rz, + boolean isEdge + ) { + if (isEdge) return Blocks.MAGMA_BLOCK.defaultBlockState(); + return r.nextFloat() < 0.08f + ? Blocks.GILDED_BLACKSTONE.defaultBlockState() + : Blocks.BLACKSTONE.defaultBlockState(); + } + + @Override + public BlockState ceilingBlock(RandomSource r) { + return r.nextFloat() < 0.20f + ? Blocks.CRACKED_NETHER_BRICKS.defaultBlockState() + : Blocks.NETHER_BRICKS.defaultBlockState(); + } + + @Override + public BlockState wallShellBlock() { + return Blocks.NETHER_BRICKS.defaultBlockState(); + } + + @Override + public void placeDecorations( + WorldGenLevel level, + RandomSource random, + int baseX, + int baseZ, + int floorY, + RoomLayout layout, + BoundingBox chunkBB + ) { + int[][] corners = layout.innerCorners(); + // Soul fire at corners: soul_sand at ry=0, soul_fire at ry=1 + for (int[] c : corners) { + if ( + layout.isInShape(c[0], c[1]) && + !layout.isWall(c[0], c[1]) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos(baseX + c[0], floorY, baseZ + c[1]), + Blocks.SOUL_SAND.defaultBlockState(), + chunkBB + ); + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + c[0], + floorY + 1, + baseZ + c[1] + ), + Blocks.SOUL_FIRE.defaultBlockState(), + chunkBB + ); + } + } + // Crying obsidian accents at wall midpoints + for (int[] wallPos : new int[][] { + { 6, 1 }, + { 6, 11 }, + { 1, 6 }, + { 11, 6 }, + }) { + if (layout.isWall(wallPos[0], wallPos[1])) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + wallPos[0], + floorY + 2, + baseZ + wallPos[1] + ), + Blocks.CRYING_OBSIDIAN.defaultBlockState(), + chunkBB + ); + } + } + // Soul lanterns on wall midpoints + for (int[] wallPos : new int[][] { + { 6, 1 }, + { 6, 11 }, + { 1, 6 }, + { 11, 6 }, + }) { + if (layout.isWall(wallPos[0], wallPos[1])) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + wallPos[0], + floorY + 3, + baseZ + wallPos[1] + ), + Blocks.SOUL_LANTERN.defaultBlockState(), + chunkBB + ); + } + } + // Corner furniture: soul_campfire + cauldron(lava) + gilded_blackstone + int[][] icorners = layout.innerCorners(); + int[] ifc = icorners[1]; + int ifcx = ifc[0] + (ifc[0] < 6 ? 1 : -1); + int ifcz = ifc[1] + (ifc[1] < 6 ? 1 : -1); + if ( + layout.isInShape(ifcx, ifcz) && !layout.isWall(ifcx, ifcz) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos(baseX + ifcx, floorY + 1, baseZ + ifcz), + Blocks.SOUL_CAMPFIRE.defaultBlockState(), + chunkBB + ); + int ifcx2 = ifcx + (ifcx < 6 ? 1 : -1); + if ( + layout.isInShape(ifcx2, ifcz) && + !layout.isWall(ifcx2, ifcz) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + ifcx2, + floorY + 1, + baseZ + ifcz + ), + Blocks.LAVA_CAULDRON.defaultBlockState(), + chunkBB + ); + } + if ( + layout.isInShape(ifcx, ifcz + (ifcz < 6 ? 1 : -1)) && + !layout.isWall(ifcx, ifcz + (ifcz < 6 ? 1 : -1)) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + ifcx, + floorY + 1, + baseZ + ifcz + (ifcz < 6 ? 1 : -1) + ), + Blocks.GILDED_BLACKSTONE.defaultBlockState(), + chunkBB + ); + } + } + // Side chains + placeSharedChains(level, baseX, baseZ, floorY, chunkBB); + // Hanging lanterns + placeSharedHangingLanterns( + level, + baseX, + baseZ, + floorY, + chunkBB + ); + } + + @Override + public BlockState pillarBlock(RandomSource r, int ry) { + if ( + ry == 1 || ry == 10 + ) return Blocks.QUARTZ_PILLAR.defaultBlockState(); + return Blocks.NETHER_BRICK_WALL.defaultBlockState(); + } + + @Override + @Nullable + public BlockState scatterBlock(RandomSource r) { + float f = r.nextFloat(); + if (f < 0.40f) return Blocks.SOUL_SAND.defaultBlockState(); + if ( + f < 0.70f + ) return Blocks.NETHER_WART_BLOCK.defaultBlockState(); + return Blocks.BONE_BLOCK.defaultBlockState(); + } + + @Override + public BlockState wallAccentBlock() { + return Blocks.RED_NETHER_BRICKS.defaultBlockState(); + } + }, + CRYPT { + @Override + public BlockState wallBlock(RandomSource r, int ry) { + float f = r.nextFloat(); + if ( + ry == 1 && f < 0.20f + ) return Blocks.MOSSY_COBBLESTONE.defaultBlockState(); + if ( + f < 0.15f + ) return Blocks.MOSSY_STONE_BRICKS.defaultBlockState(); + if ( + f < 0.30f + ) return Blocks.CRACKED_STONE_BRICKS.defaultBlockState(); + return Blocks.STONE_BRICKS.defaultBlockState(); + } + + @Override + public BlockState floorBlock( + RandomSource r, + int rx, + int rz, + boolean isEdge + ) { + boolean isCorner = + (rx == 1 || rx == 11) && (rz == 1 || rz == 11); + if ( + isCorner + ) return Blocks.MOSSY_COBBLESTONE.defaultBlockState(); + return r.nextFloat() < 0.20f + ? Blocks.COBBLESTONE.defaultBlockState() + : Blocks.STONE_BRICKS.defaultBlockState(); + } + + @Override + public BlockState ceilingBlock(RandomSource r) { + return r.nextFloat() < 0.20f + ? Blocks.CRACKED_STONE_BRICKS.defaultBlockState() + : Blocks.STONE_BRICKS.defaultBlockState(); + } + + @Override + public BlockState wallShellBlock() { + return Blocks.STONE_BRICKS.defaultBlockState(); + } + + @Override + public void placeDecorations( + WorldGenLevel level, + RandomSource random, + int baseX, + int baseZ, + int floorY, + RoomLayout layout, + BoundingBox chunkBB + ) { + int[][] corners = layout.innerCorners(); + // Cobwebs at corners (low + high) + for (int[] c : corners) { + if ( + layout.isInShape(c[0], c[1]) && + !layout.isWall(c[0], c[1]) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + c[0], + floorY + 1, + baseZ + c[1] + ), + Blocks.COBWEB.defaultBlockState(), + chunkBB + ); + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + c[0], + floorY + 9, + baseZ + c[1] + ), + Blocks.COBWEB.defaultBlockState(), + chunkBB + ); + } + } + // Wall torches at midpoints + for (int[] wallPos : new int[][] { + { 6, 0 }, + { 6, 12 }, + { 0, 6 }, + { 12, 6 }, + }) { + if (layout.isWall(wallPos[0], wallPos[1])) { + Direction torchDir = getTorchDirection( + wallPos[0], + wallPos[1] + ); + if (torchDir != null) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + wallPos[0], + floorY + 3, + baseZ + wallPos[1] + ), + Blocks.WALL_TORCH.defaultBlockState().setValue( + net.minecraft.world.level.block.WallTorchBlock.FACING, + torchDir + ), + chunkBB + ); + } + } + } + // Skull in first corner + int[] sc = corners[0]; + if ( + layout.isInShape(sc[0], sc[1]) && + !layout.isWall(sc[0], sc[1]) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos(baseX + sc[0], floorY + 1, baseZ + sc[1]), + Blocks.SKELETON_SKULL.defaultBlockState(), + chunkBB + ); + } + // Corner furniture: lectern + candles on floor + int[] cfc = corners[1]; + int cfcx = cfc[0] + (cfc[0] < 6 ? 1 : -1); + int cfcz = cfc[1] + (cfc[1] < 6 ? 1 : -1); + if ( + layout.isInShape(cfcx, cfcz) && !layout.isWall(cfcx, cfcz) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos(baseX + cfcx, floorY + 1, baseZ + cfcz), + Blocks.LECTERN.defaultBlockState(), + chunkBB + ); + int cfcx2 = cfcx + (cfcx < 6 ? 1 : -1); + if ( + layout.isInShape(cfcx2, cfcz) && + !layout.isWall(cfcx2, cfcz) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + cfcx2, + floorY + 1, + baseZ + cfcz + ), + Blocks.CANDLE.defaultBlockState() + .setValue(BlockStateProperties.CANDLES, 4) + .setValue(BlockStateProperties.LIT, true), + chunkBB + ); + } + } + placeSharedChains(level, baseX, baseZ, floorY, chunkBB); + placeSharedHangingLanterns( + level, + baseX, + baseZ, + floorY, + chunkBB + ); + } + + @Override + public BlockState pillarBlock(RandomSource r, int ry) { + if ( + ry == 1 || ry == 10 + ) return Blocks.CHISELED_STONE_BRICKS.defaultBlockState(); + return Blocks.STONE_BRICK_WALL.defaultBlockState(); + } + + @Override + @Nullable + public BlockState scatterBlock(RandomSource r) { + float f = r.nextFloat(); + if (f < 0.40f) return Blocks.COBWEB.defaultBlockState(); + if (f < 0.70f) return Blocks.CANDLE.defaultBlockState() + .setValue(BlockStateProperties.CANDLES, 1 + r.nextInt(3)) + .setValue(BlockStateProperties.LIT, true); + return Blocks.BONE_BLOCK.defaultBlockState(); + } + + @Override + public BlockState wallAccentBlock() { + return Blocks.MOSSY_STONE_BRICKS.defaultBlockState(); + } + }, + ICE { + @Override + public BlockState wallBlock(RandomSource r, int ry) { + float f = r.nextFloat(); + if (f < 0.10f) return Blocks.ICE.defaultBlockState(); + if (f < 0.30f) return Blocks.BLUE_ICE.defaultBlockState(); + return Blocks.PACKED_ICE.defaultBlockState(); + } + + @Override + public BlockState floorBlock( + RandomSource r, + int rx, + int rz, + boolean isEdge + ) { + if (isEdge) return Blocks.BLUE_ICE.defaultBlockState(); + return r.nextFloat() < 0.20f + ? Blocks.SNOW_BLOCK.defaultBlockState() + : Blocks.PACKED_ICE.defaultBlockState(); + } + + @Override + public BlockState ceilingBlock(RandomSource r) { + return r.nextFloat() < 0.20f + ? Blocks.BLUE_ICE.defaultBlockState() + : Blocks.PACKED_ICE.defaultBlockState(); + } + + @Override + public BlockState wallShellBlock() { + return Blocks.PACKED_ICE.defaultBlockState(); + } + + @Override + public void placeDecorations( + WorldGenLevel level, + RandomSource random, + int baseX, + int baseZ, + int floorY, + RoomLayout layout, + BoundingBox chunkBB + ) { + int[][] corners = layout.innerCorners(); + // Snow layers in corners + for (int[] c : corners) { + if ( + layout.isInShape(c[0], c[1]) && + !layout.isWall(c[0], c[1]) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + c[0], + floorY + 1, + baseZ + c[1] + ), + Blocks.SNOW.defaultBlockState().setValue( + net.minecraft.world.level.block.SnowLayerBlock.LAYERS, + 2 + random.nextInt(3) + ), + chunkBB + ); + } + } + // Lanterns on wall midpoints + for (int[] wallPos : new int[][] { + { 6, 1 }, + { 6, 11 }, + { 1, 6 }, + { 11, 6 }, + }) { + if (layout.isWall(wallPos[0], wallPos[1])) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + wallPos[0], + floorY + 3, + baseZ + wallPos[1] + ), + Blocks.LANTERN.defaultBlockState(), + chunkBB + ); + } + } + // Ice stalactites near ceiling corners + for (int[] c : corners) { + int cx = c[0], + cz = c[1]; + if (layout.isInShape(cx, cz) && !layout.isWall(cx, cz)) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos(baseX + cx, floorY + 10, baseZ + cz), + Blocks.ICE.defaultBlockState(), + chunkBB + ); + } + } + // Corner furniture: cauldron(powder_snow) + blue_ice seat + lantern + int[] ifc2 = corners[1]; + int ifc2x = ifc2[0] + (ifc2[0] < 6 ? 1 : -1); + int ifc2z = ifc2[1] + (ifc2[1] < 6 ? 1 : -1); + if ( + layout.isInShape(ifc2x, ifc2z) && + !layout.isWall(ifc2x, ifc2z) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos(baseX + ifc2x, floorY + 1, baseZ + ifc2z), + Blocks.POWDER_SNOW_CAULDRON.defaultBlockState().setValue( + BlockStateProperties.LEVEL_CAULDRON, + 3 + ), + chunkBB + ); + int ifc2x2 = ifc2x + (ifc2x < 6 ? 1 : -1); + if ( + layout.isInShape(ifc2x2, ifc2z) && + !layout.isWall(ifc2x2, ifc2z) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + ifc2x2, + floorY + 1, + baseZ + ifc2z + ), + Blocks.BLUE_ICE.defaultBlockState(), + chunkBB + ); + } + if ( + layout.isInShape(ifc2x, ifc2z + (ifc2z < 6 ? 1 : -1)) && + !layout.isWall(ifc2x, ifc2z + (ifc2z < 6 ? 1 : -1)) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + ifc2x, + floorY + 2, + baseZ + ifc2z + (ifc2z < 6 ? 1 : -1) + ), + Blocks.LANTERN.defaultBlockState(), + chunkBB + ); + } + } + placeSharedChains(level, baseX, baseZ, floorY, chunkBB); + placeSharedHangingLanterns( + level, + baseX, + baseZ, + floorY, + chunkBB + ); + } + + @Override + public BlockState pillarBlock(RandomSource r, int ry) { + if ( + ry == 1 || ry == 10 + ) return Blocks.BLUE_ICE.defaultBlockState(); + return r.nextFloat() < 0.5f + ? Blocks.PACKED_ICE.defaultBlockState() + : Blocks.BLUE_ICE.defaultBlockState(); + } + + @Override + @Nullable + public BlockState scatterBlock(RandomSource r) { + float f = r.nextFloat(); + if (f < 0.50f) return Blocks.SNOW.defaultBlockState().setValue( + net.minecraft.world.level.block.SnowLayerBlock.LAYERS, + 1 + r.nextInt(2) + ); + if (f < 0.70f) return Blocks.POWDER_SNOW.defaultBlockState(); + return Blocks.ICE.defaultBlockState(); + } + + @Override + public BlockState wallAccentBlock() { + return Blocks.BLUE_ICE.defaultBlockState(); + } + }, + SCULK { + @Override + public BlockState wallBlock(RandomSource r, int ry) { + float f = r.nextFloat(); + if ( + f < 0.10f + ) return Blocks.CRACKED_DEEPSLATE_BRICKS.defaultBlockState(); + if (f < 0.40f) return Blocks.SCULK.defaultBlockState(); + return Blocks.DEEPSLATE_BRICKS.defaultBlockState(); + } + + @Override + public BlockState floorBlock( + RandomSource r, + int rx, + int rz, + boolean isEdge + ) { + if (isEdge) return Blocks.SCULK.defaultBlockState(); + return r.nextFloat() < 0.30f + ? Blocks.SCULK.defaultBlockState() + : Blocks.DEEPSLATE_TILES.defaultBlockState(); + } + + @Override + public BlockState ceilingBlock(RandomSource r) { + float f = r.nextFloat(); + if ( + f < 0.10f + ) return Blocks.CRACKED_DEEPSLATE_BRICKS.defaultBlockState(); + if (f < 0.30f) return Blocks.SCULK.defaultBlockState(); + return Blocks.DEEPSLATE_BRICKS.defaultBlockState(); + } + + @Override + public BlockState wallShellBlock() { + return Blocks.DEEPSLATE_BRICKS.defaultBlockState(); + } + + @Override + public void placeDecorations( + WorldGenLevel level, + RandomSource random, + int baseX, + int baseZ, + int floorY, + RoomLayout layout, + BoundingBox chunkBB + ) { + // Sculk veins on wall midpoints at ry=2 + for (int[] wallPos : new int[][] { + { 6, 1 }, + { 6, 11 }, + { 1, 6 }, + { 11, 6 }, + }) { + if (layout.isWall(wallPos[0], wallPos[1])) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + wallPos[0], + floorY + 2, + baseZ + wallPos[1] + ), + Blocks.SCULK_VEIN.defaultBlockState(), + chunkBB + ); + } + } + // Soul lanterns on wall midpoints at ry=3 + for (int[] wallPos : new int[][] { + { 6, 1 }, + { 6, 11 }, + { 1, 6 }, + { 11, 6 }, + }) { + if (layout.isWall(wallPos[0], wallPos[1])) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + wallPos[0], + floorY + 3, + baseZ + wallPos[1] + ), + Blocks.SOUL_LANTERN.defaultBlockState(), + chunkBB + ); + } + } + // Sculk catalyst in first corner + int[][] corners = layout.innerCorners(); + int[] sc = corners[0]; + if ( + layout.isInShape(sc[0], sc[1]) && + !layout.isWall(sc[0], sc[1]) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos(baseX + sc[0], floorY + 1, baseZ + sc[1]), + Blocks.SCULK_CATALYST.defaultBlockState(), + chunkBB + ); + } + // Corner furniture: sculk_shrieker + sculk_sensor + candle + int[][] scorners = layout.innerCorners(); + int[] sfc = scorners[1]; + int sfcx = sfc[0] + (sfc[0] < 6 ? 1 : -1); + int sfcz = sfc[1] + (sfc[1] < 6 ? 1 : -1); + if ( + layout.isInShape(sfcx, sfcz) && !layout.isWall(sfcx, sfcz) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos(baseX + sfcx, floorY + 1, baseZ + sfcz), + Blocks.SCULK_SHRIEKER.defaultBlockState(), + chunkBB + ); + int sfcx2 = sfcx + (sfcx < 6 ? 1 : -1); + if ( + layout.isInShape(sfcx2, sfcz) && + !layout.isWall(sfcx2, sfcz) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + sfcx2, + floorY + 1, + baseZ + sfcz + ), + Blocks.SCULK_SENSOR.defaultBlockState(), + chunkBB + ); + } + if ( + layout.isInShape(sfcx, sfcz + (sfcz < 6 ? 1 : -1)) && + !layout.isWall(sfcx, sfcz + (sfcz < 6 ? 1 : -1)) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + sfcx, + floorY + 1, + baseZ + sfcz + (sfcz < 6 ? 1 : -1) + ), + Blocks.CANDLE.defaultBlockState() + .setValue(BlockStateProperties.CANDLES, 3) + .setValue(BlockStateProperties.LIT, true), + chunkBB + ); + } + } + placeSharedChains(level, baseX, baseZ, floorY, chunkBB); + placeSharedHangingLanterns( + level, + baseX, + baseZ, + floorY, + chunkBB + ); + } + + @Override + public BlockState pillarBlock(RandomSource r, int ry) { + if ( + ry == 1 || ry == 10 + ) return Blocks.SCULK.defaultBlockState(); + return r.nextFloat() < 0.3f + ? Blocks.SCULK.defaultBlockState() + : Blocks.DEEPSLATE_TILE_WALL.defaultBlockState(); + } + + @Override + @Nullable + public BlockState scatterBlock(RandomSource r) { + float f = r.nextFloat(); + if (f < 0.50f) return Blocks.SCULK.defaultBlockState(); + if (f < 0.80f) return Blocks.SCULK_VEIN.defaultBlockState(); + return Blocks.MOSS_CARPET.defaultBlockState(); + } + + @Override + public BlockState wallAccentBlock() { + return Blocks.SCULK_CATALYST.defaultBlockState(); + } + }, + SANDSTONE { + @Override + public BlockState wallBlock(RandomSource r, int ry) { + float f = r.nextFloat(); + if ( + f < 0.10f + ) return Blocks.CHISELED_SANDSTONE.defaultBlockState(); + if (f < 0.30f) return Blocks.SANDSTONE.defaultBlockState(); + return Blocks.CUT_SANDSTONE.defaultBlockState(); + } + + @Override + public BlockState floorBlock( + RandomSource r, + int rx, + int rz, + boolean isEdge + ) { + return r.nextFloat() < 0.15f + ? Blocks.SAND.defaultBlockState() + : Blocks.SMOOTH_SANDSTONE.defaultBlockState(); + } + + @Override + public BlockState ceilingBlock(RandomSource r) { + return r.nextFloat() < 0.20f + ? Blocks.SANDSTONE.defaultBlockState() + : Blocks.CUT_SANDSTONE.defaultBlockState(); + } + + @Override + public BlockState wallShellBlock() { + return Blocks.CUT_SANDSTONE.defaultBlockState(); + } + + @Override + public void placeDecorations( + WorldGenLevel level, + RandomSource random, + int baseX, + int baseZ, + int floorY, + RoomLayout layout, + BoundingBox chunkBB + ) { + // Wall torches at midpoints + for (int[] wallPos : new int[][] { + { 6, 0 }, + { 6, 12 }, + { 0, 6 }, + { 12, 6 }, + }) { + if (layout.isWall(wallPos[0], wallPos[1])) { + Direction torchDir = getTorchDirection( + wallPos[0], + wallPos[1] + ); + if (torchDir != null) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + wallPos[0], + floorY + 3, + baseZ + wallPos[1] + ), + Blocks.WALL_TORCH.defaultBlockState().setValue( + net.minecraft.world.level.block.WallTorchBlock.FACING, + torchDir + ), + chunkBB + ); + } + } + } + // Orange terracotta accents at wall midpoints ry=2 + for (int[] wallPos : new int[][] { + { 6, 1 }, + { 6, 11 }, + { 1, 6 }, + { 11, 6 }, + }) { + if (layout.isWall(wallPos[0], wallPos[1])) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + wallPos[0], + floorY + 2, + baseZ + wallPos[1] + ), + Blocks.ORANGE_TERRACOTTA.defaultBlockState(), + chunkBB + ); + } + } + // TNT hidden in first corner + int[][] corners = layout.innerCorners(); + int[] tc = corners[0]; + if ( + layout.isInShape(tc[0], tc[1]) && + !layout.isWall(tc[0], tc[1]) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos(baseX + tc[0], floorY + 1, baseZ + tc[1]), + Blocks.TNT.defaultBlockState(), + chunkBB + ); + } + // Corner furniture: barrel + flower_pot + orange_terracotta bench + int[] ssfc = corners[1]; + int ssfcx = ssfc[0] + (ssfc[0] < 6 ? 1 : -1); + int ssfcz = ssfc[1] + (ssfc[1] < 6 ? 1 : -1); + if ( + layout.isInShape(ssfcx, ssfcz) && + !layout.isWall(ssfcx, ssfcz) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos(baseX + ssfcx, floorY + 1, baseZ + ssfcz), + Blocks.BARREL.defaultBlockState(), + chunkBB + ); + int ssfcx2 = ssfcx + (ssfcx < 6 ? 1 : -1); + if ( + layout.isInShape(ssfcx2, ssfcz) && + !layout.isWall(ssfcx2, ssfcz) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + ssfcx2, + floorY + 1, + baseZ + ssfcz + ), + Blocks.FLOWER_POT.defaultBlockState(), + chunkBB + ); + } + if ( + layout.isInShape(ssfcx, ssfcz + (ssfcz < 6 ? 1 : -1)) && + !layout.isWall(ssfcx, ssfcz + (ssfcz < 6 ? 1 : -1)) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + ssfcx, + floorY + 1, + baseZ + ssfcz + (ssfcz < 6 ? 1 : -1) + ), + Blocks.ORANGE_TERRACOTTA.defaultBlockState(), + chunkBB + ); + } + } + placeSharedChains(level, baseX, baseZ, floorY, chunkBB); + placeSharedHangingLanterns( + level, + baseX, + baseZ, + floorY, + chunkBB + ); + } + + @Override + public BlockState pillarBlock(RandomSource r, int ry) { + if ( + ry == 1 || ry == 10 + ) return Blocks.CHISELED_SANDSTONE.defaultBlockState(); + return Blocks.SANDSTONE_WALL.defaultBlockState(); + } + + @Override + @Nullable + public BlockState scatterBlock(RandomSource r) { + float f = r.nextFloat(); + if (f < 0.40f) return Blocks.SAND.defaultBlockState(); + if (f < 0.70f) return Blocks.DEAD_BUSH.defaultBlockState(); + return Blocks.CANDLE.defaultBlockState() + .setValue(BlockStateProperties.CANDLES, 1 + r.nextInt(3)) + .setValue(BlockStateProperties.LIT, true); + } + + @Override + public BlockState wallAccentBlock() { + return Blocks.CHISELED_SANDSTONE.defaultBlockState(); + } + }; + + public abstract BlockState wallBlock(RandomSource r, int ry); + + public abstract BlockState floorBlock( + RandomSource r, + int rx, + int rz, + boolean isEdge + ); + + public abstract BlockState ceilingBlock(RandomSource r); + + /** Solid block used for wall positions on the floor layer. */ + public abstract BlockState wallShellBlock(); + + public abstract void placeDecorations( + WorldGenLevel level, + RandomSource random, + int baseX, + int baseZ, + int floorY, + RoomLayout layout, + BoundingBox chunkBB + ); + + /** Block used for pillars (may vary by height). */ + public abstract BlockState pillarBlock(RandomSource r, int ry); + + /** Random scatter block for floor decoration, or null to skip. */ + @Nullable + public abstract BlockState scatterBlock(RandomSource r); + + /** Accent block for decorative wall bands. */ + public abstract BlockState wallAccentBlock(); + + // ── Shared structural features ────────────────────────────────── + + /** Pillar positions -- verified to be inside all 4 layouts. */ + private static final int[][] PILLAR_POSITIONS = { + { 4, 4 }, + { 4, 8 }, + { 8, 4 }, + { 8, 8 }, + }; + + /** Place 4 full-height pillars at verified positions. */ + static void placeSharedPillars( + WorldGenLevel level, + RandomSource random, + int baseX, + int baseZ, + int floorY, + RoomLayout layout, + RoomTheme theme, + BoundingBox chunkBB + ) { + for (int[] p : PILLAR_POSITIONS) { + if ( + !layout.isInShape(p[0], p[1]) || layout.isWall(p[0], p[1]) + ) continue; + for (int ry = 1; ry <= 10; ry++) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos(baseX + p[0], floorY + ry, baseZ + p[1]), + theme.pillarBlock(random, ry), + chunkBB + ); + } + } + } + + /** Place random floor scatter (~12% of interior positions). */ + static void placeSharedFloorScatter( + WorldGenLevel level, + RandomSource random, + int baseX, + int baseZ, + int floorY, + RoomLayout layout, + RoomTheme theme, + BoundingBox chunkBB + ) { + for (int rx = 1; rx <= 11; rx++) { + for (int rz = 1; rz <= 11; rz++) { + if ( + !layout.isInShape(rx, rz) || layout.isWall(rx, rz) + ) continue; + // Skip pillar positions + if ((rx == 4 || rx == 8) && (rz == 4 || rz == 8)) continue; + // Skip cage center area (5-7, 5-7) + if (rx >= 5 && rx <= 7 && rz >= 5 && rz <= 7) continue; + // Skip corner positions (used by decorations/chests) + if ( + (rx <= 2 || rx >= 10) && (rz <= 2 || rz >= 10) + ) continue; + if (random.nextFloat() < 0.12f) { + BlockState scatter = theme.scatterBlock(random); + if (scatter != null) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + rx, + floorY + 1, + baseZ + rz + ), + scatter, + chunkBB + ); + } + } + } + } + } + + /** Place ceiling cobwebs and extra hanging chains. */ + static void placeSharedCeilingDecor( + WorldGenLevel level, + RandomSource random, + int baseX, + int baseZ, + int floorY, + RoomLayout layout, + BoundingBox chunkBB + ) { + // Cobwebs along walls at ceiling level + int[][] cobwebCandidates = { + { 2, 1 }, + { 4, 1 }, + { 8, 1 }, + { 10, 1 }, + { 2, 11 }, + { 4, 11 }, + { 8, 11 }, + { 10, 11 }, + { 1, 4 }, + { 1, 8 }, + { 11, 4 }, + { 11, 8 }, + }; + for (int[] pos : cobwebCandidates) { + if ( + layout.isInShape(pos[0], pos[1]) && + !layout.isWall(pos[0], pos[1]) && + random.nextFloat() < 0.45f + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + pos[0], + floorY + 10, + baseZ + pos[1] + ), + Blocks.COBWEB.defaultBlockState(), + chunkBB + ); + } + } + // Extra hanging chains at random interior positions + int[][] chainCandidates = { { 5, 3 }, { 7, 9 }, { 3, 7 } }; + for (int[] pos : chainCandidates) { + if ( + layout.isInShape(pos[0], pos[1]) && + !layout.isWall(pos[0], pos[1]) && + random.nextFloat() < 0.6f + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + pos[0], + floorY + 10, + baseZ + pos[1] + ), + Blocks.CHAIN.defaultBlockState(), + chunkBB + ); + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + pos[0], + floorY + 9, + baseZ + pos[1] + ), + Blocks.CHAIN.defaultBlockState(), + chunkBB + ); + } + } + } + + /** Place additional wall-mounted lighting. */ + static void placeSharedWallLighting( + WorldGenLevel level, + RandomSource random, + int baseX, + int baseZ, + int floorY, + RoomLayout layout, + BoundingBox chunkBB + ) { + // Lanterns on pillar sides (facing center) at ry=1 (on the floor) + int[][] pillarLanternPositions = { + { 5, 4 }, + { 4, 7 }, + { 8, 5 }, + { 7, 8 }, + }; + for (int[] pos : pillarLanternPositions) { + if ( + layout.isInShape(pos[0], pos[1]) && + !layout.isWall(pos[0], pos[1]) + ) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + pos[0], + floorY + 1, + baseZ + pos[1] + ), + Blocks.LANTERN.defaultBlockState(), + chunkBB + ); + } + } + // Extra wall sconces at quarter-points + int[][] wallSconces = { + { 4, 0 }, + { 8, 0 }, + { 4, 12 }, + { 8, 12 }, + { 0, 4 }, + { 0, 8 }, + { 12, 4 }, + { 12, 8 }, + }; + for (int[] pos : wallSconces) { + if ( + layout.isWall(pos[0], pos[1]) && random.nextFloat() < 0.5f + ) { + Direction torchDir = getTorchDirection(pos[0], pos[1]); + if (torchDir != null) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + pos[0], + floorY + 3, + baseZ + pos[1] + ), + Blocks.WALL_TORCH.defaultBlockState().setValue( + net.minecraft.world.level.block.WallTorchBlock.FACING, + torchDir + ), + chunkBB + ); + } + } + } + } + + /** Place wall accent bands at ry=5 and ry=8. */ + static void placeSharedWallBands( + WorldGenLevel level, + int baseX, + int baseZ, + int floorY, + RoomLayout layout, + RoomTheme theme, + BoundingBox chunkBB + ) { + int[][] bandPositions = { + { 6, 1 }, + { 6, 11 }, + { 1, 6 }, + { 11, 6 }, + { 4, 1 }, + { 8, 1 }, + { 4, 11 }, + { 8, 11 }, + { 1, 4 }, + { 1, 8 }, + { 11, 4 }, + { 11, 8 }, + }; + for (int[] pos : bandPositions) { + if (layout.isWall(pos[0], pos[1])) { + // Band at ry=5 + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + pos[0], + floorY + 5, + baseZ + pos[1] + ), + theme.wallAccentBlock(), + chunkBB + ); + // Optional band at ry=8 + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + pos[0], + floorY + 8, + baseZ + pos[1] + ), + theme.wallAccentBlock(), + chunkBB + ); + } + } + } + + /** Chains on cage flanks and ceiling corners -- shared by all themes. */ + static void placeSharedChains( + WorldGenLevel level, + int baseX, + int baseZ, + int floorY, + BoundingBox chunkBB + ) { + for (int[] chainPos : new int[][] { { 3, 6 }, { 9, 6 } }) { + for (int ry = 5; ry <= 10; ry++) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + chainPos[0], + floorY + ry, + baseZ + chainPos[1] + ), + Blocks.CHAIN.defaultBlockState(), + chunkBB + ); + } + } + for (int[] chainPos : new int[][] { + { 3, 3 }, + { 3, 9 }, + { 9, 3 }, + { 9, 9 }, + }) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos( + baseX + chainPos[0], + floorY + 10, + baseZ + chainPos[1] + ), + Blocks.CHAIN.defaultBlockState(), + chunkBB + ); + } + } + + /** Determine torch facing direction for a wall position (torch faces inward). */ + static Direction getTorchDirection(int rx, int rz) { + if (rz == 0) return Direction.SOUTH; + if (rz == 12) return Direction.NORTH; + if (rx == 0) return Direction.EAST; + if (rx == 12) return Direction.WEST; + return null; + } + + /** Hanging lanterns -- shared by all themes. */ + static void placeSharedHangingLanterns( + WorldGenLevel level, + int baseX, + int baseZ, + int floorY, + BoundingBox chunkBB + ) { + for (int[] pos : new int[][] { { 3, 3 }, { 9, 9 } }) { + HangingCagePiece.safeSetBlock( + level, + new BlockPos(baseX + pos[0], floorY + 9, baseZ + pos[1]), + Blocks.LANTERN.defaultBlockState().setValue( + LanternBlock.HANGING, + true + ), + chunkBB + ); + } + } +} diff --git a/src/main/resources/META-INF/mods.toml b/src/main/resources/META-INF/mods.toml new file mode 100644 index 0000000..c521b8f --- /dev/null +++ b/src/main/resources/META-INF/mods.toml @@ -0,0 +1,85 @@ +# This is an example mods.toml file. It contains the data relating to the loading mods. +# There are several mandatory fields (#mandatory), and many more that are optional (#optional). +# The overall format is standard TOML format, v0.5.0. +# Note that there are a couple of TOML lists in this file. +# Find more information on toml format here: https://github.com/toml-lang/toml +# The name of the mod loader type to load - for regular FML @Mod mods it should be javafml +modLoader="javafml" #mandatory +# A version range to match for said mod loader - for regular FML @Mod it will be the forge version +loaderVersion="${loader_version_range}" #mandatory This is typically bumped every Minecraft version by Forge. See our download page for lists of versions. +# The license for you mod. This is mandatory metadata and allows for easier comprehension of your redistributive properties. +# Review your options at https://choosealicense.com/. All rights reserved is the default copyright stance, and is thus the default here. +license="${mod_license}" +# A URL to refer people to when problems occur with this mod +#issueTrackerURL="https://change.me.to.your.issue.tracker.example.invalid/" #optional +# If your mod is purely client-side and has no multiplayer functionality (be it dedicated servers or Open to LAN), +# set this to true, and Forge will set the correct displayTest for you and skip loading your mod on dedicated servers. +#clientSideOnly=true #optional - defaults to false if absent +# A list of mods - how many allowed here is determined by the individual mod loader +[[mods]] #mandatory +# The modid of the mod +modId="${mod_id}" #mandatory +# The version number of the mod +version="${mod_version}" #mandatory +# A display name for the mod +displayName="${mod_name}" #mandatory +# A URL to query for updates for this mod. See the JSON update specification https://docs.minecraftforge.net/en/latest/misc/updatechecker/ +#updateJSONURL="https://change.me.example.invalid/updates.json" #optional +# A URL for the "homepage" for this mod, displayed in the mod UI +#displayURL="https://change.me.to.your.mods.homepage.example.invalid/" #optional +# A file name (in the root of the mod JAR) containing a logo for display +#logoFile="examplemod.png" #optional +# A text field displayed in the mod UI +#credits="" #optional +# A text field displayed in the mod UI +authors="${mod_authors}" #optional +# Display Test controls the display for your mod in the server connection screen +# MATCH_VERSION means that your mod will cause a red X if the versions on client and server differ. This is the default behaviour and should be what you choose if you have server and client elements to your mod. +# IGNORE_SERVER_VERSION means that your mod will not cause a red X if it's present on the server but not on the client. This is what you should use if you're a server only mod. +# IGNORE_ALL_VERSION means that your mod will not cause a red X if it's present on the client or the server. This is a special case and should only be used if your mod has no server component. +# NONE means that no display test is set on your mod. You need to do this yourself, see IExtensionPoint.DisplayTest for more information. You can define any scheme you wish with this value. +# IMPORTANT NOTE: this is NOT an instruction as to which environments (CLIENT or DEDICATED SERVER) your mod loads on. Your mod should load (and maybe do nothing!) whereever it finds itself. +#displayTest="MATCH_VERSION" # if nothing is specified, MATCH_VERSION is the default when clientSideOnly=false, otherwise IGNORE_ALL_VERSION when clientSideOnly=true (#optional) + +# The description text for the mod (multi line!) (#mandatory) +description='''${mod_description}''' +# A dependency - use the . to indicate dependency for a specific modid. Dependencies are optional. +[[dependencies.${mod_id}]] #optional + # the modid of the dependency + modId="forge" #mandatory + # Does this dependency have to exist - if not, ordering below must be specified + mandatory=true #mandatory + # The version range of the dependency + versionRange="${forge_version_range}" #mandatory + # An ordering relationship for the dependency - BEFORE or AFTER required if the dependency is not mandatory + # BEFORE - This mod is loaded BEFORE the dependency + # AFTER - This mod is loaded AFTER the dependency + ordering="NONE" + # Side this dependency is applied on - BOTH, CLIENT, or SERVER + side="BOTH" +# Here's another dependency +[[dependencies.${mod_id}]] + modId="minecraft" + mandatory=true + # This version range declares a minimum of the current minecraft version up to but not including the next major version + versionRange="${minecraft_version_range}" + ordering="NONE" + side="BOTH" + +# PlayerAnimator library dependency for player pose animations +[[dependencies.${mod_id}]] + modId="playeranimator" + mandatory=true + versionRange="[1.0.2-rc1,)" + ordering="NONE" + side="BOTH" + +# Mixin configuration (REQUIRED by Forge for mixin discovery) +[[mixins]] +config="tiedup.mixins.json" + +# Features are specific properties of the game environment, that you may want to declare you require. This example declares +# that your mod requires GL version 3.2 or higher. Other features will be added. They are side aware so declaring this won't +# stop your mod loading on the server for example. +#[features.${mod_id}] +#openGLVersion="[3.2,)" diff --git a/src/main/resources/assets/tiedup/blockstates/cell_core.json b/src/main/resources/assets/tiedup/blockstates/cell_core.json new file mode 100644 index 0000000..e1a4245 --- /dev/null +++ b/src/main/resources/assets/tiedup/blockstates/cell_core.json @@ -0,0 +1,5 @@ +{ + "variants": { + "": { "model": "tiedup:block/cell_core" } + } +} diff --git a/src/main/resources/assets/tiedup/blockstates/cell_door.json b/src/main/resources/assets/tiedup/blockstates/cell_door.json new file mode 100644 index 0000000..4a41a8a --- /dev/null +++ b/src/main/resources/assets/tiedup/blockstates/cell_door.json @@ -0,0 +1,36 @@ +{ + "variants": { + "facing=east,half=lower,hinge=left,open=false": {"model": "tiedup:block/cell_door_bottom_left"}, + "facing=east,half=lower,hinge=left,open=true": {"model": "tiedup:block/cell_door_bottom_left_open", "y": 90}, + "facing=east,half=lower,hinge=right,open=false": {"model": "tiedup:block/cell_door_bottom_right"}, + "facing=east,half=lower,hinge=right,open=true": {"model": "tiedup:block/cell_door_bottom_right_open", "y": 270}, + "facing=east,half=upper,hinge=left,open=false": {"model": "tiedup:block/cell_door_top_left"}, + "facing=east,half=upper,hinge=left,open=true": {"model": "tiedup:block/cell_door_top_left_open", "y": 90}, + "facing=east,half=upper,hinge=right,open=false": {"model": "tiedup:block/cell_door_top_right"}, + "facing=east,half=upper,hinge=right,open=true": {"model": "tiedup:block/cell_door_top_right_open", "y": 270}, + "facing=north,half=lower,hinge=left,open=false": {"model": "tiedup:block/cell_door_bottom_left", "y": 270}, + "facing=north,half=lower,hinge=left,open=true": {"model": "tiedup:block/cell_door_bottom_left_open"}, + "facing=north,half=lower,hinge=right,open=false": {"model": "tiedup:block/cell_door_bottom_right", "y": 270}, + "facing=north,half=lower,hinge=right,open=true": {"model": "tiedup:block/cell_door_bottom_right_open", "y": 180}, + "facing=north,half=upper,hinge=left,open=false": {"model": "tiedup:block/cell_door_top_left", "y": 270}, + "facing=north,half=upper,hinge=left,open=true": {"model": "tiedup:block/cell_door_top_left_open"}, + "facing=north,half=upper,hinge=right,open=false": {"model": "tiedup:block/cell_door_top_right", "y": 270}, + "facing=north,half=upper,hinge=right,open=true": {"model": "tiedup:block/cell_door_top_right_open", "y": 180}, + "facing=south,half=lower,hinge=left,open=false": {"model": "tiedup:block/cell_door_bottom_left", "y": 90}, + "facing=south,half=lower,hinge=left,open=true": {"model": "tiedup:block/cell_door_bottom_left_open", "y": 180}, + "facing=south,half=lower,hinge=right,open=false": {"model": "tiedup:block/cell_door_bottom_right", "y": 90}, + "facing=south,half=lower,hinge=right,open=true": {"model": "tiedup:block/cell_door_bottom_right_open"}, + "facing=south,half=upper,hinge=left,open=false": {"model": "tiedup:block/cell_door_top_left", "y": 90}, + "facing=south,half=upper,hinge=left,open=true": {"model": "tiedup:block/cell_door_top_left_open", "y": 180}, + "facing=south,half=upper,hinge=right,open=false": {"model": "tiedup:block/cell_door_top_right", "y": 90}, + "facing=south,half=upper,hinge=right,open=true": {"model": "tiedup:block/cell_door_top_right_open"}, + "facing=west,half=lower,hinge=left,open=false": {"model": "tiedup:block/cell_door_bottom_left", "y": 180}, + "facing=west,half=lower,hinge=left,open=true": {"model": "tiedup:block/cell_door_bottom_left_open", "y": 270}, + "facing=west,half=lower,hinge=right,open=false": {"model": "tiedup:block/cell_door_bottom_right", "y": 180}, + "facing=west,half=lower,hinge=right,open=true": {"model": "tiedup:block/cell_door_bottom_right_open", "y": 90}, + "facing=west,half=upper,hinge=left,open=false": {"model": "tiedup:block/cell_door_top_left", "y": 180}, + "facing=west,half=upper,hinge=left,open=true": {"model": "tiedup:block/cell_door_top_left_open", "y": 270}, + "facing=west,half=upper,hinge=right,open=false": {"model": "tiedup:block/cell_door_top_right", "y": 180}, + "facing=west,half=upper,hinge=right,open=true": {"model": "tiedup:block/cell_door_top_right_open", "y": 90} + } +} diff --git a/src/main/resources/assets/tiedup/blockstates/iron_bar_door.json b/src/main/resources/assets/tiedup/blockstates/iron_bar_door.json new file mode 100644 index 0000000..c997430 --- /dev/null +++ b/src/main/resources/assets/tiedup/blockstates/iron_bar_door.json @@ -0,0 +1,36 @@ +{ + "variants": { + "facing=east,half=lower,hinge=left,open=false": {"model": "tiedup:block/iron_bar_door_bottom_left"}, + "facing=east,half=lower,hinge=left,open=true": {"model": "tiedup:block/iron_bar_door_bottom_left_open", "y": 90}, + "facing=east,half=lower,hinge=right,open=false": {"model": "tiedup:block/iron_bar_door_bottom_right"}, + "facing=east,half=lower,hinge=right,open=true": {"model": "tiedup:block/iron_bar_door_bottom_right_open", "y": 270}, + "facing=east,half=upper,hinge=left,open=false": {"model": "tiedup:block/iron_bar_door_top_left"}, + "facing=east,half=upper,hinge=left,open=true": {"model": "tiedup:block/iron_bar_door_top_left_open", "y": 90}, + "facing=east,half=upper,hinge=right,open=false": {"model": "tiedup:block/iron_bar_door_top_right"}, + "facing=east,half=upper,hinge=right,open=true": {"model": "tiedup:block/iron_bar_door_top_right_open", "y": 270}, + "facing=north,half=lower,hinge=left,open=false": {"model": "tiedup:block/iron_bar_door_bottom_left", "y": 270}, + "facing=north,half=lower,hinge=left,open=true": {"model": "tiedup:block/iron_bar_door_bottom_left_open"}, + "facing=north,half=lower,hinge=right,open=false": {"model": "tiedup:block/iron_bar_door_bottom_right", "y": 270}, + "facing=north,half=lower,hinge=right,open=true": {"model": "tiedup:block/iron_bar_door_bottom_right_open", "y": 180}, + "facing=north,half=upper,hinge=left,open=false": {"model": "tiedup:block/iron_bar_door_top_left", "y": 270}, + "facing=north,half=upper,hinge=left,open=true": {"model": "tiedup:block/iron_bar_door_top_left_open"}, + "facing=north,half=upper,hinge=right,open=false": {"model": "tiedup:block/iron_bar_door_top_right", "y": 270}, + "facing=north,half=upper,hinge=right,open=true": {"model": "tiedup:block/iron_bar_door_top_right_open", "y": 180}, + "facing=south,half=lower,hinge=left,open=false": {"model": "tiedup:block/iron_bar_door_bottom_left", "y": 90}, + "facing=south,half=lower,hinge=left,open=true": {"model": "tiedup:block/iron_bar_door_bottom_left_open", "y": 180}, + "facing=south,half=lower,hinge=right,open=false": {"model": "tiedup:block/iron_bar_door_bottom_right", "y": 90}, + "facing=south,half=lower,hinge=right,open=true": {"model": "tiedup:block/iron_bar_door_bottom_right_open"}, + "facing=south,half=upper,hinge=left,open=false": {"model": "tiedup:block/iron_bar_door_top_left", "y": 90}, + "facing=south,half=upper,hinge=left,open=true": {"model": "tiedup:block/iron_bar_door_top_left_open", "y": 180}, + "facing=south,half=upper,hinge=right,open=false": {"model": "tiedup:block/iron_bar_door_top_right", "y": 90}, + "facing=south,half=upper,hinge=right,open=true": {"model": "tiedup:block/iron_bar_door_top_right_open"}, + "facing=west,half=lower,hinge=left,open=false": {"model": "tiedup:block/iron_bar_door_bottom_left", "y": 180}, + "facing=west,half=lower,hinge=left,open=true": {"model": "tiedup:block/iron_bar_door_bottom_left_open", "y": 270}, + "facing=west,half=lower,hinge=right,open=false": {"model": "tiedup:block/iron_bar_door_bottom_right", "y": 180}, + "facing=west,half=lower,hinge=right,open=true": {"model": "tiedup:block/iron_bar_door_bottom_right_open", "y": 90}, + "facing=west,half=upper,hinge=left,open=false": {"model": "tiedup:block/iron_bar_door_top_left", "y": 180}, + "facing=west,half=upper,hinge=left,open=true": {"model": "tiedup:block/iron_bar_door_top_left_open", "y": 270}, + "facing=west,half=upper,hinge=right,open=false": {"model": "tiedup:block/iron_bar_door_top_right", "y": 180}, + "facing=west,half=upper,hinge=right,open=true": {"model": "tiedup:block/iron_bar_door_top_right_open", "y": 90} + } +} diff --git a/src/main/resources/assets/tiedup/blockstates/kidnap_bomb.json b/src/main/resources/assets/tiedup/blockstates/kidnap_bomb.json new file mode 100644 index 0000000..5a0c3b2 --- /dev/null +++ b/src/main/resources/assets/tiedup/blockstates/kidnap_bomb.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "tiedup:block/kidnap_bomb" + } + } +} diff --git a/src/main/resources/assets/tiedup/blockstates/marker.json b/src/main/resources/assets/tiedup/blockstates/marker.json new file mode 100644 index 0000000..6efb59e --- /dev/null +++ b/src/main/resources/assets/tiedup/blockstates/marker.json @@ -0,0 +1,5 @@ +{ + "variants": { + "": {"model": "tiedup:block/marker"} + } +} diff --git a/src/main/resources/assets/tiedup/blockstates/padded_block.json b/src/main/resources/assets/tiedup/blockstates/padded_block.json new file mode 100644 index 0000000..787483b --- /dev/null +++ b/src/main/resources/assets/tiedup/blockstates/padded_block.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "tiedup:block/padded_block" + } + } +} diff --git a/src/main/resources/assets/tiedup/blockstates/padded_slab.json b/src/main/resources/assets/tiedup/blockstates/padded_slab.json new file mode 100644 index 0000000..3e6e7d3 --- /dev/null +++ b/src/main/resources/assets/tiedup/blockstates/padded_slab.json @@ -0,0 +1,13 @@ +{ + "variants": { + "type=bottom": { + "model": "tiedup:block/padded_slab" + }, + "type=double": { + "model": "tiedup:block/padded_block" + }, + "type=top": { + "model": "tiedup:block/padded_slab_top" + } + } +} diff --git a/src/main/resources/assets/tiedup/blockstates/padded_stairs.json b/src/main/resources/assets/tiedup/blockstates/padded_stairs.json new file mode 100644 index 0000000..af4abbe --- /dev/null +++ b/src/main/resources/assets/tiedup/blockstates/padded_stairs.json @@ -0,0 +1,44 @@ +{ + "variants": { + "facing=east,half=bottom,shape=inner_left": {"model": "tiedup:block/padded_stairs_inner", "y": 270, "uvlock": true}, + "facing=east,half=bottom,shape=inner_right": {"model": "tiedup:block/padded_stairs_inner"}, + "facing=east,half=bottom,shape=outer_left": {"model": "tiedup:block/padded_stairs_outer", "y": 270, "uvlock": true}, + "facing=east,half=bottom,shape=outer_right": {"model": "tiedup:block/padded_stairs_outer"}, + "facing=east,half=bottom,shape=straight": {"model": "tiedup:block/padded_stairs"}, + "facing=east,half=top,shape=inner_left": {"model": "tiedup:block/padded_stairs_inner", "x": 180, "uvlock": true}, + "facing=east,half=top,shape=inner_right": {"model": "tiedup:block/padded_stairs_inner", "x": 180, "y": 90, "uvlock": true}, + "facing=east,half=top,shape=outer_left": {"model": "tiedup:block/padded_stairs_outer", "x": 180, "uvlock": true}, + "facing=east,half=top,shape=outer_right": {"model": "tiedup:block/padded_stairs_outer", "x": 180, "y": 90, "uvlock": true}, + "facing=east,half=top,shape=straight": {"model": "tiedup:block/padded_stairs", "x": 180, "uvlock": true}, + "facing=north,half=bottom,shape=inner_left": {"model": "tiedup:block/padded_stairs_inner", "y": 180, "uvlock": true}, + "facing=north,half=bottom,shape=inner_right": {"model": "tiedup:block/padded_stairs_inner", "y": 270, "uvlock": true}, + "facing=north,half=bottom,shape=outer_left": {"model": "tiedup:block/padded_stairs_outer", "y": 180, "uvlock": true}, + "facing=north,half=bottom,shape=outer_right": {"model": "tiedup:block/padded_stairs_outer", "y": 270, "uvlock": true}, + "facing=north,half=bottom,shape=straight": {"model": "tiedup:block/padded_stairs", "y": 270, "uvlock": true}, + "facing=north,half=top,shape=inner_left": {"model": "tiedup:block/padded_stairs_inner", "x": 180, "y": 270, "uvlock": true}, + "facing=north,half=top,shape=inner_right": {"model": "tiedup:block/padded_stairs_inner", "x": 180, "uvlock": true}, + "facing=north,half=top,shape=outer_left": {"model": "tiedup:block/padded_stairs_outer", "x": 180, "y": 270, "uvlock": true}, + "facing=north,half=top,shape=outer_right": {"model": "tiedup:block/padded_stairs_outer", "x": 180, "uvlock": true}, + "facing=north,half=top,shape=straight": {"model": "tiedup:block/padded_stairs", "x": 180, "y": 270, "uvlock": true}, + "facing=south,half=bottom,shape=inner_left": {"model": "tiedup:block/padded_stairs_inner"}, + "facing=south,half=bottom,shape=inner_right": {"model": "tiedup:block/padded_stairs_inner", "y": 90, "uvlock": true}, + "facing=south,half=bottom,shape=outer_left": {"model": "tiedup:block/padded_stairs_outer"}, + "facing=south,half=bottom,shape=outer_right": {"model": "tiedup:block/padded_stairs_outer", "y": 90, "uvlock": true}, + "facing=south,half=bottom,shape=straight": {"model": "tiedup:block/padded_stairs", "y": 90, "uvlock": true}, + "facing=south,half=top,shape=inner_left": {"model": "tiedup:block/padded_stairs_inner", "x": 180, "y": 90, "uvlock": true}, + "facing=south,half=top,shape=inner_right": {"model": "tiedup:block/padded_stairs_inner", "x": 180, "y": 180, "uvlock": true}, + "facing=south,half=top,shape=outer_left": {"model": "tiedup:block/padded_stairs_outer", "x": 180, "y": 90, "uvlock": true}, + "facing=south,half=top,shape=outer_right": {"model": "tiedup:block/padded_stairs_outer", "x": 180, "y": 180, "uvlock": true}, + "facing=south,half=top,shape=straight": {"model": "tiedup:block/padded_stairs", "x": 180, "y": 90, "uvlock": true}, + "facing=west,half=bottom,shape=inner_left": {"model": "tiedup:block/padded_stairs_inner", "y": 90, "uvlock": true}, + "facing=west,half=bottom,shape=inner_right": {"model": "tiedup:block/padded_stairs_inner", "y": 180, "uvlock": true}, + "facing=west,half=bottom,shape=outer_left": {"model": "tiedup:block/padded_stairs_outer", "y": 90, "uvlock": true}, + "facing=west,half=bottom,shape=outer_right": {"model": "tiedup:block/padded_stairs_outer", "y": 180, "uvlock": true}, + "facing=west,half=bottom,shape=straight": {"model": "tiedup:block/padded_stairs", "y": 180, "uvlock": true}, + "facing=west,half=top,shape=inner_left": {"model": "tiedup:block/padded_stairs_inner", "x": 180, "y": 180, "uvlock": true}, + "facing=west,half=top,shape=inner_right": {"model": "tiedup:block/padded_stairs_inner", "x": 180, "y": 270, "uvlock": true}, + "facing=west,half=top,shape=outer_left": {"model": "tiedup:block/padded_stairs_outer", "x": 180, "y": 180, "uvlock": true}, + "facing=west,half=top,shape=outer_right": {"model": "tiedup:block/padded_stairs_outer", "x": 180, "y": 270, "uvlock": true}, + "facing=west,half=top,shape=straight": {"model": "tiedup:block/padded_stairs", "x": 180, "y": 180, "uvlock": true} + } +} diff --git a/src/main/resources/assets/tiedup/blockstates/pet_bed.json b/src/main/resources/assets/tiedup/blockstates/pet_bed.json new file mode 100644 index 0000000..b799f02 --- /dev/null +++ b/src/main/resources/assets/tiedup/blockstates/pet_bed.json @@ -0,0 +1,8 @@ +{ + "variants": { + "facing=north": { "model": "tiedup:block/pet_bed" }, + "facing=south": { "model": "tiedup:block/pet_bed", "y": 180 }, + "facing=west": { "model": "tiedup:block/pet_bed", "y": 270 }, + "facing=east": { "model": "tiedup:block/pet_bed", "y": 90 } + } +} diff --git a/src/main/resources/assets/tiedup/blockstates/pet_bowl.json b/src/main/resources/assets/tiedup/blockstates/pet_bowl.json new file mode 100644 index 0000000..05295e7 --- /dev/null +++ b/src/main/resources/assets/tiedup/blockstates/pet_bowl.json @@ -0,0 +1,8 @@ +{ + "variants": { + "facing=north": { "model": "tiedup:block/pet_bowl" }, + "facing=south": { "model": "tiedup:block/pet_bowl", "y": 180 }, + "facing=west": { "model": "tiedup:block/pet_bowl", "y": 270 }, + "facing=east": { "model": "tiedup:block/pet_bowl", "y": 90 } + } +} diff --git a/src/main/resources/assets/tiedup/blockstates/pet_cage.json b/src/main/resources/assets/tiedup/blockstates/pet_cage.json new file mode 100644 index 0000000..7e04158 --- /dev/null +++ b/src/main/resources/assets/tiedup/blockstates/pet_cage.json @@ -0,0 +1,8 @@ +{ + "variants": { + "facing=north": { "model": "tiedup:block/pet_cage" }, + "facing=south": { "model": "tiedup:block/pet_cage", "y": 180 }, + "facing=west": { "model": "tiedup:block/pet_cage", "y": 270 }, + "facing=east": { "model": "tiedup:block/pet_cage", "y": 90 } + } +} diff --git a/src/main/resources/assets/tiedup/blockstates/pet_cage_part.json b/src/main/resources/assets/tiedup/blockstates/pet_cage_part.json new file mode 100644 index 0000000..d842d49 --- /dev/null +++ b/src/main/resources/assets/tiedup/blockstates/pet_cage_part.json @@ -0,0 +1,8 @@ +{ + "variants": { + "facing=north": { "model": "tiedup:block/pet_cage_part" }, + "facing=south": { "model": "tiedup:block/pet_cage_part", "y": 180 }, + "facing=west": { "model": "tiedup:block/pet_cage_part", "y": 270 }, + "facing=east": { "model": "tiedup:block/pet_cage_part", "y": 90 } + } +} diff --git a/src/main/resources/assets/tiedup/blockstates/rope_trap.json b/src/main/resources/assets/tiedup/blockstates/rope_trap.json new file mode 100644 index 0000000..86c8074 --- /dev/null +++ b/src/main/resources/assets/tiedup/blockstates/rope_trap.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "tiedup:block/rope_trap" + } + } +} diff --git a/src/main/resources/assets/tiedup/blockstates/trapped_chest.json b/src/main/resources/assets/tiedup/blockstates/trapped_chest.json new file mode 100644 index 0000000..3fc3ab7 --- /dev/null +++ b/src/main/resources/assets/tiedup/blockstates/trapped_chest.json @@ -0,0 +1,19 @@ +{ + "variants": { + "facing=east": { + "model": "minecraft:block/chest", + "y": 90 + }, + "facing=north": { + "model": "minecraft:block/chest" + }, + "facing=south": { + "model": "minecraft:block/chest", + "y": 180 + }, + "facing=west": { + "model": "minecraft:block/chest", + "y": 270 + } + } +} diff --git a/src/main/resources/assets/tiedup/lang/en_us.json b/src/main/resources/assets/tiedup/lang/en_us.json new file mode 100644 index 0000000..8c18a6f --- /dev/null +++ b/src/main/resources/assets/tiedup/lang/en_us.json @@ -0,0 +1,687 @@ +{ + "itemGroup.tiedup": "TiedUp!", + + "item.tiedup.ropes": "Ropes", + "item.tiedup.shibari": "Shibari Ropes", + "item.tiedup.armbinder": "Armbinder", + "item.tiedup.dogbinder": "Dog Binder", + "item.tiedup.leather_straps": "Leather Straps", + "item.tiedup.medical_straps": "Medical Straps", + "item.tiedup.beam_cuffs": "Beam Cuffs", + "item.tiedup.chain": "Chain", + "item.tiedup.duct_tape": "Duct Tape", + "item.tiedup.ribbon": "Ribbon", + "item.tiedup.slime": "Slime", + "item.tiedup.vine_seed": "Vine Seed", + "item.tiedup.web_bind": "Web Bind", + "item.tiedup.straitjacket": "Straitjacket", + "item.tiedup.wrap": "Body Wrap", + "item.tiedup.latex_sack": "Latex Sack", + "item.tiedup.cloth_gag": "Cloth Gag", + "item.tiedup.ball_gag": "Ball Gag", + "item.tiedup.ball_gag_strap": "Ball Gag (Harness)", + "item.tiedup.tape_gag": "Tape Gag", + "item.tiedup.ropes_gag": "Ropes Gag", + "item.tiedup.cleave_gag": "Cleave Gag", + "item.tiedup.panel_gag": "Panel Gag", + "item.tiedup.latex_gag": "Latex Gag", + "item.tiedup.wrap_gag": "Wrap Gag", + "item.tiedup.ribbon_gag": "Ribbon Gag", + "item.tiedup.slime_gag": "Slime Gag", + "item.tiedup.tube_gag": "Tube Gag", + "item.tiedup.vine_gag": "Vine Gag", + "item.tiedup.bite_gag": "Bite Gag", + "item.tiedup.web_gag": "Web Gag", + "item.tiedup.beam_panel_gag": "Beam Panel Gag", + "item.tiedup.sponge_gag": "Sponge Gag", + "item.tiedup.chain_panel_gag": "Chain Panel Gag", + "item.tiedup.baguette_gag": "Baguette Gag", + "item.tiedup.classic_blindfold": "Classic Blindfold", + "item.tiedup.blindfold_mask": "Blindfold Mask", + "item.tiedup.hood": "Hood", + "item.tiedup.medical_gag": "Medical Gag", + "item.tiedup.clothes": "Clothes", + "item.tiedup.classic_collar": "Classic Collar", + "item.tiedup.shock_collar": "Shock Collar", + "item.tiedup.shock_collar_auto": "Automatic Shock Collar", + "item.tiedup.gps_collar": "GPS Collar", + "item.tiedup.choke_collar": "Choke Collar", + "item.tiedup.classic_earplugs": "Classic Earplugs", + "item.tiedup.mittens": "Leather Mittens", + "item.tiedup.stone_knife": "Stone Knife", + "item.tiedup.iron_knife": "Iron Knife", + "item.tiedup.golden_knife": "Golden Knife", + "item.tiedup.whip": "Whip", + "item.tiedup.chloroform_bottle": "Chloroform Bottle", + "item.tiedup.rag": "Rag", + "item.tiedup.padlock": "Padlock", + "item.tiedup.padlock.tooltip": "Combine with bondage item in Anvil", + "item.tiedup.master_key": "Master Key", + "item.tiedup.rope_arrow": "Rope Arrow", + "item.tiedup.paddle": "Paddle", + "item.tiedup.shocker_controller": "Remote Shocker Controller", + "item.tiedup.gps_locator": "GPS Locator", + "item.tiedup.collar_key": "Collar Key", + "item.tiedup.lockpick": "Lockpick", + "item.tiedup.lockpick.tooltip": "Use to pick locks on bondage items", + "item.tiedup.tiedup_guide": "TiedUp! Guide", + "item.tiedup.tiedup_guide.tooltip": "Right-click to open the guide book", + "item.tiedup.command_wand": "Command Wand", + "item.tiedup.command_wand.tooltip": "Right-click a collared NPC to give commands", + "item.tiedup.damsel_spawn_egg": "Damsel Spawn Egg", + "item.tiedup.furniture_placer": "Furniture", + + "entity.tiedup.damsel": "Damsel", + "entity.tiedup.kidnapper": "Kidnapper", + "entity.tiedup.kidnapper_elite": "Elite Kidnapper", + "entity.tiedup.kidnapper_archer": "Archer Kidnapper", + "entity.tiedup.kidnapper_merchant": "Merchant Kidnapper", + "entity.tiedup.slave_trader": "Slave Trader", + "entity.tiedup.maid": "Maid", + "entity.tiedup.master": "Master", + "entity.tiedup.kidnap_bomb_entity": "Kidnap Bomb", + "entity.tiedup.damsel_shiny": "Shiny Damsel", + "entity.tiedup.labor_guard": "Labor Guard", + "entity.tiedup.rope_arrow": "Rope Arrow", + "entity.tiedup.npc_fishing_bobber": "Fishing Bobber", + "entity.tiedup.furniture": "Furniture", + + "furniture.tiedup.seat_locked": "The seat is locked. Struggle to escape.", + "furniture.tiedup.all_seats_occupied": "All seats are occupied.", + "furniture.tiedup.broke_free": "You broke free!", + "furniture.tiedup.press_shift": "Press [Shift] to dismount", + "furniture.tiedup.test_cross": "Test St. Andrew's Cross", + + "block.tiedup.padded_block": "Padded Block", + "block.tiedup.padded_slab": "Padded Slab", + "block.tiedup.padded_stairs": "Padded Stairs", + "block.tiedup.rope_trap": "Rope Trap", + "block.tiedup.rope_trap.desc": "Ties up entities that walk on it", + "block.tiedup.kidnap_bomb": "Kidnap Bomb", + "block.tiedup.kidnap_bomb.desc": "Applies bondage items to nearby entities when ignited", + "block.tiedup.trapped_chest": "Trapped Chest", + "block.tiedup.trapped_chest.desc": "Traps players when opened (click with item to load)", + "block.tiedup.cell_door": "Cell Door", + "block.tiedup.marker": "Cell Marker", + "block.tiedup.iron_bar_door": "Iron Bar Door", + "block.tiedup.cell_core": "Cell Core", + "msg.tiedup.cell_core.created": "Cell created! (%s interior, %s walls)", + "msg.tiedup.cell_core.too_large": "Room is too large to form a cell.", + "msg.tiedup.cell_core.too_small": "Room is too small (need at least 2 blocks of air).", + "msg.tiedup.cell_core.not_enclosed": "Room is not enclosed.", + "msg.tiedup.cell_core.out_of_bounds": "Room exceeds maximum dimensions (12x8x12).", + + "item.tiedup.admin_wand": "Admin Wand", + "item.tiedup.admin_wand.tooltip": "Structure marker tool and cell debugger", + "item.tiedup.cell_key": "Cell Key", + "item.tiedup.cell_key.tooltip": "Universal key for iron bar doors", + "item.tiedup.token": "Camp Token", + "item.tiedup.token.tooltip": "Allows safe passage through kidnapper camps", + + "tiedup.trap.triggered": "You've been caught in a trap!", + + "subtitles.tiedup.electric_shock": "Electric Shock", + "subtitles.tiedup.shocker_activated": "Shocker Activated", + "subtitles.tiedup.collar_put": "Collar Put On", + "subtitles.tiedup.collar_key_open": "Collar Unlocked", + "subtitles.tiedup.collar_key_close": "Collar Locked", + + "key.categories.tiedup": "TiedUp!", + "key.tiedup.struggle": "Struggle", + "key.tiedup.adjustment_screen": "Adjust Items", + "key.tiedup.bondage_inventory": "Bondage Inventory", + "key.tiedup.slave_management": "Slave Management", + "key.tiedup.bounties": "Bounty List", + "key.tiedup.force_seat": "Force Seat", + "key.tiedup.tighten": "Tighten Binds", + + "gui.tiedup.adjust_position": "Adjust Position", + "gui.tiedup.bondage_inventory": "Bondage Inventory", + "gui.tiedup.adjust": "Adjust", + "gui.tiedup.close": "Close", + "gui.tiedup.tab.gag": "Gag", + "gui.tiedup.tab.blindfold": "Blindfold", + "gui.tiedup.tab.both": "Both", + "gui.tiedup.done": "Done", + "gui.tiedup.empty": "(empty)", + "gui.tiedup.preview": "Preview: %s", + "gui.tiedup.adjustment_slider": "Adjustment: %s", + "gui.tiedup.slot_empty": "%s slot is empty", + "gui.tiedup.slot_item": "%s: %s", + "gui.tiedup.cancel": "Cancel", + + "gui.tiedup.slot.bind": "Bind", + "gui.tiedup.slot.gag": "Gag", + "gui.tiedup.slot.blindfold": "Blindfold", + "gui.tiedup.slot.collar": "Collar", + "gui.tiedup.slot.earplugs": "Earplugs", + "gui.tiedup.slot.clothes": "Clothes", + "gui.tiedup.slot.mittens": "Mittens", + + "gui.tiedup.region.head": "Head", + "gui.tiedup.region.eyes": "Eyes", + "gui.tiedup.region.ears": "Ears", + "gui.tiedup.region.mouth": "Mouth", + "gui.tiedup.region.neck": "Neck", + "gui.tiedup.region.torso": "Torso", + "gui.tiedup.region.arms": "Arms", + "gui.tiedup.region.hands": "Hands", + "gui.tiedup.region.fingers": "Fingers", + "gui.tiedup.region.waist": "Waist", + "gui.tiedup.region.legs": "Legs", + "gui.tiedup.region.feet": "Feet", + "gui.tiedup.region.tail": "Tail", + "gui.tiedup.region.wings": "Wings", + + "gui.tiedup.item_management.remove": "Remove Item", + "gui.tiedup.item_management.lock": "Lock Item", + "gui.tiedup.item_management.unlock": "Unlock Item", + "gui.tiedup.item_management.select_remove": "Select an item to remove", + "gui.tiedup.item_management.select_lock": "Select an item to lock", + "gui.tiedup.item_management.select_unlock": "Select an item to unlock", + + "gui.tiedup.slave_management": "Slave Management", + "gui.tiedup.slave_management.no_slaves": "You have no slaves", + "gui.tiedup.slave_management.adjust_remote_todo": "Remote adjustment not yet implemented", + "gui.tiedup.slave_management.shocked": "Shocked %s!", + "gui.tiedup.slave_management.located": "Located %s on map", + "gui.tiedup.slave_management.freed": "Freed %s", + + "gui.tiedup.bounties": "Bounty List", + "gui.tiedup.bounties.title": "Active Bounties", + "gui.tiedup.bounties.delete": "Cancel Bounty", + "gui.tiedup.bounties.noEntries": "No active bounties", + "gui.tiedup.bounties.client": "Posted by: %s", + "gui.tiedup.bounties.target": "Target: %s", + "gui.tiedup.bounties.reward": "Reward: %s", + "gui.tiedup.bounties.time": "Time left: %sh %smin", + "gui.tiedup.bounties.expired": "Expired", + "gui.tiedup.bounties.delivered": "Bounty completed! Reward given.", + "gui.tiedup.bounties.cancelled": "Bounty cancelled. Reward returned.", + + "gui.tiedup.action.being_tied": "Being tied up...", + "gui.tiedup.action.being_tied_by": "%s is tying you up!", + "gui.tiedup.action.tying_target": "Tying %s...", + "gui.tiedup.action.being_untied": "Being untied...", + "gui.tiedup.action.being_untied_by": "%s is untying you!", + "gui.tiedup.action.untying_target": "Untying %s...", + "gui.tiedup.action.feeding_target": "Feeding %s...", + "gui.tiedup.action.being_fed_by": "%s is feeding you!", + "gui.tiedup.action.struggling": "Struggling...", + + "chat.tiedup.gag.muffled": "You try to speak through the %s...", + "chat.tiedup.gag.crit_fail": "Your words are completely lost behind the %s!", + "chat.tiedup.gag.no_one_heard": "No one was close enough to hear your muffled cries.", + "itemGroup.tiedup_tab": "TiedUp! Items", + + "tiedup.sale.wrong_item": "The kidnapper wants %s", + "tiedup.sale.not_enough": "You have %d but need %d %s", + "tiedup.sale.success": "You purchased %s!", + "tiedup.sale.failed": "Sale failed!", + + "tiedup.restriction.cannot_break": "You can't break blocks while tied up!", + "tiedup.restriction.cannot_place": "You can't place blocks while tied up!", + "tiedup.restriction.cannot_interact": "You can't interact with that while tied up!", + "tiedup.restriction.cannot_use_item": "You can't use items while tied up!", + "tiedup.restriction.no_elytra": "You can't fly with elytra while tied up!", + + "item.tiedup.tooltip.locked": "Locked", + "item.tiedup.tooltip.lockable": "Lockable (has padlock)", + "item.tiedup.tooltip.jammed": "Jammed (lockpick blocked)", + "item.tiedup.tooltip.escape_difficulty": "Escape Difficulty: %s", + + "item.tiedup.v2_handcuffs": "Handcuffs", + + "gui.tiedup.struggle_choice": "Choose Item to Struggle", + "gui.tiedup.struggle": "Struggle", + "gui.tiedup.continuous_struggle": "Struggling...", + "gui.tiedup.lockpick": "Lockpick", + "gui.tiedup.jammed": "JAMMED", + "gui.tiedup.lockpick_blocked_mittens": "Lockpick blocked by mittens", + "gui.tiedup.no_lockpick": "No lockpick in inventory", + "gui.tiedup.lockpick_uses": "Lockpick: %d uses left", + + "tiedup.lockpick.success": "Lock picked!", + "tiedup.lockpick.fail": "Lockpick slipped...", + "tiedup.lockpick.broke": "Lockpick broke!", + "tiedup.lockpick.jammed": "The lock jammed! Only struggle can open it now.", + "tiedup.lockpick.blocked_mittens": "You can't use a lockpick with mittens on!", + "tiedup.lockpick.blocked_jammed": "This lock is jammed! Use struggle instead.", + + "tiedup.struggle.nothing": "Nothing to struggle against", + + "tiedup.bindmode.full": "Full", + "tiedup.bindmode.arms": "Arms Only", + "tiedup.bindmode.legs": "Legs Only", + "item.tiedup.tooltip.bindmode": "Mode: %s", + "tiedup.message.bindmode_changed": "Bind mode changed to: %s", + "tiedup.message.kidnapper_guards_captive": "The kidnapper is guarding their captive!", + + "gui.tiedup.merchant.title": "Merchant Trading", + "gui.tiedup.merchant.buy": "Buy", + + "item.tiedup.clothes.tooltip.has_url": "Dynamic texture set", + "item.tiedup.clothes.tooltip.no_url": "No dynamic texture (use /tiedup clothes url set)", + "item.tiedup.clothes.tooltip.full_skin": "Full-skin mode enabled", + "item.tiedup.clothes.tooltip.small_arms": "Small arms forced", + "item.tiedup.clothes.tooltip.layers_disabled": "Hidden layers: %s", + + "command.tiedup.clothes.not_holding": "You must hold clothes in your main hand!", + "command.tiedup.clothes.url_must_https": "URL must start with https://", + "command.tiedup.clothes.url_too_long": "URL is too long (max 2048 characters)", + "command.tiedup.clothes.url_set": "Dynamic texture URL set!", + "command.tiedup.clothes.url_reset": "Dynamic texture URL removed!", + "command.tiedup.clothes.fullskin_enabled": "Full-skin mode enabled", + "command.tiedup.clothes.fullskin_disabled": "Full-skin mode disabled", + "command.tiedup.clothes.smallarms_enabled": "Small arms forcing enabled", + "command.tiedup.clothes.smallarms_disabled": "Small arms forcing disabled", + "command.tiedup.clothes.unknown_layer": "Unknown layer: %s", + "command.tiedup.clothes.layer_visible": "Wearer's %s layer is now visible", + "command.tiedup.clothes.layer_hidden": "Wearer's %s layer is now hidden", + + "gui.tiedup.command_wand.title": "Command Wand", + "gui.tiedup.command_wand.personality": "Personality", + "gui.tiedup.command_wand.trait": "Trait", + "gui.tiedup.command_wand.commands": "Commands", + "gui.tiedup.command_wand.needs": "Needs", + "gui.tiedup.command_wand.active": "Active", + "gui.tiedup.command_wand.cancel": "Cancel", + "gui.tiedup.command_wand.inventory": "Inventory", + "gui.tiedup.command_wand.mental": "Mental State", + "gui.tiedup.command_wand.relationship": "Relationship", + "gui.tiedup.command_wand.follow_distance.tooltip": "How closely the NPC follows. Click to cycle.", + "gui.tiedup.command_wand.follow_distance.current": "Current", + "tiedup.follow_distance.far": "Far", + "tiedup.follow_distance.close": "Close", + "tiedup.follow_distance.heel": "Heel", + "command.follow.distance.far": "Far", + "command.follow.distance.close": "Close", + "command.follow.distance.heel": "Heel", + + "gui.tiedup.npc_inventory": "NPC Inventory", + "gui.tiedup.npc_inventory.title": "Inventory", + "gui.tiedup.npc_inventory.take_all": "Take All", + "gui.tiedup.equipment": "Equipment", + "gui.tiedup.equipment.short": "Gear", + + "tiedup.personality.unknown": "???", + "tiedup.personality.timid": "Timid", + "tiedup.personality.gentle": "Gentle", + "tiedup.personality.submissive": "Submissive", + "tiedup.personality.calm": "Calm", + "tiedup.personality.curious": "Curious", + "tiedup.personality.proud": "Proud", + "tiedup.personality.fierce": "Fierce", + "tiedup.personality.defiant": "Defiant", + "tiedup.personality.playful": "Playful", + "tiedup.personality.masochist": "Masochist", + "tiedup.personality.sadist": "Sadist", + + "tiedup.command.none": "None", + "tiedup.command.follow": "Follow", + "tiedup.command.stay": "Stay", + "tiedup.command.come": "Come", + "tiedup.command.idle": "Rest", + "tiedup.command.sit": "Sit", + "tiedup.command.heel": "Heel", + "tiedup.command.kneel": "Kneel", + "tiedup.command.patrol": "Patrol", + "tiedup.command.guard": "Guard", + "tiedup.command.fetch": "Fetch", + "tiedup.command.collect": "Collect", + "tiedup.command.capture": "Capture", + "tiedup.command.attack": "Attack", + "tiedup.command.defend": "Defend", + "tiedup.command.farm": "Farm", + "tiedup.command.cook": "Cook", + "tiedup.command.transfer": "Transfer", + "tiedup.command.shear": "Shear", + "tiedup.command.mine": "Mine", + "tiedup.command.breed": "Breed", + "tiedup.command.fish": "Fish", + "tiedup.command.sort": "Sort", + "tiedup.command.go_home": "Go Home", + + "tiedup.need.hunger": "Hunger", + "tiedup.need.comfort": "Comfort", + "tiedup.need.rest": "Rest", + "tiedup.need.dignity": "Dignity", + + "tiedup.dialogue.command_accept": "%s nods obediently.", + "tiedup.dialogue.command_refuse": "%s shakes their head defiantly.", + "tiedup.dialogue.command_hesitate": "%s hesitates...", + "tiedup.dialogue.hungry": "%s looks hungry...", + "tiedup.dialogue.tired": "%s looks exhausted...", + "tiedup.dialogue.personality_hint": "You sense %s has a %s personality...", + + "gui.tiedup.command_wand.talk": "Talk...", + "gui.tiedup.command_wand.auto_rest": "Auto-Rest", + "gui.tiedup.command_wand.auto_rest.tooltip": "When enabled, NPC returns home to rest when tired.", + "gui.tiedup.command_wand.subtitle": "Management & Commands", + + "gui.tiedup.command.locked.requires": "Requires", + "gui.tiedup.command.locked.current": "Current", + "gui.tiedup.command.locked.need_more": "Need", + + "gui.tiedup.dialogue.title": "Talk to %s", + "gui.tiedup.dialogue.praise": "Praise", + "gui.tiedup.dialogue.praise.tooltip": "Praise the NPC. (+10 mood)", + "gui.tiedup.dialogue.scold": "Scold", + "gui.tiedup.dialogue.scold.tooltip": "Scold the NPC. (-5 mood)", + "gui.tiedup.dialogue.threaten": "Threaten", + "gui.tiedup.dialogue.threaten.tooltip": "Threaten the NPC. (-8 mood)", + "gui.tiedup.dialogue.ask": "Ask...", + "gui.tiedup.dialogue.ask.tooltip": "Start a conversation", + "gui.tiedup.dialogue.back": "Back", + + "gui.tiedup.conversation.subtitle": "Social Interaction", + + "gui.tiedup.pet_request.title": "Request from %s", + "gui.tiedup.pet_request.subtitle": "What would you like?", + "gui.tiedup.pet_request.food": "Ask for food", + "gui.tiedup.pet_request.food.tooltip": "Request your Master to feed you", + "gui.tiedup.pet_request.sleep": "Ask to rest", + "gui.tiedup.pet_request.sleep.tooltip": "Request to take a rest", + "gui.tiedup.pet_request.walk_passive": "Walk (I lead)", + "gui.tiedup.pet_request.walk_passive.tooltip": "Go for a walk where you lead", + "gui.tiedup.pet_request.walk_active": "Walk (Master leads)", + "gui.tiedup.pet_request.walk_active.tooltip": "Go for a walk where Master leads", + "gui.tiedup.pet_request.tie": "Tie me", + "gui.tiedup.pet_request.tie.tooltip": "Ask to be tied up", + "gui.tiedup.pet_request.untie": "Untie me", + "gui.tiedup.pet_request.untie.tooltip": "Ask to be untied", + "gui.tiedup.pet_request.end": "Thank you, Master", + "gui.tiedup.pet_request.end.tooltip": "End the conversation", + "gui.tiedup.pet_request.cancel": "Cancel", + + "gui.tiedup.slave_trader": "Slave Trader", + "gui.tiedup.buy": "Buy", + "gui.tiedup.close": "Close", + "gui.tiedup.no_captives_available": "No captives available for sale", + + "gui.tiedup.cell_manager": "Cell Manager", + "gui.tiedup.select_cell": "Select Cell", + + "tiedup.trader.greeting": "Welcome, buyer. Browse my selection.", + "tiedup.trader.pitch": "I have %d fine captives for sale today.", + "tiedup.trader.purchase_success": "Purchase complete! The captive is now free.", + + "tiedup.ransom.resold": "You have been put up for sale!", + "tiedup.ransom.abandoned": "You have been abandoned in the wilderness!", + + "tiedup.maid.extraction.0": "To work, slave.", + "tiedup.maid.extraction.1": "Get up. You have work to do.", + "tiedup.maid.extraction.2": "Time to work.", + "tiedup.maid.extraction.3": "Stand up. We need you.", + + "tiedup.maid.retrieval.0": "Time's up. Return the tools.", + "tiedup.maid.retrieval.1": "Time is up. Give me that.", + "tiedup.maid.retrieval.2": "That's enough. Back to your cell.", + "tiedup.maid.retrieval.3": "Done. Follow me.", + + "tiedup.maid.success.0": "Good. Rest now.", + "tiedup.maid.success.1": "Good work. Go rest.", + "tiedup.maid.success.2": "Acceptable. To your cell.", + + "tiedup.maid.failure.0": "Pathetic. You accomplished nothing.", + "tiedup.maid.failure.1": "Pitiful. You will be punished.", + "tiedup.maid.failure.2": "Disappointing. Reflect on your failure.", + + "gui.tiedup.conversation.effectiveness_hint": "Percentages show topic freshness (decreases when used repeatedly)", + + "gui.tiedup.command_wand.tab.status": "Status", + "gui.tiedup.command_wand.tab.command": "Commands", + "gui.tiedup.command_wand.tab.jobs": "Jobs", + "gui.tiedup.command_wand.stop_action": "Stop Action", + "gui.tiedup.command_wand.mood": "Mood", + "gui.tiedup.command_wand.mood.happy": "Happy", + "gui.tiedup.command_wand.mood.neutral": "Neutral", + "gui.tiedup.command_wand.mood.sad": "Sad", + "gui.tiedup.command_wand.cell": "Cell", + "gui.tiedup.command_wand.home_type": "Home", + "gui.tiedup.command_wand.home_type.bed": "Bed", + "gui.tiedup.command_wand.home_type.pet_bed": "Pet Bed", + "gui.tiedup.command_wand.home_type.cell": "Cell", + "gui.tiedup.command_wand.home_type.none": "None", + "gui.tiedup.command_wand.follow_distance": "Distance", + "gui.tiedup.command_wand.assign_cell": "Assign Cell", + "gui.tiedup.command_wand.assign_cell.tooltip": "Assign a cell to this NPC", + "gui.tiedup.command_wand.assign_cell.tooltip.assigned": "Assigned cell", + "gui.tiedup.command_wand.section.activity": "Current Activity", + "gui.tiedup.command_wand.section.movement": "Movement", + "gui.tiedup.command_wand.section.position": "Position", + "gui.tiedup.command_wand.section.resource": "Resource", + "gui.tiedup.command_wand.section.animal": "Animal", + "gui.tiedup.command_wand.section.logistics": "Logistics", + "gui.tiedup.command_wand.section.security": "Security", + "gui.tiedup.command_wand.section.utility": "Utility", + "gui.tiedup.command_wand.section.discipline": "Discipline", + + "gui.tiedup.cell_core": "Cell Core", + "gui.tiedup.cell_core.set_spawn": "Set Spawn", + "gui.tiedup.cell_core.set_delivery": "Set Delivery", + "gui.tiedup.cell_core.set_disguise": "Set Disguise", + "gui.tiedup.cell_core.rename": "Rename", + "gui.tiedup.cell_core.rescan": "Re-scan", + "gui.tiedup.cell_core.info": "Info", + "gui.tiedup.cell_core.close": "Close", + "msg.tiedup.cell_core.not_owner": "You don't own this cell.", + "msg.tiedup.cell_core.selection.spawn": "Click a block inside the cell to set spawn point...", + "msg.tiedup.cell_core.selection.delivery": "Click a block outside the cell to set delivery point...", + "msg.tiedup.cell_core.selection.disguise": "Click a solid block to copy its appearance...", + "msg.tiedup.cell_core.selection.cancelled": "Selection cancelled.", + "msg.tiedup.cell_core.spawn_set": "Spawn point set!", + "msg.tiedup.cell_core.delivery_set": "Delivery point set!", + "msg.tiedup.cell_core.disguise_set": "Disguise set to %s", + "msg.tiedup.cell_core.rescan_success": "Cell rescanned! (%s interior, %s walls)", + "msg.tiedup.cell_core.rescan_fail": "Re-scan failed: %s", + "msg.tiedup.cell_core.door_locked": "This door is locked.", + "msg.tiedup.cell_core.cant_break_core": "You cannot break the Cell Core!", + "msg.tiedup.cell_core.not_inside_cell": "That block is not inside the cell!", + "msg.tiedup.cell_core.must_be_outside": "Delivery point must be outside the cell!", + "msg.tiedup.cell_core.must_be_solid": "Must select a solid block!", + "msg.tiedup.cell_core.breach_repaired": "Cell wall fully repaired!", + + "gamerule.spawnGenderMode": "Spawn Gender Mode (0=Both, 1=Female only, 2=Male only)", + "gamerule.tyingPlayerTime": "Time (seconds) to tie up a player", + "gamerule.untyingPlayerTime": "Time (seconds) to untie a player", + "gamerule.probabilityStruggle": "Struggle escape chance (0-100%)", + "gamerule.struggleCollarRandomShock": "Random shock probability when struggling with collar (0-100%)", + "gamerule.shockerControllerBaseRadius": "Shock collar controller radius (blocks)", + "gamerule.enslavementEnabled": "Enable/disable enslavement system", + "gamerule.kidnapBombRadius": "Kidnap bomb effect radius (blocks)", + "gamerule.gagTalkProximity": "Gagged players can only be heard nearby", + "gamerule.struggle": "Enable/disable struggle system", + "gamerule.struggleMinDecrease": "Minimum resistance decrease per successful struggle", + "gamerule.struggleMaxDecrease": "Maximum resistance decrease per successful struggle", + "gamerule.struggleTimer": "Cooldown between struggle attempts (ticks, 20 = 1 second)", + "gamerule.resistanceRope": "Base resistance for rope binds", + "gamerule.resistanceGag": "Base resistance for gags", + "gamerule.resistanceBlindfold": "Base resistance for blindfolds", + "gamerule.resistanceCollar": "Base resistance for collars", + "gamerule.damselsSpawn": "Enable/disable damsel NPC spawning", + "gamerule.kidnappersSpawn": "Enable/disable kidnapper NPC spawning", + "gamerule.damselSpawnRate": "Damsel spawn rate multiplier (0-100%)", + "gamerule.kidnapperSpawnRate": "Kidnapper spawn rate multiplier (0-100%)", + "gamerule.kidnapperArcherSpawnRate": "Kidnapper Archer spawn rate multiplier (0-100%)", + "gamerule.kidnapperEliteSpawnRate": "Kidnapper Elite spawn rate multiplier (0-100%)", + "gamerule.kidnapperMerchantSpawnRate": "Kidnapper Merchant spawn rate multiplier (0-100%)", + "gamerule.masterSpawnRate": "Master spawn rate multiplier (0-100%)", + "gamerule.maxBounties": "Maximum bounties per player", + "gamerule.bountyDuration": "Bounty duration (seconds)", + "gamerule.bountyDeliveryRadius": "Bounty delivery detection radius (blocks)", + "gamerule.padlockResistance": "Resistance added by a padlock", + + "gui.tiedup.adjustment.scale": "Scale", + "gui.tiedup.adjustment.position": "Position", + + "gui.tiedup.adjustment.no_gag": "No gag equipped", + "gui.tiedup.adjustment.no_blindfold": "No blindfold equipped", + "gui.tiedup.adjustment.both": "Adjusting both items", + + "gui.tiedup.continuous_struggle.label.resistance": "RESISTANCE:", + "gui.tiedup.continuous_struggle.status.shocked": "SHOCKED!", + "gui.tiedup.continuous_struggle.status.struggling": "Struggling...", + "gui.tiedup.continuous_struggle.hold_key": "HOLD [%s] to struggle!", + "gui.tiedup.continuous_struggle.status.locked": "\uD83D\uDD12 LOCKED", + "gui.tiedup.continuous_struggle.status.escaped": "ESCAPED!", + "gui.tiedup.continuous_struggle.status.stopped": "Stopped", + "gui.tiedup.continuous_struggle.status.press_esc": "Press ESC to stop", + + "gui.tiedup.lockpick_minigame.label.position": "Position:", + "gui.tiedup.lockpick_minigame.label.tension": "Tension:", + "gui.tiedup.lockpick_minigame.status.unlocked": "UNLOCKED!", + "gui.tiedup.lockpick_minigame.status.out_of_picks": "OUT OF LOCKPICKS!", + "gui.tiedup.lockpick_minigame.hint": "[A/D] Move [SPACE] Test [ESC] Cancel", + + "gui.tiedup.struggle_choice.button.struggle": "Struggle", + "gui.tiedup.struggle_choice.button.lockpick": "Lockpick", + "gui.tiedup.struggle_choice.button.cut": "Cut", + "gui.tiedup.struggle_choice.cut_hint": "Hold knife and right-click to cut", + "gui.tiedup.struggle_choice.label.resistance": "Res: %d", + "gui.tiedup.struggle_choice.status.mittens_blocked": "Cannot use tools with mittens!", + "gui.tiedup.struggle_choice.status.lockpick_uses": "Lockpick: %d", + "gui.tiedup.struggle_choice.status.no_lockpick": "No lockpick", + "gui.tiedup.struggle_choice.status.knife_uses": "Knife: %d", + "gui.tiedup.struggle_choice.status.no_knife": "No knife", + + "gui.tiedup.cell_manager.button.rename": "Rename", + "gui.tiedup.cell_manager.button.delete": "Delete", + "gui.tiedup.cell_manager.button.release": "Release", + "gui.tiedup.cell_manager.button.teleport": "Teleport", + "gui.tiedup.cell_manager.button.save": "Save", + "gui.tiedup.cell_manager.cell_name": "Cell name", + "gui.tiedup.cell_manager.label.cell_count": "%s cell(s)", + "gui.tiedup.cell_manager.label.op_mode": "(OP Mode)", + "gui.tiedup.cell_manager.label.owner": "Owner: %s", + "gui.tiedup.cell_manager.label.empty": "(empty)", + "gui.tiedup.cell_manager.status.rename_hint": "Press Enter to save, Escape to cancel", + "gui.tiedup.cell_manager.status.no_cells": "No cells created", + "gui.tiedup.cell_manager.status.use_cellwand": "Use CellWand to create cells", + + "gui.tiedup.cell_selector.button.confirm": "Confirm", + "gui.tiedup.cell_selector.button.clear": "Clear", + "gui.tiedup.cell_selector.button.back": "Back", + "gui.tiedup.cell_selector.status.full": "FULL", + "gui.tiedup.cell_selector.status.no_cells": "No cells available", + "gui.tiedup.cell_selector.status.use_cellwand": "Create cells with CellWand first", + + "gui.tiedup.cell_core.cell_name": "Cell name", + "gui.tiedup.cell_core.button.ok": "OK", + "gui.tiedup.cell_core.info.state": "State: %s", + "gui.tiedup.cell_core.info.interior": "Interior: %s", + "gui.tiedup.cell_core.info.walls": "Walls: %s", + "gui.tiedup.cell_core.info.walls_breached": "Walls: %s (%s breached)", + "gui.tiedup.cell_core.info.prisoners": "Prisoners: %s", + "gui.tiedup.cell_core.info.beds_doors_anchors": "Beds: %s Doors: %s Anchors: %s", + "gui.tiedup.cell_core.info.feature_spawn": "Spawn", + "gui.tiedup.cell_core.info.feature_delivery": "Delivery", + "gui.tiedup.cell_core.info.feature_disguise": "Disguise", + "gui.tiedup.cell_core.info.none_set": "(none set)", + "gui.tiedup.cell_core.info.set": "Set: %s", + + "gui.tiedup.slave_item.button.lock": "Lock", + "gui.tiedup.slave_item.button.unlock": "Unlock", + "gui.tiedup.slave_item.button.remove": "Remove", + "gui.tiedup.slave_item.mode.full": "Full", + "gui.tiedup.slave_item.mode.arms": "Arms", + "gui.tiedup.slave_item.mode.legs": "Legs", + "gui.tiedup.slave_item.button.svc_on": "Svc:ON", + "gui.tiedup.slave_item.button.svc_off": "Svc:OFF", + "gui.tiedup.slave_item.button.cell_assigned": "Cell \u2713", + "gui.tiedup.slave_item.button.cell_select": "Cell...", + "gui.tiedup.slave_item.label.master_key": "(Master Key)", + + "gui.tiedup.conversation.title": "Conversation with %s", + "gui.tiedup.conversation.heading": "Conversation", + "gui.tiedup.conversation.wip_badge": "[WIP]", + "gui.tiedup.conversation.status.coming_soon": "Conversation system coming soon...", + "gui.tiedup.conversation.wip.small_talk": "[WIP] Small Talk", + "gui.tiedup.conversation.wip.deep_topics": "[WIP] Deep Topics", + "gui.tiedup.conversation.wip.flirting": "[WIP] Flirting", + "gui.tiedup.conversation.wip.requests": "[WIP] Requests", + + "gui.tiedup.command_wand.current_activity": "Current Activity", + "gui.tiedup.command_wand.idle": "Idle", + "gui.tiedup.command_wand.skill": "Skill", + "gui.tiedup.command_wand.stop": "Stop Action", + + "gui.tiedup.command_wand.hunger.tooltip": "How full the NPC is. Feed them or assign a cooking job.", + "gui.tiedup.command_wand.rest.tooltip": "How rested the NPC is. Assign a home for rest.", + "gui.tiedup.command_wand.mood.tooltip": "Overall happiness. Affected by needs and cell quality.", + "gui.tiedup.command_wand.stop.tooltip": "Stop the current command and return to idle.", + "gui.tiedup.command_wand.inventory.tooltip": "Open the NPC's inventory.", + + "tiedup.command.follow.tooltip": "Follow you at the current distance setting.", + "tiedup.command.come.tooltip": "Come to your position immediately.", + "tiedup.command.go_home.tooltip": "Return to assigned home.", + "tiedup.command.stay.tooltip": "Stay at current position.", + "tiedup.command.sit.tooltip": "Sit down in place.", + "tiedup.command.kneel.tooltip": "Kneel in place.", + "tiedup.command.idle.tooltip": "Rest and speak idle dialogue.", + "tiedup.command.farm.tooltip": "Farm crops. Close GUI then click a chest to set zone.", + "tiedup.command.mine.tooltip": "Mine blocks. Close GUI then click a chest to set zone.", + "tiedup.command.fish.tooltip": "Fish near water. Close GUI then click a chest.", + "tiedup.command.breed.tooltip": "Breed animals. Close GUI then click a chest.", + "tiedup.command.shear.tooltip": "Shear sheep. Close GUI then click a chest.", + "tiedup.command.cook.tooltip": "Cook items in furnaces. Close GUI then click a chest.", + "tiedup.command.transfer.tooltip": "Transfer items between chests. Click source then destination.", + "tiedup.command.sort.tooltip": "Sort items by category. Close GUI then click a chest.", + "tiedup.command.patrol.tooltip": "Patrol a zone. Close GUI then click a chest.", + "tiedup.command.guard.tooltip": "Guard a zone and alert on intruders.", + "tiedup.command.collect.tooltip": "Collect dropped items into a chest.", + "tiedup.command.fetch.tooltip": "Pick up a nearby item.", + + "gui.tiedup.job_level.novice": "Novice", + "gui.tiedup.job_level.apprentice": "Apprentice", + "gui.tiedup.job_level.skilled": "Skilled", + "gui.tiedup.job_level.expert": "Expert", + + "gui.tiedup.unified_bondage": "Bondage Equipment", + "gui.tiedup.mode.self": "SELF", + "gui.tiedup.mode.master": "\u265B MASTER", + "gui.tiedup.tab.head": "Head", + "gui.tiedup.tab.upper": "Upper", + "gui.tiedup.tab.arms": "Arms", + "gui.tiedup.tab.lower": "Lower", + "gui.tiedup.tab.special": "Special", + "gui.tiedup.tab_bar": "Tab bar, active: %s", + "gui.tiedup.equip": "+ Equip", + "gui.tiedup.close_esc": "Close [ESC]", + "gui.tiedup.action.equip": "Equip", + "gui.tiedup.action.remove": "Remove", + "gui.tiedup.action.struggle": "Struggle", + "gui.tiedup.action.lockpick": "Lockpick", + "gui.tiedup.action.cut": "Cut", + "gui.tiedup.action.adjust": "Adjust", + "gui.tiedup.action.lock": "Lock", + "gui.tiedup.action.unlock": "Unlock", + "gui.tiedup.action.svc_on": "Svc ON", + "gui.tiedup.action.svc_off": "Svc OFF", + "gui.tiedup.action.cell_assign": "Cell", + "gui.tiedup.action.no_selection": "No item selected", + "gui.tiedup.action.title_empty": "Empty — %s", + "gui.tiedup.action_panel": "Action panel", + "gui.tiedup.reason.locked": "Item is locked", + "gui.tiedup.reason.arms_bound": "Arms are bound", + "gui.tiedup.reason.hands_bound": "Hands are bound", + "gui.tiedup.reason.use_struggle": "Use Struggle to free arms", + "gui.tiedup.reason.no_lockpick": "No lockpick in inventory", + "gui.tiedup.reason.no_knife": "No knife in inventory", + "gui.tiedup.reason.no_key": "No key in inventory", + "gui.tiedup.reason.mittens": "Cannot use tools with mittens", + "gui.tiedup.reason.jammed": "Lock is jammed", + "gui.tiedup.reason.wrong_key": "Key doesn't match this lock", + "gui.tiedup.picker.title": "Equip — %s", + "gui.tiedup.picker.empty": "No compatible items in inventory", + "gui.tiedup.picker.arms_warning": "Warning: binding your own arms will restrict your actions!", + "gui.tiedup.status.lockpick_uses": "Lockpick: %d uses", + "gui.tiedup.status.knife_uses": "Knife: %d uses", + "gui.tiedup.status.no_lockpick": "No lockpick", + "gui.tiedup.status.no_knife": "No knife", + "gui.tiedup.status.arms_resistance": "Arms Resistance: %d/%d", + "gui.tiedup.status.key_info": "Key: %s", + "gui.tiedup.status.no_key": "No key", + "gui.tiedup.status.target_info": "Target: %s", + "gui.tiedup.status_bar": "Status bar" +} diff --git a/src/main/resources/assets/tiedup/models/block/cell_core.json b/src/main/resources/assets/tiedup/models/block/cell_core.json new file mode 100644 index 0000000..40a147c --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/cell_core.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "tiedup:block/cell_core" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/cell_door_bottom_left.json b/src/main/resources/assets/tiedup/models/block/cell_door_bottom_left.json new file mode 100644 index 0000000..cf70caa --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/cell_door_bottom_left.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:block/door_bottom_left", + "textures": { + "bottom": "tiedup:block/cell_door_bottom", + "top": "tiedup:block/cell_door_top" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/cell_door_bottom_left_open.json b/src/main/resources/assets/tiedup/models/block/cell_door_bottom_left_open.json new file mode 100644 index 0000000..d88587a --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/cell_door_bottom_left_open.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:block/door_bottom_left_open", + "textures": { + "bottom": "tiedup:block/cell_door_bottom", + "top": "tiedup:block/cell_door_top" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/cell_door_bottom_right.json b/src/main/resources/assets/tiedup/models/block/cell_door_bottom_right.json new file mode 100644 index 0000000..9e3f2ce --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/cell_door_bottom_right.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:block/door_bottom_right", + "textures": { + "bottom": "tiedup:block/cell_door_bottom", + "top": "tiedup:block/cell_door_top" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/cell_door_bottom_right_open.json b/src/main/resources/assets/tiedup/models/block/cell_door_bottom_right_open.json new file mode 100644 index 0000000..e77c017 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/cell_door_bottom_right_open.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:block/door_bottom_right_open", + "textures": { + "bottom": "tiedup:block/cell_door_bottom", + "top": "tiedup:block/cell_door_top" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/cell_door_top_left.json b/src/main/resources/assets/tiedup/models/block/cell_door_top_left.json new file mode 100644 index 0000000..b54d5b2 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/cell_door_top_left.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:block/door_top_left", + "textures": { + "bottom": "tiedup:block/cell_door_bottom", + "top": "tiedup:block/cell_door_top" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/cell_door_top_left_open.json b/src/main/resources/assets/tiedup/models/block/cell_door_top_left_open.json new file mode 100644 index 0000000..e2cc110 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/cell_door_top_left_open.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:block/door_top_left_open", + "textures": { + "bottom": "tiedup:block/cell_door_bottom", + "top": "tiedup:block/cell_door_top" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/cell_door_top_right.json b/src/main/resources/assets/tiedup/models/block/cell_door_top_right.json new file mode 100644 index 0000000..59df74b --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/cell_door_top_right.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:block/door_top_right", + "textures": { + "bottom": "tiedup:block/cell_door_bottom", + "top": "tiedup:block/cell_door_top" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/cell_door_top_right_open.json b/src/main/resources/assets/tiedup/models/block/cell_door_top_right_open.json new file mode 100644 index 0000000..938e564 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/cell_door_top_right_open.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:block/door_top_right_open", + "textures": { + "bottom": "tiedup:block/cell_door_bottom", + "top": "tiedup:block/cell_door_top" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/iron_bar_door_bottom_left.json b/src/main/resources/assets/tiedup/models/block/iron_bar_door_bottom_left.json new file mode 100644 index 0000000..c6ff16e --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/iron_bar_door_bottom_left.json @@ -0,0 +1,8 @@ +{ + "parent": "minecraft:block/door_bottom_left", + "render_type": "cutout", + "textures": { + "bottom": "tiedup:block/ironbars_door_bottom", + "top": "tiedup:block/ironbars_door_top" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/iron_bar_door_bottom_left_open.json b/src/main/resources/assets/tiedup/models/block/iron_bar_door_bottom_left_open.json new file mode 100644 index 0000000..e0b7ceb --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/iron_bar_door_bottom_left_open.json @@ -0,0 +1,8 @@ +{ + "parent": "minecraft:block/door_bottom_left_open", + "render_type": "cutout", + "textures": { + "bottom": "tiedup:block/ironbars_door_bottom", + "top": "tiedup:block/ironbars_door_top" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/iron_bar_door_bottom_right.json b/src/main/resources/assets/tiedup/models/block/iron_bar_door_bottom_right.json new file mode 100644 index 0000000..265e0f6 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/iron_bar_door_bottom_right.json @@ -0,0 +1,8 @@ +{ + "parent": "minecraft:block/door_bottom_right", + "render_type": "cutout", + "textures": { + "bottom": "tiedup:block/ironbars_door_bottom", + "top": "tiedup:block/ironbars_door_top" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/iron_bar_door_bottom_right_open.json b/src/main/resources/assets/tiedup/models/block/iron_bar_door_bottom_right_open.json new file mode 100644 index 0000000..bb93c10 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/iron_bar_door_bottom_right_open.json @@ -0,0 +1,8 @@ +{ + "parent": "minecraft:block/door_bottom_right_open", + "render_type": "cutout", + "textures": { + "bottom": "tiedup:block/ironbars_door_bottom", + "top": "tiedup:block/ironbars_door_top" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/iron_bar_door_top_left.json b/src/main/resources/assets/tiedup/models/block/iron_bar_door_top_left.json new file mode 100644 index 0000000..3bb7acb --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/iron_bar_door_top_left.json @@ -0,0 +1,8 @@ +{ + "parent": "minecraft:block/door_top_left", + "render_type": "cutout", + "textures": { + "bottom": "tiedup:block/ironbars_door_bottom", + "top": "tiedup:block/ironbars_door_top" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/iron_bar_door_top_left_open.json b/src/main/resources/assets/tiedup/models/block/iron_bar_door_top_left_open.json new file mode 100644 index 0000000..5c074dc --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/iron_bar_door_top_left_open.json @@ -0,0 +1,8 @@ +{ + "parent": "minecraft:block/door_top_left_open", + "render_type": "cutout", + "textures": { + "bottom": "tiedup:block/ironbars_door_bottom", + "top": "tiedup:block/ironbars_door_top" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/iron_bar_door_top_right.json b/src/main/resources/assets/tiedup/models/block/iron_bar_door_top_right.json new file mode 100644 index 0000000..57d5e37 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/iron_bar_door_top_right.json @@ -0,0 +1,8 @@ +{ + "parent": "minecraft:block/door_top_right", + "render_type": "cutout", + "textures": { + "bottom": "tiedup:block/ironbars_door_bottom", + "top": "tiedup:block/ironbars_door_top" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/iron_bar_door_top_right_open.json b/src/main/resources/assets/tiedup/models/block/iron_bar_door_top_right_open.json new file mode 100644 index 0000000..4008e69 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/iron_bar_door_top_right_open.json @@ -0,0 +1,8 @@ +{ + "parent": "minecraft:block/door_top_right_open", + "render_type": "cutout", + "textures": { + "bottom": "tiedup:block/ironbars_door_bottom", + "top": "tiedup:block/ironbars_door_top" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/kidnap_bomb.json b/src/main/resources/assets/tiedup/models/block/kidnap_bomb.json new file mode 100644 index 0000000..9696869 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/kidnap_bomb.json @@ -0,0 +1,8 @@ +{ + "parent": "minecraft:block/cube_bottom_top", + "textures": { + "bottom": "tiedup:block/kidnap_bomb_bottom", + "side": "tiedup:block/kidnap_bomb_side", + "top": "tiedup:block/kidnap_bomb_top" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/marker.json b/src/main/resources/assets/tiedup/models/block/marker.json new file mode 100644 index 0000000..da37c23 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/marker.json @@ -0,0 +1,6 @@ +{ + "parent": "block/block", + "textures": { + "particle": "minecraft:block/stone" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/padded_block.json b/src/main/resources/assets/tiedup/models/block/padded_block.json new file mode 100644 index 0000000..ea40288 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/padded_block.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "tiedup:block/padded_block" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/padded_slab.json b/src/main/resources/assets/tiedup/models/block/padded_slab.json new file mode 100644 index 0000000..34c9efb --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/padded_slab.json @@ -0,0 +1,8 @@ +{ + "parent": "minecraft:block/slab", + "textures": { + "bottom": "tiedup:block/padded_block", + "side": "tiedup:block/padded_block", + "top": "tiedup:block/padded_block" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/padded_slab_top.json b/src/main/resources/assets/tiedup/models/block/padded_slab_top.json new file mode 100644 index 0000000..d1b05f3 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/padded_slab_top.json @@ -0,0 +1,8 @@ +{ + "parent": "minecraft:block/slab_top", + "textures": { + "bottom": "tiedup:block/padded_block", + "side": "tiedup:block/padded_block", + "top": "tiedup:block/padded_block" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/padded_stairs.json b/src/main/resources/assets/tiedup/models/block/padded_stairs.json new file mode 100644 index 0000000..b4df328 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/padded_stairs.json @@ -0,0 +1,8 @@ +{ + "parent": "minecraft:block/stairs", + "textures": { + "bottom": "tiedup:block/padded_block", + "side": "tiedup:block/padded_block", + "top": "tiedup:block/padded_block" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/padded_stairs_inner.json b/src/main/resources/assets/tiedup/models/block/padded_stairs_inner.json new file mode 100644 index 0000000..303bb0f --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/padded_stairs_inner.json @@ -0,0 +1,8 @@ +{ + "parent": "minecraft:block/inner_stairs", + "textures": { + "bottom": "tiedup:block/padded_block", + "side": "tiedup:block/padded_block", + "top": "tiedup:block/padded_block" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/padded_stairs_outer.json b/src/main/resources/assets/tiedup/models/block/padded_stairs_outer.json new file mode 100644 index 0000000..0ec18b9 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/padded_stairs_outer.json @@ -0,0 +1,8 @@ +{ + "parent": "minecraft:block/outer_stairs", + "textures": { + "bottom": "tiedup:block/padded_block", + "side": "tiedup:block/padded_block", + "top": "tiedup:block/padded_block" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/pet_bed.json b/src/main/resources/assets/tiedup/models/block/pet_bed.json new file mode 100644 index 0000000..41324e7 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/pet_bed.json @@ -0,0 +1,5 @@ +{ + "textures": { + "particle": "tiedup:block/pet_bed" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/pet_bowl.json b/src/main/resources/assets/tiedup/models/block/pet_bowl.json new file mode 100644 index 0000000..92d89e1 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/pet_bowl.json @@ -0,0 +1,5 @@ +{ + "textures": { + "particle": "tiedup:block/pet_bowl" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/pet_cage.json b/src/main/resources/assets/tiedup/models/block/pet_cage.json new file mode 100644 index 0000000..9be5907 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/pet_cage.json @@ -0,0 +1,5 @@ +{ + "textures": { + "particle": "tiedup:block/pet_cage" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/pet_cage_part.json b/src/main/resources/assets/tiedup/models/block/pet_cage_part.json new file mode 100644 index 0000000..9be5907 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/pet_cage_part.json @@ -0,0 +1,5 @@ +{ + "textures": { + "particle": "tiedup:block/pet_cage" + } +} diff --git a/src/main/resources/assets/tiedup/models/block/rope_trap.json b/src/main/resources/assets/tiedup/models/block/rope_trap.json new file mode 100644 index 0000000..ba9e5e2 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/block/rope_trap.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/carpet", + "textures": { + "wool": "tiedup:block/rope_trap" + } +} diff --git a/src/main/resources/assets/tiedup/models/gltf/leg_cuffs_proto.glb b/src/main/resources/assets/tiedup/models/gltf/leg_cuffs_proto.glb new file mode 100644 index 0000000..4d950f3 Binary files /dev/null and b/src/main/resources/assets/tiedup/models/gltf/leg_cuffs_proto.glb differ diff --git a/src/main/resources/assets/tiedup/models/gltf/v2/handcuffs/cuffs_prototype.glb b/src/main/resources/assets/tiedup/models/gltf/v2/handcuffs/cuffs_prototype.glb new file mode 100644 index 0000000..2e48b94 Binary files /dev/null and b/src/main/resources/assets/tiedup/models/gltf/v2/handcuffs/cuffs_prototype.glb differ diff --git a/src/main/resources/assets/tiedup/models/item/admin_wand.json b/src/main/resources/assets/tiedup/models/item/admin_wand.json new file mode 100644 index 0000000..e5fd850 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/admin_wand.json @@ -0,0 +1,6 @@ +{ + "parent": "item/handheld", + "textures": { + "layer0": "tiedup:item/admin_wand" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/armbinder.json b/src/main/resources/assets/tiedup/models/item/armbinder.json new file mode 100644 index 0000000..824bdc8 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/armbinder.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "tiedup:item/armbinder" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/baguette_gag.json b/src/main/resources/assets/tiedup/models/item/baguette_gag.json new file mode 100644 index 0000000..275b54c --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/baguette_gag.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/baguette_gag" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag.json b/src/main/resources/assets/tiedup/models/item/ball_gag.json new file mode 100644 index 0000000..ef3617c --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag.json @@ -0,0 +1,98 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "tiedup:item/ball_gag" + }, + "overrides": [ + { + "predicate": { + "custom_model_data": 1 + }, + "model": "tiedup:item/ball_gag_black" + }, + { + "predicate": { + "custom_model_data": 2 + }, + "model": "tiedup:item/ball_gag_blue" + }, + { + "predicate": { + "custom_model_data": 3 + }, + "model": "tiedup:item/ball_gag_brown" + }, + { + "predicate": { + "custom_model_data": 4 + }, + "model": "tiedup:item/ball_gag_cyan" + }, + { + "predicate": { + "custom_model_data": 5 + }, + "model": "tiedup:item/ball_gag_gray" + }, + { + "predicate": { + "custom_model_data": 6 + }, + "model": "tiedup:item/ball_gag_green" + }, + { + "predicate": { + "custom_model_data": 7 + }, + "model": "tiedup:item/ball_gag_light_blue" + }, + { + "predicate": { + "custom_model_data": 8 + }, + "model": "tiedup:item/ball_gag_lime" + }, + { + "predicate": { + "custom_model_data": 9 + }, + "model": "tiedup:item/ball_gag_magenta" + }, + { + "predicate": { + "custom_model_data": 10 + }, + "model": "tiedup:item/ball_gag_orange" + }, + { + "predicate": { + "custom_model_data": 11 + }, + "model": "tiedup:item/ball_gag_pink" + }, + { + "predicate": { + "custom_model_data": 12 + }, + "model": "tiedup:item/ball_gag_purple" + }, + { + "predicate": { + "custom_model_data": 14 + }, + "model": "tiedup:item/ball_gag_silver" + }, + { + "predicate": { + "custom_model_data": 15 + }, + "model": "tiedup:item/ball_gag_white" + }, + { + "predicate": { + "custom_model_data": 16 + }, + "model": "tiedup:item/ball_gag_yellow" + } + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_3d.json b/src/main/resources/assets/tiedup/models/item/ball_gag_3d.json new file mode 100644 index 0000000..2a16b50 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_3d.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/ball_gag" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_black.json b/src/main/resources/assets/tiedup/models/item/ball_gag_black.json new file mode 100644 index 0000000..001ceaa --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_black.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_black"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_blue.json b/src/main/resources/assets/tiedup/models/item/ball_gag_blue.json new file mode 100644 index 0000000..dab1416 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_blue.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_blue"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_brown.json b/src/main/resources/assets/tiedup/models/item/ball_gag_brown.json new file mode 100644 index 0000000..6a9825a --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_brown.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_brown"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_cyan.json b/src/main/resources/assets/tiedup/models/item/ball_gag_cyan.json new file mode 100644 index 0000000..233a0a6 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_cyan.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_cyan"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_gray.json b/src/main/resources/assets/tiedup/models/item/ball_gag_gray.json new file mode 100644 index 0000000..81519e1 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_gray.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_gray"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_green.json b/src/main/resources/assets/tiedup/models/item/ball_gag_green.json new file mode 100644 index 0000000..de6e530 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_green.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_green"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_light_blue.json b/src/main/resources/assets/tiedup/models/item/ball_gag_light_blue.json new file mode 100644 index 0000000..a4d1a85 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_light_blue.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_light_blue"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_lime.json b/src/main/resources/assets/tiedup/models/item/ball_gag_lime.json new file mode 100644 index 0000000..0127f2a --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_lime.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_lime"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_magenta.json b/src/main/resources/assets/tiedup/models/item/ball_gag_magenta.json new file mode 100644 index 0000000..c8b6a58 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_magenta.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_magenta"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_orange.json b/src/main/resources/assets/tiedup/models/item/ball_gag_orange.json new file mode 100644 index 0000000..3d711d7 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_orange.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_orange"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_pink.json b/src/main/resources/assets/tiedup/models/item/ball_gag_pink.json new file mode 100644 index 0000000..ad0955e --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_pink.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_pink"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_purple.json b/src/main/resources/assets/tiedup/models/item/ball_gag_purple.json new file mode 100644 index 0000000..4a237e6 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_purple.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_purple"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_silver.json b/src/main/resources/assets/tiedup/models/item/ball_gag_silver.json new file mode 100644 index 0000000..42a906b --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_silver.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_silver"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_strap.json b/src/main/resources/assets/tiedup/models/item/ball_gag_strap.json new file mode 100644 index 0000000..f728b84 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_strap.json @@ -0,0 +1,98 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/ball_gag_strap" + }, + "overrides": [ + { + "predicate": { + "custom_model_data": 1 + }, + "model": "tiedup:item/ball_gag_strap_black" + }, + { + "predicate": { + "custom_model_data": 2 + }, + "model": "tiedup:item/ball_gag_strap_blue" + }, + { + "predicate": { + "custom_model_data": 3 + }, + "model": "tiedup:item/ball_gag_strap_brown" + }, + { + "predicate": { + "custom_model_data": 4 + }, + "model": "tiedup:item/ball_gag_strap_cyan" + }, + { + "predicate": { + "custom_model_data": 5 + }, + "model": "tiedup:item/ball_gag_strap_gray" + }, + { + "predicate": { + "custom_model_data": 6 + }, + "model": "tiedup:item/ball_gag_strap_green" + }, + { + "predicate": { + "custom_model_data": 7 + }, + "model": "tiedup:item/ball_gag_strap_light_blue" + }, + { + "predicate": { + "custom_model_data": 8 + }, + "model": "tiedup:item/ball_gag_strap_lime" + }, + { + "predicate": { + "custom_model_data": 9 + }, + "model": "tiedup:item/ball_gag_strap_magenta" + }, + { + "predicate": { + "custom_model_data": 10 + }, + "model": "tiedup:item/ball_gag_strap_orange" + }, + { + "predicate": { + "custom_model_data": 11 + }, + "model": "tiedup:item/ball_gag_strap_pink" + }, + { + "predicate": { + "custom_model_data": 12 + }, + "model": "tiedup:item/ball_gag_strap_purple" + }, + { + "predicate": { + "custom_model_data": 14 + }, + "model": "tiedup:item/ball_gag_strap_silver" + }, + { + "predicate": { + "custom_model_data": 15 + }, + "model": "tiedup:item/ball_gag_strap_white" + }, + { + "predicate": { + "custom_model_data": 16 + }, + "model": "tiedup:item/ball_gag_strap_yellow" + } + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_strap_black.json b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_black.json new file mode 100644 index 0000000..815ea79 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_black.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_strap_black"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_strap_blue.json b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_blue.json new file mode 100644 index 0000000..dee3ab4 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_blue.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_strap_blue"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_strap_brown.json b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_brown.json new file mode 100644 index 0000000..4200a40 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_brown.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_strap_brown"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_strap_cyan.json b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_cyan.json new file mode 100644 index 0000000..68cf5b7 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_cyan.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_strap_cyan"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_strap_gray.json b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_gray.json new file mode 100644 index 0000000..6d2bedd --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_gray.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_strap_gray"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_strap_green.json b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_green.json new file mode 100644 index 0000000..493a0a6 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_green.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_strap_green"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_strap_light_blue.json b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_light_blue.json new file mode 100644 index 0000000..2de7744 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_light_blue.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_strap_light_blue"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_strap_lime.json b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_lime.json new file mode 100644 index 0000000..f338432 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_lime.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_strap_lime"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_strap_magenta.json b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_magenta.json new file mode 100644 index 0000000..669001b --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_magenta.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_strap_magenta"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_strap_orange.json b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_orange.json new file mode 100644 index 0000000..e375542 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_orange.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_strap_orange"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_strap_pink.json b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_pink.json new file mode 100644 index 0000000..c05de26 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_pink.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_strap_pink"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_strap_purple.json b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_purple.json new file mode 100644 index 0000000..5a7a85b --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_purple.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_strap_purple"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_strap_silver.json b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_silver.json new file mode 100644 index 0000000..902b272 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_silver.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_strap_silver"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_strap_white.json b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_white.json new file mode 100644 index 0000000..188cf40 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_white.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_strap_white"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_strap_yellow.json b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_yellow.json new file mode 100644 index 0000000..41931ce --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_strap_yellow.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_strap_yellow"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_white.json b/src/main/resources/assets/tiedup/models/item/ball_gag_white.json new file mode 100644 index 0000000..653561e --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_white.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_white"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ball_gag_yellow.json b/src/main/resources/assets/tiedup/models/item/ball_gag_yellow.json new file mode 100644 index 0000000..355b644 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ball_gag_yellow.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ball_gag_yellow"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/beam_cuffs.json b/src/main/resources/assets/tiedup/models/item/beam_cuffs.json new file mode 100644 index 0000000..a91bb07 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/beam_cuffs.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/beam_cuffs" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/beam_panel_gag.json b/src/main/resources/assets/tiedup/models/item/beam_panel_gag.json new file mode 100644 index 0000000..efe7b08 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/beam_panel_gag.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/beam_panel_gag" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/bite_gag.json b/src/main/resources/assets/tiedup/models/item/bite_gag.json new file mode 100644 index 0000000..3127ad7 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/bite_gag.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/bite_gag" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/blindfold_mask.json b/src/main/resources/assets/tiedup/models/item/blindfold_mask.json new file mode 100644 index 0000000..10644a4 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/blindfold_mask.json @@ -0,0 +1,98 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/blindfold_mask" + }, + "overrides": [ + { + "predicate": { + "custom_model_data": 2 + }, + "model": "tiedup:item/blindfold_mask_blue" + }, + { + "predicate": { + "custom_model_data": 3 + }, + "model": "tiedup:item/blindfold_mask_brown" + }, + { + "predicate": { + "custom_model_data": 4 + }, + "model": "tiedup:item/blindfold_mask_cyan" + }, + { + "predicate": { + "custom_model_data": 5 + }, + "model": "tiedup:item/blindfold_mask_gray" + }, + { + "predicate": { + "custom_model_data": 6 + }, + "model": "tiedup:item/blindfold_mask_green" + }, + { + "predicate": { + "custom_model_data": 7 + }, + "model": "tiedup:item/blindfold_mask_light_blue" + }, + { + "predicate": { + "custom_model_data": 8 + }, + "model": "tiedup:item/blindfold_mask_lime" + }, + { + "predicate": { + "custom_model_data": 9 + }, + "model": "tiedup:item/blindfold_mask_magenta" + }, + { + "predicate": { + "custom_model_data": 10 + }, + "model": "tiedup:item/blindfold_mask_orange" + }, + { + "predicate": { + "custom_model_data": 11 + }, + "model": "tiedup:item/blindfold_mask_pink" + }, + { + "predicate": { + "custom_model_data": 12 + }, + "model": "tiedup:item/blindfold_mask_purple" + }, + { + "predicate": { + "custom_model_data": 13 + }, + "model": "tiedup:item/blindfold_mask_red" + }, + { + "predicate": { + "custom_model_data": 14 + }, + "model": "tiedup:item/blindfold_mask_silver" + }, + { + "predicate": { + "custom_model_data": 15 + }, + "model": "tiedup:item/blindfold_mask_white" + }, + { + "predicate": { + "custom_model_data": 16 + }, + "model": "tiedup:item/blindfold_mask_yellow" + } + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/blindfold_mask_blue.json b/src/main/resources/assets/tiedup/models/item/blindfold_mask_blue.json new file mode 100644 index 0000000..086100d --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/blindfold_mask_blue.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/blindfold_mask_blue"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/blindfold_mask_brown.json b/src/main/resources/assets/tiedup/models/item/blindfold_mask_brown.json new file mode 100644 index 0000000..f0dfe70 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/blindfold_mask_brown.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/blindfold_mask_brown"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/blindfold_mask_cyan.json b/src/main/resources/assets/tiedup/models/item/blindfold_mask_cyan.json new file mode 100644 index 0000000..08941b7 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/blindfold_mask_cyan.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/blindfold_mask_cyan"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/blindfold_mask_gray.json b/src/main/resources/assets/tiedup/models/item/blindfold_mask_gray.json new file mode 100644 index 0000000..a818282 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/blindfold_mask_gray.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/blindfold_mask_gray"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/blindfold_mask_green.json b/src/main/resources/assets/tiedup/models/item/blindfold_mask_green.json new file mode 100644 index 0000000..48478a3 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/blindfold_mask_green.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/blindfold_mask_green"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/blindfold_mask_light_blue.json b/src/main/resources/assets/tiedup/models/item/blindfold_mask_light_blue.json new file mode 100644 index 0000000..4daa7be --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/blindfold_mask_light_blue.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/blindfold_mask_light_blue"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/blindfold_mask_lime.json b/src/main/resources/assets/tiedup/models/item/blindfold_mask_lime.json new file mode 100644 index 0000000..73715ff --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/blindfold_mask_lime.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/blindfold_mask_lime"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/blindfold_mask_magenta.json b/src/main/resources/assets/tiedup/models/item/blindfold_mask_magenta.json new file mode 100644 index 0000000..7fe2e0a --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/blindfold_mask_magenta.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/blindfold_mask_magenta"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/blindfold_mask_orange.json b/src/main/resources/assets/tiedup/models/item/blindfold_mask_orange.json new file mode 100644 index 0000000..aeacfa3 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/blindfold_mask_orange.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/blindfold_mask_orange"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/blindfold_mask_pink.json b/src/main/resources/assets/tiedup/models/item/blindfold_mask_pink.json new file mode 100644 index 0000000..2c241bc --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/blindfold_mask_pink.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/blindfold_mask_pink"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/blindfold_mask_purple.json b/src/main/resources/assets/tiedup/models/item/blindfold_mask_purple.json new file mode 100644 index 0000000..82fd7a4 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/blindfold_mask_purple.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/blindfold_mask_purple"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/blindfold_mask_red.json b/src/main/resources/assets/tiedup/models/item/blindfold_mask_red.json new file mode 100644 index 0000000..04a738b --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/blindfold_mask_red.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/blindfold_mask_red"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/blindfold_mask_silver.json b/src/main/resources/assets/tiedup/models/item/blindfold_mask_silver.json new file mode 100644 index 0000000..b70e63a --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/blindfold_mask_silver.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/blindfold_mask_silver"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/blindfold_mask_white.json b/src/main/resources/assets/tiedup/models/item/blindfold_mask_white.json new file mode 100644 index 0000000..da5dd50 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/blindfold_mask_white.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/blindfold_mask_white"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/blindfold_mask_yellow.json b/src/main/resources/assets/tiedup/models/item/blindfold_mask_yellow.json new file mode 100644 index 0000000..f8aaabf --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/blindfold_mask_yellow.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/blindfold_mask_yellow"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cell_core.json b/src/main/resources/assets/tiedup/models/item/cell_core.json new file mode 100644 index 0000000..efbdda9 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cell_core.json @@ -0,0 +1,3 @@ +{ + "parent": "tiedup:block/cell_core" +} diff --git a/src/main/resources/assets/tiedup/models/item/cell_door.json b/src/main/resources/assets/tiedup/models/item/cell_door.json new file mode 100644 index 0000000..27f1692 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cell_door.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "tiedup:item/cell_door" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/cell_key.json b/src/main/resources/assets/tiedup/models/item/cell_key.json new file mode 100644 index 0000000..68e485d --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cell_key.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/cell_key" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/chain.json b/src/main/resources/assets/tiedup/models/item/chain.json new file mode 100644 index 0000000..090c345 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/chain.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/chain" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/chain_panel_gag.json b/src/main/resources/assets/tiedup/models/item/chain_panel_gag.json new file mode 100644 index 0000000..909a8b6 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/chain_panel_gag.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/chain_panel_gag" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/chloroform_bottle.json b/src/main/resources/assets/tiedup/models/item/chloroform_bottle.json new file mode 100644 index 0000000..7622000 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/chloroform_bottle.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/chloroform_bottle" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/choke_collar.json b/src/main/resources/assets/tiedup/models/item/choke_collar.json new file mode 100644 index 0000000..f6185ed --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/choke_collar.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/choke_collar" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/classic_blindfold.json b/src/main/resources/assets/tiedup/models/item/classic_blindfold.json new file mode 100644 index 0000000..fa60c7f --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/classic_blindfold.json @@ -0,0 +1,98 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/classic_blindfold" + }, + "overrides": [ + { + "predicate": { + "custom_model_data": 2 + }, + "model": "tiedup:item/classic_blindfold_blue" + }, + { + "predicate": { + "custom_model_data": 3 + }, + "model": "tiedup:item/classic_blindfold_brown" + }, + { + "predicate": { + "custom_model_data": 4 + }, + "model": "tiedup:item/classic_blindfold_cyan" + }, + { + "predicate": { + "custom_model_data": 5 + }, + "model": "tiedup:item/classic_blindfold_gray" + }, + { + "predicate": { + "custom_model_data": 6 + }, + "model": "tiedup:item/classic_blindfold_green" + }, + { + "predicate": { + "custom_model_data": 7 + }, + "model": "tiedup:item/classic_blindfold_light_blue" + }, + { + "predicate": { + "custom_model_data": 8 + }, + "model": "tiedup:item/classic_blindfold_lime" + }, + { + "predicate": { + "custom_model_data": 9 + }, + "model": "tiedup:item/classic_blindfold_magenta" + }, + { + "predicate": { + "custom_model_data": 10 + }, + "model": "tiedup:item/classic_blindfold_orange" + }, + { + "predicate": { + "custom_model_data": 11 + }, + "model": "tiedup:item/classic_blindfold_pink" + }, + { + "predicate": { + "custom_model_data": 12 + }, + "model": "tiedup:item/classic_blindfold_purple" + }, + { + "predicate": { + "custom_model_data": 13 + }, + "model": "tiedup:item/classic_blindfold_red" + }, + { + "predicate": { + "custom_model_data": 14 + }, + "model": "tiedup:item/classic_blindfold_silver" + }, + { + "predicate": { + "custom_model_data": 15 + }, + "model": "tiedup:item/classic_blindfold_white" + }, + { + "predicate": { + "custom_model_data": 16 + }, + "model": "tiedup:item/classic_blindfold_yellow" + } + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/classic_blindfold_blue.json b/src/main/resources/assets/tiedup/models/item/classic_blindfold_blue.json new file mode 100644 index 0000000..5cd7785 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/classic_blindfold_blue.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/classic_blindfold_blue"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/classic_blindfold_brown.json b/src/main/resources/assets/tiedup/models/item/classic_blindfold_brown.json new file mode 100644 index 0000000..a7ab7b9 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/classic_blindfold_brown.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/classic_blindfold_brown"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/classic_blindfold_cyan.json b/src/main/resources/assets/tiedup/models/item/classic_blindfold_cyan.json new file mode 100644 index 0000000..75107a6 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/classic_blindfold_cyan.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/classic_blindfold_cyan"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/classic_blindfold_gray.json b/src/main/resources/assets/tiedup/models/item/classic_blindfold_gray.json new file mode 100644 index 0000000..e9c9876 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/classic_blindfold_gray.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/classic_blindfold_gray"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/classic_blindfold_green.json b/src/main/resources/assets/tiedup/models/item/classic_blindfold_green.json new file mode 100644 index 0000000..76afb7c --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/classic_blindfold_green.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/classic_blindfold_green"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/classic_blindfold_light_blue.json b/src/main/resources/assets/tiedup/models/item/classic_blindfold_light_blue.json new file mode 100644 index 0000000..fea6f84 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/classic_blindfold_light_blue.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/classic_blindfold_light_blue"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/classic_blindfold_lime.json b/src/main/resources/assets/tiedup/models/item/classic_blindfold_lime.json new file mode 100644 index 0000000..373a247 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/classic_blindfold_lime.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/classic_blindfold_lime"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/classic_blindfold_magenta.json b/src/main/resources/assets/tiedup/models/item/classic_blindfold_magenta.json new file mode 100644 index 0000000..b66a114 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/classic_blindfold_magenta.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/classic_blindfold_magenta"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/classic_blindfold_orange.json b/src/main/resources/assets/tiedup/models/item/classic_blindfold_orange.json new file mode 100644 index 0000000..02dfb8d --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/classic_blindfold_orange.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/classic_blindfold_orange"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/classic_blindfold_pink.json b/src/main/resources/assets/tiedup/models/item/classic_blindfold_pink.json new file mode 100644 index 0000000..933f43c --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/classic_blindfold_pink.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/classic_blindfold_pink"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/classic_blindfold_purple.json b/src/main/resources/assets/tiedup/models/item/classic_blindfold_purple.json new file mode 100644 index 0000000..b0cb700 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/classic_blindfold_purple.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/classic_blindfold_purple"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/classic_blindfold_red.json b/src/main/resources/assets/tiedup/models/item/classic_blindfold_red.json new file mode 100644 index 0000000..c53567c --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/classic_blindfold_red.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/classic_blindfold_red"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/classic_blindfold_silver.json b/src/main/resources/assets/tiedup/models/item/classic_blindfold_silver.json new file mode 100644 index 0000000..6efeab0 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/classic_blindfold_silver.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/classic_blindfold_silver"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/classic_blindfold_white.json b/src/main/resources/assets/tiedup/models/item/classic_blindfold_white.json new file mode 100644 index 0000000..e808c94 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/classic_blindfold_white.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/classic_blindfold_white"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/classic_blindfold_yellow.json b/src/main/resources/assets/tiedup/models/item/classic_blindfold_yellow.json new file mode 100644 index 0000000..c5c0046 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/classic_blindfold_yellow.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/classic_blindfold_yellow"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/classic_collar.json b/src/main/resources/assets/tiedup/models/item/classic_collar.json new file mode 100644 index 0000000..535de58 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/classic_collar.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/classic_collar" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/classic_earplugs.json b/src/main/resources/assets/tiedup/models/item/classic_earplugs.json new file mode 100644 index 0000000..00e7550 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/classic_earplugs.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/classic_earplugs" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/cleave_gag.json b/src/main/resources/assets/tiedup/models/item/cleave_gag.json new file mode 100644 index 0000000..ff3d90c --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cleave_gag.json @@ -0,0 +1,98 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/cleave_gag" + }, + "overrides": [ + { + "predicate": { + "custom_model_data": 1 + }, + "model": "tiedup:item/cleave_gag_black" + }, + { + "predicate": { + "custom_model_data": 2 + }, + "model": "tiedup:item/cleave_gag_blue" + }, + { + "predicate": { + "custom_model_data": 3 + }, + "model": "tiedup:item/cleave_gag_brown" + }, + { + "predicate": { + "custom_model_data": 4 + }, + "model": "tiedup:item/cleave_gag_cyan" + }, + { + "predicate": { + "custom_model_data": 5 + }, + "model": "tiedup:item/cleave_gag_gray" + }, + { + "predicate": { + "custom_model_data": 6 + }, + "model": "tiedup:item/cleave_gag_green" + }, + { + "predicate": { + "custom_model_data": 7 + }, + "model": "tiedup:item/cleave_gag_light_blue" + }, + { + "predicate": { + "custom_model_data": 8 + }, + "model": "tiedup:item/cleave_gag_lime" + }, + { + "predicate": { + "custom_model_data": 9 + }, + "model": "tiedup:item/cleave_gag_magenta" + }, + { + "predicate": { + "custom_model_data": 10 + }, + "model": "tiedup:item/cleave_gag_orange" + }, + { + "predicate": { + "custom_model_data": 11 + }, + "model": "tiedup:item/cleave_gag_pink" + }, + { + "predicate": { + "custom_model_data": 12 + }, + "model": "tiedup:item/cleave_gag_purple" + }, + { + "predicate": { + "custom_model_data": 13 + }, + "model": "tiedup:item/cleave_gag_red" + }, + { + "predicate": { + "custom_model_data": 14 + }, + "model": "tiedup:item/cleave_gag_silver" + }, + { + "predicate": { + "custom_model_data": 16 + }, + "model": "tiedup:item/cleave_gag_yellow" + } + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cleave_gag_black.json b/src/main/resources/assets/tiedup/models/item/cleave_gag_black.json new file mode 100644 index 0000000..ab4eb51 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cleave_gag_black.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cleave_gag_black"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cleave_gag_blue.json b/src/main/resources/assets/tiedup/models/item/cleave_gag_blue.json new file mode 100644 index 0000000..47a4bc7 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cleave_gag_blue.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cleave_gag_blue"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cleave_gag_brown.json b/src/main/resources/assets/tiedup/models/item/cleave_gag_brown.json new file mode 100644 index 0000000..9a48e61 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cleave_gag_brown.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cleave_gag_brown"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cleave_gag_cyan.json b/src/main/resources/assets/tiedup/models/item/cleave_gag_cyan.json new file mode 100644 index 0000000..6149b62 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cleave_gag_cyan.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cleave_gag_cyan"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cleave_gag_gray.json b/src/main/resources/assets/tiedup/models/item/cleave_gag_gray.json new file mode 100644 index 0000000..32bac67 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cleave_gag_gray.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cleave_gag_gray"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cleave_gag_green.json b/src/main/resources/assets/tiedup/models/item/cleave_gag_green.json new file mode 100644 index 0000000..ae036ba --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cleave_gag_green.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cleave_gag_green"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cleave_gag_light_blue.json b/src/main/resources/assets/tiedup/models/item/cleave_gag_light_blue.json new file mode 100644 index 0000000..2b87b51 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cleave_gag_light_blue.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cleave_gag_light_blue"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cleave_gag_lime.json b/src/main/resources/assets/tiedup/models/item/cleave_gag_lime.json new file mode 100644 index 0000000..8ac80f8 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cleave_gag_lime.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cleave_gag_lime"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cleave_gag_magenta.json b/src/main/resources/assets/tiedup/models/item/cleave_gag_magenta.json new file mode 100644 index 0000000..8283362 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cleave_gag_magenta.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cleave_gag_magenta"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cleave_gag_orange.json b/src/main/resources/assets/tiedup/models/item/cleave_gag_orange.json new file mode 100644 index 0000000..d00851b --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cleave_gag_orange.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cleave_gag_orange"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cleave_gag_pink.json b/src/main/resources/assets/tiedup/models/item/cleave_gag_pink.json new file mode 100644 index 0000000..a5beca5 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cleave_gag_pink.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cleave_gag_pink"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cleave_gag_purple.json b/src/main/resources/assets/tiedup/models/item/cleave_gag_purple.json new file mode 100644 index 0000000..7cfbdde --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cleave_gag_purple.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cleave_gag_purple"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cleave_gag_red.json b/src/main/resources/assets/tiedup/models/item/cleave_gag_red.json new file mode 100644 index 0000000..1cf9d91 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cleave_gag_red.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cleave_gag_red"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cleave_gag_silver.json b/src/main/resources/assets/tiedup/models/item/cleave_gag_silver.json new file mode 100644 index 0000000..a24883b --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cleave_gag_silver.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cleave_gag_silver"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cleave_gag_yellow.json b/src/main/resources/assets/tiedup/models/item/cleave_gag_yellow.json new file mode 100644 index 0000000..68cb811 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cleave_gag_yellow.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cleave_gag_yellow"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cloth_gag.json b/src/main/resources/assets/tiedup/models/item/cloth_gag.json new file mode 100644 index 0000000..2ea2e32 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cloth_gag.json @@ -0,0 +1,98 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/cloth_gag" + }, + "overrides": [ + { + "predicate": { + "custom_model_data": 1 + }, + "model": "tiedup:item/cloth_gag_black" + }, + { + "predicate": { + "custom_model_data": 2 + }, + "model": "tiedup:item/cloth_gag_blue" + }, + { + "predicate": { + "custom_model_data": 3 + }, + "model": "tiedup:item/cloth_gag_brown" + }, + { + "predicate": { + "custom_model_data": 4 + }, + "model": "tiedup:item/cloth_gag_cyan" + }, + { + "predicate": { + "custom_model_data": 5 + }, + "model": "tiedup:item/cloth_gag_gray" + }, + { + "predicate": { + "custom_model_data": 6 + }, + "model": "tiedup:item/cloth_gag_green" + }, + { + "predicate": { + "custom_model_data": 7 + }, + "model": "tiedup:item/cloth_gag_light_blue" + }, + { + "predicate": { + "custom_model_data": 8 + }, + "model": "tiedup:item/cloth_gag_lime" + }, + { + "predicate": { + "custom_model_data": 9 + }, + "model": "tiedup:item/cloth_gag_magenta" + }, + { + "predicate": { + "custom_model_data": 10 + }, + "model": "tiedup:item/cloth_gag_orange" + }, + { + "predicate": { + "custom_model_data": 11 + }, + "model": "tiedup:item/cloth_gag_pink" + }, + { + "predicate": { + "custom_model_data": 12 + }, + "model": "tiedup:item/cloth_gag_purple" + }, + { + "predicate": { + "custom_model_data": 13 + }, + "model": "tiedup:item/cloth_gag_red" + }, + { + "predicate": { + "custom_model_data": 14 + }, + "model": "tiedup:item/cloth_gag_silver" + }, + { + "predicate": { + "custom_model_data": 16 + }, + "model": "tiedup:item/cloth_gag_yellow" + } + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cloth_gag_black.json b/src/main/resources/assets/tiedup/models/item/cloth_gag_black.json new file mode 100644 index 0000000..6abcf0a --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cloth_gag_black.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cloth_gag_black"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cloth_gag_blue.json b/src/main/resources/assets/tiedup/models/item/cloth_gag_blue.json new file mode 100644 index 0000000..1db41a4 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cloth_gag_blue.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cloth_gag_blue"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cloth_gag_brown.json b/src/main/resources/assets/tiedup/models/item/cloth_gag_brown.json new file mode 100644 index 0000000..ebb0e06 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cloth_gag_brown.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cloth_gag_brown"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cloth_gag_cyan.json b/src/main/resources/assets/tiedup/models/item/cloth_gag_cyan.json new file mode 100644 index 0000000..e209355 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cloth_gag_cyan.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cloth_gag_cyan"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cloth_gag_gray.json b/src/main/resources/assets/tiedup/models/item/cloth_gag_gray.json new file mode 100644 index 0000000..41ed0df --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cloth_gag_gray.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cloth_gag_gray"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cloth_gag_green.json b/src/main/resources/assets/tiedup/models/item/cloth_gag_green.json new file mode 100644 index 0000000..b0d6faf --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cloth_gag_green.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cloth_gag_green"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cloth_gag_light_blue.json b/src/main/resources/assets/tiedup/models/item/cloth_gag_light_blue.json new file mode 100644 index 0000000..8fed686 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cloth_gag_light_blue.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cloth_gag_light_blue"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cloth_gag_lime.json b/src/main/resources/assets/tiedup/models/item/cloth_gag_lime.json new file mode 100644 index 0000000..715590d --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cloth_gag_lime.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cloth_gag_lime"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cloth_gag_magenta.json b/src/main/resources/assets/tiedup/models/item/cloth_gag_magenta.json new file mode 100644 index 0000000..76ca07e --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cloth_gag_magenta.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cloth_gag_magenta"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cloth_gag_orange.json b/src/main/resources/assets/tiedup/models/item/cloth_gag_orange.json new file mode 100644 index 0000000..1a30377 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cloth_gag_orange.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cloth_gag_orange"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cloth_gag_pink.json b/src/main/resources/assets/tiedup/models/item/cloth_gag_pink.json new file mode 100644 index 0000000..42a8610 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cloth_gag_pink.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cloth_gag_pink"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cloth_gag_purple.json b/src/main/resources/assets/tiedup/models/item/cloth_gag_purple.json new file mode 100644 index 0000000..f23b670 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cloth_gag_purple.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cloth_gag_purple"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cloth_gag_red.json b/src/main/resources/assets/tiedup/models/item/cloth_gag_red.json new file mode 100644 index 0000000..d81ba47 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cloth_gag_red.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cloth_gag_red"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cloth_gag_silver.json b/src/main/resources/assets/tiedup/models/item/cloth_gag_silver.json new file mode 100644 index 0000000..91daa1f --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cloth_gag_silver.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cloth_gag_silver"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/cloth_gag_yellow.json b/src/main/resources/assets/tiedup/models/item/cloth_gag_yellow.json new file mode 100644 index 0000000..6181fc8 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/cloth_gag_yellow.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/cloth_gag_yellow"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/clothes.json b/src/main/resources/assets/tiedup/models/item/clothes.json new file mode 100644 index 0000000..cd63daa --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/clothes.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/clothes" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/collar_key.json b/src/main/resources/assets/tiedup/models/item/collar_key.json new file mode 100644 index 0000000..2a3897b --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/collar_key.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/collar_key" + } +} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/command_wand.json b/src/main/resources/assets/tiedup/models/item/command_wand.json new file mode 100644 index 0000000..5da6479 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/command_wand.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "tiedup:item/wand" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/data_driven_item.json b/src/main/resources/assets/tiedup/models/item/data_driven_item.json new file mode 100644 index 0000000..b16cd02 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/data_driven_item.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/data_driven_item" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/dogbinder.json b/src/main/resources/assets/tiedup/models/item/dogbinder.json new file mode 100644 index 0000000..72c7041 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/dogbinder.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "tiedup:item/dogbinder" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/duct_tape.json b/src/main/resources/assets/tiedup/models/item/duct_tape.json new file mode 100644 index 0000000..eeb32d3 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/duct_tape.json @@ -0,0 +1,25 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/duct_tape" + }, + "overrides": [ + { "predicate": { "custom_model_data": 1 }, "model": "tiedup:item/duct_tape_black" }, + { "predicate": { "custom_model_data": 2 }, "model": "tiedup:item/duct_tape_blue" }, + { "predicate": { "custom_model_data": 3 }, "model": "tiedup:item/duct_tape_brown" }, + { "predicate": { "custom_model_data": 4 }, "model": "tiedup:item/duct_tape_cyan" }, + { "predicate": { "custom_model_data": 6 }, "model": "tiedup:item/duct_tape_green" }, + { "predicate": { "custom_model_data": 7 }, "model": "tiedup:item/duct_tape_light_blue" }, + { "predicate": { "custom_model_data": 8 }, "model": "tiedup:item/duct_tape_lime" }, + { "predicate": { "custom_model_data": 9 }, "model": "tiedup:item/duct_tape_magenta" }, + { "predicate": { "custom_model_data": 10 }, "model": "tiedup:item/duct_tape_orange" }, + { "predicate": { "custom_model_data": 11 }, "model": "tiedup:item/duct_tape_pink" }, + { "predicate": { "custom_model_data": 12 }, "model": "tiedup:item/duct_tape_purple" }, + { "predicate": { "custom_model_data": 13 }, "model": "tiedup:item/duct_tape_red" }, + { "predicate": { "custom_model_data": 14 }, "model": "tiedup:item/duct_tape_silver" }, + { "predicate": { "custom_model_data": 15 }, "model": "tiedup:item/duct_tape_white" }, + { "predicate": { "custom_model_data": 16 }, "model": "tiedup:item/duct_tape_yellow" }, + { "predicate": { "custom_model_data": 17 }, "model": "tiedup:item/duct_tape_caution" }, + { "predicate": { "custom_model_data": 18 }, "model": "tiedup:item/duct_tape_clear" } + ] +} diff --git a/src/main/resources/assets/tiedup/models/item/duct_tape_black.json b/src/main/resources/assets/tiedup/models/item/duct_tape_black.json new file mode 100644 index 0000000..5254cff --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/duct_tape_black.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/duct_tape_black"}} diff --git a/src/main/resources/assets/tiedup/models/item/duct_tape_blue.json b/src/main/resources/assets/tiedup/models/item/duct_tape_blue.json new file mode 100644 index 0000000..906b131 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/duct_tape_blue.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/duct_tape_blue"}} diff --git a/src/main/resources/assets/tiedup/models/item/duct_tape_brown.json b/src/main/resources/assets/tiedup/models/item/duct_tape_brown.json new file mode 100644 index 0000000..a08da32 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/duct_tape_brown.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/duct_tape_brown"}} diff --git a/src/main/resources/assets/tiedup/models/item/duct_tape_caution.json b/src/main/resources/assets/tiedup/models/item/duct_tape_caution.json new file mode 100644 index 0000000..08b56cf --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/duct_tape_caution.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/duct_tape_caution"}} diff --git a/src/main/resources/assets/tiedup/models/item/duct_tape_clear.json b/src/main/resources/assets/tiedup/models/item/duct_tape_clear.json new file mode 100644 index 0000000..28e300e --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/duct_tape_clear.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/duct_tape_clear"}} diff --git a/src/main/resources/assets/tiedup/models/item/duct_tape_cyan.json b/src/main/resources/assets/tiedup/models/item/duct_tape_cyan.json new file mode 100644 index 0000000..c2f2acd --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/duct_tape_cyan.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/duct_tape_cyan"}} diff --git a/src/main/resources/assets/tiedup/models/item/duct_tape_green.json b/src/main/resources/assets/tiedup/models/item/duct_tape_green.json new file mode 100644 index 0000000..b32fdad --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/duct_tape_green.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/duct_tape_green"}} diff --git a/src/main/resources/assets/tiedup/models/item/duct_tape_light_blue.json b/src/main/resources/assets/tiedup/models/item/duct_tape_light_blue.json new file mode 100644 index 0000000..32f7fb2 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/duct_tape_light_blue.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/duct_tape_light_blue"}} diff --git a/src/main/resources/assets/tiedup/models/item/duct_tape_lime.json b/src/main/resources/assets/tiedup/models/item/duct_tape_lime.json new file mode 100644 index 0000000..1a9bd1a --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/duct_tape_lime.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/duct_tape_lime"}} diff --git a/src/main/resources/assets/tiedup/models/item/duct_tape_magenta.json b/src/main/resources/assets/tiedup/models/item/duct_tape_magenta.json new file mode 100644 index 0000000..2e06997 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/duct_tape_magenta.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/duct_tape_magenta"}} diff --git a/src/main/resources/assets/tiedup/models/item/duct_tape_orange.json b/src/main/resources/assets/tiedup/models/item/duct_tape_orange.json new file mode 100644 index 0000000..dd01fc2 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/duct_tape_orange.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/duct_tape_orange"}} diff --git a/src/main/resources/assets/tiedup/models/item/duct_tape_pink.json b/src/main/resources/assets/tiedup/models/item/duct_tape_pink.json new file mode 100644 index 0000000..70d9cb1 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/duct_tape_pink.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/duct_tape_pink"}} diff --git a/src/main/resources/assets/tiedup/models/item/duct_tape_purple.json b/src/main/resources/assets/tiedup/models/item/duct_tape_purple.json new file mode 100644 index 0000000..0c489b7 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/duct_tape_purple.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/duct_tape_purple"}} diff --git a/src/main/resources/assets/tiedup/models/item/duct_tape_red.json b/src/main/resources/assets/tiedup/models/item/duct_tape_red.json new file mode 100644 index 0000000..ff94418 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/duct_tape_red.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/duct_tape_red"}} diff --git a/src/main/resources/assets/tiedup/models/item/duct_tape_silver.json b/src/main/resources/assets/tiedup/models/item/duct_tape_silver.json new file mode 100644 index 0000000..a64f6e5 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/duct_tape_silver.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/duct_tape_silver"}} diff --git a/src/main/resources/assets/tiedup/models/item/duct_tape_white.json b/src/main/resources/assets/tiedup/models/item/duct_tape_white.json new file mode 100644 index 0000000..dcf46c1 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/duct_tape_white.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/duct_tape_white"}} diff --git a/src/main/resources/assets/tiedup/models/item/duct_tape_yellow.json b/src/main/resources/assets/tiedup/models/item/duct_tape_yellow.json new file mode 100644 index 0000000..948c677 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/duct_tape_yellow.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/duct_tape_yellow"}} diff --git a/src/main/resources/assets/tiedup/models/item/furniture_placer.json b/src/main/resources/assets/tiedup/models/item/furniture_placer.json new file mode 100644 index 0000000..a62e065 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/furniture_placer.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/furniture_placer" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/golden_knife.json b/src/main/resources/assets/tiedup/models/item/golden_knife.json new file mode 100644 index 0000000..572bbe0 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/golden_knife.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/golden_knife" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/gps_collar.json b/src/main/resources/assets/tiedup/models/item/gps_collar.json new file mode 100644 index 0000000..52d2118 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/gps_collar.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "tiedup:item/gps_collar" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/gps_locator.json b/src/main/resources/assets/tiedup/models/item/gps_locator.json new file mode 100644 index 0000000..a3b719b --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/gps_locator.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "tiedup:item/gps_locator" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/hood.json b/src/main/resources/assets/tiedup/models/item/hood.json new file mode 100644 index 0000000..c6bbb1c --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/hood.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/hood" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/iron_bar_door.json b/src/main/resources/assets/tiedup/models/item/iron_bar_door.json new file mode 100644 index 0000000..e87851e --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/iron_bar_door.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/iron_bar_door" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/iron_knife.json b/src/main/resources/assets/tiedup/models/item/iron_knife.json new file mode 100644 index 0000000..7acd460 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/iron_knife.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/iron_knife" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/kidnap_bomb.json b/src/main/resources/assets/tiedup/models/item/kidnap_bomb.json new file mode 100644 index 0000000..3b763fc --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/kidnap_bomb.json @@ -0,0 +1,3 @@ +{ + "parent": "tiedup:block/kidnap_bomb" +} diff --git a/src/main/resources/assets/tiedup/models/item/latex_gag.json b/src/main/resources/assets/tiedup/models/item/latex_gag.json new file mode 100644 index 0000000..098a7bd --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/latex_gag.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/latex_gag" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/latex_sack.json b/src/main/resources/assets/tiedup/models/item/latex_sack.json new file mode 100644 index 0000000..43643ca --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/latex_sack.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/latex_sack" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/leather_straps.json b/src/main/resources/assets/tiedup/models/item/leather_straps.json new file mode 100644 index 0000000..1139bba --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/leather_straps.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/leather_straps" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/lockpick.json b/src/main/resources/assets/tiedup/models/item/lockpick.json new file mode 100644 index 0000000..05b61ac --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/lockpick.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/lockpick" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/master_key.json b/src/main/resources/assets/tiedup/models/item/master_key.json new file mode 100644 index 0000000..31e5c55 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/master_key.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/master_key" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/medical_gag.json b/src/main/resources/assets/tiedup/models/item/medical_gag.json new file mode 100644 index 0000000..ba60028 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/medical_gag.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/medical_gag" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/medical_straps.json b/src/main/resources/assets/tiedup/models/item/medical_straps.json new file mode 100644 index 0000000..1bfe7a4 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/medical_straps.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/medical_straps" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/mittens.json b/src/main/resources/assets/tiedup/models/item/mittens.json new file mode 100644 index 0000000..e5b8926 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/mittens.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/mittens" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/padded_block.json b/src/main/resources/assets/tiedup/models/item/padded_block.json new file mode 100644 index 0000000..caa694a --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/padded_block.json @@ -0,0 +1,3 @@ +{ + "parent": "tiedup:block/padded_block" +} diff --git a/src/main/resources/assets/tiedup/models/item/padded_slab.json b/src/main/resources/assets/tiedup/models/item/padded_slab.json new file mode 100644 index 0000000..2e78f9e --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/padded_slab.json @@ -0,0 +1,3 @@ +{ + "parent": "tiedup:block/padded_slab" +} diff --git a/src/main/resources/assets/tiedup/models/item/padded_stairs.json b/src/main/resources/assets/tiedup/models/item/padded_stairs.json new file mode 100644 index 0000000..cf5e555 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/padded_stairs.json @@ -0,0 +1,3 @@ +{ + "parent": "tiedup:block/padded_stairs" +} diff --git a/src/main/resources/assets/tiedup/models/item/paddle.json b/src/main/resources/assets/tiedup/models/item/paddle.json new file mode 100644 index 0000000..f73a558 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/paddle.json @@ -0,0 +1,6 @@ +{ + "parent": "item/handheld", + "textures": { + "layer0": "tiedup:item/paddle" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/padlock.json b/src/main/resources/assets/tiedup/models/item/padlock.json new file mode 100644 index 0000000..ccc5111 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/padlock.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/padlock" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/panel_gag.json b/src/main/resources/assets/tiedup/models/item/panel_gag.json new file mode 100644 index 0000000..ca6fee1 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/panel_gag.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/panel_gag" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/pet_bed.json b/src/main/resources/assets/tiedup/models/item/pet_bed.json new file mode 100644 index 0000000..843b752 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/pet_bed.json @@ -0,0 +1,3 @@ +{ + "parent": "tiedup:block/pet_bed" +} diff --git a/src/main/resources/assets/tiedup/models/item/pet_bowl.json b/src/main/resources/assets/tiedup/models/item/pet_bowl.json new file mode 100644 index 0000000..eebffdd --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/pet_bowl.json @@ -0,0 +1,3 @@ +{ + "parent": "tiedup:block/pet_bowl" +} diff --git a/src/main/resources/assets/tiedup/models/item/pet_cage.json b/src/main/resources/assets/tiedup/models/item/pet_cage.json new file mode 100644 index 0000000..71ee014 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/pet_cage.json @@ -0,0 +1,3 @@ +{ + "parent": "tiedup:block/pet_cage" +} diff --git a/src/main/resources/assets/tiedup/models/item/rag.json b/src/main/resources/assets/tiedup/models/item/rag.json new file mode 100644 index 0000000..cae3706 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/rag.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/rag" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/ribbon.json b/src/main/resources/assets/tiedup/models/item/ribbon.json new file mode 100644 index 0000000..f9b54d2 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ribbon.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/ribbon" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/ribbon_gag.json b/src/main/resources/assets/tiedup/models/item/ribbon_gag.json new file mode 100644 index 0000000..fa2f680 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ribbon_gag.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/ribbon_gag" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/rope_arrow.json b/src/main/resources/assets/tiedup/models/item/rope_arrow.json new file mode 100644 index 0000000..1ce8d3e --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/rope_arrow.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/rope_arrow" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/rope_trap.json b/src/main/resources/assets/tiedup/models/item/rope_trap.json new file mode 100644 index 0000000..467a15e --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/rope_trap.json @@ -0,0 +1,3 @@ +{ + "parent": "tiedup:block/rope_trap" +} diff --git a/src/main/resources/assets/tiedup/models/item/ropes.json b/src/main/resources/assets/tiedup/models/item/ropes.json new file mode 100644 index 0000000..8193284 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes.json @@ -0,0 +1,23 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/ropes" + }, + "overrides": [ + { "predicate": { "custom_model_data": 1 }, "model": "tiedup:item/ropes_black" }, + { "predicate": { "custom_model_data": 2 }, "model": "tiedup:item/ropes_blue" }, + { "predicate": { "custom_model_data": 4 }, "model": "tiedup:item/ropes_cyan" }, + { "predicate": { "custom_model_data": 5 }, "model": "tiedup:item/ropes_gray" }, + { "predicate": { "custom_model_data": 6 }, "model": "tiedup:item/ropes_green" }, + { "predicate": { "custom_model_data": 7 }, "model": "tiedup:item/ropes_light_blue" }, + { "predicate": { "custom_model_data": 8 }, "model": "tiedup:item/ropes_lime" }, + { "predicate": { "custom_model_data": 9 }, "model": "tiedup:item/ropes_magenta" }, + { "predicate": { "custom_model_data": 10 }, "model": "tiedup:item/ropes_orange" }, + { "predicate": { "custom_model_data": 11 }, "model": "tiedup:item/ropes_pink" }, + { "predicate": { "custom_model_data": 12 }, "model": "tiedup:item/ropes_purple" }, + { "predicate": { "custom_model_data": 13 }, "model": "tiedup:item/ropes_red" }, + { "predicate": { "custom_model_data": 14 }, "model": "tiedup:item/ropes_silver" }, + { "predicate": { "custom_model_data": 15 }, "model": "tiedup:item/ropes_white" }, + { "predicate": { "custom_model_data": 16 }, "model": "tiedup:item/ropes_yellow" } + ] +} diff --git a/src/main/resources/assets/tiedup/models/item/ropes_black.json b/src/main/resources/assets/tiedup/models/item/ropes_black.json new file mode 100644 index 0000000..db2754f --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_black.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/ropes_black"}} diff --git a/src/main/resources/assets/tiedup/models/item/ropes_blue.json b/src/main/resources/assets/tiedup/models/item/ropes_blue.json new file mode 100644 index 0000000..bc8c90d --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_blue.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/ropes_blue"}} diff --git a/src/main/resources/assets/tiedup/models/item/ropes_cyan.json b/src/main/resources/assets/tiedup/models/item/ropes_cyan.json new file mode 100644 index 0000000..2f6a149 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_cyan.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/ropes_cyan"}} diff --git a/src/main/resources/assets/tiedup/models/item/ropes_gag.json b/src/main/resources/assets/tiedup/models/item/ropes_gag.json new file mode 100644 index 0000000..1b64e30 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_gag.json @@ -0,0 +1,98 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/ropes_gag" + }, + "overrides": [ + { + "predicate": { + "custom_model_data": 1 + }, + "model": "tiedup:item/ropes_gag_black" + }, + { + "predicate": { + "custom_model_data": 2 + }, + "model": "tiedup:item/ropes_gag_blue" + }, + { + "predicate": { + "custom_model_data": 4 + }, + "model": "tiedup:item/ropes_gag_cyan" + }, + { + "predicate": { + "custom_model_data": 5 + }, + "model": "tiedup:item/ropes_gag_gray" + }, + { + "predicate": { + "custom_model_data": 6 + }, + "model": "tiedup:item/ropes_gag_green" + }, + { + "predicate": { + "custom_model_data": 7 + }, + "model": "tiedup:item/ropes_gag_light_blue" + }, + { + "predicate": { + "custom_model_data": 8 + }, + "model": "tiedup:item/ropes_gag_lime" + }, + { + "predicate": { + "custom_model_data": 9 + }, + "model": "tiedup:item/ropes_gag_magenta" + }, + { + "predicate": { + "custom_model_data": 10 + }, + "model": "tiedup:item/ropes_gag_orange" + }, + { + "predicate": { + "custom_model_data": 11 + }, + "model": "tiedup:item/ropes_gag_pink" + }, + { + "predicate": { + "custom_model_data": 12 + }, + "model": "tiedup:item/ropes_gag_purple" + }, + { + "predicate": { + "custom_model_data": 13 + }, + "model": "tiedup:item/ropes_gag_red" + }, + { + "predicate": { + "custom_model_data": 14 + }, + "model": "tiedup:item/ropes_gag_silver" + }, + { + "predicate": { + "custom_model_data": 15 + }, + "model": "tiedup:item/ropes_gag_white" + }, + { + "predicate": { + "custom_model_data": 16 + }, + "model": "tiedup:item/ropes_gag_yellow" + } + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ropes_gag_black.json b/src/main/resources/assets/tiedup/models/item/ropes_gag_black.json new file mode 100644 index 0000000..24a6d74 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_gag_black.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ropes_gag_black"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ropes_gag_blue.json b/src/main/resources/assets/tiedup/models/item/ropes_gag_blue.json new file mode 100644 index 0000000..0db6fc4 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_gag_blue.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ropes_gag_blue"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ropes_gag_cyan.json b/src/main/resources/assets/tiedup/models/item/ropes_gag_cyan.json new file mode 100644 index 0000000..5f7978e --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_gag_cyan.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ropes_gag_cyan"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ropes_gag_gray.json b/src/main/resources/assets/tiedup/models/item/ropes_gag_gray.json new file mode 100644 index 0000000..143950b --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_gag_gray.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ropes_gag_gray"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ropes_gag_green.json b/src/main/resources/assets/tiedup/models/item/ropes_gag_green.json new file mode 100644 index 0000000..75ba9fb --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_gag_green.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ropes_gag_green"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ropes_gag_light_blue.json b/src/main/resources/assets/tiedup/models/item/ropes_gag_light_blue.json new file mode 100644 index 0000000..078efc2 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_gag_light_blue.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ropes_gag_light_blue"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ropes_gag_lime.json b/src/main/resources/assets/tiedup/models/item/ropes_gag_lime.json new file mode 100644 index 0000000..e15937b --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_gag_lime.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ropes_gag_lime"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ropes_gag_magenta.json b/src/main/resources/assets/tiedup/models/item/ropes_gag_magenta.json new file mode 100644 index 0000000..afef1b1 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_gag_magenta.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ropes_gag_magenta"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ropes_gag_orange.json b/src/main/resources/assets/tiedup/models/item/ropes_gag_orange.json new file mode 100644 index 0000000..27e9921 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_gag_orange.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ropes_gag_orange"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ropes_gag_pink.json b/src/main/resources/assets/tiedup/models/item/ropes_gag_pink.json new file mode 100644 index 0000000..701a62d --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_gag_pink.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ropes_gag_pink"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ropes_gag_purple.json b/src/main/resources/assets/tiedup/models/item/ropes_gag_purple.json new file mode 100644 index 0000000..2a2a5d6 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_gag_purple.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ropes_gag_purple"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ropes_gag_red.json b/src/main/resources/assets/tiedup/models/item/ropes_gag_red.json new file mode 100644 index 0000000..b074497 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_gag_red.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ropes_gag_red"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ropes_gag_silver.json b/src/main/resources/assets/tiedup/models/item/ropes_gag_silver.json new file mode 100644 index 0000000..c901067 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_gag_silver.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ropes_gag_silver"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ropes_gag_white.json b/src/main/resources/assets/tiedup/models/item/ropes_gag_white.json new file mode 100644 index 0000000..0cab68e --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_gag_white.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ropes_gag_white"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ropes_gag_yellow.json b/src/main/resources/assets/tiedup/models/item/ropes_gag_yellow.json new file mode 100644 index 0000000..7e0fc9f --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_gag_yellow.json @@ -0,0 +1 @@ +{"parent": "item/generated", "textures": {"layer0": "tiedup:item/ropes_gag_yellow"}} \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/item/ropes_gray.json b/src/main/resources/assets/tiedup/models/item/ropes_gray.json new file mode 100644 index 0000000..3356468 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_gray.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/ropes_gray"}} diff --git a/src/main/resources/assets/tiedup/models/item/ropes_green.json b/src/main/resources/assets/tiedup/models/item/ropes_green.json new file mode 100644 index 0000000..c9e582a --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_green.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/ropes_green"}} diff --git a/src/main/resources/assets/tiedup/models/item/ropes_light_blue.json b/src/main/resources/assets/tiedup/models/item/ropes_light_blue.json new file mode 100644 index 0000000..eca9ff8 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_light_blue.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/ropes_light_blue"}} diff --git a/src/main/resources/assets/tiedup/models/item/ropes_lime.json b/src/main/resources/assets/tiedup/models/item/ropes_lime.json new file mode 100644 index 0000000..ffaa71d --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_lime.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/ropes_lime"}} diff --git a/src/main/resources/assets/tiedup/models/item/ropes_magenta.json b/src/main/resources/assets/tiedup/models/item/ropes_magenta.json new file mode 100644 index 0000000..050fffe --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_magenta.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/ropes_magenta"}} diff --git a/src/main/resources/assets/tiedup/models/item/ropes_orange.json b/src/main/resources/assets/tiedup/models/item/ropes_orange.json new file mode 100644 index 0000000..7140001 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_orange.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/ropes_orange"}} diff --git a/src/main/resources/assets/tiedup/models/item/ropes_pink.json b/src/main/resources/assets/tiedup/models/item/ropes_pink.json new file mode 100644 index 0000000..fbb380f --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_pink.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/ropes_pink"}} diff --git a/src/main/resources/assets/tiedup/models/item/ropes_purple.json b/src/main/resources/assets/tiedup/models/item/ropes_purple.json new file mode 100644 index 0000000..e2d951b --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_purple.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/ropes_purple"}} diff --git a/src/main/resources/assets/tiedup/models/item/ropes_red.json b/src/main/resources/assets/tiedup/models/item/ropes_red.json new file mode 100644 index 0000000..7f745fc --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_red.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/ropes_red"}} diff --git a/src/main/resources/assets/tiedup/models/item/ropes_silver.json b/src/main/resources/assets/tiedup/models/item/ropes_silver.json new file mode 100644 index 0000000..88da4c7 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_silver.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/ropes_silver"}} diff --git a/src/main/resources/assets/tiedup/models/item/ropes_white.json b/src/main/resources/assets/tiedup/models/item/ropes_white.json new file mode 100644 index 0000000..a0cbc11 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_white.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/ropes_white"}} diff --git a/src/main/resources/assets/tiedup/models/item/ropes_yellow.json b/src/main/resources/assets/tiedup/models/item/ropes_yellow.json new file mode 100644 index 0000000..aeb93db --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/ropes_yellow.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/ropes_yellow"}} diff --git a/src/main/resources/assets/tiedup/models/item/shibari.json b/src/main/resources/assets/tiedup/models/item/shibari.json new file mode 100644 index 0000000..ac23fef --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/shibari.json @@ -0,0 +1,23 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/shibari" + }, + "overrides": [ + { "predicate": { "custom_model_data": 1 }, "model": "tiedup:item/shibari_black" }, + { "predicate": { "custom_model_data": 2 }, "model": "tiedup:item/shibari_blue" }, + { "predicate": { "custom_model_data": 4 }, "model": "tiedup:item/shibari_cyan" }, + { "predicate": { "custom_model_data": 5 }, "model": "tiedup:item/shibari_gray" }, + { "predicate": { "custom_model_data": 6 }, "model": "tiedup:item/shibari_green" }, + { "predicate": { "custom_model_data": 7 }, "model": "tiedup:item/shibari_light_blue" }, + { "predicate": { "custom_model_data": 8 }, "model": "tiedup:item/shibari_lime" }, + { "predicate": { "custom_model_data": 9 }, "model": "tiedup:item/shibari_magenta" }, + { "predicate": { "custom_model_data": 10 }, "model": "tiedup:item/shibari_orange" }, + { "predicate": { "custom_model_data": 11 }, "model": "tiedup:item/shibari_pink" }, + { "predicate": { "custom_model_data": 12 }, "model": "tiedup:item/shibari_purple" }, + { "predicate": { "custom_model_data": 13 }, "model": "tiedup:item/shibari_red" }, + { "predicate": { "custom_model_data": 14 }, "model": "tiedup:item/shibari_silver" }, + { "predicate": { "custom_model_data": 15 }, "model": "tiedup:item/shibari_white" }, + { "predicate": { "custom_model_data": 16 }, "model": "tiedup:item/shibari_yellow" } + ] +} diff --git a/src/main/resources/assets/tiedup/models/item/shibari_black.json b/src/main/resources/assets/tiedup/models/item/shibari_black.json new file mode 100644 index 0000000..62f7a43 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/shibari_black.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/shibari_black"}} diff --git a/src/main/resources/assets/tiedup/models/item/shibari_blue.json b/src/main/resources/assets/tiedup/models/item/shibari_blue.json new file mode 100644 index 0000000..ec3a63c --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/shibari_blue.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/shibari_blue"}} diff --git a/src/main/resources/assets/tiedup/models/item/shibari_cyan.json b/src/main/resources/assets/tiedup/models/item/shibari_cyan.json new file mode 100644 index 0000000..d19e6f3 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/shibari_cyan.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/shibari_cyan"}} diff --git a/src/main/resources/assets/tiedup/models/item/shibari_gray.json b/src/main/resources/assets/tiedup/models/item/shibari_gray.json new file mode 100644 index 0000000..ca3855d --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/shibari_gray.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/shibari_gray"}} diff --git a/src/main/resources/assets/tiedup/models/item/shibari_green.json b/src/main/resources/assets/tiedup/models/item/shibari_green.json new file mode 100644 index 0000000..70daf2d --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/shibari_green.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/shibari_green"}} diff --git a/src/main/resources/assets/tiedup/models/item/shibari_light_blue.json b/src/main/resources/assets/tiedup/models/item/shibari_light_blue.json new file mode 100644 index 0000000..b3cea80 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/shibari_light_blue.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/shibari_light_blue"}} diff --git a/src/main/resources/assets/tiedup/models/item/shibari_lime.json b/src/main/resources/assets/tiedup/models/item/shibari_lime.json new file mode 100644 index 0000000..7fe3a56 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/shibari_lime.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/shibari_lime"}} diff --git a/src/main/resources/assets/tiedup/models/item/shibari_magenta.json b/src/main/resources/assets/tiedup/models/item/shibari_magenta.json new file mode 100644 index 0000000..78cd0ec --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/shibari_magenta.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/shibari_magenta"}} diff --git a/src/main/resources/assets/tiedup/models/item/shibari_orange.json b/src/main/resources/assets/tiedup/models/item/shibari_orange.json new file mode 100644 index 0000000..04761a3 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/shibari_orange.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/shibari_orange"}} diff --git a/src/main/resources/assets/tiedup/models/item/shibari_pink.json b/src/main/resources/assets/tiedup/models/item/shibari_pink.json new file mode 100644 index 0000000..53b73a7 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/shibari_pink.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/shibari_pink"}} diff --git a/src/main/resources/assets/tiedup/models/item/shibari_purple.json b/src/main/resources/assets/tiedup/models/item/shibari_purple.json new file mode 100644 index 0000000..44b230a --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/shibari_purple.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/shibari_purple"}} diff --git a/src/main/resources/assets/tiedup/models/item/shibari_red.json b/src/main/resources/assets/tiedup/models/item/shibari_red.json new file mode 100644 index 0000000..1030b32 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/shibari_red.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/shibari_red"}} diff --git a/src/main/resources/assets/tiedup/models/item/shibari_silver.json b/src/main/resources/assets/tiedup/models/item/shibari_silver.json new file mode 100644 index 0000000..1247d93 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/shibari_silver.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/shibari_silver"}} diff --git a/src/main/resources/assets/tiedup/models/item/shibari_white.json b/src/main/resources/assets/tiedup/models/item/shibari_white.json new file mode 100644 index 0000000..f799fb2 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/shibari_white.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/shibari_white"}} diff --git a/src/main/resources/assets/tiedup/models/item/shibari_yellow.json b/src/main/resources/assets/tiedup/models/item/shibari_yellow.json new file mode 100644 index 0000000..a70075a --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/shibari_yellow.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/shibari_yellow"}} diff --git a/src/main/resources/assets/tiedup/models/item/shock_collar.json b/src/main/resources/assets/tiedup/models/item/shock_collar.json new file mode 100644 index 0000000..43ebf23 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/shock_collar.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "tiedup:item/shock_collar" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/shock_collar_auto.json b/src/main/resources/assets/tiedup/models/item/shock_collar_auto.json new file mode 100644 index 0000000..43ebf23 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/shock_collar_auto.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "tiedup:item/shock_collar" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/shocker_controller.json b/src/main/resources/assets/tiedup/models/item/shocker_controller.json new file mode 100644 index 0000000..f7ff673 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/shocker_controller.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "tiedup:item/shocker_controller" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/slime.json b/src/main/resources/assets/tiedup/models/item/slime.json new file mode 100644 index 0000000..36f77bc --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/slime.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/slime" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/slime_gag.json b/src/main/resources/assets/tiedup/models/item/slime_gag.json new file mode 100644 index 0000000..38b2377 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/slime_gag.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/slime_gag" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/sponge_gag.json b/src/main/resources/assets/tiedup/models/item/sponge_gag.json new file mode 100644 index 0000000..5756546 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/sponge_gag.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/sponge_gag" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/stone_knife.json b/src/main/resources/assets/tiedup/models/item/stone_knife.json new file mode 100644 index 0000000..ca59b01 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/stone_knife.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/stone_knife" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/straitjacket.json b/src/main/resources/assets/tiedup/models/item/straitjacket.json new file mode 100644 index 0000000..941c069 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/straitjacket.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/straitjacket" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/tape_gag.json b/src/main/resources/assets/tiedup/models/item/tape_gag.json new file mode 100644 index 0000000..2d79458 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tape_gag.json @@ -0,0 +1,25 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "tiedup:item/tape_gag" + }, + "overrides": [ + { "predicate": { "custom_model_data": 1 }, "model": "tiedup:item/tape_gag_black" }, + { "predicate": { "custom_model_data": 2 }, "model": "tiedup:item/tape_gag_blue" }, + { "predicate": { "custom_model_data": 3 }, "model": "tiedup:item/tape_gag_brown" }, + { "predicate": { "custom_model_data": 4 }, "model": "tiedup:item/tape_gag_cyan" }, + { "predicate": { "custom_model_data": 6 }, "model": "tiedup:item/tape_gag_green" }, + { "predicate": { "custom_model_data": 7 }, "model": "tiedup:item/tape_gag_light_blue" }, + { "predicate": { "custom_model_data": 8 }, "model": "tiedup:item/tape_gag_lime" }, + { "predicate": { "custom_model_data": 9 }, "model": "tiedup:item/tape_gag_magenta" }, + { "predicate": { "custom_model_data": 10 }, "model": "tiedup:item/tape_gag_orange" }, + { "predicate": { "custom_model_data": 11 }, "model": "tiedup:item/tape_gag_pink" }, + { "predicate": { "custom_model_data": 12 }, "model": "tiedup:item/tape_gag_purple" }, + { "predicate": { "custom_model_data": 13 }, "model": "tiedup:item/tape_gag_red" }, + { "predicate": { "custom_model_data": 14 }, "model": "tiedup:item/tape_gag_silver" }, + { "predicate": { "custom_model_data": 15 }, "model": "tiedup:item/tape_gag_white" }, + { "predicate": { "custom_model_data": 16 }, "model": "tiedup:item/tape_gag_yellow" }, + { "predicate": { "custom_model_data": 17 }, "model": "tiedup:item/tape_gag_caution" }, + { "predicate": { "custom_model_data": 18 }, "model": "tiedup:item/tape_gag_clear" } + ] +} diff --git a/src/main/resources/assets/tiedup/models/item/tape_gag_black.json b/src/main/resources/assets/tiedup/models/item/tape_gag_black.json new file mode 100644 index 0000000..8efdd16 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tape_gag_black.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/tape_gag_black"}} diff --git a/src/main/resources/assets/tiedup/models/item/tape_gag_blue.json b/src/main/resources/assets/tiedup/models/item/tape_gag_blue.json new file mode 100644 index 0000000..7e06c50 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tape_gag_blue.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/tape_gag_blue"}} diff --git a/src/main/resources/assets/tiedup/models/item/tape_gag_brown.json b/src/main/resources/assets/tiedup/models/item/tape_gag_brown.json new file mode 100644 index 0000000..2f52038 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tape_gag_brown.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/tape_gag_brown"}} diff --git a/src/main/resources/assets/tiedup/models/item/tape_gag_caution.json b/src/main/resources/assets/tiedup/models/item/tape_gag_caution.json new file mode 100644 index 0000000..53d95af --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tape_gag_caution.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/tape_gag_caution"}} diff --git a/src/main/resources/assets/tiedup/models/item/tape_gag_clear.json b/src/main/resources/assets/tiedup/models/item/tape_gag_clear.json new file mode 100644 index 0000000..004e9b1 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tape_gag_clear.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/tape_gag_clear"}} diff --git a/src/main/resources/assets/tiedup/models/item/tape_gag_cyan.json b/src/main/resources/assets/tiedup/models/item/tape_gag_cyan.json new file mode 100644 index 0000000..381cd0c --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tape_gag_cyan.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/tape_gag_cyan"}} diff --git a/src/main/resources/assets/tiedup/models/item/tape_gag_green.json b/src/main/resources/assets/tiedup/models/item/tape_gag_green.json new file mode 100644 index 0000000..7543f23 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tape_gag_green.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/tape_gag_green"}} diff --git a/src/main/resources/assets/tiedup/models/item/tape_gag_light_blue.json b/src/main/resources/assets/tiedup/models/item/tape_gag_light_blue.json new file mode 100644 index 0000000..8189ff4 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tape_gag_light_blue.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/tape_gag_light_blue"}} diff --git a/src/main/resources/assets/tiedup/models/item/tape_gag_lime.json b/src/main/resources/assets/tiedup/models/item/tape_gag_lime.json new file mode 100644 index 0000000..15354f1 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tape_gag_lime.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/tape_gag_lime"}} diff --git a/src/main/resources/assets/tiedup/models/item/tape_gag_magenta.json b/src/main/resources/assets/tiedup/models/item/tape_gag_magenta.json new file mode 100644 index 0000000..9d6bb90 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tape_gag_magenta.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/tape_gag_magenta"}} diff --git a/src/main/resources/assets/tiedup/models/item/tape_gag_orange.json b/src/main/resources/assets/tiedup/models/item/tape_gag_orange.json new file mode 100644 index 0000000..b7797d5 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tape_gag_orange.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/tape_gag_orange"}} diff --git a/src/main/resources/assets/tiedup/models/item/tape_gag_pink.json b/src/main/resources/assets/tiedup/models/item/tape_gag_pink.json new file mode 100644 index 0000000..5393343 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tape_gag_pink.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/tape_gag_pink"}} diff --git a/src/main/resources/assets/tiedup/models/item/tape_gag_purple.json b/src/main/resources/assets/tiedup/models/item/tape_gag_purple.json new file mode 100644 index 0000000..7477969 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tape_gag_purple.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/tape_gag_purple"}} diff --git a/src/main/resources/assets/tiedup/models/item/tape_gag_red.json b/src/main/resources/assets/tiedup/models/item/tape_gag_red.json new file mode 100644 index 0000000..2a27b34 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tape_gag_red.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/tape_gag_red"}} diff --git a/src/main/resources/assets/tiedup/models/item/tape_gag_silver.json b/src/main/resources/assets/tiedup/models/item/tape_gag_silver.json new file mode 100644 index 0000000..5dd6e7c --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tape_gag_silver.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/tape_gag_silver"}} diff --git a/src/main/resources/assets/tiedup/models/item/tape_gag_white.json b/src/main/resources/assets/tiedup/models/item/tape_gag_white.json new file mode 100644 index 0000000..951f350 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tape_gag_white.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/tape_gag_white"}} diff --git a/src/main/resources/assets/tiedup/models/item/tape_gag_yellow.json b/src/main/resources/assets/tiedup/models/item/tape_gag_yellow.json new file mode 100644 index 0000000..3cfb896 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tape_gag_yellow.json @@ -0,0 +1 @@ +{"parent":"item/generated","textures":{"layer0":"tiedup:item/tape_gag_yellow"}} diff --git a/src/main/resources/assets/tiedup/models/item/taser.json b/src/main/resources/assets/tiedup/models/item/taser.json new file mode 100644 index 0000000..d7bada6 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/taser.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/taser" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/tiedup_guide.json b/src/main/resources/assets/tiedup/models/item/tiedup_guide.json new file mode 100644 index 0000000..af82ac6 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tiedup_guide.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "minecraft:item/book" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/token.json b/src/main/resources/assets/tiedup/models/item/token.json new file mode 100644 index 0000000..7cec9f4 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/token.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/token" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/trapped_chest.json b/src/main/resources/assets/tiedup/models/item/trapped_chest.json new file mode 100644 index 0000000..e0b2c3f --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/trapped_chest.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "tiedup:item/trapped_chest" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/tube_gag.json b/src/main/resources/assets/tiedup/models/item/tube_gag.json new file mode 100644 index 0000000..b8491a8 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/tube_gag.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/tube_gag" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/v2_handcuffs.json b/src/main/resources/assets/tiedup/models/item/v2_handcuffs.json new file mode 100644 index 0000000..4051773 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/v2_handcuffs.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/v2_handcuffs" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/vine_gag.json b/src/main/resources/assets/tiedup/models/item/vine_gag.json new file mode 100644 index 0000000..2b0cc43 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/vine_gag.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/vine_gag" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/vine_seed.json b/src/main/resources/assets/tiedup/models/item/vine_seed.json new file mode 100644 index 0000000..a6a8305 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/vine_seed.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/vine_seed" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/web_bind.json b/src/main/resources/assets/tiedup/models/item/web_bind.json new file mode 100644 index 0000000..6ffb8ea --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/web_bind.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/web_bind" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/web_gag.json b/src/main/resources/assets/tiedup/models/item/web_gag.json new file mode 100644 index 0000000..b58255c --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/web_gag.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/web_gag" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/whip.json b/src/main/resources/assets/tiedup/models/item/whip.json new file mode 100644 index 0000000..884cc75 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/whip.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/whip" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/wrap.json b/src/main/resources/assets/tiedup/models/item/wrap.json new file mode 100644 index 0000000..b2a69c3 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/wrap.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/wrap" + } +} diff --git a/src/main/resources/assets/tiedup/models/item/wrap_gag.json b/src/main/resources/assets/tiedup/models/item/wrap_gag.json new file mode 100644 index 0000000..2f8822c --- /dev/null +++ b/src/main/resources/assets/tiedup/models/item/wrap_gag.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "tiedup:item/wrap_gag" + } +} diff --git a/src/main/resources/assets/tiedup/models/obj/ball_gag/model.mtl b/src/main/resources/assets/tiedup/models/obj/ball_gag/model.mtl new file mode 100644 index 0000000..67056b1 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/obj/ball_gag/model.mtl @@ -0,0 +1,35 @@ +# Blender 5.0.1 MTL File: 'GagBall.blend' +# www.blender.org + +newmtl Ball +Ns 250.000000 +Ka 1.000000 1.000000 1.000000 +Kd 0.800023 0.023085 0.000000 +Ks 0.500000 0.500000 0.500000 +Ke 0.000000 0.000000 0.000000 +Ni 1.500000 +d 1.000000 +illum 2 +map_Kd texture.png + +newmtl Leather +Ns 250.000000 +Ka 1.000000 1.000000 1.000000 +Kd 0.014653 0.014653 0.014653 +Ks 0.500000 0.500000 0.500000 +Ke 0.000000 0.000000 0.000000 +Ni 1.500000 +d 1.000000 +illum 2 +map_Kd texture.png + +newmtl Metal +Ns 250.000000 +Ka 1.000000 1.000000 1.000000 +Kd 0.588909 0.588909 0.588909 +Ks 0.500000 0.500000 0.500000 +Ke 0.000000 0.000000 0.000000 +Ni 1.500000 +d 1.000000 +illum 2 +map_Kd texture.png diff --git a/src/main/resources/assets/tiedup/models/obj/ball_gag/model.obj b/src/main/resources/assets/tiedup/models/obj/ball_gag/model.obj new file mode 100644 index 0000000..1bc8693 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/obj/ball_gag/model.obj @@ -0,0 +1,1405 @@ +# Blender 5.0.1 +# www.blender.org +mtllib model.mtl +o Cube.003 +v 0.009208 1.610105 0.263659 +v 0.009208 1.552888 0.263659 +v 0.012784 1.552888 0.251338 +v 0.012784 1.610105 0.251338 +v 0.075692 1.610105 0.292021 +v 0.075692 1.552888 0.292021 +v 0.079885 1.552888 0.280098 +v 0.079885 1.610105 0.280098 +v 0.058418 1.610105 0.263132 +v 0.058418 1.552888 0.263132 +v 0.054842 1.552888 0.275453 +v 0.054842 1.610105 0.275453 +v 0.046332 1.535183 -0.316815 +v 0.064865 1.553716 -0.316815 +v 0.064865 1.572249 -0.316815 +v 0.046332 1.553716 -0.335348 +v 0.046332 1.572249 -0.335348 +v 0.046332 1.553716 -0.316815 +v 0.046332 1.572249 -0.316815 +v 0.064865 1.535183 -0.298283 +v 0.064865 1.535183 -0.279750 +v 0.046332 1.516650 -0.298283 +v 0.046332 1.535183 -0.298283 +v 0.046332 1.516650 -0.279750 +v 0.046332 1.535183 -0.279750 +v 0.064865 1.553716 -0.298283 +v 0.064865 1.572249 -0.298283 +v 0.064865 1.553716 -0.279750 +v 0.064865 1.572249 -0.279750 +v 0.046332 1.553716 -0.298283 +v 0.027799 1.535183 -0.335348 +v 0.027799 1.516650 -0.316815 +v 0.027799 1.535183 -0.316815 +v 0.009266 1.535183 -0.335348 +v 0.009266 1.516650 -0.316815 +v 0.009266 1.535183 -0.316815 +v 0.027799 1.553716 -0.335348 +v 0.027799 1.572249 -0.335348 +v 0.027799 1.553716 -0.316815 +v 0.009266 1.553716 -0.335348 +v 0.009266 1.572249 -0.335348 +v 0.027799 1.516650 -0.298283 +v 0.027799 1.535183 -0.298283 +v 0.027799 1.516650 -0.279750 +v 0.009266 1.516650 -0.298283 +v 0.009266 1.516650 -0.279750 +v 0.064865 1.590782 -0.316815 +v 0.064865 1.609314 -0.316815 +v 0.046332 1.590782 -0.335348 +v 0.046332 1.609314 -0.335348 +v 0.046332 1.590782 -0.316815 +v 0.046332 1.609314 -0.316815 +v 0.046332 1.627847 -0.316815 +v 0.064865 1.590782 -0.298283 +v 0.064865 1.609314 -0.298283 +v 0.064865 1.590782 -0.279750 +v 0.064865 1.609314 -0.279750 +v 0.046332 1.609314 -0.298283 +v 0.064865 1.627847 -0.298283 +v 0.064865 1.627847 -0.279750 +v 0.046332 1.627847 -0.298283 +v 0.046332 1.646380 -0.298283 +v 0.046332 1.627847 -0.279750 +v 0.046332 1.646380 -0.279750 +v 0.027799 1.590782 -0.335348 +v 0.027799 1.609314 -0.335348 +v 0.027799 1.609314 -0.316815 +v 0.009266 1.590782 -0.335348 +v 0.009266 1.609314 -0.335348 +v 0.027799 1.627847 -0.335348 +v 0.027799 1.627847 -0.316815 +v 0.027799 1.646380 -0.316815 +v 0.009266 1.627847 -0.335348 +v 0.009266 1.627847 -0.316815 +v 0.009266 1.646380 -0.316815 +v 0.027799 1.627847 -0.298283 +v 0.027799 1.646380 -0.298283 +v 0.027799 1.646380 -0.279750 +v 0.009266 1.646380 -0.298283 +v 0.009266 1.646380 -0.279750 +v -0.009267 1.535183 -0.335348 +v -0.009267 1.516650 -0.316815 +v -0.009267 1.535183 -0.316815 +v -0.027800 1.535183 -0.335348 +v -0.027800 1.516650 -0.316815 +v -0.027800 1.535183 -0.316815 +v -0.009267 1.553716 -0.335348 +v -0.009267 1.572249 -0.335348 +v -0.027800 1.553716 -0.335348 +v -0.027800 1.572249 -0.335348 +v -0.027800 1.553716 -0.316815 +v -0.009267 1.516650 -0.298283 +v -0.009267 1.516650 -0.279750 +v -0.027799 1.516650 -0.298283 +v -0.027799 1.535183 -0.298283 +v -0.027800 1.516650 -0.279750 +v -0.046332 1.535183 -0.316815 +v -0.046332 1.553716 -0.335348 +v -0.046332 1.572249 -0.335348 +v -0.046332 1.553716 -0.316815 +v -0.046332 1.572249 -0.316815 +v -0.064865 1.553716 -0.316815 +v -0.064865 1.572249 -0.316815 +v -0.046332 1.516650 -0.298283 +v -0.046332 1.535183 -0.298283 +v -0.046332 1.516650 -0.279750 +v -0.046332 1.535183 -0.279750 +v -0.064865 1.535183 -0.298283 +v -0.064865 1.535183 -0.279750 +v -0.046332 1.553716 -0.298283 +v -0.064865 1.553716 -0.298283 +v -0.064865 1.572249 -0.298283 +v -0.064865 1.553716 -0.279750 +v -0.064865 1.572249 -0.279750 +v -0.009267 1.590782 -0.335348 +v -0.009267 1.609314 -0.335348 +v -0.027800 1.590782 -0.335348 +v -0.027800 1.609314 -0.335348 +v -0.027800 1.609314 -0.316815 +v -0.009267 1.627847 -0.335348 +v -0.009267 1.627847 -0.316815 +v -0.009267 1.646380 -0.316815 +v -0.027800 1.627847 -0.335348 +v -0.027800 1.627847 -0.316815 +v -0.027800 1.646380 -0.316815 +v -0.009267 1.646380 -0.298283 +v -0.009267 1.646380 -0.279750 +v -0.027799 1.627847 -0.298283 +v -0.027799 1.646380 -0.298283 +v -0.027800 1.646380 -0.279750 +v -0.046332 1.590782 -0.335348 +v -0.046332 1.609314 -0.335348 +v -0.046332 1.590782 -0.316815 +v -0.046332 1.609314 -0.316815 +v -0.064865 1.590782 -0.316815 +v -0.064865 1.609314 -0.316815 +v -0.046332 1.627847 -0.316815 +v -0.046332 1.609314 -0.298283 +v -0.064865 1.590782 -0.298283 +v -0.064865 1.609314 -0.298283 +v -0.064865 1.590782 -0.279750 +v -0.064865 1.609314 -0.279750 +v -0.046332 1.627847 -0.298283 +v -0.046332 1.646380 -0.298283 +v -0.046332 1.627847 -0.279750 +v -0.046332 1.646380 -0.279750 +v -0.064865 1.627847 -0.298283 +v -0.064865 1.627847 -0.279750 +v -0.264342 1.552812 -0.274362 +v -0.264342 1.610181 -0.274362 +v -0.264342 1.552812 -0.255332 +v -0.264342 1.610181 -0.255332 +v -0.050610 1.552812 -0.274362 +v -0.050610 1.610181 -0.274362 +v -0.050610 1.552812 -0.255332 +v -0.050610 1.610181 -0.255332 +v -0.265275 1.552812 0.264113 +v -0.265275 1.610181 0.264113 +v -0.246678 1.552812 0.264113 +v -0.246678 1.610181 0.264113 +v -0.265275 1.552812 -0.203222 +v -0.265275 1.610181 -0.203222 +v -0.246678 1.552812 -0.203222 +v -0.246678 1.610181 -0.203222 +v -0.246678 1.552812 0.250886 +v -0.246678 1.610181 0.250886 +v -0.265275 1.552812 0.250886 +v -0.265275 1.610181 0.250886 +v 0.001357 1.610181 0.264112 +v 0.001357 1.552812 0.264112 +v 0.001357 1.552812 0.250885 +v 0.001357 1.610181 0.250885 +v 0.267055 1.552812 -0.274363 +v 0.267055 1.610181 -0.274363 +v 0.267055 1.552812 -0.255332 +v 0.267055 1.610181 -0.255332 +v 0.053323 1.552812 -0.274362 +v 0.053323 1.610181 -0.274362 +v 0.053323 1.552812 -0.255332 +v 0.053323 1.610181 -0.255332 +v 0.267989 1.552812 0.264112 +v 0.267989 1.610181 0.264112 +v 0.249392 1.552812 0.264112 +v 0.249392 1.610181 0.264112 +v 0.267989 1.552812 -0.203223 +v 0.267989 1.610181 -0.203223 +v 0.249391 1.552812 -0.203223 +v 0.249391 1.610181 -0.203223 +v 0.249392 1.552812 0.250885 +v 0.249392 1.610181 0.250885 +v 0.267989 1.552812 0.250885 +v 0.267989 1.610181 0.250885 +v -0.260266 1.546786 -0.255580 +v -0.260266 1.615989 -0.255580 +v -0.260266 1.546786 -0.196445 +v -0.260266 1.615989 -0.196445 +v -0.260266 1.534940 -0.271398 +v -0.260266 1.627835 -0.271398 +v -0.260266 1.534940 -0.180628 +v -0.260266 1.627835 -0.180628 +v -0.251096 1.546786 -0.255580 +v -0.251096 1.615989 -0.255580 +v -0.251096 1.546786 -0.196445 +v -0.251096 1.615989 -0.196445 +v -0.251096 1.534940 -0.271398 +v -0.251096 1.627835 -0.271398 +v -0.251096 1.534940 -0.180628 +v -0.251096 1.627835 -0.180628 +v -0.231611 1.571106 -0.278496 +v -0.231611 1.594126 -0.278496 +v -0.251896 1.571106 -0.278496 +v -0.251896 1.594126 -0.278496 +v -0.231611 1.571106 -0.274260 +v -0.231611 1.594126 -0.274260 +v -0.251896 1.571106 -0.274260 +v -0.251896 1.594126 -0.274260 +v -0.268628 1.571106 -0.190272 +v -0.268628 1.594126 -0.190272 +v -0.268628 1.571106 -0.169514 +v -0.268628 1.594126 -0.169514 +v -0.264489 1.571106 -0.190272 +v -0.264489 1.594126 -0.190272 +v -0.264489 1.571106 -0.169514 +v -0.264489 1.594126 -0.169514 +v 0.234324 1.571106 -0.278496 +v 0.234324 1.594126 -0.278496 +v 0.254609 1.571106 -0.278496 +v 0.254609 1.594126 -0.278496 +v 0.234324 1.571106 -0.274260 +v 0.234324 1.594126 -0.274260 +v 0.254609 1.571106 -0.274260 +v 0.254609 1.594126 -0.274260 +v 0.271342 1.571106 -0.190272 +v 0.271342 1.594126 -0.190272 +v 0.271342 1.571106 -0.169515 +v 0.271342 1.594126 -0.169515 +v 0.267202 1.571106 -0.190272 +v 0.267202 1.594126 -0.190272 +v 0.267202 1.571106 -0.169515 +v 0.267202 1.594126 -0.169515 +v 0.010313 1.552583 0.273601 +v 0.010313 1.610341 0.273601 +v 0.078990 1.552583 0.273601 +v 0.078990 1.610341 0.273601 +v -0.001443 1.542696 0.273601 +v -0.001443 1.620228 0.273601 +v 0.090747 1.542696 0.273601 +v 0.090747 1.620228 0.273601 +v 0.010313 1.552583 0.264217 +v 0.010313 1.610341 0.264217 +v 0.078990 1.552583 0.264217 +v 0.078990 1.610341 0.264217 +v -0.001443 1.542696 0.264217 +v -0.001443 1.620228 0.264217 +v 0.090747 1.542696 0.264217 +v 0.090747 1.620228 0.264217 +v 0.046598 1.586406 0.281684 +v 0.002697 1.586406 0.271529 +v 0.046598 1.576519 0.281684 +v 0.002697 1.576519 0.271529 +v 0.047780 1.586406 0.272435 +v 0.003879 1.586406 0.262280 +v 0.047780 1.576519 0.272435 +v 0.003879 1.576519 0.262280 +v 0.262979 1.546786 -0.255581 +v 0.262979 1.615989 -0.255581 +v 0.262979 1.546786 -0.196446 +v 0.262979 1.615989 -0.196446 +v 0.262979 1.534940 -0.271398 +v 0.262979 1.627836 -0.271398 +v 0.262979 1.534940 -0.180628 +v 0.262979 1.627836 -0.180628 +v 0.253808 1.546786 -0.255581 +v 0.253808 1.615989 -0.255581 +v 0.253809 1.546786 -0.196446 +v 0.253809 1.615989 -0.196446 +v 0.253809 1.534940 -0.271398 +v 0.253809 1.627836 -0.271398 +v 0.253809 1.534940 -0.180628 +v 0.253809 1.627836 -0.180628 +v 0.046332 1.535183 -0.205267 +v 0.064865 1.553716 -0.205267 +v 0.064865 1.572249 -0.205267 +v 0.046332 1.553716 -0.186734 +v 0.046332 1.572249 -0.186734 +v 0.046332 1.553716 -0.205267 +v 0.046332 1.572249 -0.205267 +v 0.064865 1.535183 -0.223800 +v 0.064865 1.535183 -0.242332 +v 0.046332 1.516650 -0.223800 +v 0.046332 1.535183 -0.223800 +v 0.046332 1.516650 -0.242332 +v 0.046332 1.535183 -0.242332 +v 0.064865 1.553716 -0.223800 +v 0.064865 1.572249 -0.223800 +v 0.064865 1.553716 -0.242332 +v 0.064865 1.572249 -0.242332 +v 0.046332 1.553716 -0.223800 +v 0.027799 1.535183 -0.186734 +v 0.027799 1.516650 -0.205267 +v 0.027799 1.535183 -0.205267 +v 0.009266 1.535183 -0.186734 +v 0.009266 1.516650 -0.205267 +v 0.009266 1.535183 -0.205267 +v 0.027799 1.553716 -0.186734 +v 0.027799 1.572249 -0.186734 +v 0.027799 1.553716 -0.205267 +v 0.009266 1.553716 -0.186734 +v 0.009266 1.572249 -0.186734 +v 0.027799 1.516650 -0.223800 +v 0.027799 1.535183 -0.223800 +v 0.027799 1.516650 -0.242332 +v 0.009266 1.516650 -0.223800 +v 0.009266 1.516650 -0.242332 +v 0.064865 1.590782 -0.205267 +v 0.064865 1.609314 -0.205267 +v 0.046332 1.590782 -0.186734 +v 0.046332 1.609314 -0.186734 +v 0.046332 1.590782 -0.205267 +v 0.046332 1.609314 -0.205267 +v 0.046332 1.627847 -0.205267 +v 0.064865 1.590782 -0.223800 +v 0.064865 1.609314 -0.223800 +v 0.064865 1.590782 -0.242332 +v 0.064865 1.609314 -0.242332 +v 0.046332 1.609314 -0.223800 +v 0.064865 1.627847 -0.223800 +v 0.064865 1.627847 -0.242332 +v 0.046332 1.627847 -0.223800 +v 0.046332 1.646380 -0.223800 +v 0.046332 1.627847 -0.242332 +v 0.046332 1.646380 -0.242332 +v 0.027799 1.590782 -0.186734 +v 0.027799 1.609314 -0.186734 +v 0.027799 1.609314 -0.205267 +v 0.009266 1.590782 -0.186734 +v 0.009266 1.609314 -0.186734 +v 0.027799 1.627847 -0.186734 +v 0.027799 1.627847 -0.205267 +v 0.027799 1.646380 -0.205267 +v 0.009266 1.627847 -0.186734 +v 0.009266 1.627847 -0.205267 +v 0.009266 1.646380 -0.205267 +v 0.027799 1.627847 -0.223800 +v 0.027799 1.646380 -0.223800 +v 0.027799 1.646380 -0.242332 +v 0.009266 1.646380 -0.223800 +v 0.009266 1.646380 -0.242332 +v 0.064865 1.535183 -0.261041 +v 0.046332 1.516650 -0.261041 +v 0.046332 1.535183 -0.261041 +v 0.064865 1.553716 -0.261041 +v 0.064865 1.572249 -0.261041 +v 0.027799 1.516650 -0.261041 +v 0.009266 1.516650 -0.261041 +v 0.064865 1.590782 -0.261041 +v 0.064865 1.609314 -0.261041 +v 0.064865 1.627847 -0.261041 +v 0.046332 1.627847 -0.261041 +v 0.046332 1.646380 -0.261041 +v 0.027799 1.646380 -0.261041 +v 0.009266 1.646380 -0.261041 +v -0.009267 1.535183 -0.186734 +v -0.009267 1.516650 -0.205267 +v -0.009267 1.535183 -0.205267 +v -0.027799 1.535183 -0.186734 +v -0.027799 1.516650 -0.205267 +v -0.027799 1.535183 -0.205267 +v -0.009267 1.553716 -0.186734 +v -0.009267 1.572249 -0.186734 +v -0.027799 1.553716 -0.186734 +v -0.027799 1.572249 -0.186734 +v -0.027799 1.553716 -0.205267 +v -0.009267 1.516650 -0.223800 +v -0.009267 1.516650 -0.242332 +v -0.027799 1.516650 -0.223800 +v -0.027799 1.535183 -0.223800 +v -0.027799 1.516650 -0.242332 +v -0.046332 1.535183 -0.205267 +v -0.046332 1.553716 -0.186734 +v -0.046332 1.572249 -0.186734 +v -0.046332 1.553716 -0.205267 +v -0.046332 1.572249 -0.205267 +v -0.064865 1.553716 -0.205267 +v -0.064865 1.572249 -0.205267 +v -0.046332 1.516650 -0.223800 +v -0.046332 1.535183 -0.223800 +v -0.046332 1.516650 -0.242332 +v -0.046332 1.535183 -0.242332 +v -0.064865 1.535183 -0.223800 +v -0.064865 1.535183 -0.242332 +v -0.046332 1.553716 -0.223800 +v -0.064865 1.553716 -0.223800 +v -0.064865 1.572249 -0.223800 +v -0.064865 1.553716 -0.242332 +v -0.064865 1.572249 -0.242332 +v -0.009267 1.590782 -0.186734 +v -0.009267 1.609314 -0.186734 +v -0.027799 1.590782 -0.186734 +v -0.027799 1.609314 -0.186734 +v -0.027799 1.609314 -0.205267 +v -0.009267 1.627847 -0.186734 +v -0.009267 1.627847 -0.205267 +v -0.009267 1.646380 -0.205267 +v -0.027799 1.627847 -0.186734 +v -0.027799 1.627847 -0.205267 +v -0.027799 1.646380 -0.205267 +v -0.009267 1.646380 -0.223800 +v -0.009267 1.646380 -0.242332 +v -0.027799 1.627847 -0.223800 +v -0.027799 1.646380 -0.223800 +v -0.027799 1.646380 -0.242332 +v -0.046332 1.590782 -0.186734 +v -0.046332 1.609314 -0.186734 +v -0.046332 1.590782 -0.205267 +v -0.046332 1.609314 -0.205267 +v -0.064865 1.590782 -0.205267 +v -0.064865 1.609314 -0.205267 +v -0.046332 1.627847 -0.205267 +v -0.046332 1.609314 -0.223800 +v -0.064865 1.590782 -0.223800 +v -0.064865 1.609314 -0.223800 +v -0.064865 1.590782 -0.242332 +v -0.064865 1.609314 -0.242332 +v -0.046332 1.627847 -0.223800 +v -0.046332 1.646380 -0.223800 +v -0.046332 1.627847 -0.242332 +v -0.046332 1.646380 -0.242332 +v -0.064865 1.627847 -0.223800 +v -0.064865 1.627847 -0.242332 +v -0.009267 1.516650 -0.261041 +v -0.027799 1.516650 -0.261041 +v -0.046332 1.516650 -0.261041 +v -0.046332 1.535183 -0.261041 +v -0.064865 1.535183 -0.261041 +v -0.064865 1.553716 -0.261041 +v -0.064865 1.572249 -0.261041 +v -0.009267 1.646380 -0.261041 +v -0.027799 1.646380 -0.261041 +v -0.064865 1.590782 -0.261041 +v -0.064865 1.609314 -0.261041 +v -0.046332 1.627847 -0.261041 +v -0.046332 1.646380 -0.261041 +v -0.064865 1.627847 -0.261041 +vn 0.9434 -0.0000 0.3318 +vn 0.6201 -0.0000 -0.7846 +vn -0.0000 -1.0000 -0.0000 +vn -0.0000 1.0000 -0.0000 +vn -0.6221 -0.0000 0.7829 +vn -0.2502 -0.0000 0.9682 +vn 0.2502 -0.0000 -0.9682 +vn 1.0000 -0.0000 -0.0000 +vn -0.0000 -0.0000 -1.0000 +vn -1.0000 -0.0000 -0.0000 +vn -0.0000 -0.0000 1.0000 +vn -0.2254 -0.0000 0.9743 +vn 0.9919 -0.0000 0.1267 +vt 0.190913 0.801475 +vt 0.168583 0.801475 +vt 0.168583 0.673733 +vt 0.190913 0.673733 +vt 0.246740 0.823570 +vt 0.246740 0.951313 +vt 0.190913 0.951313 +vt 0.190913 0.823570 +vt 0.246740 0.801475 +vt 0.246740 0.651638 +vt 0.246740 0.673733 +vt 0.190913 0.651638 +vt 0.352387 0.673733 +vt 0.352387 0.801475 +vt 0.352387 0.651638 +vt 0.352387 0.823570 +vt 0.352387 0.951313 +vt 0.630344 0.173153 +vt 0.627883 0.195420 +vt 0.603213 0.190842 +vt 0.607900 0.156726 +vt 0.581708 0.166105 +vt 0.577763 0.192337 +vt 0.611368 0.076215 +vt 0.594510 0.106088 +vt 0.571187 0.082827 +vt 0.596739 0.057150 +vt 0.565991 0.120579 +vt 0.544244 0.110740 +vt 0.559448 0.152411 +vt 0.551843 0.188253 +vt 0.525168 0.186119 +vt 0.532375 0.147085 +vt 0.655709 0.093523 +vt 0.681828 0.089339 +vt 0.679214 0.115350 +vt 0.644528 0.119907 +vt 0.661446 0.141757 +vt 0.684062 0.138749 +vt 0.654591 0.166019 +vt 0.681316 0.166251 +vt 0.680783 0.192774 +vt 0.654678 0.193007 +vt 0.640514 0.072002 +vt 0.634509 0.044012 +vt 0.673760 0.037380 +vt 0.676439 0.064356 +vt 0.635894 0.147730 +vt 0.626963 0.099550 +vt 0.587753 0.137641 +vt 0.615668 0.127580 +vt 0.627891 0.217939 +vt 0.627729 0.239461 +vt 0.606746 0.254633 +vt 0.602772 0.219798 +vt 0.577170 0.219186 +vt 0.580979 0.243992 +vt 0.551328 0.223225 +vt 0.560805 0.259673 +vt 0.532473 0.264492 +vt 0.525155 0.226003 +vt 0.593740 0.305581 +vt 0.608665 0.334938 +vt 0.597647 0.355505 +vt 0.570258 0.329150 +vt 0.544083 0.301640 +vt 0.564663 0.290720 +vt 0.654397 0.219120 +vt 0.680317 0.219211 +vt 0.680046 0.245135 +vt 0.651477 0.246547 +vt 0.644587 0.292262 +vt 0.678602 0.296196 +vt 0.678803 0.322995 +vt 0.654518 0.316740 +vt 0.680635 0.272902 +vt 0.658991 0.269017 +vt 0.675040 0.347473 +vt 0.673331 0.374164 +vt 0.634648 0.367388 +vt 0.639893 0.339044 +vt 0.632642 0.265913 +vt 0.625283 0.312371 +vt 0.586984 0.273702 +vt 0.613869 0.285365 +vt 0.707965 0.090268 +vt 0.732255 0.093185 +vt 0.742296 0.119038 +vt 0.708960 0.115679 +vt 0.706583 0.139857 +vt 0.728311 0.142938 +vt 0.707268 0.166957 +vt 0.735860 0.165332 +vt 0.732944 0.192721 +vt 0.706979 0.192694 +vt 0.711589 0.063119 +vt 0.713780 0.037239 +vt 0.752869 0.045084 +vt 0.747572 0.072733 +vt 0.780533 0.157159 +vt 0.784568 0.191988 +vt 0.759458 0.193833 +vt 0.759594 0.172301 +vt 0.806350 0.167842 +vt 0.810180 0.192629 +vt 0.789108 0.056257 +vt 0.817117 0.082534 +vt 0.793604 0.106629 +vt 0.779539 0.078207 +vt 0.843246 0.110119 +vt 0.822666 0.121128 +vt 0.854874 0.147315 +vt 0.862204 0.185847 +vt 0.836026 0.188609 +vt 0.826526 0.152165 +vt 0.754581 0.145777 +vt 0.761629 0.099494 +vt 0.800303 0.138192 +vt 0.773326 0.126435 +vt 0.706497 0.219017 +vt 0.732677 0.218791 +vt 0.732850 0.245710 +vt 0.705905 0.245383 +vt 0.707813 0.296719 +vt 0.742716 0.292091 +vt 0.731295 0.318536 +vt 0.705395 0.322256 +vt 0.725896 0.269820 +vt 0.703363 0.272396 +vt 0.746176 0.340586 +vt 0.752648 0.367405 +vt 0.713081 0.374397 +vt 0.710225 0.348167 +vt 0.784146 0.220935 +vt 0.779425 0.255043 +vt 0.757031 0.238602 +vt 0.759488 0.216343 +vt 0.809601 0.219463 +vt 0.805650 0.245664 +vt 0.862189 0.225787 +vt 0.854945 0.264899 +vt 0.827928 0.259449 +vt 0.835523 0.223593 +vt 0.815808 0.329340 +vt 0.789995 0.355482 +vt 0.776441 0.334442 +vt 0.792867 0.305328 +vt 0.821271 0.291280 +vt 0.842939 0.301335 +vt 0.751452 0.264079 +vt 0.760113 0.312433 +vt 0.799606 0.274030 +vt 0.771671 0.284170 +vt 0.550122 0.063059 +vt 0.582268 0.033927 +vt 0.520298 0.097825 +vt 0.626435 0.016428 +vt 0.671377 0.009261 +vt 0.497105 0.183889 +vt 0.505069 0.139888 +vt 0.716255 0.009697 +vt 0.760069 0.017702 +vt 0.801938 0.032218 +vt 0.837555 0.061566 +vt 0.866356 0.096844 +vt 0.882253 0.139605 +vt 0.890244 0.183190 +vt 0.505093 0.272180 +vt 0.497116 0.228672 +vt 0.584418 0.378641 +vt 0.549539 0.349818 +vt 0.520926 0.314825 +vt 0.670876 0.402707 +vt 0.627041 0.394729 +vt 0.890244 0.228057 +vt 0.882218 0.272174 +vt 0.760172 0.394612 +vt 0.715711 0.402056 +vt 0.836715 0.349378 +vt 0.804067 0.379523 +vt 0.866820 0.314380 +vt 0.629801 0.722370 +vt 0.629801 0.749891 +vt 0.519022 0.749891 +vt 0.519022 0.722370 +vt 0.629801 0.406076 +vt 0.519022 0.406076 +vt 0.657322 0.722370 +vt 0.657322 0.406076 +vt 0.491501 0.406076 +vt 0.491501 0.722370 +vt 0.028418 0.833467 +vt 0.028418 0.805131 +vt 0.135693 0.805131 +vt 0.135693 0.833467 +vt 0.028418 0.853163 +vt 0.135693 0.853163 +vt 0.159858 -0.000000 +vt 0.187379 -0.000000 +vt 0.187379 0.608615 +vt 0.159858 0.608615 +vt 0.154216 0.805131 +vt 0.154216 0.833467 +vt 0.187379 0.636136 +vt 0.298158 0.608615 +vt 0.298158 0.636136 +vt 0.325679 0.608615 +vt 0.298158 -0.000000 +vt 0.325679 -0.000000 +vt 0.009894 0.833467 +vt 0.009894 0.805131 +vt 0.009894 0.427199 +vt 0.028418 0.427199 +vt 0.135693 0.427199 +vt 0.154216 0.427199 +vt 0.795622 0.433597 +vt 0.684843 0.433597 +vt 0.684843 0.406076 +vt 0.795622 0.406076 +vt 0.795622 0.749891 +vt 0.684843 0.749891 +vt 0.823143 0.433597 +vt 0.823143 0.749891 +vt 0.657322 0.749891 +vt 0.657322 0.433597 +vt 0.028418 0.020930 +vt 0.135693 0.020930 +vt 0.135693 0.049267 +vt 0.028418 0.049267 +vt 0.028418 0.001235 +vt 0.135693 0.001235 +vt 0.325679 0.636135 +vt 0.325679 0.027521 +vt 0.353201 0.027521 +vt 0.353200 0.636135 +vt 0.154216 0.049267 +vt 0.154216 0.020930 +vt 0.353201 -0.000000 +vt 0.463980 0.000000 +vt 0.463980 0.027521 +vt 0.491501 0.027521 +vt 0.491501 0.636135 +vt 0.463979 0.636135 +vt 0.009894 0.049267 +vt 0.009894 0.020930 +vt 0.868102 0.786337 +vt 0.890977 0.763462 +vt 0.890977 0.942844 +vt 0.868102 0.919969 +vt 0.734471 0.786337 +vt 0.711596 0.763462 +vt 0.890977 0.763462 +vt 0.734471 0.919969 +vt 0.711596 0.942844 +vt 0.748042 0.786337 +vt 0.748042 0.919969 +vt 0.904548 0.763462 +vt 0.904548 0.942844 +vt 0.711596 0.749891 +vt 0.890977 0.749891 +vt 0.734471 0.906398 +vt 0.868102 0.906398 +vt 0.890977 0.956415 +vt 0.711596 0.956415 +vt 0.854531 0.919969 +vt 0.854531 0.786337 +vt 0.698025 0.942844 +vt 0.698025 0.763462 +vt 0.868102 0.799908 +vt 0.734471 0.799908 +vt 0.395693 0.694111 +vt 0.436865 0.694111 +vt 0.436865 0.702513 +vt 0.395693 0.702513 +vt 0.436865 0.743686 +vt 0.395693 0.743686 +vt 0.387291 0.743686 +vt 0.387291 0.702513 +vt 0.445267 0.702513 +vt 0.445267 0.743686 +vt 0.436865 0.752087 +vt 0.395693 0.752087 +vt 0.436865 0.810064 +vt 0.395693 0.810064 +vt 0.395693 0.801662 +vt 0.436865 0.801662 +vt 0.395693 0.760490 +vt 0.436865 0.760490 +vt 0.445267 0.760490 +vt 0.445267 0.801662 +vt 0.387291 0.801662 +vt 0.387291 0.760490 +vt 0.395693 0.752088 +vt 0.436865 0.752088 +vt 0.436865 0.636136 +vt 0.436865 0.644537 +vt 0.395693 0.644537 +vt 0.395693 0.636136 +vt 0.436865 0.685710 +vt 0.395693 0.685710 +vt 0.445267 0.685710 +vt 0.445267 0.644537 +vt 0.387291 0.644538 +vt 0.387291 0.685710 +vt 0.436865 0.694112 +vt 0.395693 0.694112 +vt 0.395693 0.868039 +vt 0.395693 0.859638 +vt 0.436865 0.859638 +vt 0.436865 0.868039 +vt 0.395693 0.818465 +vt 0.436865 0.818465 +vt 0.387291 0.818465 +vt 0.387291 0.859638 +vt 0.445267 0.859638 +vt 0.445267 0.818465 +vt 0.395693 0.810064 +vt 0.436865 0.810063 +vt 0.967337 0.442522 +vt 0.986429 0.419647 +vt 0.986429 0.599029 +vt 0.967337 0.576154 +vt 0.855806 0.442522 +vt 0.836714 0.419647 +vt 0.986429 0.419647 +vt 0.967337 0.442522 +vt 0.855806 0.576154 +vt 0.836714 0.599029 +vt 0.869377 0.442522 +vt 0.869377 0.576154 +vt 1.000000 0.419647 +vt 1.000000 0.599029 +vt 0.836714 0.406076 +vt 0.986429 0.406076 +vt 0.855806 0.562583 +vt 0.967337 0.562583 +vt 0.986429 0.612600 +vt 0.836714 0.612600 +vt 0.953766 0.576154 +vt 0.953766 0.442522 +vt 0.823143 0.599029 +vt 0.823143 0.419647 +vt 0.967337 0.456093 +vt 0.855806 0.456093 +vt 0.458838 0.722811 +vt 0.458838 0.636136 +vt 0.477930 0.636136 +vt 0.477929 0.722811 +vt 0.445267 0.722811 +vt 0.445267 0.636135 +vt 0.491501 0.636135 +vt 0.491501 0.722811 +vt 0.477930 0.736382 +vt 0.458838 0.736382 +vt 0.661578 0.786337 +vt 0.527947 0.786337 +vt 0.505072 0.763462 +vt 0.684453 0.763462 +vt 0.661578 0.919969 +vt 0.684453 0.763462 +vt 0.684453 0.942844 +vt 0.527947 0.919969 +vt 0.505072 0.942844 +vt 0.527947 0.906398 +vt 0.661578 0.906398 +vt 0.505072 0.749891 +vt 0.684453 0.749891 +vt 0.698024 0.763462 +vt 0.698024 0.942844 +vt 0.541518 0.786337 +vt 0.541518 0.919969 +vt 0.491501 0.942844 +vt 0.491501 0.763462 +vt 0.661578 0.799908 +vt 0.527947 0.799908 +vt 0.684453 0.956415 +vt 0.505072 0.956415 +vt 0.648007 0.919969 +vt 0.648007 0.786337 +vt 0.952823 0.055095 +vt 0.959927 0.049842 +vt 0.961442 0.060656 +vt 0.953604 0.062053 +vt 0.968259 0.052847 +vt 0.969518 0.061168 +vt 0.958986 0.024702 +vt 0.963273 0.018021 +vt 0.971470 0.026313 +vt 0.964200 0.033925 +vt 0.973212 0.038383 +vt 0.980085 0.035195 +vt 0.975324 0.048479 +vt 0.983895 0.046759 +vt 0.986193 0.059180 +vt 0.977736 0.059870 +vt 0.944638 0.029704 +vt 0.948267 0.038109 +vt 0.937234 0.036708 +vt 0.936358 0.028453 +vt 0.942862 0.045074 +vt 0.935735 0.044162 +vt 0.945105 0.052776 +vt 0.945094 0.061354 +vt 0.936830 0.061311 +vt 0.936636 0.052891 +vt 0.949396 0.022775 +vt 0.937998 0.020586 +vt 0.938870 0.012137 +vt 0.951406 0.014217 +vt 0.951028 0.046970 +vt 0.953801 0.031675 +vt 0.966334 0.043846 +vt 0.957467 0.040622 +vt 0.953615 0.069127 +vt 0.961596 0.069853 +vt 0.960267 0.081121 +vt 0.953696 0.075781 +vt 0.969702 0.069707 +vt 0.968455 0.077610 +vt 0.977892 0.070993 +vt 0.986197 0.071867 +vt 0.983873 0.084108 +vt 0.974877 0.082576 +vt 0.964434 0.097038 +vt 0.971880 0.104683 +vt 0.962985 0.113028 +vt 0.959931 0.106076 +vt 0.973664 0.092435 +vt 0.980188 0.095926 +vt 0.945186 0.069672 +vt 0.946114 0.078521 +vt 0.937074 0.077956 +vt 0.936997 0.069710 +vt 0.948303 0.092859 +vt 0.945137 0.100705 +vt 0.937432 0.102661 +vt 0.937515 0.094151 +vt 0.936868 0.086757 +vt 0.943769 0.085553 +vt 0.938582 0.110436 +vt 0.949750 0.107866 +vt 0.951482 0.116621 +vt 0.939169 0.118868 +vt 0.952153 0.084239 +vt 0.954387 0.099214 +vt 0.966573 0.087037 +vt 0.957995 0.090640 +vt 0.927939 0.028554 +vt 0.927886 0.036873 +vt 0.917149 0.038222 +vt 0.920242 0.030444 +vt 0.928586 0.044542 +vt 0.921685 0.045661 +vt 0.928414 0.053169 +vt 0.928528 0.061319 +vt 0.920287 0.061405 +vt 0.919322 0.052760 +vt 0.926843 0.020426 +vt 0.915671 0.023315 +vt 0.913970 0.014379 +vt 0.926256 0.012088 +vt 0.905145 0.050191 +vt 0.911802 0.055055 +vt 0.911883 0.061928 +vt 0.903899 0.061247 +vt 0.895762 0.061391 +vt 0.896979 0.053537 +vt 0.902243 0.018128 +vt 0.905742 0.024658 +vt 0.901011 0.033992 +vt 0.893557 0.026510 +vt 0.891793 0.038709 +vt 0.885260 0.035246 +vt 0.881584 0.047027 +vt 0.890575 0.048557 +vt 0.887564 0.060100 +vt 0.879261 0.059224 +vt 0.913340 0.046622 +vt 0.911002 0.031827 +vt 0.898878 0.044114 +vt 0.907406 0.040416 +vt 0.928701 0.069643 +vt 0.928875 0.077966 +vt 0.920280 0.077867 +vt 0.920392 0.069595 +vt 0.928242 0.094314 +vt 0.928998 0.102429 +vt 0.920772 0.101226 +vt 0.917242 0.092968 +vt 0.922587 0.085737 +vt 0.929647 0.086544 +vt 0.915975 0.108101 +vt 0.927403 0.110639 +vt 0.926560 0.119017 +vt 0.914054 0.116934 +vt 0.904045 0.070391 +vt 0.911909 0.069178 +vt 0.912688 0.076365 +vt 0.905463 0.080910 +vt 0.897197 0.078124 +vt 0.895926 0.069873 +vt 0.879266 0.071864 +vt 0.887718 0.071169 +vt 0.890152 0.082506 +vt 0.881578 0.084237 +vt 0.893957 0.104601 +vt 0.901355 0.097201 +vt 0.906707 0.106713 +vt 0.902065 0.112757 +vt 0.892275 0.092616 +vt 0.885385 0.095762 +vt 0.914567 0.084349 +vt 0.911654 0.099319 +vt 0.899137 0.087135 +vt 0.908011 0.090345 +vt 0.967744 0.010415 +vt 0.978100 0.019958 +vt 0.987660 0.031055 +vt 0.939705 0.003103 +vt 0.953813 0.005718 +vt 0.992547 0.044454 +vt 0.995093 0.058460 +vt 0.911554 0.005570 +vt 0.925511 0.003297 +vt 0.898026 0.010789 +vt 0.886975 0.019960 +vt 0.877908 0.031071 +vt 0.872896 0.044594 +vt 0.870366 0.058385 +vt 0.995093 0.072707 +vt 0.992560 0.086549 +vt 0.978365 0.111332 +vt 0.967067 0.120635 +vt 0.987518 0.100135 +vt 0.953770 0.125172 +vt 0.939973 0.127894 +vt 0.870368 0.072586 +vt 0.872924 0.086542 +vt 0.925762 0.127812 +vt 0.911499 0.125661 +vt 0.887281 0.110885 +vt 0.897480 0.120126 +vt 0.877797 0.099879 +s 0 +usemtl Ball +f 16/18/8 17/19/8 19/20/8 18/21/8 +f 14/22/9 18/21/9 19/20/9 15/23/9 +f 22/24/8 23/25/8 25/26/8 24/27/8 +f 20/28/3 21/29/3 25/26/3 23/25/3 +f 26/30/8 27/31/8 29/32/8 28/33/8 +f 32/34/9 35/35/9 36/36/9 33/37/9 +f 31/38/3 33/37/3 36/36/3 34/39/3 +f 37/40/9 40/41/9 41/42/9 38/43/9 +f 42/44/3 44/45/3 46/46/3 45/47/3 +f 16/18/9 37/40/9 38/43/9 17/19/9 +f 16/18/3 18/21/3 39/48/3 37/40/3 +f 22/24/9 42/44/9 43/49/9 23/25/9 +f 22/24/3 24/27/3 44/45/3 42/44/3 +f 32/34/8 33/37/8 43/49/8 42/44/8 +f 32/34/3 42/44/3 45/47/3 35/35/3 +f 14/22/8 15/23/8 27/31/8 26/30/8 +f 14/22/3 26/30/3 30/50/3 18/21/3 +f 20/28/8 26/30/8 28/33/8 21/29/8 +f 20/28/9 23/25/9 30/50/9 26/30/9 +f 31/38/8 37/40/8 39/48/8 33/37/8 +f 31/38/9 34/39/9 40/41/9 37/40/9 +f 13/51/8 18/21/8 30/50/8 23/25/8 +f 13/51/9 33/37/9 39/48/9 18/21/9 +f 13/51/3 23/25/3 43/49/3 33/37/3 +f 49/52/8 50/53/8 52/54/8 51/55/8 +f 47/56/9 51/55/9 52/54/9 48/57/9 +f 54/58/8 55/59/8 57/60/8 56/61/8 +f 61/62/8 62/63/8 64/64/8 63/65/8 +f 61/62/4 63/65/4 60/66/4 59/67/4 +f 65/68/9 68/69/9 69/70/9 66/71/9 +f 71/72/9 74/73/9 75/74/9 72/75/9 +f 73/76/4 74/73/4 71/72/4 70/77/4 +f 79/78/4 80/79/4 78/80/4 77/81/4 +f 49/52/9 65/68/9 66/71/9 50/53/9 +f 66/71/4 67/82/4 52/54/4 50/53/4 +f 61/62/9 76/83/9 77/81/9 62/63/9 +f 77/81/4 78/80/4 64/64/4 62/63/4 +f 47/56/8 48/57/8 55/59/8 54/58/8 +f 52/54/4 58/84/4 55/59/4 48/57/4 +f 71/72/8 72/75/8 77/81/8 76/83/8 +f 75/74/4 79/78/4 77/81/4 72/75/4 +f 55/59/8 59/67/8 60/66/8 57/60/8 +f 55/59/9 58/84/9 61/62/9 59/67/9 +f 66/71/8 70/77/8 71/72/8 67/82/8 +f 66/71/9 69/70/9 73/76/9 70/77/9 +f 52/54/8 53/85/8 61/62/8 58/84/8 +f 52/54/9 67/82/9 71/72/9 53/85/9 +f 71/72/4 76/83/4 61/62/4 53/85/4 +f 82/86/9 85/87/9 86/88/9 83/89/9 +f 81/90/3 83/89/3 86/88/3 84/91/3 +f 87/92/9 89/93/9 90/94/9 88/95/9 +f 92/96/3 93/97/3 96/98/3 94/99/3 +f 100/100/10 101/101/10 99/102/10 98/103/10 +f 100/100/9 102/104/9 103/105/9 101/101/9 +f 106/106/10 107/107/10 105/108/10 104/109/10 +f 105/108/3 107/107/3 109/110/3 108/111/3 +f 113/112/10 114/113/10 112/114/10 111/115/10 +f 89/93/9 98/103/9 99/102/9 90/94/9 +f 89/93/3 91/116/3 100/100/3 98/103/3 +f 94/99/9 104/109/9 105/108/9 95/117/9 +f 94/99/3 96/98/3 106/106/3 104/109/3 +f 94/99/10 95/117/10 86/88/10 85/87/10 +f 82/86/3 92/96/3 94/99/3 85/87/3 +f 111/115/10 112/114/10 103/105/10 102/104/10 +f 100/100/3 110/118/3 111/115/3 102/104/3 +f 86/88/10 91/116/10 89/93/10 84/91/10 +f 81/90/9 84/91/9 89/93/9 87/92/9 +f 109/110/10 113/112/10 111/115/10 108/111/10 +f 105/108/9 108/111/9 111/115/9 110/118/9 +f 105/108/10 110/118/10 100/100/10 97/119/10 +f 86/88/9 97/119/9 100/100/9 91/116/9 +f 86/88/3 95/117/3 105/108/3 97/119/3 +f 115/120/9 117/121/9 118/122/9 116/123/9 +f 121/124/9 124/125/9 125/126/9 122/127/9 +f 123/128/4 124/125/4 121/124/4 120/129/4 +f 129/130/4 130/131/4 127/132/4 126/133/4 +f 133/134/10 134/135/10 132/136/10 131/137/10 +f 133/134/9 135/138/9 136/139/9 134/135/9 +f 141/140/10 142/141/10 140/142/10 139/143/10 +f 145/144/10 146/145/10 144/146/10 143/147/10 +f 147/148/4 148/149/4 145/144/4 143/147/4 +f 117/121/9 131/137/9 132/136/9 118/122/9 +f 132/136/4 134/135/4 119/150/4 118/122/4 +f 128/151/9 143/147/9 144/146/9 129/130/9 +f 144/146/4 146/145/4 130/131/4 129/130/4 +f 139/143/10 140/142/10 136/139/10 135/138/10 +f 136/139/4 140/142/4 138/152/4 134/135/4 +f 128/151/10 129/130/10 125/126/10 124/125/10 +f 125/126/4 129/130/4 126/133/4 122/127/4 +f 119/150/10 124/125/10 123/128/10 118/122/10 +f 116/123/9 118/122/9 123/128/9 120/129/9 +f 142/141/10 148/149/10 147/148/10 140/142/10 +f 138/152/9 140/142/9 147/148/9 143/147/9 +f 138/152/10 143/147/10 137/153/10 134/135/10 +f 119/150/9 134/135/9 137/153/9 124/125/9 +f 137/153/4 143/147/4 128/151/4 124/125/4 +f 35/35/9 82/86/9 83/89/9 36/36/9 +f 34/39/3 36/36/3 83/89/3 81/90/3 +f 40/41/9 87/92/9 88/95/9 41/42/9 +f 45/47/3 46/46/3 93/97/3 92/96/3 +f 34/39/9 81/90/9 87/92/9 40/41/9 +f 35/35/3 45/47/3 92/96/3 82/86/3 +f 68/69/9 115/120/9 116/123/9 69/70/9 +f 74/73/9 121/124/9 122/127/9 75/74/9 +f 120/129/4 121/124/4 74/73/4 73/76/4 +f 126/133/4 127/132/4 80/79/4 79/78/4 +f 69/70/9 116/123/9 120/129/9 73/76/9 +f 122/127/4 126/133/4 79/78/4 75/74/4 +f 24/27/8 25/26/8 351/154/8 350/155/8 +f 21/29/3 349/156/3 351/154/3 25/26/3 +f 44/45/3 354/157/3 355/158/3 46/46/3 +f 28/33/8 29/32/8 353/159/8 352/160/8 +f 21/29/8 28/33/8 352/160/8 349/156/8 +f 24/27/3 350/155/3 354/157/3 44/45/3 +f 93/97/3 431/161/3 432/162/3 96/98/3 +f 433/163/10 434/164/10 107/107/10 106/106/10 +f 107/107/3 434/164/3 435/165/3 109/110/3 +f 436/166/10 437/167/10 114/113/10 113/112/10 +f 435/165/10 436/166/10 113/112/10 109/110/10 +f 96/98/3 432/162/3 433/163/3 106/106/3 +f 56/61/8 57/60/8 357/168/8 356/169/8 +f 63/65/8 64/64/8 360/170/8 359/171/8 +f 63/65/4 359/171/4 358/172/4 60/66/4 +f 80/79/4 362/173/4 361/174/4 78/80/4 +f 57/60/8 60/66/8 358/172/8 357/168/8 +f 78/80/4 361/174/4 360/170/4 64/64/4 +f 440/175/10 441/176/10 142/141/10 141/140/10 +f 130/131/4 439/177/4 438/178/4 127/132/4 +f 442/179/10 443/180/10 146/145/10 145/144/10 +f 148/149/4 444/181/4 442/179/4 145/144/4 +f 441/176/10 444/181/10 148/149/10 142/141/10 +f 146/145/4 443/180/4 439/177/4 130/131/4 +f 17/19/8 49/52/8 51/55/8 19/20/8 +f 15/23/9 19/20/9 51/55/9 47/56/9 +f 27/31/8 54/58/8 56/61/8 29/32/8 +f 38/43/9 41/42/9 68/69/9 65/68/9 +f 15/23/8 47/56/8 54/58/8 27/31/8 +f 17/19/9 38/43/9 65/68/9 49/52/9 +f 88/95/9 90/94/9 117/121/9 115/120/9 +f 101/101/10 133/134/10 131/137/10 99/102/10 +f 101/101/9 103/105/9 135/138/9 133/134/9 +f 114/113/10 141/140/10 139/143/10 112/114/10 +f 112/114/10 139/143/10 135/138/10 103/105/10 +f 90/94/9 99/102/9 131/137/9 117/121/9 +f 29/32/8 56/61/8 356/169/8 353/159/8 +f 437/167/10 440/175/10 141/140/10 114/113/10 +f 41/42/9 88/95/9 115/120/9 68/69/9 +f 46/46/3 355/158/3 431/161/3 93/97/3 +f 127/132/4 438/178/4 362/173/4 80/79/4 +f 284/380/8 286/381/8 287/382/8 285/383/8 +f 282/384/11 283/385/11 287/382/11 286/381/11 +f 290/386/8 292/387/8 293/388/8 291/389/8 +f 288/390/3 291/389/3 293/388/3 289/391/3 +f 294/392/8 296/393/8 297/394/8 295/395/8 +f 300/396/11 301/397/11 304/398/11 303/399/11 +f 299/400/3 302/401/3 304/398/3 301/397/3 +f 305/402/11 306/403/11 309/404/11 308/405/11 +f 310/406/3 313/407/3 314/408/3 312/409/3 +f 284/380/11 285/383/11 306/403/11 305/402/11 +f 284/380/3 305/402/3 307/410/3 286/381/3 +f 290/386/11 291/389/11 311/411/11 310/406/11 +f 290/386/3 310/406/3 312/409/3 292/387/3 +f 300/396/8 310/406/8 311/411/8 301/397/8 +f 300/396/3 303/399/3 313/407/3 310/406/3 +f 282/384/8 294/392/8 295/395/8 283/385/8 +f 282/384/3 286/381/3 298/412/3 294/392/3 +f 288/390/8 289/391/8 296/393/8 294/392/8 +f 288/390/11 294/392/11 298/412/11 291/389/11 +f 299/400/8 301/397/8 307/410/8 305/402/8 +f 299/400/11 305/402/11 308/405/11 302/401/11 +f 281/413/8 291/389/8 298/412/8 286/381/8 +f 281/413/11 286/381/11 307/410/11 301/397/11 +f 281/413/3 301/397/3 311/411/3 291/389/3 +f 317/414/8 319/415/8 320/416/8 318/417/8 +f 315/418/11 316/419/11 320/416/11 319/415/11 +f 322/420/8 324/421/8 325/422/8 323/423/8 +f 329/424/8 331/425/8 332/426/8 330/427/8 +f 329/424/4 327/428/4 328/429/4 331/425/4 +f 333/430/11 334/431/11 337/432/11 336/433/11 +f 339/434/11 340/435/11 343/436/11 342/437/11 +f 341/438/4 338/439/4 339/434/4 342/437/4 +f 347/440/4 345/441/4 346/442/4 348/443/4 +f 317/414/11 318/417/11 334/431/11 333/430/11 +f 334/431/4 318/417/4 320/416/4 335/444/4 +f 329/424/11 330/427/11 345/441/11 344/445/11 +f 345/441/4 330/427/4 332/426/4 346/442/4 +f 315/418/8 322/420/8 323/423/8 316/419/8 +f 320/416/4 316/419/4 323/423/4 326/446/4 +f 339/434/8 344/445/8 345/441/8 340/435/8 +f 343/436/4 340/435/4 345/441/4 347/440/4 +f 323/423/8 325/422/8 328/429/8 327/428/8 +f 323/423/11 327/428/11 329/424/11 326/446/11 +f 334/431/8 335/444/8 339/434/8 338/439/8 +f 334/431/11 338/439/11 341/438/11 337/432/11 +f 320/416/8 326/446/8 329/424/8 321/447/8 +f 320/416/11 321/447/11 339/434/11 335/444/11 +f 339/434/4 321/447/4 329/424/4 344/445/4 +f 364/448/11 365/449/11 368/450/11 367/451/11 +f 363/452/3 366/453/3 368/450/3 365/449/3 +f 369/454/11 370/455/11 372/456/11 371/457/11 +f 374/458/3 376/459/3 378/460/3 375/461/3 +f 382/462/10 380/463/10 381/464/10 383/465/10 +f 382/462/11 383/465/11 385/466/11 384/467/11 +f 388/468/10 386/469/10 387/470/10 389/471/10 +f 387/470/3 390/472/3 391/473/3 389/471/3 +f 395/474/10 393/475/10 394/476/10 396/477/10 +f 371/457/11 372/456/11 381/464/11 380/463/11 +f 371/457/3 380/463/3 382/462/3 373/478/3 +f 376/459/11 377/479/11 387/470/11 386/469/11 +f 376/459/3 386/469/3 388/468/3 378/460/3 +f 376/459/10 367/451/10 368/450/10 377/479/10 +f 364/448/3 367/451/3 376/459/3 374/458/3 +f 393/475/10 384/467/10 385/466/10 394/476/10 +f 382/462/3 384/467/3 393/475/3 392/480/3 +f 368/450/10 366/453/10 371/457/10 373/478/10 +f 363/452/11 369/454/11 371/457/11 366/453/11 +f 391/473/10 390/472/10 393/475/10 395/474/10 +f 387/470/11 392/480/11 393/475/11 390/472/11 +f 387/470/10 379/481/10 382/462/10 392/480/10 +f 368/450/11 373/478/11 382/462/11 379/481/11 +f 368/450/3 379/481/3 387/470/3 377/479/3 +f 397/482/11 398/483/11 400/484/11 399/485/11 +f 403/486/11 404/487/11 407/488/11 406/489/11 +f 405/490/4 402/491/4 403/486/4 406/489/4 +f 411/492/4 408/493/4 409/494/4 412/495/4 +f 415/496/10 413/497/10 414/498/10 416/499/10 +f 415/496/11 416/499/11 418/500/11 417/501/11 +f 423/502/10 421/503/10 422/504/10 424/505/10 +f 427/506/10 425/507/10 426/508/10 428/509/10 +f 429/510/4 425/507/4 427/506/4 430/511/4 +f 399/485/11 400/484/11 414/498/11 413/497/11 +f 414/498/4 400/484/4 401/512/4 416/499/4 +f 410/513/11 411/492/11 426/508/11 425/507/11 +f 426/508/4 411/492/4 412/495/4 428/509/4 +f 421/503/10 417/501/10 418/500/10 422/504/10 +f 418/500/4 416/499/4 420/514/4 422/504/4 +f 410/513/10 406/489/10 407/488/10 411/492/10 +f 407/488/4 404/487/4 408/493/4 411/492/4 +f 401/512/10 400/484/10 405/490/10 406/489/10 +f 398/483/11 402/491/11 405/490/11 400/484/11 +f 424/505/10 422/504/10 429/510/10 430/511/10 +f 420/514/11 425/507/11 429/510/11 422/504/11 +f 420/514/10 416/499/10 419/515/10 425/507/10 +f 401/512/11 406/489/11 419/515/11 416/499/11 +f 419/515/4 406/489/4 410/513/4 425/507/4 +f 303/399/11 304/398/11 365/449/11 364/448/11 +f 302/401/3 363/452/3 365/449/3 304/398/3 +f 308/405/11 309/404/11 370/455/11 369/454/11 +f 313/407/3 374/458/3 375/461/3 314/408/3 +f 302/401/11 308/405/11 369/454/11 363/452/11 +f 303/399/3 364/448/3 374/458/3 313/407/3 +f 336/433/11 337/432/11 398/483/11 397/482/11 +f 342/437/11 343/436/11 404/487/11 403/486/11 +f 402/491/4 341/438/4 342/437/4 403/486/4 +f 408/493/4 347/440/4 348/443/4 409/494/4 +f 337/432/11 341/438/11 402/491/11 398/483/11 +f 404/487/4 343/436/4 347/440/4 408/493/4 +f 292/387/8 350/516/8 351/517/8 293/388/8 +f 289/391/3 293/388/3 351/517/3 349/518/3 +f 312/409/3 314/408/3 355/519/3 354/520/3 +f 296/393/8 352/521/8 353/522/8 297/394/8 +f 289/391/8 349/518/8 352/521/8 296/393/8 +f 292/387/3 312/409/3 354/520/3 350/516/3 +f 375/461/3 378/460/3 432/523/3 431/524/3 +f 433/525/10 388/468/10 389/471/10 434/526/10 +f 389/471/3 391/473/3 435/527/3 434/526/3 +f 436/528/10 395/474/10 396/477/10 437/529/10 +f 435/527/10 391/473/10 395/474/10 436/528/10 +f 378/460/3 388/468/3 433/525/3 432/523/3 +f 324/421/8 356/530/8 357/531/8 325/422/8 +f 331/425/8 359/532/8 360/533/8 332/426/8 +f 331/425/4 328/429/4 358/534/4 359/532/4 +f 348/443/4 346/442/4 361/535/4 362/536/4 +f 325/422/8 357/531/8 358/534/8 328/429/8 +f 346/442/4 332/426/4 360/533/4 361/535/4 +f 440/537/10 423/502/10 424/505/10 441/538/10 +f 412/495/4 409/494/4 438/539/4 439/540/4 +f 442/541/10 427/506/10 428/509/10 443/542/10 +f 430/511/4 427/506/4 442/541/4 444/543/4 +f 441/538/10 424/505/10 430/511/10 444/543/10 +f 428/509/4 412/495/4 439/540/4 443/542/4 +f 285/383/8 287/382/8 319/415/8 317/414/8 +f 283/385/11 315/418/11 319/415/11 287/382/11 +f 295/395/8 297/394/8 324/421/8 322/420/8 +f 306/403/11 333/430/11 336/433/11 309/404/11 +f 283/385/8 295/395/8 322/420/8 315/418/8 +f 285/383/11 317/414/11 333/430/11 306/403/11 +f 370/455/11 397/482/11 399/485/11 372/456/11 +f 383/465/10 381/464/10 413/497/10 415/496/10 +f 383/465/11 415/496/11 417/501/11 385/466/11 +f 396/477/10 394/476/10 421/503/10 423/502/10 +f 394/476/10 385/466/10 417/501/10 421/503/10 +f 372/456/11 399/485/11 413/497/11 381/464/11 +f 297/394/8 353/522/8 356/530/8 324/421/8 +f 437/529/10 396/477/10 423/502/10 440/537/10 +f 309/404/11 336/433/11 397/482/11 370/455/11 +f 314/408/3 375/461/3 431/524/3 355/519/3 +f 409/494/4 348/443/4 362/536/4 438/539/4 +usemtl Leather +f 6/1/1 7/2/1 8/3/1 5/4/1 +f 10/5/2 9/6/2 8/7/2 7/8/2 +f 11/9/3 10/5/3 7/8/3 6/1/3 +f 9/10/4 12/11/4 5/4/4 8/12/4 +f 12/11/5 11/9/5 6/1/5 5/4/5 +f 1/13/6 2/14/6 11/9/6 12/11/6 +f 4/15/4 1/13/4 12/11/4 9/10/4 +f 2/14/3 3/16/3 10/5/3 11/9/3 +f 3/16/7 4/17/7 9/6/7 10/5/7 +f 149/182/10 151/183/10 152/184/10 150/185/10 +f 153/186/9 149/182/9 150/185/9 154/187/9 +f 151/188/3 149/182/3 153/186/3 155/189/3 +f 156/190/4 154/187/4 150/185/4 152/191/4 +f 157/192/11 159/193/11 160/194/11 158/195/11 +f 167/196/10 157/192/10 158/195/10 168/197/10 +f 165/198/3 167/199/3 161/200/3 163/201/3 +f 166/202/4 168/203/4 158/195/4 160/194/4 +f 163/204/9 161/200/9 162/205/9 164/206/9 +f 164/207/4 162/205/4 168/208/4 166/209/4 +f 159/193/3 157/192/3 167/210/3 165/211/3 +f 161/200/10 167/199/10 168/208/10 162/205/10 +f 165/211/3 171/212/3 170/213/3 159/193/3 +f 160/194/4 169/214/4 172/215/4 166/202/4 +f 159/193/11 170/213/11 169/214/11 160/194/11 +f 173/216/8 174/217/8 176/218/8 175/219/8 +f 177/220/9 178/221/9 174/217/9 173/216/9 +f 175/222/3 179/223/3 177/220/3 173/216/3 +f 180/224/4 176/225/4 174/217/4 178/221/4 +f 181/226/11 182/227/11 184/228/11 183/229/11 +f 191/230/8 192/231/8 182/227/8 181/226/8 +f 189/232/3 187/233/3 185/234/3 191/235/3 +f 190/236/4 184/228/4 182/227/4 192/237/4 +f 187/238/9 188/239/9 186/240/9 185/234/9 +f 188/241/4 190/242/4 192/243/4 186/240/4 +f 183/229/3 189/244/3 191/245/3 181/226/3 +f 185/234/8 186/240/8 192/243/8 191/235/8 +f 189/244/3 183/229/3 170/213/3 171/212/3 +f 184/228/4 190/236/4 172/215/4 169/214/4 +f 183/229/11 184/228/11 169/214/11 170/213/11 +usemtl Metal +f 196/246/10 200/247/10 198/248/10 194/249/10 +f 195/250/10 199/251/10 200/252/10 196/246/10 +f 193/253/10 197/254/10 199/251/10 195/250/10 +f 193/253/10 194/249/10 198/248/10 197/254/10 +f 195/250/4 203/255/4 201/256/4 193/253/4 +f 200/247/4 208/257/4 206/258/4 198/248/4 +f 199/251/11 207/259/11 208/260/11 200/252/11 +f 193/253/11 201/261/11 202/262/11 194/249/11 +f 198/248/9 206/263/9 205/264/9 197/254/9 +f 194/249/3 202/265/3 204/266/3 196/246/3 +f 197/254/3 205/267/3 207/268/3 199/251/3 +f 196/246/9 204/269/9 203/270/9 195/250/9 +f 215/271/10 216/272/10 212/273/10 211/274/10 +f 211/274/9 212/273/9 210/275/9 209/276/9 +f 213/277/3 215/278/3 211/274/3 209/276/3 +f 216/279/4 214/280/4 210/275/4 212/273/4 +f 209/276/8 210/275/8 214/281/8 213/282/8 +f 223/283/11 224/284/11 220/285/11 219/286/11 +f 219/286/10 220/285/10 218/287/10 217/288/10 +f 221/289/3 223/290/3 219/286/3 217/288/3 +f 224/291/4 222/292/4 218/287/4 220/285/4 +f 217/288/9 218/287/9 222/293/9 221/294/9 +f 231/295/8 227/296/8 228/297/8 232/298/8 +f 227/296/9 225/299/9 226/300/9 228/297/9 +f 229/301/3 225/299/3 227/296/3 231/302/3 +f 232/303/4 228/297/4 226/300/4 230/304/4 +f 225/299/10 229/305/10 230/306/10 226/300/10 +f 239/307/11 235/308/11 236/309/11 240/310/11 +f 235/308/8 233/311/8 234/312/8 236/309/8 +f 237/313/3 233/311/3 235/308/3 239/314/3 +f 240/315/4 236/309/4 234/312/4 238/316/4 +f 233/311/9 237/317/9 238/318/9 234/312/9 +f 244/319/11 248/320/11 246/321/11 242/322/11 +f 243/323/11 247/324/11 248/325/11 244/326/11 +f 241/327/11 245/328/11 247/324/11 243/323/11 +f 241/327/11 242/322/11 246/321/11 245/328/11 +f 243/323/4 251/329/4 249/330/4 241/327/4 +f 248/320/4 256/331/4 254/332/4 246/321/4 +f 247/324/8 255/333/8 256/334/8 248/325/8 +f 241/327/8 249/335/8 250/336/8 242/322/8 +f 246/321/10 254/337/10 253/338/10 245/328/10 +f 242/322/3 250/339/3 252/340/3 244/319/3 +f 245/328/3 253/341/3 255/342/3 247/324/3 +f 244/326/10 252/343/10 251/344/10 243/323/10 +f 257/345/12 258/346/12 260/347/12 259/348/12 +f 257/345/4 261/349/4 262/350/4 258/346/4 +f 260/347/3 264/351/3 263/352/3 259/348/3 +f 257/345/13 259/348/13 263/353/13 261/354/13 +f 268/355/8 266/356/8 270/357/8 272/358/8 +f 267/359/8 268/355/8 272/360/8 271/361/8 +f 265/362/8 267/359/8 271/361/8 269/363/8 +f 265/362/8 269/363/8 270/357/8 266/356/8 +f 267/359/4 265/362/4 273/364/4 275/365/4 +f 272/358/4 270/357/4 278/366/4 280/367/4 +f 271/361/11 272/360/11 280/368/11 279/369/11 +f 265/362/11 266/356/11 274/370/11 273/371/11 +f 270/357/9 269/363/9 277/372/9 278/373/9 +f 266/356/3 268/355/3 276/374/3 274/375/3 +f 269/363/3 271/361/3 279/376/3 277/377/3 +f 268/355/9 267/359/9 275/378/9 276/379/9 diff --git a/src/main/resources/assets/tiedup/models/obj/ball_gag/texture.png b/src/main/resources/assets/tiedup/models/obj/ball_gag/texture.png new file mode 100644 index 0000000..e44b745 Binary files /dev/null and b/src/main/resources/assets/tiedup/models/obj/ball_gag/texture.png differ diff --git a/src/main/resources/assets/tiedup/models/obj/blocks/bed/model.obj b/src/main/resources/assets/tiedup/models/obj/blocks/bed/model.obj new file mode 100644 index 0000000..afa7cd9 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/obj/blocks/bed/model.obj @@ -0,0 +1,740 @@ +# Made in Blockbench 5.0.5 +mtllib pet_bed.mtl + +o Pet_Bed +v -0.72021 0.26076125 0 +v -0.72021 0.368893125 0 +v -0.710051875 0.371245 -0.510496875 +v -0.710051875 0.26159875 -0.510496875 +v -0.512496875 0.261651875 0.53741875 +v -0.512496875 0.371393125 0.53741875 +v -0.699353125 0.36654125 0.51242 +v -0.699353125 0.259921875 0.51242 +v -0.564041875 0.12387625 0.405523125 +v -0.512496875 0.160571875 0.439483125 +v -0.47753125 0.211653125 0.40180625 +v -0.535431875 0.15737 0.384953125 +v -0.512496875 0.231446875 -0.45577375 +v -0.585751875 0.25197375 -0.42113125 +v -0.564041875 0.12991875 -0.405523125 +v -0.512496875 0.17517375 -0.439483125 +v -0.58132375 0.24060875 0 +v -0.585751875 0.241671875 0.42113125 +v -0.562386875 0.12457 0 +v -0.72021 0.000216875 0 +v -0.72021 0.145495 0 +v -0.710051875 0.14514 -0.510496875 +v -0.710051875 0.000216875 -0.510496875 +v -0.512496875 0.000216875 0.53741875 +v -0.512496875 0.145118125 0.53741875 +v -0.699353125 0.14587125 0.51242 +v -0.699353125 0.000216875 0.51242 +v -0.491866875 0.24784875 0.437426875 +v 0 0.254583125 0.438186875 +v 0 0.16239125 0.440348125 +v -0.512496875 0.000216875 -0.53741875 +v -0.512496875 0.16192375 -0.53741875 +v 0 0.14267875 -0.53741875 +v 0 0.000216875 -0.53741875 +v -0.512496875 0.30131875 -0.53741875 +v -0.512496875 0.235866875 -0.53741875 +v 0 0.000216875 0.563338125 +v 0 0.145118125 0.563338125 +v -0.512496875 0.24358375 -0.496596875 +v -0.512496875 0.30719625 -0.504741875 +v -0.512496875 0.30131875 -0.472065 +v 0 0.228671875 0.40180625 +v 0 0.261651875 0.563338125 +v 0 0.371393125 0.563338125 +v 0 0.15895125 -0.48845125 +v -0.512496875 0.17694625 -0.48845125 +v 0 0.16352 -0.439483125 +v -0.653903125 0.38591875 0.479743125 +v -0.512496875 0.388345 0.504741875 +v -0.512496875 0.373723125 0.472065 +v -0.608451875 0.373723125 0.447066875 +v 0 0.388345 0.52406875 +v 0 0.373723125 0.48173125 +v -0.678405 0.38947625 0 +v -0.639706875 0.37848375 0 +v -0.66460125 0.393031875 -0.47782 +v -0.61915125 0.383245 -0.44514375 +v -0.47753125 0.21235375 0 +v -0.535385 0.15913875 0 +v 0 0.23787375 0 +v -0.47753125 0.221988125 -0.40180625 +v 0 0.225568125 -0.40180625 +v -0.535431875 0.16541625 -0.384953125 +v 0.72021 0.26076125 0 +v 0.710051875 0.26159875 -0.510496875 +v 0.710051875 0.371245 -0.510496875 +v 0.72021 0.368893125 0 +v 0.512496875 0.261651875 0.53741875 +v 0.699353125 0.259921875 0.51242 +v 0.699353125 0.36654125 0.51242 +v 0.512496875 0.371393125 0.53741875 +v 0.564041875 0.12387625 0.405523125 +v 0.535431875 0.15737 0.384953125 +v 0.47753125 0.211653125 0.40180625 +v 0.512496875 0.160571875 0.439483125 +v 0.512496875 0.231446875 -0.45577375 +v 0.512496875 0.17517375 -0.439483125 +v 0.564041875 0.12991875 -0.405523125 +v 0.585751875 0.25197375 -0.42113125 +v 0.58132375 0.24060875 0 +v 0.562386875 0.12457 0 +v 0.585751875 0.241671875 0.42113125 +v 0.72021 0.000216875 0 +v 0.710051875 0.000216875 -0.510496875 +v 0.710051875 0.14514 -0.510496875 +v 0.72021 0.145495 0 +v 0.512496875 0.000216875 0.53741875 +v 0.699353125 0.000216875 0.51242 +v 0.699353125 0.14587125 0.51242 +v 0.512496875 0.145118125 0.53741875 +v 0.491866875 0.24784875 0.437426875 +v 0.512496875 0.000216875 -0.53741875 +v 0.512496875 0.16192375 -0.53741875 +v 0.512496875 0.235866875 -0.53741875 +v 0.512496875 0.30131875 -0.53741875 +v 0.512496875 0.24358375 -0.496596875 +v 0.512496875 0.30131875 -0.472065 +v 0.512496875 0.30719625 -0.504741875 +v 0.512496875 0.17694625 -0.48845125 +v 0.653903125 0.38591875 0.479743125 +v 0.608451875 0.373723125 0.447066875 +v 0.512496875 0.373723125 0.472065 +v 0.512496875 0.388345 0.504741875 +v 0.678405 0.38947625 0 +v 0.639706875 0.37848375 0 +v 0.61915125 0.383245 -0.44514375 +v 0.66460125 0.393031875 -0.47782 +v 0.47753125 0.21235375 0 +v 0.535385 0.15913875 0 +v 0.47753125 0.221988125 -0.40180625 +v 0.535431875 0.16541625 -0.384953125 +vt 0.597375 0.47237968750000003 +vt 0.597375 0.49941250000000004 +vt 0.4697265625 0.5 +vt 0.4697265625 0.4725890625 +vt 0.0941859375 0.6131890625 +vt 0.0941859375 0.640625 +vt 0.04705625 0.6394124999999999 +vt 0.04705625 0.6127578124999999 +vt 0.821858125 0.5746475 +vt 0.8309065625 0.59015375 +vt 0.82122390625 0.60546515625 +vt 0.816801875 0.58568078125 +vt 0.1347490625 0.820060625 +vt 0.11451625 0.8252903125 +vt 0.11746 0.794191875 +vt 0.1328271875 0.80556375 +vt 0.39464203125 0.3531896875 +vt 0.28935390625 0.35372875000000004 +vt 0.29323375 0.32377968749999997 +vt 0.39461390625 0.32380062499999995 +vt 0.597375 0.40724375 +vt 0.597375 0.44356249999999997 +vt 0.4697265625 0.4434734375 +vt 0.4697265625 0.40724375 +vt 0.0941859375 0.54783125 +vt 0.0941859375 0.5840562499999999 +vt 0.04705625 0.58424375 +vt 0.04705625 0.54783125 +vt 0.83966625 0.74532109375 +vt 0.7166990625 0.7470054687500001 +vt 0.7166990625 0.72395078125 +vt 0.8448225 0.72349609375 +vt 0.7296875 0.79786875 +vt 0.7296875 0.8382953125 +vt 0.6015625 0.833484375 +vt 0.6015625 0.79786875 +vt 0.7795328125 0.8632140625 +vt 0.7795328125 0.890625 +vt 0.7296875 0.87314375 +vt 0.7296875 0.85678125 +vt 0.7795328125 0.79786875 +vt 0.7795328125 0.8340984375 +vt 0.7296875 0.8382953125 +vt 0.7296875 0.79786875 +vt 0.86350078125 0.74395671875 +vt 0.83966625 0.74532109375 +vt 0.8448225 0.7234959375000001 +vt 0.85906265625 0.71484375 +vt 0.222475 0.54783125 +vt 0.222475 0.5840562499999999 +vt 0.0941859375 0.5840562499999999 +vt 0.0941859375 0.54783125 +vt 0.171959375 0.984096875 +vt 0.1739953125 1 +vt 0.165825 0.99853125 +vt 0.161753125 0.9810640625 +vt 0.61018 0.5291696875 +vt 0.73829625 0.52770953125 +vt 0.7385575 0.546875 +vt 0.61913 0.5448234375000001 +vt 0.222475 0.6131890625 +vt 0.222475 0.640625 +vt 0.0941859375 0.640625 +vt 0.0941859375 0.6131890625 +vt 0.1491853125 0.90157359375 +vt 0.0572321875 0.99090953125 +vt 0.0487071875 0.98211109375 +vt 0.1406603125 0.89285640625 +vt 0.25008171875 0.3413740625 +vt 0.25752609375 0.3764990625 +vt 0.2485853125 0.37687609375 +vt 0.2418659375 0.35304500000000005 +vt 0.25752609375 0.3764990625 +vt 0.2673475 0.50433796875 +vt 0.25615765625 0.5047979687499999 +vt 0.24861765625 0.37687468749999997 +vt 0.1301875 0.334444375 +vt 0.25008171875 0.3413740625 +vt 0.2418371875 0.35308578125 +vt 0.13012046875 0.34449968750000004 +vt 0.00146375 0.37993515624999996 +vt 0.01070109375 0.33673234375000005 +vt 0.0187965625 0.3484071875 +vt 0.0087015625 0.38093890625000004 +vt 0.1875 0.921875 +vt 0.24032296875 0.921875 +vt 0.2325409375 0.9375 +vt 0.1890271875 0.931398125 +vt 0.01759078125 0.8545684375 +vt 0.04199796875 0.728700625 +vt 0.05293265625 0.73868796875 +vt 0.0290696875 0.85638625 +vt 0.0890553125 0.72582484375 +vt 0.217344375 0.72582484375 +vt 0.2168475 0.7365076562499999 +vt 0.0886428125 0.7349967187499999 +vt 0.04199796875 0.728700625 +vt 0.0890553125 0.72582484375 +vt 0.088641875 0.7350176562499999 +vt 0.05292765625 0.7386834375 +vt 0.1582734375 0.91072234375 +vt 0.0662490625 1 +vt 0.057233125 0.99090875 +vt 0.1491853125 0.90157359375 +vt 0.1821640625 0.98216875 +vt 0.1821640625 0.99853125 +vt 0.1739953125 1 +vt 0.171959375 0.984096875 +vt 0.72078203125 0.60666390625 +vt 0.72057296875 0.58701328125 +vt 0.816801875 0.58568078125 +vt 0.82122609375 0.605475 +vt 0.44141265625 0.61861953125 +vt 0.327350625 0.5828053125 +vt 0.35743359375 0.48696343750000004 +vt 0.47138953125 0.52274046875 +vt 0.6445475 0.3094859375 +vt 0.772650625 0.3132015625 +vt 0.76374125 0.328125 +vt 0.64437875 0.32749375000000003 +vt 0.6104996875 0.594838125 +vt 0.61909953125 0.57774453125 +vt 0.62434546875 0.58909609375 +vt 0.62010125 0.6093265625 +vt 0.7204590625 0.5760576562499999 +vt 0.8218303125 0.57461671875 +vt 0.81680140625 0.58568078125 +vt 0.72057296875 0.58701328125 +vt 0.61909953125 0.57774453125 +vt 0.72047609375 0.57604765625 +vt 0.72057296875 0.58701328125 +vt 0.62434484375 0.58909453125 +vt 0.41131625 0.7143490625 +vt 0.2973725 0.6787099999999999 +vt 0.327350625 0.5828053125 +vt 0.4412928125 0.6185818750000001 +vt 0.62034109375 0.609375 +vt 0.6243440625 0.58909453125 +vt 0.72057296875 0.58701328125 +vt 0.720781875 0.6066575000000001 +vt 0.00014421875 0.9810228125 +vt 0.01759078125 0.8545684375 +vt 0.02909796875 0.856390625 +vt 0.01386640625 0.97492375 +vt 0.01070109375 0.33673234375000005 +vt 0.1301875 0.334444375 +vt 0.13012046875 0.34450046874999996 +vt 0.0187809375 0.34838453125 +vt 0.7255859375 0.40724375 +vt 0.7255859375 0.44365625 +vt 0.597375 0.44356249999999997 +vt 0.597375 0.40724375 +vt 0.49992953125 0.356069375 +vt 0.39464203125 0.3531896875 +vt 0.39461390625 0.3237959375 +vt 0.49599515625 0.3250834375 +vt 0.7255859375 0.47217031249999997 +vt 0.7255859375 0.49882499999999996 +vt 0.597375 0.49941250000000004 +vt 0.597375 0.47237968750000003 +vt 0.7255859375 0.44365625 +vt 0.7255859375 0.47217031249999997 +vt 0.597375 0.47237968750000003 +vt 0.597375 0.44356249999999997 +vt 0.50622 0.3895346875 +vt 0.3948146875 0.390625 +vt 0.39475328125 0.3531928125 +vt 0.49992953125 0.356069375 +vt 0.1821640625 0.9636828125 +vt 0.1821640625 0.98216875 +vt 0.171959375 0.984096875 +vt 0.169921875 0.9674375 +vt 0.222475 0.5840562499999999 +vt 0.222475 0.6131890625 +vt 0.0941859375 0.6131890625 +vt 0.0941859375 0.5840562499999999 +vt 0.169921875 0.9674375 +vt 0.171959375 0.984096875 +vt 0.161753125 0.9810640625 +vt 0.1576796875 0.9669953125 +vt 0.86960375 0.7760175 +vt 0.84488078125 0.7778475 +vt 0.83965015625 0.74522109375 +vt 0.86326 0.74257578125 +vt 0.7795328125 0.8340984375 +vt 0.7795328125 0.8632140625 +vt 0.7296875 0.85678125 +vt 0.7296875 0.8382953125 +vt 0.84488078125 0.7778475 +vt 0.716736875 0.77871671875 +vt 0.71672640625 0.74700515625 +vt 0.83966625 0.74532109375 +vt 0.3948146875 0.390625 +vt 0.2828425 0.3865665625 +vt 0.28977828125 0.35316015624999997 +vt 0.3947546875 0.35407109375 +vt 0.13671875 0.83785390625 +vt 0.110025 0.859375 +vt 0.11454359375 0.82528328125 +vt 0.1347490625 0.820060625 +vt 0.0941859375 0.5840562499999999 +vt 0.0941859375 0.6131890625 +vt 0.04705625 0.6127578124999999 +vt 0.04705625 0.58424375 +vt 0.597375 0.44356249999999997 +vt 0.597375 0.47237968750000003 +vt 0.4697265625 0.4725890625 +vt 0.4697265625 0.4434734375 +vt 0.5666875 0.9723796875 +vt 0.6943359375 0.9725890625 +vt 0.6943359375 1 +vt 0.5666875 0.9994125 +vt 0.1284703125 0.5038140625 +vt 0.1756 0.5033828124999999 +vt 0.1756 0.5300374999999999 +vt 0.1284703125 0.53125 +vt 0.54130390625 0.357351875 +vt 0.5464253125 0.36837375000000006 +vt 0.5419978125 0.38817015624999995 +vt 0.53237140625 0.37287453125000003 +vt 0.0639465625 0.867690625 +vt 0.0654746875 0.853125 +vt 0.080604375 0.8414109375000001 +vt 0.084173125 0.872346875 +vt 0.70676671875 0.6656935937499999 +vt 0.70674484375 0.63630140625 +vt 0.80812765625 0.63612484375 +vt 0.81205265625 0.66606078125 +vt 0.5666875 0.90724375 +vt 0.6943359375 0.90724375 +vt 0.6943359375 0.9434734375 +vt 0.5666875 0.9435625 +vt 0.1284703125 0.43845625 +vt 0.1756 0.43845625 +vt 0.1756 0.47486874999999995 +vt 0.1284703125 0.47468125000000005 +vt 0.59375734375 0.7455028125000001 +vt 0.58858796875 0.723679375 +vt 0.71671328125 0.72405734375 +vt 0.71672546875 0.7471120312499999 +vt 0.4734375 0.79786875 +vt 0.6015625 0.79786875 +vt 0.6015625 0.833484375 +vt 0.4734375 0.8382953125 +vt 0.4235921875 0.8632140625 +vt 0.4734375 0.85678125 +vt 0.4734375 0.87314375 +vt 0.4235921875 0.890625 +vt 0.4235921875 0.79786875 +vt 0.4734375 0.79786875 +vt 0.4734375 0.8382953125 +vt 0.4235921875 0.8340984375 +vt 0.2038465625 1 +vt 0.2038465625 0.9698015625 +vt 0.22005 0.977528125 +vt 0.2265625 0.997813125 +vt 0.00018125 0.43845625 +vt 0.1284703125 0.43845625 +vt 0.1284703125 0.47468125000000005 +vt 0.00018125 0.47468125000000005 +vt 0.167884375 0.796596875 +vt 0.178090625 0.7935640625 +vt 0.17401875 0.81103125 +vt 0.1658484375 0.8125 +vt 0.8663825 0.525534375 +vt 0.857898125 0.5415421874999999 +vt 0.7385575 0.546875 +vt 0.738298125 0.5278484375 +vt 0.00018125 0.5038140625 +vt 0.1284703125 0.5038140625 +vt 0.1284703125 0.53125 +vt 0.00018125 0.53125 +vt 0.1491853125 0.90157359375 +vt 0.14058890625 0.8927834375 +vt 0.232021875 0.80298296875 +vt 0.240543125 0.8116434375 +vt 0.2635859375 0.66816453125 +vt 0.25433234375 0.6572334375 +vt 0.259125 0.63291046875 +vt 0.2679625 0.6325528125 +vt 0.2680675 0.6325485937499999 +vt 0.259125 0.63291046875 +vt 0.25615765625 0.5047979687499999 +vt 0.2673015625 0.50433984375 +vt 0.144779375 0.6857178125000001 +vt 0.14382 0.675705625 +vt 0.25433234375 0.6572334375 +vt 0.2635853125 0.66816390625 +vt 0.01353484375 0.6520459375000001 +vt 0.021645625 0.6502825 +vt 0.0318325 0.683021875 +vt 0.0255653125 0.6940775 +vt 0.0052421875 0.6523828125 +vt 0.01353484375 0.6520459375000001 +vt 0.02569203125 0.69452 +vt 0.0170284375 0.703125 +vt 0.40432953125 0.872475625 +vt 0.39270515625 0.87322625 +vt 0.37988609375 0.75381578125 +vt 0.391670625 0.74489234375 +vt 0.34508140625 0.73768625 +vt 0.34464203125 0.746879375 +vt 0.21684890625 0.7364790625 +vt 0.217344375 0.72582484375 +vt 0.391670625 0.74489234375 +vt 0.37986390625 0.75383265625 +vt 0.34464265625 0.74686640625 +vt 0.34508140625 0.73768625 +vt 0.1582734375 0.91072234375 +vt 0.149181875 0.90157015625 +vt 0.23913953125 0.8102253125 +vt 0.2481678125 0.81930203125 +vt 0.1576796875 0.79466875 +vt 0.167884375 0.796596875 +vt 0.1658484375 0.8125 +vt 0.1576796875 0.81103125 +vt 0.64261203125 0.38858453125000003 +vt 0.5421609375 0.38820437500000005 +vt 0.5464259375 0.36837375000000006 +vt 0.6426628125 0.3689346875 +vt 0.44141265625 0.61861953125 +vt 0.47139609375 0.522719375 +vt 0.58544 0.5582728125 +vt 0.55554390625 0.65414890625 +vt 0.6445475 0.3094859375 +vt 0.6443775 0.32763359375 +vt 0.525005 0.32574562500000004 +vt 0.516411875 0.31077062499999997 +vt 0.75278484375 0.37603203124999995 +vt 0.74327953125 0.39058328124999997 +vt 0.73890265625 0.37038078124999996 +vt 0.7440721875 0.35899546875 +vt 0.64268625 0.35796906250000005 +vt 0.6426628125 0.3689346875 +vt 0.5464253125 0.36837375000000006 +vt 0.541308125 0.35736124999999996 +vt 0.7440721875 0.35899546875 +vt 0.73890265625 0.370380625 +vt 0.6426628125 0.368935 +vt 0.64268625 0.35796906250000005 +vt 0.41131625 0.7143490625 +vt 0.4413378125 0.61843828125 +vt 0.5553728125 0.654338125 +vt 0.52512328125 0.75 +vt 0.7430434375 0.390625 +vt 0.6425840625 0.388586875 +vt 0.64266359375 0.368935 +vt 0.73890265625 0.370380625 +vt 0.41001203125 1 +vt 0.3969115625 0.99265921875 +vt 0.392704375 0.87322625 +vt 0.40432953125 0.872475625 +vt 0.0255653125 0.6940775 +vt 0.0325715625 0.681718125 +vt 0.14382 0.675705625 +vt 0.14477625 0.6856853125 +vt 0.4384765625 0.90724375 +vt 0.5666875 0.90724375 +vt 0.5666875 0.9435625 +vt 0.4384765625 0.94365625 +vt 0.60148546875 0.6687634375 +vt 0.60536515625 0.63776859375 +vt 0.70674484375 0.6363029687499999 +vt 0.70676671875 0.6656935937499999 +vt 0.4384765625 0.9721703125 +vt 0.5666875 0.9723796875 +vt 0.5666875 0.9994125 +vt 0.4384765625 0.998825 +vt 0.4384765625 0.94365625 +vt 0.5666875 0.9435625 +vt 0.5666875 0.9723796875 +vt 0.4384765625 0.9721703125 +vt 0.59549578125 0.7026290625 +vt 0.60148546875 0.6687634375 +vt 0.7067675 0.6656935937499999 +vt 0.706763125 0.7026935937500001 +vt 0.1576796875 0.7761828125 +vt 0.169921875 0.7799375 +vt 0.167884375 0.796596875 +vt 0.1576796875 0.79466875 +vt 0.00018125 0.47468125000000005 +vt 0.1284703125 0.47468125000000005 +vt 0.1284703125 0.5038140625 +vt 0.00018125 0.5038140625 +vt 0.169921875 0.7799375 +vt 0.1821640625 0.7794953125 +vt 0.178090625 0.7935640625 +vt 0.167884375 0.796596875 +vt 0.56399 0.7765746875 +vt 0.57001453125 0.7429915625 +vt 0.59374421875 0.74558671875 +vt 0.58861734375 0.7781445312499999 +vt 0.4235921875 0.8340984375 +vt 0.4734375 0.8382953125 +vt 0.4734375 0.85678125 +vt 0.4235921875 0.8632140625 +vt 0.58861734375 0.7781445312499999 +vt 0.59375734375 0.7455028125000001 +vt 0.71672640625 0.7471120312499999 +vt 0.716736875 0.77871671875 +vt 0.7068759375 0.703125 +vt 0.70676671875 0.6656935937499999 +vt 0.81205390625 0.66606078125 +vt 0.81856671875 0.69865484375 +vt 0.0625 0.8855678124999999 +vt 0.0639465625 0.867690625 +vt 0.0843109375 0.8723785937499999 +vt 0.08974015625 0.90625 +vt 0.1284703125 0.47468125000000005 +vt 0.1756 0.47486874999999995 +vt 0.1756 0.5033828124999999 +vt 0.1284703125 0.5038140625 +vt 0.5666875 0.9435625 +vt 0.6943359375 0.9434734375 +vt 0.6943359375 0.9725890625 +vt 0.5666875 0.9723796875 +vn -0.9998020835064746 0 -0.019894567502532167 +vn -0.13260453781534245 0 0.9911690252175861 +vn -0.6749125420143068 0.6775520550603202 0.2922606256670973 +vn 0.4631468326686442 0.19299617983587655 0.8650129975657945 +vn 0.983621757372947 0.17997349872924243 0.009888284872069122 +vn -0.9998020835064745 0 -0.019894567502532164 +vn -0.13260453781534243 0 0.991169025217586 +vn 0.0018655859675867088 -0.023436475321481482 -0.9997235874047902 +vn 0 0 -1 +vn -0.1350273146460284 0 -0.99084187653706 +vn -0.1350273146460284 0 -0.99084187653706 +vn 0.1747728931585646 -0.06445949459761244 -0.9824965187588226 +vn -0.05051014245554314 0 0.998723548089821 +vn 1 0 0 +vn -0.003243590732757029 0.5026824182006996 0.8644650748012064 +vn -0.05051014245554313 0 0.9987235480898209 +vn 0.03506784692299363 0.9987308675194547 0.03615107710736817 +vn 0.05645542785621704 0.9113283870718722 -0.4077908233182194 +vn 0.012309676227576128 0.945144816220311 -0.3264195892452642 +vn 0.25521618824166603 0.9668662265959538 -0.0058649064354150396 +vn 0.4191009221854565 0.8460237973668786 -0.3295423361454892 +vn 0.15646042035942195 0.707403987342159 -0.6892747895820415 +vn -0.3800867474743398 0.9247407139789969 0.019714875195968592 +vn -0.02004026963000022 0.9179235345366584 0.3962508956922182 +vn -0.08435493154531952 0.8844984316024609 0.4588537566771914 +vn 0.03587691636246509 0.9554069898882676 -0.29310464094749605 +vn 1 0 0 +vn -0.6769808426327552 0.7359932429665458 0.003299244736612172 +vn -0.053365296454318395 0.9985735392810049 0.0017412013525393466 +vn 0.014109648613348822 0.620499909616163 -0.7840795750319871 +vn -0.7129671317728575 0.6780881191824705 -0.17853395093136928 +vn -0.7609686622461402 0.6487855671986212 -0.0019957139282090757 +vn -0.7880286116294645 0.6155342633922924 0.011334806722961471 +vn -0.007494526920883468 0.9996845834324397 0.023970100411596168 +vn -0.697047744500393 0.7169280081232087 0.011775952420224913 +vn -0.4417172716637775 0.8971422241231191 -0.00465635150432616 +vn 0.27321657305615865 0.9618348052034432 0.015050305867429985 +vn -0.9991726731459544 0 0.04066902062218692 +vn 0.9869260577841771 0.16106081279315584 -0.006030841482866327 +vn -0.9991726731459544 0 0.04066902062218693 +vn -0.9991726731459544 0 0.04066902062218693 +vn 0.9198418409826481 0.3895067356831917 0.046642152985008796 +vn 1 0 0 +vn -0.05051014245554314 0 0.9987235480898209 +vn 1 0 0 +vn 0.24096174350723362 0.2940140744178092 -0.9249287335844817 +vn -0.1350273146460284 0 -0.99084187653706 +vn 0.0177121849416344 0.343225702271267 -0.939085936325845 +vn 0.9810845919410712 0.1817424790044743 -0.06665354290005839 +vn 0.42301898485845063 0.26607209836384715 0.866175834875105 +vn -0.13260453781534243 0 0.991169025217586 +vn -0.9998020835064746 0 -0.019894567502532167 +vn 0.9998020835064745 0 -0.019894567502532167 +vn 0.13260453781534243 0 0.991169025217586 +vn 0.6995528351586359 0.6962676806065237 0.16073937838727173 +vn -0.36230595279267386 0.2591818058985846 0.8952983793474338 +vn -0.9869348674147096 0.16106225047558684 0.004303365293505413 +vn 0.9998020835064746 0 -0.019894567502532164 +vn 0.13260453781534245 0 0.9911690252175861 +vn -0.0017724294069064058 -0.02397222715366932 -0.9997110536646528 +vn 0 0 -1 +vn 0.13502731464602843 0 -0.9908418765370601 +vn 0.13502731464602843 0 -0.9908418765370601 +vn -0.4377793616291937 0.19692115682650652 -0.8772521236940076 +vn 0.05051014245554314 0 0.998723548089821 +vn -1 0 0 +vn 0.02148802214503871 0.6029351200850838 0.7975007873803511 +vn 0.05051014245554313 0 0.9987235480898209 +vn -0.022635031862158234 0.9954206238233644 -0.09287323080254765 +vn -0.06867132701227172 0.9621862719958053 -0.26359405689276794 +vn -0.007703437507644744 0.9127570837634678 -0.4084301202043942 +vn -0.27323679803202644 0.9619060054439943 -0.008859395689569182 +vn -0.6250278616375852 0.7682736798572856 0.13818728239347186 +vn -0.4641913203341563 0.8717457441875368 -0.15679851918098778 +vn 0.4415337008643223 0.8967693858261538 0.029200336402823088 +vn 0.017363206184231293 0.887528446566024 0.4604256461222638 +vn 0.08871242224727682 0.9060018592118494 0.413872851542226 +vn -0.03330226694422399 0.9484472208574927 -0.3151806279961927 +vn -1 0 0 +vn 0.6841462029790126 0.7293437842370334 0.0012717484828348963 +vn 0.035607092781431884 0.9991039015663986 0.02288075213918724 +vn 0.0038910554711782323 0.5190225092097992 -0.8547517151903729 +vn 0.7137271275511211 0.6771599047878866 -0.17901969373587373 +vn 0.7880762966784077 0.6155715104935309 0.002732413284808699 +vn 0.7814533619883405 0.6238791497011569 0.010268865822539327 +vn 0.053340439415695905 0.9981084133905388 -0.030567836723617254 +vn 0.6768791337352793 0.7358826681074394 0.01764474689118851 +vn 0.4289321488315532 0.9033189183620652 -0.005669517434038924 +vn -0.19699346053619524 0.9802092441143786 0.01958096674562745 +vn 0.9991726731459544 0 0.040669020622186934 +vn -0.9845830141430282 0.17490957665019474 -0.0017112150886563094 +vn 0.9991726731459544 0 0.04066902062218692 +vn 0.9991726731459544 0 0.04066902062218693 +vn -0.969272899369612 0.24596222397869505 -0.0035540009961341095 +vn -1 0 0 +vn 0.05051014245554314 0 0.9987235480898209 +vn -1 0 0 +vn -0.15319001465514584 0.2157395697075587 -0.9643595063420871 +vn 0.13502731464602843 0 -0.99084187653706 +vn 0.002138228171427599 0.26499153493569516 -0.9642483675862311 +vn -0.9208091974065634 0.3899163624539405 0.008697830888665005 +vn -0.4642625315454664 0.20111404534235544 0.8625621383813622 +vn 0.13260453781534243 0 0.9911690252175861 +vn 0.9998020835064746 0 -0.019894567502532167 +usemtl m_bc212402-1add-520a-3365-024917670a00 +f 1/1/1 2/2/1 3/3/1 4/4/1 +f 5/5/2 6/6/2 7/7/2 8/8/2 +f 9/9/3 10/10/3 11/11/3 12/12/3 +f 13/13/4 14/14/4 15/15/4 16/16/4 +f 17/17/5 18/18/5 9/19/5 19/20/5 +f 20/21/6 21/22/6 22/23/6 23/24/6 +f 24/25/7 25/26/7 26/27/7 27/28/7 +f 28/29/8 29/30/8 30/31/8 10/32/8 +f 31/33/9 32/34/9 33/35/9 34/36/9 +f 4/37/10 3/38/10 35/39/10 36/40/10 +f 23/41/11 22/42/11 32/43/11 31/44/11 +f 18/45/12 28/46/12 10/47/12 9/48/12 +f 37/49/13 38/50/13 25/51/13 24/52/13 +f 39/53/14 40/54/14 41/55/14 13/56/14 +f 10/57/15 30/58/15 42/59/15 11/60/15 +f 43/61/16 44/62/16 6/63/16 5/64/16 +f 45/65/17 46/66/17 16/67/17 47/68/17 +f 48/69/18 49/70/18 50/71/18 51/72/18 +f 49/73/19 52/74/19 53/75/19 50/76/19 +f 54/77/20 48/78/20 51/79/20 55/80/20 +f 40/81/21 56/82/21 57/83/21 41/84/21 +f 35/85/22 3/86/22 56/87/22 40/88/22 +f 2/89/23 7/90/23 48/91/23 54/92/23 +f 6/93/24 44/94/24 52/95/24 49/96/24 +f 7/97/25 6/98/25 49/99/25 48/100/25 +f 33/101/26 32/102/26 46/103/26 45/104/26 +f 36/105/27 35/106/27 40/107/27 39/108/27 +f 58/109/28 59/110/28 12/111/28 11/112/28 +f 60/113/29 58/114/29 11/115/29 42/116/29 +f 47/117/30 16/118/30 61/119/30 62/120/30 +f 16/121/31 15/122/31 63/123/31 61/124/31 +f 19/125/32 9/126/32 12/127/32 59/128/32 +f 15/129/33 19/130/33 59/131/33 63/132/33 +f 62/133/34 61/134/34 58/135/34 60/136/34 +f 61/137/35 63/138/35 59/139/35 58/140/35 +f 3/141/36 2/142/36 54/143/36 56/144/36 +f 56/145/37 54/146/37 55/147/37 57/148/37 +f 27/149/38 26/150/38 21/151/38 20/152/38 +f 14/153/39 17/154/39 19/155/39 15/156/39 +f 8/157/40 7/158/40 2/159/40 1/160/40 +f 26/161/41 8/162/41 1/163/41 21/164/41 +f 57/165/42 55/166/42 17/167/42 14/168/42 +f 32/169/43 36/170/43 39/171/43 46/172/43 +f 38/173/44 43/174/44 5/175/44 25/176/44 +f 46/177/45 39/178/45 13/179/45 16/180/45 +f 51/181/46 50/182/46 28/183/46 18/184/46 +f 22/185/47 4/186/47 36/187/47 32/188/47 +f 50/189/48 53/190/48 29/191/48 28/192/48 +f 55/193/49 51/194/49 18/195/49 17/196/49 +f 41/197/50 57/198/50 14/199/50 13/200/50 +f 25/201/51 5/202/51 8/203/51 26/204/51 +f 21/205/52 1/206/52 4/207/52 22/208/52 +f 64/209/53 65/210/53 66/211/53 67/212/53 +f 68/213/54 69/214/54 70/215/54 71/216/54 +f 72/217/55 73/218/55 74/219/55 75/220/55 +f 76/221/56 77/222/56 78/223/56 79/224/56 +f 80/225/57 81/226/57 72/227/57 82/228/57 +f 83/229/58 84/230/58 85/231/58 86/232/58 +f 87/233/59 88/234/59 89/235/59 90/236/59 +f 91/237/60 75/238/60 30/239/60 29/240/60 +f 92/241/61 34/242/61 33/243/61 93/244/61 +f 65/245/62 94/246/62 95/247/62 66/248/62 +f 84/249/63 92/250/63 93/251/63 85/252/63 +f 82/253/64 72/254/64 75/255/64 91/256/64 +f 37/257/65 87/258/65 90/259/65 38/260/65 +f 96/261/66 76/262/66 97/263/66 98/264/66 +f 75/265/67 74/266/67 42/267/67 30/268/67 +f 43/269/68 68/270/68 71/271/68 44/272/68 +f 45/273/69 47/274/69 77/275/69 99/276/69 +f 100/277/70 101/278/70 102/279/70 103/280/70 +f 103/281/71 102/282/71 53/283/71 52/284/71 +f 104/285/72 105/286/72 101/287/72 100/288/72 +f 98/289/73 97/290/73 106/291/73 107/292/73 +f 95/293/74 98/294/74 107/295/74 66/296/74 +f 67/297/75 104/298/75 100/299/75 70/300/75 +f 71/301/76 103/302/76 52/303/76 44/304/76 +f 70/305/77 100/306/77 103/307/77 71/308/77 +f 33/309/78 45/310/78 99/311/78 93/312/78 +f 94/313/79 96/314/79 98/315/79 95/316/79 +f 108/317/80 74/318/80 73/319/80 109/320/80 +f 60/321/81 42/322/81 74/323/81 108/324/81 +f 47/325/82 62/326/82 110/327/82 77/328/82 +f 77/329/83 110/330/83 111/331/83 78/332/83 +f 81/333/84 109/334/84 73/335/84 72/336/84 +f 78/337/85 111/338/85 109/339/85 81/340/85 +f 62/341/86 60/342/86 108/343/86 110/344/86 +f 110/345/87 108/346/87 109/347/87 111/348/87 +f 66/349/88 107/350/88 104/351/88 67/352/88 +f 107/353/89 106/354/89 105/355/89 104/356/89 +f 88/357/90 83/358/90 86/359/90 89/360/90 +f 79/361/91 78/362/91 81/363/91 80/364/91 +f 69/365/92 64/366/92 67/367/92 70/368/92 +f 89/369/93 86/370/93 64/371/93 69/372/93 +f 106/373/94 79/374/94 80/375/94 105/376/94 +f 93/377/95 99/378/95 96/379/95 94/380/95 +f 38/381/96 90/382/96 68/383/96 43/384/96 +f 99/385/97 77/386/97 76/387/97 96/388/97 +f 101/389/98 82/390/98 91/391/98 102/392/98 +f 85/393/99 93/394/99 94/395/99 65/396/99 +f 102/397/100 91/398/100 29/399/100 53/400/100 +f 105/401/101 80/402/101 82/403/101 101/404/101 +f 97/405/102 76/406/102 79/407/102 106/408/102 +f 90/409/103 89/410/103 69/411/103 68/412/103 +f 86/413/104 85/414/104 65/415/104 64/416/104 \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/obj/blocks/bed/pet_bed.mtl b/src/main/resources/assets/tiedup/models/obj/blocks/bed/pet_bed.mtl new file mode 100644 index 0000000..98eddb4 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/obj/blocks/bed/pet_bed.mtl @@ -0,0 +1,4 @@ +# Made in Blockbench 5.0.5 +newmtl m_bc212402-1add-520a-3365-024917670a00 +map_Kd pet_bed.png +newmtl none diff --git a/src/main/resources/assets/tiedup/models/obj/blocks/bed/pet_bed.png b/src/main/resources/assets/tiedup/models/obj/blocks/bed/pet_bed.png new file mode 100644 index 0000000..ae6886c Binary files /dev/null and b/src/main/resources/assets/tiedup/models/obj/blocks/bed/pet_bed.png differ diff --git a/src/main/resources/assets/tiedup/models/obj/blocks/bowl/model.obj b/src/main/resources/assets/tiedup/models/obj/blocks/bowl/model.obj new file mode 100644 index 0000000..8c68022 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/obj/blocks/bowl/model.obj @@ -0,0 +1,995 @@ +# Made in Blockbench 5.0.7 +mtllib petbowl.mtl + +o BowlFood +v 0.180447 0.038883 -0.179022 +v 0.253418 0.038883 -0.002853 +v 0.259095 0.038883 -0.002853 +v 0.184461 0.038883 -0.183036 +v -0.202591 0.134061 -0.002853 +v -0.142001 0.134061 0.143425 +v -0.123283 0.134061 0.124708 +v -0.176121 0.134061 -0.002853 +v 0.004277 0.134061 -0.183251 +v -0.123283 0.134061 -0.130414 +v -0.103442 0.036904 -0.110573 +v 0.004277 0.036904 -0.155192 +v -0.171892 0.038883 -0.179022 +v 0.004277 0.038883 -0.251994 +v 0.004277 0.038883 -0.257671 +v -0.175906 0.038883 -0.183037 +v 0.004277 0.134061 0.204016 +v 0.004277 0.134061 0.177545 +v 0.184676 0.134061 -0.002853 +v 0.131838 0.134061 -0.130414 +v 0.111997 0.036904 -0.110573 +v 0.156616 0.036904 -0.002853 +v 0.004277 0.038883 0.246288 +v -0.171892 0.038883 0.173316 +v -0.175906 0.038883 0.17733 +v 0.004277 0.038883 0.251965 +v 0.150556 0.134061 0.143425 +v 0.131838 0.134061 0.124707 +v 0.111997 0.036904 0.104866 +v 0.004277 0.036904 0.149485 +v 0.180447 0.038883 0.173316 +v 0.184461 0.038883 0.17733 +v 0.211146 0.134061 -0.002853 +v -0.148061 0.036904 -0.002853 +v 0.150556 0.134061 -0.149131 +v -0.244863 0.038883 -0.002853 +v -0.25054 0.038883 -0.002853 +v 0.004277 0.134061 -0.209722 +v -0.142001 0.134061 -0.149132 +v -0.103442 0.036904 0.104866 +v 0.184461 0.023444 0.17733 +v 0.259095 0.023444 -0.002853 +v 0.184461 0.023444 -0.183036 +v 0.004277 0.023444 -0.257671 +v 0.004277 0.023444 0.251965 +v -0.175906 0.023444 -0.183037 +v -0.25054 0.023444 -0.002853 +v -0.175906 0.023444 0.17733 +v 0.059057 0.036153 -0.051429 +v 0.059057 0.10845 -0.051429 +v 0.059057 0.10845 0.020868 +v 0.059057 0.036153 0.020868 +v -0.01324 0.10845 0.020868 +v -0.01324 0.036153 0.020868 +v -0.01324 0.10845 -0.051429 +v -0.01324 0.036153 -0.051429 +v -0.061293 0.06473 -0.126482 +v -0.0574 0.113402 -0.122295 +v -0.048981 0.108596 -0.074257 +v -0.052875 0.059924 -0.078444 +v -0.097102 0.111693 -0.065515 +v -0.100995 0.063021 -0.069701 +v -0.10552 0.116499 -0.113553 +v -0.109414 0.067827 -0.117739 +v -0.002115 0.092472 0.004282 +v -0.013549 0.136859 0.004156 +v 0.013174 0.143846 0.040734 +v 0.024608 0.09946 0.04086 +v -0.022267 0.134795 0.068355 +v -0.010833 0.090409 0.068481 +v -0.04899 0.127808 0.031777 +v -0.037556 0.083422 0.031903 +v -0.034535 0.043203 0.012473 +v -0.025777 0.109742 0.014451 +v -0.036383 0.109168 0.080747 +v -0.045141 0.042629 0.07877 +v -0.102101 0.118128 0.070311 +v -0.110859 0.051589 0.068333 +v -0.091495 0.118703 0.004014 +v -0.100253 0.052164 0.002037 +v 0.154095 0.039882 -0.012225 +v 0.155873 0.092552 -0.017029 +v 0.143407 0.09764 0.034147 +v 0.141629 0.044971 0.038951 +v 0.092009 0.098228 0.021569 +v 0.090231 0.045558 0.026373 +v 0.104475 0.093139 -0.029607 +v 0.102697 0.04047 -0.024803 +v -0.003314 0.037875 -0.145565 +v -0.010041 0.077934 -0.147518 +v 0.003703 0.082097 -0.109471 +v 0.010429 0.042037 -0.107518 +v -0.033976 0.076464 -0.095245 +v -0.02725 0.036405 -0.093291 +v -0.047719 0.072301 -0.133292 +v -0.040993 0.032242 -0.131339 +v 0.09194 0.048917 0.07144 +v 0.095961 0.108784 0.058752 +v 0.111107 0.12013 0.117088 +v 0.107086 0.060263 0.129776 +v 0.051814 0.127088 0.131129 +v 0.047794 0.067221 0.143818 +v 0.036668 0.115742 0.072794 +v 0.032647 0.055875 0.085482 +v 0.086097 0.040103 -0.105447 +v 0.085007 0.112206 -0.103977 +v 0.069659 0.110537 -0.033522 +v 0.070749 0.038434 -0.034993 +v -0.000806 0.109785 -0.04889 +v 0.000284 0.037682 -0.050361 +v 0.014541 0.111454 -0.119344 +v 0.015631 0.039351 -0.120815 +v 0.001692 0.023242 -0.051056 +v 0.009054 0.090455 -0.063983 +v 0.005677 0.103797 0.003468 +v -0.001685 0.036583 0.016394 +v -0.062685 0.110376 -0.001256 +v -0.070047 0.043163 0.01167 +v -0.059308 0.097035 -0.068707 +v -0.06667 0.029821 -0.055781 +v -0.065169 0.085243 -0.04212 +v -0.048492 0.12017 -0.051055 +v -0.066929 0.136893 -0.0201 +v -0.083607 0.101966 -0.011165 +v -0.097909 0.145742 -0.043333 +v -0.114587 0.110816 -0.034398 +v -0.079471 0.129019 -0.074288 +v -0.096149 0.094093 -0.065354 +v 0.132817 0.059188 0.029302 +v 0.126396 0.106242 0.031444 +v 0.131246 0.104753 0.078711 +v 0.137666 0.057698 0.076569 +v 0.084393 0.098151 0.08331 +v 0.090814 0.051096 0.081168 +v 0.079544 0.09964 0.036043 +v 0.085964 0.052586 0.033901 +v 0.060756 0.079275 0.015588 +v 0.060187 0.126515 0.022855 +v 0.064382 0.119325 0.069925 +v 0.064951 0.072084 0.062657 +v 0.01677 0.118127 0.073985 +v 0.017339 0.070886 0.066718 +v 0.012575 0.125318 0.026916 +v 0.013144 0.078077 0.019648 +v -0.016408 0.048917 0.058075 +v -0.013119 0.093097 0.061066 +v 0.017605 0.08866 0.092815 +v 0.014315 0.04448 0.089824 +v -0.014285 0.088942 0.123713 +v -0.017574 0.044762 0.120722 +v -0.045008 0.09338 0.091963 +v -0.048297 0.0492 0.088972 +v 0.10355 0.0522 -0.089037 +v 0.140359 0.093936 -0.082833 +v 0.137104 0.088536 -0.027196 +v 0.100295 0.046801 -0.0334 +v 0.095035 0.125472 -0.026073 +v 0.058227 0.083736 -0.032277 +v 0.09829 0.130871 -0.08171 +v 0.061481 0.089135 -0.087914 +v 0.008287 0.060816 -0.106678 +v 0.008654 0.092628 -0.105259 +v 0.010389 0.091192 -0.073493 +v 0.010021 0.059379 -0.074912 +v -0.021408 0.091481 -0.071744 +v -0.021775 0.059668 -0.073162 +v -0.023143 0.092917 -0.10351 +v -0.02351 0.061105 -0.104928 +v -0.071455 0.049599 -0.057477 +v -0.066303 0.094373 -0.061293 +v -0.058065 0.097208 -0.01691 +v -0.063217 0.052434 -0.013093 +v -0.102239 0.102959 -0.009078 +v -0.107391 0.058185 -0.005261 +v -0.110478 0.100124 -0.053461 +v -0.11563 0.05535 -0.049645 +v 0.101847 0.093687 -0.030006 +v 0.109236 0.13636 -0.021141 +v 0.105475 0.128027 0.022109 +v 0.098086 0.085354 0.013245 +v 0.062053 0.13601 0.019872 +v 0.054665 0.093337 0.011007 +v 0.065814 0.144343 -0.023379 +v 0.058425 0.10167 -0.032243 +v -0.021459 0.092639 -0.113258 +v -0.001648 0.12803 -0.091353 +v -0.018781 0.112855 -0.05134 +v -0.038591 0.077464 -0.073245 +v -0.056713 0.138193 -0.057973 +v -0.076523 0.102801 -0.079877 +v -0.03958 0.153368 -0.097985 +v -0.059391 0.117976 -0.11989 +vt 0.21699218750000002 0.960678125 +vt 0.25347656250000006 0.872590625 +vt 0.2563140625 0.872590625 +vt 0.21899843750000003 0.962684375 +vt 0.220784375 0.6465624999999999 +vt 0.25108125000000003 0.5734250000000001 +vt 0.260440625 0.582784375 +vt 0.234021875 0.6465624999999999 +vt 0.1953125 0.5 +vt 0.126275 0.5 +vt 0.13164375 0.4375 +vt 0.18994375 0.4375 +vt 0.04082031250000001 0.960678125 +vt 0.1289046875 0.9971625 +vt 0.1289046875 1 +vt 0.038814062500000024 0.962684375 +vt 0.25108125000000003 0.5734250000000001 +vt 0.32421875 0.543128125 +vt 0.32421875 0.556365625 +vt 0.260440625 0.582784375 +vt 0.444034375 0.46875 +vt 0.375 0.46875 +vt 0.38036875 0.40625 +vt 0.438665625 0.40625 +vt 0.1289046875 0.7480218750000001 +vt 0.04082031250000001 0.78450625 +vt 0.038814062500000024 0.7825 +vt 0.1289046875 0.745184375 +vt 0.32421875 0.543128125 +vt 0.39735937499999996 0.5734250000000001 +vt 0.388 0.582784375 +vt 0.32421875 0.556365625 +vt 0.3203125 0.5 +vt 0.251275 0.5 +vt 0.25664375 0.4375 +vt 0.31494374999999997 0.4375 +vt 0.25347656250000006 0.872590625 +vt 0.21699218750000002 0.78450625 +vt 0.21899843750000003 0.7825 +vt 0.2563140625 0.872590625 +vt 0.39735937499999996 0.5734250000000001 +vt 0.42765312499999997 0.6465624999999999 +vt 0.41441875 0.6465624999999999 +vt 0.388 0.582784375 +vt 0.5703125 0.46875 +vt 0.501278125 0.46875 +vt 0.5066468749999999 0.40625 +vt 0.56494375 0.40625 +vt 0.1289046875 0.9971625 +vt 0.21699218750000002 0.960678125 +vt 0.21899843750000003 0.962684375 +vt 0.1289046875 1 +vt 0.42765312499999997 0.6465624999999999 +vt 0.39735937499999996 0.719703125 +vt 0.388 0.71034375 +vt 0.41441875 0.6465624999999999 +vt 0.662784375 1 +vt 0.59375 1 +vt 0.59911875 0.9375 +vt 0.6574156250000001 0.9375 +vt 0.004335937500000012 0.872590625 +vt 0.04082031250000001 0.960678125 +vt 0.038814062500000024 0.962684375 +vt 0.001498437500000005 0.872590625 +vt 0.39735937499999996 0.719703125 +vt 0.32421875 0.75 +vt 0.32421875 0.7367625 +vt 0.388 0.71034375 +vt 0.0703125 0.40625 +vt 0.001278124999999991 0.40625 +vt 0.00664687499999999 0.34375 +vt 0.06494375 0.34375 +vt 0.04082031250000001 0.78450625 +vt 0.004335937500000012 0.872590625 +vt 0.001498437500000005 0.872590625 +vt 0.038814062500000024 0.7825 +vt 0.32421875 0.75 +vt 0.25108125000000003 0.719703125 +vt 0.260440625 0.71034375 +vt 0.32421875 0.7367625 +vt 0.0690375 0.5 +vt 0 0.5 +vt 0.005368750000000002 0.4375 +vt 0.063665625 0.4375 +vt 0.4455875 0.8125 +vt 0.4375 0.75 +vt 0.532840625 0.75 +vt 0.524753125 0.8125 +vt 0.25108125000000003 0.719703125 +vt 0.220784375 0.6465624999999999 +vt 0.234021875 0.6465624999999999 +vt 0.260440625 0.71034375 +vt 0.194034375 0.40625 +vt 0.125 0.40625 +vt 0.13036875 0.34375 +vt 0.188665625 0.34375 +vt 0.024264062500000017 0.665190625 +vt 0.0019546875000000102 0.611328125 +vt 0.024264062500000017 0.55746875 +vt 0.07812343750000002 0.535159375 +vt 0.13198593749999998 0.6651906249999999 +vt 0.07812343750000002 0.6875 +vt 0.13198593749999998 0.55746875 +vt 0.1542953125 0.611328125 +vt 0.07812343750000002 0.6875 +vt 0.024264062500000017 0.665190625 +vt 0.07812343750000002 0.535159375 +vt 0.13198593749999998 0.55746875 +vt 0.34375 0.40625 +vt 0.25 0.40625 +vt 0.25 0.375 +vt 0.34375 0.375 +vt 0.6875 0.625 +vt 0.59375 0.625 +vt 0.59375 0.59375 +vt 0.6875 0.59375 +vt 0.6875 0.5625 +vt 0.59375 0.5625 +vt 0.59375 0.53125 +vt 0.6875 0.53125 +vt 0.6875 0.75 +vt 0.59375 0.75 +vt 0.59375 0.71875 +vt 0.6875 0.71875 +vt 0.375 0.8125 +vt 0.28125 0.8125 +vt 0.28125 0.78125 +vt 0.375 0.78125 +vt 0.5625 0.625 +vt 0.46875 0.625 +vt 0.46875 0.59375 +vt 0.5625 0.59375 +vt 0.6875 0.8125 +vt 0.59375 0.8125 +vt 0.59375 0.78125 +vt 0.6875 0.78125 +vt 0.326809375 0.90625 +vt 0.31871875 0.84375 +vt 0.4140625 0.84375 +vt 0.405971875 0.90625 +vt 0.0705875 0.90625 +vt 0.0625 0.84375 +vt 0.15784375 0.84375 +vt 0.149753125 0.90625 +vt 0.29555625 1 +vt 0.28746875 0.9375 +vt 0.3828125 0.9375 +vt 0.374721875 1 +vt 0.451809375 1 +vt 0.443721875 0.9375 +vt 0.5390625 0.9375 +vt 0.530975 1 +vt 0.4768375 0.90625 +vt 0.46875 0.84375 +vt 0.564090625 0.84375 +vt 0.556003125 0.90625 +vt 0.451809375 0.5625 +vt 0.443721875 0.5 +vt 0.5390625 0.5 +vt 0.530975 0.5625 +vt 0.4768375 0.71875 +vt 0.46875 0.65625 +vt 0.564090625 0.65625 +vt 0.556003125 0.71875 +vt 0.21699218750000002 0.78450625 +vt 0.1289046875 0.7480218750000001 +vt 0.1289046875 0.745184375 +vt 0.21899843750000003 0.7825 +vt 0.71875 0.90625 +vt 0.625 0.90625 +vt 0.625 0.875 +vt 0.71875 0.875 +vt 0.34375 0.625 +vt 0.34375 0.65625 +vt 0.3125 0.65625 +vt 0.3125 0.625 +vt 0.65625 0.65625 +vt 0.65625 0.6875 +vt 0.625 0.6875 +vt 0.625 0.65625 +vt 0.40625 0.34375 +vt 0.40625 0.375 +vt 0.375 0.375 +vt 0.375 0.34375 +vt 0.46875 0.34375 +vt 0.46875 0.375 +vt 0.4375 0.375 +vt 0.4375 0.34375 +vt 0.5 0.34375 +vt 0.53125 0.34375 +vt 0.53125 0.375 +vt 0.5 0.375 +vt 0.75 0.53125 +vt 0.75 0.5625 +vt 0.71875 0.5625 +vt 0.71875 0.53125 +vt 0 0.15625 +vt 0.03125 0.15625 +vt 0.03125 0.1875 +vt 0 0.1875 +vt 0.71875 0.59375 +vt 0.75 0.59375 +vt 0.75 0.625 +vt 0.71875 0.625 +vt 0.8125 0.5625 +vt 0.8125 0.59375 +vt 0.78125 0.59375 +vt 0.78125 0.5625 +vt 0.625 0.3125 +vt 0.625 0.28125 +vt 0.65625 0.28125 +vt 0.65625 0.3125 +vt 0.25 0.125 +vt 0.28125 0.125 +vt 0.28125 0.15625 +vt 0.25 0.15625 +vt 0.03125 0.09375 +vt 0.03125 0.125 +vt 0 0.125 +vt 0 0.09375 +vt 0.34375 0.125 +vt 0.34375 0.15625 +vt 0.3125 0.15625 +vt 0.3125 0.125 +vt 0.0625 0.09375 +vt 0.09375 0.09375 +vt 0.09375 0.125 +vt 0.0625 0.125 +vt 0.15625 0.15625 +vt 0.15625 0.1875 +vt 0.125 0.1875 +vt 0.125 0.15625 +vt 0.78125 0.40625 +vt 0.78125 0.4375 +vt 0.75 0.4375 +vt 0.75 0.40625 +vt 0.0625 0.21875 +vt 0.09375 0.21875 +vt 0.09375 0.25 +vt 0.0625 0.25 +vt 0.5625 0.21875 +vt 0.59375 0.21875 +vt 0.59375 0.25 +vt 0.5625 0.25 +vt 0.78125 0.90625 +vt 0.78125 0.9375 +vt 0.75 0.9375 +vt 0.75 0.90625 +vt 0.6875 0.375 +vt 0.6875 0.34375 +vt 0.71875 0.34375 +vt 0.71875 0.375 +vt 0.375 0.21875 +vt 0.40625 0.21875 +vt 0.40625 0.25 +vt 0.375 0.25 +vt 0.8125 0.71875 +vt 0.84375 0.71875 +vt 0.84375 0.75 +vt 0.8125 0.75 +vt 0.46875 0.21875 +vt 0.46875 0.25 +vt 0.4375 0.25 +vt 0.4375 0.21875 +vt 0.40625 0.15625 +vt 0.40625 0.1875 +vt 0.375 0.1875 +vt 0.375 0.15625 +vt 0.15625 0.3125 +vt 0.125 0.3125 +vt 0.125 0.28125 +vt 0.15625 0.28125 +vt 0.8125 0.5 +vt 0.84375 0.5 +vt 0.84375 0.53125 +vt 0.8125 0.53125 +vt 0.59375 0.15625 +vt 0.59375 0.1875 +vt 0.5625 0.1875 +vt 0.5625 0.15625 +vt 0.46875 0.15625 +vt 0.46875 0.1875 +vt 0.4375 0.1875 +vt 0.4375 0.15625 +vt 0.8125 0.375 +vt 0.84375 0.375 +vt 0.84375 0.40625 +vt 0.8125 0.40625 +vt 0.65625 0.21875 +vt 0.65625 0.25 +vt 0.625 0.25 +vt 0.625 0.21875 +vt 0.8125 0.90625 +vt 0.84375 0.90625 +vt 0.84375 0.9375 +vt 0.8125 0.9375 +vt 0.5 0.15625 +vt 0.53125 0.15625 +vt 0.53125 0.1875 +vt 0.5 0.1875 +vt 0.09375 0.15625 +vt 0.09375 0.1875 +vt 0.0625 0.1875 +vt 0.0625 0.15625 +vt 0.84375 0.4375 +vt 0.84375 0.46875 +vt 0.8125 0.46875 +vt 0.8125 0.4375 +vt 0.78125 0.375 +vt 0.75 0.375 +vt 0.75 0.34375 +vt 0.78125 0.34375 +vt 0.34375 0.25 +vt 0.34375 0.28125 +vt 0.3125 0.28125 +vt 0.3125 0.25 +vt 0.8125 0.625 +vt 0.8125 0.65625 +vt 0.78125 0.65625 +vt 0.78125 0.625 +vt 0.25 0.25 +vt 0.28125 0.25 +vt 0.28125 0.28125 +vt 0.25 0.28125 +vt 0.3125 0.1875 +vt 0.34375 0.1875 +vt 0.34375 0.21875 +vt 0.3125 0.21875 +vt 0.625 0.46875 +vt 0.65625 0.46875 +vt 0.65625 0.5 +vt 0.625 0.5 +vt 0.1875 0.28125 +vt 0.21875 0.28125 +vt 0.21875 0.3125 +vt 0.1875 0.3125 +vt 0.75 0.6875 +vt 0.78125 0.6875 +vt 0.78125 0.71875 +vt 0.75 0.71875 +vt 0.71875 0.65625 +vt 0.71875 0.6875 +vt 0.6875 0.6875 +vt 0.6875 0.65625 +vt 0.21875 0.21875 +vt 0.21875 0.25 +vt 0.1875 0.25 +vt 0.1875 0.21875 +vt 0.28125 0.21875 +vt 0.25 0.21875 +vt 0.25 0.1875 +vt 0.28125 0.1875 +vt 0.84375 0.65625 +vt 0.875 0.65625 +vt 0.875 0.6875 +vt 0.84375 0.6875 +vt 0.1875 0.09375 +vt 0.21875 0.09375 +vt 0.21875 0.125 +vt 0.1875 0.125 +vt 0.875 0.59375 +vt 0.875 0.625 +vt 0.84375 0.625 +vt 0.84375 0.59375 +vt 0.90625 0.71875 +vt 0.90625 0.75 +vt 0.875 0.75 +vt 0.875 0.71875 +vt 0.84375 0.15625 +vt 0.8125 0.15625 +vt 0.8125 0.125 +vt 0.84375 0.125 +vt 0.75 0.96875 +vt 0.75 1 +vt 0.71875 1 +vt 0.71875 0.96875 +vt 0.59375 0.28125 +vt 0.59375 0.3125 +vt 0.5625 0.3125 +vt 0.5625 0.28125 +vt 0.6875 0.28125 +vt 0.71875 0.28125 +vt 0.71875 0.3125 +vt 0.6875 0.3125 +vt 0.6875 0.40625 +vt 0.71875 0.40625 +vt 0.71875 0.4375 +vt 0.6875 0.4375 +vt 0 0.21875 +vt 0.03125 0.21875 +vt 0.03125 0.25 +vt 0 0.25 +vt 0.28125 0.3125 +vt 0.28125 0.34375 +vt 0.25 0.34375 +vt 0.25 0.3125 +vt 0.53125 0.21875 +vt 0.53125 0.25 +vt 0.5 0.25 +vt 0.5 0.21875 +vt 0.3125 0.3125 +vt 0.34375 0.3125 +vt 0.34375 0.34375 +vt 0.3125 0.34375 +vt 0.75 0.46875 +vt 0.78125 0.46875 +vt 0.78125 0.5 +vt 0.75 0.5 +vt 0.375 0.28125 +vt 0.40625 0.28125 +vt 0.40625 0.3125 +vt 0.375 0.3125 +vt 0.84375 0.25 +vt 0.84375 0.28125 +vt 0.8125 0.28125 +vt 0.8125 0.25 +vt 0.84375 0.84375 +vt 0.875 0.84375 +vt 0.875 0.875 +vt 0.84375 0.875 +vt 0.6875 0.15625 +vt 0.71875 0.15625 +vt 0.71875 0.1875 +vt 0.6875 0.1875 +vt 0.875 0.96875 +vt 0.875 1 +vt 0.84375 1 +vt 0.84375 0.96875 +vt 0.4375 0.3125 +vt 0.4375 0.28125 +vt 0.46875 0.28125 +vt 0.46875 0.3125 +vt 0.84375 0.3125 +vt 0.84375 0.34375 +vt 0.8125 0.34375 +vt 0.8125 0.3125 +vt 0.6875 0.46875 +vt 0.71875 0.46875 +vt 0.71875 0.5 +vt 0.6875 0.5 +vt 0.625 0.15625 +vt 0.65625 0.15625 +vt 0.65625 0.1875 +vt 0.625 0.1875 +vt 0.53125 0.28125 +vt 0.53125 0.3125 +vt 0.5 0.3125 +vt 0.5 0.28125 +vt 0.84375 0.8125 +vt 0.84375 0.78125 +vt 0.875 0.78125 +vt 0.875 0.8125 +vt 0.65625 0.40625 +vt 0.65625 0.4375 +vt 0.625 0.4375 +vt 0.625 0.40625 +vt 0.0625 0.28125 +vt 0.09375 0.28125 +vt 0.09375 0.3125 +vt 0.0625 0.3125 +vt 0.625 0.34375 +vt 0.65625 0.34375 +vt 0.65625 0.375 +vt 0.625 0.375 +vt 0.03125 0.28125 +vt 0.03125 0.3125 +vt 0 0.3125 +vt 0 0.28125 +vt 0.5625 0.375 +vt 0.5625 0.34375 +vt 0.59375 0.34375 +vt 0.59375 0.375 +vt 0.6875 0.21875 +vt 0.71875 0.21875 +vt 0.71875 0.25 +vt 0.6875 0.25 +vt 0.78125 0.78125 +vt 0.8125 0.78125 +vt 0.8125 0.8125 +vt 0.78125 0.8125 +vt 0.78125 0.28125 +vt 0.78125 0.3125 +vt 0.75 0.3125 +vt 0.75 0.28125 +vt 0.8125 0.84375 +vt 0.8125 0.875 +vt 0.78125 0.875 +vt 0.78125 0.84375 +vt 0.15625 0.25 +vt 0.125 0.25 +vt 0.125 0.21875 +vt 0.15625 0.21875 +vt 0.75 0.75 +vt 0.75 0.78125 +vt 0.71875 0.78125 +vt 0.71875 0.75 +vt 0.75 0.21875 +vt 0.78125 0.21875 +vt 0.78125 0.25 +vt 0.75 0.25 +vt 0.71875 0.8125 +vt 0.75 0.8125 +vt 0.75 0.84375 +vt 0.71875 0.84375 +vt 0.8125 0.96875 +vt 0.8125 1 +vt 0.78125 1 +vt 0.78125 0.96875 +vt 0.1875 0.1875 +vt 0.1875 0.15625 +vt 0.21875 0.15625 +vt 0.21875 0.1875 +vt 0.78125 0.15625 +vt 0.78125 0.1875 +vt 0.75 0.1875 +vt 0.75 0.15625 +vt 0.125 0.09375 +vt 0.15625 0.09375 +vt 0.15625 0.125 +vt 0.125 0.125 +vt 0.8125 0.1875 +vt 0.84375 0.1875 +vt 0.84375 0.21875 +vt 0.8125 0.21875 +vt 0.90625 0.90625 +vt 0.90625 0.9375 +vt 0.875 0.9375 +vt 0.875 0.90625 +vt 0.375 0.125 +vt 0.375 0.09375 +vt 0.40625 0.09375 +vt 0.40625 0.125 +vn 0 1 0 +vn 0 1 0 +vn 0.3697474195862868 0.25780169741764597 0.8926506206337748 +vn 0 1 0 +vn 0 1 0 +vn -0.8926490750080555 0.25780208816210454 0.36975087860141914 +vn 0 1 0 +vn 0 1 0 +vn -0.3697508786014192 0.2578020881621045 -0.8926490750080555 +vn 0 1 0 +vn 0 1 0 +vn 0.892647995945159 0.25780646547568525 0.3697504316346713 +vn 0 1 0 +vn 0 1 0 +vn -0.36974526629226373 0.25779855544840435 0.892652419961532 +vn 0 1 0 +vn 0 1 0 +vn -0.8926470392268018 0.2578061891650016 -0.3697529339813872 +vn 0 1 0 +vn 0 1 0 +vn 0.8926481992081888 0.2578056409274074 -0.3697505158297777 +vn -0.8547236096297557 0.37961373874497284 0.35403525318468576 +vn 0 1 0 +vn 0.36974689282587153 0.2578068562294606 -0.8926493489196616 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0.9238799717754941 0 0.382682371885762 +vn 0.3826849356022358 0 -0.9238789098486406 +vn 0.38268493560223593 0 0.9238789098486405 +vn -0.3826823718857617 0 -0.9238799717754941 +vn -0.9238807226641912 0 -0.38268055906916937 +vn 0.923879971775494 0 -0.3826823718857618 +vn -0.9238799717754941 0 0.3826823718857618 +vn -0.3540394967792186 0.37961308668656524 -0.8547221414802685 +vn 0.8547236096297558 0.3796137387449727 -0.35403525318468565 +vn 0.354037739027542 0.3796133567786655 0.8547227496091934 +vn -0.8547243572057328 0.37961176673677927 -0.35403556283829474 +vn 0.354037428804446 0.37961533239038303 -0.854722000664354 +vn 0.8547231502891115 0.3796149504199115 0.354035062921097 +vn -0.35403910173112607 0.3796156024820344 0.8547211877551766 +vn 0 1 0 +vn -0.3826867484324464 0 0.9238781589442417 +vn 1 0 0 +vn 0 0 1 +vn -1 0 0 +vn 0 0 -1 +vn 0 1 0 +vn 0.9819248122948318 -0.06319085586031163 -0.1784112629163339 +vn 0.17176660991019432 -0.09806751908518212 0.9802443539344855 +vn -0.9819279651148741 0.06319643606696977 0.1783919330961921 +vn -0.17177164060106279 0.09804787495470711 -0.980245437480882 +vn 0.07944075108241308 0.9931712661254567 0.08544005623956273 +vn 0.7732165536543917 0.1974683767600399 -0.6026129780668356 +vn 0.5830134102198514 0.15245183760672837 0.798031202844946 +vn -0.7732154789795203 -0.1974725487750553 0.6026129898589596 +vn -0.5830134102198513 -0.1524518376067283 -0.798031202844946 +vn -0.24944378103516862 0.9683854050227322 -0.0027399711381434393 +vn 0.9787911013765017 -0.13345098517551415 0.15543106002938983 +vn -0.15796523420525538 -0.008546047964950555 0.9874076918105611 +vn -0.9787917810221046 0.1334509483416168 -0.15542681168414862 +vn 0.15797975306968054 0.008544068455348584 -0.9874053861075871 +vn 0.13043929907409024 0.9910188722011719 0.02944799141159767 +vn 0.9712755832864818 -0.011107395355801141 0.23769805863724408 +vn -0.23556387644038188 0.09616153369606953 0.9670897680947722 +vn -0.9712755338397139 0.011107375171902438 -0.23769826162841348 +vn 0.23556564142983788 -0.09616155388217752 -0.9670893361694741 +vn 0.03359951426153794 0.9953048926165181 -0.09077027803752777 +vn 0.926507782761758 0.13852984290926929 -0.3498468394961203 +vn 0.33793678776565156 0.10235058160529241 0.9355870274430336 +vn -0.9265206736845397 -0.138501258713964 0.3498240165708955 +vn -0.33794407088426215 -0.10235421183631269 -0.9355839995818335 +vn -0.16540089969428204 0.9850560168944099 -0.048033175623129684 +vn 0.9668040022959411 -0.11345836678061157 -0.22894807304720072 +vn 0.24696143726222697 0.18500678157192843 0.951200577837173 +vn -0.9668038690714442 0.11344731311896193 0.22895411307634103 +vn -0.24697643217057372 -0.18500502184223788 -0.951197026827485 +vn 0.06556184717165937 0.9761670110342906 -0.20688066793158613 +vn 0.9769805403739376 0.010425234300174382 0.2130735511987253 +vn -0.2127923744455931 -0.023145371420946297 0.9768232681297028 +vn -0.9769828387252752 -0.010422526217007245 -0.21306314506164284 +vn 0.2127762605253235 0.023145199517278427 -0.9768267823397171 +vn -0.015115148557279549 0.9996778678381654 0.020388546751327452 +vn 0.9930541857332384 -0.0955734222114905 0.0686229201104039 +vn -0.049056688323601175 0.19380221843475542 0.9798133196994663 +vn -0.9930541472306533 0.09557466458099076 -0.06862174697740461 +vn 0.04905389263054388 -0.19380194937262105 -0.9798135128876124 +vn 0.10693965906738179 0.9763730503785817 -0.1877753333453988 +vn 0.7799261666490147 -0.22277656238999247 0.5848809945842586 +vn -0.464168267830042 0.4210042081902916 0.7792966545711101 +vn -0.7799064545202707 0.22279049167099582 -0.5849019738542662 +vn 0.4641811686142965 -0.4209988806346987 -0.7792918485446879 +vn 0.41985023558843015 0.8792743052501094 -0.22494994066075522 +vn 0.9855693962619345 0.13889541297007107 -0.09675241292990751 +vn 0.10201397833057059 -0.03134284280707165 0.9942890799108381 +vn -0.9855719058724727 -0.138891871288409 0.09673193084450404 +vn -0.10201618236743495 0.03134319739115485 -0.9942888425967947 +vn -0.13505264186407212 0.9898142804717763 0.04503858456562142 +vn 0.996070444569669 0.025064567195526615 -0.08494372799732149 +vn 0.08775471346606928 -0.15044108109939855 0.9847165030515801 +vn -0.9960703408708539 -0.025064237400984908 0.08494504129687798 +vn -0.0877720983877272 0.15044064360688622 -0.9847150204479268 +vn -0.011903826414273287 0.9883048584837547 0.15202567419388274 +vn 0.7181532528466409 -0.0063535723912693225 -0.6958559747129126 +vn 0.6919089552841307 -0.09993324491975189 0.7150352048377981 +vn -0.7181751877798601 0.006356739939912765 0.6958333072760308 +vn -0.691908119395247 0.09991772316773005 -0.7150381828349516 +vn 0.07407216213943879 0.9949747155504055 0.06736935661989442 +vn 0.7513152702614228 -0.6596382688447988 -0.020067858566439212 +vn -0.058139414675167034 -0.09642963889619394 0.9936403439895034 +vn -0.751323406063937 0.6596294034265134 0.0200546661818865 +vn 0.058136988695899984 0.09642949124795555 -0.9936405002630646 +vn 0.6573805771869294 0.7453679728075033 0.11080325739892487 +vn 0.998448299595997 -0.009067835028458475 -0.0549433108008538 +vn 0.0544573425054901 -0.04512265986385018 0.9974960367911501 +vn -0.9984482829964678 0.009069274013410197 0.05494337494434706 +vn -0.054457707016772695 0.0450910774411017 -0.9974974450501997 +vn 0.011528562094657926 0.9989416123279936 0.04452805200745368 +vn 0.9766536996099617 -0.1271382444456312 -0.1731572055603159 +vn 0.18214064961690446 0.06269543242079292 0.9812716578556124 +vn -0.9766493304262159 0.12714338785176565 0.1731780710817621 +vn -0.182134245435563 -0.06267446867226593 -0.9812741857484418 +vn 0.11391538409947002 0.9899010994762809 -0.08437475049516019 +vn 0.9822536414460373 -0.18059718103474193 0.05062056961655586 +vn -0.08505921328543795 -0.1885004754671565 0.9783825943790693 +vn -0.9822574527207377 0.180575515597724 -0.050623904839353064 +vn 0.08508095268138223 0.18849647852330023 -0.9783814742088812 +vn 0.16714158574282562 0.9653256822477764 0.20052435639652008 +vn 0.8228878980279518 -0.5496854723475227 0.14387977192510523 +vn -0.37169182221646263 -0.32920504344626866 0.8680260529885874 +vn -0.8229008761721915 0.5496612416293223 -0.1438981148088872 +vn 0.37166979172387327 0.3292037204008796 -0.8680359879602644 +vn 0.42976769719485813 0.7677780636857405 0.47520161128809696 +usemtl m_e9770093-4047-e868-3967-ff5368d931ff +f 1/1/1 2/2/1 3/3/1 4/4/1 +f 5/5/2 6/6/2 7/7/2 8/8/2 +f 9/9/3 10/10/3 11/11/3 12/12/3 +f 13/13/4 14/14/4 15/15/4 16/16/4 +f 6/17/5 17/18/5 18/19/5 7/20/5 +f 19/21/6 20/22/6 21/23/6 22/24/6 +f 23/25/7 24/26/7 25/27/7 26/28/7 +f 17/29/8 27/30/8 28/31/8 18/32/8 +f 18/33/9 28/34/9 29/35/9 30/36/9 +f 2/37/10 31/38/10 32/39/10 3/40/10 +f 27/41/11 33/42/11 19/43/11 28/44/11 +f 10/45/12 8/46/12 34/47/12 11/48/12 +f 14/49/13 1/50/13 4/51/13 15/52/13 +f 33/53/14 35/54/14 20/55/14 19/56/14 +f 20/57/15 9/58/15 12/59/15 21/60/15 +f 36/61/16 13/62/16 16/63/16 37/64/16 +f 35/65/17 38/66/17 9/67/17 20/68/17 +f 28/69/18 19/70/18 22/71/18 29/72/18 +f 24/73/19 36/74/19 37/75/19 25/76/19 +f 38/77/20 39/78/20 10/79/20 9/80/20 +f 8/81/21 7/82/21 40/83/21 34/84/21 +f 5/85/22 36/86/22 24/87/22 6/88/22 +f 39/89/23 5/90/23 8/91/23 10/92/23 +f 7/93/24 18/94/24 30/95/24 40/96/24 +f 11/97/25 34/98/25 40/99/25 30/100/25 +f 21/101/26 12/102/26 29/103/26 22/104/26 +f 12/105/27 11/106/27 30/107/27 29/108/27 +f 3/109/28 32/110/28 41/111/28 42/112/28 +f 15/113/29 4/114/29 43/115/29 44/116/29 +f 32/117/30 26/118/30 45/119/30 41/120/30 +f 16/121/31 15/122/31 44/123/31 46/124/31 +f 37/125/32 16/126/32 46/127/32 47/128/32 +f 4/129/33 3/130/33 42/131/33 43/132/33 +f 25/133/34 37/134/34 47/135/34 48/136/34 +f 38/137/35 14/138/35 13/139/35 39/140/35 +f 33/141/36 2/142/36 1/143/36 35/144/36 +f 17/145/37 23/146/37 31/147/37 27/148/37 +f 39/149/38 13/150/38 36/151/38 5/152/38 +f 35/153/39 1/154/39 14/155/39 38/156/39 +f 27/157/40 31/158/40 2/159/40 33/160/40 +f 6/161/41 24/162/41 23/163/41 17/164/41 +f 31/165/42 23/166/42 26/167/42 32/168/42 +f 26/169/43 25/170/43 48/171/43 45/172/43 +f 49/173/44 50/174/44 51/175/44 52/176/44 +f 52/177/45 51/178/45 53/179/45 54/180/45 +f 54/181/46 53/182/46 55/183/46 56/184/46 +f 56/185/47 55/186/47 50/187/47 49/188/47 +f 53/189/48 51/190/48 50/191/48 55/192/48 +f 57/193/49 58/194/49 59/195/49 60/196/49 +f 60/197/50 59/198/50 61/199/50 62/200/50 +f 62/201/51 61/202/51 63/203/51 64/204/51 +f 64/205/52 63/206/52 58/207/52 57/208/52 +f 61/209/53 59/210/53 58/211/53 63/212/53 +f 65/213/54 66/214/54 67/215/54 68/216/54 +f 68/217/55 67/218/55 69/219/55 70/220/55 +f 70/221/56 69/222/56 71/223/56 72/224/56 +f 72/225/57 71/226/57 66/227/57 65/228/57 +f 69/229/58 67/230/58 66/231/58 71/232/58 +f 73/233/59 74/234/59 75/235/59 76/236/59 +f 76/237/60 75/238/60 77/239/60 78/240/60 +f 78/241/61 77/242/61 79/243/61 80/244/61 +f 80/245/62 79/246/62 74/247/62 73/248/62 +f 77/249/63 75/250/63 74/251/63 79/252/63 +f 81/253/64 82/254/64 83/255/64 84/256/64 +f 84/257/65 83/258/65 85/259/65 86/260/65 +f 86/261/66 85/262/66 87/263/66 88/264/66 +f 88/265/67 87/266/67 82/267/67 81/268/67 +f 85/269/68 83/270/68 82/271/68 87/272/68 +f 89/273/69 90/274/69 91/275/69 92/276/69 +f 92/277/70 91/278/70 93/279/70 94/280/70 +f 94/281/71 93/282/71 95/283/71 96/284/71 +f 96/285/72 95/286/72 90/287/72 89/288/72 +f 93/289/73 91/290/73 90/291/73 95/292/73 +f 97/293/74 98/294/74 99/295/74 100/296/74 +f 100/297/75 99/298/75 101/299/75 102/300/75 +f 102/301/76 101/302/76 103/303/76 104/304/76 +f 104/305/77 103/306/77 98/307/77 97/308/77 +f 101/309/78 99/310/78 98/311/78 103/312/78 +f 105/313/79 106/314/79 107/315/79 108/316/79 +f 108/317/80 107/318/80 109/319/80 110/320/80 +f 110/321/81 109/322/81 111/323/81 112/324/81 +f 112/325/82 111/326/82 106/327/82 105/328/82 +f 109/329/83 107/330/83 106/331/83 111/332/83 +f 113/333/84 114/334/84 115/335/84 116/336/84 +f 116/337/85 115/338/85 117/339/85 118/340/85 +f 118/341/86 117/342/86 119/343/86 120/344/86 +f 120/345/87 119/346/87 114/347/87 113/348/87 +f 117/349/88 115/350/88 114/351/88 119/352/88 +f 121/353/89 122/354/89 123/355/89 124/356/89 +f 124/357/90 123/358/90 125/359/90 126/360/90 +f 126/361/91 125/362/91 127/363/91 128/364/91 +f 128/365/92 127/366/92 122/367/92 121/368/92 +f 125/369/93 123/370/93 122/371/93 127/372/93 +f 129/373/94 130/374/94 131/375/94 132/376/94 +f 132/377/95 131/378/95 133/379/95 134/380/95 +f 134/381/96 133/382/96 135/383/96 136/384/96 +f 136/385/97 135/386/97 130/387/97 129/388/97 +f 133/389/98 131/390/98 130/391/98 135/392/98 +f 137/393/99 138/394/99 139/395/99 140/396/99 +f 140/397/100 139/398/100 141/399/100 142/400/100 +f 142/401/101 141/402/101 143/403/101 144/404/101 +f 144/405/102 143/406/102 138/407/102 137/408/102 +f 141/409/103 139/410/103 138/411/103 143/412/103 +f 145/413/104 146/414/104 147/415/104 148/416/104 +f 148/417/105 147/418/105 149/419/105 150/420/105 +f 150/421/106 149/422/106 151/423/106 152/424/106 +f 152/425/107 151/426/107 146/427/107 145/428/107 +f 149/429/108 147/430/108 146/431/108 151/432/108 +f 153/433/109 154/434/109 155/435/109 156/436/109 +f 156/437/110 155/438/110 157/439/110 158/440/110 +f 158/441/111 157/442/111 159/443/111 160/444/111 +f 160/445/112 159/446/112 154/447/112 153/448/112 +f 157/449/113 155/450/113 154/451/113 159/452/113 +f 161/453/114 162/454/114 163/455/114 164/456/114 +f 164/457/115 163/458/115 165/459/115 166/460/115 +f 166/461/116 165/462/116 167/463/116 168/464/116 +f 168/465/117 167/466/117 162/467/117 161/468/117 +f 165/469/118 163/470/118 162/471/118 167/472/118 +f 169/473/119 170/474/119 171/475/119 172/476/119 +f 172/477/120 171/478/120 173/479/120 174/480/120 +f 174/481/121 173/482/121 175/483/121 176/484/121 +f 176/485/122 175/486/122 170/487/122 169/488/122 +f 173/489/123 171/490/123 170/491/123 175/492/123 +f 177/493/124 178/494/124 179/495/124 180/496/124 +f 180/497/125 179/498/125 181/499/125 182/500/125 +f 182/501/126 181/502/126 183/503/126 184/504/126 +f 184/505/127 183/506/127 178/507/127 177/508/127 +f 181/509/128 179/510/128 178/511/128 183/512/128 +f 185/513/129 186/514/129 187/515/129 188/516/129 +f 188/517/130 187/518/130 189/519/130 190/520/130 +f 190/521/131 189/522/131 191/523/131 192/524/131 +f 192/525/132 191/526/132 186/527/132 185/528/132 +f 189/529/133 187/530/133 186/531/133 191/532/133 \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/obj/blocks/bowl/petbowl.mtl b/src/main/resources/assets/tiedup/models/obj/blocks/bowl/petbowl.mtl new file mode 100644 index 0000000..a0056e3 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/obj/blocks/bowl/petbowl.mtl @@ -0,0 +1,4 @@ +# Made in Blockbench 5.0.7 +newmtl m_e9770093-4047-e868-3967-ff5368d931ff +map_Kd petbowl.png +newmtl none \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/obj/blocks/bowl/petbowl.png b/src/main/resources/assets/tiedup/models/obj/blocks/bowl/petbowl.png new file mode 100644 index 0000000..59e6e54 Binary files /dev/null and b/src/main/resources/assets/tiedup/models/obj/blocks/bowl/petbowl.png differ diff --git a/src/main/resources/assets/tiedup/models/obj/blocks/cage/model.mtl b/src/main/resources/assets/tiedup/models/obj/blocks/cage/model.mtl new file mode 100644 index 0000000..053c267 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/obj/blocks/cage/model.mtl @@ -0,0 +1,4 @@ +# Made in Blockbench 5.0.7 +newmtl m_a0ed59c9-b727-a860-adda-44d308056dc0 +map_Kd texture.png +newmtl none diff --git a/src/main/resources/assets/tiedup/models/obj/blocks/cage/model.obj b/src/main/resources/assets/tiedup/models/obj/blocks/cage/model.obj new file mode 100644 index 0000000..40b76db --- /dev/null +++ b/src/main/resources/assets/tiedup/models/obj/blocks/cage/model.obj @@ -0,0 +1,2173 @@ +# Made in Blockbench 5.0.7 +mtllib model.mtl + +o Pet_Cage_Large +v -1.088411 0.785434 -1.053296 +v -1.045141 0.785434 -1.105281 +v -1.001814 0.729063 -1.069218 +v -1.045085 0.729063 -1.017233 +v -0.899514 1.020939 -0.896064 +v -0.86408 1.067042 -0.86657 +v -0.826474 1.067042 -0.911749 +v -0.861908 1.020939 -0.941244 +v -0.791039 1.020939 -0.882254 +v -0.826474 0.974835 -0.911749 +v -0.792452 0.974835 -0.952623 +v -0.773767 1.020939 -0.903005 +v -0.809202 1.067042 -0.932499 +v -0.827886 1.020939 -0.982117 +v -0.86408 0.974835 -0.86657 +v -0.828645 1.020939 -0.837075 +v -0.865206 0.880176 -1.013181 +v -0.90064 0.926279 -1.042675 +v -0.934662 0.926279 -1.001801 +v -0.899228 0.880176 -0.972307 +v -1.062443 0.837268 -1.031681 +v -0.980141 0.94435 -0.963176 +v -0.905367 0.94435 -1.053009 +v -0.987669 0.837268 -1.121514 +v -0.84817 0.869932 -1.0054 +v -0.930472 0.762849 -1.073905 +v -0.922944 0.869932 -0.915567 +v -1.005246 0.762849 -0.984072 +v -1.088356 0.729063 -0.965247 +v -1.131682 0.785434 -1.001311 +v -0.93712 1.020939 -0.850885 +v -0.901685 1.067042 -0.82139 +v -0.866251 1.020939 -0.791896 +v -0.883523 1.020939 -0.771145 +v -0.935707 0.974835 -0.780516 +v -0.901685 0.974835 -0.82139 +v -0.971142 1.020939 -0.810011 +v -0.918957 1.067042 -0.80064 +v -1.043896 0.926279 -0.870569 +v -1.008461 0.880176 -0.841074 +v -0.974439 0.880176 -0.881948 +v -1.009874 0.926279 -0.911443 +v -1.137218 0.837268 -0.941848 +v -1.054915 0.94435 -0.873342 +v -1.08002 0.762849 -0.894239 +v -0.997718 0.869932 -0.825733 +v -0.840201 0.102556 -0.959208 +v -0.840201 0.102556 -0.840201 +v 0.840201 0.102556 -0.840201 +v 0.840201 0.102556 -0.959208 +v 0.840201 0.102556 0.959208 +v 0.840202 0.102555 0.840201 +v -0.840201 0.102556 0.840201 +v -0.840201 0.102556 0.959208 +v 0.959208 0.102556 -0.840201 +v 0.924352 0.102556 -0.924352 +v -0.959208 0.102556 -0.840201 +v -0.924352 0.102556 -0.924352 +v -0.959208 0.102556 0.840201 +v 0.959208 0.102556 0.840202 +v 0.924352 0.102556 0.924352 +v -0.924352 0.102556 0.924352 +v -0.882644 0.000777 1 +v -0.882093 0.102556 1 +v -0.965466 0.102556 0.965466 +v -0.965627 0.000777 0.965627 +v -1 0.102556 0.882093 +v -1 0.000777 0.882644 +v -0.882093 0.102556 -1 +v -0.882644 0.000777 -1 +v -0.965627 0.000777 -0.965627 +v -0.965466 0.102556 -0.965466 +v -1 0.000777 -0.882644 +v -1 0.102556 -0.882093 +v 1 0.102556 -0.882093 +v 1 0.000777 -0.882644 +v 0.965627 0.000777 -0.965627 +v 0.965466 0.102556 -0.965466 +v 0.882644 0.000777 -1 +v 0.882094 0.102556 -1 +v 0.882094 0.102555 1 +v 0.882644 0.000777 1 +v 0.965627 0.000777 0.965627 +v 0.965466 0.102556 0.965466 +v 1 0.000777 0.882644 +v 1 0.102556 0.882093 +v 0.885528 0.000437 0.961667 +v 0.885528 2.211133 0.961667 +v 0.885528 2.211133 0.883399 +v 0.885528 0.000437 0.883399 +v 0.963796 2.211133 0.883399 +v 0.963796 0.000437 0.883399 +v 0.963796 2.211133 0.961667 +v 0.963796 0.000437 0.961667 +v -0.885528 0.000437 0.961667 +v -0.885528 0.000438 0.883398 +v -0.885528 2.211133 0.883398 +v -0.885528 2.211133 0.961667 +v -0.963796 0.000438 0.883398 +v -0.963796 2.211133 0.883398 +v -0.963796 0.000438 0.961667 +v -0.963796 2.211133 0.961667 +v 0.885528 0.000437 -0.961667 +v 0.885528 0.000437 -0.883399 +v 0.885528 2.211133 -0.883399 +v 0.885528 2.211133 -0.961667 +v 0.963796 0.000437 -0.883398 +v 0.963796 2.211133 -0.883398 +v 0.963796 0.000438 -0.961667 +v 0.963796 2.211133 -0.961667 +v -0.885527 0.000437 -0.961667 +v -0.885528 2.211133 -0.961667 +v -0.885527 2.211133 -0.883398 +v -0.885527 0.000437 -0.883398 +v -0.963796 2.211133 -0.883398 +v -0.963796 0.000437 -0.883398 +v -0.963796 2.211133 -0.961667 +v -0.963796 0.000438 -0.961667 +v 0.907682 0.000437 0.747558 +v 0.903617 2.141308 0.751623 +v 0.903617 2.141308 0.709533 +v 0.907682 0.000437 0.713598 +v 0.945707 2.141308 0.709533 +v 0.941642 0.000437 0.713598 +v 0.945707 2.141308 0.751623 +v 0.941642 0.000438 0.747558 +v 0.907682 0.000437 0.457133 +v 0.903617 2.141308 0.461199 +v 0.903617 2.141308 0.419108 +v 0.907682 0.000437 0.423174 +v 0.945707 2.141308 0.419108 +v 0.941642 0.000438 0.423174 +v 0.945707 2.141308 0.461199 +v 0.941642 0.000437 0.457133 +v 0.907682 0.000437 0.166709 +v 0.903617 2.141308 0.170775 +v 0.903617 2.141308 0.128684 +v 0.907682 0.000437 0.132749 +v 0.945707 2.141308 0.128684 +v 0.941642 0.000437 0.132749 +v 0.945707 2.141308 0.170775 +v 0.941642 0.000437 0.166709 +v 0.907682 0.000437 -0.123715 +v 0.903617 2.141308 -0.11965 +v 0.903617 2.141308 -0.16174 +v 0.907682 0.000437 -0.157675 +v 0.945707 2.141308 -0.16174 +v 0.941642 0.000437 -0.157675 +v 0.945707 2.141308 -0.11965 +v 0.941642 0.000437 -0.123715 +v 0.907682 0.000437 -0.414139 +v 0.903617 2.141308 -0.410074 +v 0.903617 2.141308 -0.452165 +v 0.907682 0.000437 -0.448099 +v 0.945707 2.141308 -0.452165 +v 0.941642 0.000437 -0.448099 +v 0.945707 2.141308 -0.410074 +v 0.941642 0.000437 -0.41414 +v 0.907682 0.000438 -0.704564 +v 0.903617 2.141308 -0.700499 +v 0.903617 2.141308 -0.742589 +v 0.907682 0.000437 -0.738524 +v 0.945707 2.141308 -0.742589 +v 0.941642 0.000437 -0.738524 +v 0.945707 2.141308 -0.700499 +v 0.941642 0.000437 -0.704564 +v -0.907682 0.000438 0.747558 +v -0.907682 0.000438 0.713598 +v -0.903616 2.141308 0.709533 +v -0.903617 2.141308 0.751623 +v -0.941642 0.000437 0.713598 +v -0.945707 2.141308 0.709532 +v -0.941642 0.000438 0.747558 +v -0.945707 2.141308 0.751623 +v -0.907682 0.000437 0.457133 +v -0.907682 0.000438 0.423174 +v -0.903617 2.141308 0.419108 +v -0.903616 2.141308 0.461199 +v -0.941642 0.000438 0.423174 +v -0.945707 2.141308 0.419108 +v -0.941642 0.000438 0.457133 +v -0.945707 2.141308 0.461199 +v -0.907682 0.000438 0.166709 +v -0.907682 0.000437 0.132749 +v -0.903617 2.141308 0.128684 +v -0.903617 2.141308 0.170774 +v -0.941642 0.000438 0.132749 +v -0.945707 2.141308 0.128684 +v -0.941642 0.000437 0.166709 +v -0.945707 2.141308 0.170775 +v -0.907682 0.000437 -0.123715 +v -0.907682 0.000438 -0.157675 +v -0.903617 2.141308 -0.16174 +v -0.903617 2.141308 -0.11965 +v -0.941642 0.000437 -0.157675 +v -0.945707 2.141308 -0.16174 +v -0.941642 0.000438 -0.123715 +v -0.945707 2.141308 -0.11965 +v -0.907682 0.000438 -0.414139 +v -0.907682 0.000437 -0.448099 +v -0.903617 2.141308 -0.452164 +v -0.903617 2.141308 -0.410074 +v -0.941642 0.000438 -0.448099 +v -0.945707 2.141308 -0.452165 +v -0.941642 0.000437 -0.41414 +v -0.945707 2.141308 -0.410074 +v -0.907682 0.000437 -0.704564 +v -0.907682 0.000438 -0.738523 +v -0.903617 2.141308 -0.742589 +v -0.903616 2.141308 -0.700498 +v -0.941642 0.000437 -0.738524 +v -0.945707 2.141308 -0.742589 +v -0.941642 0.000438 -0.704564 +v -0.945707 2.141308 -0.700499 +v 0.743041 0.000438 -0.903165 +v 0.747106 2.141308 -0.8991 +v 0.705016 2.141308 -0.8991 +v 0.709081 0.000437 -0.903165 +v 0.705016 2.141308 -0.94119 +v 0.709081 0.000438 -0.937125 +v 0.747106 2.141308 -0.94119 +v 0.743041 0.000438 -0.937125 +v 0.452616 0.000438 -0.903165 +v 0.456682 2.141308 -0.8991 +v 0.414591 2.141308 -0.8991 +v 0.418657 0.000438 -0.903165 +v 0.414591 2.141308 -0.94119 +v 0.418657 0.000438 -0.937125 +v 0.456682 2.141308 -0.94119 +v 0.452616 0.000438 -0.937125 +v 0.162192 0.000437 -0.903165 +v 0.166257 2.141308 -0.8991 +v 0.124167 2.141308 -0.8991 +v 0.128232 0.000437 -0.903165 +v 0.124167 2.141308 -0.94119 +v 0.128232 0.000437 -0.937125 +v 0.166257 2.141308 -0.94119 +v 0.162192 0.000438 -0.937125 +v -0.128232 0.000437 -0.903165 +v -0.124167 2.141308 -0.8991 +v -0.166257 2.141308 -0.8991 +v -0.162192 0.000438 -0.903165 +v -0.166257 2.141308 -0.94119 +v -0.162192 0.000438 -0.937125 +v -0.124167 2.141308 -0.94119 +v -0.128232 0.000438 -0.937125 +v -0.418657 0.000438 -0.903165 +v -0.414591 2.141308 -0.8991 +v -0.456682 2.141308 -0.8991 +v -0.452616 0.000437 -0.903165 +v -0.456682 2.141308 -0.94119 +v -0.452616 0.000437 -0.937125 +v -0.414591 2.141308 -0.94119 +v -0.418657 0.000437 -0.937125 +v -0.709081 0.000438 -0.903165 +v -0.705016 2.141308 -0.8991 +v -0.747106 2.141308 -0.8991 +v -0.743041 0.000437 -0.903165 +v -0.747106 2.141308 -0.94119 +v -0.743041 0.000437 -0.937125 +v -0.705016 2.141308 -0.94119 +v -0.709081 0.000437 -0.937125 +v 0.743041 0.000438 0.912199 +v 0.709081 0.000437 0.912199 +v 0.705016 2.141308 0.908134 +v 0.747106 2.141308 0.908134 +v 0.709081 0.000438 0.946159 +v 0.705015 2.141308 0.950224 +v 0.743041 0.000438 0.946159 +v 0.747106 2.141308 0.950224 +v 0.452616 0.000438 0.912199 +v 0.418657 0.000438 0.912199 +v 0.414591 2.141308 0.908134 +v 0.456682 2.141308 0.908134 +v 0.418657 0.000438 0.946159 +v 0.414591 2.141308 0.950224 +v 0.452616 0.000437 0.946159 +v 0.456682 2.141308 0.950224 +v 0.162192 0.000437 0.912199 +v 0.128232 0.000438 0.912199 +v 0.124167 2.141308 0.908134 +v 0.166257 2.141308 0.908134 +v 0.128232 0.000438 0.946159 +v 0.124167 2.141308 0.950224 +v 0.162192 0.000438 0.946159 +v 0.166257 2.141308 0.950224 +v -0.128232 0.000438 0.912199 +v -0.162192 0.000438 0.912199 +v -0.166258 2.141308 0.908134 +v -0.124167 2.141308 0.908133 +v -0.162192 0.000438 0.946159 +v -0.166257 2.141308 0.950224 +v -0.128232 0.000438 0.946159 +v -0.124167 2.141308 0.950224 +v -0.418657 0.000438 0.912199 +v -0.452616 0.000437 0.912199 +v -0.456682 2.141308 0.908133 +v -0.414591 2.141308 0.908134 +v -0.452616 0.000438 0.946159 +v -0.456682 2.141308 0.950224 +v -0.418657 0.000438 0.946159 +v -0.414591 2.141308 0.950224 +v -0.709081 0.000438 0.912199 +v -0.743041 0.000438 0.912199 +v -0.747106 2.141308 0.908134 +v -0.705016 2.141308 0.908133 +v -0.743041 0.000438 0.946159 +v -0.747106 2.141308 0.950224 +v -0.709081 0.000438 0.946159 +v -0.705016 2.141308 0.950224 +v -0.840202 2.21792 -0.959208 +v -0.840202 2.21792 -0.840202 +v 0.840201 2.21792 -0.840201 +v 0.840201 2.21792 -0.959208 +v 0.840201 2.21792 0.959208 +v 0.840201 2.21792 0.840201 +v -0.840202 2.21792 0.840201 +v -0.840202 2.21792 0.959208 +v 0.959208 2.21792 -0.840201 +v 0.924352 2.21792 -0.924352 +v -0.959208 2.21792 -0.840201 +v -0.924352 2.21792 -0.924352 +v -0.959208 2.21792 0.840201 +v 0.959208 2.21792 0.840201 +v 0.924352 2.21792 0.924352 +v -0.924352 2.21792 0.924352 +v -0.882369 2.166572 1 +v -0.882094 2.21792 1 +v -0.965466 2.21792 0.965466 +v -0.965547 2.166572 0.965547 +v -1 2.21792 0.882093 +v -1 2.166572 0.882369 +v 0.882093 2.115224 1 +v -0.882094 2.115224 1 +v -0.840201 2.115224 0.959208 +v 0.840201 2.115224 0.959208 +v 0.882369 2.166572 1 +v -1 2.115224 0.882093 +v -1 2.115224 -0.882094 +v -0.959208 2.115224 -0.840201 +v -0.959208 2.115224 0.840201 +v -0.882094 2.115224 -1 +v 0.882093 2.115224 -1 +v 0.840201 2.115224 -0.959208 +v -0.840202 2.115224 -0.959208 +v 0.882368 2.166572 -1 +v -0.882369 2.166572 -1 +v -1 2.166572 -0.882369 +v -0.882094 2.21792 -1 +v -0.965466 2.21792 -0.965466 +v -1 2.21792 -0.882094 +v 1 2.21792 0.882093 +v 0.965466 2.21792 0.965466 +v 0.882093 2.21792 1 +v 1 2.21792 -0.882094 +v 0.965466 2.21792 -0.965466 +v 0.882093 2.21792 -1 +v 1 2.166572 -0.882369 +v 1 2.166572 0.882369 +v 0.924352 2.115224 0.924352 +v 0.965466 2.115224 0.965466 +v 0.959208 2.115224 0.840201 +v 1 2.115224 0.882093 +v 0.965466 2.115224 -0.965466 +v 0.924352 2.115224 -0.924352 +v 1 2.115224 -0.882094 +v 0.959208 2.115224 -0.840201 +v -0.924352 2.115224 0.924352 +v -0.965466 2.115224 0.965466 +v -0.965466 2.115224 -0.965466 +v -0.924352 2.115224 -0.924352 +v -0.965547 2.166572 -0.965547 +v -0.840202 2.115224 -0.840201 +v 0.840201 2.115224 -0.840201 +v 0.840201 2.115224 0.840201 +v -0.840202 2.115224 0.840201 +v 0.965546 2.166572 0.965547 +v 0.965547 2.166572 -0.965547 +v -0.944552 0.876936 0.944552 +v -0.944551 0.944551 0.944552 +v -0.944552 0.944552 -0.944552 +v -0.944552 0.876936 -0.944552 +v 0.944552 0.944552 -0.944552 +v 0.944552 0.876936 -0.944551 +v 0.944552 0.944552 0.944552 +v 0.944551 0.876936 0.944552 +v 0 0.876936 0.944552 +v 0 0.944551 0.944552 +v 0.916489 0.943547 -0.916489 +v 0.916489 0.943547 0.916489 +v -0.916489 0.87794 0.916489 +v 0 0.87794 0.916489 +v 0.916489 0.87794 -0.916489 +v -0.916489 0.87794 -0.916489 +v -0.916489 0.943547 0.916489 +v -0.916489 0.943547 -0.916489 +v 0 0.943547 0.916489 +v 0.916489 0.87794 0.916489 +vt 0.747657 0.404359 +vt 0.750027 0.4369529999999999 +vt 0.694747 0.44097300000000006 +vt 0.692377 0.408378 +vt 0.870245 0.16786499999999993 +vt 0.845239 0.18391599999999997 +vt 0.831262 0.160744 +vt 0.856374 0.145629 +vt 0.804511 0.17525999999999997 +vt 0.768802 0.19030400000000003 +vt 0.768177 0.158192 +vt 0.799624 0.16405500000000006 +vt 0.856374 0.145629 +vt 0.831262 0.160744 +vt 0.825244 0.15026300000000004 +vt 0.844173 0.12529100000000004 +vt 0.760427 0.23835399999999995 +vt 0.744217 0.21661200000000003 +vt 0.768802 0.19030400000000003 +vt 0.789168 0.21990599999999993 +vt 0.831262 0.160744 +vt 0.804511 0.17525999999999997 +vt 0.799624 0.16405500000000006 +vt 0.825244 0.15026300000000004 +vt 0.831262 0.160744 +vt 0.845239 0.18391599999999997 +vt 0.819264 0.200588 +vt 0.804511 0.17525999999999997 +vt 0.774866 0.12882499999999997 +vt 0.768177 0.158192 +vt 0.708422 0.14157799999999998 +vt 0.716796 0.112367 +vt 0.768802 0.19030400000000003 +vt 0.804511 0.17525999999999997 +vt 0.819264 0.200588 +vt 0.789168 0.21990599999999993 +vt 0.774866 0.12882499999999997 +vt 0.805512 0.13536000000000004 +vt 0.799624 0.16405500000000006 +vt 0.768177 0.158192 +vt 0.768802 0.19030400000000003 +vt 0.744217 0.21661200000000003 +vt 0.687809 0.18864800000000004 +vt 0.701429 0.16117500000000007 +vt 0.768177 0.158192 +vt 0.768802 0.19030400000000003 +vt 0.701429 0.16117500000000007 +vt 0.708422 0.14157799999999998 +vt 0.856374 0.145629 +vt 0.844173 0.12529100000000004 +vt 0.89537 0.09457700000000002 +vt 0.907571 0.11491600000000002 +vt 0.797814 0.451928 +vt 0.821345 0.53108 +vt 0.755291 0.544114 +vt 0.749997 0.47428399999999993 +vt 0.749997 0.47428399999999993 +vt 0.755291 0.544114 +vt 0.678839 0.534905 +vt 0.695353 0.47350899999999996 +vt 0.695353 0.47350899999999996 +vt 0.678839 0.534905 +vt 0.628385 0.51679 +vt 0.652771 0.45291100000000006 +vt 0.797814 0.451928 +vt 0.749997 0.47428399999999993 +vt 0.750027 0.4369529999999999 +vt 0.784177 0.42074999999999996 +vt 0.755291 0.544114 +vt 0.748184 0.60312 +vt 0.671732 0.593912 +vt 0.678839 0.534905 +vt 0.749997 0.47428399999999993 +vt 0.695353 0.47350899999999996 +vt 0.694747 0.44097300000000006 +vt 0.750027 0.4369529999999999 +vt 0.695353 0.47350899999999996 +vt 0.652771 0.45291100000000006 +vt 0.665533 0.42645599999999995 +vt 0.694747 0.44097300000000006 +vt 0.747657 0.404359 +vt 0.692377 0.408378 +vt 0.690007 0.375784 +vt 0.745287 0.371764 +vt 0.870245 0.16786499999999993 +vt 0.884685 0.18973600000000002 +vt 0.860485 0.20627300000000004 +vt 0.845239 0.18391599999999997 +vt 0.836148 0.224549 +vt 0.8443 0.23365900000000006 +vt 0.836535 0.26469 +vt 0.8076 0.25075000000000003 +vt 0.884685 0.18973600000000002 +vt 0.898095 0.209299 +vt 0.867508 0.216109 +vt 0.860485 0.20627300000000004 +vt 0.760427 0.23835399999999995 +vt 0.789168 0.21990599999999993 +vt 0.8076 0.25075000000000003 +vt 0.773443 0.262146 +vt 0.860485 0.20627300000000004 +vt 0.867508 0.216109 +vt 0.8443 0.23365900000000006 +vt 0.836148 0.224549 +vt 0.860485 0.20627300000000004 +vt 0.836148 0.224549 +vt 0.819264 0.200588 +vt 0.845239 0.18391599999999997 +vt 0.86602 0.270838 +vt 0.856801 0.330487 +vt 0.826755 0.325936 +vt 0.836535 0.26469 +vt 0.8076 0.25075000000000003 +vt 0.789168 0.21990599999999993 +vt 0.819264 0.200588 +vt 0.836148 0.224549 +vt 0.86602 0.270838 +vt 0.836535 0.26469 +vt 0.8443 0.23365900000000006 +vt 0.872841 0.24025399999999997 +vt 0.8076 0.25075000000000003 +vt 0.806027 0.324133 +vt 0.775377 0.325075 +vt 0.773443 0.262146 +vt 0.836535 0.26469 +vt 0.826755 0.325936 +vt 0.806027 0.324133 +vt 0.8076 0.25075000000000003 +vt 0.884685 0.18973600000000002 +vt 0.933929 0.15598 +vt 0.947339 0.175543 +vt 0.898095 0.209299 +vt 0.797814 0.451928 +vt 0.857846 0.430416 +vt 0.88366 0.511156 +vt 0.821345 0.53108 +vt 0.751401 0.33351299999999995 +vt 0.678423 0.338819 +vt 0.673117 0.265842 +vt 0.746095 0.26053499999999996 +vt 0.603512 0.431601 +vt 0.652771 0.45291100000000006 +vt 0.628385 0.51679 +vt 0.577518 0.497112 +vt 0.797814 0.451928 +vt 0.784177 0.42074999999999996 +vt 0.81935 0.40654 +vt 0.857846 0.430416 +vt 0.741077 0.662127 +vt 0.664625 0.652919 +vt 0.671732 0.593912 +vt 0.748184 0.60312 +vt 0.751401 0.33351299999999995 +vt 0.745287 0.371764 +vt 0.690007 0.375784 +vt 0.678423 0.338819 +vt 0.603512 0.431601 +vt 0.636467 0.413374 +vt 0.665533 0.42645599999999995 +vt 0.652771 0.45291100000000006 +vt 0.910364 0.702108 +vt 0.910364 0.714508 +vt 0.636476 0.714508 +vt 0.636476 0.702108 +vt 0.636476 0.945891 +vt 0.636476 0.933491 +vt 0.910364 0.933491 +vt 0.910364 0.945891 +vt 0.636476 0.702108 +vt 0.636476 0.714508 +vt 0.620967 0.714508 +vt 0.620967 0.702108 +vt 0.925872 0.714508 +vt 0.910364 0.714508 +vt 0.910364 0.702108 +vt 0.925872 0.702108 +vt 0.925872 0.933491 +vt 0.910364 0.933491 +vt 0.910364 0.714508 +vt 0.925872 0.714508 +vt 0.620967 0.933491 +vt 0.636476 0.933491 +vt 0.636476 0.945891 +vt 0.620967 0.945891 +vt 0.910364 0.933491 +vt 0.636476 0.933491 +vt 0.636476 0.714508 +vt 0.910364 0.714508 +vt 0.910364 0.945891 +vt 0.910364 0.933491 +vt 0.925872 0.933491 +vt 0.925872 0.945891 +vt 0.910364 0.966249 +vt 0.910364 0.95331 +vt 0.925872 0.95331 +vt 0.925872 0.966249 +vt 0.951335 0.945891 +vt 0.935152 0.945891 +vt 0.935152 0.933491 +vt 0.951335 0.933491 +vt 0.910364 0.694689 +vt 0.910364 0.68175 +vt 0.925872 0.68175 +vt 0.925872 0.694689 +vt 0.935152 0.702108 +vt 0.951335 0.702108 +vt 0.951335 0.714508 +vt 0.935152 0.714508 +vt 0.611688 0.714508 +vt 0.595505 0.714508 +vt 0.595505 0.702108 +vt 0.611688 0.702108 +vt 0.620967 0.694689 +vt 0.620967 0.68175 +vt 0.636476 0.68175 +vt 0.636476 0.694689 +vt 0.636476 0.95331 +vt 0.636476 0.966249 +vt 0.620967 0.966249 +vt 0.620967 0.95331 +vt 0.611688 0.945891 +vt 0.595505 0.945891 +vt 0.595505 0.933491 +vt 0.611688 0.933491 +vt 0.910364 0.95331 +vt 0.910364 0.945891 +vt 0.925872 0.945891 +vt 0.925872 0.95331 +vt 0.935152 0.945891 +vt 0.925872 0.945891 +vt 0.925872 0.933491 +vt 0.935152 0.933491 +vt 0.910364 0.702108 +vt 0.910364 0.694689 +vt 0.925872 0.694689 +vt 0.925872 0.702108 +vt 0.925872 0.702108 +vt 0.935152 0.702108 +vt 0.935152 0.714508 +vt 0.925872 0.714508 +vt 0.611688 0.933491 +vt 0.620967 0.933491 +vt 0.620967 0.945891 +vt 0.611688 0.945891 +vt 0.620967 0.95331 +vt 0.620967 0.945891 +vt 0.636476 0.945891 +vt 0.636476 0.95331 +vt 0.620967 0.714508 +vt 0.611688 0.714508 +vt 0.611688 0.702108 +vt 0.620967 0.702108 +vt 0.620967 0.702108 +vt 0.620967 0.694689 +vt 0.636476 0.694689 +vt 0.636476 0.702108 +vt 0.951335 0.933491 +vt 0.935152 0.933491 +vt 0.935152 0.714508 +vt 0.951335 0.714508 +vt 0.636476 0.966249 +vt 0.636476 0.95331 +vt 0.910364 0.95331 +vt 0.910364 0.966249 +vt 0.910364 0.95331 +vt 0.636476 0.95331 +vt 0.636476 0.945891 +vt 0.910364 0.945891 +vt 0.935152 0.714508 +vt 0.935152 0.933491 +vt 0.925872 0.933491 +vt 0.925872 0.714508 +vt 0.910364 0.68175 +vt 0.910364 0.694689 +vt 0.636476 0.694689 +vt 0.636476 0.68175 +vt 0.636476 0.694689 +vt 0.910364 0.694689 +vt 0.910364 0.702108 +vt 0.636476 0.702108 +vt 0.595505 0.714508 +vt 0.611688 0.714508 +vt 0.611688 0.933491 +vt 0.595505 0.933491 +vt 0.611688 0.933491 +vt 0.611688 0.714508 +vt 0.620967 0.714508 +vt 0.620967 0.933491 +vt 0.620967 0.714508 +vt 0.636476 0.714508 +vt 0.636476 0.933491 +vt 0.620967 0.933491 +vt 0.042403 0.00003799999999998249 +vt 0.042403 0.999962 +vt 0.007001 0.999962 +vt 0.007001 0.00003799999999998249 +vt 0.148608 0.00003799999999998249 +vt 0.148608 0.999962 +vt 0.113206 0.999962 +vt 0.113206 0.00003799999999998249 +vt 0.113206 0.00003799999999998249 +vt 0.113206 0.999962 +vt 0.077805 0.999962 +vt 0.077805 0.00003799999999998249 +vt 0.077805 0.00003799999999998249 +vt 0.077805 0.999962 +vt 0.042403 0.999962 +vt 0.042403 0.00003799999999998249 +vt 0.042403 0.00003799999999998249 +vt 0.007001 0.00003799999999998249 +vt 0.007001 0.999962 +vt 0.042403 0.999962 +vt 0.148608 0.00003799999999998249 +vt 0.113206 0.00003799999999998249 +vt 0.113206 0.999962 +vt 0.148608 0.999962 +vt 0.113206 0.00003799999999998249 +vt 0.077805 0.00003799999999998249 +vt 0.077805 0.999962 +vt 0.113206 0.999962 +vt 0.077805 0.00003799999999998249 +vt 0.042403 0.00003799999999998249 +vt 0.042403 0.999962 +vt 0.077805 0.999962 +vt 0.042403 0.00003799999999998249 +vt 0.007001 0.00003799999999998249 +vt 0.007001 0.999962 +vt 0.042403 0.999962 +vt 0.148608 0.00003799999999998249 +vt 0.113206 0.00003799999999998249 +vt 0.113206 0.999962 +vt 0.148608 0.999962 +vt 0.113206 0.00003799999999998249 +vt 0.077805 0.00003799999999998249 +vt 0.077805 0.999962 +vt 0.113206 0.999962 +vt 0.077805 0.00003799999999998249 +vt 0.042403 0.00003799999999998249 +vt 0.042403 0.999962 +vt 0.077805 0.999962 +vt 0.042403 0.00003799999999998249 +vt 0.042403 0.999962 +vt 0.007001 0.999962 +vt 0.007001 0.00003799999999998249 +vt 0.148608 0.00003799999999998249 +vt 0.148608 0.999962 +vt 0.113206 0.999962 +vt 0.113206 0.00003799999999998249 +vt 0.113206 0.00003799999999998249 +vt 0.113206 0.999962 +vt 0.077805 0.999962 +vt 0.077805 0.00003799999999998249 +vt 0.077805 0.00003799999999998249 +vt 0.077805 0.999962 +vt 0.042403 0.999962 +vt 0.042403 0.00003799999999998249 +vt 0.972176 0.06626900000000002 +vt 0.181216 0.07276099999999996 +vt 0.181113 0.05595799999999995 +vt 0.972093 0.05271300000000001 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181078 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.02235200000000004 +vt 0.972093 0.02559800000000001 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181216 0.005550000000000055 +vt 0.972176 0.012040999999999968 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.16431 0.05599299999999996 +vt 0.164275 0.03919000000000006 +vt 0.972176 0.06626900000000002 +vt 0.181216 0.07276099999999996 +vt 0.181113 0.05595799999999995 +vt 0.972093 0.05271300000000001 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181078 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.02235200000000004 +vt 0.972093 0.02559800000000001 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181216 0.005550000000000055 +vt 0.972176 0.012040999999999968 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.16431 0.05599299999999996 +vt 0.164275 0.03919000000000006 +vt 0.972176 0.06626900000000002 +vt 0.181216 0.07276099999999996 +vt 0.181113 0.05595799999999995 +vt 0.972093 0.05271300000000001 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181078 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.02235200000000004 +vt 0.972093 0.02559800000000001 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181216 0.005550000000000055 +vt 0.972176 0.012040999999999968 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.16431 0.05599299999999996 +vt 0.164275 0.03919000000000006 +vt 0.972176 0.06626900000000002 +vt 0.181216 0.07276099999999996 +vt 0.181113 0.05595799999999995 +vt 0.972093 0.05271300000000001 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181078 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.02235200000000004 +vt 0.972093 0.02559800000000001 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181216 0.005550000000000055 +vt 0.972176 0.012040999999999968 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.16431 0.05599299999999996 +vt 0.164275 0.03919000000000006 +vt 0.972176 0.06626900000000002 +vt 0.181216 0.07276099999999996 +vt 0.181113 0.05595799999999995 +vt 0.972093 0.05271300000000001 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181078 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.02235200000000004 +vt 0.972093 0.02559800000000001 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181216 0.005550000000000055 +vt 0.972176 0.012040999999999968 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.16431 0.05599299999999996 +vt 0.164275 0.03919000000000006 +vt 0.972176 0.06626900000000002 +vt 0.181216 0.07276099999999996 +vt 0.181113 0.05595799999999995 +vt 0.972093 0.05271300000000001 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181078 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.02235200000000004 +vt 0.972093 0.02559800000000001 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181216 0.005550000000000055 +vt 0.972176 0.012040999999999968 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.16431 0.05599299999999996 +vt 0.164275 0.03919000000000006 +vt 0.972176 0.06626900000000002 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181216 0.07276099999999996 +vt 0.972093 0.05271300000000001 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.972065 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.972176 0.012040999999999968 +vt 0.181216 0.005550000000000055 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.164275 0.03919000000000006 +vt 0.16431 0.05599299999999996 +vt 0.181113 0.05595799999999995 +vt 0.972176 0.06626900000000002 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181216 0.07276099999999996 +vt 0.972093 0.05271300000000001 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.972065 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.972176 0.012040999999999968 +vt 0.181216 0.005550000000000055 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.164275 0.03919000000000006 +vt 0.16431 0.05599299999999996 +vt 0.181113 0.05595799999999995 +vt 0.972176 0.06626900000000002 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181216 0.07276099999999996 +vt 0.972093 0.05271300000000001 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.972065 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.972176 0.012040999999999968 +vt 0.181216 0.005550000000000055 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.164275 0.03919000000000006 +vt 0.16431 0.05599299999999996 +vt 0.181113 0.05595799999999995 +vt 0.972176 0.06626900000000002 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181216 0.07276099999999996 +vt 0.972093 0.05271300000000001 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.972065 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.972176 0.012040999999999968 +vt 0.181216 0.005550000000000055 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.164275 0.03919000000000006 +vt 0.16431 0.05599299999999996 +vt 0.181113 0.05595799999999995 +vt 0.972176 0.06626900000000002 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181216 0.07276099999999996 +vt 0.972093 0.05271300000000001 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.972065 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.972176 0.012040999999999968 +vt 0.181216 0.005550000000000055 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.164275 0.03919000000000006 +vt 0.16431 0.05599299999999996 +vt 0.181113 0.05595799999999995 +vt 0.972176 0.06606299999999998 +vt 0.972093 0.05250599999999994 +vt 0.181113 0.055752000000000024 +vt 0.181216 0.07255500000000004 +vt 0.972093 0.05250599999999994 +vt 0.972065 0.03894900000000001 +vt 0.181078 0.03894900000000001 +vt 0.181113 0.055752000000000024 +vt 0.972065 0.03894900000000001 +vt 0.972093 0.02539199999999997 +vt 0.181113 0.022146 +vt 0.181078 0.03894900000000001 +vt 0.972093 0.02539199999999997 +vt 0.972176 0.01183500000000004 +vt 0.181216 0.005342999999999987 +vt 0.181113 0.022146 +vt 0.181078 0.03894900000000001 +vt 0.164275 0.03898400000000002 +vt 0.16431 0.05578700000000003 +vt 0.181113 0.055752000000000024 +vt 0.972176 0.06626900000000002 +vt 0.181216 0.07276099999999996 +vt 0.181113 0.05595799999999995 +vt 0.972093 0.05271300000000001 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181078 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.02235200000000004 +vt 0.972093 0.02559800000000001 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181216 0.005550000000000055 +vt 0.972176 0.012040999999999968 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.16431 0.05599299999999996 +vt 0.164275 0.03919000000000006 +vt 0.972176 0.06626900000000002 +vt 0.181216 0.07276099999999996 +vt 0.181113 0.05595799999999995 +vt 0.972093 0.05271300000000001 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181078 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.02235200000000004 +vt 0.972093 0.02559800000000001 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181216 0.005550000000000055 +vt 0.972176 0.012040999999999968 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.16431 0.05599299999999996 +vt 0.164275 0.03919000000000006 +vt 0.972176 0.06626900000000002 +vt 0.181216 0.07276099999999996 +vt 0.181113 0.05595799999999995 +vt 0.972093 0.05271300000000001 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181078 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.02235200000000004 +vt 0.972093 0.02559800000000001 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181216 0.005550000000000055 +vt 0.972176 0.012040999999999968 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.16431 0.05599299999999996 +vt 0.164275 0.03919000000000006 +vt 0.972176 0.06626900000000002 +vt 0.181216 0.07276099999999996 +vt 0.181113 0.05595799999999995 +vt 0.972093 0.05271300000000001 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181078 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.02235200000000004 +vt 0.972093 0.02559800000000001 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181216 0.005550000000000055 +vt 0.972176 0.012040999999999968 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.16431 0.05599299999999996 +vt 0.164275 0.03919000000000006 +vt 0.972176 0.06626900000000002 +vt 0.181216 0.07276099999999996 +vt 0.181113 0.05595799999999995 +vt 0.972093 0.05271300000000001 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181078 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.02235200000000004 +vt 0.972093 0.02559800000000001 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181216 0.005550000000000055 +vt 0.972176 0.012040999999999968 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.16431 0.05599299999999996 +vt 0.164275 0.03919000000000006 +vt 0.972176 0.06626900000000002 +vt 0.181216 0.07276099999999996 +vt 0.181113 0.05595799999999995 +vt 0.972093 0.05271300000000001 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181078 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.02235200000000004 +vt 0.972093 0.02559800000000001 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181216 0.005550000000000055 +vt 0.972176 0.012040999999999968 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.16431 0.05599299999999996 +vt 0.164275 0.03919000000000006 +vt 0.972176 0.06626900000000002 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181216 0.07276099999999996 +vt 0.972093 0.05271300000000001 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.972065 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.972176 0.012040999999999968 +vt 0.181216 0.005550000000000055 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.164275 0.03919000000000006 +vt 0.16431 0.05599299999999996 +vt 0.181113 0.05595799999999995 +vt 0.972176 0.06626900000000002 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181216 0.07276099999999996 +vt 0.972093 0.05271300000000001 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.972065 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.972176 0.012040999999999968 +vt 0.181216 0.005550000000000055 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.164275 0.03919000000000006 +vt 0.16431 0.05599299999999996 +vt 0.181113 0.05595799999999995 +vt 0.972176 0.06626900000000002 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181216 0.07276099999999996 +vt 0.972093 0.05271300000000001 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.972065 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.972176 0.012040999999999968 +vt 0.181216 0.005550000000000055 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.164275 0.03919000000000006 +vt 0.16431 0.05599299999999996 +vt 0.181113 0.05595799999999995 +vt 0.972176 0.06626900000000002 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181216 0.07276099999999996 +vt 0.972093 0.05271300000000001 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.972065 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.972176 0.012040999999999968 +vt 0.181216 0.005550000000000055 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.164275 0.03919000000000006 +vt 0.16431 0.05599299999999996 +vt 0.181113 0.05595799999999995 +vt 0.972176 0.06626900000000002 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181216 0.07276099999999996 +vt 0.972093 0.05271300000000001 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.972065 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.972176 0.012040999999999968 +vt 0.181216 0.005550000000000055 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.164275 0.03919000000000006 +vt 0.16431 0.05599299999999996 +vt 0.181113 0.05595799999999995 +vt 0.972176 0.06626900000000002 +vt 0.972093 0.05271300000000001 +vt 0.181113 0.05595799999999995 +vt 0.181216 0.07276099999999996 +vt 0.972093 0.05271300000000001 +vt 0.972065 0.03915500000000005 +vt 0.181078 0.03915500000000005 +vt 0.181113 0.05595799999999995 +vt 0.972065 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.972093 0.02559800000000001 +vt 0.972176 0.012040999999999968 +vt 0.181216 0.005550000000000055 +vt 0.181113 0.02235200000000004 +vt 0.181078 0.03915500000000005 +vt 0.164275 0.03919000000000006 +vt 0.16431 0.05599299999999996 +vt 0.181113 0.05595799999999995 +vt 0.471498 0.8734 +vt 0.460377 0.873803 +vt 0.455889 0.718446 +vt 0.467047 0.718402 +vt 0.289851 0.723858 +vt 0.300878 0.723419 +vt 0.305445 0.878047 +vt 0.294379 0.878059 +vt 0.467047 0.718402 +vt 0.455889 0.718446 +vt 0.455377 0.707283 +vt 0.46392 0.709924 +vt 0.460379 0.884899 +vt 0.460377 0.873803 +vt 0.471498 0.8734 +vt 0.468812 0.881798 +vt 0.305843 0.889139 +vt 0.305445 0.878047 +vt 0.460377 0.873803 +vt 0.460379 0.884899 +vt 0.300766 0.712397 +vt 0.300878 0.723419 +vt 0.289851 0.723858 +vt 0.292505 0.715532 +vt 0.305445 0.878047 +vt 0.300878 0.723419 +vt 0.455889 0.718446 +vt 0.460377 0.873803 +vt 0.294379 0.878059 +vt 0.305445 0.878047 +vt 0.305843 0.889139 +vt 0.297477 0.886459 +vt 0.189055 0.891793 +vt 0.290524 0.881817 +vt 0.29263 0.890386 +vt 0.189957 0.900742 +vt 0.285481 0.989644 +vt 0.293947 0.891308 +vt 0.302336 0.893037 +vt 0.294052 0.990381 +vt 0.461161 0.297999 +vt 0.461161 0.46638599999999997 +vt 0.459488 0.46012300000000006 +vt 0.455036 0.305125 +vt 0.561097 0.46638599999999997 +vt 0.461161 0.46638599999999997 +vt 0.461161 0.297999 +vt 0.561097 0.297999 +vt 0.452133 0.4754940000000001 +vt 0.290325 0.47975999999999996 +vt 0.293832 0.475862 +vt 0.448369 0.471622 +vt 0.278514 0.46853999999999996 +vt 0.27395 0.30709699999999995 +vt 0.27784 0.310581 +vt 0.282368 0.46478200000000003 +vt 0.176917 0.2989919999999999 +vt 0.27395 0.30709699999999995 +vt 0.278514 0.46853999999999996 +vt 0.177045 0.47851599999999994 +vt 0.282041 0.577104 +vt 0.290325 0.47975999999999996 +vt 0.452133 0.4754940000000001 +vt 0.461998 0.577271 +vt 0.290524 0.881817 +vt 0.294379 0.878059 +vt 0.297477 0.886459 +vt 0.29263 0.890386 +vt 0.293947 0.891308 +vt 0.297477 0.886459 +vt 0.305843 0.889139 +vt 0.302336 0.893037 +vt 0.471498 0.8734 +vt 0.475413 0.876916 +vt 0.473683 0.885339 +vt 0.468812 0.881798 +vt 0.468812 0.881798 +vt 0.472746 0.886669 +vt 0.464144 0.888771 +vt 0.460379 0.884899 +vt 0.296963 0.70857 +vt 0.300766 0.712397 +vt 0.292505 0.715532 +vt 0.288632 0.710864 +vt 0.287674 0.712017 +vt 0.292505 0.715532 +vt 0.289851 0.723858 +vt 0.285961 0.720375 +vt 0.455377 0.707283 +vt 0.458847 0.703341 +vt 0.467502 0.704864 +vt 0.46392 0.709924 +vt 0.46392 0.709924 +vt 0.468813 0.705972 +vt 0.47093 0.714613 +vt 0.467047 0.718402 +vt 0.294052 0.990381 +vt 0.302336 0.893037 +vt 0.464144 0.888771 +vt 0.474009 0.990548 +vt 0.188927 0.712269 +vt 0.285961 0.720375 +vt 0.290524 0.881817 +vt 0.189055 0.891793 +vt 0.290524 0.881817 +vt 0.285961 0.720375 +vt 0.289851 0.723858 +vt 0.294379 0.878059 +vt 0.464144 0.888771 +vt 0.302336 0.893037 +vt 0.305843 0.889139 +vt 0.460379 0.884899 +vt 0.573107 0.885156 +vt 0.475413 0.876916 +vt 0.47093 0.714613 +vt 0.573107 0.704603 +vt 0.47093 0.714613 +vt 0.475413 0.876916 +vt 0.471498 0.8734 +vt 0.467047 0.718402 +vt 0.462499 0.601328 +vt 0.458847 0.703341 +vt 0.296963 0.70857 +vt 0.282764 0.611626 +vt 0.296963 0.70857 +vt 0.458847 0.703341 +vt 0.455377 0.707283 +vt 0.300766 0.712397 +vt 0.455377 0.707283 +vt 0.455889 0.718446 +vt 0.300878 0.723419 +vt 0.300766 0.712397 +vt 0.45191 0.296647 +vt 0.456802 0.2926949999999999 +vt 0.461161 0.297999 +vt 0.455036 0.305125 +vt 0.443366 0.294006 +vt 0.446836 0.290064 +vt 0.455491 0.29158700000000004 +vt 0.45191 0.296647 +vt 0.275663 0.29874 +vt 0.280495 0.30225499999999994 +vt 0.27784 0.310581 +vt 0.27395 0.30709699999999995 +vt 0.284952 0.295293 +vt 0.288756 0.29912000000000005 +vt 0.280495 0.30225499999999994 +vt 0.276621 0.29758700000000005 +vt 0.456801 0.46852099999999997 +vt 0.460735 0.47339200000000003 +vt 0.452133 0.4754940000000001 +vt 0.448369 0.471622 +vt 0.459488 0.46012300000000006 +vt 0.461161 0.46638599999999997 +vt 0.461672 0.472062 +vt 0.456801 0.46852099999999997 +vt 0.281936 0.478031 +vt 0.285467 0.473182 +vt 0.293832 0.475862 +vt 0.290325 0.47975999999999996 +vt 0.278514 0.46853999999999996 +vt 0.282368 0.46478200000000003 +vt 0.285467 0.473182 +vt 0.280619 0.477109 +vt 0.27347 0.576366 +vt 0.281936 0.478031 +vt 0.290325 0.47975999999999996 +vt 0.282041 0.577104 +vt 0.177045 0.47851599999999994 +vt 0.278514 0.46853999999999996 +vt 0.280619 0.477109 +vt 0.177947 0.48746500000000004 +vt 0.282368 0.46478200000000003 +vt 0.293434 0.46477 +vt 0.293832 0.475862 +vt 0.285467 0.473182 +vt 0.293434 0.46477 +vt 0.288867 0.3101419999999999 +vt 0.443878 0.305169 +vt 0.448366 0.460526 +vt 0.288756 0.29912000000000005 +vt 0.288867 0.3101419999999999 +vt 0.27784 0.310581 +vt 0.280495 0.30225499999999994 +vt 0.293832 0.475862 +vt 0.293434 0.46477 +vt 0.448366 0.460526 +vt 0.448369 0.471622 +vt 0.448369 0.471622 +vt 0.448366 0.460526 +vt 0.459488 0.46012300000000006 +vt 0.456801 0.46852099999999997 +vt 0.455036 0.305125 +vt 0.443878 0.305169 +vt 0.443366 0.294006 +vt 0.45191 0.296647 +vt 0.288632 0.710864 +vt 0.274224 0.612881 +vt 0.282764 0.611626 +vt 0.296963 0.70857 +vt 0.285961 0.720375 +vt 0.188927 0.712269 +vt 0.18965 0.703726 +vt 0.287674 0.712017 +vt 0.468813 0.705972 +vt 0.572203 0.695592 +vt 0.573107 0.704603 +vt 0.47093 0.714613 +vt 0.458847 0.703341 +vt 0.462499 0.601328 +vt 0.471492 0.601676 +vt 0.467502 0.704864 +vt 0.472746 0.886669 +vt 0.482985 0.989656 +vt 0.474009 0.990548 +vt 0.464144 0.888771 +vt 0.475413 0.876916 +vt 0.573107 0.885156 +vt 0.572374 0.893757 +vt 0.473683 0.885339 +vt 0.27784 0.310581 +vt 0.288867 0.3101419999999999 +vt 0.293434 0.46477 +vt 0.282368 0.46478200000000003 +vt 0.459488 0.46012300000000006 +vt 0.448366 0.460526 +vt 0.443878 0.305169 +vt 0.455036 0.305125 +vt 0.450488 0.18805099999999997 +vt 0.446836 0.290064 +vt 0.284952 0.295293 +vt 0.270753 0.198349 +vt 0.284952 0.295293 +vt 0.446836 0.290064 +vt 0.443366 0.294006 +vt 0.288756 0.29912000000000005 +vt 0.443366 0.294006 +vt 0.443878 0.305169 +vt 0.288867 0.3101419999999999 +vt 0.288756 0.29912000000000005 +vt 0.276621 0.29758700000000005 +vt 0.262213 0.19960500000000003 +vt 0.270753 0.198349 +vt 0.284952 0.295293 +vt 0.27395 0.30709699999999995 +vt 0.176917 0.2989919999999999 +vt 0.177639 0.29044899999999996 +vt 0.275663 0.29874 +vt 0.456802 0.2926949999999999 +vt 0.560193 0.28231399999999995 +vt 0.561097 0.297999 +vt 0.461161 0.297999 +vt 0.446836 0.290064 +vt 0.450488 0.18805099999999997 +vt 0.459481 0.18839899999999998 +vt 0.455491 0.29158700000000004 +vt 0.460735 0.47339200000000003 +vt 0.470974 0.576379 +vt 0.461998 0.577271 +vt 0.452133 0.4754940000000001 +vt 0.461161 0.46638599999999997 +vt 0.561097 0.46638599999999997 +vt 0.560364 0.48048 +vt 0.461672 0.472062 +vt 0.980369 0.19466499999999998 +vt 0.98777 0.19466499999999998 +vt 0.98777 0.398161 +vt 0.980369 0.398161 +vt 0.980369 0.398161 +vt 0.98777 0.398161 +vt 0.98777 0.601656 +vt 0.980369 0.601656 +vt 0.980369 0.601656 +vt 0.98777 0.601656 +vt 0.98777 0.805152 +vt 0.980369 0.805152 +vt 0.980369 0.09291700000000003 +vt 0.98777 0.09291700000000003 +vt 0.98777 0.19466499999999998 +vt 0.980369 0.19466499999999998 +vt 0.98777 0.805152 +vt 0.98777 0.601656 +vt 0.991862 0.601656 +vt 0.991862 0.805152 +vt 0.980369 0.09291700000000003 +vt 0.980369 0.19466499999999998 +vt 0.976278 0.19466499999999998 +vt 0.976278 0.09291700000000003 +vt 0.980369 0.398161 +vt 0.980369 0.601656 +vt 0.976278 0.601656 +vt 0.976278 0.398161 +vt 0.98777 0.398161 +vt 0.98777 0.19466499999999998 +vt 0.991862 0.19466499999999998 +vt 0.991862 0.398161 +vt 0.98777 0.9069 +vt 0.98777 0.805152 +vt 0.991862 0.805152 +vt 0.991862 0.9069 +vt 0.980369 0.19466499999999998 +vt 0.980369 0.398161 +vt 0.976278 0.398161 +vt 0.976278 0.19466499999999998 +vt 0.980369 0.601656 +vt 0.980369 0.805152 +vt 0.976278 0.805152 +vt 0.976278 0.601656 +vt 0.98777 0.601656 +vt 0.98777 0.398161 +vt 0.991862 0.398161 +vt 0.991862 0.601656 +vt 0.969097 0.9069 +vt 0.969097 0.805152 +vt 0.976278 0.805152 +vt 0.976278 0.9069 +vt 0.969097 0.805152 +vt 0.969097 0.601656 +vt 0.976278 0.601656 +vt 0.976278 0.805152 +vt 0.969097 0.601656 +vt 0.969097 0.398161 +vt 0.976278 0.398161 +vt 0.976278 0.601656 +vt 0.969097 0.398161 +vt 0.969097 0.19466499999999998 +vt 0.976278 0.19466499999999998 +vt 0.976278 0.398161 +vt 0.969097 0.19466499999999998 +vt 0.969097 0.09291700000000003 +vt 0.976278 0.09291700000000003 +vt 0.976278 0.19466499999999998 +vt 0.98777 0.19466499999999998 +vt 0.98777 0.09291700000000003 +vt 0.991862 0.09291700000000003 +vt 0.991862 0.19466499999999998 +vt 0.980369 0.805152 +vt 0.980369 0.9069 +vt 0.976278 0.9069 +vt 0.976278 0.805152 +vt 0.980369 0.805152 +vt 0.98777 0.805152 +vt 0.98777 0.9069 +vt 0.980369 0.9069 +vn -0.5434723587581247 -0.7071111902229937 -0.4523621999319811 +vn -0.5434711851981038 0.7071049333756506 -0.45237339008299887 +vn 0.5434727877514994 -0.7071080647682157 0.45236657006609404 +vn -0.543465365516027 0.7071098394771812 -0.45237271292495806 +vn -0.5434782317949073 -0.7071021707152731 -0.4523692426932116 +vn 0.5434715546109692 0.7071157333692065 0.4523560643458489 +vn 0.5434628844076868 0.707115733399127 0.45236648068871355 +vn 0.6397452936136582 6.525957264131172e-7 -0.7685869887649338 +vn 0.543468778468858 -0.7071080647659087 0.4523713867748255 +vn 0.7961854510865565 0.263513689884752 -0.5446551778156706 +vn -0.6397485798018452 0.000011742494705429444 0.7685842533539416 +vn 0.5434724156846934 -0.7071085488574925 0.452366260371499 +vn -0.5434698444228147 0.707104813839094 -0.45237518770222335 +vn -0.5434727503683903 0.7071067735534564 -0.4523686333089844 +vn 0.6397450967817299 -0.0000022232177211036344 -0.7685871525980594 +vn 0.5434752879981141 -0.707103471854114 0.4523707455475268 +vn -0.6438760764306906 0.5460685123821359 -0.5359410209948299 +vn 0.5434709053232316 0.7071091741145803 0.4523670975547884 +vn 0.15337566007433742 -0.44618078879251083 -0.8817021099040504 +vn 0.41968846893015516 -0.837751063851812 0.34933471637130464 +vn -0.5434752020053015 -0.7071063697405466 -0.4523663191238294 +vn -0.5434628844076859 0.7071157333991275 -0.4523664806887138 +vn 0.5434825198845655 -0.7071014667876421 0.4523651912412007 +vn -0.543469024705116 0.7071129607680916 -0.45236343784600164 +vn -0.5434879173104056 -0.7070972645912774 -0.45236527513186814 +vn 0.543465365516026 0.7071098394771824 0.4523727129249573 +vn 0.5434711851981034 0.7071049333756495 0.45237339008300104 +vn -0.6397463675596605 7.447034826133101e-7 0.7685860948479781 +vn 0.5434796164626724 -0.7071080647082109 0.4523583660265338 +vn -0.39114849222740095 0.2634987179341678 0.8818000241978229 +vn 0.6397463675566369 -7.447074166126956e-7 -0.7685860948504949 +vn 0.5434724156846931 -0.7071085488574929 0.45236626037149896 +vn -0.5434752863616862 0.7071048138552949 -0.4523686498164433 +vn -0.5434662018697339 0.7071114233963331 -0.4523692322955857 +vn -0.6397445514418302 -0.000005663814590385804 0.7685876065019607 +vn 0.5434752879981143 -0.7071034718541142 0.45237074554752665 +vn -0.64387609562415 0.5460634194589714 -0.5359461870491999 +vn 0.543473381331579 0.7071091741068769 0.452364122889858 +vn -0.8392370487177734 -0.4461842455044445 0.3108066844891076 +vn 0.4196932663932536 -0.8377483976328659 0.3493353466337567 +vn 0 1 0 +vn 5.950953431798915e-7 0.9999999999645189 -0.000008402862057443479 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn -0.000008402867057062045 0.9999999999293914 -0.000008402937665830153 +vn 5.950953432007952e-7 0.9999999999996458 5.950953432007952e-7 +vn 0 1 0 +vn -0.3826804608808248 0.0020717135553043583 0.9238784405228776 +vn -0.9238784499323498 0.002066791629593295 0.38268046477833007 +vn -0.38268561357207315 0.002071741450379746 -0.9238763061429429 +vn -0.9238763155966682 0.002066796404234502 -0.3826856174879705 +vn 0.9238763061429429 0.002071741450379746 -0.38268561357207315 +vn 0.3826856174879704 0.002066796404234502 -0.9238763155966682 +vn 0.38268561653428607 0.0020680018186035698 0.9238763132942915 +vn 0.9238763155966682 0.002066796404234502 0.3826856174879705 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn -1 0 0 +vn 0 0 1 +vn 5.668333343996174e-7 0.9999999997134577 0.000023932478202501798 +vn 0 1 0 +vn 0 0 -1 +vn 0 1 0 +vn 1 0 0 +vn 0 1 0 +vn 0 0.9999999999998229 5.950956973395789e-7 +vn -1 0 0 +vn 0 0 -1 +vn 1 0 0 +vn 0 0 1 +vn 1 0 0 +vn 0 0 -1 +vn -1 0 0 +vn 0 0 1 +vn -1 0 0 +vn -0.000012776613685633141 0 0.9999999999183792 +vn 1 0 0 +vn 0 0 -1 +vn 0.999999999918279 4.5234622939881136e-7 -0.000012776450445860229 +vn 0 0 1 +vn -1 0 0 +vn 0 0 -1 +vn -0.9999981973603006 -0.0018987564744767935 0 +vn 0 -0.0018987564744767935 -0.9999981973603006 +vn 0.9999981973603006 -0.0018987564744767935 0 +vn 0 -0.0018987573613824633 0.9999981973586167 +vn 0 1 0 +vn -0.9999981973603006 -0.0018987564744767933 0 +vn 0 -0.0018992235715558745 -0.9999981964732862 +vn 0.9999981973586167 -0.0018987573613824108 0 +vn 0 -0.0018992235715558745 0.9999981964732862 +vn 0 1 0 +vn -0.9999981973603006 -0.0018987564744767935 0 +vn 0 -0.0018987564744768063 -0.9999981973603006 +vn 0.9999981973603006 -0.0018987564744767933 0 +vn 0 -0.0018992235715558745 0.9999981964732862 +vn 0 1 0 +vn -0.9999981973603006 -0.0018987564744767935 0 +vn 0 -0.0018987564744767935 -0.9999981973603006 +vn 0.9999981973603006 -0.0018987564744767935 0 +vn 0 -0.0018987564744768 0.9999981973603006 +vn 0 1 0 +vn -0.9999981973603006 -0.0018987564744767935 0 +vn 0 -0.0018992235715558482 -0.9999981964732862 +vn 0.9999981973603006 -0.0018987564744767928 0 +vn 0 -0.0018992235715558745 0.9999981964732862 +vn 0 1 0 +vn -0.9999981973586167 -0.001898757361382411 0 +vn 0 -0.0018987564744768455 -0.9999981973603006 +vn 0.9999981973603006 -0.0018987564744767935 0 +vn 0 -0.0018987564744767935 0.9999981973603006 +vn 0 1 0 +vn 0.9999981964716016 -0.0018992244586796703 0 +vn 5.5925308167476934e-8 -0.00189922346536747 -0.9999981964734864 +vn -0.9999981973584136 -0.0018987574675447523 5.5911586205677233e-8 +vn 0 -0.0018987573613824628 0.9999981973586167 +vn 0 1 0 +vn 0.9999981973584134 -0.0018987574675739954 -5.59132326503737e-8 +vn 0 -0.0018992244586796703 -0.9999981964716016 +vn -0.9999981973586167 -0.0018987573613824112 0 +vn -5.5925308166709245e-8 -0.001899223465341399 0.9999981964734864 +vn 0 1 0 +vn 0.9999981973605007 -0.0018987563683145572 5.591155383729693e-8 +vn -5.5911586205677624e-8 -0.0018987574675447653 -0.9999981973584136 +vn -0.9999981973605006 -0.0018987563682884405 -5.5911553836527884e-8 +vn 5.5911586205677624e-8 -0.0018987574675447653 0.9999981973584136 +vn 0 1 0 +vn 0.9999981973584136 -0.0018987574675447523 -5.5911586205677214e-8 +vn 5.591155383729693e-8 -0.0018987563683145572 -0.9999981973605007 +vn -0.9999981973584136 -0.0018987574675447523 5.5911586205677214e-8 +vn -5.591155383729713e-8 -0.0018987563683145637 0.9999981973605007 +vn 0 1 0 +vn 0.9999981973605008 -0.001898756368314557 5.591155383729684e-8 +vn -5.592534054382062e-8 -0.0018992245648681014 -0.9999981964713982 +vn -0.9999981973605007 -0.001898756368285314 -5.591320027931797e-8 +vn -0.000029390444508424414 -0.0018987015553400678 0.9999981970326774 +vn 0 1 0 +vn 0.9999981973584134 -0.0018987574675739952 -5.59132326503736e-8 +vn 0.00002950226435233633 -0.001898700456136048 -0.9999981970314719 +vn -0.9999981973584136 -0.0018987574675447523 5.5911586205677233e-8 +vn -5.5925308166709245e-8 -0.001899223465341399 0.9999981964734864 +vn 0 1 0 +vn 0 -0.001898757361382411 0.9999981973586167 +vn -0.9999981973603006 -0.0018987564744767935 0 +vn 0 -0.001898757361382411 -0.9999981973586167 +vn 0.9999981973586167 -0.0018987573613824633 0 +vn 0 1 0 +vn 0 -0.0018987573613824108 0.9999981973586167 +vn -0.9999981964716015 -0.0018992244586796706 0 +vn 0 -0.001898757361382411 -0.9999981973586167 +vn 0.9999981964716015 -0.0018992244586796443 0 +vn 0 1 0 +vn 0 -0.0018987564744767935 0.9999981973603006 +vn -0.9999981973603006 -0.0018987564744768063 0 +vn 0 -0.0018987564744767933 -0.9999981973603007 +vn 0.9999981973586167 -0.0018987573613824112 0 +vn 0 1 0 +vn 0 -0.0018987564744767933 0.9999981973603007 +vn -0.9999981973586167 -0.0018987573613824112 0 +vn 0 -0.0018987573613824108 -0.9999981973586167 +vn 0.9999981973586167 -0.0018987573613824243 0 +vn 0 1 0 +vn 0 -0.001898757361382411 0.9999981973586167 +vn -0.9999981964732862 -0.0018992235715558482 0 +vn 0 -0.0018987564744767928 -0.9999981973603006 +vn 0.9999981964732862 -0.0018992235715558745 0 +vn 0 1 0 +vn 0 -0.001898757361382411 0.9999981973586167 +vn -0.9999981973603006 -0.0018987564744768455 0 +vn 0 -0.0018987564744767935 -0.9999981973603006 +vn 0.9999981973603006 -0.0018987564744767935 0 +vn 0 1 0 +vn 5.591155383729693e-8 -0.0018987563683145572 -0.9999981973605007 +vn -0.9999981964713982 -0.0018992245648681272 5.592534054382138e-8 +vn 0 -0.001898757361382411 0.9999981973586167 +vn 0.9999981973586167 -0.0018987573613824628 0 +vn 0 1 0 +vn 0 -0.0018987573613824112 -0.9999981973586167 +vn -0.9999981964716016 -0.0018992244586796703 0 +vn -5.591320027931797e-8 -0.001898756368285314 0.9999981973605007 +vn 0.9999981964713982 -0.0018992245648681014 -5.592534054382062e-8 +vn 0 1 0 +vn -5.5911586205677233e-8 -0.0018987574675447523 -0.9999981973584136 +vn -0.9999981973586167 -0.001898757361382424 0 +vn 0 -0.001898757361382411 0.9999981973586167 +vn 0.9999981973605007 -0.0018987563683145572 5.591155383729693e-8 +vn 0 1 0 +vn 0 -0.001898757361382411 -0.9999981973586167 +vn -0.9999981973586167 -0.001898757361382411 0 +vn 0 -0.001898757361382411 0.9999981973586167 +vn 0.9999981973586167 -0.001898757361382424 0 +vn 0 1 0 +vn 5.592695501452684e-8 -0.0018992234653382716 -0.9999981964734864 +vn -0.9999981964713982 -0.0018992245648681014 5.592534054382062e-8 +vn 0 -0.0018987573613824112 0.9999981973586167 +vn 0.9999981964716016 -0.0018992244586796703 0 +vn 0 1 0 +vn 0 -0.001898757361382411 -0.9999981973586167 +vn -0.9999981973586167 -0.0018987573613824628 0 +vn 0 -0.001898757361382411 0.9999981973586167 +vn 0.9999981973586167 -0.001898757361382411 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn -0.38268439622810074 0.0020495094056768516 0.9238768599736277 +vn -0.9238784608628806 0.002061059307931493 0.3826804693058744 +vn 0 -1 0 +vn 0 0 1 +vn 0 -1 0 +vn 0 -1 0 +vn 0 0 -1 +vn -1 0 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn -1 0 0 +vn 0 0 1 +vn 0 1 0 +vn 0 1 0 +vn 0 0 -1 +vn 0 1 0 +vn 1 0 0 +vn 0 1 0 +vn 0 1 0 +vn 0 -1 0 +vn 0 -1 0 +vn 0 -1 0 +vn 0 -1 0 +vn 0 -1 0 +vn 0 -1 0 +vn 0 -1 0 +vn 0 -1 0 +vn -0.9238768380353447 -0.0020610629282401334 -0.38268438714091724 +vn -0.38268439622810074 -0.0020495094056768516 -0.9238768599736277 +vn 0 -1 0 +vn 0 -1 0 +vn 0 -1 0 +vn 0 -1 0 +vn 0 -1 0 +vn 0 -1 0 +vn 0.9238754667923345 0.0020430737060994765 0.3826877940424517 +vn 0.38268222955150794 0.0020569505210756883 0.9238777409051704 +vn 0.3826743723756862 0.0020610536739504638 -0.9238809862664281 +vn 0.9238793816906321 0.002049476801710662 -0.38267830841553385 +vn -0.923879359701266 0.0020610573027127813 -0.3826782993073607 +vn -0.3826783084155339 0.0020494768017106624 -0.9238793816906322 +vn 0 -1 0 +vn 0 -1 0 +vn 1 0 0 +vn 0 -1 0 +vn 0 -1 0 +vn 0.923879359701266 -0.0020610573027127813 -0.3826782993073607 +vn 0.3826743814973982 -0.0020494557706589203 -0.9238810082887449 +vn 0.38268223218028324 -0.0020536082437833734 0.923877747251603 +vn 0.9238754404534202 -0.002056980372059382 0.3826877831323438 +vn -0.923879359701266 -0.0020610573027127813 0.3826782993073607 +vn -0.3826783084155339 -0.0020494768017106624 0.9238793816906322 +vn -0.9999999998904936 0.000014789617685546509 5.29359309718211e-7 +vn 0 0 -1 +vn 1 0 0 +vn 0 0 1 +vn -0.035789336609158166 0.9993593564804788 0 +vn 0 -0.9993606295368274 -0.03575377087463854 +vn -1.892697513668112e-8 -0.9993605846876212 0.035755024441546296 +vn 0.035755552796778126 0.99936056578391 5.100857405986432e-7 +vn -0.0000010580247506107574 0.999359394321106 -0.035788279939566674 +vn 0.03575377087463854 -0.9993606295368274 0 +vn -0.03575502444153629 -0.9993605846876216 -1.8926985155703446e-8 +vn 0 0.9993593564804788 0.035789336609158166 +vn 0 0 -1 +vn -1 0 0 +vn 0 0 1 +vn 1 0 0 +vn 0 0 -1 +vn 0 0.9993606295368274 -0.03575377087463854 +vn 0 -0.9993606295368274 -0.03575377087463854 +vn 0 0 1 +usemtl m_a0ed59c9-b727-a860-adda-44d308056dc0 +f 1/1/1 2/2/1 3/3/1 4/4/1 +f 5/5/2 6/6/2 7/7/2 8/8/2 +f 9/9/3 10/10/3 11/11/3 12/12/3 +f 8/13/4 7/14/4 13/15/4 14/16/4 +f 5/17/5 8/18/5 10/19/5 15/20/5 +f 7/21/6 9/22/6 12/23/6 13/24/6 +f 7/25/7 6/26/7 16/27/7 9/28/7 +f 14/29/8 11/30/8 17/31/8 18/32/8 +f 10/33/9 9/34/9 16/35/9 15/36/9 +f 14/37/10 13/38/10 12/39/10 11/40/10 +f 10/41/11 8/42/11 19/43/11 20/44/11 +f 11/45/12 10/46/12 20/47/12 17/48/12 +f 8/49/13 14/50/13 18/51/13 19/52/13 +f 21/53/14 22/54/14 23/55/14 24/56/14 +f 24/57/15 23/58/15 25/59/15 26/60/15 +f 26/61/16 25/62/16 27/63/16 28/64/16 +f 21/65/17 24/66/17 2/67/17 1/68/17 +f 23/69/18 22/70/18 27/71/18 25/72/18 +f 24/73/19 26/74/19 3/75/19 2/76/19 +f 26/77/20 28/78/20 4/79/20 3/80/20 +f 1/81/21 4/82/21 29/83/21 30/84/21 +f 5/85/22 31/86/22 32/87/22 6/88/22 +f 33/89/23 34/90/23 35/91/23 36/92/23 +f 31/93/24 37/94/24 38/95/24 32/96/24 +f 5/97/25 15/98/25 36/99/25 31/100/25 +f 32/101/26 38/102/26 34/103/26 33/104/26 +f 32/105/27 33/106/27 16/107/27 6/108/27 +f 37/109/28 39/110/28 40/111/28 35/112/28 +f 36/113/29 15/114/29 16/115/29 33/116/29 +f 37/117/30 35/118/30 34/119/30 38/120/30 +f 36/121/31 41/122/31 42/123/31 31/124/31 +f 35/125/32 40/126/32 41/127/32 36/128/32 +f 31/129/33 42/130/33 39/131/33 37/132/33 +f 21/133/34 43/134/34 44/135/34 22/136/34 +f 43/137/35 45/138/35 46/139/35 44/140/35 +f 45/141/36 28/142/36 27/143/36 46/144/36 +f 21/145/37 1/146/37 30/147/37 43/148/37 +f 44/149/38 46/150/38 27/151/38 22/152/38 +f 43/153/39 30/154/39 29/155/39 45/156/39 +f 45/157/40 29/158/40 4/159/40 28/160/40 +f 47/161/41 48/162/41 49/163/41 50/164/41 +f 51/165/42 52/166/42 53/167/42 54/168/42 +f 50/169/43 49/170/43 55/171/43 56/172/43 +f 57/173/44 48/174/44 47/175/44 58/176/44 +f 59/177/45 53/178/45 48/179/45 57/180/45 +f 60/181/46 52/182/46 51/183/46 61/184/46 +f 53/185/47 52/186/47 49/187/47 48/188/47 +f 54/189/48 53/190/48 59/191/48 62/192/48 +f 63/193/49 64/194/49 65/195/49 66/196/49 +f 66/197/50 65/198/50 67/199/50 68/200/50 +f 69/201/51 70/202/51 71/203/51 72/204/51 +f 72/205/52 71/206/52 73/207/52 74/208/52 +f 75/209/53 76/210/53 77/211/53 78/212/53 +f 78/213/54 77/214/54 79/215/54 80/216/54 +f 81/217/55 82/218/55 83/219/55 84/220/55 +f 84/221/56 83/222/56 85/223/56 86/224/56 +f 64/225/57 54/226/57 62/227/57 65/228/57 +f 65/229/58 62/230/58 59/231/58 67/232/58 +f 47/233/59 69/234/59 72/235/59 58/236/59 +f 58/237/60 72/238/60 74/239/60 57/240/60 +f 86/241/61 60/242/61 61/243/61 84/244/61 +f 84/245/62 61/246/62 51/247/62 81/248/62 +f 55/249/63 75/250/63 78/251/63 56/252/63 +f 56/253/64 78/254/64 80/255/64 50/256/64 +f 68/257/65 67/258/65 74/259/65 73/260/65 +f 82/261/66 81/262/66 64/263/66 63/264/66 +f 64/265/67 81/266/67 51/267/67 54/268/67 +f 74/269/68 67/270/68 59/271/68 57/272/68 +f 70/273/69 69/274/69 80/275/69 79/276/69 +f 80/277/70 69/278/70 47/279/70 50/280/70 +f 76/281/71 75/282/71 86/283/71 85/284/71 +f 86/285/72 75/286/72 55/287/72 60/288/72 +f 55/289/73 49/290/73 52/291/73 60/292/73 +f 87/293/74 88/294/74 89/295/74 90/296/74 +f 90/297/75 89/298/75 91/299/75 92/300/75 +f 92/301/76 91/302/76 93/303/76 94/304/76 +f 94/305/77 93/306/77 88/307/77 87/308/77 +f 95/309/78 96/310/78 97/311/78 98/312/78 +f 96/313/79 99/314/79 100/315/79 97/316/79 +f 99/317/80 101/318/80 102/319/80 100/320/80 +f 101/321/81 95/322/81 98/323/81 102/324/81 +f 103/325/82 104/326/82 105/327/82 106/328/82 +f 104/329/83 107/330/83 108/331/83 105/332/83 +f 107/333/84 109/334/84 110/335/84 108/336/84 +f 109/337/85 103/338/85 106/339/85 110/340/85 +f 111/341/86 112/342/86 113/343/86 114/344/86 +f 114/345/87 113/346/87 115/347/87 116/348/87 +f 116/349/88 115/350/88 117/351/88 118/352/88 +f 118/353/89 117/354/89 112/355/89 111/356/89 +f 119/357/90 120/358/90 121/359/90 122/360/90 +f 122/361/91 121/362/91 123/363/91 124/364/91 +f 124/365/92 123/366/92 125/367/92 126/368/92 +f 126/369/93 125/370/93 120/371/93 119/372/93 +f 123/373/94 121/374/94 120/375/94 125/376/94 +f 127/377/95 128/378/95 129/379/95 130/380/95 +f 130/381/96 129/382/96 131/383/96 132/384/96 +f 132/385/97 131/386/97 133/387/97 134/388/97 +f 134/389/98 133/390/98 128/391/98 127/392/98 +f 131/393/99 129/394/99 128/395/99 133/396/99 +f 135/397/100 136/398/100 137/399/100 138/400/100 +f 138/401/101 137/402/101 139/403/101 140/404/101 +f 140/405/102 139/406/102 141/407/102 142/408/102 +f 142/409/103 141/410/103 136/411/103 135/412/103 +f 139/413/104 137/414/104 136/415/104 141/416/104 +f 143/417/105 144/418/105 145/419/105 146/420/105 +f 146/421/106 145/422/106 147/423/106 148/424/106 +f 148/425/107 147/426/107 149/427/107 150/428/107 +f 150/429/108 149/430/108 144/431/108 143/432/108 +f 147/433/109 145/434/109 144/435/109 149/436/109 +f 151/437/110 152/438/110 153/439/110 154/440/110 +f 154/441/111 153/442/111 155/443/111 156/444/111 +f 156/445/112 155/446/112 157/447/112 158/448/112 +f 158/449/113 157/450/113 152/451/113 151/452/113 +f 155/453/114 153/454/114 152/455/114 157/456/114 +f 159/457/115 160/458/115 161/459/115 162/460/115 +f 162/461/116 161/462/116 163/463/116 164/464/116 +f 164/465/117 163/466/117 165/467/117 166/468/117 +f 166/469/118 165/470/118 160/471/118 159/472/118 +f 163/473/119 161/474/119 160/475/119 165/476/119 +f 167/477/120 168/478/120 169/479/120 170/480/120 +f 168/481/121 171/482/121 172/483/121 169/484/121 +f 171/485/122 173/486/122 174/487/122 172/488/122 +f 173/489/123 167/490/123 170/491/123 174/492/123 +f 172/493/124 174/494/124 170/495/124 169/496/124 +f 175/497/125 176/498/125 177/499/125 178/500/125 +f 176/501/126 179/502/126 180/503/126 177/504/126 +f 179/505/127 181/506/127 182/507/127 180/508/127 +f 181/509/128 175/510/128 178/511/128 182/512/128 +f 180/513/129 182/514/129 178/515/129 177/516/129 +f 183/517/130 184/518/130 185/519/130 186/520/130 +f 184/521/131 187/522/131 188/523/131 185/524/131 +f 187/525/132 189/526/132 190/527/132 188/528/132 +f 189/529/133 183/530/133 186/531/133 190/532/133 +f 188/533/134 190/534/134 186/535/134 185/536/134 +f 191/537/135 192/538/135 193/539/135 194/540/135 +f 192/541/136 195/542/136 196/543/136 193/544/136 +f 195/545/137 197/546/137 198/547/137 196/548/137 +f 197/549/138 191/550/138 194/551/138 198/552/138 +f 196/553/139 198/554/139 194/555/139 193/556/139 +f 199/557/140 200/558/140 201/559/140 202/560/140 +f 200/561/141 203/562/141 204/563/141 201/564/141 +f 203/565/142 205/566/142 206/567/142 204/568/142 +f 205/569/143 199/570/143 202/571/143 206/572/143 +f 204/573/144 206/574/144 202/575/144 201/576/144 +f 207/577/145 208/578/145 209/579/145 210/580/145 +f 208/581/146 211/582/146 212/583/146 209/584/146 +f 211/585/147 213/586/147 214/587/147 212/588/147 +f 213/589/148 207/590/148 210/591/148 214/592/148 +f 212/593/149 214/594/149 210/595/149 209/596/149 +f 215/597/150 216/598/150 217/599/150 218/600/150 +f 218/601/151 217/602/151 219/603/151 220/604/151 +f 220/605/152 219/606/152 221/607/152 222/608/152 +f 222/609/153 221/610/153 216/611/153 215/612/153 +f 219/613/154 217/614/154 216/615/154 221/616/154 +f 223/617/155 224/618/155 225/619/155 226/620/155 +f 226/621/156 225/622/156 227/623/156 228/624/156 +f 228/625/157 227/626/157 229/627/157 230/628/157 +f 230/629/158 229/630/158 224/631/158 223/632/158 +f 227/633/159 225/634/159 224/635/159 229/636/159 +f 231/637/160 232/638/160 233/639/160 234/640/160 +f 234/641/161 233/642/161 235/643/161 236/644/161 +f 236/645/162 235/646/162 237/647/162 238/648/162 +f 238/649/163 237/650/163 232/651/163 231/652/163 +f 235/653/164 233/654/164 232/655/164 237/656/164 +f 239/657/165 240/658/165 241/659/165 242/660/165 +f 242/661/166 241/662/166 243/663/166 244/664/166 +f 244/665/167 243/666/167 245/667/167 246/668/167 +f 246/669/168 245/670/168 240/671/168 239/672/168 +f 243/673/169 241/674/169 240/675/169 245/676/169 +f 247/677/170 248/678/170 249/679/170 250/680/170 +f 250/681/171 249/682/171 251/683/171 252/684/171 +f 252/685/172 251/686/172 253/687/172 254/688/172 +f 254/689/173 253/690/173 248/691/173 247/692/173 +f 251/693/174 249/694/174 248/695/174 253/696/174 +f 255/697/175 256/698/175 257/699/175 258/700/175 +f 258/701/176 257/702/176 259/703/176 260/704/176 +f 260/705/177 259/706/177 261/707/177 262/708/177 +f 262/709/178 261/710/178 256/711/178 255/712/178 +f 259/713/179 257/714/179 256/715/179 261/716/179 +f 263/717/180 264/718/180 265/719/180 266/720/180 +f 264/721/181 267/722/181 268/723/181 265/724/181 +f 267/725/182 269/726/182 270/727/182 268/728/182 +f 269/729/183 263/730/183 266/731/183 270/732/183 +f 268/733/184 270/734/184 266/735/184 265/736/184 +f 271/737/185 272/738/185 273/739/185 274/740/185 +f 272/741/186 275/742/186 276/743/186 273/744/186 +f 275/745/187 277/746/187 278/747/187 276/748/187 +f 277/749/188 271/750/188 274/751/188 278/752/188 +f 276/753/189 278/754/189 274/755/189 273/756/189 +f 279/757/190 280/758/190 281/759/190 282/760/190 +f 280/761/191 283/762/191 284/763/191 281/764/191 +f 283/765/192 285/766/192 286/767/192 284/768/192 +f 285/769/193 279/770/193 282/771/193 286/772/193 +f 284/773/194 286/774/194 282/775/194 281/776/194 +f 287/777/195 288/778/195 289/779/195 290/780/195 +f 288/781/196 291/782/196 292/783/196 289/784/196 +f 291/785/197 293/786/197 294/787/197 292/788/197 +f 293/789/198 287/790/198 290/791/198 294/792/198 +f 292/793/199 294/794/199 290/795/199 289/796/199 +f 295/797/200 296/798/200 297/799/200 298/800/200 +f 296/801/201 299/802/201 300/803/201 297/804/201 +f 299/805/202 301/806/202 302/807/202 300/808/202 +f 301/809/203 295/810/203 298/811/203 302/812/203 +f 300/813/204 302/814/204 298/815/204 297/816/204 +f 303/817/205 304/818/205 305/819/205 306/820/205 +f 304/821/206 307/822/206 308/823/206 305/824/206 +f 307/825/207 309/826/207 310/827/207 308/828/207 +f 309/829/208 303/830/208 306/831/208 310/832/208 +f 308/833/209 310/834/209 306/835/209 305/836/209 +f 311/837/210 312/838/210 313/839/210 314/840/210 +f 315/841/211 316/842/211 317/843/211 318/844/211 +f 314/845/212 313/846/212 319/847/212 320/848/212 +f 321/849/213 312/850/213 311/851/213 322/852/213 +f 323/853/214 317/854/214 312/855/214 321/856/214 +f 324/857/215 316/858/215 315/859/215 325/860/215 +f 317/861/216 316/862/216 313/863/216 312/864/216 +f 318/865/217 317/866/217 323/867/217 326/868/217 +f 327/869/218 328/870/218 329/871/218 330/872/218 +f 330/873/219 329/874/219 331/875/219 332/876/219 +f 333/877/220 334/878/220 335/879/220 336/880/220 +f 327/881/221 334/882/221 333/883/221 337/884/221 +f 338/885/222 339/886/222 340/887/222 341/888/222 +f 342/889/223 343/890/223 344/891/223 345/892/223 +f 346/893/224 343/894/224 342/895/224 347/896/224 +f 348/897/225 339/898/225 338/899/225 332/900/225 +f 328/901/226 318/902/226 326/903/226 329/904/226 +f 329/905/227 326/906/227 323/907/227 331/908/227 +f 311/909/228 349/910/228 350/911/228 322/912/228 +f 322/913/229 350/914/229 351/915/229 321/916/229 +f 352/917/230 324/918/230 325/919/230 353/920/230 +f 353/921/231 325/922/231 315/923/231 354/924/231 +f 319/925/232 355/926/232 356/927/232 320/928/232 +f 320/929/233 356/930/233 357/931/233 314/932/233 +f 332/933/234 331/934/234 351/935/234 348/936/234 +f 337/937/235 354/938/235 328/939/235 327/940/235 +f 328/941/236 354/942/236 315/943/236 318/944/236 +f 351/945/237 331/946/237 323/947/237 321/948/237 +f 347/949/238 349/950/238 357/951/238 346/952/238 +f 357/953/239 349/954/239 311/955/239 314/956/239 +f 358/957/240 355/958/240 352/959/240 359/960/240 +f 352/961/241 355/962/241 319/963/241 324/964/241 +f 319/965/242 313/966/242 316/967/242 324/968/242 +f 360/969/243 361/970/243 333/971/243 336/972/243 +f 362/973/244 363/974/244 361/975/244 360/976/244 +f 364/977/245 365/978/245 344/979/245 343/980/245 +f 366/981/246 367/982/246 365/983/246 364/984/246 +f 368/985/247 369/986/247 338/987/247 341/988/247 +f 335/989/248 334/990/248 369/991/248 368/992/248 +f 370/993/249 371/994/249 340/995/249 339/996/249 +f 342/997/250 345/998/250 371/999/250 370/1000/250 +f 372/1001/251 370/1002/251 339/1003/251 348/1004/251 +f 347/1005/252 342/1006/252 370/1007/252 372/1008/252 +f 345/1009/253 373/1010/253 340/1011/253 371/1012/253 +f 373/1013/254 374/1014/254 375/1015/254 376/1016/254 +f 367/1017/255 374/1018/255 344/1019/255 365/1020/255 +f 340/1021/256 373/1022/256 376/1023/256 341/1024/256 +f 341/1025/257 376/1026/257 335/1027/257 368/1028/257 +f 336/1029/258 375/1030/258 362/1031/258 360/1032/258 +f 353/1033/259 377/1034/259 359/1035/259 352/1036/259 +f 354/1037/260 337/1038/260 377/1039/260 353/1040/260 +f 356/1041/261 378/1042/261 346/1043/261 357/1044/261 +f 355/1045/262 358/1046/262 378/1047/262 356/1048/262 +f 350/1049/263 372/1050/263 348/1051/263 351/1052/263 +f 349/1053/264 347/1054/264 372/1055/264 350/1056/264 +f 344/1057/265 374/1058/265 373/1059/265 345/1060/265 +f 335/1061/266 376/1062/266 375/1063/266 336/1064/266 +f 359/1065/267 363/1066/267 366/1067/267 358/1068/267 +f 366/1069/268 363/1070/268 362/1071/268 367/1072/268 +f 362/1073/269 375/1074/269 374/1075/269 367/1076/269 +f 364/1077/270 378/1078/270 358/1079/270 366/1080/270 +f 343/1081/271 346/1082/271 378/1083/271 364/1084/271 +f 361/1085/272 377/1086/272 337/1087/272 333/1088/272 +f 363/1089/273 359/1090/273 377/1091/273 361/1092/273 +f 369/1093/274 330/1094/274 332/1095/274 338/1096/274 +f 334/1097/275 327/1098/275 330/1099/275 369/1100/275 +f 379/1101/276 380/1102/276 381/1103/276 382/1104/276 +f 382/1105/277 381/1106/277 383/1107/277 384/1108/277 +f 384/1109/278 383/1110/278 385/1111/278 386/1112/278 +f 387/1113/279 388/1114/279 380/1115/279 379/1116/279 +f 385/1117/280 383/1118/280 389/1119/280 390/1120/280 +f 387/1121/281 379/1122/281 391/1123/281 392/1124/281 +f 382/1125/282 384/1126/282 393/1127/282 394/1128/282 +f 381/1129/283 380/1130/283 395/1131/283 396/1132/283 +f 388/1133/284 385/1134/284 390/1135/284 397/1136/284 +f 379/1137/285 382/1138/285 394/1139/285 391/1140/285 +f 384/1141/286 386/1142/286 398/1143/286 393/1144/286 +f 383/1145/287 381/1146/287 396/1147/287 389/1148/287 +f 397/1149/288 390/1150/288 398/1151/288 392/1152/288 +f 390/1153/289 389/1154/289 393/1155/289 398/1156/289 +f 389/1157/290 396/1158/290 394/1159/290 393/1160/290 +f 396/1161/291 395/1162/291 391/1163/291 394/1164/291 +f 395/1165/292 397/1166/292 392/1167/292 391/1168/292 +f 380/1169/293 388/1170/293 397/1171/293 395/1172/293 +f 386/1173/294 387/1174/294 392/1175/294 398/1176/294 +f 386/1177/295 385/1178/295 388/1179/295 387/1180/295 \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/obj/blocks/cage/texture.png b/src/main/resources/assets/tiedup/models/obj/blocks/cage/texture.png new file mode 100644 index 0000000..e328f44 Binary files /dev/null and b/src/main/resources/assets/tiedup/models/obj/blocks/cage/texture.png differ diff --git a/src/main/resources/assets/tiedup/models/obj/choke_collar_leather/model.mtl b/src/main/resources/assets/tiedup/models/obj/choke_collar_leather/model.mtl new file mode 100644 index 0000000..2b40476 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/obj/choke_collar_leather/model.mtl @@ -0,0 +1,4 @@ +# Made in Blockbench 5.0.7 +newmtl m_197d5025-59a4-f3c1-d159-d8096840c879 +map_Kd texture.png +newmtl none diff --git a/src/main/resources/assets/tiedup/models/obj/choke_collar_leather/model.obj b/src/main/resources/assets/tiedup/models/obj/choke_collar_leather/model.obj new file mode 100644 index 0000000..e14eb5c --- /dev/null +++ b/src/main/resources/assets/tiedup/models/obj/choke_collar_leather/model.obj @@ -0,0 +1,4644 @@ +# Made in Blockbench 5.0.7 +mtllib model.mtl + +o Choke_Collar_Leather +v -0.217712 1.436479 0.166706 +v -0.217711 1.501214 0.166706 +v -0.231884 1.501214 0.162577 +v -0.231884 1.436479 0.162577 +v -0.237754 1.501214 0.15261 +v -0.237754 1.436479 0.15261 +v -0.217711 1.501214 -0.163197 +v -0.217711 1.436479 -0.163197 +v -0.231884 1.436479 -0.159069 +v -0.231884 1.501214 -0.159069 +v -0.237754 1.436479 -0.149101 +v -0.237754 1.501214 -0.149101 +v -0.182596 1.436479 -0.110743 +v -0.176685 1.436479 -0.120774 +v -0.162413 1.436479 -0.124929 +v -0.162413 1.501214 -0.124929 +v -0.176685 1.501214 -0.120774 +v -0.182596 1.501214 -0.110743 +v -0.162413 1.501214 0.128437 +v -0.176685 1.501214 0.124282 +v -0.182596 1.501214 0.114251 +v -0.162413 1.436479 0.128437 +v -0.176685 1.436479 0.124282 +v -0.182596 1.436479 0.114251 +v 0 1.436479 0.166705 +v 0 1.501214 0.166705 +v 0 1.436479 0.128437 +v -0.084777 1.50472 -0.122856 +v -0.074784 1.50472 -0.122856 +v -0.074784 1.432973 -0.122856 +v -0.084777 1.432973 -0.122856 +v 0 1.50218 -0.168462 +v -0.007268 1.50218 -0.168462 +v -0.007268 1.502877 -0.126454 +v 0 1.502877 -0.126454 +v -0.007268 1.435513 -0.168462 +v -0.007268 1.441493 -0.161035 +v -0.007268 1.4962 -0.161035 +v -0.007268 1.434816 -0.126454 +v -0.007268 1.440922 -0.126566 +v -0.007268 1.496771 -0.126566 +v 0 1.435513 -0.168462 +v 0 1.434816 -0.126454 +v -0.074784 1.50472 -0.144063 +v -0.074784 1.496579 -0.144063 +v -0.074784 1.496579 -0.127669 +v -0.084777 1.441114 -0.127669 +v -0.084777 1.496579 -0.127669 +v 0 1.436479 -0.163197 +v 0 1.436479 -0.124929 +v -0.074784 1.432973 -0.16527 +v -0.074784 1.441114 -0.160457 +v -0.074784 1.496579 -0.160457 +v -0.074784 1.50472 -0.16527 +v 0 1.501214 -0.163197 +v -0.084777 1.432973 -0.16527 +v -0.084777 1.441114 -0.160457 +v 0 1.501214 -0.124929 +v -0.084777 1.50472 -0.16527 +v -0.084777 1.496579 -0.160457 +v -0.084777 1.50472 -0.144063 +v -0.084777 1.496579 -0.144063 +v -0.074784 1.441114 -0.127669 +v 0.010191 1.50472 0.169105 +v 0.020184 1.50472 0.169105 +v 0.020184 1.432973 0.169105 +v 0.010191 1.432973 0.169105 +v 0.020184 1.50472 0.147898 +v 0.020184 1.496579 0.147898 +v 0.020184 1.496579 0.164292 +v 0.010191 1.441114 0.164292 +v 0.010191 1.496579 0.164292 +v 0.020184 1.432973 0.126692 +v 0.020184 1.441114 0.131504 +v 0.020184 1.496579 0.131504 +v 0.020184 1.50472 0.126692 +v 0.010191 1.432973 0.126692 +v 0.010191 1.441114 0.131504 +v 0.010191 1.50472 0.126692 +v 0.010191 1.496579 0.131504 +v 0.010191 1.50472 0.147898 +v 0.010191 1.496579 0.147898 +v 0.020184 1.441114 0.164292 +v -0.020898 1.50472 0.169105 +v -0.010905 1.50472 0.169105 +v -0.010905 1.432973 0.169105 +v -0.020899 1.432973 0.169105 +v -0.010905 1.50472 0.147898 +v -0.010905 1.496579 0.147898 +v -0.010905 1.496579 0.164292 +v -0.020899 1.441114 0.164292 +v -0.020899 1.496579 0.164292 +v -0.010905 1.432973 0.126692 +v -0.010905 1.441114 0.131504 +v -0.010905 1.496579 0.131504 +v -0.010905 1.50472 0.126692 +v -0.020899 1.432973 0.126692 +v -0.020899 1.441114 0.131504 +v -0.020898 1.50472 0.126692 +v -0.020899 1.496579 0.131504 +v -0.020898 1.50472 0.147898 +v -0.020899 1.496579 0.147898 +v -0.010905 1.441114 0.164292 +v 0 1.501214 0.128437 +v 0.163199 1.515074 0.171616 +v 0.103819 1.515073 0.171616 +v 0.103819 1.501747 0.171616 +v 0.153108 1.501747 0.171616 +v 0.103819 1.501747 0.163557 +v 0.153108 1.501747 0.163557 +v 0.163199 1.515074 0.161188 +v 0.103819 1.515073 0.161188 +v 0.103819 1.422619 0.171616 +v 0.103819 1.422619 0.161188 +v 0.163199 1.422619 0.161188 +v 0.163199 1.422619 0.171616 +v 0.153108 1.435946 0.171616 +v 0.103819 1.435945 0.171616 +v 0.153108 1.435946 0.163557 +v 0.103819 1.435945 0.163557 +v 0.217711 1.436479 0.166705 +v 0.231884 1.436479 0.162577 +v 0.231884 1.501214 0.162577 +v 0.217711 1.501214 0.166705 +v 0.237754 1.436479 0.152609 +v 0.237754 1.501214 0.152609 +v 0.217711 1.501214 -0.163197 +v 0.231884 1.501214 -0.159069 +v 0.231884 1.436479 -0.159069 +v 0.217711 1.436479 -0.163197 +v 0.237754 1.501214 -0.149101 +v 0.237754 1.436479 -0.149101 +v 0.182596 1.436479 -0.110743 +v 0.176685 1.436479 -0.120774 +v 0.162413 1.436479 -0.124929 +v 0.162413 1.501214 -0.124929 +v 0.176685 1.501214 -0.120774 +v 0.182596 1.501214 -0.110743 +v 0.176685 1.501214 0.124282 +v 0.162413 1.501214 0.128437 +v 0.182596 1.501214 0.114251 +v 0.162413 1.436479 0.128437 +v 0.176685 1.436479 0.124282 +v 0.182596 1.436479 0.114251 +v 0.084777 1.50472 -0.122856 +v 0.084777 1.432973 -0.122856 +v 0.074784 1.432973 -0.122856 +v 0.074784 1.50472 -0.122856 +v 0.007268 1.502877 -0.126454 +v 0.007268 1.50218 -0.168462 +v 0.007268 1.4962 -0.161035 +v 0.007268 1.441493 -0.161035 +v 0.007268 1.435513 -0.168462 +v 0.007268 1.440922 -0.126566 +v 0.007268 1.434816 -0.126454 +v 0.007268 1.496771 -0.126566 +v 0.074784 1.50472 -0.144063 +v 0.074784 1.496579 -0.127669 +v 0.074784 1.496579 -0.144063 +v 0.084777 1.496579 -0.127669 +v 0.084777 1.441114 -0.127669 +v 0.074784 1.432973 -0.16527 +v 0.074784 1.50472 -0.16527 +v 0.074784 1.496579 -0.160457 +v 0.074784 1.441114 -0.160457 +v 0.084777 1.432973 -0.16527 +v 0.084777 1.441114 -0.160457 +v 0.084777 1.50472 -0.16527 +v 0.084777 1.496579 -0.160457 +v 0.084777 1.50472 -0.144063 +v 0.084777 1.496579 -0.144063 +v 0.074784 1.441114 -0.127669 +v 0.044439 1.515074 0.171616 +v 0.05453 1.501747 0.171616 +v 0.05453 1.501747 0.163557 +v 0.044439 1.515074 0.161188 +v 0.044439 1.422619 0.171616 +v 0.044439 1.422619 0.161188 +v 0.05453 1.435946 0.171616 +v 0.05453 1.435946 0.163557 +v -0.010205 1.309194 -0.168899 +v -0.013277 1.309194 -0.168899 +v -0.013272 1.32242 -0.168899 +v -0.010209 1.32242 -0.168899 +v 0.001817 1.322417 -0.168899 +v -0.004617 1.322417 -0.168899 +v -0.004617 1.325267 -0.168899 +v 0.004889 1.325267 -0.168899 +v 0.001817 1.318722 -0.168899 +v 0.004889 1.318718 -0.168899 +v 0.004889 1.315871 -0.168899 +v 0.001817 1.315872 -0.168899 +v 0.004889 1.309194 -0.168899 +v -0.004817 1.309194 -0.168899 +v -0.004817 1.312043 -0.168899 +v 0.001817 1.312043 -0.168899 +v -0.004394 1.315872 -0.168899 +v -0.004394 1.318722 -0.168899 +v 0.001817 1.318722 -0.166157 +v 0.001817 1.322417 -0.166157 +v -0.004394 1.318722 -0.166157 +v -0.004394 1.315872 -0.166157 +v 0.001817 1.315872 -0.166157 +v 0.001817 1.312043 -0.166157 +v -0.004817 1.312043 -0.166157 +v -0.004817 1.309194 -0.166157 +v 0.004889 1.309194 -0.166157 +v 0.004889 1.315869 -0.166157 +v -0.004617 1.325267 -0.166157 +v 0.004889 1.325267 -0.166157 +v -0.004617 1.322417 -0.166157 +v -0.013277 1.309194 -0.166157 +v -0.013272 1.32242 -0.166157 +v -0.010205 1.309194 -0.166157 +v -0.010209 1.32242 -0.166157 +v -0.004951 1.322417 -0.168899 +v -0.004951 1.325267 -0.168899 +v -0.004951 1.325267 -0.166157 +v -0.004951 1.322417 -0.166157 +v -0.013268 1.325267 -0.168899 +v -0.018531 1.325267 -0.168899 +v -0.018531 1.325267 -0.166157 +v -0.013268 1.325267 -0.166157 +v -0.018531 1.322417 -0.168899 +v -0.018531 1.322417 -0.166157 +v 0.004889 1.31871 -0.166157 +v -0.010214 1.325267 -0.168899 +v -0.010214 1.325267 -0.166157 +v 0.013282 1.318611 -0.168899 +v 0.016354 1.318608 -0.168899 +v 0.016354 1.315759 -0.168899 +v 0.013282 1.315761 -0.168899 +v 0.011168 1.325267 -0.168899 +v 0.013284 1.325267 -0.168899 +v 0.013282 1.322417 -0.168899 +v 0.011168 1.322417 -0.168899 +v 0.005884 1.320456 -0.168899 +v 0.006201 1.322318 -0.168899 +v 0.009137 1.321231 -0.168899 +v 0.009036 1.320556 -0.168899 +v 0.009614 1.31913 -0.168899 +v 0.007269 1.317102 -0.168899 +v 0.006221 1.318615 -0.168899 +v 0.009193 1.319716 -0.168899 +v 0.011356 1.318611 -0.168899 +v 0.011356 1.315761 -0.168899 +v 0.010297 1.318754 -0.168899 +v 0.009026 1.316107 -0.168899 +v 0.016354 1.309194 -0.168899 +v 0.013282 1.309194 -0.168899 +v 0.016354 1.325267 -0.166157 +v 0.016354 1.325267 -0.168899 +v 0.013284 1.325267 -0.166157 +v 0.016354 1.309194 -0.166157 +v 0.016354 1.315759 -0.166157 +v 0.013282 1.309194 -0.166157 +v 0.013282 1.315761 -0.166157 +v 0.011356 1.315761 -0.166157 +v 0.009026 1.316107 -0.166157 +v 0.007269 1.317102 -0.166157 +v 0.006221 1.318615 -0.166157 +v 0.005884 1.320456 -0.166157 +v 0.006201 1.322318 -0.166157 +v 0.007187 1.323866 -0.166157 +v 0.007187 1.323866 -0.168899 +v 0.008854 1.324904 -0.166157 +v 0.008854 1.324904 -0.168899 +v 0.016354 1.322417 -0.166157 +v 0.016354 1.322417 -0.168899 +v 0.009468 1.321812 -0.168899 +v 0.010096 1.322234 -0.168899 +v 0.016354 1.318608 -0.166157 +v 0.011168 1.325267 -0.166157 +v 0.011356 1.318611 -0.166157 +v 0.010297 1.318754 -0.166157 +v 0.013282 1.318611 -0.166157 +v 0.013282 1.322417 -0.166157 +v 0.011168 1.322417 -0.166157 +v 0.010096 1.322234 -0.166157 +v 0.009468 1.321812 -0.166157 +v 0.009137 1.321231 -0.166157 +v 0.009036 1.320556 -0.166157 +v 0.009193 1.319716 -0.166157 +v 0.009614 1.31913 -0.166157 +v -0.01753 1.30779 -0.166593 +v -0.00095 1.30779 -0.166593 +v -0.00095 1.30356 -0.166593 +v -0.01753 1.30356 -0.166593 +v -0.01753 1.326721 -0.166593 +v -0.00095 1.326721 -0.166593 +v -0.00095 1.317255 -0.166593 +v -0.01753 1.317255 -0.166593 +v -0.053641 1.326721 -0.166593 +v -0.034109 1.326721 -0.166593 +v -0.034109 1.317255 -0.166593 +v -0.044593 1.317255 -0.166593 +v -0.053641 1.30779 -0.166593 +v -0.034109 1.30779 -0.166593 +v -0.034109 1.28991 -0.166593 +v -0.044141 1.291654 -0.166593 +v -0.044141 1.342857 -0.166593 +v -0.034109 1.344599 -0.166593 +v -0.01753 1.330951 -0.166593 +v -0.00095 1.330951 -0.166593 +v -0.034109 1.28991 -0.162664 +v -0.044141 1.291654 -0.162664 +v -0.053641 1.30779 -0.162664 +v -0.044593 1.317255 -0.162664 +v -0.053641 1.326721 -0.162664 +v -0.044141 1.342857 -0.162664 +v -0.034109 1.344599 -0.162664 +v -0.01753 1.330951 -0.162664 +v -0.01753 1.30356 -0.162664 +v -0.00095 1.30356 -0.162664 +v -0.00095 1.330951 -0.162664 +v 0.015629 1.30779 -0.166593 +v 0.015629 1.30356 -0.166593 +v 0.015629 1.326721 -0.166593 +v 0.015629 1.317255 -0.166593 +v 0.05174 1.326721 -0.166593 +v 0.042692 1.317255 -0.166593 +v 0.032209 1.317255 -0.166593 +v 0.032209 1.326721 -0.166593 +v 0.05174 1.30779 -0.166593 +v 0.04224 1.291654 -0.166593 +v 0.032209 1.28991 -0.166593 +v 0.032209 1.30779 -0.166593 +v 0.04224 1.342857 -0.166593 +v 0.032209 1.344599 -0.166593 +v 0.015629 1.330951 -0.166593 +v 0.04224 1.291654 -0.162664 +v 0.032209 1.28991 -0.162664 +v 0.05174 1.30779 -0.162664 +v 0.042692 1.317255 -0.162664 +v 0.05174 1.326721 -0.162664 +v 0.032209 1.344599 -0.162664 +v 0.04224 1.342857 -0.162664 +v 0.015629 1.330951 -0.162664 +v 0.015629 1.30356 -0.162664 +v 0.113333 1.436754 0.168262 +v 0.113333 1.500749 0.168262 +v 0.053886 1.50075 0.168262 +v 0.053886 1.436754 0.168262 +v 0.053886 1.436754 0.150691 +v 0.113333 1.436754 0.150691 +v 0.113333 1.500749 0.150691 +v 0.053886 1.50075 0.150691 +v 0.171851 1.436411 0.19988 +v 0.171851 1.500407 0.19988 +v 0.171851 1.436411 0.182309 +v 0.171851 1.500407 0.182309 +v 0.090704 1.464771 0.172232 +v 0.090704 1.472584 0.172232 +v 0.046426 1.472584 0.172232 +v 0.046426 1.464771 0.172232 +v 0.046426 1.464771 0.165511 +v 0.090704 1.464771 0.165511 +v 0.090704 1.472584 0.165511 +v 0.046426 1.472584 0.165511 +v -0.012905 0.871243 0.169436 +v -0.012905 1.384361 0.169436 +v -0.012905 1.384361 0.163744 +v -0.012905 0.871243 0.163744 +v 0.012905 1.384361 0.163744 +v 0.012905 0.871243 0.163744 +v 0.012905 1.384361 0.169436 +v 0.012905 0.871243 0.169436 +v -0.004778 1.026889 0.176498 +v -0.004778 1.036445 0.176498 +v -0.004778 1.036445 0.171 +v -0.004778 1.026889 0.171 +v 0.004778 1.026889 0.171 +v 0.004778 1.036445 0.171 +v 0.004778 1.036445 0.176498 +v 0.004778 1.026889 0.176498 +v -0.012905 0.950616 0.1924 +v -0.012905 1.022502 0.169217 +v 0.012905 1.022502 0.169217 +v 0.012905 0.950616 0.1924 +v 0.012905 0.86512 0.182335 +v 0.012905 0.862162 0.182335 +v 0.012905 0.871243 0.163808 +v 0.012905 0.874201 0.163808 +v -0.012905 0.950616 0.198091 +v -0.012905 1.022502 0.174908 +v -0.012905 0.869649 0.182335 +v 0.012905 0.869649 0.182335 +v 0.012905 0.87873 0.163808 +v -0.012905 0.87873 0.163808 +v 0.012905 1.022502 0.174908 +v 0.012905 0.950616 0.198091 +v -0.012905 0.874201 0.163808 +v -0.012905 0.86512 0.182335 +v -0.012905 0.862162 0.182335 +v -0.012905 0.871243 0.163808 +v -0.012905 0.871243 0.203622 +v 0.012905 0.871243 0.203622 +v 0.012905 0.874201 0.206554 +v -0.012905 0.874201 0.206554 +v 0.012905 1.052954 0.171975 +v -0.012905 1.052954 0.171975 +v -0.012905 1.039399 0.174908 +v 0.012905 1.039399 0.174908 +v 0.012905 0.87873 0.206554 +v -0.012905 0.87873 0.206554 +v -0.012905 0.874201 0.200863 +v -0.012905 0.871243 0.200863 +v 0.012905 0.871243 0.200863 +v 0.012905 0.874201 0.200863 +v -0.012905 1.039399 0.169217 +v 0.012905 1.052954 0.169217 +v -0.012905 1.052954 0.169217 +v 0.012905 1.039399 0.169217 +v -0.012905 0.87873 0.200863 +v 0.012905 0.87873 0.200863 +v 0 1.323193 -0.165858 +v 0 1.325739 -0.172004 +v 0.001249 1.326353 -0.171389 +v 0.001249 1.324062 -0.165858 +v 0.001249 1.327582 -0.17016 +v 0.001249 1.3258 -0.165858 +v 0 1.328197 -0.169545 +v 0 1.32667 -0.165858 +v -0.001249 1.327582 -0.17016 +v -0.001249 1.3258 -0.165858 +v -0.001249 1.326353 -0.171389 +v -0.001249 1.324062 -0.165858 +v 0 1.331885 -0.17455 +v 0.001249 1.331885 -0.17368 +v 0.001249 1.331885 -0.171942 +v 0 1.331885 -0.171073 +v -0.001249 1.331885 -0.171942 +v -0.001249 1.331885 -0.17368 +v 0 1.338031 -0.172004 +v 0.001249 1.337416 -0.171389 +v 0.001249 1.336187 -0.17016 +v 0 1.335572 -0.169545 +v -0.001249 1.336187 -0.17016 +v -0.001249 1.337416 -0.171389 +v 0 1.340577 -0.165858 +v 0.001249 1.339707 -0.165858 +v 0.001249 1.337969 -0.165858 +v 0 1.3371 -0.165858 +v -0.001249 1.337969 -0.165858 +v -0.001249 1.339707 -0.165858 +v 0 1.338031 -0.159712 +v 0.001249 1.337416 -0.160326 +v 0.001249 1.336187 -0.161555 +v 0 1.335572 -0.16217 +v -0.001249 1.336187 -0.161555 +v -0.001249 1.337416 -0.160326 +v 0 1.331885 -0.157166 +v 0.001249 1.331885 -0.158035 +v 0.001249 1.331885 -0.159773 +v 0 1.331885 -0.160643 +v -0.001249 1.331885 -0.159773 +v -0.001249 1.331885 -0.158035 +v 0 1.325739 -0.159712 +v 0.001249 1.326353 -0.160326 +v 0.001249 1.327582 -0.161555 +v 0 1.328197 -0.16217 +v -0.001249 1.327582 -0.161555 +v -0.001249 1.326353 -0.160326 +v 0 1.333916 -0.165858 +v -0.006146 1.336462 -0.165858 +v -0.005532 1.337077 -0.167107 +v 0 1.334785 -0.167107 +v -0.004302 1.338306 -0.167107 +v 0 1.336524 -0.167107 +v -0.003688 1.33892 -0.165858 +v 0 1.337393 -0.165858 +v -0.004302 1.338306 -0.164609 +v 0 1.336524 -0.164609 +v -0.005532 1.337077 -0.164609 +v 0 1.334785 -0.164609 +v -0.008692 1.342608 -0.165858 +v -0.007823 1.342608 -0.167107 +v -0.006084 1.342608 -0.167107 +v -0.005215 1.342608 -0.165858 +v -0.006084 1.342608 -0.164609 +v -0.007823 1.342608 -0.164609 +v -0.006146 1.348754 -0.165858 +v -0.005532 1.34814 -0.167107 +v -0.004302 1.34691 -0.167107 +v -0.003688 1.346296 -0.165858 +v -0.004302 1.34691 -0.164609 +v -0.005532 1.34814 -0.164609 +v 0 1.3513 -0.165858 +v 0 1.350431 -0.167107 +v 0 1.348692 -0.167107 +v 0 1.347823 -0.165858 +v 0 1.348692 -0.164609 +v 0 1.350431 -0.164609 +v 0.006146 1.348754 -0.165858 +v 0.005532 1.34814 -0.167107 +v 0.004302 1.34691 -0.167107 +v 0.003688 1.346296 -0.165858 +v 0.004302 1.34691 -0.164609 +v 0.005532 1.34814 -0.164609 +v 0.008692 1.342608 -0.165858 +v 0.007823 1.342608 -0.167107 +v 0.006084 1.342608 -0.167107 +v 0.005215 1.342608 -0.165858 +v 0.006084 1.342608 -0.164609 +v 0.007823 1.342608 -0.164609 +v 0.006146 1.336462 -0.165858 +v 0.005532 1.337077 -0.167107 +v 0.004302 1.338306 -0.167107 +v 0.003688 1.33892 -0.165858 +v 0.004302 1.338306 -0.164609 +v 0.005532 1.337077 -0.164609 +v 0 1.34412 -0.165858 +v 0 1.346666 -0.172004 +v 0.001249 1.347281 -0.171389 +v 0.001249 1.344989 -0.165858 +v 0.001249 1.34851 -0.17016 +v 0.001249 1.346728 -0.165858 +v 0 1.349124 -0.169545 +v 0 1.347597 -0.165858 +v -0.001249 1.34851 -0.17016 +v -0.001249 1.346728 -0.165858 +v -0.001249 1.347281 -0.171389 +v -0.001249 1.344989 -0.165858 +v 0 1.352812 -0.17455 +v 0.001249 1.352812 -0.17368 +v 0.001249 1.352812 -0.171942 +v 0 1.352812 -0.171073 +v -0.001249 1.352812 -0.171942 +v -0.001249 1.352812 -0.17368 +v 0 1.358958 -0.172004 +v 0.001249 1.358344 -0.171389 +v 0.001249 1.357114 -0.17016 +v 0 1.3565 -0.169545 +v -0.001249 1.357114 -0.17016 +v -0.001249 1.358344 -0.171389 +v 0 1.361504 -0.165858 +v 0.001249 1.360635 -0.165858 +v 0.001249 1.358896 -0.165858 +v 0 1.358027 -0.165858 +v -0.001249 1.358896 -0.165858 +v -0.001249 1.360635 -0.165858 +v 0 1.358958 -0.159712 +v 0.001249 1.358344 -0.160326 +v 0.001249 1.357114 -0.161555 +v 0 1.3565 -0.16217 +v -0.001249 1.357114 -0.161555 +v -0.001249 1.358344 -0.160326 +v 0 1.352812 -0.157166 +v 0.001249 1.352812 -0.158035 +v 0.001249 1.352812 -0.159773 +v 0 1.352812 -0.160643 +v -0.001249 1.352812 -0.159773 +v -0.001249 1.352812 -0.158035 +v 0 1.346666 -0.159712 +v 0.001249 1.347281 -0.160326 +v 0.001249 1.34851 -0.161555 +v 0 1.349124 -0.16217 +v -0.001249 1.34851 -0.161555 +v -0.001249 1.347281 -0.160326 +v 0.044934 1.398782 -0.165544 +v 0.038914 1.421249 -0.165544 +v 0.038159 1.420814 -0.163783 +v 0.044062 1.398782 -0.163783 +v 0.036603 1.419915 -0.163404 +v 0.042266 1.398782 -0.163404 +v 0.035517 1.419288 -0.164327 +v 0.041012 1.398782 -0.164327 +v 0.035237 1.419126 -0.165544 +v 0.040688 1.398782 -0.165544 +v 0.035517 1.419288 -0.166762 +v 0.041012 1.398782 -0.166762 +v 0.036603 1.419915 -0.167685 +v 0.042266 1.398782 -0.167685 +v 0.038159 1.420814 -0.167305 +v 0.044062 1.398782 -0.167305 +v 0.022467 1.437696 -0.165544 +v 0.022031 1.436942 -0.163783 +v 0.021133 1.435385 -0.163404 +v 0.020506 1.4343 -0.164327 +v 0.020344 1.434019 -0.165544 +v 0.020506 1.4343 -0.166762 +v 0.021133 1.435385 -0.167685 +v 0.022031 1.436942 -0.167305 +v 0 1.443716 -0.165544 +v 0 1.442845 -0.163783 +v 0 1.441048 -0.163404 +v 0 1.439794 -0.164327 +v 0 1.43947 -0.165544 +v 0 1.439794 -0.166762 +v 0 1.441048 -0.167685 +v 0 1.442845 -0.167305 +v -0.022467 1.437696 -0.165544 +v -0.022031 1.436942 -0.163783 +v -0.021133 1.435385 -0.163404 +v -0.020506 1.4343 -0.164327 +v -0.020344 1.434019 -0.165544 +v -0.020506 1.4343 -0.166762 +v -0.021133 1.435385 -0.167685 +v -0.022031 1.436942 -0.167305 +v -0.038914 1.421249 -0.165544 +v -0.038159 1.420814 -0.163783 +v -0.036603 1.419915 -0.163404 +v -0.035517 1.419288 -0.164327 +v -0.035237 1.419126 -0.165544 +v -0.035517 1.419288 -0.166762 +v -0.036603 1.419915 -0.167685 +v -0.038159 1.420814 -0.167305 +v -0.044934 1.398782 -0.165544 +v -0.044062 1.398782 -0.163783 +v -0.042266 1.398782 -0.163404 +v -0.041012 1.398782 -0.164327 +v -0.040688 1.398782 -0.165544 +v -0.041012 1.398782 -0.166762 +v -0.042266 1.398782 -0.167685 +v -0.044062 1.398782 -0.167305 +v -0.038914 1.376316 -0.165544 +v -0.038159 1.376751 -0.163783 +v -0.036603 1.37765 -0.163404 +v -0.035517 1.378276 -0.164327 +v -0.035237 1.378438 -0.165544 +v -0.035517 1.378276 -0.166762 +v -0.036603 1.37765 -0.167685 +v -0.038159 1.376751 -0.167305 +v -0.022467 1.359869 -0.165544 +v -0.022031 1.360623 -0.163783 +v -0.021133 1.362179 -0.163404 +v -0.020506 1.363265 -0.164327 +v -0.020344 1.363546 -0.165544 +v -0.020506 1.363265 -0.166762 +v -0.021133 1.362179 -0.167685 +v -0.022031 1.360623 -0.167305 +v 0 1.353849 -0.165544 +v 0 1.35472 -0.163783 +v 0 1.356517 -0.163404 +v 0 1.35777 -0.164327 +v 0 1.358095 -0.165544 +v 0 1.35777 -0.166762 +v 0 1.356517 -0.167685 +v 0 1.35472 -0.167305 +v 0.022467 1.359869 -0.165544 +v 0.022031 1.360623 -0.163783 +v 0.021133 1.362179 -0.163404 +v 0.020506 1.363265 -0.164327 +v 0.020344 1.363546 -0.165544 +v 0.020506 1.363265 -0.166762 +v 0.021133 1.362179 -0.167685 +v 0.022031 1.360623 -0.167305 +v 0.038914 1.376316 -0.165544 +v 0.038159 1.376751 -0.163783 +v 0.036603 1.37765 -0.163404 +v 0.035517 1.378276 -0.164327 +v 0.035237 1.378438 -0.165544 +v 0.035517 1.378276 -0.166762 +v 0.036603 1.37765 -0.167685 +v 0.038159 1.376751 -0.167305 +v 0.035241 1.41252 0.16659 +v 0.03052 1.43014 0.16659 +v 0.029928 1.429799 0.167971 +v 0.034558 1.41252 0.167971 +v 0.028707 1.429094 0.168268 +v 0.033148 1.41252 0.168268 +v 0.027856 1.428603 0.167544 +v 0.032165 1.41252 0.167544 +v 0.027636 1.428475 0.16659 +v 0.031911 1.41252 0.16659 +v 0.027856 1.428603 0.165635 +v 0.032165 1.41252 0.165635 +v 0.028707 1.429094 0.164911 +v 0.033148 1.41252 0.164911 +v 0.029928 1.429799 0.165208 +v 0.034558 1.41252 0.165208 +v 0.01762 1.44304 0.16659 +v 0.017279 1.442448 0.167971 +v 0.016574 1.441227 0.168268 +v 0.016083 1.440376 0.167544 +v 0.015956 1.440156 0.16659 +v 0.016083 1.440376 0.165635 +v 0.016574 1.441227 0.164911 +v 0.017279 1.442448 0.165208 +v 0 1.447761 0.16659 +v 0 1.447078 0.167971 +v 0 1.445668 0.168268 +v 0 1.444685 0.167544 +v 0 1.444431 0.16659 +v 0 1.444685 0.165635 +v 0 1.445668 0.164911 +v 0 1.447078 0.165208 +v -0.01762 1.44304 0.16659 +v -0.017279 1.442448 0.167971 +v -0.016574 1.441227 0.168268 +v -0.016083 1.440376 0.167544 +v -0.015956 1.440156 0.16659 +v -0.016083 1.440376 0.165635 +v -0.016574 1.441227 0.164911 +v -0.017279 1.442448 0.165208 +v -0.03052 1.43014 0.16659 +v -0.029928 1.429799 0.167971 +v -0.028707 1.429094 0.168268 +v -0.027856 1.428603 0.167544 +v -0.027636 1.428475 0.16659 +v -0.027856 1.428603 0.165635 +v -0.028707 1.429094 0.164911 +v -0.029928 1.429799 0.165208 +v -0.035241 1.41252 0.16659 +v -0.034558 1.41252 0.167971 +v -0.033148 1.41252 0.168268 +v -0.032165 1.41252 0.167544 +v -0.031911 1.41252 0.16659 +v -0.032165 1.41252 0.165635 +v -0.033148 1.41252 0.164911 +v -0.034558 1.41252 0.165208 +v -0.03052 1.394899 0.16659 +v -0.029928 1.395241 0.167971 +v -0.028707 1.395946 0.168268 +v -0.027856 1.396437 0.167544 +v -0.027636 1.396564 0.16659 +v -0.027856 1.396437 0.165635 +v -0.028707 1.395946 0.164911 +v -0.029928 1.395241 0.165208 +v -0.01762 1.382 0.16659 +v -0.017279 1.382592 0.167971 +v -0.016574 1.383813 0.168268 +v -0.016083 1.384664 0.167544 +v -0.015956 1.384884 0.16659 +v -0.016083 1.384664 0.165635 +v -0.016574 1.383813 0.164911 +v -0.017279 1.382592 0.165208 +v 0 1.377279 0.16659 +v 0 1.377962 0.167971 +v 0 1.379372 0.168268 +v 0 1.380355 0.167544 +v 0 1.380609 0.16659 +v 0 1.380355 0.165635 +v 0 1.379372 0.164911 +v 0 1.377962 0.165208 +v 0.01762 1.382 0.16659 +v 0.017279 1.382592 0.167971 +v 0.016574 1.383813 0.168268 +v 0.016083 1.384664 0.167544 +v 0.015956 1.384884 0.16659 +v 0.016083 1.384664 0.165635 +v 0.016574 1.383813 0.164911 +v 0.017279 1.382592 0.165208 +v 0.03052 1.394899 0.16659 +v 0.029928 1.395241 0.167971 +v 0.028707 1.395946 0.168268 +v 0.027856 1.396437 0.167544 +v 0.027636 1.396564 0.16659 +v 0.027856 1.396437 0.165635 +v 0.028707 1.395946 0.164911 +v 0.029928 1.395241 0.165208 +vt 0.207290625 0.983815625 +vt 0.207290625 1 +vt 0.2036 1 +vt 0.2036 0.983815625 +vt 0.28474687499999995 0.8900671874999999 +vt 0.284746875 0.90625 +vt 0.2818546875 0.90625 +vt 0.2818546875 0.8900671874999999 +vt 0.316146875 0.953125 +vt 0.316146875 0.9369421874999999 +vt 0.3198375 0.9369421874999999 +vt 0.3198375 0.953125 +vt 0.203534375 0.90625 +vt 0.203534375 0.8900671874999999 +vt 0.2064265625 0.8900671874999999 +vt 0.2064265625 0.90625 +vt 0.0148984375 0.9271765625 +vt 0.0011078124999999994 0.9175875 +vt 0.0025765624999999973 0.9150953125 +vt 0.016375 0.92466875 +vt 0.016375 0.92466875 +vt 0.0025765624999999973 0.9150953125 +vt 0.006118749999999999 0.9140625 +vt 0.019943750000000003 0.9236296875 +vt 0.019943750000000003 0.8810578125 +vt 0.006118749999999999 0.890625 +vt 0.0025765624999999973 0.8895921875 +vt 0.016375 0.88001875 +vt 0.016375 0.88001875 +vt 0.0025765624999999973 0.8895921875 +vt 0.0011078124999999994 0.8871 +vt 0.0148984375 0.8775109375 +vt 0.006118749999999999 0.8081484375 +vt 0.019943750000000003 0.817715625 +vt 0.016375 0.8187546875 +vt 0.0025765624999999973 0.80918125 +vt 0.0025765624999999973 0.80918125 +vt 0.016375 0.8187546875 +vt 0.0148984375 0.8212625 +vt 0.0011078124999999994 0.811671875 +vt 0.019943750000000003 0.986971875 +vt 0.006118749999999999 0.9965390625 +vt 0.0025765624999999973 0.99550625 +vt 0.016375 0.9859328125 +vt 0.016375 0.9859328125 +vt 0.0025765624999999973 0.99550625 +vt 0.0011078124999999994 0.993015625 +vt 0.0148984375 0.983425 +vt 0.0011078124999999994 0.993015625 +vt 0.0011078124999999994 0.9175875 +vt 0.0148984375 0.9271765625 +vt 0.0148984375 0.983425 +vt 0.2818546875 0.8900671874999999 +vt 0.2818546875 0.90625 +vt 0.2064265625 0.90625 +vt 0.2064265625 0.8900671874999999 +vt 0.26171875 0.9838171874999999 +vt 0.26171875 1 +vt 0.207290625 1 +vt 0.207290625 0.9838171874999999 +vt 0.060546875 0.9965390625 +vt 0.006118749999999999 0.9965390625 +vt 0.019943750000000003 0.986971875 +vt 0.060546875 0.986971875 +vt 0.0011078124999999994 0.8871 +vt 0.0011078124999999994 0.811671875 +vt 0.0148984375 0.8212625 +vt 0.0148984375 0.8775109375 +vt 0.140625 0.71875 +vt 0.125 0.71875 +vt 0.125 0.703125 +vt 0.140625 0.703125 +vt 0.470703125 0.989496875 +vt 0.4725203125 0.989496875 +vt 0.4725203125 1 +vt 0.470703125 1 +vt 0.12560859375 0.5935765625 +vt 0.12560859375 0.5769093750000001 +vt 0.12746484375 0.5784046875 +vt 0.12746484375 0.59208125 +vt 0.12560859375 0.5769093750000001 +vt 0.13611015625 0.5767359375000001 +vt 0.13608203124999999 0.5782625 +vt 0.12746484375 0.5784046875 +vt 0.13611015625 0.59375 +vt 0.12560859375 0.5935765625 +vt 0.12746484375 0.59208125 +vt 0.13608203124999999 0.5922234375 +vt 0.3787703125 0.5458328125 +vt 0.3787703125 0.5625 +vt 0.376953125 0.5625 +vt 0.376953125 0.5458328125 +vt 0.0037703125000000002 0.5300359375 +vt 0.001953125 0.5300359375 +vt 0.001953125 0.51953125 +vt 0.0037703125000000002 0.51953125 +vt 0.412109375 0.953125 +vt 0.412109375 0.951090625 +vt 0.4162078125 0.951090625 +vt 0.4174109375 0.953125 +vt 0.0630578125 0.5758140625 +vt 0.0642609375 0.5778484374999999 +vt 0.0642609375 0.591715625 +vt 0.0630578125 0.59375 +vt 0.006118749999999999 0.9140625 +vt 0.060546875 0.9140625 +vt 0.060546875 0.9236296875 +vt 0.019943750000000003 0.9236296875 +vt 0.4068078125 0.9351890625 +vt 0.4080109375 0.9372234374999999 +vt 0.4080109375 0.951090625 +vt 0.4068078125 0.953125 +vt 0.316146875 0.953125 +vt 0.26171875 0.953125 +vt 0.26171875 0.9369421874999999 +vt 0.316146875 0.9369421874999999 +vt 0.0736609375 0.5758140625 +vt 0.0724578125 0.5778484374999999 +vt 0.0642609375 0.5778484374999999 +vt 0.0630578125 0.5758140625 +vt 0.060546875 0.890625 +vt 0.006118749999999999 0.890625 +vt 0.019943750000000003 0.8810578125 +vt 0.060546875 0.8810578125 +vt 0.0736609375 0.59375 +vt 0.0724578125 0.591715625 +vt 0.0724578125 0.5778484374999999 +vt 0.0736609375 0.5758140625 +vt 0.15625 0.71875 +vt 0.15625 0.703125 +vt 0.171875 0.703125 +vt 0.171875 0.71875 +vt 0.1875 0.71875 +vt 0.1875 0.703125 +vt 0.203125 0.703125 +vt 0.203125 0.71875 +vt 0.34695234375 0.5248328125 +vt 0.34695234375 0.530134375 +vt 0.34445390625 0.530134375 +vt 0.34445390625 0.5248328125 +vt 0.068359375 0.59375 +vt 0.068359375 0.591715625 +vt 0.0724578125 0.591715625 +vt 0.0736609375 0.59375 +vt 0.4174109375 0.9351890625 +vt 0.4162078125 0.9372234374999999 +vt 0.4080109375 0.9372234374999999 +vt 0.4068078125 0.9351890625 +vt 0.4174109375 0.953125 +vt 0.4162078125 0.951090625 +vt 0.4162078125 0.9372234374999999 +vt 0.4174109375 0.9351890625 +vt 0.296875 0.8125 +vt 0.28125 0.8125 +vt 0.28125 0.796875 +vt 0.296875 0.796875 +vt 0.396484375 0.78125 +vt 0.396484375 0.779215625 +vt 0.4005828125 0.779215625 +vt 0.4017859375 0.78125 +vt 0.3911828125 0.7164390625 +vt 0.3923859375 0.7184734374999999 +vt 0.3923859375 0.732340625 +vt 0.3911828125 0.734375 +vt 0.3911828125 0.7633140625 +vt 0.3923859375 0.7653484374999999 +vt 0.3923859375 0.779215625 +vt 0.3911828125 0.78125 +vt 0.4017859375 0.7164390625 +vt 0.4005828125 0.7184734374999999 +vt 0.3923859375 0.7184734374999999 +vt 0.3911828125 0.7164390625 +vt 0.4017859375 0.734375 +vt 0.4005828125 0.732340625 +vt 0.4005828125 0.7184734374999999 +vt 0.4017859375 0.7164390625 +vt 0.265625 0.71875 +vt 0.265625 0.703125 +vt 0.28125 0.703125 +vt 0.28125 0.71875 +vt 0 0.703125 +vt 0 0.6875 +vt 0.015625 0.6875 +vt 0.015625 0.703125 +vt 0.4719515625 0.5873328125 +vt 0.4719515625 0.592634375 +vt 0.4694546875 0.592634375 +vt 0.4694546875 0.5873328125 +vt 0.396484375 0.734375 +vt 0.396484375 0.732340625 +vt 0.4005828125 0.732340625 +vt 0.4017859375 0.734375 +vt 0.4017859375 0.7633140625 +vt 0.4005828125 0.7653484374999999 +vt 0.3923859375 0.7653484374999999 +vt 0.3911828125 0.7633140625 +vt 0.4017859375 0.78125 +vt 0.4005828125 0.779215625 +vt 0.4005828125 0.7653484374999999 +vt 0.4017859375 0.7633140625 +vt 0.046875 0.703125 +vt 0.03125 0.703125 +vt 0.03125 0.6875 +vt 0.046875 0.6875 +vt 0.412109375 1 +vt 0.412109375 0.997965625 +vt 0.4162078125 0.997965625 +vt 0.4174109375 1 +vt 0.2193081548412004 0.5914343385965785 +vt 0.2205112798412004 0.5934687135965784 +vt 0.2205112798412004 0.6073359010965784 +vt 0.2193081548412004 0.6093702760965785 +vt 0.4068078125 0.9820640625 +vt 0.4080109375 0.9840984374999999 +vt 0.4080109375 0.997965625 +vt 0.4068078125 1 +vt 0.22991127984120038 0.5914343385965785 +vt 0.22870815484120038 0.5934687135965784 +vt 0.2205112798412004 0.5934687135965784 +vt 0.2193081548412004 0.5914343385965785 +vt 0.22991127984120038 0.6093702760965785 +vt 0.22870815484120038 0.6073359010965784 +vt 0.22870815484120038 0.5934687135965784 +vt 0.22991127984120038 0.5914343385965785 +vt 0.21875 0.703125 +vt 0.21875 0.6875 +vt 0.234375 0.6875 +vt 0.234375 0.703125 +vt 0.296875 0.78125 +vt 0.296875 0.765625 +vt 0.3125 0.765625 +vt 0.3125 0.78125 +vt 0.47195234375 0.6185828125 +vt 0.47195234375 0.623884375 +vt 0.46945390625 0.623884375 +vt 0.46945390625 0.6185828125 +vt 0.22460903235894575 0.6093732169161372 +vt 0.2246097171093947 0.6073372795312888 +vt 0.228708154377589 0.6073386579659992 +vt 0.22991059455909185 0.609375 +vt 0.4174109375 0.9820640625 +vt 0.4162078125 0.9840984374999999 +vt 0.4080109375 0.9840984374999999 +vt 0.4068078125 0.9820640625 +vt 0.4174109375 1 +vt 0.4162078125 0.997965625 +vt 0.4162078125 0.9840984374999999 +vt 0.4174109375 0.9820640625 +vt 0.006118749999999999 0.8081484375 +vt 0.060546875 0.8081484375 +vt 0.060546875 0.817715625 +vt 0.019943750000000003 0.817715625 +vt 0.2335953125 0.8125 +vt 0.21875 0.8125 +vt 0.21875 0.80916875 +vt 0.231071875 0.80916875 +vt 0.11974375 0.5449843750000001 +vt 0.107421875 0.5449843750000001 +vt 0.107421875 0.54296875 +vt 0.11974375 0.54296875 +vt 0.003257031249999997 0.578125 +vt 0.0006492187500000031 0.578125 +vt 0.0006492187500000031 0.5632796875 +vt 0.003257031249999997 0.5632796875 +vt 0.4375 0.7799515625 +vt 0.4375 0.77734375 +vt 0.4523453125 0.77734375 +vt 0.4523453125 0.7799515625 +vt 0.296875 0.734375 +vt 0.3125 0.734375 +vt 0.3125 0.75 +vt 0.296875 0.75 +vt 0.21875 0.7893859375000001 +vt 0.2335953125 0.7893859375000001 +vt 0.231071875 0.7927171875000001 +vt 0.21875 0.7927171875000001 +vt 0.2335953125 0.7893859375000001 +vt 0.2335953125 0.8125 +vt 0.231071875 0.80916875 +vt 0.231071875 0.7927171875000001 +vt 0.3125 0.703125 +vt 0.3125 0.71875 +vt 0.296875 0.71875 +vt 0.296875 0.703125 +vt 0.0654609375 0.534553125 +vt 0.0654609375 0.546875 +vt 0.0634453125 0.546875 +vt 0.0634453125 0.534553125 +vt 0.316146875 0.9838171874999999 +vt 0.3198375 0.9838171874999999 +vt 0.3198375 1 +vt 0.316146875 1 +vt 0.20353515625000002 0.8431921874999999 +vt 0.20642734375 0.8431921874999999 +vt 0.20642734375 0.859375 +vt 0.20353515625000002 0.859375 +vt 0.207290625 0.953125 +vt 0.2036 0.953125 +vt 0.2036 0.9369421874999999 +vt 0.207290625 0.9369421874999999 +vt 0.28474609375 0.859375 +vt 0.28185390625 0.859375 +vt 0.28185390625 0.8431921874999999 +vt 0.28474609375 0.8431921874999999 +vt 0.1061953125 0.9271765625 +vt 0.10471875 0.92466875 +vt 0.11851718750000001 0.9150953125 +vt 0.1199859375 0.9175875 +vt 0.10471875 0.92466875 +vt 0.10114999999999999 0.9236296875 +vt 0.114975 0.9140625 +vt 0.11851718750000001 0.9150953125 +vt 0.10114999999999999 0.8810578125 +vt 0.10471875 0.88001875 +vt 0.11851718750000001 0.8895921875 +vt 0.114975 0.890625 +vt 0.10471875 0.88001875 +vt 0.1061953125 0.8775109375 +vt 0.1199859375 0.8871 +vt 0.11851718750000001 0.8895921875 +vt 0.114975 0.8081484375 +vt 0.11851718750000001 0.80918125 +vt 0.10471875 0.8187546875 +vt 0.10114999999999999 0.817715625 +vt 0.11851718750000001 0.80918125 +vt 0.1199859375 0.8116734375 +vt 0.1061953125 0.8212625 +vt 0.10471875 0.8187546875 +vt 0.10114999999999999 0.986971875 +vt 0.10471875 0.9859328125 +vt 0.1185171875 0.99550625 +vt 0.114975 0.9965390625 +vt 0.10471875 0.9859328125 +vt 0.1061953125 0.983425 +vt 0.1199859375 0.9930140625 +vt 0.11851718750000001 0.99550625 +vt 0.1199859375 0.9930140625 +vt 0.1061953125 0.983425 +vt 0.1061953125 0.9271765625 +vt 0.1199859375 0.9175875 +vt 0.20642734375 0.8431921874999999 +vt 0.28185390625 0.8431921874999999 +vt 0.28185390625 0.859375 +vt 0.20642734375 0.859375 +vt 0.26171875 0.9838171874999999 +vt 0.316146875 0.9838171874999999 +vt 0.316146875 1 +vt 0.26171875 1 +vt 0.060546875 0.9965390625 +vt 0.060546875 0.986971875 +vt 0.10114999999999999 0.986971875 +vt 0.114975 0.9965390625 +vt 0.1199859375 0.8871 +vt 0.1061953125 0.8775109375 +vt 0.1061953125 0.8212625 +vt 0.1199859375 0.8116734375 +vt 0.0625 0.6875 +vt 0.0625 0.671875 +vt 0.078125 0.671875 +vt 0.078125 0.6875 +vt 0.470703125 0.989496875 +vt 0.470703125 1 +vt 0.4688859375 1 +vt 0.4688859375 0.989496875 +vt 0.41736015625 0.8592015625 +vt 0.41550390625 0.85770625 +vt 0.41550390625 0.8440296875 +vt 0.41736015625 0.8425343750000001 +vt 0.41736015625 0.8425343750000001 +vt 0.41550390625 0.8440296875 +vt 0.40688671875 0.8438875 +vt 0.40685859375 0.8423609375000001 +vt 0.40685859375 0.859375 +vt 0.40688671875 0.8578484375 +vt 0.41550390625 0.85770625 +vt 0.41736015625 0.8592015625 +vt 0.3751359375 0.5458328125 +vt 0.376953125 0.5458328125 +vt 0.376953125 0.5625 +vt 0.3751359375 0.5625 +vt 0.0001359375 0.5300359375 +vt 0.0001359375 0.51953125 +vt 0.001953125 0.51953125 +vt 0.001953125 0.5300359375 +vt 0.099609375 0.59375 +vt 0.0943078125 0.59375 +vt 0.0955109375 0.591715625 +vt 0.099609375 0.591715625 +vt 0.4174109375 0.8883140625 +vt 0.4174109375 0.90625 +vt 0.4162078125 0.904215625 +vt 0.4162078125 0.8903484374999999 +vt 0.114975 0.9140625 +vt 0.10114999999999999 0.9236296875 +vt 0.060546875 0.9236296875 +vt 0.060546875 0.9140625 +vt 0.1049109375 0.5758140625 +vt 0.1049109375 0.59375 +vt 0.1037078125 0.591715625 +vt 0.1037078125 0.5778484374999999 +vt 0.207290625 0.953125 +vt 0.207290625 0.9369421874999999 +vt 0.26171875 0.9369421874999999 +vt 0.26171875 0.953125 +vt 0.4068078125 0.8883140625 +vt 0.4174109375 0.8883140625 +vt 0.4162078125 0.8903484374999999 +vt 0.4080109375 0.8903484374999999 +vt 0.060546875 0.890625 +vt 0.060546875 0.8810578125 +vt 0.10114999999999999 0.8810578125 +vt 0.114975 0.890625 +vt 0.4068078125 0.90625 +vt 0.4068078125 0.8883140625 +vt 0.4080109375 0.8903484374999999 +vt 0.4080109375 0.904215625 +vt 0.109375 0.6875 +vt 0.09375 0.6875 +vt 0.09375 0.671875 +vt 0.109375 0.671875 +vt 0.328125 0.90625 +vt 0.3125 0.90625 +vt 0.3125 0.890625 +vt 0.328125 0.890625 +vt 0.46945390625 0.6498328125 +vt 0.47195234375 0.6498328125 +vt 0.47195234375 0.655134375 +vt 0.46945390625 0.655134375 +vt 0.412109375 0.90625 +vt 0.4068078125 0.90625 +vt 0.4080109375 0.904215625 +vt 0.412109375 0.904215625 +vt 0.0943078125 0.5758140625 +vt 0.1049109375 0.5758140625 +vt 0.1037078125 0.5778484374999999 +vt 0.0955109375 0.5778484374999999 +vt 0.0943078125 0.59375 +vt 0.0943078125 0.5758140625 +vt 0.0955109375 0.5778484374999999 +vt 0.0955109375 0.591715625 +vt 0.114975 0.8081484375 +vt 0.10114999999999999 0.817715625 +vt 0.060546875 0.817715625 +vt 0.060546875 0.8081484375 +vt 0.2039046875 0.8125 +vt 0.206428125 0.80916875 +vt 0.21875 0.80916875 +vt 0.21875 0.8125 +vt 0.0951 0.5449843750000001 +vt 0.0951 0.54296875 +vt 0.107421875 0.54296875 +vt 0.107421875 0.5449843750000001 +vt 0.003257031249999997 0.548434375 +vt 0.003257031249999997 0.5632796875 +vt 0.0006492187500000031 0.5632796875 +vt 0.0006492187500000031 0.548434375 +vt 0.4375 0.7799515625 +vt 0.4226546875 0.7799515625 +vt 0.4226546875 0.77734375 +vt 0.4375 0.77734375 +vt 0.140625 0.671875 +vt 0.140625 0.6875 +vt 0.125 0.6875 +vt 0.125 0.671875 +vt 0.21875 0.7893859375000001 +vt 0.21875 0.7927171875000001 +vt 0.206428125 0.7927171875000001 +vt 0.2039046875 0.7893859375000001 +vt 0.2039046875 0.7893859375000001 +vt 0.206428125 0.7927171875000001 +vt 0.206428125 0.80916875 +vt 0.2039046875 0.8125 +vt 0.3125 0.859375 +vt 0.328125 0.859375 +vt 0.328125 0.875 +vt 0.3125 0.875 +vt 0.0654609375 0.534553125 +vt 0.0634453125 0.534553125 +vt 0.0634453125 0.5222312499999999 +vt 0.0654609375 0.5222312499999999 +vt 0.00156953125 0.49598125000000004 +vt 0.00233671875 0.49598125000000004 +vt 0.00233671875 0.4992875 +vt 0.00156953125 0.4992875 +vt 0.09525703125 0.4836625 +vt 0.09686640625 0.4836625 +vt 0.09686640625 0.484375 +vt 0.09448984375 0.484375 +vt 0.09525703125 0.4827390625 +vt 0.09448984375 0.48273750000000004 +vt 0.09448984375 0.4820249999999999 +vt 0.09525703125 0.4820265625 +vt 0.09448984375 0.48035625000000004 +vt 0.09691640625 0.48035625000000004 +vt 0.09691640625 0.48106874999999993 +vt 0.09525703125 0.48106874999999993 +vt 0.09525703125 0.4820265625 +vt 0.09681015625 0.4820265625 +vt 0.09681015625 0.4827390625 +vt 0.09525703125 0.4827390625 +vt 0.15625 0.6875 +vt 0.15625 0.671875 +vt 0.171875 0.671875 +vt 0.171875 0.6875 +vt 0.328125 0.84375 +vt 0.3125 0.84375 +vt 0.3125 0.828125 +vt 0.328125 0.828125 +vt 0.1875 0.6875 +vt 0.1875 0.671875 +vt 0.203125 0.671875 +vt 0.203125 0.6875 +vt 0.3125 0.796875 +vt 0.328125 0.796875 +vt 0.328125 0.8125 +vt 0.3125 0.8125 +vt 0.25 0.6875 +vt 0.25 0.671875 +vt 0.265625 0.671875 +vt 0.265625 0.6875 +vt 0.296875 0.6875 +vt 0.28125 0.6875 +vt 0.28125 0.671875 +vt 0.296875 0.671875 +vt 0.3125 0.6875 +vt 0.3125 0.671875 +vt 0.328125 0.671875 +vt 0.328125 0.6875 +vt 0 0.65625 +vt 0.015625 0.65625 +vt 0.015625 0.671875 +vt 0 0.671875 +vt 0.06479609375 0.46473125000000004 +vt 0.06479609375 0.4663999999999999 +vt 0.06411015625 0.4663999999999999 +vt 0.06411015625 0.46473125000000004 +vt 0.046875 0.671875 +vt 0.03125 0.671875 +vt 0.03125 0.65625 +vt 0.046875 0.65625 +vt 0.21875 0.671875 +vt 0.21875 0.65625 +vt 0.234375 0.65625 +vt 0.234375 0.671875 +vt 0.328125 0.765625 +vt 0.34375 0.765625 +vt 0.34375 0.78125 +vt 0.328125 0.78125 +vt 0.359375 0.78125 +vt 0.359375 0.765625 +vt 0.375 0.765625 +vt 0.375 0.78125 +vt 0.328125 0.734375 +vt 0.34375 0.734375 +vt 0.34375 0.75 +vt 0.328125 0.75 +vt 0.234375 0.625 +vt 0.234375 0.640625 +vt 0.21875 0.640625 +vt 0.21875 0.625 +vt 0.34375 0.703125 +vt 0.34375 0.71875 +vt 0.328125 0.71875 +vt 0.328125 0.703125 +vt 0.53282109375 0.90625 +vt 0.53150546875 0.90625 +vt 0.53150546875 0.9055640625 +vt 0.53282109375 0.9055640625 +vt 0.34375 1 +vt 0.34375 0.984375 +vt 0.359375 0.984375 +vt 0.359375 1 +vt 0.359375 0.71875 +vt 0.359375 0.703125 +vt 0.375 0.703125 +vt 0.375 0.71875 +vt 0.09448984375 0.48273750000000004 +vt 0.09525703125 0.4827390625 +vt 0.09525703125 0.4836625 +vt 0.09448984375 0.484375 +vt 0.09525703125 0.4820265625 +vt 0.09448984375 0.4820249999999999 +vt 0.09448984375 0.48035625000000004 +vt 0.09525703125 0.48106874999999993 +vt 0.06411015625 0.4663999999999999 +vt 0.06479609375 0.4663999999999999 +vt 0.06479609375 0.46711250000000004 +vt 0.06411015625 0.4671109375 +vt 0.06411015625 0.4671109375 +vt 0.06479609375 0.46711250000000004 +vt 0.06479609375 0.46875 +vt 0.06411015625 0.46875 +vt 0.00233671875 0.4992875 +vt 0.00365078125 0.4992875 +vt 0.00365078125 0.5 +vt 0.00233515625 0.5 +vt 0.00025546875000000005 0.4992875 +vt 0.00156953125 0.4992875 +vt 0.0015710937499999998 0.5 +vt 0.00025546875000000005 0.5 +vt 0.00156953125 0.4992875 +vt 0.00233671875 0.4992875 +vt 0.00233515625 0.5 +vt 0.0015710937499999998 0.5 +vt 0.375 0.734375 +vt 0.375 0.75 +vt 0.359375 0.75 +vt 0.359375 0.734375 +vt 0.53490078125 0.90625 +vt 0.53358515625 0.90625 +vt 0.53358515625 0.9055640625 +vt 0.53490078125 0.9055640625 +vt 0.53358515625 0.90625 +vt 0.53282109375 0.90625 +vt 0.53282109375 0.9055640625 +vt 0.53358515625 0.9055640625 +vt 0.5014125 0.5139609375 +vt 0.50064375 0.513959375 +vt 0.50064375 0.513246875 +vt 0.5014125 0.5132484374999999 +vt 0.501940625 0.515625 +vt 0.5014125 0.515625 +vt 0.5014125 0.5149125 +vt 0.501940625 0.5149125 +vt 0.5032625 0.514421875 +vt 0.5031828125 0.5148874999999999 +vt 0.5024484375 0.514615625 +vt 0.5024734375 0.514446875 +vt 0.5023296875 0.514090625 +vt 0.502915625 0.5135828124999999 +vt 0.503178125 0.5139609375 +vt 0.502434375 0.5142375 +vt 0.50189375 0.5139609375 +vt 0.5014125 0.5139609375 +vt 0.5014125 0.5132484374999999 +vt 0.50189375 0.5132484374999999 +vt 0.5021578125 0.513996875 +vt 0.50189375 0.5139609375 +vt 0.50189375 0.5132484374999999 +vt 0.5024765625 0.513334375 +vt 0.5014125 0.5132484374999999 +vt 0.50064375 0.513246875 +vt 0.50064375 0.51160625 +vt 0.5014125 0.51160625 +vt 0.34688984375 0.4524390625 +vt 0.34688984375 0.453125 +vt 0.34612109375 0.453125 +vt 0.34612109375 0.4524390625 +vt 0.53286015625 0.93348125 +vt 0.53354609375 0.93348125 +vt 0.53354609375 0.935121875 +vt 0.53286015625 0.935121875 +vt 0.34375 0.96875 +vt 0.34375 0.953125 +vt 0.359375 0.953125 +vt 0.359375 0.96875 +vt 0.078125 0.65625 +vt 0.0625 0.65625 +vt 0.0625 0.640625 +vt 0.078125 0.640625 +vt 0.5647593750000001 0.7155296875 +vt 0.5647593750000001 0.71484375 +vt 0.565240625 0.71484375 +vt 0.565240625 0.7155296875 +vt 0.5641703125 0.7155296875 +vt 0.5641703125 0.71484375 +vt 0.5647593750000001 0.71484375 +vt 0.5647593750000001 0.7155296875 +vt 0.563665625 0.7155296875 +vt 0.563665625 0.71484375 +vt 0.5641703125 0.71484375 +vt 0.5641703125 0.7155296875 +vt 0.37729609375 0.45172812500000004 +vt 0.37661015625 0.45172812500000004 +vt 0.37661015625 0.45126875 +vt 0.37729609375 0.45126875 +vt 0.37729609375 0.4521953125 +vt 0.37661015625 0.4521953125 +vt 0.37661015625 0.45172812500000004 +vt 0.37729609375 0.45172812500000004 +vt 0.37729609375 0.4526671875 +vt 0.37661015625 0.4526671875 +vt 0.37661015625 0.4521953125 +vt 0.37729609375 0.4521953125 +vt 0.37729609375 0.453125 +vt 0.37661015625 0.453125 +vt 0.37661015625 0.4526671875 +vt 0.37729609375 0.4526671875 +vt 0.34500703125 0.4524390625 +vt 0.34500703125 0.453125 +vt 0.34451640625 0.453125 +vt 0.34451640625 0.4524390625 +vt 0.53354609375 0.9375 +vt 0.53286015625 0.9375 +vt 0.53286015625 0.9367875 +vt 0.53354609375 0.9367875 +vt 0.502434375 0.5142375 +vt 0.503178125 0.5139609375 +vt 0.5032625 0.514421875 +vt 0.5024734375 0.514446875 +vt 0.5021578125 0.513996875 +vt 0.5024765625 0.513334375 +vt 0.502915625 0.5135828124999999 +vt 0.5023296875 0.514090625 +vt 0.5024484375 0.514615625 +vt 0.5031828125 0.5148874999999999 +vt 0.5029359375 0.5152749999999999 +vt 0.502365625 0.5147609375 +vt 0.50251875 0.515534375 +vt 0.502209375 0.514865625 +vt 0.502365625 0.5147609375 +vt 0.5029359375 0.5152749999999999 +vt 0.501940625 0.515625 +vt 0.501940625 0.5149125 +vt 0.502209375 0.514865625 +vt 0.50251875 0.515534375 +vt 0.50064375 0.5149125 +vt 0.5014125 0.5149125 +vt 0.5014125 0.515625 +vt 0.50064375 0.515625 +vt 0.50064375 0.513959375 +vt 0.5014125 0.5139609375 +vt 0.5014125 0.5149125 +vt 0.50064375 0.5149125 +vt 0.53354609375 0.9367875 +vt 0.53286015625 0.9367875 +vt 0.53286015625 0.935834375 +vt 0.53354609375 0.935834375 +vt 0.34559296875 0.4524390625 +vt 0.34559296875 0.453125 +vt 0.34500703125 0.453125 +vt 0.34500703125 0.4524390625 +vt 0.31444296875 0.4055640625 +vt 0.31444296875 0.40625 +vt 0.31417578125 0.40625 +vt 0.31417578125 0.4055640625 +vt 0.53354609375 0.935834375 +vt 0.53286015625 0.935834375 +vt 0.53286015625 0.935121875 +vt 0.53354609375 0.935121875 +vt 0.31492421875 0.4055640625 +vt 0.31492421875 0.40625 +vt 0.31444296875 0.40625 +vt 0.31444296875 0.4055640625 +vt 0.359375 0.9375 +vt 0.34375 0.9375 +vt 0.34375 0.921875 +vt 0.359375 0.921875 +vt 0.59566953125 0.7155296875 +vt 0.59566953125 0.71484375 +vt 0.59619765625 0.71484375 +vt 0.59619765625 0.7155296875 +vt 0.59539765625 0.7155296875 +vt 0.59539765625 0.71484375 +vt 0.59566953125 0.71484375 +vt 0.59566953125 0.7155296875 +vt 0.59520859375 0.7155296875 +vt 0.59520859375 0.71484375 +vt 0.59539765625 0.71484375 +vt 0.59539765625 0.7155296875 +vt 0.59536015625 0.6841593749999999 +vt 0.59604609375 0.6841593749999999 +vt 0.59604609375 0.6843265624999999 +vt 0.59536015625 0.6843265624999999 +vt 0.59536015625 0.6839890625 +vt 0.59604609375 0.6839890625 +vt 0.59604609375 0.6841593749999999 +vt 0.59536015625 0.6841593749999999 +vt 0.59536015625 0.683775 +vt 0.59604609375 0.683775 +vt 0.59604609375 0.6839890625 +vt 0.59536015625 0.6839890625 +vt 0.59536015625 0.68359375 +vt 0.59604609375 0.68359375 +vt 0.59604609375 0.683775 +vt 0.59536015625 0.683775 +vt 0.31417578125 0.4055640625 +vt 0.31417578125 0.40625 +vt 0.31398203125 0.40625 +vt 0.31398203125 0.4055640625 +vt 0.34559296875 0.453125 +vt 0.34559296875 0.4524390625 +vt 0.34612109375 0.4524390625 +vt 0.34612109375 0.453125 +vt 0.04906640625 0.850171875 +vt 0.04492109375 0.850171875 +vt 0.04492109375 0.849115625 +vt 0.04906640625 0.849115625 +vt 0.04906640625 0.8549046874999999 +vt 0.04492109375 0.8549046874999999 +vt 0.04492109375 0.8525390625 +vt 0.04906640625 0.8525390625 +vt 0.05809453125 0.8549046874999999 +vt 0.05321015625 0.8549046874999999 +vt 0.05321015625 0.8525390625 +vt 0.055832031250000004 0.8525390625 +vt 0.05809453125 0.850171875 +vt 0.05321015625 0.850171875 +vt 0.05321015625 0.845703125 +vt 0.05571953125 0.8461390625 +vt 0.05571953125 0.8589390625 +vt 0.05321015625 0.859375 +vt 0.05321015625 0.8549046874999999 +vt 0.05809453125 0.8549046874999999 +vt 0.04906640625 0.8559625 +vt 0.04492109375 0.8559625 +vt 0.04492109375 0.8549046874999999 +vt 0.04906640625 0.8549046874999999 +vt 0.055832031250000004 0.8525390625 +vt 0.05321015625 0.8525390625 +vt 0.05321015625 0.850171875 +vt 0.05809453125 0.850171875 +vt 0.04906640625 0.8525390625 +vt 0.04492109375 0.8525390625 +vt 0.04492109375 0.850171875 +vt 0.04906640625 0.850171875 +vt 0.05321015625 0.8525390625 +vt 0.04906640625 0.8525390625 +vt 0.04906640625 0.850171875 +vt 0.05321015625 0.850171875 +vt 0.05321015625 0.859375 +vt 0.04906640625 0.8559625 +vt 0.04906640625 0.8549046874999999 +vt 0.05321015625 0.8549046874999999 +vt 0.05321015625 0.8549046874999999 +vt 0.04906640625 0.8549046874999999 +vt 0.04906640625 0.8525390625 +vt 0.05321015625 0.8525390625 +vt 0.05321015625 0.850171875 +vt 0.04906640625 0.850171875 +vt 0.04906640625 0.849115625 +vt 0.05321015625 0.845703125 +vt 0.0625 0.609375 +vt 0.078125 0.609375 +vt 0.078125 0.625 +vt 0.0625 0.625 +vt 0.15625 0.609375 +vt 0.171875 0.609375 +vt 0.171875 0.625 +vt 0.15625 0.625 +vt 0.3125 0.609375 +vt 0.328125 0.609375 +vt 0.328125 0.625 +vt 0.3125 0.625 +vt 0.25 0.609375 +vt 0.265625 0.609375 +vt 0.265625 0.625 +vt 0.25 0.625 +vt 0.375 0.984375 +vt 0.390625 0.984375 +vt 0.390625 1 +vt 0.375 1 +vt 0.390625 0.625 +vt 0.375 0.625 +vt 0.375 0.609375 +vt 0.390625 0.609375 +vt 0.375 0.828125 +vt 0.390625 0.828125 +vt 0.390625 0.84375 +vt 0.375 0.84375 +vt 0.359375 0.625 +vt 0.34375 0.625 +vt 0.34375 0.609375 +vt 0.359375 0.609375 +vt 0.037109375 0.46484375 +vt 0.037109375 0.4658265625 +vt 0.0329640625 0.4658265625 +vt 0.0329640625 0.46484375 +vt 0.5329640625 0.96875 +vt 0.5329640625 0.9677671875 +vt 0.537109375 0.9677671875 +vt 0.537109375 0.96875 +vt 0.040775781250000004 0.850171875 +vt 0.040775781250000004 0.849115625 +vt 0.04492109375 0.849115625 +vt 0.04492109375 0.850171875 +vt 0.040775781250000004 0.8549046874999999 +vt 0.040775781250000004 0.8525390625 +vt 0.04492109375 0.8525390625 +vt 0.04492109375 0.8549046874999999 +vt 0.03174921875 0.8549046874999999 +vt 0.03401015625 0.8525390625 +vt 0.03663203125 0.8525390625 +vt 0.03663203125 0.8549046874999999 +vt 0.03174921875 0.850171875 +vt 0.034124218750000004 0.8461390625 +vt 0.03663203125 0.845703125 +vt 0.03663203125 0.850171875 +vt 0.034124218750000004 0.8589390625 +vt 0.03174921875 0.8549046874999999 +vt 0.03663203125 0.8549046874999999 +vt 0.03663203125 0.859375 +vt 0.040775781250000004 0.8559625 +vt 0.040775781250000004 0.8549046874999999 +vt 0.04492109375 0.8549046874999999 +vt 0.04492109375 0.8559625 +vt 0.03401015625 0.8525390625 +vt 0.03174921875 0.850171875 +vt 0.03663203125 0.850171875 +vt 0.03663203125 0.8525390625 +vt 0.040775781250000004 0.8525390625 +vt 0.040775781250000004 0.850171875 +vt 0.04492109375 0.850171875 +vt 0.04492109375 0.8525390625 +vt 0.03663203125 0.8525390625 +vt 0.03663203125 0.850171875 +vt 0.040775781250000004 0.850171875 +vt 0.040775781250000004 0.8525390625 +vt 0.03663203125 0.859375 +vt 0.03663203125 0.8549046874999999 +vt 0.040775781250000004 0.8549046874999999 +vt 0.040775781250000004 0.8559625 +vt 0.03663203125 0.8549046874999999 +vt 0.03663203125 0.8525390625 +vt 0.040775781250000004 0.8525390625 +vt 0.040775781250000004 0.8549046874999999 +vt 0.03663203125 0.850171875 +vt 0.03663203125 0.845703125 +vt 0.040775781250000004 0.849115625 +vt 0.040775781250000004 0.850171875 +vt 0.390625 0.921875 +vt 0.390625 0.9375 +vt 0.375 0.9375 +vt 0.375 0.921875 +vt 0.203125 0.609375 +vt 0.203125 0.625 +vt 0.1875 0.625 +vt 0.1875 0.609375 +vt 0.390625 0.671875 +vt 0.390625 0.6875 +vt 0.375 0.6875 +vt 0.375 0.671875 +vt 0.296875 0.609375 +vt 0.296875 0.625 +vt 0.28125 0.625 +vt 0.28125 0.609375 +vt 0.390625 0.953125 +vt 0.390625 0.96875 +vt 0.375 0.96875 +vt 0.375 0.953125 +vt 0 0.609375 +vt 0 0.59375 +vt 0.015625 0.59375 +vt 0.015625 0.609375 +vt 0.390625 0.796875 +vt 0.390625 0.8125 +vt 0.375 0.8125 +vt 0.375 0.796875 +vt 0.375 0.65625 +vt 0.375 0.640625 +vt 0.390625 0.640625 +vt 0.390625 0.65625 +vt 0.037109375 0.46484375 +vt 0.0412546875 0.46484375 +vt 0.0412546875 0.4658265625 +vt 0.037109375 0.4658265625 +vt 0.5412546875 0.96875 +vt 0.537109375 0.96875 +vt 0.537109375 0.9677671875 +vt 0.5412546875 0.9677671875 +vt 0.07919453125 0.7652515625 +vt 0.07919453125 0.78125 +vt 0.06433359375 0.78125 +vt 0.06433359375 0.7652515625 +vt 0.046990625 0.9575171875 +vt 0.032129687500000004 0.9575171875 +vt 0.032129687500000004 0.953125 +vt 0.046990625 0.953125 +vt 0.21269375 0.765625 +vt 0.21269375 0.7507640625 +vt 0.2170859375 0.7507640625 +vt 0.2170859375 0.765625 +vt 0.109375 0.640625 +vt 0.109375 0.65625 +vt 0.09375 0.65625 +vt 0.09375 0.640625 +vt 0.07919453125 0.78125 +vt 0.07919453125 0.7652515625 +vt 0.09582265625 0.7651656250000001 +vt 0.09582265625 0.7811640625 +vt 0.046990625 0.9575171875 +vt 0.046990625 0.953125 +vt 0.061620312499999996 0.961028125 +vt 0.061620312499999996 0.965421875 +vt 0.2170859375 0.7507640625 +vt 0.21269375 0.7507640625 +vt 0.2047890625 0.736134375 +vt 0.2091828125 0.736134375 +vt 0.359375 0.90625 +vt 0.34375 0.90625 +vt 0.34375 0.890625 +vt 0.359375 0.890625 +vt 0.140625 0.640625 +vt 0.140625 0.65625 +vt 0.125 0.65625 +vt 0.125 0.640625 +vt 0.359375 0.875 +vt 0.34375 0.875 +vt 0.34375 0.859375 +vt 0.359375 0.859375 +vt 0.15625 0.640625 +vt 0.171875 0.640625 +vt 0.171875 0.65625 +vt 0.15625 0.65625 +vt 0.359375 0.828125 +vt 0.359375 0.84375 +vt 0.34375 0.84375 +vt 0.34375 0.828125 +vt 0.203125 0.65625 +vt 0.1875 0.65625 +vt 0.1875 0.640625 +vt 0.203125 0.640625 +vt 0.15625 0.875 +vt 0.15625 1 +vt 0.140625 1 +vt 0.140625 0.875 +vt 0.15625 0.734375 +vt 0.15625 0.859375 +vt 0.140625 0.859375 +vt 0.140625 0.734375 +vt 0.1875 0.875 +vt 0.1875 1 +vt 0.171875 1 +vt 0.171875 0.875 +vt 0.34375 0.796875 +vt 0.359375 0.796875 +vt 0.359375 0.8125 +vt 0.34375 0.8125 +vt 0.265625 0.65625 +vt 0.25 0.65625 +vt 0.25 0.640625 +vt 0.265625 0.640625 +vt 0.296875 0.640625 +vt 0.296875 0.65625 +vt 0.28125 0.65625 +vt 0.28125 0.640625 +vt 0.328125 0.640625 +vt 0.328125 0.65625 +vt 0.3125 0.65625 +vt 0.3125 0.640625 +vt 0.359375 0.671875 +vt 0.359375 0.6875 +vt 0.34375 0.6875 +vt 0.34375 0.671875 +vt 0.34375 0.640625 +vt 0.359375 0.640625 +vt 0.359375 0.65625 +vt 0.34375 0.65625 +vt 0.015625 0.640625 +vt 0 0.640625 +vt 0 0.625 +vt 0.015625 0.625 +vt 0.0383828125 0.5884078125 +vt 0.0383828125 0.607290625 +vt 0.0319296875 0.607290625 +vt 0.0319296875 0.5884078125 +vt 0.0065703124999999946 0.734290625 +vt 0.0065703124999999946 0.7335515625 +vt 0.011203125000000001 0.735821875 +vt 0.011203125000000001 0.7365609375 +vt 0.040337500000000005 0.755665625 +vt 0.034540625000000005 0.7736359374999999 +vt 0.03311875 0.7736359374999999 +vt 0.0389140625 0.755665625 +vt 0.375 0.890625 +vt 0.390625 0.890625 +vt 0.390625 0.90625 +vt 0.375 0.90625 +vt 0.1875 0.734375 +vt 0.1875 0.859375 +vt 0.171875 0.859375 +vt 0.171875 0.734375 +vt 0.004054687500000001 0.755665625 +vt 0.009849999999999998 0.7736359374999999 +vt 0.008428124999999995 0.7736359374999999 +vt 0.0026312499999999947 0.755665625 +vt 0.2415078125 0.73905 +vt 0.2415078125 0.7579328125 +vt 0.2350546875 0.7579328125 +vt 0.2350546875 0.73905 +vt 0.5071328125 0.9988671875 +vt 0.5071328125 1 +vt 0.5006796875 1 +vt 0.5006796875 0.9988671875 +vt 0.036398437500000005 0.734290625 +vt 0.036398437500000005 0.7354234374999999 +vt 0.031765625 0.73769375 +vt 0.031765625 0.7365609375 +vt 0.125 0.609375 +vt 0.140625 0.609375 +vt 0.140625 0.625 +vt 0.125 0.625 +vt 0.375 0.859375 +vt 0.390625 0.859375 +vt 0.390625 0.875 +vt 0.375 0.875 +vt 0.2415078125 0.765625 +vt 0.2350546875 0.765625 +vt 0.2350546875 0.7621578124999999 +vt 0.2415078125 0.7621578124999999 +vt 0.2415078125 0.7198203125 +vt 0.2415078125 0.720953125 +vt 0.2350546875 0.720953125 +vt 0.2350546875 0.7198203125 +vt 0.0417203125 0.735821875 +vt 0.042453125 0.7365609375 +vt 0.0410296875 0.7365609375 +vt 0.0410296875 0.735821875 +vt 0.4694296875 0.9660953125 +vt 0.4758828125 0.9660953125 +vt 0.4758828125 0.9667859375 +vt 0.4694296875 0.9667859375 +vt 0.001939062499999998 0.735821875 +vt 0.001939062499999998 0.7365609375 +vt 0.0005156249999999987 0.7365609375 +vt 0.0012484374999999978 0.735821875 +vt 0.034540625000000005 0.7736359374999999 +vt 0.034540625000000005 0.7778609375000001 +vt 0.03311875 0.7778609375000001 +vt 0.03311875 0.7736359374999999 +vt 0.2415078125 0.7579328125 +vt 0.2415078125 0.7621578124999999 +vt 0.2350546875 0.7621578124999999 +vt 0.2350546875 0.7579328125 +vt 0.046875 0.625 +vt 0.046875 0.640625 +vt 0.03125 0.640625 +vt 0.03125 0.625 +vt 0.009849999999999998 0.7736359374999999 +vt 0.009849999999999998 0.7778609375000001 +vt 0.008428124999999995 0.7778609375000001 +vt 0.008428124999999995 0.7736359374999999 +vt 0.036398437500000005 0.7335515625 +vt 0.036398437500000005 0.734290625 +vt 0.031765625 0.7365609375 +vt 0.031765625 0.735821875 +vt 0.008428124999999995 0.7778609375000001 +vt 0.009849999999999998 0.7778609375000001 +vt 0.009849999999999998 0.78125 +vt 0.0091609375 0.78125 +vt 0.03311875 0.7778609375000001 +vt 0.034540625000000005 0.7778609375000001 +vt 0.0338078125 0.78125 +vt 0.03311875 0.78125 +vt 0.5071328125 0.998128125 +vt 0.5071328125 0.9988671875 +vt 0.5006796875 0.9988671875 +vt 0.5006796875 0.998128125 +vt 0.0065703124999999946 0.7354234374999999 +vt 0.0065703124999999946 0.734290625 +vt 0.011203125000000001 0.7365609375 +vt 0.011203125000000001 0.73769375 +vt 0.0410296875 0.7365609375 +vt 0.042453125 0.7365609375 +vt 0.042453125 0.73769375 +vt 0.0410296875 0.73769375 +vt 0.0005156249999999987 0.7365609375 +vt 0.001939062499999998 0.7365609375 +vt 0.001939062499999998 0.73769375 +vt 0.0005156249999999987 0.73769375 +vt 0.2415078125 0.720953125 +vt 0.2415078125 0.73905 +vt 0.2350546875 0.73905 +vt 0.2350546875 0.720953125 +vt 0.001939062499999998 0.73769375 +vt 0.004054687500000001 0.755665625 +vt 0.0026312499999999947 0.755665625 +vt 0.0005156249999999987 0.73769375 +vt 0.042453125 0.73769375 +vt 0.040337500000000005 0.755665625 +vt 0.0389140625 0.755665625 +vt 0.0410296875 0.73769375 +vt 0.0383828125 0.5703125 +vt 0.0383828125 0.5884078125 +vt 0.0319296875 0.5884078125 +vt 0.0319296875 0.5703125 +vt 0.001939062499999998 0.73769375 +vt 0.001939062499999998 0.7365609375 +vt 0.0065703124999999946 0.734290625 +vt 0.0065703124999999946 0.7354234374999999 +vt 0.0410296875 0.735821875 +vt 0.0410296875 0.7365609375 +vt 0.036398437500000005 0.734290625 +vt 0.036398437500000005 0.7335515625 +vt 0.4758828125 0.9660953125 +vt 0.4694296875 0.9660953125 +vt 0.4694296875 0.9609375 +vt 0.4758828125 0.9609375 +vt 0.0410296875 0.7365609375 +vt 0.0410296875 0.73769375 +vt 0.036398437500000005 0.7354234374999999 +vt 0.036398437500000005 0.734290625 +vt 0.109375 0.625 +vt 0.09375 0.625 +vt 0.09375 0.609375 +vt 0.109375 0.609375 +vt 0.001939062499999998 0.7365609375 +vt 0.001939062499999998 0.735821875 +vt 0.0065703124999999946 0.7335515625 +vt 0.0065703124999999946 0.734290625 +vt 0.21930747015879962 0.6093714338322743 +vt 0.2205112798412004 0.6073359010965784 +vt 0.2246097171093947 0.6073372795312888 +vt 0.22460903235894575 0.6093732169161372 +vt 0.47195234375 0.61328125 +vt 0.47195234375 0.6185828125 +vt 0.46945390625 0.6185828125 +vt 0.46945390625 0.61328125 +vt 0.4068078125 1 +vt 0.4080109375 0.997965625 +vt 0.412109375 0.997965625 +vt 0.412109375 1 +vt 0.3911828125 0.734375 +vt 0.3923859375 0.732340625 +vt 0.396484375 0.732340625 +vt 0.396484375 0.734375 +vt 0.4719515625 0.58203125 +vt 0.4719515625 0.5873328125 +vt 0.4694546875 0.5873328125 +vt 0.4694546875 0.58203125 +vt 0.3911828125 0.78125 +vt 0.3923859375 0.779215625 +vt 0.396484375 0.779215625 +vt 0.396484375 0.78125 +vt 0.4174109375 0.90625 +vt 0.412109375 0.90625 +vt 0.412109375 0.904215625 +vt 0.4162078125 0.904215625 +vt 0.46945390625 0.64453125 +vt 0.47195234375 0.64453125 +vt 0.47195234375 0.6498328125 +vt 0.46945390625 0.6498328125 +vt 0.1049109375 0.59375 +vt 0.099609375 0.59375 +vt 0.099609375 0.591715625 +vt 0.1037078125 0.591715625 +vt 0.0630578125 0.59375 +vt 0.0642609375 0.591715625 +vt 0.068359375 0.591715625 +vt 0.068359375 0.59375 +vt 0.34695234375 0.51953125 +vt 0.34695234375 0.5248328125 +vt 0.34445390625 0.5248328125 +vt 0.34445390625 0.51953125 +vt 0.4068078125 0.953125 +vt 0.4080109375 0.951090625 +vt 0.412109375 0.951090625 +vt 0.412109375 0.953125 +vt 0.187659375 0.44921875 +vt 0.1889796875 0.45022968750000003 +vt 0.1886875 0.4504734375 +vt 0.1875 0.44956406250000003 +vt 0.37890546875 0.5117140625 +vt 0.38028828125 0.5122859375 +vt 0.37998203125 0.51259375 +vt 0.37890546875 0.5121484375 +vt 0.15923125 0.42092343749999994 +vt 0.16015625 0.42163125 +vt 0.15986406250000001 0.421875 +vt 0.159071875 0.42126874999999997 +vt 0.126084375 0.42126874999999997 +vt 0.12529218749999999 0.421875 +vt 0.125 0.42163125 +vt 0.125925 0.42092343749999994 +vt 0.41015703125 0.5121484375 +vt 0.40908046875 0.51259375 +vt 0.40877421875 0.5122859375 +vt 0.41015703125 0.5117140625 +vt 0.53515625 0.5276890625 +vt 0.5339671875 0.5286 +vt 0.5336765625 0.52835625 +vt 0.534996875 0.52734375 +vt 0.0003437500000000003 0.43359375 +vt 0.0007078125000000018 0.43521718750000005 +vt 0.0003265625000000022 0.43521718750000005 +vt 0 0.43375625000000007 +vt 0.38028828125 0.5122859375 +vt 0.38086171875 0.5136703125 +vt 0.38042734375 0.5136703125 +vt 0.37998203125 0.51259375 +vt 0.5974015625 0.9051140625 +vt 0.59765625 0.90625 +vt 0.5972765625 0.90625 +vt 0.5970578125 0.9052765625 +vt 0.5943484375 0.8740265625 +vt 0.5941296875 0.875 +vt 0.59375 0.875 +vt 0.5940046875 0.8738640625 +vt 0.40908046875 0.51259375 +vt 0.40863515625 0.5136703125 +vt 0.40820078125 0.5136703125 +vt 0.40877421875 0.5122859375 +vt 0.56640625 0.8712546875 +vt 0.5660796875 0.872715625 +vt 0.5656984375 0.872715625 +vt 0.5660625 0.87109375 +vt 0.5007078125 0.890625 +vt 0.50034375 0.90625 +vt 0.5 0.9060874999999999 +vt 0.5003265625 0.890625 +vt 0.38086171875 0.5136703125 +vt 0.38028828125 0.515053125 +vt 0.37998203125 0.5147453125 +vt 0.38042734375 0.5136703125 +vt 0.50390625 0.46875 +vt 0.5036515625 0.484375 +vt 0.5033078125 0.48421249999999993 +vt 0.5035265625 0.46875 +vt 0.51600625 0.765625 +vt 0.516225 0.7810875 +vt 0.5158796875 0.78125 +vt 0.515625 0.765625 +vt 0.40863515625 0.5136703125 +vt 0.40908046875 0.5147453125 +vt 0.40877421875 0.515053125 +vt 0.40820078125 0.5136703125 +vt 0.503578125 0.921875 +vt 0.50390625 0.9373374999999999 +vt 0.5035609375 0.9375 +vt 0.5031984375 0.921875 +vt 0.53273125 0.49898906249999997 +vt 0.5314109375 0.5 +vt 0.53125 0.49965468749999997 +vt 0.5324390625 0.4987453125 +vt 0.38028828125 0.515053125 +vt 0.37890546875 0.515625 +vt 0.37890546875 0.515190625 +vt 0.37998203125 0.5147453125 +vt 0.56640625 0.5900875 +vt 0.56548125 0.5907953125000001 +vt 0.565321875 0.59045 +vt 0.5661140625 0.58984375 +vt 0.5784171875 0.74609375 +vt 0.579209375 0.7467 +vt 0.57905 0.7470453125000001 +vt 0.578125 0.7463375 +vt 0.40908046875 0.5147453125 +vt 0.41015703125 0.515190625 +vt 0.41015703125 0.515625 +vt 0.40877421875 0.515053125 +vt 0.5339671875 0.71749375 +vt 0.53515625 0.7184046875 +vt 0.534996875 0.71875 +vt 0.5336765625 0.7177390625 +vt 0.534996875 0.5625 +vt 0.5336765625 0.5614890625 +vt 0.53396875 0.56124375 +vt 0.53515625 0.5621546875 +vt 0.37890546875 0.515625 +vt 0.37752265625 0.515053125 +vt 0.37783046875 0.5147453125 +vt 0.37890546875 0.515190625 +vt 0.313425 0.434546875 +vt 0.3125 0.43383906249999993 +vt 0.3127921875 0.43359375 +vt 0.313584375 0.43420156249999997 +vt 0.565321875 0.6842015625 +vt 0.5661140625 0.68359375 +vt 0.56640625 0.6838390625 +vt 0.56548125 0.684546875 +vt 0.41015703125 0.515190625 +vt 0.41123203125 0.5147453125 +vt 0.41153984375 0.515053125 +vt 0.41015703125 0.515625 +vt 0.53125 0.6871546875 +vt 0.5324375 0.68624375 +vt 0.5327296875 0.6864890625 +vt 0.531409375 0.6875 +vt 0.30043593749999997 0.5 +vt 0.3000734375 0.484375 +vt 0.300453125 0.484375 +vt 0.30078125 0.49983749999999993 +vt 0.37752265625 0.515053125 +vt 0.37695078125 0.5136703125 +vt 0.37738515625 0.5136703125 +vt 0.37783046875 0.5147453125 +vt 0.1877546875 0.484375 +vt 0.1875 0.46875 +vt 0.18788125 0.46875 +vt 0.1881 0.4842140625 +vt 0.15955625 0.48421249999999993 +vt 0.159775 0.46875 +vt 0.16015625 0.46875 +vt 0.1599015625 0.484375 +vt 0.41123203125 0.5147453125 +vt 0.41167734375 0.5136703125 +vt 0.41211171875 0.5136703125 +vt 0.41153984375 0.515053125 +vt 0.5 0.8435875 +vt 0.500328125 0.828125 +vt 0.5007078125 0.828125 +vt 0.5003453125 0.84375 +vt 0.28445 0.435215625 +vt 0.2848125 0.43359375 +vt 0.28515625 0.4337546875 +vt 0.2848296875 0.435215625 +vt 0.37695078125 0.5136703125 +vt 0.37752265625 0.5122859375 +vt 0.37783046875 0.51259375 +vt 0.37738515625 0.5136703125 +vt 0.0625 0.40625 +vt 0.0627546875 0.40511406250000004 +vt 0.0630984375 0.4052765625 +vt 0.0628796875 0.40625 +vt 0.597275 0.96875 +vt 0.5970578125 0.9677765625 +vt 0.5974015625 0.9676140625 +vt 0.59765625 0.96875 +vt 0.41167734375 0.5136703125 +vt 0.41123203125 0.51259375 +vt 0.41153984375 0.5122859375 +vt 0.41211171875 0.5136703125 +vt 0.5628265625 0.8102171874999999 +vt 0.5625 0.8087562500000001 +vt 0.56284375 0.80859375 +vt 0.56320625 0.8102171874999999 +vt 0.5336765625 0.6533546875 +vt 0.534996875 0.65234375 +vt 0.53515625 0.6526890625 +vt 0.5339671875 0.6536 +vt 0.37752265625 0.5122859375 +vt 0.37890546875 0.5117140625 +vt 0.37890546875 0.5121484375 +vt 0.37783046875 0.51259375 +vt 0.34375 0.42163125 +vt 0.344675 0.42092343750000005 +vt 0.344834375 0.4212687500000001 +vt 0.3440421875 0.421875 +vt 0.09736562500000001 0.421875 +vt 0.096571875 0.42126874999999997 +vt 0.0967328125 0.42092343749999994 +vt 0.09765625 0.42163125 +vt 0.41123203125 0.51259375 +vt 0.41015703125 0.5121484375 +vt 0.41015703125 0.5117140625 +vt 0.41153984375 0.5122859375 +vt 0.1261890625 0.4504734375 +vt 0.125 0.44956406250000003 +vt 0.1251609375 0.44921875 +vt 0.12648125 0.45022968750000003 +vt 0.5338359375 0.80859375 +vt 0.53515625 0.8096046875 +vt 0.5348640625 0.80985 +vt 0.5336765625 0.8089390625 +vt 0.23828125 0.5117140625000001 +vt 0.2396640625 0.5122859375 +vt 0.23935625 0.51259375 +vt 0.23828125 0.5121484375 +vt 0.562659375 0.46779843749999994 +vt 0.563584375 0.46850625 +vt 0.5632921875 0.46875 +vt 0.5625 0.46814374999999997 +vt 0.56640625 0.53064375 +vt 0.5656140625 0.53125 +vt 0.565321875 0.53100625 +vt 0.566246875 0.5302984374999999 +vt 0.48828125 0.7621484375 +vt 0.48720625 0.76259375 +vt 0.4868984375 0.7622859375 +vt 0.48828125 0.7617140625000001 +vt 0.5327296875 0.5901890625 +vt 0.531540625 0.5911 +vt 0.53125 0.59085625 +vt 0.5325703125 0.58984375 +vt 0.5660421875 0.90234375 +vt 0.56640625 0.903965625 +vt 0.566025 0.903965625 +vt 0.5656984375 0.9025046875 +vt 0.2396640625 0.5122859375 +vt 0.2402375 0.51366875 +vt 0.2398015625 0.51366875 +vt 0.23935625 0.51259375 +vt 0.59409375 0.8113640625 +vt 0.5943484375 0.8125 +vt 0.5939671875 0.8125 +vt 0.59375 0.8115265625 +vt 0.59765625 0.9990265625 +vt 0.5974375 1 +vt 0.5970578125 1 +vt 0.5973109375 0.9988640625 +vt 0.48720625 0.76259375 +vt 0.4867609375 0.76366875 +vt 0.486325 0.76366875 +vt 0.4868984375 0.7622859375 +vt 0.5632078125 0.9962562500000001 +vt 0.56288125 0.9977171874999999 +vt 0.5625 0.9977171874999999 +vt 0.5628640625 0.99609375 +vt 0.06640625 0.484375 +vt 0.06604375 0.5 +vt 0.0656984375 0.49983750000000005 +vt 0.0660265625 0.484375 +vt 0.2402375 0.51366875 +vt 0.2396640625 0.5150515625000001 +vt 0.23935625 0.5147453125 +vt 0.2398015625 0.51366875 +vt 0.3912234375 0.46875 +vt 0.39096875 0.484375 +vt 0.390625 0.4842140625 +vt 0.3908421875 0.46875 +vt 0.5193140625 0.734375 +vt 0.51953125 0.7498375 +vt 0.5191875 0.75 +vt 0.5189328125 0.734375 +vt 0.4867609375 0.76366875 +vt 0.48720625 0.7647453125 +vt 0.4868984375 0.7650515625000001 +vt 0.486325 0.76366875 +vt 0.5003796875 0.65625 +vt 0.50070625 0.6717124999999999 +vt 0.5003625 0.671875 +vt 0.5 0.65625 +vt 0.25390625 0.45211406249999997 +vt 0.2525859375 0.453125 +vt 0.2524265625 0.45277968749999997 +vt 0.2536140625 0.4518703125 +vt 0.2396640625 0.5150515625000001 +vt 0.23828125 0.515625 +vt 0.23828125 0.515190625 +vt 0.23935625 0.5147453125 +vt 0.563584375 0.5588375 +vt 0.5626609375 0.5595453125000001 +vt 0.5625 0.5592 +vt 0.5632921875 0.55859375 +vt 0.5656140625 0.49609375 +vt 0.56640625 0.49670000000000003 +vt 0.566246875 0.49704531250000006 +vt 0.565321875 0.4963375 +vt 0.48720625 0.7647453125 +vt 0.48828125 0.765190625 +vt 0.48828125 0.765625 +vt 0.4868984375 0.7650515625000001 +vt 0.547165625 0.7799953125 +vt 0.5483546875 0.7809046875 +vt 0.5481953125 0.78125 +vt 0.546875 0.7802390625 +vt 0.4231953125 0.46875 +vt 0.421875 0.46773750000000003 +vt 0.4221671875 0.46749375000000004 +vt 0.4233546875 0.46840468749999997 +vt 0.23828125 0.515625 +vt 0.2368984375 0.5150515625000001 +vt 0.23720625 0.5147453125 +vt 0.23828125 0.515190625 +vt 0.550621875 0.43454531250000006 +vt 0.549696875 0.4338375 +vt 0.5499890625 0.43359375 +vt 0.55078125 0.43420000000000003 +vt 0.5625 0.6217015625 +vt 0.5632921875 0.62109375 +vt 0.563584375 0.6213375 +vt 0.562659375 0.622046875 +vt 0.48828125 0.765190625 +vt 0.48935625 0.7647453125 +vt 0.4896640625 0.7650515625000001 +vt 0.48828125 0.765625 +vt 0.1586765625 0.45277968749999997 +vt 0.159865625 0.4518703125 +vt 0.16015625 0.45211406249999997 +vt 0.1588375 0.453125 +vt 0.5003640625 0.96875 +vt 0.5 0.953125 +vt 0.50038125 0.953125 +vt 0.5007078125 0.9685874999999999 +vt 0.2368984375 0.5150515625000001 +vt 0.236325 0.51366875 +vt 0.2367609375 0.51366875 +vt 0.23720625 0.5147453125 +vt 0.3629375 0.484375 +vt 0.3626828125 0.46875 +vt 0.3630640625 0.46875 +vt 0.36328125 0.48421249999999993 +vt 0.25 0.4842140625 +vt 0.25021875 0.46875 +vt 0.2505984375 0.46875 +vt 0.25034531250000003 0.484375 +vt 0.48935625 0.7647453125 +vt 0.4898015625 0.76366875 +vt 0.4902375 0.76366875 +vt 0.4896640625 0.7650515625000001 +vt 0.0344484375 0.49983749999999993 +vt 0.034775 0.484375 +vt 0.03515625 0.484375 +vt 0.0347921875 0.5 +vt 0.5625 0.9664671874999999 +vt 0.5628640625 0.96484375 +vt 0.5632078125 0.96500625 +vt 0.56288125 0.9664671874999999 +vt 0.236325 0.51366875 +vt 0.2368984375 0.5122859375 +vt 0.23720625 0.51259375 +vt 0.2367609375 0.51366875 +vt 0.0345578125 0.40625 +vt 0.0348125 0.40511406250000004 +vt 0.03515625 0.4052765625 +vt 0.0349390625 0.40625 +vt 0.5939671875 0.84375 +vt 0.59375 0.8427765625 +vt 0.59409375 0.8426140625 +vt 0.5943484375 0.84375 +vt 0.4898015625 0.76366875 +vt 0.48935625 0.76259375 +vt 0.4896640625 0.7622859375 +vt 0.4902375 0.76366875 +vt 0.034775 0.43521718749999994 +vt 0.0344484375 0.43375625000000007 +vt 0.0347921875 0.43359375 +vt 0.03515625 0.43521718749999994 +vt 0.28125 0.46585468750000003 +vt 0.2825703125 0.46484375 +vt 0.2827296875 0.46518906250000003 +vt 0.2815421875 0.46609999999999996 +vt 0.2368984375 0.5122859375 +vt 0.23828125 0.5117140625000001 +vt 0.23828125 0.5121484375 +vt 0.23720625 0.51259375 +vt 0.565321875 0.6560046875000001 +vt 0.5662453125 0.655296875 +vt 0.56640625 0.6556421875 +vt 0.5656140625 0.65625 +vt 0.5164171875 0.4375 +vt 0.515625 0.43689374999999997 +vt 0.515784375 0.43654843749999994 +vt 0.516709375 0.43725625 +vt 0.48935625 0.76259375 +vt 0.48828125 0.7621484375 +vt 0.48828125 0.7617140625000001 +vt 0.4896640625 0.7622859375 +vt 0.5348640625 0.8411 +vt 0.533675 0.8401890625 +vt 0.5338359375 0.83984375 +vt 0.53515625 0.84085625 +vt 0.312659375 0.46484375 +vt 0.3139796875 0.46585468750000003 +vt 0.3136875 0.46609999999999996 +vt 0.3125 0.46518906250000003 +vt 0.26953046875 0.5117125 +vt 0.27091328125 0.5122859375 +vt 0.27060703125 0.51259375 +vt 0.26953046875 0.512146875 +vt 0.4404828125 0.43654843749999994 +vt 0.44140625 0.43725625 +vt 0.4411140625 0.4375 +vt 0.440321875 0.43689374999999997 +vt 0.251084375 0.42126874999999997 +vt 0.2502921875 0.421875 +vt 0.25 0.42163125 +vt 0.250925 0.42092343749999994 +vt 0.48828203125 0.730896875 +vt 0.48720546875 0.73134375 +vt 0.48689921875 0.7310359375 +vt 0.48828203125 0.7304625 +vt 0.09765625 0.44956406250000003 +vt 0.0964671875 0.4504734375 +vt 0.09617500000000001 0.45022968750000003 +vt 0.0974953125 0.44921875 +vt 0.56284375 0.83984375 +vt 0.5632078125 0.841465625 +vt 0.5628265625 0.841465625 +vt 0.5625 0.8400062500000001 +vt 0.27091328125 0.5122859375 +vt 0.27148671875 0.51366875 +vt 0.27105234375 0.51366875 +vt 0.27060703125 0.51259375 +vt 0.0036515625000000003 0.4051125 +vt 0.00390625 0.40625 +vt 0.0035265625 0.40625 +vt 0.0033078125 0.40527500000000005 +vt 0.2818484375 0.4052765625000001 +vt 0.2816296875 0.40625 +vt 0.28125 0.40625 +vt 0.2815046875 0.40511406250000004 +vt 0.48720546875 0.73134375 +vt 0.48676015625 0.73241875 +vt 0.48632578125 0.73241875 +vt 0.48689921875 0.7310359375 +vt 0.48828125 0.44938125000000007 +vt 0.4879546875 0.45084218750000005 +vt 0.4875734375 0.45084218750000005 +vt 0.4879375 0.44921875 +vt 0.3288328125 0.484375 +vt 0.32846875 0.5 +vt 0.328125 0.49983750000000005 +vt 0.3284515625 0.484375 +vt 0.27148671875 0.51366875 +vt 0.27091328125 0.5150515625000001 +vt 0.27060703125 0.51474375 +vt 0.27105234375 0.51366875 +vt 0.00390625 0.453125 +vt 0.0036515625000000003 0.46875 +vt 0.0033078125 0.46858749999999993 +vt 0.0035265625 0.453125 +vt 0.53163125 0.984375 +vt 0.5318484375 0.9998390625 +vt 0.5315046875 1 +vt 0.53125 0.984375 +vt 0.48676015625 0.73241875 +vt 0.48720546875 0.73349375 +vt 0.48689921875 0.7338015625000001 +vt 0.48632578125 0.73241875 +vt 0.5035796875 0.6875 +vt 0.50390625 0.7029624999999999 +vt 0.5035625 0.703125 +vt 0.5031984375 0.6875 +vt 0.53273125 0.46773906249999997 +vt 0.5314109375 0.46875 +vt 0.53125 0.46840625 +vt 0.5324390625 0.4674953125 +vt 0.27091328125 0.5150515625000001 +vt 0.26953046875 0.515625 +vt 0.26953046875 0.5151890625 +vt 0.27060703125 0.51474375 +vt 0.37890625 0.4182125 +vt 0.37798125 0.41892031249999995 +vt 0.377821875 0.4185749999999999 +vt 0.3786140625 0.41796875 +vt 0.21904218749999999 0.41796875 +vt 0.219834375 0.41857500000000003 +vt 0.219675 0.41892031250000006 +vt 0.21875 0.4182125 +vt 0.48720546875 0.73349375 +vt 0.48828203125 0.7339390625 +vt 0.48828203125 0.734375 +vt 0.48689921875 0.7338015625000001 +vt 0.54959375 0.7487453125 +vt 0.55078125 0.7496546875 +vt 0.550621875 0.75 +vt 0.5493015625 0.7489890625 +vt 0.5349953125 0.875 +vt 0.533675 0.8739875 +vt 0.5339671875 0.87374375 +vt 0.53515625 0.8746546875 +vt 0.26953046875 0.515625 +vt 0.26814765625 0.5150515625000001 +vt 0.26845546875 0.51474375 +vt 0.26953046875 0.5151890625 +vt 0.4071734375 0.434546875 +vt 0.40625 0.4338375 +vt 0.406540625 0.43359375 +vt 0.4073328125 0.43420156249999997 +vt 0.580946875 0.77795 +vt 0.5817390625 0.77734375 +vt 0.58203125 0.7775875 +vt 0.58110625 0.7782953125000001 +vt 0.48828203125 0.7339390625 +vt 0.48935703125 0.73349375 +vt 0.48966484375 0.7338015625000001 +vt 0.48828203125 0.734375 +vt 0.21875 0.45278125 +vt 0.2199390625 0.4518703125 +vt 0.2202296875 0.45211406249999997 +vt 0.2189109375 0.453125 +vt 0.5035609375 0.8125 +vt 0.5031984375 0.796875 +vt 0.503578125 0.796875 +vt 0.50390625 0.8123374999999999 +vt 0.26814765625 0.5150515625000001 +vt 0.26757578125 0.51366875 +vt 0.26801015625 0.51366875 +vt 0.26845546875 0.51474375 +vt 0.2190046875 0.484375 +vt 0.21875 0.46875 +vt 0.21913125 0.46875 +vt 0.21935 0.48421249999999993 +vt 0.12830625 0.48421249999999993 +vt 0.128525 0.46875 +vt 0.12890625 0.46875 +vt 0.1286515625 0.484375 +vt 0.48935703125 0.73349375 +vt 0.48980234375 0.73241875 +vt 0.49023671875 0.73241875 +vt 0.48966484375 0.7338015625000001 +vt 0.5 0.8748390625 +vt 0.500328125 0.859375 +vt 0.5007078125 0.859375 +vt 0.5003453125 0.875 +vt 0.5656984375 0.9352171874999999 +vt 0.5660625 0.93359375 +vt 0.56640625 0.9337562500000001 +vt 0.5660796875 0.9352171874999999 +vt 0.26757578125 0.51366875 +vt 0.26814765625 0.5122859375 +vt 0.26845546875 0.51259375 +vt 0.26801015625 0.51366875 +vt 0.59375 0.9375 +vt 0.5940046875 0.9363640625 +vt 0.5943484375 0.936525 +vt 0.5941296875 0.9375 +vt 0.58165 0.4375 +vt 0.58143125 0.4365265625 +vt 0.5817765625 0.43636406250000004 +vt 0.58203125 0.4375 +vt 0.48980234375 0.73241875 +vt 0.48935703125 0.73134375 +vt 0.48966484375 0.7310359375 +vt 0.49023671875 0.73241875 +vt 0.062828125 0.43521718749999994 +vt 0.0625 0.43375625000000007 +vt 0.0628453125 0.43359375 +vt 0.0632078125 0.43521718749999994 +vt 0.4555515625 0.46585468750000003 +vt 0.456871875 0.46484375 +vt 0.45703125 0.46518906250000003 +vt 0.4558421875 0.46609999999999996 +vt 0.26814765625 0.5122859375 +vt 0.26953046875 0.5117125 +vt 0.26953046875 0.512146875 +vt 0.26845546875 0.51259375 +vt 0.46875 0.42163125 +vt 0.4696734375 0.42092343749999994 +vt 0.4698328125 0.42126874999999997 +vt 0.469040625 0.421875 +vt 0.19111406250000001 0.421875 +vt 0.190321875 0.42126874999999997 +vt 0.1904828125 0.42092343749999994 +vt 0.19140625 0.42163125 +vt 0.48935703125 0.73134375 +vt 0.48828203125 0.730896875 +vt 0.48828203125 0.7304625 +vt 0.48966484375 0.7310359375 +vt 0.5324375 0.62235 +vt 0.53125 0.6214390625 +vt 0.531409375 0.62109375 +vt 0.5327296875 0.62210625 +vt 0.44140625 0.613754060295256 +vt 0.4407390625 0.6195306227952562 +vt 0.440259375 0.6194181227952561 +vt 0.4409140625 0.613754060295256 +vt 0.27703612424346297 0.7546110486810375 +vt 0.27548321742122694 0.7600985118812884 +vt 0.27508775930522805 0.759865621609936 +vt 0.27657840182258775 0.7546019673493958 +vt 0.41766333961567964 0.8008577108081553 +vt 0.41549538455303786 0.8058779914693014 +vt 0.4151770961290388 0.8056532442183628 +vt 0.417281864356033 0.80078125 +vt 0.472303647757488 0.79822219625011 +vt 0.47265625 0.8035191060746989 +vt 0.4723409154103934 0.8035187430954922 +vt 0.47199081176572055 0.7982640624999999 +vt 0.47199081176572055 0.7982640624999999 +vt 0.4723408117657205 0.8035171875 +vt 0.4720283117657205 0.803559375 +vt 0.47167674926572056 0.7982640624999999 +vt 0.34443693351683047 0.58203125 +vt 0.3465417017438235 0.5869032442183633 +vt 0.3462234133198244 0.5871279914693018 +vt 0.34405545825718375 0.5821077108081552 +vt 0.06325877343717295 0.7233602400124776 +vt 0.06475680934555687 0.7286213835281947 +vt 0.06436174813488334 0.728854946452952 +vt 0.0627995049961648 0.7233701339358114 +vt 0.43799062499999997 0.5668698750556649 +vt 0.4386453125 0.5725355000556649 +vt 0.4381671875 0.572646437555665 +vt 0.4375 0.5668698750556649 +vt 0.4407390625 0.6195306227952562 +vt 0.438762859493156 0.625 +vt 0.4383223156626184 0.6247807856700902 +vt 0.4402611439291988 0.61941853765813 +vt 0.27548321742122694 0.7600985118812884 +vt 0.271437313256537 0.7641171874999999 +vt 0.27120738586004595 0.7637198979849746 +vt 0.2750882684405917 0.7598659214461719 +vt 0.41549538455303786 0.8058779914693014 +vt 0.41147812500000003 0.8095904871553168 +vt 0.4112798429782491 0.8092563741422876 +vt 0.41517744180953525 0.8056534883074827 +vt 0.47265625 0.8035191060746989 +vt 0.4723092929649779 0.808815116717249 +vt 0.47199685061138763 0.8087731102736009 +vt 0.47234180314898716 0.8035187441173612 +vt 0.47234180314898716 0.8035187441173613 +vt 0.47199685061138763 0.8087731102736009 +vt 0.4716820745064683 0.8087724200359256 +vt 0.47203048426035454 0.8034765047653087 +vt 0.3465417017438235 0.5869032442183633 +vt 0.3504390625 0.590504585797201 +vt 0.3502407804782491 0.5908386988102301 +vt 0.34622375900032093 0.5871277473801819 +vt 0.06475680934555687 0.7286213835281947 +vt 0.0686442450038352 0.73246875 +vt 0.06841499404761281 0.7328664302301817 +vt 0.06436225675924835 0.7288546457507037 +vt 0.4386453125 0.5725355000556649 +vt 0.4405550529920621 0.5779081763014517 +vt 0.44011332765567046 0.578125 +vt 0.43816669110888906 0.5726465527313802 +vt 0.4765625 0.5594296875 +vt 0.471625 0.5625 +vt 0.4714140625 0.5620562499999999 +vt 0.4762578125 0.5590453125 +vt 0.271437313256537 0.7641171874999999 +vt 0.265938875756537 0.765625 +vt 0.26593262575653703 0.7651671875 +vt 0.271207625756537 0.7637203125 +vt 0.41147812500000003 0.8095904871553168 +vt 0.4062953125 0.8113373621553167 +vt 0.40625 0.8109498621553167 +vt 0.4112796875 0.8092561121553168 +vt 0.1604506413745579 0.5078125 +vt 0.1640625 0.5117005959054297 +vt 0.16381760530737904 0.5118987803594299 +vt 0.16023484803086963 0.5080410273216185 +vt 0.16023484803086963 0.5080410273216185 +vt 0.16381766053086963 0.5118988398216184 +vt 0.16360203553086963 0.5121285273216184 +vt 0.15998953553086964 0.5082394648216184 +vt 0.3504390625 0.590504585797201 +vt 0.35546875 0.5921983357972009 +vt 0.3554234375 0.592584273297201 +vt 0.35024062499999997 0.590838960797201 +vt 0.0686442450038352 0.73246875 +vt 0.0739192450038352 0.7339156250000001 +vt 0.0739129950038352 0.734375 +vt 0.06841455750383521 0.7328671875 +vt 0.0940546875 0.5121718749999999 +vt 0.09889843749999999 0.51518125 +vt 0.0986875 0.515625 +vt 0.09375 0.51255625 +vt 0.4465501972725397 0.8125 +vt 0.44161269727253966 0.8094296875 +vt 0.44191738477253967 0.8090453125 +vt 0.4467611347725397 0.8120546875 +vt 0.10516374021354122 0.734375 +vt 0.09966530271354122 0.7328671875 +vt 0.09989499021354122 0.73246875 +vt 0.10516842771354122 0.7339156250000001 +vt 0.4179234375 0.6550884188027333 +vt 0.412740625 0.6533415438027333 +vt 0.4129390625 0.6530071688027334 +vt 0.41796875 0.6547024813027333 +vt 0.34375 0.5587258166547784 +vt 0.34736503570414784 0.5548408932142218 +vt 0.34758044533215376 0.5550697822617287 +vt 0.34399456184376065 0.5589244117021931 +vt 0.34399456184376065 0.5589244117021931 +vt 0.3475804993437607 0.555069724202193 +vt 0.34782424934376066 0.5552681617021931 +vt 0.3442101868437607 0.5591540992021931 +vt 0.40625 0.685949898297201 +vt 0.4112796875 0.684254585797201 +vt 0.41147812500000003 0.684588960797201 +vt 0.4062953125 0.6863358357972009 +vt 0.2502938095787091 0.8120421874999999 +vt 0.2555688095787091 0.8105953125 +vt 0.2557969345787091 0.8109921874999999 +vt 0.2502984970787091 0.8125 +vt 0.3149572095905351 0.5620562499999999 +vt 0.31980095959053506 0.5590453125 +vt 0.3201056470905351 0.5594296875 +vt 0.3151681470905351 0.5625 +vt 0.44161269727253966 0.8094296875 +vt 0.4375 0.8053180957794523 +vt 0.4378850194819634 0.8050135810231762 +vt 0.44191836798404094 0.8090440721408754 +vt 0.09966530271354122 0.7328671875 +vt 0.09561256542517675 0.7288554030205219 +vt 0.09600711801148527 0.7286221407980131 +vt 0.09989455366976362 0.7324695072698183 +vt 0.412740625 0.6533415438027333 +vt 0.4087236035220719 0.6496305923726852 +vt 0.40904154626557443 0.6494060892108666 +vt 0.4129389070217509 0.6530074307897042 +vt 0.34736503570414784 0.5548408932142218 +vt 0.35145369287402733 0.5514575540190341 +vt 0.35163685222240293 0.5517128112743352 +vt 0.3475807644971067 0.5550701213986686 +vt 0.3475804993437607 0.555069724202193 +vt 0.35063735475311303 0.55078125 +vt 0.35090451860238214 0.5509470825383292 +vt 0.34782461049965696 0.5552684557201343 +vt 0.4112796875 0.684254585797201 +vt 0.4151770482561765 0.6806532442183633 +vt 0.41549499099967907 0.6808777473801819 +vt 0.4114779695217509 0.6845886988102301 +vt 0.2555688095787091 0.8105953125 +vt 0.25946106076185327 0.80675281780346 +vt 0.25985532089310537 0.8069865739942178 +vt 0.25579756228676676 0.8109932795400454 +vt 0.31980095959053506 0.5590453125 +vt 0.323834308092613 0.5550148213823013 +vt 0.32421875 0.5553207879697861 +vt 0.3201066303020363 0.5594309278591246 +vt 0.031918749999999996 0.5443767171713436 +vt 0.03125 0.5386001546713436 +vt 0.0317421875 0.5386001546713436 +vt 0.032396875 0.5442657796713436 +vt 0.09561256542517675 0.7288554030205219 +vt 0.09405032228645878 0.7233705905033812 +vt 0.09450959072746694 0.7233606965800473 +vt 0.09600762663585032 0.7286218400957647 +vt 0.4087236035220719 0.6496305923726852 +vt 0.40656230267650567 0.6446070888405887 +vt 0.40694390207268494 0.64453125 +vt 0.40904072536959496 0.6494066688553046 +vt 0.2045300062625405 0.5311966568774101 +vt 0.20347749943077825 0.5259949974203353 +vt 0.20379033459670473 0.525953125 +vt 0.20483414724192786 0.5311133844341076 +vt 0.20344033459670474 0.5312078124999999 +vt 0.20379033459670473 0.525953125 +vt 0.20410595959670474 0.525953125 +vt 0.20375283459670473 0.53125 +vt 0.4151770482561765 0.6806532442183633 +vt 0.41728181648316953 0.67578125 +vt 0.41766329174281625 0.6758577108081552 +vt 0.4154953366801756 0.6808779914693018 +vt 0.25946106076185327 0.80675281780346 +vt 0.26096568473197423 0.8014935546056043 +vt 0.2614249404212909 0.8015040237247308 +vt 0.2598558291404617 0.806986875333249 +vt 0.409009375 0.5598803167749038 +vt 0.40966562500000003 0.5542162542749037 +vt 0.41015625 0.5542162542749037 +vt 0.4094890625 0.5599928167749038 +vt 0.03125 0.5386001546713436 +vt 0.0319171875 0.5328235921713436 +vt 0.032396875 0.5329345296713437 +vt 0.0317421875 0.5386001546713436 +vt 0.09405032228645878 0.7233705905033812 +vt 0.09537494300116385 0.7178251497485538 +vt 0.09577969621836846 0.7180414824394781 +vt 0.09450802858991249 0.7233607302328477 +vt 0.31280698311955846 0.5936739525056667 +vt 0.3149713359913597 0.5886500991999907 +vt 0.31528833534625866 0.5888741960915491 +vt 0.3131885409887105 0.59375 +vt 0.20347749943077825 0.5259949974203353 +vt 0.203125 0.52069964324053 +vt 0.20344033459670474 0.5206999999999999 +vt 0.20379033459670473 0.525953125 +vt 0.20379033459670473 0.525953125 +vt 0.20344033459670474 0.5206999999999999 +vt 0.20375283459670473 0.5206578125 +vt 0.20410439709670472 0.525953125 +vt 0.3860236327177401 0.59375 +vt 0.38393440054091044 0.5888713233490603 +vt 0.38425187054081567 0.5886478937024762 +vt 0.3864053496946814 0.5936747552100072 +vt 0.26096568473197423 0.8014935546056043 +vt 0.25970227781274474 0.7961711918190006 +vt 0.26010730165498713 0.7959553662245611 +vt 0.2614249404212909 0.8015040237247308 +vt 0.40966562500000003 0.5542162542749037 +vt 0.4090109375 0.5485521917749038 +vt 0.4094890625 0.5484412542749038 +vt 0.41015625 0.5542162542749037 +vt 0.0319171875 0.5328235921713436 +vt 0.03385989388478307 0.52734375 +vt 0.03430177463402906 0.5275602567955247 +vt 0.03239588842547898 0.5329343015059007 +vt 0.09537494300116385 0.7178251497485538 +vt 0.09924743015969553 0.7136391439582404 +vt 0.09949408754400012 0.7140262688749974 +vt 0.09578105450161022 0.7180422084153474 +vt 0.3149713359913597 0.5886500991999907 +vt 0.31899062499999997 0.584939800775311 +vt 0.3191900294994442 0.5852758052074454 +vt 0.3152891559251559 0.5888747761847806 +vt 0.203125 0.52069964324053 +vt 0.2034718525751458 0.5154036257555445 +vt 0.20378429575722068 0.5154456260364954 +vt 0.203439446858091 0.5206999989956409 +vt 0.203439446858091 0.5206999989956409 +vt 0.20378429575722068 0.5154456260364954 +vt 0.20409954822598977 0.515444821946958 +vt 0.20375124293009889 0.5207407440886638 +vt 0.38393440054091044 0.5888713233490603 +vt 0.3800296875 0.5852779547206847 +vt 0.3802272862472001 0.5849434371565271 +vt 0.3842521250114032 0.588647714610677 +vt 0.25970227781274474 0.7961711918190006 +vt 0.2560010613128672 0.7921443591695527 +vt 0.25624885516749357 0.7917579607068026 +vt 0.2601074635908379 0.7959552799335885 +vt 0.4090109375 0.5485521917749038 +vt 0.4070848362833727 0.5431853591063394 +vt 0.40752580127513177 0.54296875 +vt 0.4094891206407134 0.5484412407847382 +vt 0.4375 0.5304140625 +vt 0.4424375 0.52734375 +vt 0.4426484375 0.5277890625 +vt 0.4378046875 0.5307984375 +vt 0.09924743015969553 0.7136391439582404 +vt 0.10467657964391211 0.7118982679274461 +vt 0.1047024101472899 0.7123569587146006 +vt 0.09949383019214952 0.7140258649652826 +vt 0.31899062499999997 0.584939800775311 +vt 0.3241734375 0.583192925775311 +vt 0.32421875 0.5835788632753112 +vt 0.3191890625 0.585274175775311 +vt 0.12861579846152174 0.515625 +vt 0.125 0.5117410001788547 +vt 0.12524435197401726 0.5115421469639571 +vt 0.1288326352620899 0.5153944354221143 +vt 0.1288326352620899 0.5153944354221143 +vt 0.1252435727620899 0.5115413104221143 +vt 0.1254591977620899 0.5113116229221143 +vt 0.1290763852620899 0.5151944354221143 +vt 0.3800296875 0.5852779547206847 +vt 0.375 0.5835826422206847 +vt 0.3750453125 0.5831967047206847 +vt 0.380228125 0.5849420172206847 +vt 0.2560010613128672 0.7921443591695527 +vt 0.25080035536616013 0.7904497412416673 +vt 0.2508283140118271 0.789991175251588 +vt 0.2562493270209905 0.791757224919928 +vt 0.4762578125 0.5307984375 +vt 0.4714140625 0.5277875 +vt 0.471625 0.52734375 +vt 0.4765625 0.5304140625 +vt 0.22141913030203647 0.5546875 +vt 0.22635663030203648 0.5577578125 +vt 0.22605194280203647 0.5581421875 +vt 0.22120819280203646 0.55513125 +vt 0.2663844452148255 0.7431671558022712 +vt 0.27181874841283377 0.7448918767155437 +vt 0.27157349965875893 0.7452793288569831 +vt 0.2663599790673229 0.7436259213859318 +vt 0.4062953125 0.6144379775174303 +vt 0.41147812500000003 0.6161832900174303 +vt 0.4112796875 0.6165176650174303 +vt 0.40625 0.6148239150174304 +vt 0.44921875 0.6483154982485235 +vt 0.44560457127802305 0.652202780918994 +vt 0.44538890638409473 0.6519724673987886 +vt 0.4489740647659449 0.6481170552478491 +vt 0.4489740647659449 0.6481170552478491 +vt 0.4453881272659449 0.6519733052478491 +vt 0.4451443772659449 0.6517733052478492 +vt 0.4487584397659449 0.6478873677478492 +vt 0.41796875 0.5835862827465821 +vt 0.4129390625 0.5852800327465821 +vt 0.41274062499999997 0.5849456577465821 +vt 0.4179234375 0.5831987827465821 +vt 0.0734594791678292 0.7123569341839817 +vt 0.06825375282136409 0.7140360665406897 +vt 0.06800664038123631 0.7136481951303014 +vt 0.07343444637196081 0.7118982177983879 +vt 0.44677237363616723 0.68013125 +vt 0.44192862363616725 0.6831421875 +vt 0.44162393613616724 0.6827578125 +vt 0.4465614361361672 0.6796875 +vt 0.22635663030203648 0.5577578125 +vt 0.23046875 0.5618679523893387 +vt 0.230084308092613 0.5621739189768236 +vt 0.22605095959053523 0.5581434278591246 +vt 0.27181874841283377 0.7448918767155437 +vt 0.2757020682154979 0.7490678701959645 +vt 0.2752982043670566 0.7492846119772035 +vt 0.27157324350935796 0.7452797335303292 +vt 0.41147812500000003 0.6161832900174303 +vt 0.4154974140086403 0.61989358844211 +vt 0.4151795940748441 0.6201182654268997 +vt 0.4112787205005558 0.6165192944495647 +vt 0.44560457127802305 0.652202780918994 +vt 0.4415074520262345 0.6555758678433294 +vt 0.44132352227633265 0.6553208251013702 +vt 0.44538941735436843 0.6519730130757428 +vt 0.4453881272659449 0.6519733052478491 +vt 0.44231481342361 0.65625 +vt 0.44204828885720504 0.6560831419506656 +vt 0.44514478165652727 0.6517736370555064 +vt 0.4129390625 0.5852800327465821 +vt 0.4090305654329605 0.5888692851350599 +vt 0.4087130767467222 0.5886453417468076 +vt 0.4127402728195842 0.5849450643087161 +vt 0.06825375282136409 0.7140360665406897 +vt 0.06452887892807446 0.7180410258719083 +vt 0.06412344834230388 0.7178243311407126 +vt 0.06800667746486151 0.7136482533373185 +vt 0.44192862363616725 0.6831421875 +vt 0.43788417847747146 0.6871615434368841 +vt 0.4375 0.6868559683467197 +vt 0.4416234356453207 0.6827571811115474 +vt 0.4407375 0.6079774977952561 +vt 0.44140625 0.613754060295256 +vt 0.4409140625 0.613754060295256 +vt 0.440259375 0.6080884352952561 +vt 0.2757020682154979 0.7490678701959645 +vt 0.27703612424346297 0.7546110486810375 +vt 0.27657683963002505 0.7546019363550899 +vt 0.2752976837393872 0.7492848913826736 +vt 0.4154974140086403 0.61989358844211 +vt 0.41764330835842234 0.624925353821199 +vt 0.41726147386954443 0.625 +vt 0.41517989385306775 0.6201180535041717 +vt 0.47125124352473324 0.7930205160341451 +vt 0.472303647757488 0.79822219625011 +vt 0.47199081176572055 0.7982640624999999 +vt 0.470947100902919 0.7931037824784803 +vt 0.4723408117657205 0.793009375 +vt 0.47199081176572055 0.7982640624999999 +vt 0.4716751867657205 0.7982640624999999 +vt 0.4720283117657205 0.79296875 +vt 0.4090305654329605 0.5888692851350599 +vt 0.4069460992278427 0.59375 +vt 0.40656430894181694 0.5936751280666561 +vt 0.4087116447582103 0.588644331681337 +vt 0.06452887892807446 0.7180410258719083 +vt 0.06325877343717295 0.7233602400124776 +vt 0.0627995049961648 0.7233701339358114 +vt 0.06412412571086987 0.7178246931809841 +vt 0.438646875 0.5612058125556649 +vt 0.43799062499999997 0.5668698750556649 +vt 0.4375 0.5668698750556649 +vt 0.4381671875 0.561093312555665 +vt 0.47265625 0.8681976019749671 +vt 0.4721328125 0.8727288519749671 +vt 0.4717578125 0.8726397894749671 +vt 0.472271875 0.8681976019749671 +vt 0.26020025728472795 0.5850977268937164 +vt 0.258999953905363 0.5894060536018535 +vt 0.2586886896560832 0.5892246035055935 +vt 0.2598393638820387 0.5850920850144519 +vt 0.44645000532735407 0.8945906728594395 +vt 0.4447552104957369 0.8985311178233846 +vt 0.44450680479961385 0.8983542757306616 +vt 0.44615116858859477 0.89453125 +vt 0.47180701050085455 0.6834186045580191 +vt 0.4709823128076117 0.6875 +vt 0.4707437191791549 0.6874334196444807 +vt 0.471562312817996 0.6833858889710818 +vt 0.471562312817996 0.6833858889710818 +vt 0.4707437191791549 0.6874334196444807 +vt 0.4704984152087964 0.6874005434382084 +vt 0.4713241331832291 0.683320944939737 +vt 0.4405767882606692 0.86328125 +vt 0.44221239942462315 0.8671080285575026 +vt 0.4419635897069583 0.8672843017546159 +vt 0.4402778163256016 0.8633399888687173 +vt 0.15810289519377857 0.5851118081536224 +vt 0.15929486263559678 0.5892330179114053 +vt 0.15898535966556357 0.5894174561987893 +vt 0.15774363483279033 0.5851208846974365 +vt 0.4691359375 0.9306928809708328 +vt 0.46965 0.9351350684708328 +vt 0.469275 0.9352225684708328 +vt 0.4687515625 0.9306928809708328 +vt 0.4609375 0.7443690416506121 +vt 0.45771207226770927 0.7475921875 +vt 0.45747248420140774 0.7472899620307438 +vt 0.4606359248596603 0.7441295920188827 +vt 0.258999953905363 0.5894060536018535 +vt 0.25583568021527203 0.5925671875 +vt 0.25565498713568774 0.5922556477076133 +vt 0.2586880259196648 0.5892242165834292 +vt 0.4447552104957369 0.8985311178233846 +vt 0.4416 0.901436591635906 +vt 0.4414450036367547 0.9011735675043382 +vt 0.4445075854738967 0.8983548314992131 +vt 0.4549001137304198 0.7114745731078429 +vt 0.4581065364762551 0.7141265155068195 +vt 0.4579376330022045 0.7143054799085273 +vt 0.45475534221739394 0.7116754943212978 +vt 0.4555479826255715 0.7109375 +vt 0.4579365300554319 0.7143066485535798 +vt 0.45774558940588517 0.7144618848540243 +vt 0.4553369258448811 0.7110670498372234 +vt 0.4422123994246232 0.8671080285575026 +vt 0.4452734375 0.8699270058215393 +vt 0.4451184411367547 0.870190029953107 +vt 0.44196323064101783 0.8672845561405855 +vt 0.15929486263559678 0.5892330179114053 +vt 0.16234073966877918 0.5922546875 +vt 0.16215964400856753 0.5925669213969167 +vt 0.15898519962923208 0.5894175515672663 +vt 0.17217818417234507 0.5409995899045603 +vt 0.1753386767431903 0.5441640624999999 +vt 0.17509889056079408 0.5444661308076939 +vt 0.171875 0.5412394186647247 +vt 0.45771207226770927 0.7475921875 +vt 0.4538386347677093 0.75 +vt 0.4536745722677093 0.7496515625 +vt 0.45747300976770927 0.747290625 +vt 0.25583568021527203 0.5925671875 +vt 0.25152318021527204 0.59375 +vt 0.25151849271527205 0.5933890625 +vt 0.255654430215272 0.5922546875 +vt 0.4416 0.901436591635906 +vt 0.43753593749999997 0.9028053416359061 +vt 0.4375 0.902502216635906 +vt 0.4414453125 0.901174091635906 +vt 0.4581065364762551 0.7141265155068195 +vt 0.4609375 0.7171781213724675 +vt 0.46074584954932163 0.7173331512563965 +vt 0.4579365300554319 0.7143066485535798 +vt 0.4579365300554319 0.7143066485535798 +vt 0.4607459050554319 0.7173332110535797 +vt 0.46057715505543184 0.7175128985535798 +vt 0.45774434255543184 0.7144628985535798 +vt 0.4452734375 0.8699270058215393 +vt 0.44921875 0.8712566933215393 +vt 0.44918281250000003 0.8715598183215392 +vt 0.44511875 0.8701895058215392 +vt 0.16234073966877918 0.5922546875 +vt 0.16647667716877917 0.5933890625 +vt 0.16647198966877919 0.59375 +vt 0.1621594896687792 0.5925671875 +vt 0.1753386767431903 0.5441640624999999 +vt 0.17913555174319032 0.5465265625 +vt 0.1789714892431903 0.546875 +vt 0.17509805174319032 0.5444671875 +vt 0.5038734375 0.578125 +vt 0.5 0.5757187500000001 +vt 0.5002390624999999 0.5754171875 +vt 0.5040390625 0.5777765625 +vt 0.29119999209939673 0.591779950155542 +vt 0.28692215206246585 0.5904721790431169 +vt 0.2871124133331913 0.5901650822157776 +vt 0.29121356689821637 0.5914208180334936 +vt 0.44918281250000003 0.9653203928836724 +vt 0.4451171875 0.9639500803836725 +vt 0.4452734375 0.9636875803836725 +vt 0.44921875 0.9650172678836725 +vt 0.46875 0.49914389714558793 +vt 0.47158253512287024 0.49609375 +vt 0.47175089043019497 0.49627408510004456 +vt 0.46894157056465496 0.4992990257339075 +vt 0.46894157056465496 0.4992990257339075 +vt 0.47175094556465497 0.49627402573390744 +vt 0.47194313306465496 0.49642871323390747 +vt 0.46911032056465496 0.49947871323390747 +vt 0.4375 0.9962455228276426 +vt 0.4414453125 0.9949173978276427 +vt 0.4416 0.9951798978276426 +vt 0.43753593749999997 0.9965486478276426 +vt 0.18938237604348068 0.5915258889687168 +vt 0.19346023743370916 0.5901929375919934 +vt 0.19365473787121798 0.5904948304643362 +vt 0.18940442108279923 0.5918846178071487 +vt 0.503775 0.6402765625 +vt 0.507571875 0.637915625 +vt 0.5078125 0.6382171875 +vt 0.5039390625 0.640625 +vt 0.47081089747058114 0.84375 +vt 0.4692734375 0.8394571839008039 +vt 0.46964844058711847 0.8393696831804762 +vt 0.4711567092970892 0.8435792462110864 +vt 0.28692215206246585 0.5904721790431169 +vt 0.2838511468433031 0.587220360713061 +vt 0.284168230969437 0.5870476718990483 +vt 0.2871118287241652 0.5901660258212548 +vt 0.4451171875 0.9639500803836725 +vt 0.4419748109875442 0.9610307309353155 +vt 0.44222321043376894 0.9608555375146444 +vt 0.4452733414084234 0.9636877418175211 +vt 0.31338103479406954 0.5280097834864856 +vt 0.3130775275854747 0.5238579651753845 +vt 0.31332362865572727 0.5238562014075973 +vt 0.31362657905669633 0.5279746219250359 +vt 0.3125 0.5279023825540898 +vt 0.3133236832087476 0.5238562010166243 +vt 0.3135691145843218 0.5238892398556438 +vt 0.31273906260574136 0.5279685444075151 +vt 0.4414453125 0.9949173978276427 +vt 0.44450635057537685 0.992098420563606 +vt 0.4447555193589822 0.9922749481466889 +vt 0.4416003088632453 0.9951804219592104 +vt 0.19346023743370916 0.5901929375919934 +vt 0.19636156690213277 0.5870322191104511 +vt 0.19667951581732995 0.587202078861367 +vt 0.19365572762695946 0.5904963667087145 +vt 0.4702462990643151 0.9060788436466001 +vt 0.4717578125 0.9018695768183507 +vt 0.47213427295627436 0.9019574175914813 +vt 0.4705919118222857 0.90625 +vt 0.4692734375 0.8394571839008039 +vt 0.46875 0.8349274964008039 +vt 0.4691359375 0.8349274964008039 +vt 0.4696484375 0.8393696839008039 +vt 0.2838511468433031 0.587220360713061 +vt 0.28277664321702733 0.5828789497461422 +vt 0.28313598569389514 0.5828837845102035 +vt 0.28416755626151763 0.5870480393551927 +vt 0.4419748109875442 0.9610307309353155 +vt 0.440277392153155 0.9570910608036962 +vt 0.4405777448296225 0.95703125 +vt 0.44222347283917285 0.9608553524429744 +vt 0.3130775275854747 0.5238579651753845 +vt 0.313352327486045 0.5197046188382852 +vt 0.31359702434415465 0.5197373405933768 +vt 0.3133236832087476 0.5238562010166243 +vt 0.3133236832087476 0.5238562010166243 +vt 0.3135971207087476 0.5197358885166243 +vt 0.3138455582087476 0.5197358885166243 +vt 0.31356743320874764 0.5238890135166243 +vt 0.44450635057537685 0.992098420563606 +vt 0.4461680933545461 0.98828125 +vt 0.44646819268760546 0.9883423192459206 +vt 0.44475580623961103 0.9922751513918279 +vt 0.19636156690213277 0.5870322191104511 +vt 0.19735806281818802 0.5828598389572062 +vt 0.19771891695089366 0.5828520817788848 +vt 0.19667935149877414 0.5872019910764819 +vt 0.4717578125 0.9018695768183507 +vt 0.4722703125 0.8974258268183506 +vt 0.47265625 0.8974258268183506 +vt 0.4721328125 0.9019570768183507 +vt 0.46875 0.8349274964008039 +vt 0.4692734375 0.8303962464008039 +vt 0.4696484375 0.8304853089008039 +vt 0.4691359375 0.8349274964008039 +vt 0.28277664321702733 0.5828789497461422 +vt 0.283967538532901 0.5785695811721181 +vt 0.2842791972699075 0.5787503528567373 +vt 0.2831375480524902 0.5828838055309169 +vt 0.42462530753325783 0.7499391781408238 +vt 0.4263385395042631 0.7460067142505024 +vt 0.42658642611341707 0.7461831868008704 +vt 0.42492386267741483 0.75 +vt 0.313352327486045 0.5197046188382852 +vt 0.3141784696745081 0.515625 +vt 0.3144157200100029 0.5156898305557887 +vt 0.31359702434415465 0.5197373405933768 +vt 0.3135967152559509 0.5197388686796478 +vt 0.3144157200100029 0.5156898305557887 +vt 0.3146610239797359 0.515722706766728 +vt 0.3138348948894822 0.5198038127155241 +vt 0.44616017555498355 0.9375 +vt 0.44450635057537685 0.9336793923110832 +vt 0.44475464036179924 0.9335034874675183 +vt 0.4464588690975501 0.9374398614908293 +vt 0.19735806281818802 0.5828598389572062 +vt 0.19618162324249652 0.5787341697781825 +vt 0.19649181856510917 0.5785508983070208 +vt 0.19771735481179106 0.5828521153597434 +vt 0.4722703125 0.8974258268183506 +vt 0.47175625 0.8929836393183507 +vt 0.47213125 0.8928961393183507 +vt 0.4726546875 0.8974258268183506 +vt 0.25 0.5446994114810737 +vt 0.253220343938608 0.5414703125 +vt 0.2534590909381241 0.5417714770157295 +vt 0.2503020596434538 0.5449382496338107 +vt 0.283967538532901 0.5785695811721181 +vt 0.2871249192829726 0.5754015625 +vt 0.28730629051530476 0.5757127079761559 +vt 0.2842798618475272 0.5787507383322641 +vt 0.4263385395042631 0.7460067142505024 +vt 0.42949375 0.7431012404379809 +vt 0.4296487463632453 0.7433642645695486 +vt 0.4265861645261033 0.7461830005746739 +vt 0.28728879539822944 0.546338601994149 +vt 0.2840809635237449 0.5436859844931798 +vt 0.28424986699779553 0.543507020091472 +vt 0.28743215778260617 0.5461370056787013 +vt 0.2866395173744276 0.546875 +vt 0.2842509699445681 0.5435058514464196 +vt 0.28444191059411483 0.5433506151459752 +vt 0.28685057415511805 0.5467454501627766 +vt 0.44450635057537685 0.9336793923110832 +vt 0.4414453125 0.9308604150470465 +vt 0.4416003088632453 0.9305973909154788 +vt 0.44475551935898217 0.9335028647280001 +vt 0.19618162324249652 0.5787341697781825 +vt 0.19313577054910633 0.5757140625 +vt 0.19331686620931798 0.5754018286030833 +vt 0.19649131058865343 0.5785511984327337 +vt 0.1481343158276549 0.5449379100954397 +vt 0.14497382325680983 0.5417734375000001 +vt 0.14521360943920605 0.5414713691923061 +vt 0.1484375 0.5446980813352755 +vt 0.253220343938608 0.5414703125 +vt 0.257093781438608 0.5390625 +vt 0.257257843938608 0.5394109375 +vt 0.253459406438608 0.541771875 +vt 0.2871249192829726 0.5754015625 +vt 0.2914374192829726 0.57421875 +vt 0.29144210678297267 0.5745781249999999 +vt 0.28730616928297265 0.5757125 +vt 0.42949375 0.7431012404379809 +vt 0.43355781250000003 0.7417309279379809 +vt 0.43359375 0.7420340529379809 +vt 0.4296484375 0.7433637404379809 +vt 0.2840809635237449 0.5436859844931798 +vt 0.28125 0.5406343786275318 +vt 0.28144165045067837 0.5404793487436028 +vt 0.2842509699445681 0.5435058514464196 +vt 0.2842509699445681 0.5435058514464196 +vt 0.2814415949445681 0.5404792889464196 +vt 0.28161034494456816 0.5402996014464196 +vt 0.28444315744456816 0.5433496014464196 +vt 0.4414453125 0.9308604150470465 +vt 0.4375 0.9295307275470466 +vt 0.43753593749999997 0.9292276025470465 +vt 0.4416 0.9305979150470465 +vt 0.19313577054910633 0.5757140625 +vt 0.18899983304910634 0.5745796875 +vt 0.18900452054910633 0.57421875 +vt 0.19331702054910632 0.5754015625 +vt 0.14497382325680983 0.5417734375000001 +vt 0.14117694825680982 0.5394109375 +vt 0.14134101075680983 0.5390625 +vt 0.14521444825680982 0.5414703125 +vt 0.4414390625 0.49609375 +vt 0.4453125 0.49849999999999994 +vt 0.4450734375 0.49880156249999996 +vt 0.4412734375 0.4964421875 +vt 0.25181367829083406 0.5761521930799243 +vt 0.2560836393938723 0.5774854645611325 +vt 0.2558915492226743 0.5777914207322242 +vt 0.2517979062215261 0.5765127993560218 +vt 0.43753593749999997 0.835455920092886 +vt 0.4416015625 0.8368246700928861 +vt 0.4414453125 0.8370871700928861 +vt 0.4375 0.835759045092886 +vt 0.50390625 0.543824123610316 +vt 0.5010745003488303 0.546875 +vt 0.5009060986128728 0.5466947082554885 +vt 0.5037146394978688 0.5436690443543555 +vt 0.5037146394978688 0.5436690443543555 +vt 0.5009052644978688 0.5466956068543555 +vt 0.5007130769978687 0.5465393568543555 +vt 0.5035458894978687 0.5434893568543555 +vt 0.43359375 0.7107841646723576 +vt 0.4296484375 0.7121138521723576 +vt 0.42949375 0.7118513521723576 +vt 0.43355781250000003 0.7104810396723575 +vt 0.1660600110230876 0.576434756057472 +vt 0.1619850501057879 0.577776548296645 +vt 0.16178981650956414 0.5774735174557033 +vt 0.1660371091545825 0.5760745153657373 +vt 0.5040375 0.6058171875 +vt 0.500240625 0.608178125 +vt 0.5 0.6078765625 +vt 0.5038734375 0.60546875 +vt 0.47059379312938815 0.859375 +vt 0.4721328125 0.863667914474967 +vt 0.47175780941288153 0.8637554151952946 +vt 0.47024798130288004 0.8595457537889136 +vt 0.2560836393938723 0.5774854645611325 +vt 0.2591351880416749 0.5807555481010297 +vt 0.25881707921517405 0.5809263419651862 +vt 0.25589213945129774 0.5777904806316008 +vt 0.4416015625 0.8368246700928861 +vt 0.4447439390124558 0.8397440195412431 +vt 0.44449553956623106 0.8399192129619144 +vt 0.4414454085915766 0.8370870086590374 +vt 0.4717752831411375 0.6751140261565169 +vt 0.47208170570674457 0.6792652512952793 +vt 0.4718343319046574 0.6792654742238817 +vt 0.47153158168540993 0.6751481764288625 +vt 0.47265625 0.6752178671845079 +vt 0.47183575718869636 0.6792654729394426 +vt 0.4715903258131222 0.679232434100423 +vt 0.47241881678923126 0.675153197941053 +vt 0.4296484375 0.7121138521723576 +vt 0.42658739942462315 0.7149328294363944 +vt 0.4263382306410178 0.7147563018533113 +vt 0.4294934411367547 0.7118508280407898 +vt 0.1619850501057879 0.577776548296645 +vt 0.15908367193515494 0.5809357050373809 +vt 0.1587657717221666 0.5807674070272708 +vt 0.16178955991253763 0.5774731191799238 +vt 0.4711733132096674 0.9220471176739401 +vt 0.4696484375 0.9262506934708328 +vt 0.46927378277555376 0.9261617129737768 +vt 0.47082817818956846 0.921875 +vt 0.4721328125 0.863667914474967 +vt 0.47265625 0.8681976019749671 +vt 0.4722703125 0.8681976019749671 +vt 0.4717578125 0.8637554144749671 +vt 0.2591351880416749 0.5807555481010297 +vt 0.26020025728472795 0.5850977268937164 +vt 0.2598409261911413 0.5850921094381716 +vt 0.258818428455449 0.5809256175528436 +vt 0.4447439390124558 0.8397440195412431 +vt 0.44642288712927514 0.843691596603992 +vt 0.44612225761336777 0.84375 +vt 0.44449475645046155 0.8399197652849268 +vt 0.47208170570674457 0.6792652512952793 +vt 0.47180701050085455 0.6834186045580191 +vt 0.47156231281799604 0.6833858889710818 +vt 0.47183575718869636 0.6792654729394426 +vt 0.47183575718869636 0.6792654729394426 +vt 0.4715623196886964 0.6833857854394426 +vt 0.47131388218869635 0.6833857854394426 +vt 0.47159200718869637 0.6792326604394426 +vt 0.42658739942462315 0.7149328294363944 +vt 0.4249256566454533 0.71875 +vt 0.42462555731239393 0.7186889307540794 +vt 0.426337943760389 0.7147560986081725 +vt 0.15908367193515494 0.5809357050373809 +vt 0.15810289519377857 0.5851118081536224 +vt 0.15774207283122083 0.5851209241606704 +vt 0.1587652502742435 0.5807671309700546 +vt 0.4696484375 0.9262506934708328 +vt 0.4691359375 0.9306928809708328 +vt 0.46875 0.9306928809708328 +vt 0.4692734375 0.9261616309708328 +vn -0.27970084401384354 0.000004320705090200406 0.9600872032473275 +vn -0.8616674058478467 0 0.5074734295502004 +vn -0.2796384011834744 0 -0.9601053924354089 +vn -0.8616896672900923 0 -0.5074356287111599 +vn 0 -1 0 +vn 0 -1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 -1 0 +vn 0 -1 0 +vn 0 -1 0 +vn -1 0 0 +vn 0.000004593245173603774 0 0.9999999999894512 +vn 0 -1 0 +vn 0 1 0 +vn 0 0 -1 +vn 0 0.9998623798929617 -0.01658979429597558 +vn -1 0 0 +vn -1 0 0 +vn -1 0 0 +vn 0 0 -1 +vn 0 -0.9998623798929617 -0.01658979429597558 +vn -1 0 0 +vn 1 0 0 +vn 0 -1 0 +vn -1 0 0 +vn 0 0 -1 +vn 1 0 0 +vn 0 1 0 +vn 1 0 0 +vn 0 1 0 +vn 0 0 1 +vn 0 -1 0 +vn 1 0 0 +vn -1 0 0 +vn -1 0 0 +vn 0 0 -1 +vn -1 0 0 +vn 1 0 0 +vn -1 0 0 +vn 1 0 0 +vn 1 0 0 +vn 0 1 0 +vn 0 0 1 +vn 0 -1 0 +vn 1 0 0 +vn -1 0 0 +vn -1 0 0 +vn 0 0 -1 +vn -1 0 0 +vn 1 0 0 +vn -1 0 0 +vn 1 0 0 +vn 0.9999999784067134 0 0.00020781379434906723 +vn 0 1 0 +vn 0 0 1 +vn 0 -1 0 +vn 0.9999999924557774 -0.00012283503162471233 0 +vn -1 0 0 +vn -1 0 0 +vn 0 1 0 +vn 0 0 1 +vn 0 -1 0 +vn -0.000016840687099999555 0.9999999998581958 0 +vn 0 -1 0 +vn 1 0 0 +vn 0 0 1 +vn 0 0 1 +vn -1 0 0 +vn -0.000020288502499785386 0.9999999997941884 0 +vn 0.2796384011834726 0 0.9601053924354093 +vn 0.8616896672900929 0 0.5074356287111589 +vn 0.2796384011834744 0 -0.9601053924354089 +vn 0.8616896672900923 0 -0.5074356287111599 +vn 0 -1 0 +vn 0 -1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 1 0 +vn 0 -1 0 +vn 0 -1 0 +vn 0 -1 0 +vn 1 0 0 +vn 0 0 1 +vn 0 -1 0 +vn 0 1 0 +vn 0 0 -1 +vn 0 0.9998623798929617 -0.01658979429597558 +vn 1 0 0 +vn 1 0 0 +vn 1 0 0 +vn 0 0 -1 +vn 0 -0.9998623798929617 -0.01658979429597558 +vn 1 0 0 +vn -1 0 0 +vn 0 -1 0 +vn 1 0 0 +vn 0 0 -1 +vn -1 0 0 +vn 0 1 0 +vn -1 0 0 +vn 0 1 0 +vn 0 0 1 +vn 0 -1 0 +vn -1 0 0 +vn 1 0 0 +vn 1 0 0 +vn 0 1 0 +vn 0 0 1 +vn 0 -1 0 +vn 0.00001684068709999956 0.9999999998581958 0 +vn 0 -1 0 +vn -1 0 0 +vn 0 0 1 +vn 0 0 1 +vn 1 0 0 +vn 0.00002028850249978539 0.9999999997941885 0 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn -1 0 0 +vn 0 1 0 +vn -1 0 0 +vn 0 -1 0 +vn -1 0 0 +vn 0 1 0 +vn -1 0 0 +vn 0 -1 0 +vn 1 0 0 +vn 0 1 0 +vn -1 0 0 +vn 0 -1 0 +vn -0.999999928541659 0.0003780432211332305 0 +vn 0 -1 0 +vn 0.9999999542666601 0.0003024345846866361 0 +vn 1 0 0 +vn 0 1 0 +vn -1 0 0 +vn -0.0005705590550910522 -0.9999998372311693 0 +vn 0 0 -1 +vn 0 0 -1 +vn 1 0 0 +vn 1 0 0 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0.0005704505631972692 -0.9999998372930644 0 +vn 0 1 0 +vn 0 1 0 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 1 0 +vn 1 0 0 +vn 0 -1 0 +vn -1 0 0 +vn 0 -1 0 +vn -0.1468871362705595 -0.9891532587007104 0 +vn -0.4927751328565911 -0.870156691888403 0 +vn -0.8220552492041413 -0.5694077337514108 0 +vn -0.983655485911368 -0.1800607815057762 0 +vn -0.9858155790311286 0.16783219041507103 0 +vn -0.8434370616049499 0.5372279991876896 0 +vn -0.5285792240947049 0.8488839754969107 0 +vn 1 0 0 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 1 0 0 +vn -0.1549759390008552 0.9879182447605689 0 +vn 0.13381854273672986 0.9910058514558924 0 +vn 1 0 0 +vn 0 1 0 +vn -1 0 0 +vn 0 -1 0 +vn 0.1682746704333333 -0.9857401459261731 0 +vn 0.557746486676587 -0.830011359319813 0 +vn 0.8688866264164248 -0.49501114172782484 0 +vn 0.9889900228411355 -0.1479822108251202 0 +vn 0.9829780058617292 0.1837232701431651 0 +vn 0.8121385048818909 0.5834646937803583 0 +vn 0.4822633692046625 0.8760262796990552 0 +vn 0 1 0 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn -0.17127486139601694 -0.9852232852778984 0 +vn -0.861741950393139 -0.5073468349488632 0 +vn -0.7228508075401505 0.691004131708742 0 +vn -0.7228872706225067 -0.690965986117944 0 +vn -0.17108419753534493 0.9852564119830367 0 +vn 0.6355603360492338 0.7720512024736347 0 +vn -0.861741950393139 0.5073468349488632 0 +vn 0.6356158460731367 -0.7720055027140226 0 +vn 0 -1 0 +vn 0 1 0 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0 0 -1 +vn 0.17129143499492505 -0.9852204039185241 0 +vn 0.861741950393139 -0.5073468349488632 0 +vn 0.7228508075401505 0.691004131708742 0 +vn 0.7228872706225067 -0.690965986117944 0 +vn 0.17110075379792253 0.985253536938479 0 +vn -0.6355374863578725 0.7720700120027438 0 +vn 0.861741950393139 0.5073468349488632 0 +vn -0.6355929970911549 -0.7720243144154744 0 +vn 0 -1 0 +vn 0 1 0 +vn 0 0 1 +vn 0 -1 0 +vn 0.00001682170672660098 0.9999999998585151 0 +vn -1 0 0 +vn -0.4753616843343147 0 0.8797904688429193 +vn -0.005861343655062494 -0.9999828221777399 0 +vn 0.0058442557741126226 0.9999829221913976 0 +vn 1 0 0 +vn 0 0 1 +vn 0 -1 0 +vn 0 1 0 +vn -1 0 0 +vn 1 0 0 +vn -1 0 0 +vn 0 0 -1 +vn 1 0 0 +vn 0 -1 0 +vn 0 1 0 +vn -1 0 0 +vn 1 0 0 +vn 0 0 1 +vn 0 -1 0 +vn 0 1 0 +vn 0 -0.3069304424102096 -0.9517319494069079 +vn 1 0 0 +vn -1 0 0 +vn 0 0.8979372481496226 0.44012350355949337 +vn 0 0 1 +vn 1 0 0 +vn 0 0.3069304424102096 0.9517319494069079 +vn 0 0 -1 +vn -1 0 0 +vn 0 -0.8979372481496226 -0.44012350355949337 +vn 0 -0.7039785682485021 0.7102212158523492 +vn 0 0.21148359134035302 0.9773815481140345 +vn 0 0 1 +vn -1 0 0 +vn 0 -1 0 +vn 1 0 0 +vn -1 0 0 +vn 0 0 1 +vn 0 1 0 +vn 1 0 0 +vn -1 0 0 +vn 1 0 0 +vn -1 0 0 +vn 0 0 -1 +vn 1 0 0 +vn -1 0 0 +vn 1 0 0 +vn 0 0.11692060382216189 0.9931412650785694 +vn 1 0 0 +vn -1 0 0 +vn 0 -0.11692060382216228 -0.9931412650785693 +vn 1 0 0 +vn -1 0 0 +vn 0 -0.8979466359027157 0.44010435020685235 +vn -1 0 0 +vn 0 0.8979466359027157 -0.44010435020685235 +vn 1 0 0 +vn 0.9999999924557774 -0.00012283503162471233 0 +vn 0 -1 0 +vn -1 0 0 +vn 1 0 0 +vn 0 -1 0 +vn -1 0 0 +vn -1 0 0 +vn 0 -1 0 +vn 1 0 0 +vn 1 0 0 +vn 0 -1 0 +vn -1 0 0 +vn 0.5406122395267138 -0.7772230029138733 -0.32196709492656644 +vn 1 0 0 +vn 0.5410487422028901 0.7769711891264823 0.32184162227414054 +vn -0.541039529580247 0.7769953686938156 0.3217987328439113 +vn -1 0 0 +vn -0.5406064963359176 -0.7772380534338938 -0.3219404050654328 +vn 0.541155497025788 -0.32183339269146954 -0.7769002480289674 +vn 1 0 0 +vn 0.540732120261815 0.32185568428719374 0.7771857516766403 +vn -0.5407068192789195 0.32198597160912906 0.7771493869728003 +vn -1 0 0 +vn -0.5411713583206951 -0.3217517419670976 -0.7769230190144144 +vn 0.5410524261118321 0.3218587742815667 -0.776961518748817 +vn 1 0 0 +vn 0.5410487422028841 -0.3218416222741488 0.7769711891264831 +vn -0.5410762150558138 -0.32196955508265857 0.7768990507786385 +vn -1 0 0 +vn -0.5410466953053197 0.32183209245170197 -0.7769765619163501 +vn 0.5411554970257533 0.7769002480289918 -0.3218333926914691 +vn 1 0 0 +vn 0.5407190985249835 -0.777167035739583 0.3219227470218266 +vn -0.5406916136922189 -0.7771275322227555 0.3220642444362222 +vn -1 0 0 +vn -0.5411612285404351 0.776908476375932 -0.32180389068473886 +vn 0.5408701313324525 0.7770698438629193 0.321903648303765 +vn 1 0 0 +vn 0.5410357168993974 -0.7770053738922782 -0.32178098449360926 +vn -0.5410610193669944 -0.776938957754231 -0.3218987872691013 +vn -1 0 0 +vn -0.5408543065934902 0.7771114540116514 0.3218297796711359 +vn 0.5407154145462281 0.3219417169077418 0.777161740814997 +vn 1 0 0 +vn 0.54115918152216 -0.32181442771315166 -0.7769055376105405 +vn -0.5411683935313876 -0.32176700585658335 -0.7769187626674664 +vn -1 0 0 +vn -0.5407211452557408 0.32191220726661385 0.7771699774734332 +vn 0.5404297606512753 -0.3220119626085738 0.7773313127228183 +vn 1 0 0 +vn 0.5410357168993927 0.32178098449362247 -0.7770053738922761 +vn -0.5410243311537852 0.32172798850598633 -0.777035246633955 +vn -1 0 0 +vn -0.5404139016701894 -0.3219380717468718 0.7773729432142008 +vn 0.5407154145462297 -0.7771617408149989 0.3219417169077342 +vn 1 0 0 +vn 0.5411722050881717 0.7769242346611303 -0.3217473823300244 +vn -0.5411835844463225 0.7769405712339241 -0.3216887885776157 +vn -1 0 0 +vn -0.5407312736132205 -0.7771845348019627 0.3218600450526309 +vn -0.3219036483037656 -0.7770698438629192 -0.5408701313324521 +vn 0 0 -1 +vn 0.32199480035023603 0.777340982663723 -0.5404260774785106 +vn 0.32188109867209785 0.777405037264344 0.5404016713090988 +vn 0 0 1 +vn -0.321943673162391 -0.7770472949102868 0.5408787043167013 +vn -0.7771617408149988 -0.3219417169077435 -0.5407154145462241 +vn 0 0 -1 +vn 0.7771670357395838 0.32192274702183377 -0.5407190985249785 +vn 0.7772021047522853 0.3217970753678871 0.5407434980222066 +vn 0 0 1 +vn -0.7771699774734353 -0.32191220726661646 0.5407211452557367 +vn -0.7773313127227947 0.32201196260856485 -0.5404297606513149 +vn 0 0 -1 +vn 0.7773409826637375 -0.3219948003502489 -0.5404260774784824 +vn 0.7774050372643633 -0.32188109867210163 0.5404016713090686 +vn 0 0 1 +vn -0.7773729432141708 0.32193807174687405 0.5404139016702313 +vn -0.3219417169077348 0.7771617408149988 -0.5407154145462293 +vn 0 0 -1 +vn 0.3219227470218266 -0.777167035739583 -0.5407190985249833 +vn 0.32179707536788993 -0.7772021047522819 0.5407434980222097 +vn 0 0 1 +vn -0.3218600450526594 0.7771845348019549 0.5407312736132147 +vn 0.3220119626085506 0.7773313127227811 -0.5404297606513426 +vn 0 0 -1 +vn -0.32199480035023603 -0.777340982663723 -0.5404260774785106 +vn -0.32188109867209785 -0.777405037264344 0.5404016713090988 +vn 0 0 1 +vn 0.32193807174686556 0.7773729432141541 0.5404139016702604 +vn 0.7771617408149988 0.3219417169077435 -0.5407154145462241 +vn 0 0 -1 +vn -0.7771670357395838 -0.32192274702183377 -0.5407190985249785 +vn -0.7772021047522853 -0.3217970753678871 0.5407434980222066 +vn 0 0 1 +vn 0.7771845348019566 0.32186004505266175 0.5407312736132107 +vn 0.7772230029138352 -0.32196709492655856 -0.540612239526773 +vn 0 0 -1 +vn -0.7773409826637375 0.3219948003502489 -0.5404260774784824 +vn -0.7774050372643633 0.32188109867210163 0.5404016713090686 +vn 0 0 1 +vn 0.7772380534338467 -0.32194040506544175 0.5406064963359802 +vn 0.3219417169077348 -0.7771617408149988 -0.5407154145462293 +vn 0 0 -1 +vn -0.3219227470218266 0.777167035739583 -0.5407190985249833 +vn -0.32179707536788993 0.7772021047522819 0.5407434980222097 +vn 0 0 1 +vn 0.32198597160911707 -0.7771493869728038 0.5407068192789215 +vn 0.5410524261118443 -0.7769615187488133 -0.3218587742815546 +vn 1 0 0 +vn 0.5406085476567074 0.7772326780180536 0.3219499377564633 +vn -0.5405993151272416 0.7772568694027536 0.3219070354157986 +vn -1 0 0 +vn -0.5410711494946209 -0.776912355643096 -0.3219459625987964 +vn 0.541155497025788 -0.32183339269146954 -0.7769002480289674 +vn 1 0 0 +vn 0.5407190985249792 0.32192274702184864 0.7771670357395768 +vn -0.5407068192789195 0.32198597160912906 0.7771493869728003 +vn -1 0 0 +vn -0.5411612285404727 -0.32180389068472565 -0.776908476375911 +vn 0.5408701313324484 0.3219036483037663 -0.7770698438629217 +vn 1 0 0 +vn 0.5408664561129104 -0.32188649120820717 0.7770795090784017 +vn -0.5408787043166682 -0.321943673162394 0.7770472949103087 +vn -1 0 0 +vn -0.5408543065934838 0.3218297796711268 -0.7771114540116599 +vn 0.5407154145462295 0.7771617408149986 -0.3219417169077355 +vn 1 0 0 +vn 0.5407190985249765 -0.777167035739573 0.32192274702186263 +vn -0.5407283092402781 -0.777180274155467 0.3218753128927157 +vn -1 0 0 +vn -0.5407211452557399 0.7771699774734323 -0.32191220726661796 +vn 0.540429760651343 0.777331312722781 0.3220119626085499 +vn 1 0 0 +vn 0.5405954942087153 -0.7772668794907557 -0.32188928172267256 +vn -0.5405840838659441 -0.7772967668302193 -0.3218362697803117 +vn -1 0 0 +vn -0.5404139016702608 0.777372943214154 0.321938071746865 +vn 0.5407154145462301 0.3219417169077313 0.7771617408149997 +vn 1 0 0 +vn 0.54115918152216 -0.32181442771315166 -0.7769055376105405 +vn -0.5411835844463633 -0.3216887885775853 -0.7769405712339083 +vn -1 0 0 +vn -0.5407312736132172 0.3218600450526476 0.7771845348019581 +vn 0.5406122395267741 -0.32196709492655756 0.7772230029138348 +vn 1 0 0 +vn 0.5408664561129133 0.32188649120822066 -0.7770795090783942 +vn -0.5408421026338808 0.32177282373815713 -0.7771435323813328 +vn -1 0 0 +vn -0.5406064963359812 -0.32194040506544 0.7772380534338468 +vn 0.5407154145462297 -0.7771617408149989 0.3219417169077342 +vn 1 0 0 +vn 0.5407321202618107 0.7771857516766336 -0.321855684287217 +vn -0.5407434980222096 0.777202104752282 -0.3217970753678902 +vn -1 0 0 +vn -0.5407068192789218 -0.7771493869728037 0.3219859716091166 +vn 0.8714636837537898 0.23350742761373638 0.4313065373345166 +vn 0.20606696530463564 0.055211206254232995 0.9769790829460427 +vn -0.5854376237922859 -0.15687944274526597 0.7953940087103857 +vn -0.9355898936550568 -0.2507103513915197 0.2486279762932162 +vn -0.9356445502017953 -0.25069791796844054 -0.24843475925073233 +vn -0.5854374269392296 -0.15687987228279748 -0.7953940688811476 +vn 0.2065816921667063 0.05535759819902779 -0.9768620889271808 +vn 0.8714688690647024 0.23349131872226422 -0.4313047812538446 +vn 0.6380124058970137 0.6380124058970096 0.4311384230650452 +vn 0.15082605204182012 0.15082605204182087 0.9769866959436838 +vn -0.4287322056121576 -0.4287322056121611 0.7952215991419415 +vn -0.6848096361564318 -0.6847640186746825 0.24926692711989432 +vn -0.6848213690967132 -0.6848213690967212 -0.2490770661000242 +vn -0.4287489015908782 -0.4287203411791072 -0.7952189940160418 +vn 0.15120583562748172 0.15120583562748294 -0.9768692801723216 +vn 0.638012405897012 0.6380124058970152 -0.43113842306503936 +vn 0.23353521526900314 0.8715673889449783 0.4310818828910504 +vn 0.055209824466290594 0.20605245516124848 0.9769822214372293 +vn -0.15688077976239287 -0.585442613229478 0.7953900725783439 +vn -0.25063936105940654 -0.9354952198551307 0.24905542418492604 +vn -0.2506695017698045 -0.935538496423558 -0.24886245717670344 +vn -0.1568534028399737 -0.5854451908693914 -0.7953935745940011 +vn 0.05535479388510471 0.20657122711881995 -0.9768644608749787 +vn 0.23352869085144687 0.8715687935199447 -0.4310825776013197 +vn -0.23354390616786405 0.8715998238992487 0.4310115901813484 +vn -0.055191921651046645 0.20598563880979337 0.9769973226107563 +vn 0.15695861677670328 -0.5857330828786783 0.7951608316818577 +vn 0.25061999948304226 -0.9354229540224176 0.2493461709092278 +vn 0.25065001382094115 -0.9354657642952006 -0.24915532184394237 +vn 0.15693360740259676 -0.5857445492168883 -0.795157321497018 +vn -0.05533608608293574 0.20650141394855034 -0.9768802811062732 +vn -0.23353755351894823 0.8716018705024489 -0.43101089363613176 +vn -0.638012405897032 0.638012405897028 0.43113842306499073 +vn -0.15082605204182278 0.15082605204182353 0.976986695943683 +vn 0.4285738822934771 -0.42857388229348053 0.7953922647548131 +vn 0.6849048415205338 -0.6848592176968337 0.24874326121005483 +vn 0.6849171859155939 -0.6849171859156019 -0.24854958635032318 +vn 0.42858573713047865 -0.42855718758764316 -0.7953948723089017 +vn -0.1512058356274847 0.15120583562748593 -0.9768692801723207 +vn -0.6380124058970305 0.6380124058970336 -0.43113842306498473 +vn -0.8713813891115727 0.23348537688394838 0.4314847082937547 +vn -0.20616198728243754 0.055236665347141695 0.9769575967262264 +vn 0.5854426132295051 -0.15688077976239426 0.7953900725783238 +vn 0.9354845223244589 -0.25068211499916454 0.24905257619812934 +vn 0.9355384964235579 -0.25066950176979863 -0.24886245717670938 +vn 0.5854425689334423 -0.15688125018478707 -0.7953900123971072 +vn -0.2066809798976387 0.05538420428525644 -0.9768395786741244 +vn -0.8713849487535335 0.2334688340818852 -0.4314864709330379 +vn -0.8714606036206437 -0.23351699607390317 0.43130758036668443 +vn -0.20606574003898454 -0.05521338402478918 0.976979218308515 +vn 0.5855137499695524 0.1569072669921251 0.7953324827778975 +vn 0.9355898936550561 0.25071035139152226 0.24862797629321665 +vn 0.9356445502017945 0.2506979179684431 -0.2484347592507328 +vn 0.5855165804210805 0.15690108306904568 -0.795331619001637 +vn -0.20658041156058046 -0.055359874629356966 -0.9768622307370078 +vn -0.8714657284647302 -0.2335010755357143 -0.43130584488863816 +vn -0.638012405897036 -0.638012405897032 0.4311384230649791 +vn -0.15088471541415596 -0.1508847154141546 0.9769685794890124 +vn 0.4285900816835721 0.4285623788795071 0.795389734213113 +vn 0.6847864470749182 0.6847864470749134 0.24926901894344386 +vn 0.6847979934210338 0.6848439777074514 -0.24907917296386262 +vn 0.4285738822934821 0.428573882293479 -0.7953922647548113 +vn -0.15127077229477215 -0.1512609945963495 -0.9768506871385547 +vn -0.6380124058970387 -0.6380124058970331 -0.43113842306497346 +vn -0.23353521526901422 -0.8715673889450196 0.43108188289096094 +vn -0.05520982446628407 -0.2060524551612241 0.9769822214372347 +vn 0.15693292066673087 0.5857406238873173 0.795160348570718 +vn 0.2506340636900616 0.9353052065565746 0.24977337069105163 +vn 0.2506215261874412 0.9353594439107363 -0.24958277444262073 +vn 0.1569604253991664 0.5857380315259856 -0.7951568293629733 +vn -0.05534504901080703 -0.20657133888120333 -0.9768649893935285 +vn -0.23352869085145797 -0.8715687935199861 -0.43108257760123 +vn 0.23354390616787513 -0.87159982389929 0.4310115901812589 +vn 0.0552212435297044 -0.20609507304809746 0.9769725866821098 +vn -0.15685476966112225 0.5854489309870122 0.7953905521444926 +vn -0.25066237103969663 0.9354108426824375 0.24934901471008492 +vn -0.25065001382093166 0.9354657642952033 -0.2491553218439416 +vn -0.15687987228280373 0.585437426939246 -0.7953940688811342 +vn 0.05535697969672768 -0.20661586929193473 -0.9768548957524831 +vn 0.2335375535189593 -0.87160187050249 -0.4310108936360422 +vn 0.638012405897032 -0.638012405897028 0.43113842306499073 +vn 0.15082605204182364 -0.1508260520418223 0.976986695943683 +vn -0.4287437074955998 0.42871599476161354 0.7952241376608784 +vn -0.6848824067718051 0.6848824067718002 0.24874118635426248 +vn -0.6848945694168909 0.684940560188401 -0.24854749604864465 +vn -0.42873220561215897 0.4287322056121559 -0.7952215991419436 +vn 0.15120947448369426 -0.15119970074738207 -0.9768696664961335 +vn 0.6380124058970346 -0.638012405897029 -0.43113842306498534 +vn 0.8713792746004092 -0.23349520311112335 0.4314836612445017 +vn 0.20616195873027204 -0.05523916492146523 0.9769574614236165 +vn -0.5854419313734145 0.1568880208862227 0.7953891461996262 +vn -0.9354845223244581 0.25068211499916704 0.24905257619812926 +vn -0.9355384964235574 0.25066950176980124 -0.2488624571767089 +vn -0.5854425689334418 0.15688125018478868 -0.7953900123971069 +vn 0.2066809498962103 -0.05538681711443513 -0.9768394368778998 +vn 0.8713827927874856 -0.2334788536981774 -0.4314854033564372 +vn 0.8714951017406156 0.23350331301461158 0.4312452787606079 +vn 0.20581451459446792 0.055149094425162554 0.977035804342056 +vn -0.5858477563510347 -0.156977789667849 0.7950725626818319 +vn -0.9354236268781534 -0.2506211781519584 0.24934246196716206 +vn -0.9354799163514859 -0.2506535031277103 -0.24909867015466802 +vn -0.5858542434975154 -0.15696362216195878 -0.7950705796932447 +vn 0.20581506984425316 0.055148106985538965 -0.9770357431132787 +vn 0.8716068953036451 0.23355170584269141 -0.43099306346751065 +vn 0.6380396104284876 0.6380396104284809 0.4310578975596389 +vn 0.15066451381358367 0.15066451381358473 0.977036544124442 +vn -0.42891206003333754 -0.42891206003333937 0.7950276029899312 +vn -0.6848189563375969 -0.6848189563375949 0.2490903331752797 +vn -0.6848932323146979 -0.6848345992154468 -0.24884298673449487 +vn -0.4289120600333395 -0.4289120600333382 -0.7950276029899309 +vn 0.15066451381358398 0.15066451381358462 -0.977036544124442 +vn 0.6381253815764981 0.6381253815765028 -0.4308038936403563 +vn 0.233526539563709 0.8715817892634135 0.43105746710129794 +vn 0.055145678129763356 0.20580176509808928 0.9770386827890283 +vn -0.1569337000930262 -0.5856832121913679 0.7952024828509489 +vn -0.2506391902232083 -0.9354908555024265 0.24907198878154246 +vn -0.2506569032137857 -0.9355512392231902 -0.24882724058918515 +vn -0.1569181905579706 -0.5856846736467631 -0.7952044671198075 +vn 0.05514454543642535 0.20580177799219337 -0.9770387440034617 +vn 0.23357536766363576 0.8716952004016945 -0.43080160772384246 +vn -0.2335033130145992 0.8714951017405761 0.43124527876069485 +vn -0.055149094425166745 0.205814514594477 0.9770358043420537 +vn 0.15697778966785456 -0.5858477563510628 0.79507256268181 +vn 0.2506375877222855 -0.9354848742951066 0.24909606497458417 +vn 0.25065529101631073 -0.9355452218611059 -0.24885148771583238 +vn 0.1569636221619632 -0.5858542434975436 -0.7950705796932228 +vn -0.05514810698554105 0.20581506984426354 -0.9770357431132765 +vn -0.23355170584268758 0.8716068953036029 -0.4309930634675983 +vn -0.6380396104285159 0.6380396104285092 0.4310578975595552 +vn -0.15066451381358129 0.15066451381358234 0.9770365441244427 +vn 0.4289120600333204 -0.4289120600333222 0.7950276029899498 +vn 0.6846963624692901 -0.684696362469288 0.24976345297630706 +vn 0.6847698968798039 -0.6847112743391895 -0.24952085908802504 +vn 0.4289120600333218 -0.4289120600333205 -0.79502760298995 +vn -0.1506645138135813 0.15066451381358195 -0.9770365441244429 +vn -0.6381253815765268 0.6381253815765313 -0.4308038936402717 +vn -0.8715817892634096 0.23352653956370978 0.43105746710130505 +vn -0.20580176509810763 0.055145678129766514 0.9770386827890243 +vn 0.5856832121913519 -0.1569337000930239 0.7952024828509608 +vn 0.9354908555024204 -0.2506391902232116 0.24907198878156203 +vn 0.9355475550405283 -0.25067162631139295 -0.2488262607123514 +vn 0.5856846736467474 -0.15691819055796952 -0.7952044671198195 +vn -0.2058017779922116 0.05514454543643093 -0.9770387440034576 +vn -0.8716952004016927 0.23357536766362785 -0.43080160772385034 +vn -0.871435479706581 -0.23347408771890052 0.43138156552220486 +vn -0.20581451459446792 -0.055149094425162554 0.977035804342056 +vn 0.5858477563510347 0.156977789667849 0.7950725626818319 +vn 0.9354848742951314 0.2506375877222971 0.24909606497447978 +vn 0.9355452218611325 0.250655291016315 -0.24885148771572757 +vn 0.5858542434975154 0.15696362216195878 -0.7950705796932447 +vn -0.20581506984425316 -0.055148106985538965 -0.9770357431132787 +vn -0.8715434055530957 -0.23353469342617175 -0.4311306521267725 +vn -0.6380136426522972 -0.6380631049084918 0.43106155702414706 +vn -0.15066451381358367 -0.15066451381358473 0.977036544124442 +vn 0.42891206003333754 0.42891206003333937 0.7950276029899312 +vn 0.6848189563375969 0.6848189563375949 0.2490903331752797 +vn 0.684863429807408 0.6848634298074131 -0.24884566507146597 +vn 0.4289120600333395 0.4289120600333382 -0.7950276029899309 +vn -0.15066451381358398 -0.15066451381358462 -0.977036544124442 +vn -0.6381253815764981 -0.6381253815765028 -0.4308038936403563 +vn -0.233526539563709 -0.8715817892634135 0.43105746710129794 +vn -0.055145678129763356 -0.20580176509808928 0.9770386827890283 +vn 0.1569337000930262 0.5856832121913679 0.7952024828509489 +vn 0.2506391902232083 0.9354908555024265 0.24907198878154246 +vn 0.2506569032137857 0.9355512392231902 -0.24882724058918515 +vn 0.1569181905579706 0.5856846736467631 -0.7952044671198075 +vn -0.05514454543642535 -0.20580177799219337 -0.9770387440034617 +vn -0.23357536766363576 -0.8716952004016945 -0.43080160772384246 +vn 0.2335033130145992 -0.8714951017405761 0.43124527876069485 +vn 0.055149094425166745 -0.205814514594477 0.9770358043420537 +vn -0.15697778966785456 0.5858477563510628 0.79507256268181 +vn -0.2506375877222855 0.9354848742951066 0.24909606497458417 +vn -0.25065529101631073 0.9355452218611059 -0.24885148771583238 +vn -0.1569636221619632 0.5858542434975436 -0.7950705796932228 +vn 0.05514810698554105 -0.20581506984426354 -0.9770357431132765 +vn 0.23355170584268758 -0.8716068953036029 -0.4309930634675983 +vn 0.6378890172448498 -0.6379384698394112 0.43143030767129736 +vn 0.15066451381358129 -0.15066451381358234 0.9770365441244427 +vn -0.4289120600333204 0.4289120600333222 0.7950276029899498 +vn -0.6848189563376117 0.6848189563376096 0.24909033317519913 +vn -0.6848634298074228 0.6848634298074279 -0.2488456650713848 +vn -0.4289120600333218 0.4289120600333205 -0.79502760298995 +vn 0.1506645138135813 -0.15066451381358195 -0.9770365441244429 +vn 0.6379984158028826 -0.6379984158028872 -0.4311798265990866 +vn 0.8715844866287762 -0.23351400949857723 0.43105880113500894 +vn 0.20580176509810763 -0.055145678129766514 0.9770386827890243 +vn -0.5856832121913519 0.1569337000930239 0.7952024828509608 +vn -0.9354908555024204 0.2506391902232116 0.24907198878156203 +vn -0.9355512392231858 0.25065690321378165 -0.24882724058920522 +vn -0.5856846736467474 0.15691819055796952 -0.7952044671198195 +vn 0.2058017779922116 -0.05514454543643093 -0.9770387440034576 +vn 0.8716952004016927 -0.23357536766362785 -0.43080160772385034 +usemtl m_197d5025-59a4-f3c1-d159-d8096840c879 +f 1/1/1 2/2/1 3/3/1 4/4/1 +f 4/5/2 3/6/2 5/7/2 6/8/2 +f 7/9/3 8/10/3 9/11/3 10/12/3 +f 10/13/4 9/14/4 11/15/4 12/16/4 +f 13/17/5 11/18/5 9/19/5 14/20/5 +f 14/21/6 9/22/6 8/23/6 15/24/6 +f 16/25/7 7/26/7 10/27/7 17/28/7 +f 17/29/8 10/30/8 12/31/8 18/32/8 +f 2/33/9 19/34/9 20/35/9 3/36/9 +f 3/37/10 20/38/10 21/39/10 5/40/10 +f 22/41/11 1/42/11 4/43/11 23/44/11 +f 23/45/12 4/46/12 6/47/12 24/48/12 +f 6/49/13 11/50/13 13/51/13 24/52/13 +f 6/53/14 5/54/14 12/55/14 11/56/14 +f 25/57/15 26/58/15 2/59/15 1/60/15 +f 25/61/16 1/62/16 22/63/16 27/64/16 +f 12/65/17 5/66/17 21/67/17 18/68/17 +f 28/69/18 29/70/18 30/71/18 31/72/18 +f 32/73/19 33/74/19 34/75/19 35/76/19 +f 33/77/20 36/78/20 37/79/20 38/80/20 +f 36/81/21 39/82/21 40/83/21 37/84/21 +f 34/85/22 33/86/22 38/87/22 41/88/22 +f 36/89/23 33/90/23 32/91/23 42/92/23 +f 36/93/24 42/94/24 43/95/24 39/96/24 +f 44/97/25 45/98/25 46/99/25 29/100/25 +f 31/101/26 47/102/26 48/103/26 28/104/26 +f 8/105/27 49/106/27 50/107/27 15/108/27 +f 51/109/28 52/110/28 53/111/28 54/112/28 +f 7/113/29 55/114/29 49/115/29 8/116/29 +f 56/117/30 57/118/30 47/119/30 31/120/30 +f 55/121/31 7/122/31 16/123/31 58/124/31 +f 59/125/32 60/126/32 57/127/32 56/128/32 +f 56/129/33 31/130/33 30/131/33 51/132/33 +f 59/133/34 56/134/34 51/135/34 54/136/34 +f 44/137/35 29/138/35 28/139/35 61/140/35 +f 61/141/36 62/142/36 60/143/36 59/144/36 +f 30/145/37 63/146/37 52/147/37 51/148/37 +f 29/149/38 46/150/38 63/151/38 30/152/38 +f 64/153/39 65/154/39 66/155/39 67/156/39 +f 68/157/40 69/158/40 70/159/40 65/160/40 +f 67/161/41 71/162/41 72/163/41 64/164/41 +f 73/165/42 74/166/42 75/167/42 76/168/42 +f 77/169/43 78/170/43 71/171/43 67/172/43 +f 79/173/44 80/174/44 78/175/44 77/176/44 +f 77/177/45 67/178/45 66/179/45 73/180/45 +f 79/181/46 77/182/46 73/183/46 76/184/46 +f 68/185/47 65/186/47 64/187/47 81/188/47 +f 81/189/48 82/190/48 80/191/48 79/192/48 +f 66/193/49 83/194/49 74/195/49 73/196/49 +f 65/197/50 70/198/50 83/199/50 66/200/50 +f 84/201/51 85/202/51 86/203/51 87/204/51 +f 88/205/52 89/206/52 90/207/52 85/208/52 +f 87/209/53 91/210/53 92/211/53 84/212/53 +f 93/213/54 94/214/54 95/215/54 96/216/54 +f 97/217/55 98/218/55 91/219/55 87/220/55 +f 99/221/56 100/222/56 98/223/56 97/224/56 +f 97/225/57 87/226/57 86/227/57 93/228/57 +f 99/229/58 97/230/58 93/231/58 96/232/58 +f 88/233/59 85/234/59 84/235/59 101/236/59 +f 101/237/60 102/238/60 100/239/60 99/240/60 +f 86/241/61 103/242/61 94/243/61 93/244/61 +f 85/245/62 90/246/62 103/247/62 86/248/62 +f 2/249/63 26/250/63 104/251/63 19/252/63 +f 105/253/64 106/254/64 107/255/64 108/256/64 +f 108/257/65 107/258/65 109/259/65 110/260/65 +f 105/261/66 111/262/66 112/263/66 106/264/66 +f 113/265/67 114/266/67 115/267/67 116/268/67 +f 116/269/68 115/270/68 111/271/68 105/272/68 +f 113/273/69 116/274/69 117/275/69 118/276/69 +f 116/277/70 105/278/70 108/279/70 117/280/70 +f 117/281/71 108/282/71 110/283/71 119/284/71 +f 118/285/72 117/286/72 119/287/72 120/288/72 +f 121/289/73 122/290/73 123/291/73 124/292/73 +f 122/293/74 125/294/74 126/295/74 123/296/74 +f 127/297/75 128/298/75 129/299/75 130/300/75 +f 128/301/76 131/302/76 132/303/76 129/304/76 +f 133/305/77 134/306/77 129/307/77 132/308/77 +f 134/309/78 135/310/78 130/311/78 129/312/78 +f 136/313/79 137/314/79 128/315/79 127/316/79 +f 137/317/80 138/318/80 131/319/80 128/320/80 +f 124/321/81 123/322/81 139/323/81 140/324/81 +f 123/325/82 126/326/82 141/327/82 139/328/82 +f 142/329/83 143/330/83 122/331/83 121/332/83 +f 143/333/84 144/334/84 125/335/84 122/336/84 +f 125/337/85 144/338/85 133/339/85 132/340/85 +f 125/341/86 132/342/86 131/343/86 126/344/86 +f 25/345/87 121/346/87 124/347/87 26/348/87 +f 25/349/88 27/350/88 142/351/88 121/352/88 +f 131/353/89 138/354/89 141/355/89 126/356/89 +f 145/357/90 146/358/90 147/359/90 148/360/90 +f 32/361/91 35/362/91 149/363/91 150/364/91 +f 150/365/92 151/366/92 152/367/92 153/368/92 +f 153/369/93 152/370/93 154/371/93 155/372/93 +f 149/373/94 156/374/94 151/375/94 150/376/94 +f 153/377/95 42/378/95 32/379/95 150/380/95 +f 153/381/96 155/382/96 43/383/96 42/384/96 +f 157/385/97 148/386/97 158/387/97 159/388/97 +f 146/389/98 145/390/98 160/391/98 161/392/98 +f 130/393/99 135/394/99 50/395/99 49/396/99 +f 162/397/100 163/398/100 164/399/100 165/400/100 +f 127/401/101 130/402/101 49/403/101 55/404/101 +f 166/405/102 146/406/102 161/407/102 167/408/102 +f 55/409/103 58/410/103 136/411/103 127/412/103 +f 168/413/104 166/414/104 167/415/104 169/416/104 +f 166/417/105 162/418/105 147/419/105 146/420/105 +f 168/421/106 163/422/106 162/423/106 166/424/106 +f 157/425/107 170/426/107 145/427/107 148/428/107 +f 170/429/108 168/430/108 169/431/108 171/432/108 +f 147/433/109 162/434/109 165/435/109 172/436/109 +f 148/437/110 147/438/110 172/439/110 158/440/110 +f 124/441/111 140/442/111 104/443/111 26/444/111 +f 173/445/112 174/446/112 107/447/112 106/448/112 +f 174/449/113 175/450/113 109/451/113 107/452/113 +f 173/453/114 106/454/114 112/455/114 176/456/114 +f 113/457/115 177/458/115 178/459/115 114/460/115 +f 177/461/116 173/462/116 176/463/116 178/464/116 +f 113/465/117 118/466/117 179/467/117 177/468/117 +f 177/469/118 179/470/118 174/471/118 173/472/118 +f 179/473/119 180/474/119 175/475/119 174/476/119 +f 118/477/120 120/478/120 180/479/120 179/480/120 +f 181/481/121 182/482/121 183/483/121 184/484/121 +f 185/485/122 186/486/122 187/487/122 188/488/122 +f 189/489/123 190/490/123 191/491/123 192/492/123 +f 193/493/124 194/494/124 195/495/124 196/496/124 +f 192/497/125 197/498/125 198/499/125 189/500/125 +f 185/501/126 189/502/126 199/503/126 200/504/126 +f 189/505/127 198/506/127 201/507/127 199/508/127 +f 198/509/128 197/510/128 202/511/128 201/512/128 +f 197/513/129 192/514/129 203/515/129 202/516/129 +f 192/517/130 196/518/130 204/519/130 203/520/130 +f 196/521/131 195/522/131 205/523/131 204/524/131 +f 195/525/132 194/526/132 206/527/132 205/528/132 +f 194/529/133 193/530/133 207/531/133 206/532/133 +f 193/533/134 191/534/134 208/535/134 207/536/134 +f 188/537/135 187/538/135 209/539/135 210/540/135 +f 187/541/136 186/542/136 211/543/136 209/544/136 +f 186/545/137 185/546/137 200/547/137 211/548/137 +f 183/549/138 182/550/138 212/551/138 213/552/138 +f 182/553/139 181/554/139 214/555/139 212/556/139 +f 181/557/140 184/558/140 215/559/140 214/560/140 +f 216/561/141 217/562/141 218/563/141 219/564/141 +f 220/565/142 221/566/142 222/567/142 223/568/142 +f 221/569/143 224/570/143 225/571/143 222/572/143 +f 184/573/144 216/574/144 219/575/144 215/576/144 +f 190/577/145 189/578/145 185/579/145 188/580/145 +f 192/581/146 191/582/146 193/583/146 196/584/146 +f 208/585/147 191/586/147 190/587/147 226/588/147 +f 226/589/148 190/590/148 188/591/148 210/592/148 +f 183/593/149 224/594/149 221/595/149 220/596/149 +f 216/597/150 184/598/150 227/599/150 217/600/150 +f 184/601/151 183/602/151 220/603/151 227/604/151 +f 224/605/152 183/606/152 213/607/152 225/608/152 +f 217/609/153 227/610/153 228/611/153 218/612/153 +f 227/613/154 220/614/154 223/615/154 228/616/154 +f 229/617/155 230/618/155 231/619/155 232/620/155 +f 233/621/156 234/622/156 235/623/156 236/624/156 +f 237/625/157 238/626/157 239/627/157 240/628/157 +f 241/629/158 242/630/158 243/631/158 244/632/158 +f 245/633/159 229/634/159 232/635/159 246/636/159 +f 247/637/160 245/638/160 246/639/160 248/640/160 +f 232/641/161 231/642/161 249/643/161 250/644/161 +f 251/645/162 252/646/162 234/647/162 253/648/162 +f 254/649/163 249/650/163 231/651/163 255/652/163 +f 256/653/164 250/654/164 249/655/164 254/656/164 +f 257/657/165 232/658/165 250/659/165 256/660/165 +f 258/661/166 246/662/166 232/663/166 257/664/166 +f 259/665/167 248/666/167 246/667/167 258/668/167 +f 260/669/168 242/670/168 248/671/168 259/672/168 +f 261/673/169 243/674/169 242/675/169 260/676/169 +f 262/677/170 237/678/170 243/679/170 261/680/170 +f 263/681/171 238/682/171 237/683/171 262/684/171 +f 264/685/172 265/686/172 238/687/172 263/688/172 +f 266/689/173 267/690/173 265/691/173 264/692/173 +f 252/693/174 251/694/174 268/695/174 269/696/174 +f 244/697/175 243/698/175 237/699/175 240/700/175 +f 247/701/176 248/702/176 242/703/176 241/704/176 +f 239/705/177 238/706/177 265/707/177 270/708/177 +f 267/709/178 271/710/178 270/711/178 265/712/178 +f 233/713/179 236/714/179 271/715/179 267/716/179 +f 269/717/180 235/718/180 234/719/180 252/720/180 +f 230/721/181 229/722/181 235/723/181 269/724/181 +f 269/725/182 268/726/182 272/727/182 230/728/182 +f 273/729/183 233/730/183 267/731/183 266/732/183 +f 274/733/184 245/734/184 247/735/184 275/736/184 +f 230/737/185 272/738/185 255/739/185 231/740/185 +f 276/741/186 229/742/186 245/743/186 274/744/186 +f 277/745/187 235/746/187 229/747/187 276/748/187 +f 278/749/188 236/750/188 235/751/188 277/752/188 +f 279/753/189 271/754/189 236/755/189 278/756/189 +f 280/757/190 270/758/190 271/759/190 279/760/190 +f 281/761/191 239/762/191 270/763/191 280/764/191 +f 282/765/192 240/766/192 239/767/192 281/768/192 +f 283/769/193 244/770/193 240/771/193 282/772/193 +f 284/773/194 241/774/194 244/775/194 283/776/194 +f 275/777/195 247/778/195 241/779/195 284/780/195 +f 233/781/196 273/782/196 253/783/196 234/784/196 +f 285/785/197 286/786/197 287/787/197 288/788/197 +f 289/789/198 290/790/198 291/791/198 292/792/198 +f 293/793/199 294/794/199 295/795/199 296/796/199 +f 297/797/200 298/798/200 299/799/200 300/800/200 +f 301/801/201 302/802/201 294/803/201 293/804/201 +f 303/805/202 304/806/202 290/807/202 289/808/202 +f 296/809/203 295/810/203 298/811/203 297/812/203 +f 292/813/204 291/814/204 286/815/204 285/816/204 +f 295/817/205 292/818/205 285/819/205 298/820/205 +f 302/821/206 303/822/206 289/823/206 294/824/206 +f 294/825/207 289/826/207 292/827/207 295/828/207 +f 298/829/208 285/830/208 288/831/208 299/832/208 +f 299/833/209 305/834/209 306/835/209 300/836/209 +f 300/837/210 306/838/210 307/839/210 297/840/210 +f 297/841/211 307/842/211 308/843/211 296/844/211 +f 296/845/212 308/846/212 309/847/212 293/848/212 +f 301/849/213 310/850/213 311/851/213 302/852/213 +f 302/853/214 311/854/214 312/855/214 303/856/214 +f 293/857/215 309/858/215 310/859/215 301/860/215 +f 288/861/216 313/862/216 305/863/216 299/864/216 +f 287/865/217 314/866/217 313/867/217 288/868/217 +f 303/869/218 312/870/218 315/871/218 304/872/218 +f 316/873/219 317/874/219 287/875/219 286/876/219 +f 318/877/220 319/878/220 291/879/220 290/880/220 +f 320/881/221 321/882/221 322/883/221 323/884/221 +f 324/885/222 325/886/222 326/887/222 327/888/222 +f 328/889/223 320/890/223 323/891/223 329/892/223 +f 330/893/224 318/894/224 290/895/224 304/896/224 +f 321/897/225 324/898/225 327/899/225 322/900/225 +f 319/901/226 316/902/226 286/903/226 291/904/226 +f 322/905/227 327/906/227 316/907/227 319/908/227 +f 329/909/228 323/910/228 318/911/228 330/912/228 +f 323/913/229 322/914/229 319/915/229 318/916/229 +f 327/917/230 326/918/230 317/919/230 316/920/230 +f 326/921/231 325/922/231 331/923/231 332/924/231 +f 325/925/232 324/926/232 333/927/232 331/928/232 +f 324/929/233 321/930/233 334/931/233 333/932/233 +f 321/933/234 320/934/234 335/935/234 334/936/234 +f 328/937/235 329/938/235 336/939/235 337/940/235 +f 329/941/236 330/942/236 338/943/236 336/944/236 +f 320/945/237 328/946/237 337/947/237 335/948/237 +f 317/949/238 326/950/238 332/951/238 339/952/238 +f 287/953/239 317/954/239 339/955/239 314/956/239 +f 330/957/240 304/958/240 315/959/240 338/960/240 +f 340/961/241 341/962/241 342/963/241 343/964/241 +f 340/965/242 343/966/242 344/967/242 345/968/242 +f 342/969/243 341/970/243 346/971/243 347/972/243 +f 343/973/244 342/974/244 347/975/244 344/976/244 +f 341/977/245 340/978/245 348/979/245 349/980/245 +f 340/981/246 345/982/246 350/983/246 348/984/246 +f 346/985/247 341/986/247 349/987/247 351/988/247 +f 351/989/248 349/990/248 348/991/248 350/992/248 +f 352/993/249 353/994/249 354/995/249 355/996/249 +f 352/997/250 355/998/250 356/999/250 357/1000/250 +f 354/1001/251 353/1002/251 358/1003/251 359/1004/251 +f 355/1005/252 354/1006/252 359/1007/252 356/1008/252 +f 358/1009/253 353/1010/253 352/1011/253 357/1012/253 +f 360/1013/254 361/1014/254 362/1015/254 363/1016/254 +f 363/1017/255 362/1018/255 364/1019/255 365/1020/255 +f 365/1021/256 364/1022/256 366/1023/256 367/1024/256 +f 363/1025/257 365/1026/257 367/1027/257 360/1028/257 +f 364/1029/258 362/1030/258 361/1031/258 366/1032/258 +f 368/1033/259 369/1034/259 370/1035/259 371/1036/259 +f 372/1037/260 373/1038/260 374/1039/260 375/1040/260 +f 375/1041/261 374/1042/261 369/1043/261 368/1044/261 +f 371/1045/262 372/1046/262 375/1047/262 368/1048/262 +f 373/1049/263 370/1050/263 369/1051/263 374/1052/263 +f 376/1053/264 377/1054/264 378/1055/264 379/1056/264 +f 380/1057/265 381/1058/265 382/1059/265 383/1060/265 +f 384/1061/266 385/1062/266 377/1063/266 376/1064/266 +f 386/1065/267 387/1066/267 388/1067/267 389/1068/267 +f 367/1069/268 366/1070/268 361/1071/268 360/1072/268 +f 379/1073/269 378/1074/269 390/1075/269 391/1076/269 +f 391/1077/270 390/1078/270 385/1079/270 384/1080/270 +f 392/1081/271 389/1082/271 388/1083/271 383/1084/271 +f 393/1085/272 386/1086/272 389/1087/272 392/1088/272 +f 381/1089/273 394/1090/273 395/1091/273 382/1092/273 +f 396/1093/274 397/1094/274 398/1095/274 399/1096/274 +f 400/1097/275 401/1098/275 402/1099/275 403/1100/275 +f 398/1101/276 404/1102/276 405/1103/276 399/1104/276 +f 396/1105/277 399/1106/277 406/1107/277 407/1108/277 +f 407/1109/278 408/1110/278 397/1111/278 396/1112/278 +f 408/1113/279 409/1114/279 398/1115/279 397/1116/279 +f 385/1117/280 402/1118/280 410/1119/280 377/1120/280 +f 390/1121/281 403/1122/281 402/1123/281 385/1124/281 +f 400/1125/282 411/1126/282 412/1127/282 401/1128/282 +f 378/1129/283 413/1130/283 403/1131/283 390/1132/283 +f 394/1133/284 393/1134/284 392/1135/284 395/1136/284 +f 403/1137/285 413/1138/285 411/1139/285 400/1140/285 +f 410/1141/286 402/1142/286 401/1143/286 412/1144/286 +f 395/1145/287 392/1146/287 383/1147/287 382/1148/287 +f 387/1149/288 380/1150/288 383/1151/288 388/1152/288 +f 406/1153/289 399/1154/289 405/1155/289 414/1156/289 +f 398/1157/290 409/1158/290 415/1159/290 404/1160/290 +f 404/1161/291 391/1162/291 384/1163/291 405/1164/291 +f 415/1165/292 379/1166/292 391/1167/292 404/1168/292 +f 405/1169/293 384/1170/293 376/1171/293 414/1172/293 +f 414/1173/294 376/1174/294 379/1175/294 415/1176/294 +f 415/1177/295 409/1178/295 380/1179/295 387/1180/295 +f 407/1181/296 406/1182/296 393/1183/296 394/1184/296 +f 408/1185/297 407/1186/297 394/1187/297 381/1188/297 +f 406/1189/298 414/1190/298 386/1191/298 393/1192/298 +f 414/1193/299 415/1194/299 387/1195/299 386/1196/299 +f 409/1197/300 408/1198/300 381/1199/300 380/1200/300 +f 84/1201/301 92/1202/301 102/1203/301 101/1204/301 +f 96/1205/302 88/1206/302 101/1207/302 99/1208/302 +f 96/1209/303 95/1210/303 89/1211/303 88/1212/303 +f 64/1213/304 72/1214/304 82/1215/304 81/1216/304 +f 76/1217/305 68/1218/305 81/1219/305 79/1220/305 +f 76/1221/306 75/1222/306 69/1223/306 68/1224/306 +f 145/1225/307 170/1226/307 171/1227/307 160/1228/307 +f 163/1229/308 168/1230/308 170/1231/308 157/1232/308 +f 163/1233/309 157/1234/309 159/1235/309 164/1236/309 +f 28/1237/310 48/1238/310 62/1239/310 61/1240/310 +f 54/1241/311 44/1242/311 61/1243/311 59/1244/311 +f 54/1245/312 53/1246/312 45/1247/312 44/1248/312 +f 416/1249/313 417/1250/313 418/1251/313 419/1252/313 +f 419/1253/314 418/1254/314 420/1255/314 421/1256/314 +f 421/1257/315 420/1258/315 422/1259/315 423/1260/315 +f 423/1261/316 422/1262/316 424/1263/316 425/1264/316 +f 425/1265/317 424/1266/317 426/1267/317 427/1268/317 +f 427/1269/318 426/1270/318 417/1271/318 416/1272/318 +f 417/1273/319 428/1274/319 429/1275/319 418/1276/319 +f 418/1277/320 429/1278/320 430/1279/320 420/1280/320 +f 420/1281/321 430/1282/321 431/1283/321 422/1284/321 +f 422/1285/322 431/1286/322 432/1287/322 424/1288/322 +f 424/1289/323 432/1290/323 433/1291/323 426/1292/323 +f 426/1293/324 433/1294/324 428/1295/324 417/1296/324 +f 428/1297/325 434/1298/325 435/1299/325 429/1300/325 +f 429/1301/326 435/1302/326 436/1303/326 430/1304/326 +f 430/1305/327 436/1306/327 437/1307/327 431/1308/327 +f 431/1309/328 437/1310/328 438/1311/328 432/1312/328 +f 432/1313/329 438/1314/329 439/1315/329 433/1316/329 +f 433/1317/330 439/1318/330 434/1319/330 428/1320/330 +f 434/1321/331 440/1322/331 441/1323/331 435/1324/331 +f 435/1325/332 441/1326/332 442/1327/332 436/1328/332 +f 436/1329/333 442/1330/333 443/1331/333 437/1332/333 +f 437/1333/334 443/1334/334 444/1335/334 438/1336/334 +f 438/1337/335 444/1338/335 445/1339/335 439/1340/335 +f 439/1341/336 445/1342/336 440/1343/336 434/1344/336 +f 440/1345/337 446/1346/337 447/1347/337 441/1348/337 +f 441/1349/338 447/1350/338 448/1351/338 442/1352/338 +f 442/1353/339 448/1354/339 449/1355/339 443/1356/339 +f 443/1357/340 449/1358/340 450/1359/340 444/1360/340 +f 444/1361/341 450/1362/341 451/1363/341 445/1364/341 +f 445/1365/342 451/1366/342 446/1367/342 440/1368/342 +f 446/1369/343 452/1370/343 453/1371/343 447/1372/343 +f 447/1373/344 453/1374/344 454/1375/344 448/1376/344 +f 448/1377/345 454/1378/345 455/1379/345 449/1380/345 +f 449/1381/346 455/1382/346 456/1383/346 450/1384/346 +f 450/1385/347 456/1386/347 457/1387/347 451/1388/347 +f 451/1389/348 457/1390/348 452/1391/348 446/1392/348 +f 452/1393/349 458/1394/349 459/1395/349 453/1396/349 +f 453/1397/350 459/1398/350 460/1399/350 454/1400/350 +f 454/1401/351 460/1402/351 461/1403/351 455/1404/351 +f 455/1405/352 461/1406/352 462/1407/352 456/1408/352 +f 456/1409/353 462/1410/353 463/1411/353 457/1412/353 +f 457/1413/354 463/1414/354 458/1415/354 452/1416/354 +f 458/1417/355 416/1418/355 419/1419/355 459/1420/355 +f 459/1421/356 419/1422/356 421/1423/356 460/1424/356 +f 460/1425/357 421/1426/357 423/1427/357 461/1428/357 +f 461/1429/358 423/1430/358 425/1431/358 462/1432/358 +f 462/1433/359 425/1434/359 427/1435/359 463/1436/359 +f 463/1437/360 427/1438/360 416/1439/360 458/1440/360 +f 464/1441/361 465/1442/361 466/1443/361 467/1444/361 +f 467/1445/362 466/1446/362 468/1447/362 469/1448/362 +f 469/1449/363 468/1450/363 470/1451/363 471/1452/363 +f 471/1453/364 470/1454/364 472/1455/364 473/1456/364 +f 473/1457/365 472/1458/365 474/1459/365 475/1460/365 +f 475/1461/366 474/1462/366 465/1463/366 464/1464/366 +f 465/1465/367 476/1466/367 477/1467/367 466/1468/367 +f 466/1469/368 477/1470/368 478/1471/368 468/1472/368 +f 468/1473/369 478/1474/369 479/1475/369 470/1476/369 +f 470/1477/370 479/1478/370 480/1479/370 472/1480/370 +f 472/1481/371 480/1482/371 481/1483/371 474/1484/371 +f 474/1485/372 481/1486/372 476/1487/372 465/1488/372 +f 476/1489/373 482/1490/373 483/1491/373 477/1492/373 +f 477/1493/374 483/1494/374 484/1495/374 478/1496/374 +f 478/1497/375 484/1498/375 485/1499/375 479/1500/375 +f 479/1501/376 485/1502/376 486/1503/376 480/1504/376 +f 480/1505/377 486/1506/377 487/1507/377 481/1508/377 +f 481/1509/378 487/1510/378 482/1511/378 476/1512/378 +f 482/1513/379 488/1514/379 489/1515/379 483/1516/379 +f 483/1517/380 489/1518/380 490/1519/380 484/1520/380 +f 484/1521/381 490/1522/381 491/1523/381 485/1524/381 +f 485/1525/382 491/1526/382 492/1527/382 486/1528/382 +f 486/1529/383 492/1530/383 493/1531/383 487/1532/383 +f 487/1533/384 493/1534/384 488/1535/384 482/1536/384 +f 488/1537/385 494/1538/385 495/1539/385 489/1540/385 +f 489/1541/386 495/1542/386 496/1543/386 490/1544/386 +f 490/1545/387 496/1546/387 497/1547/387 491/1548/387 +f 491/1549/388 497/1550/388 498/1551/388 492/1552/388 +f 492/1553/389 498/1554/389 499/1555/389 493/1556/389 +f 493/1557/390 499/1558/390 494/1559/390 488/1560/390 +f 494/1561/391 500/1562/391 501/1563/391 495/1564/391 +f 495/1565/392 501/1566/392 502/1567/392 496/1568/392 +f 496/1569/393 502/1570/393 503/1571/393 497/1572/393 +f 497/1573/394 503/1574/394 504/1575/394 498/1576/394 +f 498/1577/395 504/1578/395 505/1579/395 499/1580/395 +f 499/1581/396 505/1582/396 500/1583/396 494/1584/396 +f 500/1585/397 506/1586/397 507/1587/397 501/1588/397 +f 501/1589/398 507/1590/398 508/1591/398 502/1592/398 +f 502/1593/399 508/1594/399 509/1595/399 503/1596/399 +f 503/1597/400 509/1598/400 510/1599/400 504/1600/400 +f 504/1601/401 510/1602/401 511/1603/401 505/1604/401 +f 505/1605/402 511/1606/402 506/1607/402 500/1608/402 +f 506/1609/403 464/1610/403 467/1611/403 507/1612/403 +f 507/1613/404 467/1614/404 469/1615/404 508/1616/404 +f 508/1617/405 469/1618/405 471/1619/405 509/1620/405 +f 509/1621/406 471/1622/406 473/1623/406 510/1624/406 +f 510/1625/407 473/1626/407 475/1627/407 511/1628/407 +f 511/1629/408 475/1630/408 464/1631/408 506/1632/408 +f 512/1633/409 513/1634/409 514/1635/409 515/1636/409 +f 515/1637/410 514/1638/410 516/1639/410 517/1640/410 +f 517/1641/411 516/1642/411 518/1643/411 519/1644/411 +f 519/1645/412 518/1646/412 520/1647/412 521/1648/412 +f 521/1649/413 520/1650/413 522/1651/413 523/1652/413 +f 523/1653/414 522/1654/414 513/1655/414 512/1656/414 +f 513/1657/415 524/1658/415 525/1659/415 514/1660/415 +f 514/1661/416 525/1662/416 526/1663/416 516/1664/416 +f 516/1665/417 526/1666/417 527/1667/417 518/1668/417 +f 518/1669/418 527/1670/418 528/1671/418 520/1672/418 +f 520/1673/419 528/1674/419 529/1675/419 522/1676/419 +f 522/1677/420 529/1678/420 524/1679/420 513/1680/420 +f 524/1681/421 530/1682/421 531/1683/421 525/1684/421 +f 525/1685/422 531/1686/422 532/1687/422 526/1688/422 +f 526/1689/423 532/1690/423 533/1691/423 527/1692/423 +f 527/1693/424 533/1694/424 534/1695/424 528/1696/424 +f 528/1697/425 534/1698/425 535/1699/425 529/1700/425 +f 529/1701/426 535/1702/426 530/1703/426 524/1704/426 +f 530/1705/427 536/1706/427 537/1707/427 531/1708/427 +f 531/1709/428 537/1710/428 538/1711/428 532/1712/428 +f 532/1713/429 538/1714/429 539/1715/429 533/1716/429 +f 533/1717/430 539/1718/430 540/1719/430 534/1720/430 +f 534/1721/431 540/1722/431 541/1723/431 535/1724/431 +f 535/1725/432 541/1726/432 536/1727/432 530/1728/432 +f 536/1729/433 542/1730/433 543/1731/433 537/1732/433 +f 537/1733/434 543/1734/434 544/1735/434 538/1736/434 +f 538/1737/435 544/1738/435 545/1739/435 539/1740/435 +f 539/1741/436 545/1742/436 546/1743/436 540/1744/436 +f 540/1745/437 546/1746/437 547/1747/437 541/1748/437 +f 541/1749/438 547/1750/438 542/1751/438 536/1752/438 +f 542/1753/439 548/1754/439 549/1755/439 543/1756/439 +f 543/1757/440 549/1758/440 550/1759/440 544/1760/440 +f 544/1761/441 550/1762/441 551/1763/441 545/1764/441 +f 545/1765/442 551/1766/442 552/1767/442 546/1768/442 +f 546/1769/443 552/1770/443 553/1771/443 547/1772/443 +f 547/1773/444 553/1774/444 548/1775/444 542/1776/444 +f 548/1777/445 554/1778/445 555/1779/445 549/1780/445 +f 549/1781/446 555/1782/446 556/1783/446 550/1784/446 +f 550/1785/447 556/1786/447 557/1787/447 551/1788/447 +f 551/1789/448 557/1790/448 558/1791/448 552/1792/448 +f 552/1793/449 558/1794/449 559/1795/449 553/1796/449 +f 553/1797/450 559/1798/450 554/1799/450 548/1800/450 +f 554/1801/451 512/1802/451 515/1803/451 555/1804/451 +f 555/1805/452 515/1806/452 517/1807/452 556/1808/452 +f 556/1809/453 517/1810/453 519/1811/453 557/1812/453 +f 557/1813/454 519/1814/454 521/1815/454 558/1816/454 +f 558/1817/455 521/1818/455 523/1819/455 559/1820/455 +f 559/1821/456 523/1822/456 512/1823/456 554/1824/456 +f 560/1825/457 561/1826/457 562/1827/457 563/1828/457 +f 563/1829/458 562/1830/458 564/1831/458 565/1832/458 +f 565/1833/459 564/1834/459 566/1835/459 567/1836/459 +f 567/1837/460 566/1838/460 568/1839/460 569/1840/460 +f 569/1841/461 568/1842/461 570/1843/461 571/1844/461 +f 571/1845/462 570/1846/462 572/1847/462 573/1848/462 +f 573/1849/463 572/1850/463 574/1851/463 575/1852/463 +f 575/1853/464 574/1854/464 561/1855/464 560/1856/464 +f 561/1857/465 576/1858/465 577/1859/465 562/1860/465 +f 562/1861/466 577/1862/466 578/1863/466 564/1864/466 +f 564/1865/467 578/1866/467 579/1867/467 566/1868/467 +f 566/1869/468 579/1870/468 580/1871/468 568/1872/468 +f 568/1873/469 580/1874/469 581/1875/469 570/1876/469 +f 570/1877/470 581/1878/470 582/1879/470 572/1880/470 +f 572/1881/471 582/1882/471 583/1883/471 574/1884/471 +f 574/1885/472 583/1886/472 576/1887/472 561/1888/472 +f 576/1889/473 584/1890/473 585/1891/473 577/1892/473 +f 577/1893/474 585/1894/474 586/1895/474 578/1896/474 +f 578/1897/475 586/1898/475 587/1899/475 579/1900/475 +f 579/1901/476 587/1902/476 588/1903/476 580/1904/476 +f 580/1905/477 588/1906/477 589/1907/477 581/1908/477 +f 581/1909/478 589/1910/478 590/1911/478 582/1912/478 +f 582/1913/479 590/1914/479 591/1915/479 583/1916/479 +f 583/1917/480 591/1918/480 584/1919/480 576/1920/480 +f 584/1921/481 592/1922/481 593/1923/481 585/1924/481 +f 585/1925/482 593/1926/482 594/1927/482 586/1928/482 +f 586/1929/483 594/1930/483 595/1931/483 587/1932/483 +f 587/1933/484 595/1934/484 596/1935/484 588/1936/484 +f 588/1937/485 596/1938/485 597/1939/485 589/1940/485 +f 589/1941/486 597/1942/486 598/1943/486 590/1944/486 +f 590/1945/487 598/1946/487 599/1947/487 591/1948/487 +f 591/1949/488 599/1950/488 592/1951/488 584/1952/488 +f 592/1953/489 600/1954/489 601/1955/489 593/1956/489 +f 593/1957/490 601/1958/490 602/1959/490 594/1960/490 +f 594/1961/491 602/1962/491 603/1963/491 595/1964/491 +f 595/1965/492 603/1966/492 604/1967/492 596/1968/492 +f 596/1969/493 604/1970/493 605/1971/493 597/1972/493 +f 597/1973/494 605/1974/494 606/1975/494 598/1976/494 +f 598/1977/495 606/1978/495 607/1979/495 599/1980/495 +f 599/1981/496 607/1982/496 600/1983/496 592/1984/496 +f 600/1985/497 608/1986/497 609/1987/497 601/1988/497 +f 601/1989/498 609/1990/498 610/1991/498 602/1992/498 +f 602/1993/499 610/1994/499 611/1995/499 603/1996/499 +f 603/1997/500 611/1998/500 612/1999/500 604/2000/500 +f 604/2001/501 612/2002/501 613/2003/501 605/2004/501 +f 605/2005/502 613/2006/502 614/2007/502 606/2008/502 +f 606/2009/503 614/2010/503 615/2011/503 607/2012/503 +f 607/2013/504 615/2014/504 608/2015/504 600/2016/504 +f 608/2017/505 616/2018/505 617/2019/505 609/2020/505 +f 609/2021/506 617/2022/506 618/2023/506 610/2024/506 +f 610/2025/507 618/2026/507 619/2027/507 611/2028/507 +f 611/2029/508 619/2030/508 620/2031/508 612/2032/508 +f 612/2033/509 620/2034/509 621/2035/509 613/2036/509 +f 613/2037/510 621/2038/510 622/2039/510 614/2040/510 +f 614/2041/511 622/2042/511 623/2043/511 615/2044/511 +f 615/2045/512 623/2046/512 616/2047/512 608/2048/512 +f 616/2049/513 624/2050/513 625/2051/513 617/2052/513 +f 617/2053/514 625/2054/514 626/2055/514 618/2056/514 +f 618/2057/515 626/2058/515 627/2059/515 619/2060/515 +f 619/2061/516 627/2062/516 628/2063/516 620/2064/516 +f 620/2065/517 628/2066/517 629/2067/517 621/2068/517 +f 621/2069/518 629/2070/518 630/2071/518 622/2072/518 +f 622/2073/519 630/2074/519 631/2075/519 623/2076/519 +f 623/2077/520 631/2078/520 624/2079/520 616/2080/520 +f 624/2081/521 632/2082/521 633/2083/521 625/2084/521 +f 625/2085/522 633/2086/522 634/2087/522 626/2088/522 +f 626/2089/523 634/2090/523 635/2091/523 627/2092/523 +f 627/2093/524 635/2094/524 636/2095/524 628/2096/524 +f 628/2097/525 636/2098/525 637/2099/525 629/2100/525 +f 629/2101/526 637/2102/526 638/2103/526 630/2104/526 +f 630/2105/527 638/2106/527 639/2107/527 631/2108/527 +f 631/2109/528 639/2110/528 632/2111/528 624/2112/528 +f 632/2113/529 640/2114/529 641/2115/529 633/2116/529 +f 633/2117/530 641/2118/530 642/2119/530 634/2120/530 +f 634/2121/531 642/2122/531 643/2123/531 635/2124/531 +f 635/2125/532 643/2126/532 644/2127/532 636/2128/532 +f 636/2129/533 644/2130/533 645/2131/533 637/2132/533 +f 637/2133/534 645/2134/534 646/2135/534 638/2136/534 +f 638/2137/535 646/2138/535 647/2139/535 639/2140/535 +f 639/2141/536 647/2142/536 640/2143/536 632/2144/536 +f 640/2145/537 648/2146/537 649/2147/537 641/2148/537 +f 641/2149/538 649/2150/538 650/2151/538 642/2152/538 +f 642/2153/539 650/2154/539 651/2155/539 643/2156/539 +f 643/2157/540 651/2158/540 652/2159/540 644/2160/540 +f 644/2161/541 652/2162/541 653/2163/541 645/2164/541 +f 645/2165/542 653/2166/542 654/2167/542 646/2168/542 +f 646/2169/543 654/2170/543 655/2171/543 647/2172/543 +f 647/2173/544 655/2174/544 648/2175/544 640/2176/544 +f 648/2177/545 560/2178/545 563/2179/545 649/2180/545 +f 649/2181/546 563/2182/546 565/2183/546 650/2184/546 +f 650/2185/547 565/2186/547 567/2187/547 651/2188/547 +f 651/2189/548 567/2190/548 569/2191/548 652/2192/548 +f 652/2193/549 569/2194/549 571/2195/549 653/2196/549 +f 653/2197/550 571/2198/550 573/2199/550 654/2200/550 +f 654/2201/551 573/2202/551 575/2203/551 655/2204/551 +f 655/2205/552 575/2206/552 560/2207/552 648/2208/552 +f 656/2209/553 657/2210/553 658/2211/553 659/2212/553 +f 659/2213/554 658/2214/554 660/2215/554 661/2216/554 +f 661/2217/555 660/2218/555 662/2219/555 663/2220/555 +f 663/2221/556 662/2222/556 664/2223/556 665/2224/556 +f 665/2225/557 664/2226/557 666/2227/557 667/2228/557 +f 667/2229/558 666/2230/558 668/2231/558 669/2232/558 +f 669/2233/559 668/2234/559 670/2235/559 671/2236/559 +f 671/2237/560 670/2238/560 657/2239/560 656/2240/560 +f 657/2241/561 672/2242/561 673/2243/561 658/2244/561 +f 658/2245/562 673/2246/562 674/2247/562 660/2248/562 +f 660/2249/563 674/2250/563 675/2251/563 662/2252/563 +f 662/2253/564 675/2254/564 676/2255/564 664/2256/564 +f 664/2257/565 676/2258/565 677/2259/565 666/2260/565 +f 666/2261/566 677/2262/566 678/2263/566 668/2264/566 +f 668/2265/567 678/2266/567 679/2267/567 670/2268/567 +f 670/2269/568 679/2270/568 672/2271/568 657/2272/568 +f 672/2273/569 680/2274/569 681/2275/569 673/2276/569 +f 673/2277/570 681/2278/570 682/2279/570 674/2280/570 +f 674/2281/571 682/2282/571 683/2283/571 675/2284/571 +f 675/2285/572 683/2286/572 684/2287/572 676/2288/572 +f 676/2289/573 684/2290/573 685/2291/573 677/2292/573 +f 677/2293/574 685/2294/574 686/2295/574 678/2296/574 +f 678/2297/575 686/2298/575 687/2299/575 679/2300/575 +f 679/2301/576 687/2302/576 680/2303/576 672/2304/576 +f 680/2305/577 688/2306/577 689/2307/577 681/2308/577 +f 681/2309/578 689/2310/578 690/2311/578 682/2312/578 +f 682/2313/579 690/2314/579 691/2315/579 683/2316/579 +f 683/2317/580 691/2318/580 692/2319/580 684/2320/580 +f 684/2321/581 692/2322/581 693/2323/581 685/2324/581 +f 685/2325/582 693/2326/582 694/2327/582 686/2328/582 +f 686/2329/583 694/2330/583 695/2331/583 687/2332/583 +f 687/2333/584 695/2334/584 688/2335/584 680/2336/584 +f 688/2337/585 696/2338/585 697/2339/585 689/2340/585 +f 689/2341/586 697/2342/586 698/2343/586 690/2344/586 +f 690/2345/587 698/2346/587 699/2347/587 691/2348/587 +f 691/2349/588 699/2350/588 700/2351/588 692/2352/588 +f 692/2353/589 700/2354/589 701/2355/589 693/2356/589 +f 693/2357/590 701/2358/590 702/2359/590 694/2360/590 +f 694/2361/591 702/2362/591 703/2363/591 695/2364/591 +f 695/2365/592 703/2366/592 696/2367/592 688/2368/592 +f 696/2369/593 704/2370/593 705/2371/593 697/2372/593 +f 697/2373/594 705/2374/594 706/2375/594 698/2376/594 +f 698/2377/595 706/2378/595 707/2379/595 699/2380/595 +f 699/2381/596 707/2382/596 708/2383/596 700/2384/596 +f 700/2385/597 708/2386/597 709/2387/597 701/2388/597 +f 701/2389/598 709/2390/598 710/2391/598 702/2392/598 +f 702/2393/599 710/2394/599 711/2395/599 703/2396/599 +f 703/2397/600 711/2398/600 704/2399/600 696/2400/600 +f 704/2401/601 712/2402/601 713/2403/601 705/2404/601 +f 705/2405/602 713/2406/602 714/2407/602 706/2408/602 +f 706/2409/603 714/2410/603 715/2411/603 707/2412/603 +f 707/2413/604 715/2414/604 716/2415/604 708/2416/604 +f 708/2417/605 716/2418/605 717/2419/605 709/2420/605 +f 709/2421/606 717/2422/606 718/2423/606 710/2424/606 +f 710/2425/607 718/2426/607 719/2427/607 711/2428/607 +f 711/2429/608 719/2430/608 712/2431/608 704/2432/608 +f 712/2433/609 720/2434/609 721/2435/609 713/2436/609 +f 713/2437/610 721/2438/610 722/2439/610 714/2440/610 +f 714/2441/611 722/2442/611 723/2443/611 715/2444/611 +f 715/2445/612 723/2446/612 724/2447/612 716/2448/612 +f 716/2449/613 724/2450/613 725/2451/613 717/2452/613 +f 717/2453/614 725/2454/614 726/2455/614 718/2456/614 +f 718/2457/615 726/2458/615 727/2459/615 719/2460/615 +f 719/2461/616 727/2462/616 720/2463/616 712/2464/616 +f 720/2465/617 728/2466/617 729/2467/617 721/2468/617 +f 721/2469/618 729/2470/618 730/2471/618 722/2472/618 +f 722/2473/619 730/2474/619 731/2475/619 723/2476/619 +f 723/2477/620 731/2478/620 732/2479/620 724/2480/620 +f 724/2481/621 732/2482/621 733/2483/621 725/2484/621 +f 725/2485/622 733/2486/622 734/2487/622 726/2488/622 +f 726/2489/623 734/2490/623 735/2491/623 727/2492/623 +f 727/2493/624 735/2494/624 728/2495/624 720/2496/624 +f 728/2497/625 736/2498/625 737/2499/625 729/2500/625 +f 729/2501/626 737/2502/626 738/2503/626 730/2504/626 +f 730/2505/627 738/2506/627 739/2507/627 731/2508/627 +f 731/2509/628 739/2510/628 740/2511/628 732/2512/628 +f 732/2513/629 740/2514/629 741/2515/629 733/2516/629 +f 733/2517/630 741/2518/630 742/2519/630 734/2520/630 +f 734/2521/631 742/2522/631 743/2523/631 735/2524/631 +f 735/2525/632 743/2526/632 736/2527/632 728/2528/632 +f 736/2529/633 744/2530/633 745/2531/633 737/2532/633 +f 737/2533/634 745/2534/634 746/2535/634 738/2536/634 +f 738/2537/635 746/2538/635 747/2539/635 739/2540/635 +f 739/2541/636 747/2542/636 748/2543/636 740/2544/636 +f 740/2545/637 748/2546/637 749/2547/637 741/2548/637 +f 741/2549/638 749/2550/638 750/2551/638 742/2552/638 +f 742/2553/639 750/2554/639 751/2555/639 743/2556/639 +f 743/2557/640 751/2558/640 744/2559/640 736/2560/640 +f 744/2561/641 656/2562/641 659/2563/641 745/2564/641 +f 745/2565/642 659/2566/642 661/2567/642 746/2568/642 +f 746/2569/643 661/2570/643 663/2571/643 747/2572/643 +f 747/2573/644 663/2574/644 665/2575/644 748/2576/644 +f 748/2577/645 665/2578/645 667/2579/645 749/2580/645 +f 749/2581/646 667/2582/646 669/2583/646 750/2584/646 +f 750/2585/647 669/2586/647 671/2587/647 751/2588/647 +f 751/2589/648 671/2590/648 656/2591/648 744/2592/648 \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/models/obj/choke_collar_leather/texture.png b/src/main/resources/assets/tiedup/models/obj/choke_collar_leather/texture.png new file mode 100644 index 0000000..864eaaf Binary files /dev/null and b/src/main/resources/assets/tiedup/models/obj/choke_collar_leather/texture.png differ diff --git a/src/main/resources/assets/tiedup/models/obj/exemple/model.mtl b/src/main/resources/assets/tiedup/models/obj/exemple/model.mtl new file mode 100644 index 0000000..67056b1 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/obj/exemple/model.mtl @@ -0,0 +1,35 @@ +# Blender 5.0.1 MTL File: 'GagBall.blend' +# www.blender.org + +newmtl Ball +Ns 250.000000 +Ka 1.000000 1.000000 1.000000 +Kd 0.800023 0.023085 0.000000 +Ks 0.500000 0.500000 0.500000 +Ke 0.000000 0.000000 0.000000 +Ni 1.500000 +d 1.000000 +illum 2 +map_Kd texture.png + +newmtl Leather +Ns 250.000000 +Ka 1.000000 1.000000 1.000000 +Kd 0.014653 0.014653 0.014653 +Ks 0.500000 0.500000 0.500000 +Ke 0.000000 0.000000 0.000000 +Ni 1.500000 +d 1.000000 +illum 2 +map_Kd texture.png + +newmtl Metal +Ns 250.000000 +Ka 1.000000 1.000000 1.000000 +Kd 0.588909 0.588909 0.588909 +Ks 0.500000 0.500000 0.500000 +Ke 0.000000 0.000000 0.000000 +Ni 1.500000 +d 1.000000 +illum 2 +map_Kd texture.png diff --git a/src/main/resources/assets/tiedup/models/obj/exemple/model.obj b/src/main/resources/assets/tiedup/models/obj/exemple/model.obj new file mode 100644 index 0000000..1bc8693 --- /dev/null +++ b/src/main/resources/assets/tiedup/models/obj/exemple/model.obj @@ -0,0 +1,1405 @@ +# Blender 5.0.1 +# www.blender.org +mtllib model.mtl +o Cube.003 +v 0.009208 1.610105 0.263659 +v 0.009208 1.552888 0.263659 +v 0.012784 1.552888 0.251338 +v 0.012784 1.610105 0.251338 +v 0.075692 1.610105 0.292021 +v 0.075692 1.552888 0.292021 +v 0.079885 1.552888 0.280098 +v 0.079885 1.610105 0.280098 +v 0.058418 1.610105 0.263132 +v 0.058418 1.552888 0.263132 +v 0.054842 1.552888 0.275453 +v 0.054842 1.610105 0.275453 +v 0.046332 1.535183 -0.316815 +v 0.064865 1.553716 -0.316815 +v 0.064865 1.572249 -0.316815 +v 0.046332 1.553716 -0.335348 +v 0.046332 1.572249 -0.335348 +v 0.046332 1.553716 -0.316815 +v 0.046332 1.572249 -0.316815 +v 0.064865 1.535183 -0.298283 +v 0.064865 1.535183 -0.279750 +v 0.046332 1.516650 -0.298283 +v 0.046332 1.535183 -0.298283 +v 0.046332 1.516650 -0.279750 +v 0.046332 1.535183 -0.279750 +v 0.064865 1.553716 -0.298283 +v 0.064865 1.572249 -0.298283 +v 0.064865 1.553716 -0.279750 +v 0.064865 1.572249 -0.279750 +v 0.046332 1.553716 -0.298283 +v 0.027799 1.535183 -0.335348 +v 0.027799 1.516650 -0.316815 +v 0.027799 1.535183 -0.316815 +v 0.009266 1.535183 -0.335348 +v 0.009266 1.516650 -0.316815 +v 0.009266 1.535183 -0.316815 +v 0.027799 1.553716 -0.335348 +v 0.027799 1.572249 -0.335348 +v 0.027799 1.553716 -0.316815 +v 0.009266 1.553716 -0.335348 +v 0.009266 1.572249 -0.335348 +v 0.027799 1.516650 -0.298283 +v 0.027799 1.535183 -0.298283 +v 0.027799 1.516650 -0.279750 +v 0.009266 1.516650 -0.298283 +v 0.009266 1.516650 -0.279750 +v 0.064865 1.590782 -0.316815 +v 0.064865 1.609314 -0.316815 +v 0.046332 1.590782 -0.335348 +v 0.046332 1.609314 -0.335348 +v 0.046332 1.590782 -0.316815 +v 0.046332 1.609314 -0.316815 +v 0.046332 1.627847 -0.316815 +v 0.064865 1.590782 -0.298283 +v 0.064865 1.609314 -0.298283 +v 0.064865 1.590782 -0.279750 +v 0.064865 1.609314 -0.279750 +v 0.046332 1.609314 -0.298283 +v 0.064865 1.627847 -0.298283 +v 0.064865 1.627847 -0.279750 +v 0.046332 1.627847 -0.298283 +v 0.046332 1.646380 -0.298283 +v 0.046332 1.627847 -0.279750 +v 0.046332 1.646380 -0.279750 +v 0.027799 1.590782 -0.335348 +v 0.027799 1.609314 -0.335348 +v 0.027799 1.609314 -0.316815 +v 0.009266 1.590782 -0.335348 +v 0.009266 1.609314 -0.335348 +v 0.027799 1.627847 -0.335348 +v 0.027799 1.627847 -0.316815 +v 0.027799 1.646380 -0.316815 +v 0.009266 1.627847 -0.335348 +v 0.009266 1.627847 -0.316815 +v 0.009266 1.646380 -0.316815 +v 0.027799 1.627847 -0.298283 +v 0.027799 1.646380 -0.298283 +v 0.027799 1.646380 -0.279750 +v 0.009266 1.646380 -0.298283 +v 0.009266 1.646380 -0.279750 +v -0.009267 1.535183 -0.335348 +v -0.009267 1.516650 -0.316815 +v -0.009267 1.535183 -0.316815 +v -0.027800 1.535183 -0.335348 +v -0.027800 1.516650 -0.316815 +v -0.027800 1.535183 -0.316815 +v -0.009267 1.553716 -0.335348 +v -0.009267 1.572249 -0.335348 +v -0.027800 1.553716 -0.335348 +v -0.027800 1.572249 -0.335348 +v -0.027800 1.553716 -0.316815 +v -0.009267 1.516650 -0.298283 +v -0.009267 1.516650 -0.279750 +v -0.027799 1.516650 -0.298283 +v -0.027799 1.535183 -0.298283 +v -0.027800 1.516650 -0.279750 +v -0.046332 1.535183 -0.316815 +v -0.046332 1.553716 -0.335348 +v -0.046332 1.572249 -0.335348 +v -0.046332 1.553716 -0.316815 +v -0.046332 1.572249 -0.316815 +v -0.064865 1.553716 -0.316815 +v -0.064865 1.572249 -0.316815 +v -0.046332 1.516650 -0.298283 +v -0.046332 1.535183 -0.298283 +v -0.046332 1.516650 -0.279750 +v -0.046332 1.535183 -0.279750 +v -0.064865 1.535183 -0.298283 +v -0.064865 1.535183 -0.279750 +v -0.046332 1.553716 -0.298283 +v -0.064865 1.553716 -0.298283 +v -0.064865 1.572249 -0.298283 +v -0.064865 1.553716 -0.279750 +v -0.064865 1.572249 -0.279750 +v -0.009267 1.590782 -0.335348 +v -0.009267 1.609314 -0.335348 +v -0.027800 1.590782 -0.335348 +v -0.027800 1.609314 -0.335348 +v -0.027800 1.609314 -0.316815 +v -0.009267 1.627847 -0.335348 +v -0.009267 1.627847 -0.316815 +v -0.009267 1.646380 -0.316815 +v -0.027800 1.627847 -0.335348 +v -0.027800 1.627847 -0.316815 +v -0.027800 1.646380 -0.316815 +v -0.009267 1.646380 -0.298283 +v -0.009267 1.646380 -0.279750 +v -0.027799 1.627847 -0.298283 +v -0.027799 1.646380 -0.298283 +v -0.027800 1.646380 -0.279750 +v -0.046332 1.590782 -0.335348 +v -0.046332 1.609314 -0.335348 +v -0.046332 1.590782 -0.316815 +v -0.046332 1.609314 -0.316815 +v -0.064865 1.590782 -0.316815 +v -0.064865 1.609314 -0.316815 +v -0.046332 1.627847 -0.316815 +v -0.046332 1.609314 -0.298283 +v -0.064865 1.590782 -0.298283 +v -0.064865 1.609314 -0.298283 +v -0.064865 1.590782 -0.279750 +v -0.064865 1.609314 -0.279750 +v -0.046332 1.627847 -0.298283 +v -0.046332 1.646380 -0.298283 +v -0.046332 1.627847 -0.279750 +v -0.046332 1.646380 -0.279750 +v -0.064865 1.627847 -0.298283 +v -0.064865 1.627847 -0.279750 +v -0.264342 1.552812 -0.274362 +v -0.264342 1.610181 -0.274362 +v -0.264342 1.552812 -0.255332 +v -0.264342 1.610181 -0.255332 +v -0.050610 1.552812 -0.274362 +v -0.050610 1.610181 -0.274362 +v -0.050610 1.552812 -0.255332 +v -0.050610 1.610181 -0.255332 +v -0.265275 1.552812 0.264113 +v -0.265275 1.610181 0.264113 +v -0.246678 1.552812 0.264113 +v -0.246678 1.610181 0.264113 +v -0.265275 1.552812 -0.203222 +v -0.265275 1.610181 -0.203222 +v -0.246678 1.552812 -0.203222 +v -0.246678 1.610181 -0.203222 +v -0.246678 1.552812 0.250886 +v -0.246678 1.610181 0.250886 +v -0.265275 1.552812 0.250886 +v -0.265275 1.610181 0.250886 +v 0.001357 1.610181 0.264112 +v 0.001357 1.552812 0.264112 +v 0.001357 1.552812 0.250885 +v 0.001357 1.610181 0.250885 +v 0.267055 1.552812 -0.274363 +v 0.267055 1.610181 -0.274363 +v 0.267055 1.552812 -0.255332 +v 0.267055 1.610181 -0.255332 +v 0.053323 1.552812 -0.274362 +v 0.053323 1.610181 -0.274362 +v 0.053323 1.552812 -0.255332 +v 0.053323 1.610181 -0.255332 +v 0.267989 1.552812 0.264112 +v 0.267989 1.610181 0.264112 +v 0.249392 1.552812 0.264112 +v 0.249392 1.610181 0.264112 +v 0.267989 1.552812 -0.203223 +v 0.267989 1.610181 -0.203223 +v 0.249391 1.552812 -0.203223 +v 0.249391 1.610181 -0.203223 +v 0.249392 1.552812 0.250885 +v 0.249392 1.610181 0.250885 +v 0.267989 1.552812 0.250885 +v 0.267989 1.610181 0.250885 +v -0.260266 1.546786 -0.255580 +v -0.260266 1.615989 -0.255580 +v -0.260266 1.546786 -0.196445 +v -0.260266 1.615989 -0.196445 +v -0.260266 1.534940 -0.271398 +v -0.260266 1.627835 -0.271398 +v -0.260266 1.534940 -0.180628 +v -0.260266 1.627835 -0.180628 +v -0.251096 1.546786 -0.255580 +v -0.251096 1.615989 -0.255580 +v -0.251096 1.546786 -0.196445 +v -0.251096 1.615989 -0.196445 +v -0.251096 1.534940 -0.271398 +v -0.251096 1.627835 -0.271398 +v -0.251096 1.534940 -0.180628 +v -0.251096 1.627835 -0.180628 +v -0.231611 1.571106 -0.278496 +v -0.231611 1.594126 -0.278496 +v -0.251896 1.571106 -0.278496 +v -0.251896 1.594126 -0.278496 +v -0.231611 1.571106 -0.274260 +v -0.231611 1.594126 -0.274260 +v -0.251896 1.571106 -0.274260 +v -0.251896 1.594126 -0.274260 +v -0.268628 1.571106 -0.190272 +v -0.268628 1.594126 -0.190272 +v -0.268628 1.571106 -0.169514 +v -0.268628 1.594126 -0.169514 +v -0.264489 1.571106 -0.190272 +v -0.264489 1.594126 -0.190272 +v -0.264489 1.571106 -0.169514 +v -0.264489 1.594126 -0.169514 +v 0.234324 1.571106 -0.278496 +v 0.234324 1.594126 -0.278496 +v 0.254609 1.571106 -0.278496 +v 0.254609 1.594126 -0.278496 +v 0.234324 1.571106 -0.274260 +v 0.234324 1.594126 -0.274260 +v 0.254609 1.571106 -0.274260 +v 0.254609 1.594126 -0.274260 +v 0.271342 1.571106 -0.190272 +v 0.271342 1.594126 -0.190272 +v 0.271342 1.571106 -0.169515 +v 0.271342 1.594126 -0.169515 +v 0.267202 1.571106 -0.190272 +v 0.267202 1.594126 -0.190272 +v 0.267202 1.571106 -0.169515 +v 0.267202 1.594126 -0.169515 +v 0.010313 1.552583 0.273601 +v 0.010313 1.610341 0.273601 +v 0.078990 1.552583 0.273601 +v 0.078990 1.610341 0.273601 +v -0.001443 1.542696 0.273601 +v -0.001443 1.620228 0.273601 +v 0.090747 1.542696 0.273601 +v 0.090747 1.620228 0.273601 +v 0.010313 1.552583 0.264217 +v 0.010313 1.610341 0.264217 +v 0.078990 1.552583 0.264217 +v 0.078990 1.610341 0.264217 +v -0.001443 1.542696 0.264217 +v -0.001443 1.620228 0.264217 +v 0.090747 1.542696 0.264217 +v 0.090747 1.620228 0.264217 +v 0.046598 1.586406 0.281684 +v 0.002697 1.586406 0.271529 +v 0.046598 1.576519 0.281684 +v 0.002697 1.576519 0.271529 +v 0.047780 1.586406 0.272435 +v 0.003879 1.586406 0.262280 +v 0.047780 1.576519 0.272435 +v 0.003879 1.576519 0.262280 +v 0.262979 1.546786 -0.255581 +v 0.262979 1.615989 -0.255581 +v 0.262979 1.546786 -0.196446 +v 0.262979 1.615989 -0.196446 +v 0.262979 1.534940 -0.271398 +v 0.262979 1.627836 -0.271398 +v 0.262979 1.534940 -0.180628 +v 0.262979 1.627836 -0.180628 +v 0.253808 1.546786 -0.255581 +v 0.253808 1.615989 -0.255581 +v 0.253809 1.546786 -0.196446 +v 0.253809 1.615989 -0.196446 +v 0.253809 1.534940 -0.271398 +v 0.253809 1.627836 -0.271398 +v 0.253809 1.534940 -0.180628 +v 0.253809 1.627836 -0.180628 +v 0.046332 1.535183 -0.205267 +v 0.064865 1.553716 -0.205267 +v 0.064865 1.572249 -0.205267 +v 0.046332 1.553716 -0.186734 +v 0.046332 1.572249 -0.186734 +v 0.046332 1.553716 -0.205267 +v 0.046332 1.572249 -0.205267 +v 0.064865 1.535183 -0.223800 +v 0.064865 1.535183 -0.242332 +v 0.046332 1.516650 -0.223800 +v 0.046332 1.535183 -0.223800 +v 0.046332 1.516650 -0.242332 +v 0.046332 1.535183 -0.242332 +v 0.064865 1.553716 -0.223800 +v 0.064865 1.572249 -0.223800 +v 0.064865 1.553716 -0.242332 +v 0.064865 1.572249 -0.242332 +v 0.046332 1.553716 -0.223800 +v 0.027799 1.535183 -0.186734 +v 0.027799 1.516650 -0.205267 +v 0.027799 1.535183 -0.205267 +v 0.009266 1.535183 -0.186734 +v 0.009266 1.516650 -0.205267 +v 0.009266 1.535183 -0.205267 +v 0.027799 1.553716 -0.186734 +v 0.027799 1.572249 -0.186734 +v 0.027799 1.553716 -0.205267 +v 0.009266 1.553716 -0.186734 +v 0.009266 1.572249 -0.186734 +v 0.027799 1.516650 -0.223800 +v 0.027799 1.535183 -0.223800 +v 0.027799 1.516650 -0.242332 +v 0.009266 1.516650 -0.223800 +v 0.009266 1.516650 -0.242332 +v 0.064865 1.590782 -0.205267 +v 0.064865 1.609314 -0.205267 +v 0.046332 1.590782 -0.186734 +v 0.046332 1.609314 -0.186734 +v 0.046332 1.590782 -0.205267 +v 0.046332 1.609314 -0.205267 +v 0.046332 1.627847 -0.205267 +v 0.064865 1.590782 -0.223800 +v 0.064865 1.609314 -0.223800 +v 0.064865 1.590782 -0.242332 +v 0.064865 1.609314 -0.242332 +v 0.046332 1.609314 -0.223800 +v 0.064865 1.627847 -0.223800 +v 0.064865 1.627847 -0.242332 +v 0.046332 1.627847 -0.223800 +v 0.046332 1.646380 -0.223800 +v 0.046332 1.627847 -0.242332 +v 0.046332 1.646380 -0.242332 +v 0.027799 1.590782 -0.186734 +v 0.027799 1.609314 -0.186734 +v 0.027799 1.609314 -0.205267 +v 0.009266 1.590782 -0.186734 +v 0.009266 1.609314 -0.186734 +v 0.027799 1.627847 -0.186734 +v 0.027799 1.627847 -0.205267 +v 0.027799 1.646380 -0.205267 +v 0.009266 1.627847 -0.186734 +v 0.009266 1.627847 -0.205267 +v 0.009266 1.646380 -0.205267 +v 0.027799 1.627847 -0.223800 +v 0.027799 1.646380 -0.223800 +v 0.027799 1.646380 -0.242332 +v 0.009266 1.646380 -0.223800 +v 0.009266 1.646380 -0.242332 +v 0.064865 1.535183 -0.261041 +v 0.046332 1.516650 -0.261041 +v 0.046332 1.535183 -0.261041 +v 0.064865 1.553716 -0.261041 +v 0.064865 1.572249 -0.261041 +v 0.027799 1.516650 -0.261041 +v 0.009266 1.516650 -0.261041 +v 0.064865 1.590782 -0.261041 +v 0.064865 1.609314 -0.261041 +v 0.064865 1.627847 -0.261041 +v 0.046332 1.627847 -0.261041 +v 0.046332 1.646380 -0.261041 +v 0.027799 1.646380 -0.261041 +v 0.009266 1.646380 -0.261041 +v -0.009267 1.535183 -0.186734 +v -0.009267 1.516650 -0.205267 +v -0.009267 1.535183 -0.205267 +v -0.027799 1.535183 -0.186734 +v -0.027799 1.516650 -0.205267 +v -0.027799 1.535183 -0.205267 +v -0.009267 1.553716 -0.186734 +v -0.009267 1.572249 -0.186734 +v -0.027799 1.553716 -0.186734 +v -0.027799 1.572249 -0.186734 +v -0.027799 1.553716 -0.205267 +v -0.009267 1.516650 -0.223800 +v -0.009267 1.516650 -0.242332 +v -0.027799 1.516650 -0.223800 +v -0.027799 1.535183 -0.223800 +v -0.027799 1.516650 -0.242332 +v -0.046332 1.535183 -0.205267 +v -0.046332 1.553716 -0.186734 +v -0.046332 1.572249 -0.186734 +v -0.046332 1.553716 -0.205267 +v -0.046332 1.572249 -0.205267 +v -0.064865 1.553716 -0.205267 +v -0.064865 1.572249 -0.205267 +v -0.046332 1.516650 -0.223800 +v -0.046332 1.535183 -0.223800 +v -0.046332 1.516650 -0.242332 +v -0.046332 1.535183 -0.242332 +v -0.064865 1.535183 -0.223800 +v -0.064865 1.535183 -0.242332 +v -0.046332 1.553716 -0.223800 +v -0.064865 1.553716 -0.223800 +v -0.064865 1.572249 -0.223800 +v -0.064865 1.553716 -0.242332 +v -0.064865 1.572249 -0.242332 +v -0.009267 1.590782 -0.186734 +v -0.009267 1.609314 -0.186734 +v -0.027799 1.590782 -0.186734 +v -0.027799 1.609314 -0.186734 +v -0.027799 1.609314 -0.205267 +v -0.009267 1.627847 -0.186734 +v -0.009267 1.627847 -0.205267 +v -0.009267 1.646380 -0.205267 +v -0.027799 1.627847 -0.186734 +v -0.027799 1.627847 -0.205267 +v -0.027799 1.646380 -0.205267 +v -0.009267 1.646380 -0.223800 +v -0.009267 1.646380 -0.242332 +v -0.027799 1.627847 -0.223800 +v -0.027799 1.646380 -0.223800 +v -0.027799 1.646380 -0.242332 +v -0.046332 1.590782 -0.186734 +v -0.046332 1.609314 -0.186734 +v -0.046332 1.590782 -0.205267 +v -0.046332 1.609314 -0.205267 +v -0.064865 1.590782 -0.205267 +v -0.064865 1.609314 -0.205267 +v -0.046332 1.627847 -0.205267 +v -0.046332 1.609314 -0.223800 +v -0.064865 1.590782 -0.223800 +v -0.064865 1.609314 -0.223800 +v -0.064865 1.590782 -0.242332 +v -0.064865 1.609314 -0.242332 +v -0.046332 1.627847 -0.223800 +v -0.046332 1.646380 -0.223800 +v -0.046332 1.627847 -0.242332 +v -0.046332 1.646380 -0.242332 +v -0.064865 1.627847 -0.223800 +v -0.064865 1.627847 -0.242332 +v -0.009267 1.516650 -0.261041 +v -0.027799 1.516650 -0.261041 +v -0.046332 1.516650 -0.261041 +v -0.046332 1.535183 -0.261041 +v -0.064865 1.535183 -0.261041 +v -0.064865 1.553716 -0.261041 +v -0.064865 1.572249 -0.261041 +v -0.009267 1.646380 -0.261041 +v -0.027799 1.646380 -0.261041 +v -0.064865 1.590782 -0.261041 +v -0.064865 1.609314 -0.261041 +v -0.046332 1.627847 -0.261041 +v -0.046332 1.646380 -0.261041 +v -0.064865 1.627847 -0.261041 +vn 0.9434 -0.0000 0.3318 +vn 0.6201 -0.0000 -0.7846 +vn -0.0000 -1.0000 -0.0000 +vn -0.0000 1.0000 -0.0000 +vn -0.6221 -0.0000 0.7829 +vn -0.2502 -0.0000 0.9682 +vn 0.2502 -0.0000 -0.9682 +vn 1.0000 -0.0000 -0.0000 +vn -0.0000 -0.0000 -1.0000 +vn -1.0000 -0.0000 -0.0000 +vn -0.0000 -0.0000 1.0000 +vn -0.2254 -0.0000 0.9743 +vn 0.9919 -0.0000 0.1267 +vt 0.190913 0.801475 +vt 0.168583 0.801475 +vt 0.168583 0.673733 +vt 0.190913 0.673733 +vt 0.246740 0.823570 +vt 0.246740 0.951313 +vt 0.190913 0.951313 +vt 0.190913 0.823570 +vt 0.246740 0.801475 +vt 0.246740 0.651638 +vt 0.246740 0.673733 +vt 0.190913 0.651638 +vt 0.352387 0.673733 +vt 0.352387 0.801475 +vt 0.352387 0.651638 +vt 0.352387 0.823570 +vt 0.352387 0.951313 +vt 0.630344 0.173153 +vt 0.627883 0.195420 +vt 0.603213 0.190842 +vt 0.607900 0.156726 +vt 0.581708 0.166105 +vt 0.577763 0.192337 +vt 0.611368 0.076215 +vt 0.594510 0.106088 +vt 0.571187 0.082827 +vt 0.596739 0.057150 +vt 0.565991 0.120579 +vt 0.544244 0.110740 +vt 0.559448 0.152411 +vt 0.551843 0.188253 +vt 0.525168 0.186119 +vt 0.532375 0.147085 +vt 0.655709 0.093523 +vt 0.681828 0.089339 +vt 0.679214 0.115350 +vt 0.644528 0.119907 +vt 0.661446 0.141757 +vt 0.684062 0.138749 +vt 0.654591 0.166019 +vt 0.681316 0.166251 +vt 0.680783 0.192774 +vt 0.654678 0.193007 +vt 0.640514 0.072002 +vt 0.634509 0.044012 +vt 0.673760 0.037380 +vt 0.676439 0.064356 +vt 0.635894 0.147730 +vt 0.626963 0.099550 +vt 0.587753 0.137641 +vt 0.615668 0.127580 +vt 0.627891 0.217939 +vt 0.627729 0.239461 +vt 0.606746 0.254633 +vt 0.602772 0.219798 +vt 0.577170 0.219186 +vt 0.580979 0.243992 +vt 0.551328 0.223225 +vt 0.560805 0.259673 +vt 0.532473 0.264492 +vt 0.525155 0.226003 +vt 0.593740 0.305581 +vt 0.608665 0.334938 +vt 0.597647 0.355505 +vt 0.570258 0.329150 +vt 0.544083 0.301640 +vt 0.564663 0.290720 +vt 0.654397 0.219120 +vt 0.680317 0.219211 +vt 0.680046 0.245135 +vt 0.651477 0.246547 +vt 0.644587 0.292262 +vt 0.678602 0.296196 +vt 0.678803 0.322995 +vt 0.654518 0.316740 +vt 0.680635 0.272902 +vt 0.658991 0.269017 +vt 0.675040 0.347473 +vt 0.673331 0.374164 +vt 0.634648 0.367388 +vt 0.639893 0.339044 +vt 0.632642 0.265913 +vt 0.625283 0.312371 +vt 0.586984 0.273702 +vt 0.613869 0.285365 +vt 0.707965 0.090268 +vt 0.732255 0.093185 +vt 0.742296 0.119038 +vt 0.708960 0.115679 +vt 0.706583 0.139857 +vt 0.728311 0.142938 +vt 0.707268 0.166957 +vt 0.735860 0.165332 +vt 0.732944 0.192721 +vt 0.706979 0.192694 +vt 0.711589 0.063119 +vt 0.713780 0.037239 +vt 0.752869 0.045084 +vt 0.747572 0.072733 +vt 0.780533 0.157159 +vt 0.784568 0.191988 +vt 0.759458 0.193833 +vt 0.759594 0.172301 +vt 0.806350 0.167842 +vt 0.810180 0.192629 +vt 0.789108 0.056257 +vt 0.817117 0.082534 +vt 0.793604 0.106629 +vt 0.779539 0.078207 +vt 0.843246 0.110119 +vt 0.822666 0.121128 +vt 0.854874 0.147315 +vt 0.862204 0.185847 +vt 0.836026 0.188609 +vt 0.826526 0.152165 +vt 0.754581 0.145777 +vt 0.761629 0.099494 +vt 0.800303 0.138192 +vt 0.773326 0.126435 +vt 0.706497 0.219017 +vt 0.732677 0.218791 +vt 0.732850 0.245710 +vt 0.705905 0.245383 +vt 0.707813 0.296719 +vt 0.742716 0.292091 +vt 0.731295 0.318536 +vt 0.705395 0.322256 +vt 0.725896 0.269820 +vt 0.703363 0.272396 +vt 0.746176 0.340586 +vt 0.752648 0.367405 +vt 0.713081 0.374397 +vt 0.710225 0.348167 +vt 0.784146 0.220935 +vt 0.779425 0.255043 +vt 0.757031 0.238602 +vt 0.759488 0.216343 +vt 0.809601 0.219463 +vt 0.805650 0.245664 +vt 0.862189 0.225787 +vt 0.854945 0.264899 +vt 0.827928 0.259449 +vt 0.835523 0.223593 +vt 0.815808 0.329340 +vt 0.789995 0.355482 +vt 0.776441 0.334442 +vt 0.792867 0.305328 +vt 0.821271 0.291280 +vt 0.842939 0.301335 +vt 0.751452 0.264079 +vt 0.760113 0.312433 +vt 0.799606 0.274030 +vt 0.771671 0.284170 +vt 0.550122 0.063059 +vt 0.582268 0.033927 +vt 0.520298 0.097825 +vt 0.626435 0.016428 +vt 0.671377 0.009261 +vt 0.497105 0.183889 +vt 0.505069 0.139888 +vt 0.716255 0.009697 +vt 0.760069 0.017702 +vt 0.801938 0.032218 +vt 0.837555 0.061566 +vt 0.866356 0.096844 +vt 0.882253 0.139605 +vt 0.890244 0.183190 +vt 0.505093 0.272180 +vt 0.497116 0.228672 +vt 0.584418 0.378641 +vt 0.549539 0.349818 +vt 0.520926 0.314825 +vt 0.670876 0.402707 +vt 0.627041 0.394729 +vt 0.890244 0.228057 +vt 0.882218 0.272174 +vt 0.760172 0.394612 +vt 0.715711 0.402056 +vt 0.836715 0.349378 +vt 0.804067 0.379523 +vt 0.866820 0.314380 +vt 0.629801 0.722370 +vt 0.629801 0.749891 +vt 0.519022 0.749891 +vt 0.519022 0.722370 +vt 0.629801 0.406076 +vt 0.519022 0.406076 +vt 0.657322 0.722370 +vt 0.657322 0.406076 +vt 0.491501 0.406076 +vt 0.491501 0.722370 +vt 0.028418 0.833467 +vt 0.028418 0.805131 +vt 0.135693 0.805131 +vt 0.135693 0.833467 +vt 0.028418 0.853163 +vt 0.135693 0.853163 +vt 0.159858 -0.000000 +vt 0.187379 -0.000000 +vt 0.187379 0.608615 +vt 0.159858 0.608615 +vt 0.154216 0.805131 +vt 0.154216 0.833467 +vt 0.187379 0.636136 +vt 0.298158 0.608615 +vt 0.298158 0.636136 +vt 0.325679 0.608615 +vt 0.298158 -0.000000 +vt 0.325679 -0.000000 +vt 0.009894 0.833467 +vt 0.009894 0.805131 +vt 0.009894 0.427199 +vt 0.028418 0.427199 +vt 0.135693 0.427199 +vt 0.154216 0.427199 +vt 0.795622 0.433597 +vt 0.684843 0.433597 +vt 0.684843 0.406076 +vt 0.795622 0.406076 +vt 0.795622 0.749891 +vt 0.684843 0.749891 +vt 0.823143 0.433597 +vt 0.823143 0.749891 +vt 0.657322 0.749891 +vt 0.657322 0.433597 +vt 0.028418 0.020930 +vt 0.135693 0.020930 +vt 0.135693 0.049267 +vt 0.028418 0.049267 +vt 0.028418 0.001235 +vt 0.135693 0.001235 +vt 0.325679 0.636135 +vt 0.325679 0.027521 +vt 0.353201 0.027521 +vt 0.353200 0.636135 +vt 0.154216 0.049267 +vt 0.154216 0.020930 +vt 0.353201 -0.000000 +vt 0.463980 0.000000 +vt 0.463980 0.027521 +vt 0.491501 0.027521 +vt 0.491501 0.636135 +vt 0.463979 0.636135 +vt 0.009894 0.049267 +vt 0.009894 0.020930 +vt 0.868102 0.786337 +vt 0.890977 0.763462 +vt 0.890977 0.942844 +vt 0.868102 0.919969 +vt 0.734471 0.786337 +vt 0.711596 0.763462 +vt 0.890977 0.763462 +vt 0.734471 0.919969 +vt 0.711596 0.942844 +vt 0.748042 0.786337 +vt 0.748042 0.919969 +vt 0.904548 0.763462 +vt 0.904548 0.942844 +vt 0.711596 0.749891 +vt 0.890977 0.749891 +vt 0.734471 0.906398 +vt 0.868102 0.906398 +vt 0.890977 0.956415 +vt 0.711596 0.956415 +vt 0.854531 0.919969 +vt 0.854531 0.786337 +vt 0.698025 0.942844 +vt 0.698025 0.763462 +vt 0.868102 0.799908 +vt 0.734471 0.799908 +vt 0.395693 0.694111 +vt 0.436865 0.694111 +vt 0.436865 0.702513 +vt 0.395693 0.702513 +vt 0.436865 0.743686 +vt 0.395693 0.743686 +vt 0.387291 0.743686 +vt 0.387291 0.702513 +vt 0.445267 0.702513 +vt 0.445267 0.743686 +vt 0.436865 0.752087 +vt 0.395693 0.752087 +vt 0.436865 0.810064 +vt 0.395693 0.810064 +vt 0.395693 0.801662 +vt 0.436865 0.801662 +vt 0.395693 0.760490 +vt 0.436865 0.760490 +vt 0.445267 0.760490 +vt 0.445267 0.801662 +vt 0.387291 0.801662 +vt 0.387291 0.760490 +vt 0.395693 0.752088 +vt 0.436865 0.752088 +vt 0.436865 0.636136 +vt 0.436865 0.644537 +vt 0.395693 0.644537 +vt 0.395693 0.636136 +vt 0.436865 0.685710 +vt 0.395693 0.685710 +vt 0.445267 0.685710 +vt 0.445267 0.644537 +vt 0.387291 0.644538 +vt 0.387291 0.685710 +vt 0.436865 0.694112 +vt 0.395693 0.694112 +vt 0.395693 0.868039 +vt 0.395693 0.859638 +vt 0.436865 0.859638 +vt 0.436865 0.868039 +vt 0.395693 0.818465 +vt 0.436865 0.818465 +vt 0.387291 0.818465 +vt 0.387291 0.859638 +vt 0.445267 0.859638 +vt 0.445267 0.818465 +vt 0.395693 0.810064 +vt 0.436865 0.810063 +vt 0.967337 0.442522 +vt 0.986429 0.419647 +vt 0.986429 0.599029 +vt 0.967337 0.576154 +vt 0.855806 0.442522 +vt 0.836714 0.419647 +vt 0.986429 0.419647 +vt 0.967337 0.442522 +vt 0.855806 0.576154 +vt 0.836714 0.599029 +vt 0.869377 0.442522 +vt 0.869377 0.576154 +vt 1.000000 0.419647 +vt 1.000000 0.599029 +vt 0.836714 0.406076 +vt 0.986429 0.406076 +vt 0.855806 0.562583 +vt 0.967337 0.562583 +vt 0.986429 0.612600 +vt 0.836714 0.612600 +vt 0.953766 0.576154 +vt 0.953766 0.442522 +vt 0.823143 0.599029 +vt 0.823143 0.419647 +vt 0.967337 0.456093 +vt 0.855806 0.456093 +vt 0.458838 0.722811 +vt 0.458838 0.636136 +vt 0.477930 0.636136 +vt 0.477929 0.722811 +vt 0.445267 0.722811 +vt 0.445267 0.636135 +vt 0.491501 0.636135 +vt 0.491501 0.722811 +vt 0.477930 0.736382 +vt 0.458838 0.736382 +vt 0.661578 0.786337 +vt 0.527947 0.786337 +vt 0.505072 0.763462 +vt 0.684453 0.763462 +vt 0.661578 0.919969 +vt 0.684453 0.763462 +vt 0.684453 0.942844 +vt 0.527947 0.919969 +vt 0.505072 0.942844 +vt 0.527947 0.906398 +vt 0.661578 0.906398 +vt 0.505072 0.749891 +vt 0.684453 0.749891 +vt 0.698024 0.763462 +vt 0.698024 0.942844 +vt 0.541518 0.786337 +vt 0.541518 0.919969 +vt 0.491501 0.942844 +vt 0.491501 0.763462 +vt 0.661578 0.799908 +vt 0.527947 0.799908 +vt 0.684453 0.956415 +vt 0.505072 0.956415 +vt 0.648007 0.919969 +vt 0.648007 0.786337 +vt 0.952823 0.055095 +vt 0.959927 0.049842 +vt 0.961442 0.060656 +vt 0.953604 0.062053 +vt 0.968259 0.052847 +vt 0.969518 0.061168 +vt 0.958986 0.024702 +vt 0.963273 0.018021 +vt 0.971470 0.026313 +vt 0.964200 0.033925 +vt 0.973212 0.038383 +vt 0.980085 0.035195 +vt 0.975324 0.048479 +vt 0.983895 0.046759 +vt 0.986193 0.059180 +vt 0.977736 0.059870 +vt 0.944638 0.029704 +vt 0.948267 0.038109 +vt 0.937234 0.036708 +vt 0.936358 0.028453 +vt 0.942862 0.045074 +vt 0.935735 0.044162 +vt 0.945105 0.052776 +vt 0.945094 0.061354 +vt 0.936830 0.061311 +vt 0.936636 0.052891 +vt 0.949396 0.022775 +vt 0.937998 0.020586 +vt 0.938870 0.012137 +vt 0.951406 0.014217 +vt 0.951028 0.046970 +vt 0.953801 0.031675 +vt 0.966334 0.043846 +vt 0.957467 0.040622 +vt 0.953615 0.069127 +vt 0.961596 0.069853 +vt 0.960267 0.081121 +vt 0.953696 0.075781 +vt 0.969702 0.069707 +vt 0.968455 0.077610 +vt 0.977892 0.070993 +vt 0.986197 0.071867 +vt 0.983873 0.084108 +vt 0.974877 0.082576 +vt 0.964434 0.097038 +vt 0.971880 0.104683 +vt 0.962985 0.113028 +vt 0.959931 0.106076 +vt 0.973664 0.092435 +vt 0.980188 0.095926 +vt 0.945186 0.069672 +vt 0.946114 0.078521 +vt 0.937074 0.077956 +vt 0.936997 0.069710 +vt 0.948303 0.092859 +vt 0.945137 0.100705 +vt 0.937432 0.102661 +vt 0.937515 0.094151 +vt 0.936868 0.086757 +vt 0.943769 0.085553 +vt 0.938582 0.110436 +vt 0.949750 0.107866 +vt 0.951482 0.116621 +vt 0.939169 0.118868 +vt 0.952153 0.084239 +vt 0.954387 0.099214 +vt 0.966573 0.087037 +vt 0.957995 0.090640 +vt 0.927939 0.028554 +vt 0.927886 0.036873 +vt 0.917149 0.038222 +vt 0.920242 0.030444 +vt 0.928586 0.044542 +vt 0.921685 0.045661 +vt 0.928414 0.053169 +vt 0.928528 0.061319 +vt 0.920287 0.061405 +vt 0.919322 0.052760 +vt 0.926843 0.020426 +vt 0.915671 0.023315 +vt 0.913970 0.014379 +vt 0.926256 0.012088 +vt 0.905145 0.050191 +vt 0.911802 0.055055 +vt 0.911883 0.061928 +vt 0.903899 0.061247 +vt 0.895762 0.061391 +vt 0.896979 0.053537 +vt 0.902243 0.018128 +vt 0.905742 0.024658 +vt 0.901011 0.033992 +vt 0.893557 0.026510 +vt 0.891793 0.038709 +vt 0.885260 0.035246 +vt 0.881584 0.047027 +vt 0.890575 0.048557 +vt 0.887564 0.060100 +vt 0.879261 0.059224 +vt 0.913340 0.046622 +vt 0.911002 0.031827 +vt 0.898878 0.044114 +vt 0.907406 0.040416 +vt 0.928701 0.069643 +vt 0.928875 0.077966 +vt 0.920280 0.077867 +vt 0.920392 0.069595 +vt 0.928242 0.094314 +vt 0.928998 0.102429 +vt 0.920772 0.101226 +vt 0.917242 0.092968 +vt 0.922587 0.085737 +vt 0.929647 0.086544 +vt 0.915975 0.108101 +vt 0.927403 0.110639 +vt 0.926560 0.119017 +vt 0.914054 0.116934 +vt 0.904045 0.070391 +vt 0.911909 0.069178 +vt 0.912688 0.076365 +vt 0.905463 0.080910 +vt 0.897197 0.078124 +vt 0.895926 0.069873 +vt 0.879266 0.071864 +vt 0.887718 0.071169 +vt 0.890152 0.082506 +vt 0.881578 0.084237 +vt 0.893957 0.104601 +vt 0.901355 0.097201 +vt 0.906707 0.106713 +vt 0.902065 0.112757 +vt 0.892275 0.092616 +vt 0.885385 0.095762 +vt 0.914567 0.084349 +vt 0.911654 0.099319 +vt 0.899137 0.087135 +vt 0.908011 0.090345 +vt 0.967744 0.010415 +vt 0.978100 0.019958 +vt 0.987660 0.031055 +vt 0.939705 0.003103 +vt 0.953813 0.005718 +vt 0.992547 0.044454 +vt 0.995093 0.058460 +vt 0.911554 0.005570 +vt 0.925511 0.003297 +vt 0.898026 0.010789 +vt 0.886975 0.019960 +vt 0.877908 0.031071 +vt 0.872896 0.044594 +vt 0.870366 0.058385 +vt 0.995093 0.072707 +vt 0.992560 0.086549 +vt 0.978365 0.111332 +vt 0.967067 0.120635 +vt 0.987518 0.100135 +vt 0.953770 0.125172 +vt 0.939973 0.127894 +vt 0.870368 0.072586 +vt 0.872924 0.086542 +vt 0.925762 0.127812 +vt 0.911499 0.125661 +vt 0.887281 0.110885 +vt 0.897480 0.120126 +vt 0.877797 0.099879 +s 0 +usemtl Ball +f 16/18/8 17/19/8 19/20/8 18/21/8 +f 14/22/9 18/21/9 19/20/9 15/23/9 +f 22/24/8 23/25/8 25/26/8 24/27/8 +f 20/28/3 21/29/3 25/26/3 23/25/3 +f 26/30/8 27/31/8 29/32/8 28/33/8 +f 32/34/9 35/35/9 36/36/9 33/37/9 +f 31/38/3 33/37/3 36/36/3 34/39/3 +f 37/40/9 40/41/9 41/42/9 38/43/9 +f 42/44/3 44/45/3 46/46/3 45/47/3 +f 16/18/9 37/40/9 38/43/9 17/19/9 +f 16/18/3 18/21/3 39/48/3 37/40/3 +f 22/24/9 42/44/9 43/49/9 23/25/9 +f 22/24/3 24/27/3 44/45/3 42/44/3 +f 32/34/8 33/37/8 43/49/8 42/44/8 +f 32/34/3 42/44/3 45/47/3 35/35/3 +f 14/22/8 15/23/8 27/31/8 26/30/8 +f 14/22/3 26/30/3 30/50/3 18/21/3 +f 20/28/8 26/30/8 28/33/8 21/29/8 +f 20/28/9 23/25/9 30/50/9 26/30/9 +f 31/38/8 37/40/8 39/48/8 33/37/8 +f 31/38/9 34/39/9 40/41/9 37/40/9 +f 13/51/8 18/21/8 30/50/8 23/25/8 +f 13/51/9 33/37/9 39/48/9 18/21/9 +f 13/51/3 23/25/3 43/49/3 33/37/3 +f 49/52/8 50/53/8 52/54/8 51/55/8 +f 47/56/9 51/55/9 52/54/9 48/57/9 +f 54/58/8 55/59/8 57/60/8 56/61/8 +f 61/62/8 62/63/8 64/64/8 63/65/8 +f 61/62/4 63/65/4 60/66/4 59/67/4 +f 65/68/9 68/69/9 69/70/9 66/71/9 +f 71/72/9 74/73/9 75/74/9 72/75/9 +f 73/76/4 74/73/4 71/72/4 70/77/4 +f 79/78/4 80/79/4 78/80/4 77/81/4 +f 49/52/9 65/68/9 66/71/9 50/53/9 +f 66/71/4 67/82/4 52/54/4 50/53/4 +f 61/62/9 76/83/9 77/81/9 62/63/9 +f 77/81/4 78/80/4 64/64/4 62/63/4 +f 47/56/8 48/57/8 55/59/8 54/58/8 +f 52/54/4 58/84/4 55/59/4 48/57/4 +f 71/72/8 72/75/8 77/81/8 76/83/8 +f 75/74/4 79/78/4 77/81/4 72/75/4 +f 55/59/8 59/67/8 60/66/8 57/60/8 +f 55/59/9 58/84/9 61/62/9 59/67/9 +f 66/71/8 70/77/8 71/72/8 67/82/8 +f 66/71/9 69/70/9 73/76/9 70/77/9 +f 52/54/8 53/85/8 61/62/8 58/84/8 +f 52/54/9 67/82/9 71/72/9 53/85/9 +f 71/72/4 76/83/4 61/62/4 53/85/4 +f 82/86/9 85/87/9 86/88/9 83/89/9 +f 81/90/3 83/89/3 86/88/3 84/91/3 +f 87/92/9 89/93/9 90/94/9 88/95/9 +f 92/96/3 93/97/3 96/98/3 94/99/3 +f 100/100/10 101/101/10 99/102/10 98/103/10 +f 100/100/9 102/104/9 103/105/9 101/101/9 +f 106/106/10 107/107/10 105/108/10 104/109/10 +f 105/108/3 107/107/3 109/110/3 108/111/3 +f 113/112/10 114/113/10 112/114/10 111/115/10 +f 89/93/9 98/103/9 99/102/9 90/94/9 +f 89/93/3 91/116/3 100/100/3 98/103/3 +f 94/99/9 104/109/9 105/108/9 95/117/9 +f 94/99/3 96/98/3 106/106/3 104/109/3 +f 94/99/10 95/117/10 86/88/10 85/87/10 +f 82/86/3 92/96/3 94/99/3 85/87/3 +f 111/115/10 112/114/10 103/105/10 102/104/10 +f 100/100/3 110/118/3 111/115/3 102/104/3 +f 86/88/10 91/116/10 89/93/10 84/91/10 +f 81/90/9 84/91/9 89/93/9 87/92/9 +f 109/110/10 113/112/10 111/115/10 108/111/10 +f 105/108/9 108/111/9 111/115/9 110/118/9 +f 105/108/10 110/118/10 100/100/10 97/119/10 +f 86/88/9 97/119/9 100/100/9 91/116/9 +f 86/88/3 95/117/3 105/108/3 97/119/3 +f 115/120/9 117/121/9 118/122/9 116/123/9 +f 121/124/9 124/125/9 125/126/9 122/127/9 +f 123/128/4 124/125/4 121/124/4 120/129/4 +f 129/130/4 130/131/4 127/132/4 126/133/4 +f 133/134/10 134/135/10 132/136/10 131/137/10 +f 133/134/9 135/138/9 136/139/9 134/135/9 +f 141/140/10 142/141/10 140/142/10 139/143/10 +f 145/144/10 146/145/10 144/146/10 143/147/10 +f 147/148/4 148/149/4 145/144/4 143/147/4 +f 117/121/9 131/137/9 132/136/9 118/122/9 +f 132/136/4 134/135/4 119/150/4 118/122/4 +f 128/151/9 143/147/9 144/146/9 129/130/9 +f 144/146/4 146/145/4 130/131/4 129/130/4 +f 139/143/10 140/142/10 136/139/10 135/138/10 +f 136/139/4 140/142/4 138/152/4 134/135/4 +f 128/151/10 129/130/10 125/126/10 124/125/10 +f 125/126/4 129/130/4 126/133/4 122/127/4 +f 119/150/10 124/125/10 123/128/10 118/122/10 +f 116/123/9 118/122/9 123/128/9 120/129/9 +f 142/141/10 148/149/10 147/148/10 140/142/10 +f 138/152/9 140/142/9 147/148/9 143/147/9 +f 138/152/10 143/147/10 137/153/10 134/135/10 +f 119/150/9 134/135/9 137/153/9 124/125/9 +f 137/153/4 143/147/4 128/151/4 124/125/4 +f 35/35/9 82/86/9 83/89/9 36/36/9 +f 34/39/3 36/36/3 83/89/3 81/90/3 +f 40/41/9 87/92/9 88/95/9 41/42/9 +f 45/47/3 46/46/3 93/97/3 92/96/3 +f 34/39/9 81/90/9 87/92/9 40/41/9 +f 35/35/3 45/47/3 92/96/3 82/86/3 +f 68/69/9 115/120/9 116/123/9 69/70/9 +f 74/73/9 121/124/9 122/127/9 75/74/9 +f 120/129/4 121/124/4 74/73/4 73/76/4 +f 126/133/4 127/132/4 80/79/4 79/78/4 +f 69/70/9 116/123/9 120/129/9 73/76/9 +f 122/127/4 126/133/4 79/78/4 75/74/4 +f 24/27/8 25/26/8 351/154/8 350/155/8 +f 21/29/3 349/156/3 351/154/3 25/26/3 +f 44/45/3 354/157/3 355/158/3 46/46/3 +f 28/33/8 29/32/8 353/159/8 352/160/8 +f 21/29/8 28/33/8 352/160/8 349/156/8 +f 24/27/3 350/155/3 354/157/3 44/45/3 +f 93/97/3 431/161/3 432/162/3 96/98/3 +f 433/163/10 434/164/10 107/107/10 106/106/10 +f 107/107/3 434/164/3 435/165/3 109/110/3 +f 436/166/10 437/167/10 114/113/10 113/112/10 +f 435/165/10 436/166/10 113/112/10 109/110/10 +f 96/98/3 432/162/3 433/163/3 106/106/3 +f 56/61/8 57/60/8 357/168/8 356/169/8 +f 63/65/8 64/64/8 360/170/8 359/171/8 +f 63/65/4 359/171/4 358/172/4 60/66/4 +f 80/79/4 362/173/4 361/174/4 78/80/4 +f 57/60/8 60/66/8 358/172/8 357/168/8 +f 78/80/4 361/174/4 360/170/4 64/64/4 +f 440/175/10 441/176/10 142/141/10 141/140/10 +f 130/131/4 439/177/4 438/178/4 127/132/4 +f 442/179/10 443/180/10 146/145/10 145/144/10 +f 148/149/4 444/181/4 442/179/4 145/144/4 +f 441/176/10 444/181/10 148/149/10 142/141/10 +f 146/145/4 443/180/4 439/177/4 130/131/4 +f 17/19/8 49/52/8 51/55/8 19/20/8 +f 15/23/9 19/20/9 51/55/9 47/56/9 +f 27/31/8 54/58/8 56/61/8 29/32/8 +f 38/43/9 41/42/9 68/69/9 65/68/9 +f 15/23/8 47/56/8 54/58/8 27/31/8 +f 17/19/9 38/43/9 65/68/9 49/52/9 +f 88/95/9 90/94/9 117/121/9 115/120/9 +f 101/101/10 133/134/10 131/137/10 99/102/10 +f 101/101/9 103/105/9 135/138/9 133/134/9 +f 114/113/10 141/140/10 139/143/10 112/114/10 +f 112/114/10 139/143/10 135/138/10 103/105/10 +f 90/94/9 99/102/9 131/137/9 117/121/9 +f 29/32/8 56/61/8 356/169/8 353/159/8 +f 437/167/10 440/175/10 141/140/10 114/113/10 +f 41/42/9 88/95/9 115/120/9 68/69/9 +f 46/46/3 355/158/3 431/161/3 93/97/3 +f 127/132/4 438/178/4 362/173/4 80/79/4 +f 284/380/8 286/381/8 287/382/8 285/383/8 +f 282/384/11 283/385/11 287/382/11 286/381/11 +f 290/386/8 292/387/8 293/388/8 291/389/8 +f 288/390/3 291/389/3 293/388/3 289/391/3 +f 294/392/8 296/393/8 297/394/8 295/395/8 +f 300/396/11 301/397/11 304/398/11 303/399/11 +f 299/400/3 302/401/3 304/398/3 301/397/3 +f 305/402/11 306/403/11 309/404/11 308/405/11 +f 310/406/3 313/407/3 314/408/3 312/409/3 +f 284/380/11 285/383/11 306/403/11 305/402/11 +f 284/380/3 305/402/3 307/410/3 286/381/3 +f 290/386/11 291/389/11 311/411/11 310/406/11 +f 290/386/3 310/406/3 312/409/3 292/387/3 +f 300/396/8 310/406/8 311/411/8 301/397/8 +f 300/396/3 303/399/3 313/407/3 310/406/3 +f 282/384/8 294/392/8 295/395/8 283/385/8 +f 282/384/3 286/381/3 298/412/3 294/392/3 +f 288/390/8 289/391/8 296/393/8 294/392/8 +f 288/390/11 294/392/11 298/412/11 291/389/11 +f 299/400/8 301/397/8 307/410/8 305/402/8 +f 299/400/11 305/402/11 308/405/11 302/401/11 +f 281/413/8 291/389/8 298/412/8 286/381/8 +f 281/413/11 286/381/11 307/410/11 301/397/11 +f 281/413/3 301/397/3 311/411/3 291/389/3 +f 317/414/8 319/415/8 320/416/8 318/417/8 +f 315/418/11 316/419/11 320/416/11 319/415/11 +f 322/420/8 324/421/8 325/422/8 323/423/8 +f 329/424/8 331/425/8 332/426/8 330/427/8 +f 329/424/4 327/428/4 328/429/4 331/425/4 +f 333/430/11 334/431/11 337/432/11 336/433/11 +f 339/434/11 340/435/11 343/436/11 342/437/11 +f 341/438/4 338/439/4 339/434/4 342/437/4 +f 347/440/4 345/441/4 346/442/4 348/443/4 +f 317/414/11 318/417/11 334/431/11 333/430/11 +f 334/431/4 318/417/4 320/416/4 335/444/4 +f 329/424/11 330/427/11 345/441/11 344/445/11 +f 345/441/4 330/427/4 332/426/4 346/442/4 +f 315/418/8 322/420/8 323/423/8 316/419/8 +f 320/416/4 316/419/4 323/423/4 326/446/4 +f 339/434/8 344/445/8 345/441/8 340/435/8 +f 343/436/4 340/435/4 345/441/4 347/440/4 +f 323/423/8 325/422/8 328/429/8 327/428/8 +f 323/423/11 327/428/11 329/424/11 326/446/11 +f 334/431/8 335/444/8 339/434/8 338/439/8 +f 334/431/11 338/439/11 341/438/11 337/432/11 +f 320/416/8 326/446/8 329/424/8 321/447/8 +f 320/416/11 321/447/11 339/434/11 335/444/11 +f 339/434/4 321/447/4 329/424/4 344/445/4 +f 364/448/11 365/449/11 368/450/11 367/451/11 +f 363/452/3 366/453/3 368/450/3 365/449/3 +f 369/454/11 370/455/11 372/456/11 371/457/11 +f 374/458/3 376/459/3 378/460/3 375/461/3 +f 382/462/10 380/463/10 381/464/10 383/465/10 +f 382/462/11 383/465/11 385/466/11 384/467/11 +f 388/468/10 386/469/10 387/470/10 389/471/10 +f 387/470/3 390/472/3 391/473/3 389/471/3 +f 395/474/10 393/475/10 394/476/10 396/477/10 +f 371/457/11 372/456/11 381/464/11 380/463/11 +f 371/457/3 380/463/3 382/462/3 373/478/3 +f 376/459/11 377/479/11 387/470/11 386/469/11 +f 376/459/3 386/469/3 388/468/3 378/460/3 +f 376/459/10 367/451/10 368/450/10 377/479/10 +f 364/448/3 367/451/3 376/459/3 374/458/3 +f 393/475/10 384/467/10 385/466/10 394/476/10 +f 382/462/3 384/467/3 393/475/3 392/480/3 +f 368/450/10 366/453/10 371/457/10 373/478/10 +f 363/452/11 369/454/11 371/457/11 366/453/11 +f 391/473/10 390/472/10 393/475/10 395/474/10 +f 387/470/11 392/480/11 393/475/11 390/472/11 +f 387/470/10 379/481/10 382/462/10 392/480/10 +f 368/450/11 373/478/11 382/462/11 379/481/11 +f 368/450/3 379/481/3 387/470/3 377/479/3 +f 397/482/11 398/483/11 400/484/11 399/485/11 +f 403/486/11 404/487/11 407/488/11 406/489/11 +f 405/490/4 402/491/4 403/486/4 406/489/4 +f 411/492/4 408/493/4 409/494/4 412/495/4 +f 415/496/10 413/497/10 414/498/10 416/499/10 +f 415/496/11 416/499/11 418/500/11 417/501/11 +f 423/502/10 421/503/10 422/504/10 424/505/10 +f 427/506/10 425/507/10 426/508/10 428/509/10 +f 429/510/4 425/507/4 427/506/4 430/511/4 +f 399/485/11 400/484/11 414/498/11 413/497/11 +f 414/498/4 400/484/4 401/512/4 416/499/4 +f 410/513/11 411/492/11 426/508/11 425/507/11 +f 426/508/4 411/492/4 412/495/4 428/509/4 +f 421/503/10 417/501/10 418/500/10 422/504/10 +f 418/500/4 416/499/4 420/514/4 422/504/4 +f 410/513/10 406/489/10 407/488/10 411/492/10 +f 407/488/4 404/487/4 408/493/4 411/492/4 +f 401/512/10 400/484/10 405/490/10 406/489/10 +f 398/483/11 402/491/11 405/490/11 400/484/11 +f 424/505/10 422/504/10 429/510/10 430/511/10 +f 420/514/11 425/507/11 429/510/11 422/504/11 +f 420/514/10 416/499/10 419/515/10 425/507/10 +f 401/512/11 406/489/11 419/515/11 416/499/11 +f 419/515/4 406/489/4 410/513/4 425/507/4 +f 303/399/11 304/398/11 365/449/11 364/448/11 +f 302/401/3 363/452/3 365/449/3 304/398/3 +f 308/405/11 309/404/11 370/455/11 369/454/11 +f 313/407/3 374/458/3 375/461/3 314/408/3 +f 302/401/11 308/405/11 369/454/11 363/452/11 +f 303/399/3 364/448/3 374/458/3 313/407/3 +f 336/433/11 337/432/11 398/483/11 397/482/11 +f 342/437/11 343/436/11 404/487/11 403/486/11 +f 402/491/4 341/438/4 342/437/4 403/486/4 +f 408/493/4 347/440/4 348/443/4 409/494/4 +f 337/432/11 341/438/11 402/491/11 398/483/11 +f 404/487/4 343/436/4 347/440/4 408/493/4 +f 292/387/8 350/516/8 351/517/8 293/388/8 +f 289/391/3 293/388/3 351/517/3 349/518/3 +f 312/409/3 314/408/3 355/519/3 354/520/3 +f 296/393/8 352/521/8 353/522/8 297/394/8 +f 289/391/8 349/518/8 352/521/8 296/393/8 +f 292/387/3 312/409/3 354/520/3 350/516/3 +f 375/461/3 378/460/3 432/523/3 431/524/3 +f 433/525/10 388/468/10 389/471/10 434/526/10 +f 389/471/3 391/473/3 435/527/3 434/526/3 +f 436/528/10 395/474/10 396/477/10 437/529/10 +f 435/527/10 391/473/10 395/474/10 436/528/10 +f 378/460/3 388/468/3 433/525/3 432/523/3 +f 324/421/8 356/530/8 357/531/8 325/422/8 +f 331/425/8 359/532/8 360/533/8 332/426/8 +f 331/425/4 328/429/4 358/534/4 359/532/4 +f 348/443/4 346/442/4 361/535/4 362/536/4 +f 325/422/8 357/531/8 358/534/8 328/429/8 +f 346/442/4 332/426/4 360/533/4 361/535/4 +f 440/537/10 423/502/10 424/505/10 441/538/10 +f 412/495/4 409/494/4 438/539/4 439/540/4 +f 442/541/10 427/506/10 428/509/10 443/542/10 +f 430/511/4 427/506/4 442/541/4 444/543/4 +f 441/538/10 424/505/10 430/511/10 444/543/10 +f 428/509/4 412/495/4 439/540/4 443/542/4 +f 285/383/8 287/382/8 319/415/8 317/414/8 +f 283/385/11 315/418/11 319/415/11 287/382/11 +f 295/395/8 297/394/8 324/421/8 322/420/8 +f 306/403/11 333/430/11 336/433/11 309/404/11 +f 283/385/8 295/395/8 322/420/8 315/418/8 +f 285/383/11 317/414/11 333/430/11 306/403/11 +f 370/455/11 397/482/11 399/485/11 372/456/11 +f 383/465/10 381/464/10 413/497/10 415/496/10 +f 383/465/11 415/496/11 417/501/11 385/466/11 +f 396/477/10 394/476/10 421/503/10 423/502/10 +f 394/476/10 385/466/10 417/501/10 421/503/10 +f 372/456/11 399/485/11 413/497/11 381/464/11 +f 297/394/8 353/522/8 356/530/8 324/421/8 +f 437/529/10 396/477/10 423/502/10 440/537/10 +f 309/404/11 336/433/11 397/482/11 370/455/11 +f 314/408/3 375/461/3 431/524/3 355/519/3 +f 409/494/4 348/443/4 362/536/4 438/539/4 +usemtl Leather +f 6/1/1 7/2/1 8/3/1 5/4/1 +f 10/5/2 9/6/2 8/7/2 7/8/2 +f 11/9/3 10/5/3 7/8/3 6/1/3 +f 9/10/4 12/11/4 5/4/4 8/12/4 +f 12/11/5 11/9/5 6/1/5 5/4/5 +f 1/13/6 2/14/6 11/9/6 12/11/6 +f 4/15/4 1/13/4 12/11/4 9/10/4 +f 2/14/3 3/16/3 10/5/3 11/9/3 +f 3/16/7 4/17/7 9/6/7 10/5/7 +f 149/182/10 151/183/10 152/184/10 150/185/10 +f 153/186/9 149/182/9 150/185/9 154/187/9 +f 151/188/3 149/182/3 153/186/3 155/189/3 +f 156/190/4 154/187/4 150/185/4 152/191/4 +f 157/192/11 159/193/11 160/194/11 158/195/11 +f 167/196/10 157/192/10 158/195/10 168/197/10 +f 165/198/3 167/199/3 161/200/3 163/201/3 +f 166/202/4 168/203/4 158/195/4 160/194/4 +f 163/204/9 161/200/9 162/205/9 164/206/9 +f 164/207/4 162/205/4 168/208/4 166/209/4 +f 159/193/3 157/192/3 167/210/3 165/211/3 +f 161/200/10 167/199/10 168/208/10 162/205/10 +f 165/211/3 171/212/3 170/213/3 159/193/3 +f 160/194/4 169/214/4 172/215/4 166/202/4 +f 159/193/11 170/213/11 169/214/11 160/194/11 +f 173/216/8 174/217/8 176/218/8 175/219/8 +f 177/220/9 178/221/9 174/217/9 173/216/9 +f 175/222/3 179/223/3 177/220/3 173/216/3 +f 180/224/4 176/225/4 174/217/4 178/221/4 +f 181/226/11 182/227/11 184/228/11 183/229/11 +f 191/230/8 192/231/8 182/227/8 181/226/8 +f 189/232/3 187/233/3 185/234/3 191/235/3 +f 190/236/4 184/228/4 182/227/4 192/237/4 +f 187/238/9 188/239/9 186/240/9 185/234/9 +f 188/241/4 190/242/4 192/243/4 186/240/4 +f 183/229/3 189/244/3 191/245/3 181/226/3 +f 185/234/8 186/240/8 192/243/8 191/235/8 +f 189/244/3 183/229/3 170/213/3 171/212/3 +f 184/228/4 190/236/4 172/215/4 169/214/4 +f 183/229/11 184/228/11 169/214/11 170/213/11 +usemtl Metal +f 196/246/10 200/247/10 198/248/10 194/249/10 +f 195/250/10 199/251/10 200/252/10 196/246/10 +f 193/253/10 197/254/10 199/251/10 195/250/10 +f 193/253/10 194/249/10 198/248/10 197/254/10 +f 195/250/4 203/255/4 201/256/4 193/253/4 +f 200/247/4 208/257/4 206/258/4 198/248/4 +f 199/251/11 207/259/11 208/260/11 200/252/11 +f 193/253/11 201/261/11 202/262/11 194/249/11 +f 198/248/9 206/263/9 205/264/9 197/254/9 +f 194/249/3 202/265/3 204/266/3 196/246/3 +f 197/254/3 205/267/3 207/268/3 199/251/3 +f 196/246/9 204/269/9 203/270/9 195/250/9 +f 215/271/10 216/272/10 212/273/10 211/274/10 +f 211/274/9 212/273/9 210/275/9 209/276/9 +f 213/277/3 215/278/3 211/274/3 209/276/3 +f 216/279/4 214/280/4 210/275/4 212/273/4 +f 209/276/8 210/275/8 214/281/8 213/282/8 +f 223/283/11 224/284/11 220/285/11 219/286/11 +f 219/286/10 220/285/10 218/287/10 217/288/10 +f 221/289/3 223/290/3 219/286/3 217/288/3 +f 224/291/4 222/292/4 218/287/4 220/285/4 +f 217/288/9 218/287/9 222/293/9 221/294/9 +f 231/295/8 227/296/8 228/297/8 232/298/8 +f 227/296/9 225/299/9 226/300/9 228/297/9 +f 229/301/3 225/299/3 227/296/3 231/302/3 +f 232/303/4 228/297/4 226/300/4 230/304/4 +f 225/299/10 229/305/10 230/306/10 226/300/10 +f 239/307/11 235/308/11 236/309/11 240/310/11 +f 235/308/8 233/311/8 234/312/8 236/309/8 +f 237/313/3 233/311/3 235/308/3 239/314/3 +f 240/315/4 236/309/4 234/312/4 238/316/4 +f 233/311/9 237/317/9 238/318/9 234/312/9 +f 244/319/11 248/320/11 246/321/11 242/322/11 +f 243/323/11 247/324/11 248/325/11 244/326/11 +f 241/327/11 245/328/11 247/324/11 243/323/11 +f 241/327/11 242/322/11 246/321/11 245/328/11 +f 243/323/4 251/329/4 249/330/4 241/327/4 +f 248/320/4 256/331/4 254/332/4 246/321/4 +f 247/324/8 255/333/8 256/334/8 248/325/8 +f 241/327/8 249/335/8 250/336/8 242/322/8 +f 246/321/10 254/337/10 253/338/10 245/328/10 +f 242/322/3 250/339/3 252/340/3 244/319/3 +f 245/328/3 253/341/3 255/342/3 247/324/3 +f 244/326/10 252/343/10 251/344/10 243/323/10 +f 257/345/12 258/346/12 260/347/12 259/348/12 +f 257/345/4 261/349/4 262/350/4 258/346/4 +f 260/347/3 264/351/3 263/352/3 259/348/3 +f 257/345/13 259/348/13 263/353/13 261/354/13 +f 268/355/8 266/356/8 270/357/8 272/358/8 +f 267/359/8 268/355/8 272/360/8 271/361/8 +f 265/362/8 267/359/8 271/361/8 269/363/8 +f 265/362/8 269/363/8 270/357/8 266/356/8 +f 267/359/4 265/362/4 273/364/4 275/365/4 +f 272/358/4 270/357/4 278/366/4 280/367/4 +f 271/361/11 272/360/11 280/368/11 279/369/11 +f 265/362/11 266/356/11 274/370/11 273/371/11 +f 270/357/9 269/363/9 277/372/9 278/373/9 +f 266/356/3 268/355/3 276/374/3 274/375/3 +f 269/363/3 271/361/3 279/376/3 277/377/3 +f 268/355/9 267/359/9 275/378/9 276/379/9 diff --git a/src/main/resources/assets/tiedup/models/obj/exemple/texture.png b/src/main/resources/assets/tiedup/models/obj/exemple/texture.png new file mode 100644 index 0000000..e44b745 Binary files /dev/null and b/src/main/resources/assets/tiedup/models/obj/exemple/texture.png differ diff --git a/src/main/resources/assets/tiedup/models/obj/shared/white.png b/src/main/resources/assets/tiedup/models/obj/shared/white.png new file mode 100644 index 0000000..146086a Binary files /dev/null and b/src/main/resources/assets/tiedup/models/obj/shared/white.png differ diff --git a/src/main/resources/assets/tiedup/names/female_names.txt b/src/main/resources/assets/tiedup/names/female_names.txt new file mode 100644 index 0000000..b249459 --- /dev/null +++ b/src/main/resources/assets/tiedup/names/female_names.txt @@ -0,0 +1,5001 @@ +Abagael +Abagail +Abbe +Abbey +Abbi +Abbie +Abby +Abigael +Abigail +Abigale +Abra +Acacia +Ada +Adah +Adaline +Adara +Addie +Addis +Adel +Adela +Adelaide +Adele +Adelice +Adelina +Adelind +Adeline +Adella +Adelle +Adena +Adey +Adi +Adiana +Adina +Adora +Adore +Adoree +Adorne +Adrea +Adria +Adriaens +Adrian +Adriana +Adriane +Adrianna +Adrianne +Adrien +Adriena +Adrienne +Aeriel +Aeriela +Aeriell +Ag +Agace +Agata +Agatha +Agathe +Aggi +Aggie +Aggy +Agna +Agnella +Agnes +Agnese +Agnesse +Agneta +Agnola +Agretha +Aida +Aidan +Aigneis +Aila +Aile +Ailee +Aileen +Ailene +Ailey +Aili +Ailina +Ailyn +Aime +Aimee +Aimil +Aina +Aindrea +Ainslee +Ainsley +Ainslie +Ajay +Alaine +Alameda +Alana +Alanah +Alane +Alanna +Alayne +Alberta +Albertina +Albertine +Albina +Alecia +Aleda +Aleece +Aleecia +Aleen +Alejandra +Alejandrina +Alena +Alene +Alessandra +Aleta +Alethea +Alex +Alexa +Alexandra +Alexandrina +Alexi +Alexia +Alexina +Alexine +Alexis +Alfie +Alfreda +Ali +Alia +Alica +Alice +Alicea +Alicia +Alida +Alidia +Alina +Aline +Alis +Alisa +Alisha +Alison +Alissa +Alisun +Alix +Aliza +Alla +Alleen +Allegra +Allene +Alli +Allianora +Allie +Allina +Allis +Allison +Allissa +Allsun +Ally +Allyce +Allyn +Allys +Allyson +Alma +Almeda +Almeria +Almeta +Almira +Almire +Aloise +Aloisia +Aloysia +Alpa +Alta +Althea +Alvera +Alvina +Alvinia +Alvira +Alyce +Alyda +Alys +Alysa +Alyse +Alysia +Alyson +Alyss +Alyssa +Amabel +Amabelle +Amalea +Amalee +Amaleta +Amalia +Amalie +Amalita +Amalle +Amanda +Amandi +Amandie +Amandy +Amara +Amargo +Amata +Amber +Amberly +Ambrosia +Ambur +Ame +Amelia +Amelie +Amelina +Ameline +Amelita +Ami +Amie +Amity +Ammamaria +Amy +Ana +Anabel +Anabella +Anabelle +Anais +Analiese +Analise +Anallese +Anallise +Anastasia +Anastasie +Anastassia +Anatola +Andee +Andi +Andie +Andra +Andrea +Andreana +Andree +Andrei +Andria +Andriana +Andriette +Andromache +Andromeda +Andy +Anestassia +Anet +Anett +Anetta +Anette +Ange +Angel +Angela +Angele +Angelia +Angelica +Angelika +Angelina +Angeline +Angelique +Angelita +Angelle +Angie +Angil +Angy +Ania +Anica +Anissa +Anita +Anitra +Anja +Anjanette +Anjela +Ann +Ann-Mari +Ann-Marie +Anna +Anna-Diana +Anna-Diane +Anna-Maria +Annabal +Annabel +Annabela +Annabell +Annabella +Annabelle +Annadiana +Annadiane +Annalee +Annalena +Annaliese +Annalisa +Annalise +Annalyse +Annamari +Annamaria +Annamarie +Anne +Anne-Corinne +Anne-Mar +Anne-Marie +Annecorinne +Anneliese +Annelise +Annemarie +Annetta +Annette +Anni +Annice +Annie +Annissa +Annmaria +Annmarie +Annnora +Annora +Anny +Anselma +Ansley +Anstice +Anthe +Anthea +Anthia +Antoinette +Antonella +Antonetta +Antonia +Antonie +Antonietta +Antonina +Anya +Aphrodite +Appolonia +April +Aprilette +Ara +Arabel +Arabela +Arabele +Arabella +Arabelle +Arda +Ardath +Ardeen +Ardelia +Ardelis +Ardella +Ardelle +Arden +Ardene +Ardenia +Ardine +Ardis +Ardith +Ardra +Ardyce +Ardys +Ardyth +Aretha +Ariadne +Ariana +Arianne +Aridatha +Ariel +Ariela +Ariella +Arielle +Arlana +Arlee +Arleen +Arlen +Arlena +Arlene +Arleta +Arlette +Arleyne +Arlie +Arliene +Arlina +Arlinda +Arline +Arly +Arlyn +Arlyne +Aryn +Ashely +Ashlee +Ashleigh +Ashlen +Ashley +Ashli +Ashlie +Ashly +Asia +Astra +Astrid +Astrix +Atalanta +Athena +Athene +Atlanta +Atlante +Auberta +Aubine +Aubree +Aubrette +Aubrey +Aubrie +Aubry +Audi +Audie +Audra +Audre +Audrey +Audrie +Audry +Audrye +Audy +Augusta +Auguste +Augustina +Augustine +Aura +Aurea +Aurel +Aurelea +Aurelia +Aurelie +Auria +Aurie +Aurilia +Aurlie +Auroora +Aurora +Aurore +Austin +Austina +Austine +Ava +Aveline +Averil +Averyl +Avie +Avis +Aviva +Avivah +Avril +Avrit +Ayn +Bab +Babara +Babette +Babita +Babs +Bambi +Bambie +Bamby +Barb +Barbabra +Barbara +Barbara-Anne +Barbaraanne +Barbe +Barbee +Barbette +Barbey +Barbi +Barbie +Barbra +Barby +Bari +Barrie +Barry +Basia +Bathsheba +Batsheva +Bea +Beatrice +Beatrisa +Beatrix +Beatriz +Beau +Bebe +Becca +Becka +Becki +Beckie +Becky +Bee +Beilul +Beitris +Bekki +Bel +Belia +Belicia +Belinda +Belita +Bell +Bella +Bellamy +Bellanca +Belle +Bellina +Belva +Belvia +Bendite +Benedetta +Benedicta +Benedikta +Benetta +Benita +Benni +Bennie +Benny +Benoite +Berenice +Beret +Berget +Berna +Bernadene +Bernadette +Bernadina +Bernadine +Bernardina +Bernardine +Bernelle +Bernete +Bernetta +Bernette +Berni +Bernice +Bernie +Bernita +Berny +Berri +Berrie +Berry +Bert +Berta +Berte +Bertha +Berthe +Berti +Bertie +Bertina +Bertine +Berty +Beryl +Beryle +Bess +Bessie +Bessy +Beth +Bethanne +Bethany +Bethena +Bethina +Betsey +Betsy +Betta +Bette +Bette-Ann +Betteann +Betteanne +Betti +Bettie +Bettina +Bettine +Betty +Bettye +Beulah +Bev +Beverie +Beverlee +Beverlie +Beverly +Bevvy +Bianca +Bianka +Biddy +Bidget +Bill +Billi +Billie +Billy +Binni +Binnie +Binny +Bird +Birdie +Birgit +Birgitta +Blair +Blaire +Blake +Blakelee +Blakeley +Blanca +Blanch +Blancha +Blanche +Blinni +Blinnie +Blinny +Bliss +Blisse +Blithe +Blondell +Blondelle +Blondie +Blondy +Blythe +Bo +Bobbette +Bobbi +Bobbie +Bobby +Bobette +Bobina +Bobine +Bobinette +Bonita +Bonnee +Bonni +Bonnie +Bonny +Brana +Brandais +Brande +Brandea +Brandi +Brandice +Brandie +Brandise +Brandy +Brea +Breanne +Brear +Bree +Breena +Bren +Brena +Brenda +Brenn +Brenna +Brett +Bria +Briana +Brianna +Brianne +Bride +Bridget +Bridgett +Bridgette +Bridie +Brier +Brietta +Brigid +Brigida +Brigit +Brigitta +Brigitte +Brina +Briney +Briny +Brit +Brita +Britaney +Britani +Briteny +Britney +Britni +Britt +Britta +Brittan +Brittany +Britte +Brittney +Brook +Brooke +Brooks +Brunella +Brunhilda +Brunhilde +Bryana +Bryn +Bryna +Brynn +Brynna +Brynne +Buffy +Bunni +Bunnie +Bunny +Burta +Cabrina +Cacilia +Cacilie +Caitlin +Caitrin +Cal +Calida +Calla +Calley +Calli +Callida +Callie +Cally +Calypso +Cam +Camala +Camel +Camella +Camellia +Cameo +Cami +Camila +Camile +Camilla +Camille +Cammi +Cammie +Cammy +Canada +Candace +Candi +Candice +Candida +Candide +Candie +Candis +Candra +Candy +Cappella +Caprice +Cara +Caralie +Caren +Carena +Caresa +Caressa +Caresse +Carey +Cari +Caria +Carie +Caril +Carilyn +Carin +Carina +Carine +Cariotta +Carissa +Carita +Caritta +Carla +Carlee +Carleen +Carlen +Carlena +Carlene +Carley +Carli +Carlie +Carlin +Carlina +Carline +Carlisle +Carlita +Carlota +Carlotta +Carly +Carlye +Carlyn +Carlynn +Carlynne +Carma +Carmel +Carmela +Carmelia +Carmelina +Carmelita +Carmella +Carmelle +Carmen +Carmina +Carmine +Carmita +Carmon +Caro +Carol +Carol-Jean +Carola +Carolan +Carolann +Carole +Carolee +Caroleen +Carolie +Carolin +Carolina +Caroline +Caroljean +Carolyn +Carolyne +Carolynn +Caron +Carree +Carri +Carrie +Carrissa +Carrol +Carroll +Carry +Cary +Caryl +Caryn +Casandra +Casey +Casi +Casia +Casie +Cass +Cassandra +Cassandre +Cassandry +Cassaundra +Cassey +Cassi +Cassie +Cassondra +Cassy +Cat +Catarina +Cate +Caterina +Catha +Catharina +Catharine +Cathe +Cathee +Catherin +Catherina +Catherine +Cathi +Cathie +Cathleen +Cathlene +Cathrin +Cathrine +Cathryn +Cathy +Cathyleen +Cati +Catie +Catina +Catlaina +Catlee +Catlin +Catrina +Catriona +Caty +Cayla +Cecelia +Cecil +Cecile +Ceciley +Cecilia +Cecilla +Cecily +Ceil +Cele +Celene +Celesta +Celeste +Celestia +Celestina +Celestine +Celestyn +Celestyna +Celia +Celie +Celina +Celinda +Celine +Celinka +Celisse +Celle +Cesya +Chad +Chanda +Chandal +Chandra +Channa +Chantal +Chantalle +Charil +Charin +Charis +Charissa +Charisse +Charita +Charity +Charla +Charlean +Charleen +Charlena +Charlene +Charline +Charlot +Charlott +Charlotta +Charlotte +Charmain +Charmaine +Charmane +Charmian +Charmine +Charmion +Charo +Charyl +Chastity +Chelsae +Chelsea +Chelsey +Chelsie +Chelsy +Cher +Chere +Cherey +Cheri +Cherianne +Cherice +Cherida +Cherie +Cherilyn +Cherilynn +Cherin +Cherise +Cherish +Cherlyn +Cherri +Cherrita +Cherry +Chery +Cherye +Cheryl +Cheslie +Chiarra +Chickie +Chicky +Chiquita +Chloe +Chloette +Chloris +Chris +Chriss +Chrissa +Chrissie +Chrissy +Christa +Christabel +Christabella +Christabelle +Christal +Christalle +Christan +Christean +Christel +Christen +Christi +Christian +Christiana +Christiane +Christie +Christin +Christina +Christine +Christy +Christyna +Chrysa +Chrysler +Chrystal +Chryste +Chrystel +Ciara +Cicely +Cicily +Ciel +Cilka +Cinda +Cindee +Cindelyn +Cinderella +Cindi +Cindie +Cindra +Cindy +Cinnamon +Cissie +Cissy +Clair +Claire +Clara +Clarabelle +Clare +Claresta +Clareta +Claretta +Clarette +Clarey +Clari +Claribel +Clarice +Clarie +Clarinda +Clarine +Clarisa +Clarissa +Clarisse +Clarita +Clary +Claude +Claudelle +Claudetta +Claudette +Claudia +Claudie +Claudina +Claudine +Clea +Clem +Clemence +Clementia +Clementina +Clementine +Clemmie +Clemmy +Cleo +Cleopatra +Clerissa +Cleva +Clio +Clo +Cloe +Cloris +Clotilda +Clovis +Codee +Codi +Codie +Cody +Coleen +Colene +Coletta +Colette +Colleen +Collete +Collette +Collie +Colline +Colly +Con +Concettina +Conchita +Concordia +Conney +Conni +Connie +Conny +Consolata +Constance +Constancia +Constancy +Constanta +Constantia +Constantina +Constantine +Consuela +Consuelo +Cookie +Cora +Corabel +Corabella +Corabelle +Coral +Coralie +Coraline +Coralyn +Cordelia +Cordelie +Cordey +Cordie +Cordula +Cordy +Coreen +Corella +Corena +Corenda +Corene +Coretta +Corette +Corey +Cori +Corie +Corilla +Corina +Corine +Corinna +Corinne +Coriss +Corissa +Corliss +Corly +Cornela +Cornelia +Cornelle +Cornie +Corny +Correna +Correy +Corri +Corrianne +Corrie +Corrina +Corrine +Corrinne +Corry +Cortney +Cory +Cosetta +Cosette +Courtenay +Courtney +Cresa +Cris +Crissie +Crissy +Crista +Cristabel +Cristal +Cristen +Cristi +Cristie +Cristin +Cristina +Cristine +Cristionna +Cristy +Crysta +Crystal +Crystie +Cyb +Cybal +Cybel +Cybelle +Cybil +Cybill +Cyndi +Cyndy +Cynthea +Cynthia +Cynthie +Cynthy +Dacey +Dacia +Dacie +Dacy +Dael +Daffi +Daffie +Daffy +Dafna +Dagmar +Dahlia +Daile +Daisey +Daisi +Daisie +Daisy +Dale +Dalenna +Dalia +Dalila +Dallas +Daloris +Damara +Damaris +Damita +Dana +Danell +Danella +Danelle +Danette +Dani +Dania +Danica +Danice +Daniel +Daniela +Daniele +Daniella +Danielle +Danika +Danila +Danit +Danita +Danna +Danni +Dannie +Danny +Dannye +Danya +Danyelle +Danyette +Daphene +Daphna +Daphne +Dara +Darb +Darbie +Darby +Darcee +Darcey +Darci +Darcie +Darcy +Darda +Dareen +Darell +Darelle +Dari +Daria +Darice +Darla +Darleen +Darlene +Darline +Darryl +Darsey +Darsie +Darya +Daryl +Daryn +Dasha +Dasi +Dasie +Dasya +Datha +Daune +Daveen +Daveta +Davida +Davina +Davine +Davita +Dawn +Dawna +Dayle +Dayna +Dea +Deana +Deane +Deanna +Deanne +Deb +Debbi +Debbie +Debbra +Debby +Debee +Debera +Debi +Debor +Debora +Deborah +Debra +Dede +Dedie +Dedra +Dee +Dee Dee +Deeann +Deeanne +Deedee +Deena +Deerdre +Dehlia +Deidre +Deina +Deirdre +Del +Dela +Delaney +Delcina +Delcine +Delia +Delila +Delilah +Delinda +Dell +Della +Delly +Delora +Delores +Deloria +Deloris +Delphina +Delphine +Delphinia +Demeter +Demetra +Demetria +Demetris +Dena +Deni +Denice +Denise +Denna +Denni +Dennie +Denny +Deny +Denys +Denyse +Deonne +Desaree +Desdemona +Desirae +Desiree +Desiri +Deva +Devan +Devi +Devin +Devina +Devinne +Devon +Devondra +Devonna +Devonne +Devora +Dew +Di +Diahann +Diamond +Dian +Diana +Diandra +Diane +Diane-Marie +Dianemarie +Diann +Dianna +Dianne +Diannne +Didi +Dido +Diena +Dierdre +Dina +Dinah +Dinnie +Dinny +Dion +Dione +Dionis +Dionne +Dita +Dix +Dixie +Dode +Dodi +Dodie +Dody +Doe +Doll +Dolley +Dolli +Dollie +Dolly +Dolora +Dolores +Dolorita +Doloritas +Dominica +Dominique +Dona +Donella +Donelle +Donetta +Donia +Donica +Donielle +Donna +Donnajean +Donnamarie +Donni +Donnie +Donny +Dora +Doralia +Doralin +Doralyn +Doralynn +Doralynne +Dorcas +Dore +Doreen +Dorelia +Dorella +Dorelle +Dorena +Dorene +Doretta +Dorette +Dorey +Dori +Doria +Dorian +Dorice +Dorie +Dorine +Doris +Dorisa +Dorise +Dorit +Dorita +Doro +Dorolice +Dorolisa +Dorotea +Doroteya +Dorothea +Dorothee +Dorothy +Dorree +Dorri +Dorrie +Dorris +Dorry +Dorthea +Dorthy +Dory +Dosi +Dot +Doti +Dotti +Dottie +Dotty +Dove +Drea +Drew +Dulce +Dulcea +Dulci +Dulcia +Dulciana +Dulcie +Dulcine +Dulcinea +Dulcy +Dulsea +Dusty +Dyan +Dyana +Dyane +Dyann +Dyanna +Dyanne +Dyna +Dynah +E'Lane +Eada +Eadie +Eadith +Ealasaid +Eartha +Easter +Eba +Ebba +Ebonee +Ebony +Eda +Eddi +Eddie +Eddy +Ede +Edee +Edeline +Eden +Edi +Edie +Edin +Edita +Edith +Editha +Edithe +Ediva +Edna +Edwina +Edy +Edyth +Edythe +Effie +Eileen +Eilis +Eimile +Eirena +Ekaterina +Elaina +Elaine +Elana +Elane +Elayne +Elberta +Elbertina +Elbertine +Eleanor +Eleanora +Eleanore +Electra +Elena +Elene +Eleni +Elenore +Eleonora +Eleonore +Elfie +Elfreda +Elfrida +Elfrieda +Elga +Elianora +Elianore +Elicia +Elie +Elinor +Elinore +Elisa +Elisabet +Elisabeth +Elisabetta +Elise +Elisha +Elissa +Elita +Eliza +Elizabet +Elizabeth +Elka +Elke +Ella +Elladine +Elle +Ellen +Ellene +Ellette +Elli +Ellie +Ellissa +Elly +Ellyn +Ellynn +Elmira +Elna +Elnora +Elnore +Eloisa +Eloise +Elonore +Elora +Elsa +Elsbeth +Else +Elsey +Elsi +Elsie +Elsinore +Elspeth +Elsy +Elva +Elvera +Elvina +Elvira +Elwina +Elwira +Elyn +Elyse +Elysee +Elysha +Elysia +Elyssa +Em +Ema +Emalee +Emalia +Emanuela +Emelda +Emelia +Emelina +Emeline +Emelita +Emelyne +Emera +Emilee +Emili +Emilia +Emilie +Emiline +Emily +Emlyn +Emlynn +Emlynne +Emma +Emmalee +Emmaline +Emmalyn +Emmalynn +Emmalynne +Emmeline +Emmey +Emmi +Emmie +Emmy +Emmye +Emogene +Emyle +Emylee +Endora +Engracia +Enid +Enrica +Enrichetta +Enrika +Enriqueta +Enya +Eolanda +Eolande +Eran +Erda +Erena +Erica +Ericha +Ericka +Erika +Erin +Erina +Erinn +Erinna +Erma +Ermengarde +Ermentrude +Ermina +Erminia +Erminie +Erna +Ernaline +Ernesta +Ernestine +Ertha +Eryn +Esma +Esmaria +Esme +Esmeralda +Esmerelda +Essa +Essie +Essy +Esta +Estel +Estele +Estell +Estella +Estelle +Ester +Esther +Estrella +Estrellita +Ethel +Ethelda +Ethelin +Ethelind +Etheline +Ethelyn +Ethyl +Etta +Etti +Ettie +Etty +Eudora +Eugenia +Eugenie +Eugine +Eula +Eulalie +Eunice +Euphemia +Eustacia +Eva +Evaleen +Evangelia +Evangelin +Evangelina +Evangeline +Evania +Evanne +Eve +Eveleen +Evelina +Eveline +Evelyn +Evette +Evey +Evie +Evita +Evonne +Evvie +Evvy +Evy +Eyde +Eydie +Fabrianne +Fabrice +Fae +Faina +Faith +Fallon +Fan +Fanchette +Fanchon +Fancie +Fancy +Fanechka +Fania +Fanni +Fannie +Fanny +Fanya +Fara +Farah +Farand +Farica +Farra +Farrah +Farrand +Fatima +Faun +Faunie +Faustina +Faustine +Fawn +Fawna +Fawne +Fawnia +Fay +Faydra +Faye +Fayette +Fayina +Fayre +Fayth +Faythe +Federica +Fedora +Felecia +Felicdad +Felice +Felicia +Felicity +Felicle +Felipa +Felisha +Felita +Feliza +Fenelia +Feodora +Ferdinanda +Ferdinande +Fern +Fernanda +Fernande +Fernandina +Ferne +Fey +Fiann +Fianna +Fidela +Fidelia +Fidelity +Fifi +Fifine +Filia +Filide +Filippa +Fina +Fiona +Fionna +Fionnula +Fiorenze +Fleur +Fleurette +Flo +Flor +Flora +Florance +Flore +Florella +Florence +Florencia +Florentia +Florenza +Florette +Flori +Floria +Florice +Florida +Florie +Florina +Florinda +Floris +Florri +Florrie +Florry +Flory +Flossi +Flossie +Flossy +Flower +Fortuna +Fortune +Fran +France +Francene +Frances +Francesca +Francesmary +Francine +Francis +Francisca +Franciska +Francoise +Francyne +Frank +Frankie +Franky +Franni +Frannie +Franny +Frayda +Fred +Freda +Freddi +Freddie +Freddy +Fredelia +Frederica +Fredericka +Fredi +Fredia +Fredra +Fredrika +Freida +Frieda +Friederike +Fulvia +Gabbey +Gabbi +Gabbie +Gabey +Gabi +Gabie +Gabriel +Gabriela +Gabriell +Gabriella +Gabrielle +Gabriellia +Gabrila +Gaby +Gae +Gael +Gail +Gale +Gale +Galina +Garland +Garnet +Garnette +Gates +Gavra +Gavrielle +Gay +Gayla +Gayle +Gayleen +Gaylene +Gaynor +Gelya +Gen +Gena +Gene +Geneva +Genevieve +Genevra +Genia +Genna +Genni +Gennie +Gennifer +Genny +Genovera +Genvieve +George +Georgeanna +Georgeanne +Georgena +Georgeta +Georgetta +Georgette +Georgia +Georgiamay +Georgiana +Georgianna +Georgianne +Georgie +Georgina +Georgine +Gera +Geralda +Geraldina +Geraldine +Gerda +Gerhardine +Geri +Gerianna +Gerianne +Gerladina +Germain +Germaine +Germana +Gerri +Gerrie +Gerrilee +Gerry +Gert +Gerta +Gerti +Gertie +Gertrud +Gertruda +Gertrude +Gertrudis +Gerty +Giacinta +Giana +Gianina +Gianna +Gigi +Gilberta +Gilberte +Gilbertina +Gilbertine +Gilda +Gill +Gillan +Gilli +Gillian +Gillie +Gilligan +Gilly +Gina +Ginelle +Ginevra +Ginger +Ginni +Ginnie +Ginnifer +Ginny +Giorgia +Giovanna +Gipsy +Giralda +Gisela +Gisele +Gisella +Giselle +Gizela +Glad +Gladi +Gladis +Gladys +Gleda +Glen +Glenda +Glenine +Glenn +Glenna +Glennie +Glennis +Glori +Gloria +Gloriana +Gloriane +Glorianna +Glory +Glyn +Glynda +Glynis +Glynnis +Godiva +Golda +Goldarina +Goldi +Goldia +Goldie +Goldina +Goldy +Grace +Gracia +Gracie +Grata +Gratia +Gratiana +Gray +Grayce +Grazia +Gredel +Greer +Greta +Gretal +Gretchen +Grete +Gretel +Grethel +Gretna +Gretta +Grier +Griselda +Grissel +Guendolen +Guenevere +Guenna +Guglielma +Gui +Guillema +Guillemette +Guinevere +Guinna +Gunilla +Gunvor +Gus +Gusella +Gussi +Gussie +Gussy +Gusta +Gusti +Gustie +Gusty +Gwen +Gwendolen +Gwendolin +Gwendolyn +Gweneth +Gwenette +Gwenn +Gwenneth +Gwenni +Gwennie +Gwenny +Gwenora +Gwenore +Gwyn +Gwyneth +Gwynne +Gypsy +Hadria +Hailee +Haily +Haleigh +Halette +Haley +Hali +Halie +Halimeda +Halley +Halli +Hallie +Hally +Hana +Hanna +Hannah +Hanni +Hannibal +Hannie +Hannis +Hanny +Happy +Harlene +Harley +Harli +Harlie +Harmonia +Harmonie +Harmony +Harri +Harrie +Harriet +Harriett +Harrietta +Harriette +Harriot +Harriott +Hatti +Hattie +Hatty +Havivah +Hayley +Hazel +Heath +Heather +Heda +Hedda +Heddi +Heddie +Hedi +Hedvig +Hedwig +Hedy +Heida +Heide +Heidi +Heidie +Helaina +Helaine +Helen +Helen-Elizabeth +Helena +Helene +Helga +Helge +Helise +Hellene +Helli +Heloise +Helsa +Helyn +Hendrika +Henka +Henrie +Henrieta +Henrietta +Henriette +Henryetta +Hephzibah +Hermia +Hermina +Hermine +Herminia +Hermione +Herta +Hertha +Hester +Hesther +Hestia +Hetti +Hettie +Hetty +Hilarie +Hilary +Hilda +Hildagard +Hildagarde +Hilde +Hildegaard +Hildegarde +Hildy +Hillary +Hilliary +Hinda +Holley +Holli +Hollie +Holly +Holly-Anne +Hollyanne +Honey +Honor +Honoria +Hope +Horatia +Hortense +Hortensia +Hulda +Hyacinth +Hyacintha +Hyacinthe +Hyacinthia +Hyacinthie +Hynda +Ianthe +Ibbie +Ibby +Ida +Idalia +Idalina +Idaline +Idell +Idelle +Idette +Ike +Ikey +Ilana +Ileana +Ileane +Ilene +Ilise +Ilka +Illa +Ilona +Ilsa +Ilse +Ilysa +Ilyse +Ilyssa +Imelda +Imogen +Imogene +Imojean +Ina +Inci +Indira +Ines +Inesita +Inessa +Inez +Inga +Ingaberg +Ingaborg +Inge +Ingeberg +Ingeborg +Inger +Ingrid +Ingunna +Inna +Ioana +Iolande +Iolanthe +Iona +Iormina +Ira +Irena +Irene +Irina +Iris +Irita +Irma +Isa +Isabeau +Isabel +Isabelita +Isabella +Isabelle +Isador +Isadora +Isadore +Isahella +Iseabal +Isidora +Isis +Isobel +Issi +Issie +Issy +Ivett +Ivette +Ivie +Ivonne +Ivory +Ivy +Izabel +Izzi +Jacenta +Jacinda +Jacinta +Jacintha +Jacinthe +Jackelyn +Jacki +Jackie +Jacklin +Jacklyn +Jackquelin +Jackqueline +Jacky +Jaclin +Jaclyn +Jacquelin +Jacqueline +Jacquelyn +Jacquelynn +Jacquenetta +Jacquenette +Jacquetta +Jacquette +Jacqui +Jacquie +Jacynth +Jada +Jade +Jaime +Jaimie +Jaine +Jaleh +Jami +Jamie +Jamima +Jammie +Jan +Jana +Janaya +Janaye +Jandy +Jane +Janean +Janeczka +Janeen +Janel +Janela +Janella +Janelle +Janene +Janenna +Janessa +Janet +Janeta +Janetta +Janette +Janeva +Janey +Jania +Janice +Janie +Janifer +Janina +Janine +Janis +Janith +Janka +Janna +Jannel +Jannelle +Janot +Jany +Jaquelin +Jaquelyn +Jaquenetta +Jaquenette +Jaquith +Jasmin +Jasmina +Jasmine +Jayme +Jaymee +Jayne +Jaynell +Jazmin +Jean +Jeana +Jeane +Jeanelle +Jeanette +Jeanie +Jeanine +Jeanna +Jeanne +Jeannette +Jeannie +Jeannine +Jehanna +Jelene +Jemie +Jemima +Jemimah +Jemmie +Jemmy +Jen +Jena +Jenda +Jenelle +Jenette +Jeni +Jenica +Jeniece +Jenifer +Jeniffer +Jenilee +Jenine +Jenn +Jenna +Jennee +Jennette +Jenni +Jennica +Jennie +Jennifer +Jennilee +Jennine +Jenny +Jeraldine +Jeralee +Jere +Jeri +Jermaine +Jerrie +Jerrilee +Jerrilyn +Jerrine +Jerry +Jerrylee +Jess +Jessa +Jessalin +Jessalyn +Jessamine +Jessamyn +Jesse +Jesselyn +Jessi +Jessica +Jessie +Jessika +Jessy +Jewel +Jewell +Jewelle +Jill +Jillana +Jillane +Jillayne +Jilleen +Jillene +Jilli +Jillian +Jillie +Jilly +Jinny +Jo +Jo Ann +Jo-Ann +Jo-Anne +JoAnn +JoAnne +Joan +Joana +Joane +Joanie +Joann +Joanna +Joanne +Joannes +Jobey +Jobi +Jobie +Jobina +Joby +Jobye +Jobyna +Jocelin +Joceline +Jocelyn +Jocelyne +Jodee +Jodi +Jodie +Jody +Joela +Joelie +Joell +Joella +Joelle +Joellen +Joelly +Joellyn +Joelynn +Joete +Joey +Johanna +Johannah +Johnette +Johnna +Joice +Jojo +Jolee +Joleen +Jolene +Joletta +Joli +Jolie +Joline +Joly +Jolyn +Jolynn +Jonell +Joni +Jonie +Jonis +Jordain +Jordan +Jordana +Jordanna +Jorey +Jori +Jorie +Jorrie +Jorry +Joscelin +Josee +Josefa +Josefina +Joselyn +Josepha +Josephina +Josephine +Josey +Josi +Josie +Joslyn +Josselyn +Josy +Jourdan +Joy +Joya +Joyan +Joyann +Joyce +Joycelin +Joye +Joyous +Juana +Juanita +Jude +Judi +Judie +Judith +Juditha +Judy +Judye +Julee +Juli +Julia +Juliana +Juliane +Juliann +Julianna +Julianne +Julie +Julienne +Juliet +Julieta +Julietta +Juliette +Julina +Juline +Julissa +Julita +June +Junette +Junia +Junie +Junina +Justin +Justina +Justine +Jyoti +Kacey +Kacie +Kacy +Kai +Kaia +Kaila +Kaile +Kailey +Kaitlin +Kaitlyn +Kaitlynn +Kaja +Kakalina +Kala +Kaleena +Kali +Kalie +Kalila +Kalina +Kalinda +Kalindi +Kalli +Kally +Kameko +Kamila +Kamilah +Kamillah +Kandace +Kandy +Kania +Kanya +Kara +Kara-Lynn +Karalee +Karalynn +Kare +Karee +Karel +Karen +Karena +Kari +Karia +Karie +Karil +Karilynn +Karin +Karina +Karine +Kariotta +Karisa +Karissa +Karita +Karla +Karlee +Karleen +Karlen +Karlene +Karlie +Karlotta +Karlotte +Karly +Karlyn +Karmen +Karna +Karol +Karola +Karole +Karolina +Karoline +Karoly +Karon +Karrah +Karrie +Karry +Kary +Karyl +Karylin +Karyn +Kasey +Kass +Kassandra +Kassey +Kassi +Kassia +Kassie +Kaster +Kat +Kata +Katalin +Kate +Katee +Katerina +Katerine +Katey +Kath +Katha +Katharina +Katharine +Katharyn +Kathe +Katheleen +Katherina +Katherine +Katheryn +Kathi +Kathie +Kathleen +Kathlene +Kathlin +Kathrine +Kathryn +Kathryne +Kathy +Kathye +Kati +Katie +Katina +Katine +Katinka +Katleen +Katlin +Katrina +Katrine +Katrinka +Katti +Kattie +Katuscha +Katusha +Katy +Katya +Kay +Kaycee +Kaye +Kayla +Kayle +Kaylee +Kayley +Kaylil +Kaylyn +Kee +Keeley +Keelia +Keely +Kelcey +Kelci +Kelcie +Kelcy +Kelila +Kellen +Kelley +Kelli +Kellia +Kellie +Kellina +Kellsie +Kelly +Kellyann +Kelsey +Kelsi +Kelsy +Kendra +Kendre +Kenna +Keren +Keri +Keriann +Kerianne +Kerri +Kerrie +Kerrill +Kerrin +Kerry +Kerstin +Kesley +Keslie +Kessia +Kessiah +Ketti +Kettie +Ketty +Kevina +Kevyn +Ki +Kia +Kiah +Kial +Kiele +Kiersten +Kikelia +Kiley +Kim +Kimberlee +Kimberley +Kimberli +Kimberly +Kimberlyn +Kimbra +Kimmi +Kimmie +Kimmy +Kinna +Kip +Kipp +Kippie +Kippy +Kira +Kirbee +Kirbie +Kirby +Kiri +Kirsten +Kirsteni +Kirsti +Kirstie +Kirstin +Kirstyn +Kissee +Kissiah +Kissie +Kit +Kitti +Kittie +Kitty +Kizzee +Kizzie +Klara +Klarika +Klarrisa +Konstance +Konstanze +Koo +Kora +Koral +Koralle +Kordula +Kore +Korella +Koren +Koressa +Kori +Korie +Korney +Korrie +Korry +Kourtney +Kris +Krissie +Krissy +Krista +Kristal +Kristan +Kriste +Kristel +Kristen +Kristi +Kristien +Kristin +Kristina +Kristine +Kristy +Kristyn +Krysta +Krystal +Krystalle +Krystle +Krystyna +Kyla +Kyle +Kylen +Kylie +Kylila +Kylynn +Kym +Kynthia +Kyrstin +La +Lacee +Lacey +Lacie +Lacy +Ladonna +Laetitia +Laila +Laina +Lainey +Lamb +Lana +Lane +Lanette +Laney +Lani +Lanie +Lanita +Lanna +Lanni +Lanny +Lara +Laraine +Lari +Larina +Larine +Larisa +Larissa +Lark +Laryssa +Latashia +Latia +Latisha +Latrena +Latrina +Laura +Lauraine +Laural +Lauralee +Laure +Lauree +Laureen +Laurel +Laurella +Lauren +Laurena +Laurene +Lauretta +Laurette +Lauri +Laurianne +Laurice +Laurie +Lauryn +Lavena +Laverna +Laverne +Lavina +Lavinia +Lavinie +Layla +Layne +Layney +Lea +Leah +Leandra +Leann +Leanna +Leanne +Leanor +Leanora +Lebbie +Leda +Lee +LeeAnn +Leeann +Leeanne +Leela +Leelah +Leena +Leesa +Leese +Legra +Leia +Leiah +Leigh +Leigha +Leila +Leilah +Leisha +Lela +Lelah +Leland +Lelia +Lena +Lenee +Lenette +Lenka +Lenna +Lenora +Lenore +Leodora +Leoine +Leola +Leoline +Leona +Leonanie +Leone +Leonelle +Leonie +Leonora +Leonore +Leontine +Leontyne +Leora +Leorah +Leshia +Lesley +Lesli +Leslie +Lesly +Lesya +Leta +Lethia +Leticia +Letisha +Letitia +Letta +Letti +Lettie +Letty +Leyla +Lezlie +Lia +Lian +Liana +Liane +Lianna +Lianne +Lib +Libbey +Libbi +Libbie +Libby +Licha +Lida +Lidia +Lil +Lila +Lilah +Lilas +Lilia +Lilian +Liliane +Lilias +Lilith +Lilla +Lilli +Lillian +Lillis +Lilllie +Lilly +Lily +Lilyan +Lin +Lina +Lind +Linda +Lindi +Lindie +Lindsay +Lindsey +Lindsy +Lindy +Linea +Linell +Linet +Linette +Linn +Linnea +Linnell +Linnet +Linnie +Linzy +Liora +Liorah +Lira +Lisa +Lisabeth +Lisandra +Lisbeth +Lise +Lisetta +Lisette +Lisha +Lishe +Lissa +Lissi +Lissie +Lissy +Lita +Liuka +Livia +Liz +Liza +Lizabeth +Lizbeth +Lizette +Lizzie +Lizzy +Loella +Lois +Loise +Lola +Lolande +Loleta +Lolita +Lolly +Lona +Lonee +Loni +Lonna +Lonni +Lonnie +Lora +Lorain +Loraine +Loralee +Loralie +Loralyn +Loree +Loreen +Lorelei +Lorelle +Loren +Lorena +Lorene +Lorenza +Loretta +Lorettalorna +Lorette +Lori +Loria +Lorianna +Lorianne +Lorie +Lorilee +Lorilyn +Lorinda +Lorine +Lorita +Lorna +Lorne +Lorraine +Lorrayne +Lorri +Lorrie +Lorrin +Lorry +Lory +Lotta +Lotte +Lotti +Lottie +Lotty +Lou +Louella +Louisa +Louise +Louisette +Love +Luana +Luanna +Luce +Luci +Lucia +Luciana +Lucie +Lucienne +Lucila +Lucilia +Lucille +Lucina +Lucinda +Lucine +Lucita +Lucky +Lucretia +Lucy +Luella +Luelle +Luisa +Luise +Lula +Lulita +Lulu +Luna +Lura +Lurette +Lurleen +Lurlene +Lurline +Lusa +Lust +Lyda +Lydia +Lydie +Lyn +Lynda +Lynde +Lyndel +Lyndell +Lyndsay +Lyndsey +Lyndsie +Lyndy +Lynea +Lynelle +Lynett +Lynette +Lynn +Lynna +Lynne +Lynnea +Lynnell +Lynnelle +Lynnet +Lynnett +Lynnette +Lynsey +Lysandra +Lyssa +Mab +Mabel +Mabelle +Mable +Mada +Madalena +Madalyn +Maddalena +Maddi +Maddie +Maddy +Madel +Madelaine +Madeleine +Madelena +Madelene +Madelin +Madelina +Madeline +Madella +Madelle +Madelon +Madelyn +Madge +Madlen +Madlin +Madona +Madonna +Mady +Mae +Maegan +Mag +Magda +Magdaia +Magdalen +Magdalena +Magdalene +Maggee +Maggi +Maggie +Maggy +Magna +Mahala +Mahalia +Maia +Maible +Maiga +Mair +Maire +Mairead +Maisey +Maisie +Mala +Malanie +Malcah +Malena +Malia +Malina +Malinda +Malinde +Malissa +Malissia +Malka +Malkah +Mallissa +Mallorie +Mallory +Malorie +Malory +Malva +Malvina +Malynda +Mame +Mamie +Manda +Mandi +Mandie +Mandy +Manon +Manya +Mara +Marabel +Marcela +Marcelia +Marcella +Marcelle +Marcellina +Marcelline +Marchelle +Marci +Marcia +Marcie +Marcile +Marcille +Marcy +Mareah +Maren +Marena +Maressa +Marga +Margalit +Margalo +Margaret +Margareta +Margarete +Margaretha +Margarethe +Margaretta +Margarette +Margarita +Margaux +Marge +Margeaux +Margery +Marget +Margette +Margi +Margie +Margit +Marglerite +Margo +Margot +Margret +Marguerite +Margurite +Margy +Mari +Maria +Mariam +Marian +Mariana +Mariann +Marianna +Marianne +Maribel +Maribelle +Maribeth +Marice +Maridel +Marie +Marie-Ann +Marie-Jeanne +Marieann +Mariejeanne +Mariel +Mariele +Marielle +Mariellen +Marietta +Mariette +Marigold +Marijo +Marika +Marilee +Marilin +Marillin +Marilyn +Marin +Marina +Marinna +Marion +Mariquilla +Maris +Marisa +Mariska +Marissa +Marit +Marita +Maritsa +Mariya +Marj +Marja +Marje +Marji +Marjie +Marjorie +Marjory +Marjy +Marketa +Marla +Marlane +Marleah +Marlee +Marleen +Marlena +Marlene +Marley +Marlie +Marline +Marlo +Marlyn +Marna +Marne +Marney +Marni +Marnia +Marnie +Marquita +Marrilee +Marris +Marrissa +Marry +Marsha +Marsiella +Marta +Martelle +Martguerita +Martha +Marthe +Marthena +Marti +Martica +Martie +Martina +Martita +Marty +Martynne +Mary +Marya +Maryangelyn +Maryann +Maryanna +Maryanne +Marybelle +Marybeth +Maryellen +Maryjane +Maryjo +Maryl +Marylee +Marylin +Marylinda +Marylou +Marylynne +Maryrose +Marys +Marysa +Masha +Matelda +Mathilda +Mathilde +Matilda +Matilde +Matti +Mattie +Matty +Maud +Maude +Maudie +Maura +Maure +Maureen +Maureene +Maurene +Maurine +Maurise +Maurita +Mavis +Mavra +Max +Maxi +Maxie +Maxine +Maxy +May +Maya +Maybelle +Mayda +Maye +Mead +Meade +Meagan +Meaghan +Meara +Mechelle +Meg +Megan +Megen +Meggan +Meggi +Meggie +Meggy +Meghan +Meghann +Mehetabel +Mei +Meira +Mel +Mela +Melamie +Melania +Melanie +Melantha +Melany +Melba +Melesa +Melessa +Melicent +Melina +Melinda +Melinde +Melisa +Melisande +Melisandra +Melisenda +Melisent +Melissa +Melisse +Melita +Melitta +Mella +Melli +Mellicent +Mellie +Mellisa +Mellisent +Mellissa +Melloney +Melly +Melodee +Melodie +Melody +Melonie +Melony +Melosa +Melva +Mercedes +Merci +Mercie +Mercy +Meredith +Meredithe +Meridel +Meridith +Meriel +Merilee +Merilyn +Meris +Merissa +Merl +Merla +Merle +Merlina +Merline +Merna +Merola +Merralee +Merridie +Merrie +Merrielle +Merrile +Merrilee +Merrili +Merrill +Merrily +Merry +Mersey +Meryl +Meta +Mia +Micaela +Michaela +Michaelina +Michaeline +Michaella +Michal +Michel +Michele +Michelina +Micheline +Michell +Michelle +Micki +Mickie +Micky +Midge +Mignon +Mignonne +Miguela +Miguelita +Mildred +Mildrid +Milena +Milicent +Milissent +Milka +Milli +Millicent +Millie +Millisent +Milly +Milzie +Mimi +Min +Mina +Minda +Mindy +Minerva +Minetta +Minette +Minna +Minni +Minnie +Minny +Minta +Miquela +Mira +Mirabel +Mirabella +Mirabelle +Miran +Miranda +Mireielle +Mireille +Mirella +Mirelle +Miriam +Mirilla +Mirna +Misha +Missie +Missy +Misti +Misty +Mitra +Mitzi +Mmarianne +Modesta +Modestia +Modestine +Modesty +Moina +Moira +Moll +Mollee +Molli +Mollie +Molly +Mommy +Mona +Monah +Monica +Monika +Monique +Mora +Moreen +Morena +Morgan +Morgana +Morganica +Morganne +Morgen +Moria +Morissa +Morlee +Morna +Moselle +Moya +Moyna +Moyra +Mozelle +Muffin +Mufi +Mufinella +Muire +Mureil +Murial +Muriel +Murielle +Myna +Myra +Myrah +Myranda +Myriam +Myrilla +Myrle +Myrlene +Myrna +Myrta +Myrtia +Myrtice +Myrtie +Myrtle +Nada +Nadean +Nadeen +Nadia +Nadine +Nadiya +Nady +Nadya +Nalani +Nan +Nana +Nananne +Nance +Nancee +Nancey +Nanci +Nancie +Nancy +Nanete +Nanette +Nani +Nanice +Nanine +Nannette +Nanni +Nannie +Nanny +Nanon +Naoma +Naomi +Nara +Nari +Nariko +Nat +Nata +Natala +Natalee +Natalia +Natalie +Natalina +Nataline +Natalya +Natasha +Natassia +Nathalia +Nathalie +Natka +Natty +Neala +Neda +Nedda +Nedi +Neely +Neila +Neile +Neilla +Neille +Nela +Nelia +Nelie +Nell +Nelle +Nelli +Nellie +Nelly +Nena +Nerissa +Nerita +Nert +Nerta +Nerte +Nerti +Nertie +Nerty +Nessa +Nessi +Nessie +Nessy +Nesta +Netta +Netti +Nettie +Nettle +Netty +Nevsa +Neysa +Nichol +Nichole +Nicholle +Nicki +Nickie +Nicky +Nicol +Nicola +Nicole +Nicolea +Nicolette +Nicoli +Nicolina +Nicoline +Nicolle +Nidia +Nike +Niki +Nikki +Nikkie +Nikoletta +Nikolia +Nil +Nina +Ninetta +Ninette +Ninnetta +Ninnette +Ninon +Nisa +Nissa +Nisse +Nissie +Nissy +Nita +Nitin +Nixie +Noami +Noel +Noelani +Noell +Noella +Noelle +Noellyn +Noelyn +Noemi +Nola +Nolana +Nolie +Nollie +Nomi +Nona +Nonah +Noni +Nonie +Nonna +Nonnah +Nora +Norah +Norean +Noreen +Norene +Norina +Norine +Norma +Norri +Norrie +Norry +Nova +Novelia +Nydia +Nyssa +Octavia +Odele +Odelia +Odelinda +Odella +Odelle +Odessa +Odetta +Odette +Odilia +Odille +Ofelia +Ofella +Ofilia +Ola +Olenka +Olga +Olia +Olimpia +Olive +Olivette +Olivia +Olivie +Oliy +Ollie +Olly +Olva +Olwen +Olympe +Olympia +Olympie +Ondrea +Oneida +Onida +Onlea +Oona +Opal +Opalina +Opaline +Ophelia +Ophelie +Oprah +Ora +Oralee +Oralia +Oralie +Oralla +Oralle +Orel +Orelee +Orelia +Orelie +Orella +Orelle +Oreste +Oriana +Orly +Orsa +Orsola +Ortensia +Otha +Othelia +Othella +Othilia +Othilie +Ottilie +Pacifica +Page +Paige +Paloma +Pam +Pamela +Pamelina +Pamella +Pammi +Pammie +Pammy +Pandora +Pansie +Pansy +Paola +Paolina +Parwane +Pat +Patience +Patrica +Patrice +Patricia +Patrizia +Patsy +Patti +Pattie +Patty +Paula +Paula-Grace +Paule +Pauletta +Paulette +Pauli +Paulie +Paulina +Pauline +Paulita +Pauly +Pavia +Pavla +Pearl +Pearla +Pearle +Pearline +Peg +Pegeen +Peggi +Peggie +Peggy +Pen +Penelopa +Penelope +Penni +Pennie +Penny +Pepi +Pepita +Peri +Peria +Perl +Perla +Perle +Perri +Perrine +Perry +Persis +Pet +Peta +Petra +Petrina +Petronella +Petronia +Petronilla +Petronille +Petunia +Phaedra +Phaidra +Phebe +Phedra +Phelia +Phil +Philipa +Philippa +Philippe +Philippine +Philis +Phillida +Phillie +Phillis +Philly +Philomena +Phoebe +Phylis +Phyllida +Phyllis +Phyllys +Phylys +Pia +Pier +Pierette +Pierrette +Pietra +Piper +Pippa +Pippy +Polly +Pollyanna +Pooh +Poppy +Portia +Pris +Prisca +Priscella +Priscilla +Prissie +Pru +Prudence +Prudi +Prudy +Prue +Prunella +Queada +Queenie +Quentin +Querida +Quinn +Quinta +Quintana +Quintilla +Quintina +Rachael +Rachel +Rachele +Rachelle +Rae +Raf +Rafa +Rafaela +Rafaelia +Rafaelita +Ragnhild +Rahal +Rahel +Raina +Raine +Rakel +Ralina +Ramona +Ramonda +Rana +Randa +Randee +Randene +Randi +Randie +Randy +Ranee +Rani +Rania +Ranice +Ranique +Ranna +Raphaela +Raquel +Raquela +Rasia +Rasla +Raven +Ray +Raychel +Raye +Rayna +Raynell +Rayshell +Rea +Reba +Rebbecca +Rebe +Rebeca +Rebecca +Rebecka +Rebeka +Rebekah +Rebekkah +Ree +Reeba +Reena +Reeta +Reeva +Regan +Reggi +Reggie +Regina +Regine +Reiko +Reina +Reine +Remy +Rena +Renae +Renata +Renate +Rene +Renee +Renel +Renell +Renelle +Renie +Rennie +Reta +Retha +Revkah +Rey +Reyna +Rhea +Rheba +Rheta +Rhetta +Rhiamon +Rhianna +Rhianon +Rhoda +Rhodia +Rhodie +Rhody +Rhona +Rhonda +Riane +Riannon +Rianon +Rica +Ricca +Rici +Ricki +Rickie +Ricky +Riki +Rikki +Rina +Risa +Rissa +Rita +Riva +Rivalee +Rivi +Rivkah +Rivy +Roana +Roanna +Roanne +Robbi +Robbie +Robbin +Robby +Robbyn +Robena +Robenia +Roberta +Robin +Robina +Robinet +Robinett +Robinetta +Robinette +Robinia +Roby +Robyn +Roch +Rochell +Rochella +Rochelle +Rochette +Roda +Rodi +Rodie +Rodina +Romola +Romona +Romonda +Romy +Rona +Ronalda +Ronda +Ronica +Ronna +Ronni +Ronnica +Ronnie +Ronny +Roobbie +Rora +Rori +Rorie +Rory +Ros +Rosa +Rosabel +Rosabella +Rosabelle +Rosaleen +Rosalia +Rosalie +Rosalind +Rosalinda +Rosalinde +Rosaline +Rosalyn +Rosalynd +Rosamond +Rosamund +Rosana +Rosanna +Rosanne +Rosario +Rose +Roseann +Roseanna +Roseanne +Roselia +Roselin +Roseline +Rosella +Roselle +Roselyn +Rosemaria +Rosemarie +Rosemary +Rosemonde +Rosene +Rosetta +Rosette +Roshelle +Rosie +Rosina +Rosita +Roslyn +Rosmunda +Rosy +Row +Rowe +Rowena +Roxana +Roxane +Roxanna +Roxanne +Roxi +Roxie +Roxine +Roxy +Roz +Rozalie +Rozalin +Rozamond +Rozanna +Rozanne +Roze +Rozele +Rozella +Rozelle +Rozina +Rubetta +Rubi +Rubia +Rubie +Rubina +Ruby +Ruella +Ruperta +Ruth +Ruthann +Ruthanne +Ruthe +Ruthi +Ruthie +Ruthy +Ryann +Rycca +Saba +Sabina +Sabine +Sabra +Sabrina +Sacha +Sada +Sadella +Sadie +Sal +Sallee +Salli +Sallie +Sally +Sallyann +Sallyanne +Salome +Sam +Samantha +Samara +Samaria +Sammy +Samuela +Samuella +Sande +Sandi +Sandie +Sandra +Sandy +Sandye +Sapphira +Sapphire +Sara +Sara-Ann +Saraann +Sarah +Sarajane +Saree +Sarena +Sarene +Sarette +Sari +Sarina +Sarine +Sarita +Sascha +Sasha +Sashenka +Saudra +Saundra +Savina +Sayre +Scarlet +Scarlett +Scotty +Sean +Seana +Secunda +Seka +Sela +Selena +Selene +Selestina +Selia +Selie +Selina +Selinda +Seline +Sella +Selle +Selma +Sena +Sephira +Serena +Serene +Shaina +Shaine +Shalna +Shalne +Shamit +Shana +Shanda +Shandee +Shandie +Shandra +Shandy +Shane +Shani +Shanie +Shanna +Shannah +Shannen +Shannon +Shanon +Shanta +Shantee +Shara +Sharai +Shari +Sharia +Sharie +Sharity +Sharl +Sharla +Sharleen +Sharlene +Sharline +Sharna +Sharon +Sharona +Sharra +Sharron +Sharyl +Shaun +Shauna +Shawn +Shawna +Shawnee +Shay +Shayla +Shaylah +Shaylyn +Shaylynn +Shayna +Shayne +Shea +Sheba +Sheela +Sheelagh +Sheelah +Sheena +Sheeree +Sheila +Sheila-Kathryn +Sheilah +Sheilakathryn +Shel +Shela +Shelagh +Shelba +Shelbi +Shelby +Shelia +Shell +Shelley +Shelli +Shellie +Shelly +Shena +Sher +Sheree +Sheri +Sherie +Sheril +Sherill +Sherilyn +Sherline +Sherri +Sherrie +Sherry +Sherye +Sheryl +Shilpa +Shina +Shir +Shira +Shirah +Shirl +Shirlee +Shirleen +Shirlene +Shirley +Shirline +Shoshana +Shoshanna +Shoshie +Siana +Sianna +Sib +Sibbie +Sibby +Sibeal +Sibel +Sibella +Sibelle +Sibilla +Sibley +Sibyl +Sibylla +Sibylle +Sidoney +Sidonia +Sidonnie +Sigrid +Sile +Sileas +Silva +Silvana +Silvia +Silvie +Simona +Simone +Simonette +Simonne +Sindee +Sinead +Siobhan +Sioux +Siouxie +Sisely +Sisile +Sissie +Sissy +Sofia +Sofie +Solange +Sondra +Sonia +Sonja +Sonni +Sonnie +Sonnnie +Sonny +Sonya +Sophey +Sophi +Sophia +Sophie +Sophronia +Sorcha +Sosanna +Stace +Stacee +Stacey +Staci +Stacia +Stacie +Stacy +Stafani +Star +Starla +Starlene +Starlin +Starr +Stefa +Stefania +Stefanie +Steffane +Steffi +Steffie +Stella +Stepha +Stephana +Stephani +Stephanie +Stephannie +Stephenie +Stephi +Stephie +Stephine +Stesha +Stevana +Stevena +Stoddard +Storey +Storm +Stormi +Stormie +Stormy +Sue +Sue-elle +Suellen +Sukey +Suki +Sula +Sunny +Sunshine +Susan +Susana +Susanetta +Susann +Susanna +Susannah +Susanne +Susette +Susi +Susie +Sussi +Susy +Suzan +Suzann +Suzanna +Suzanne +Suzetta +Suzette +Suzi +Suzie +Suzy +Suzzy +Sybil +Sybila +Sybilla +Sybille +Sybyl +Sydel +Sydelle +Sydney +Sylvia +Sylvie +Tabatha +Tabbatha +Tabbi +Tabbie +Tabbitha +Tabby +Tabina +Tabitha +Taffy +Talia +Tallia +Tallie +Tally +Talya +Talyah +Tamar +Tamara +Tamarah +Tamarra +Tamera +Tami +Tamiko +Tamma +Tammara +Tammi +Tammie +Tammy +Tamra +Tana +Tandi +Tandie +Tandy +Tani +Tania +Tansy +Tanya +Tara +Tarah +Tarra +Tarrah +Taryn +Tasha +Tasia +Tate +Tatiana +Tatiania +Tatum +Tawnya +Tawsha +Teane +Ted +Tedda +Teddi +Teddie +Teddy +Tedi +Tedra +Teena +Tella +Teodora +Tera +Teresa +TeresaAnne +Terese +Teresina +Teresita +Teressa +Teri +Teriann +Terina +Terra +Terri +Terri-Jo +Terrianne +Terrie +Terry +Terrye +Tersina +Teryl +Terza +Tess +Tessa +Tessi +Tessie +Tessy +Thalia +Thea +Theada +Theadora +Theda +Thekla +Thelma +Theo +Theodora +Theodosia +Theresa +Theresa-Marie +Therese +Theresina +Theresita +Theressa +Therine +Thia +Thomasa +Thomasin +Thomasina +Thomasine +Tia +Tiana +Tiena +Tierney +Tiertza +Tiff +Tiffani +Tiffanie +Tiffany +Tiffi +Tiffie +Tiffy +Tilda +Tildi +Tildie +Tildy +Tillie +Tilly +Tim +Timi +Timmi +Timmie +Timmy +Timothea +Tina +Tine +Tiphani +Tiphanie +Tiphany +Tish +Tisha +Tobe +Tobey +Tobi +Tobie +Toby +Tobye +Toinette +Toma +Tomasina +Tomasine +Tomi +Tomiko +Tommi +Tommie +Tommy +Toni +Tonia +Tonie +Tony +Tonya +Tootsie +Torey +Tori +Torie +Torrie +Tory +Tova +Tove +Trace +Tracee +Tracey +Traci +Tracie +Tracy +Trenna +Tresa +Trescha +Tressa +Tricia +Trina +Trish +Trisha +Trista +Trix +Trixi +Trixie +Trixy +Truda +Trude +Trudey +Trudi +Trudie +Trudy +Trula +Tuesday +Twila +Twyla +Tybi +Tybie +Tyne +Ula +Ulla +Ulrica +Ulrika +Ulrike +Umeko +Una +Ursa +Ursala +Ursola +Ursula +Ursulina +Ursuline +Uta +Val +Valaree +Valaria +Vale +Valeda +Valencia +Valene +Valenka +Valentia +Valentina +Valentine +Valera +Valeria +Valerie +Valery +Valerye +Valida +Valina +Valli +Vallie +Vally +Valma +Valry +Van +Vanda +Vanessa +Vania +Vanna +Vanni +Vannie +Vanny +Vanya +Veda +Velma +Velvet +Vena +Venita +Ventura +Venus +Vera +Veradis +Vere +Verena +Verene +Veriee +Verile +Verina +Verine +Verla +Verna +Vernice +Veronica +Veronika +Veronike +Veronique +Vi +Vicki +Vickie +Vicky +Victoria +Vida +Viki +Vikki +Vikkie +Vikky +Vilhelmina +Vilma +Vin +Vina +Vinita +Vinni +Vinnie +Vinny +Viola +Violante +Viole +Violet +Violetta +Violette +Virgie +Virgina +Virginia +Virginie +Vita +Vitia +Vitoria +Vittoria +Viv +Viva +Vivi +Vivia +Vivian +Viviana +Vivianna +Vivianne +Vivie +Vivien +Viviene +Vivienne +Viviyan +Vivyan +Vivyanne +Vonni +Vonnie +Vonny +Wallie +Wallis +Wally +Waly +Wanda +Wandie +Wandis +Waneta +Wenda +Wendeline +Wendi +Wendie +Wendy +Wenona +Wenonah +Whitney +Wileen +Wilhelmina +Wilhelmine +Wilie +Willa +Willabella +Willamina +Willetta +Willette +Willi +Willie +Willow +Willy +Willyt +Wilma +Wilmette +Wilona +Wilone +Wilow +Windy +Wini +Winifred +Winna +Winnah +Winne +Winni +Winnie +Winnifred +Winny +Winona +Winonah +Wren +Wrennie +Wylma +Wynn +Wynne +Wynnie +Wynny +Xaviera +Xena +Xenia +Xylia +Xylina +Yalonda +Yehudit +Yelena +Yetta +Yettie +Yetty +Yevette +Yoko +Yolanda +Yolande +Yolane +Yolanthe +Yonina +Yoshi +Yoshiko +Yovonnda +Yvette +Yvonne +Zabrina +Zahara +Zandra +Zaneta +Zara +Zarah +Zaria +Zarla +Zea +Zelda +Zelma +Zena +Zenia +Zia +Zilvia +Zita +Zitella +Zoe +Zola +Zonda +Zondra +Zonnya +Zora +Zorah +Zorana +Zorina +Zorine +Zsa Zsa +Zsazsa +Zulema +Zuzana +Mikako +Kaari +Gita +Geeta \ No newline at end of file diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/about.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/about.json new file mode 100644 index 0000000..254bc24 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/about.json @@ -0,0 +1,6 @@ +{ + "name": "About", + "description": "Information about the mod, development, and how to help.", + "icon": "minecraft:writable_book", + "sortnum": 99 +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/blocks.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/blocks.json new file mode 100644 index 0000000..9e68e71 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/blocks.json @@ -0,0 +1,6 @@ +{ + "name": "Blocks", + "description": "Decorative blocks, traps, lockable doors, and pet furniture for building prisons and creating ambushes.", + "icon": "tiedup:padded_block", + "sortnum": 5 +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/camps_prisons.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/camps_prisons.json new file mode 100644 index 0000000..186ecf6 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/camps_prisons.json @@ -0,0 +1,6 @@ +{ + "name": "Camps & Prisons", + "description": "Camp structures, cell systems, labor tasks, and prisoner management.", + "icon": "minecraft:iron_bars", + "sortnum": 6 +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/gameplay.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/gameplay.json new file mode 100644 index 0000000..a57f662 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/gameplay.json @@ -0,0 +1,6 @@ +{ + "name": "Gameplay", + "description": "Core mechanics: tying, self-bondage, struggle, captivity, prison, bounty, and interfaces.", + "icon": "minecraft:chain", + "sortnum": 4 +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/items.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/items.json new file mode 100644 index 0000000..65d16f5 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/items.json @@ -0,0 +1,6 @@ +{ + "name": "Equipment", + "description": "All bondage equipment types and their variants.", + "icon": "tiedup:ropes", + "sortnum": 1 +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/npcs.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/npcs.json new file mode 100644 index 0000000..66753f1 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/npcs.json @@ -0,0 +1,6 @@ +{ + "name": "NPCs", + "description": "Interactive NPCs with bondage mechanics and AI behaviors.", + "icon": "minecraft:player_head", + "sortnum": 2 +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/pet_play.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/pet_play.json new file mode 100644 index 0000000..37309ff --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/pet_play.json @@ -0,0 +1,6 @@ +{ + "name": "Pet Play", + "description": "Master NPC, choke collar, pet tasks, and pet furniture.", + "icon": "tiedup:choke_collar", + "sortnum": 7 +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/structures.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/structures.json new file mode 100644 index 0000000..e09c599 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/structures.json @@ -0,0 +1,6 @@ +{ + "name": "Structures", + "description": "World-generated structures: camps, outposts, fortresses, and hanging cages.", + "icon": "minecraft:structure_block", + "sortnum": 8 +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/tools_items.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/tools_items.json new file mode 100644 index 0000000..1b8e931 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/categories/tools_items.json @@ -0,0 +1,6 @@ +{ + "name": "Tools & Items", + "description": "Special tools, chemicals, locks, and weapons for capturing and controlling.", + "icon": "minecraft:iron_ingot", + "sortnum": 3 +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/about/help_wanted.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/about/help_wanted.json new file mode 100644 index 0000000..850c442 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/about/help_wanted.json @@ -0,0 +1,58 @@ +{ + "name": "Help Wanted", + "icon": "minecraft:writable_book", + "category": "tiedup:about", + "sortnum": 1, + "pages": [ + { + "type": "patchouli:text", + "title": "Help Wanted", + "text": "$(bold)I need your help!$(br2)I'm not an artist or animator. If you have skills in any of these areas, your contribution would be greatly appreciated.$(br2)Contact me via $(#5555FF)$(l:https://www.loverslab.com/files/file/46423-tiedup-a-minecraft-bondage-mod/)LoversLab$()." + }, + { + "type": "patchouli:text", + "title": "Why Hiding?", + "text": "$(bold)This is an adult mod and not necessarily liked by Mojang.(br)$(li) This is why I stay hidden$(br2)$(thing)Consequences:$()$(br)$(li) All help must stay private$(br)$(li) No payments possible$(br)$(li) No donations accepted$(br)$(li) Everyone stays safe this way" + }, + { + "type": "patchouli:text", + "title": "Free & Private", + "text": "$(bold)For your safety and mine$(br2)$(thing)This Mod Is:$()$(br)$(li) 100% free forever$(br)$(li) No donations accepted$(br)$(li) No payments of any kind$(br)$(li) Community project$(br2)$(thing)All Contributions:$()$(br)$(li) Are volunteer-based$(br)$(li) Cannot be compensated$(br)$(li) Are for passion only$(br2)$(thing)No Personal Info:$()$(br)I won't ask, you won't share" + }, + { + "type": "patchouli:text", + "title": "3D Models", + "text": "$(bold)3D Item Models$(br2)$(thing)Wanted:$()$(br)3D artist who can create custom item models$(br2)$(thing)Current Status:$()$(br)I'm using basic item textures$(br)Would love proper 3D models for bondage items$(br2)$(thing)Skills Needed:$()$(br)Blockbench or similar 3D modeling$(br)Minecraft item model formats" + }, + { + "type": "patchouli:text", + "title": "Textures & Art", + "text": "$(bold)Texture Artists$(br2)$(thing)Wanted:$()$(br)Artists who can create item and block textures$(br2)$(thing)Current Status:$()$(br)I'm not an artist$(br)Many textures are placeholders or adapted from existing ones$(br2)$(thing)Skills Needed:$()$(br)Pixel art$(br)Minecraft texture style$(br)16x16 or 32x32 resolution" + }, + { + "type": "patchouli:text", + "title": "Animations", + "text": "$(bold)Animation Help$(br2)$(thing)Wanted:$()$(br)Someone experienced with anything animation related$(br2)$(thing)Current Status:$()$(br)I'm using PlayerAnimator library$(br)I have limited animation knowledge$(br)Could use better player animations$(br2)$(thing)Skills Needed:$()$(br)Help lol" + }, + { + "type": "patchouli:text", + "title": "Source Code", + "text": "$(bold)Source Code Access$(br2)$(thing)No Public Repository:$()$(br)No GitHub/GitLab repo for safety$(br2)$(thing)But You Can:$()$(br)$(li) Deobfuscate the mod JAR$(br)$(li) Study the code$(br)$(li) Suggest improvements$(br)$(li) Report issues$(br2)I'm considering alternatives for easier collaboration." + }, + { + "type": "patchouli:text", + "title": "Multiplayer Testing", + "text": "$(bold)Multiplayer Support$(br2)$(thing)Status:$()$(br)$(li) Tested on LAN - works$(br)$(li) Should work on dedicated servers$(br)$(li) Not fully tested$(br2)$(thing)If Issues:$()$(br)Contact me on LoversLab with details:$(br)$(li) Server type$(br)$(li) Error messages$(br)$(li) What doesn't work" + }, + { + "type": "patchouli:text", + "title": "Why This Remake?", + "text": "$(bold)My Reasons$(br2)$(thing)Privacy:$()$(br)I don't want to share my Discord ID or any personal info$(br2)$(thing)Freedom:$()$(br)Making a free, community version$(br)Independent development$(br)No legal risks$(br2)$(thing)Goal:$()$(br)Provide a maintained, modern version of TiedUp! for Minecraft 1.20+ while keeping everyone safe" + }, + { + "type": "patchouli:text", + "title": "Contact & Contribute", + "text": "$(bold)Get In Touch$(br2)$(thing)LoversLab:$()$(br)$(#5555FF)$(l:https://www.loverslab.com/files/file/46423-tiedup-a-minecraft-bondage-mod/)TiedUp! Mod Page$()$(br2)$(thing)How to Help:$()$(br)$(li) Create textures/models$(br)$(li) Report bugs$(br)$(li) Suggest features$(br)$(li) Test multiplayer$(br)$(li) Share feedback$(br2)$(bold)All contributions welcome!$()$(br)Stay safe, have fun!" + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/cell_door.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/cell_door.json new file mode 100644 index 0000000..e4ee04e --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/cell_door.json @@ -0,0 +1,29 @@ +{ + "name": "Cell Door", + "icon": "tiedup:cell_door", + "category": "tiedup:blocks", + "sortnum": 5, + "pages": [ + { + "type": "patchouli:spotlight", + "item": "tiedup:cell_door", + "title": "Cell Door", + "text": "$(bold)Prison-style door with redstone control.$(br2)$(thing)Properties:$()$(br)$(li) Metal construction$(br)$(li) Very strong (5.0 hardness)$(br)$(li) High blast resistance$(br)$(li) Redstone-only opening$(br2)Cannot be opened by hand - requires redstone signal." + }, + { + "type": "patchouli:text", + "title": "How It Works", + "text": "$(bold)Opens ONLY with redstone.$(br2)$(thing)Opening:$()$(br)$(li) Apply redstone signal$(br)$(li) Door opens automatically$(br)$(li) Cannot right-click to open$(br)$(li) Standard door physics$(br2)$(thing)Closing:$()$(br)$(li) Remove redstone signal$(br)$(li) Door closes automatically$(br)$(li) No manual override" + }, + { + "type": "patchouli:text", + "title": "Design & Usage", + "text": "$(thing)Features:$()$(br)$(li) 2 blocks tall (standard door)$(br)$(li) 4 orientations$(br)$(li) Metal appearance$(br)$(li) Requires tool to break$(br2)$(thing)Purpose:$()$(br)$(li) Prison cell entrance$(br)$(li) Secure areas$(br)$(li) Automated systems$(br)$(li) Prevents manual escape" + }, + { + "type": "patchouli:text", + "title": "Redstone Ideas", + "text": "$(thing)Activation Methods:$()$(br)$(li) Lever (manual control)$(br)$(li) Button (timed access)$(br)$(li) Pressure plate (auto-open)$(br)$(li) Tripwire (detection)$(br)$(li) Redstone clock (cycling)$(br)$(li) Remote control circuits$(br2)$(thing)Build:$()$(br)Combine with padded blocks for complete prison cells" + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/decorative.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/decorative.json new file mode 100644 index 0000000..e9cbb66 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/decorative.json @@ -0,0 +1,31 @@ +{ + "name": "Padded Blocks", + "icon": "tiedup:padded_block", + "category": "tiedup:blocks", + "sortnum": 1, + "pages": [ + { + "type": "patchouli:spotlight", + "item": "tiedup:padded_block", + "title": "Padded Block", + "text": "$(bold)Soft building block.$(br2)$(thing)Properties:$()$(br)$(li) Wool material$(br)$(li) Soft cloth texture$(br)$(li) Moderate hardness$(br2)$(thing)Use:$()$(br)Build padded rooms, cells, or safe areas." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:padded_slab", + "title": "Padded Slab", + "text": "$(bold)Half-height padded variant.$(br2)$(thing)Properties:$()$(br)$(li) Same material as block$(br)$(li) Half-height (slab)$(br)$(li) Standard slab placement$(br2)$(thing)Placement:$()$(br)Bottom, top, or full block$(br)Combine two for full block" + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:padded_stairs", + "title": "Padded Stairs", + "text": "$(bold)Stair variant for building.$(br2)$(thing)Properties:$()$(br)$(li) Stair shape$(br)$(li) 4 orientations$(br)$(li) Supports corners$(br2)$(thing)Use:$()$(br)Create steps, decorative patterns, or complex padded structures" + }, + { + "type": "patchouli:text", + "title": "Building Tips", + "text": "$(thing)Design Ideas:$()$(br)$(li) Padded prison cells$(br)$(li) Safe rooms$(br)$(li) Asylum-style areas$(br)$(li) Sound-dampened spaces$(br2)$(thing)Combine:$()$(br)Use all three variants together for detailed architecture$(br2)Mix all three variants for detailed architecture" + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/iron_bar_door.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/iron_bar_door.json new file mode 100644 index 0000000..52b1a24 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/iron_bar_door.json @@ -0,0 +1,14 @@ +{ + "name": "Iron Bar Door", + "icon": "tiedup:iron_bar_door", + "category": "tiedup:blocks", + "sortnum": 6, + "pages": [ + { + "type": "patchouli:spotlight", + "item": "tiedup:iron_bar_door", + "title": "Iron Bar Door", + "text": "$(bold)Lockable prison door.$(br2)$(thing)Properties:$()$(br)$(li) Lockable with Cell Key$(br)$(li) Master Key opens all$(br)$(li) Visual iron bar style$(br2)$(thing)Usage:$()$(br)Build prison cells and secure areas. Lock with Cell Key, unlock with Cell Key or Master Key." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/kidnap_bomb.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/kidnap_bomb.json new file mode 100644 index 0000000..f2f305b --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/kidnap_bomb.json @@ -0,0 +1,39 @@ +{ + "name": "Kidnap Bomb", + "icon": "tiedup:kidnap_bomb", + "category": "tiedup:blocks", + "sortnum": 3, + "pages": [ + { + "type": "patchouli:spotlight", + "item": "tiedup:kidnap_bomb", + "title": "Kidnap Bomb", + "text": "$(bold)TNT-like block with area restraint effect.$(br2)$(thing)Properties:$()$(br)$(li) Instantly breakable$(br)$(li) Ignitable like TNT$(br)$(li) Area effect$(br)$(li) No block damage$(br2)Load with bondage items, ignite, and restrain everyone nearby!" + }, + { + "type": "patchouli:text", + "title": "How to Load", + "text": "$(bold)Right-click with bondage items.$(br2)$(thing)Accepts:$()$(br)$(li) Bind items$(br)$(li) Gag items$(br)$(li) Blindfold items$(br)$(li) Earplugs$(br)$(li) Collars$(br2)$(thing)Capacity:$()$(br)ONE item per slot$(br)Up to 6 different items total$(br2)$(bold)Important:$() Don't hold flint & steel when loading!" + }, + { + "type": "patchouli:text", + "title": "How to Ignite", + "text": "$(bold)Multiple ignition methods.$(br2)$(thing)Can Ignite With:$()$(br)$(li) Flint & Steel$(br)$(li) Fire Charge$(br)$(li) Lava contact$(br)$(li) Fire spread$(br2)$(thing)After Ignition:$()$(br)$(li) Spawns primed entity$(br)$(li) 4 second fuse$(br)$(li) Smoke particles$(br)$(li) Can bounce/move" + }, + { + "type": "patchouli:text", + "title": "Explosion Effect", + "text": "$(bold)Area restraint explosion.$(br2)$(thing)Radius:$()$(br)5 blocks (default)$(br)Configurable via gamerule$(br2)$(thing)Affects:$()$(br)$(li) All living entities in range$(br)$(li) NOT spectators$(br)$(li) NOT already tied$(br)$(li) NOT the placer$(br2)$(thing)Effect:$()$(br)Applies ALL loaded items to each entity" + }, + { + "type": "patchouli:text", + "title": "Special Features", + "text": "$(thing)No Block Damage:$()$(br)$(li) Explosion is safe$(br)$(li) Doesn't destroy terrain$(br)$(li) Only affects entities$(br)$(li) Perfect for traps$(br2)$(thing)Physics:$()$(br)$(li) Primed bomb has gravity$(br)$(li) Can bounce on impact$(br)$(li) Can be pushed$(br)$(li) Can fall into pits" + }, + { + "type": "patchouli:text", + "title": "Tooltip & Strategy", + "text": "$(thing)Tooltip:$()$(br)$(bold)Empty:$() $(#55FF55)Green$(br)$(bold)Loaded:$() $(#FFFF55)Yellow$(br)Lists all loaded items in $(#FFAA00)gold$()$(br2)$(thing)Tactics:$()$(br)$(li) Set traps at chokepoints$(br)$(li) Use redstone to ignite$(br)$(li) Combine with dispensers$(br)$(li) Create chain reactions$(br)$(li) Area crowd control" + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/pet_furniture.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/pet_furniture.json new file mode 100644 index 0000000..65e288c --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/pet_furniture.json @@ -0,0 +1,20 @@ +{ + "name": "Pet Furniture", + "icon": "tiedup:pet_bowl", + "category": "tiedup:blocks", + "sortnum": 7, + "pages": [ + { + "type": "patchouli:spotlight", + "item": "tiedup:pet_bowl", + "title": "Pet Bowl", + "text": "$(bold)Food bowl for pets.$(br2)$(thing)Usage:$()$(br)$(li) Fill with food items$(br)$(li) Crouch + right-click to eat$(br)$(li) Pets only$(br2)Place in pet areas for feeding." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:pet_bed", + "title": "Pet Bed", + "text": "$(bold)Sleeping spot for pets and NPC home block.$(br2)$(thing)States:$() Stand / Sit / Sleep$(br)$(thing)Cycle:$() Right-click to change$(br2)$(thing)Features:$()$(br)$(li) Skips night when sleeping$(br)$(li) Three position states$(br)$(li) Assign as NPC home via Command Wand$(br)$(li) NPCs return here when idle$(br2)Functional rest and home block for pet play." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/rope_trap.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/rope_trap.json new file mode 100644 index 0000000..92d575b --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/rope_trap.json @@ -0,0 +1,34 @@ +{ + "name": "Rope Trap", + "icon": "tiedup:rope_trap", + "category": "tiedup:blocks", + "sortnum": 2, + "pages": [ + { + "type": "patchouli:spotlight", + "item": "tiedup:rope_trap", + "title": "Rope Trap", + "text": "$(bold)Floor trap that restrains entities.$(br2)$(thing)Properties:$()$(br)$(li) 1 pixel tall (carpet-like)$(br)$(li) No collision$(br)$(li) Nearly invisible$(br)$(li) Single-use$(br2)Load with bondage items, then wait for victims to walk over it." + }, + { + "type": "patchouli:text", + "title": "How to Load", + "text": "$(bold)Right-click with bondage items.$(br2)$(thing)Accepts:$()$(br)$(li) Bind items$(br)$(li) Gag items$(br)$(li) Blindfold items$(br)$(li) Earplugs$(br)$(li) Collars$(br2)$(thing)Capacity:$()$(br)ONE item per slot$(br)Up to 6 different items total$(br2)In creative: items not consumed" + }, + { + "type": "patchouli:text", + "title": "Trigger Mechanics", + "text": "$(bold)Activates when entity walks on it.$(br2)$(thing)Process:$()$(br)1. Entity steps on trap$(br)2. Check if already tied$(br)3. If not tied: apply ALL items$(br)4. Trap destroys itself$(br)5. Victim gets notification$(br2)$(thing)Single Use:$()$(br)Trap is destroyed after triggering" + }, + { + "type": "patchouli:text", + "title": "Placement Rules", + "text": "$(bold)Requires solid block below.$(br2)$(thing)Requirements:$()$(br)$(li) Must place on full block$(br)$(li) Block must be solid$(br)$(li) Cannot place in air$(br2)$(thing)Behavior:$()$(br)$(li) Falls if support removed$(br)$(li) Breaks when falling$(br)$(li) Drops as item with contents" + }, + { + "type": "patchouli:text", + "title": "Tooltip & States", + "text": "$(thing)States:$()$(br)$(bold)Disarmed:$() $(#55FF55)Green$() - empty$(br)$(bold)Armed:$() $(#AA0000)Dark Red$() - loaded$(br2)$(thing)Tooltip Shows:$()$(br)$(li) Armed/disarmed status$(br)$(li) List of loaded items$(br)$(li) Items shown in $(#FFAA00)gold$(br2)$(thing)Strategy:$()$(br)Hide trap under carpets or similar textures for camouflage" + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/trapped_chest.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/trapped_chest.json new file mode 100644 index 0000000..198c003 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/blocks/trapped_chest.json @@ -0,0 +1,34 @@ +{ + "name": "Trapped Chest", + "icon": "tiedup:trapped_chest", + "category": "tiedup:blocks", + "sortnum": 4, + "pages": [ + { + "type": "patchouli:spotlight", + "item": "tiedup:trapped_chest", + "title": "Trapped Chest", + "text": "$(bold)Chest that restrains when opened.$(br2)$(thing)Properties:$()$(br)$(li) Functions as normal chest$(br)$(li) 27 inventory slots$(br)$(li) Can be double chest$(br)$(li) Triggers on open$(br2)Load with bondage items as hidden trap." + }, + { + "type": "patchouli:text", + "title": "How to Load", + "text": "$(bold)Right-click while holding bondage item.$(br2)$(thing)Accepts:$()$(br)$(li) Bind items$(br)$(li) Gag items$(br)$(li) Blindfold items$(br)$(li) Earplugs$(br)$(li) Collars$(br2)$(thing)Capacity:$()$(br)ONE item per slot$(br)Up to 6 trap items$(br2)$(bold)Note:$() Loading does NOT open chest GUI" + }, + { + "type": "patchouli:text", + "title": "Trigger Mechanics", + "text": "$(bold)Activates when chest is opened.$(br2)$(thing)Process:$()$(br)1. Player opens chest$(br)2. Check if trap armed$(br)3. Check if player tied$(br)4. If armed + not tied: apply items$(br)5. Clear trap contents$(br)6. Chest GUI opens normally$(br2)$(thing)Message:$()$(br)\"You fell into a trap!\" (warning)" + }, + { + "type": "patchouli:text", + "title": "Dual Functionality", + "text": "$(thing)Trap Storage:$()$(br)$(li) Separate from inventory$(br)$(li) Not visible in GUI$(br)$(li) Persists when broken$(br)$(li) Preserved on placement$(br2)$(thing)Chest Storage:$()$(br)$(li) Normal 27 slots$(br)$(li) Can store any items$(br)$(li) Independent of trap$(br)$(li) Can be double chest" + }, + { + "type": "patchouli:text", + "title": "Tooltip & Strategy", + "text": "$(thing)Tooltip:$()$(br)$(bold)Disarmed:$() $(#55FF55)Green$() - no trap$(br)$(bold)Armed:$() $(#AA0000)Dark Red$() - loaded$(br)Lists trap items in $(#FFAA00)gold$()$(br2)$(thing)Tactics:$()$(br)$(li) Bait with valuable loot$(br)$(li) Hide in treasure rooms$(br)$(li) Use as false security$(br)$(li) Combine with other traps$(br)$(li) Great for dungeons" + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/camps_prisons/cell_system.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/camps_prisons/cell_system.json new file mode 100644 index 0000000..5156b64 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/camps_prisons/cell_system.json @@ -0,0 +1,23 @@ +{ + "name": "Cell System", + "icon": "tiedup:cell_core", + "category": "tiedup:camps_prisons", + "sortnum": 1, + "pages": [ + { + "type": "patchouli:text", + "title": "Cell System", + "text": "$(bold)Prison cell construction and management.$(br2)Cells are defined by a $(thing)Cell Core$() block placed in the wall of a room. The Core flood-fills to detect the enclosed room, walls, doors, and furniture automatically.$(br2)$(thing)Max Room Size:$() 12x8x12 interior" + }, + { + "type": "patchouli:text", + "title": "Cell Core", + "text": "$(thing)Cell Core$() is a solid wall block (obsidian-tier hardness) placed $(bold)in the wall$() of a room.$(br2)$(thing)Right-click menu:$()$(br)$(li) Set Spawn — prisoner spawn point$(br)$(li) Set Delivery — drop-off point$(br)$(li) Set Disguise — camouflage block$(br)$(li) Re-scan — update room detection$(br)$(li) Info — cell stats" + }, + { + "type": "patchouli:text", + "title": "Auto-Detection", + "text": "The Cell Core automatically detects:$(br2)$(li) $(thing)Walls$() — solid blocks enclosing the room$(br)$(li) $(thing)Doors$() — door/trapdoor/fence gate in walls$(br)$(li) $(thing)Beds$() — prisoner rest spots$(br)$(li) $(thing)Anchors$() — chain attachment points$(br)$(li) $(thing)Redstone$() — levers/buttons on walls$(br2)No manual markers needed." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/camps_prisons/labor_system.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/camps_prisons/labor_system.json new file mode 100644 index 0000000..ff24462 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/camps_prisons/labor_system.json @@ -0,0 +1,23 @@ +{ + "name": "Labor System", + "icon": "minecraft:iron_pickaxe", + "category": "tiedup:camps_prisons", + "sortnum": 2, + "pages": [ + { + "type": "patchouli:text", + "title": "Labor System", + "text": "$(bold)Forced labor task system.$(br2)All labor tasks follow the same format: bring X items. Tools are automatically assigned based on task type.$(br2)$(thing)Value:$() 8-35 emeralds per task$(br)$(thing)Ransom:$() 100-200 emeralds$(br)$(thing)Tasks to Freedom:$() ~6 tasks" + }, + { + "type": "patchouli:text", + "title": "Task Types", + "text": "$(thing)Mining:$() Pickaxe, collect ores/stone$(br)$(thing)Chopping:$() Axe, collect wood$(br)$(thing)Gathering:$() Shovel, collect soil/sand$(br)$(thing)Combat:$() Sword, collect mob drops$(br)$(thing)Foraging:$() No tool, collect plants$(br)$(thing)Crafting:$() No tool, produce planks/sticks/torches$(br2)$(thing)Combat Notes:$()$(br)$(li) Night-only assignment$(br)$(li) Guard skips task-relevant mobs$(br)$(li) 2-minute inactivity threshold" + }, + { + "type": "patchouli:text", + "title": "Task Flow", + "text": "$(thing)1. IMPRISONED:$() Prisoner in cell$(br)$(thing)2. Extract:$() Maid takes prisoner from cell$(br)$(thing)3. Guard Spawns:$() Labor Guard assigned$(br)$(thing)4. WORKING:$() Prisoner performs task$(br)$(thing)5. Complete:$() Items delivered$(br)$(thing)6. Return:$() Maid returns prisoner to cell$(br2)$(thing)Inactivity:$() 30s threshold (2min for combat)$(br)Escalating shocks on idle, task failure on repeated violations." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/camps_prisons/prisoner_states.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/camps_prisons/prisoner_states.json new file mode 100644 index 0000000..4e26fc9 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/camps_prisons/prisoner_states.json @@ -0,0 +1,23 @@ +{ + "name": "Prisoner States", + "icon": "minecraft:chain", + "category": "tiedup:camps_prisons", + "sortnum": 3, + "pages": [ + { + "type": "patchouli:text", + "title": "Prisoner States", + "text": "$(bold)Prisoner state machine.$(br2)Prisoners progress through defined states managed by the PrisonerService API.$(br2)$(thing)States:$()$(br)$(li) FREE$(br)$(li) CAPTURED$(br)$(li) IMPRISONED$(br)$(li) WORKING$(br)$(li) PROTECTED" + }, + { + "type": "patchouli:text", + "title": "State Transitions", + "text": "$(thing)FREE → CAPTURED:$() Kidnapper binds target$(br)$(thing)CAPTURED → IMPRISONED:$() Brought to cell$(br)$(thing)IMPRISONED → WORKING:$() Maid extracts for labor$(br)$(thing)WORKING → IMPRISONED:$() Task complete, returned$(br)$(thing)Any → FREE:$() Escape or release$(br)$(thing)Any → PROTECTED:$() Admin protection" + }, + { + "type": "patchouli:text", + "title": "Escape Triggers", + "text": "$(thing)Distance:$() Leave guard's 20-block radius$(br)$(thing)Guard Death:$() Guard killed during task$(br)$(thing)Struggle:$() Break free from restraints$(br)$(thing)Tie Guard:$() Restrain the guard$(br2)$(thing)PrisonerService API:$()$(br)capture, imprison, extract, return, transfer, escape, release" + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/bind_modes_restrictions.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/bind_modes_restrictions.json new file mode 100644 index 0000000..56f4b98 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/bind_modes_restrictions.json @@ -0,0 +1,43 @@ +{ + "name": "Bind Modes & Restrictions", + "icon": "minecraft:chain_command_block", + "category": "tiedup:gameplay", + "sortnum": 6, + "pages": [ + { + "type": "patchouli:text", + "title": "Bind Modes & Restrictions", + "text": "$(bold)3 bind modes with different restrictions.$(br2)$(thing)Modes:$()$(br)$(li) FULL - Arms + legs bound$(br)$(li) ARMS - Only arms, legs free$(br)$(li) LEGS - Only legs, arms free$(br2)$(thing)Cycling:$()$(br)Shift+Right-click on bind item$(br)Cycles: FULL → ARMS → LEGS → FULL" + }, + { + "type": "patchouli:text", + "title": "Mode Descriptions", + "text": "$(bold)FULL (default):$()$(br)$(li) Arms AND legs bound$(br)$(li) Maximum restriction$(br)$(li) 90% speed reduction$(br)$(li) Cannot attack, break, use items$(br2)$(bold)ARMS:$()$(br)$(li) Only arms bound$(br)$(li) Legs free$(br)$(li) Normal movement speed$(br)$(li) Cannot attack, break, use items" + }, + { + "type": "patchouli:text", + "title": "How to Change Mode", + "text": "$(thing)Cycling Modes:$()$(br)Shift+Right-click on bind item$(br2)$(thing)Order:$()$(br)FULL → ARMS → LEGS → FULL$(br2)$(thing)Info:$()$(br)$(li) Mode saved in item$(br)$(li) Default: FULL$(br)$(li) Shows in tooltip$(br2)$(thing)Strategy:$()$(br)Choose mode based on your needs" + }, + { + "type": "patchouli:text", + "title": "Movement Restrictions", + "text": "$(bold)Legs Bound:$()$(br)$(li) Speed reduced to 10% (x0.1)$(br)$(li) Affects walking AND sprinting$(br)$(li) 90% slower than normal$(br)$(li) Very limited movement$(br2)$(bold)Arms Bound:$()$(br)$(li) No movement reduction$(br)$(li) Full speed movement$(br)$(li) Can walk/sprint normally" + }, + { + "type": "patchouli:text", + "title": "Action Restrictions (Arms)", + "text": "$(bold)When arms bound:$(br2)$(thing)Cannot:$()$(br)$(li) Attack entities$(br)$(li) Break blocks$(br)$(li) Use items$(br)$(li) Draw bow$(br)$(li) Eat food$(br)$(li) Access crafting table$(br)$(li) Access brewing stand$(br2)Complete hand incapacitation" + }, + { + "type": "patchouli:text", + "title": "Action Restrictions (Legs)", + "text": "$(bold)When ONLY legs bound:$(br2)$(thing)Can:$()$(br)$(li) Attack normally$(br)$(li) Break blocks normally$(br)$(li) Use items normally$(br)$(li) Access inventories$(br)$(li) Craft/brew$(br2)$(thing)Cannot:$()$(br)$(li) Move quickly (90% slower)$(br2)Only movement affected" + }, + { + "type": "patchouli:text", + "title": "Mittens Restrictions", + "text": "$(bold)Mittens add hand restrictions:$(br2)$(thing)Effects:$()$(br)$(li) Cannot interact with world$(br)$(li) Cannot use items$(br)$(li) Cannot break/place blocks$(br)$(li) Can punch (0 damage)$(br2)$(thing)Special:$()$(br)$(li) Works even without bind$(br)$(li) Complete hand restriction" + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/bounty_system.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/bounty_system.json new file mode 100644 index 0000000..5960f18 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/bounty_system.json @@ -0,0 +1,38 @@ +{ + "name": "Bounty System", + "icon": "minecraft:gold_ingot", + "category": "tiedup:gameplay", + "sortnum": 5, + "pages": [ + { + "type": "patchouli:text", + "title": "Bounty System", + "text": "$(bold)Place bounties on players.$(br2)$(thing)How It Works:$()$(br)$(li) Hold reward item in main hand$(br)$(li) Run /bounty $(br)$(li) Item consumed on creation$(br)$(li) Kidnappers hunt target$(br)$(li) Reward delivered on capture$(br2)$(thing)View Bounties:$()$(br)Press $(k:key.tiedup.bounties)" + }, + { + "type": "patchouli:text", + "title": "Creating a Bounty", + "text": "$(thing)Command:$()$(br)/bounty $(br2)$(thing)Requirements:$()$(br)$(li) Hold reward item in main hand$(br)$(li) Item consumed on creation$(br)$(li) Duration set by server$(br)$(li) Max bounties per player limited$(br2)$(thing)Restrictions:$()$(br)$(li) Cannot bounty yourself$(br)$(li) Cannot be tied up" + }, + { + "type": "patchouli:text", + "title": "Bounty Rules", + "text": "$(thing)To Create Bounty:$()$(br)$(bold)Must be a player$()$(br)$(bold)Cannot bounty yourself$()$(br)$(bold)Cannot be tied up$()$(br)$(bold)Must hold reward item$()$(br)$(bold)Max bounties limit$() (server setting)$(br2)$(thing)On Success:$()$(br)Bounty created$(br)Reward item consumed$(br)All players notified" + }, + { + "type": "patchouli:text", + "title": "Bounty Information", + "text": "$(thing)Each Bounty Shows:$()$(br)$(bold)Creator:$() Who posted it$(br)$(bold)Target:$() Who to capture$(br)$(bold)Reward:$() What you'll receive$(br)$(bold)Time:$() When it expires$(br2)$(thing)Expiration:$()$(br)Bounties expire after time limit$(br)Expired bounties removed automatically" + }, + { + "type": "patchouli:text", + "title": "How Bounties Work", + "text": "$(thing)Kidnapper Behavior:$()$(br)$(li) Kidnappers target bounty players$(br)$(li) Hunt and capture them$(br)$(li) Deliver to bounty creator$(br2)$(thing)Reward Delivery:$()$(br)$(li) Creator receives reward$(br)$(li) If offline: saved for later$(br)$(li) Bounty removed when complete$(br2)All players see when bounty is posted" + }, + { + "type": "patchouli:text", + "title": "Viewing Bounties", + "text": "$(thing)Bounty List Screen:$()$(br)$(li) Press $(k:key.tiedup.bounties)$(br)$(li) View all active bounties$(br)$(li) See creator and target$(br)$(li) Check time remaining$(br2)$(thing)Features:$()$(br)$(li) Auto-updates$(br)$(li) Removes expired bounties$(br)$(li) Saves pending rewards$(br)$(li) Notifies creator and target" + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/captivity_prison.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/captivity_prison.json new file mode 100644 index 0000000..5a23c09 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/captivity_prison.json @@ -0,0 +1,48 @@ +{ + "name": "Captivity & Prison", + "icon": "minecraft:lead", + "category": "tiedup:gameplay", + "sortnum": 4, + "pages": [ + { + "type": "patchouli:text", + "title": "Captivity & Prison", + "text": "$(bold)Two interconnected systems.$(br2)$(thing)Captivity:$()$(br)Leash-based slave system$(br)Invisible leash connects captive to master$(br2)$(thing)Prison:$()$(br)Collar creates zone boundary$(br)Radius: 3-1000 blocks$(br)Auto-return and shock on escape" + }, + { + "type": "patchouli:text", + "title": "How to Capture", + "text": "$(bold)Requirements to capture a target:$(br2)$(thing)Target Must:$()$(br)$(li) Be tied up$(br)OR$(br)$(li) Wear your collar$(br2)$(thing)Also:$()$(br)$(li) Must not already be someone's captive$(br)$(li) You must allow captures in settings$(br2)Once captured, an invisible leash connects you to the target." + }, + { + "type": "patchouli:text", + "title": "Leash System", + "text": "$(bold)Invisible leash connects you to captives.$(br2)$(thing)How It Works:$()$(br)$(li) Leash appears at neck height$(br)$(li) Completely invisible entity$(br)$(li) Follows player automatically$(br)$(li) Renders like normal leash$(br2)$(thing)NPCs:$()$(br)NPCs use vanilla leash mechanics directly" + }, + { + "type": "patchouli:text", + "title": "Freeing Captives", + "text": "$(bold)Release or transfer captives.$(br2)$(thing)Free Captive:$()$(br)$(li) Removes leash$(br)$(li) Drops lead item$(br)$(li) Captive is released$(br2)$(thing)Transfer Captive:$()$(br)$(li) Move to new master$(br)$(li) Requires permission$(br)$(li) Rebinds leash to new master$(br2)$(thing)Multi-Captive:$()$(br)Hold multiple slaves at once" + }, + { + "type": "patchouli:text", + "title": "Prison Zones", + "text": "$(bold)Collar creates enforced zone.$(br2)$(thing)How It Works:$()$(br)$(li) Prison location stored in collar$(br)$(li) Circular boundary around center$(br)$(li) Radius: 3-1000 blocks (default 10)$(br)$(li) Can set home location too$(br2)$(thing)Features:$()$(br)$(li) Auto-return on escape$(br)$(li) Fence mode available" + }, + { + "type": "patchouli:text", + "title": "Prison Settings", + "text": "$(thing)Configure via collar:$()$(br2)$(bold)Prison Location:$()$(br)Set center of zone$(br2)$(bold)Radius:$()$(br)Size of boundary (3-1000)$(br2)$(bold)Fence Mode:$()$(br)Kidnappers avoid this zone$(br2)$(bold)Home:$()$(br)Respawn point on death$(br2)$(bold)Auto-Return:$()$(br)Teleport back on escape" + }, + { + "type": "patchouli:text", + "title": "Prison Enforcement", + "text": "$(thing)Zone Boundary:$()$(br)Circular area around prison center$(br2)$(thing)When Escaping:$()$(br)$(li) Triggers security checks$(br)$(li) Shock collar activates$(br)$(li) Force teleport back$(br)$(li) Master gets notified$(br2)$(thing)Fence Mode:$()$(br)Kidnappers won't target players already inside prison zone" + }, + { + "type": "patchouli:text", + "title": "Tips & Tricks", + "text": "$(thing)Leash System:$()$(br)$(li) Leash appears at neck height$(br)$(li) Looks like normal Minecraft leash$(br)$(li) Automatically follows player$(br2)$(thing)Trading Captives:$()$(br)$(li) Can transfer to other players$(br)$(li) Requires permission from captor$(br2)$(thing)Prison:$()$(br)$(li) Check radius in collar GUI$(br)$(li) Set home for respawn$(br)$(li) Fence prevents re-capture$(br2)See $(l:tiedup:camps_prisons/cell_system)Cell System$(/l) for camp-based prisons." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/interfaces.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/interfaces.json new file mode 100644 index 0000000..da80291 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/interfaces.json @@ -0,0 +1,48 @@ +{ + "name": "Interfaces", + "icon": "minecraft:ender_eye", + "category": "tiedup:gameplay", + "sortnum": 1, + "pages": [ + { + "type": "patchouli:text", + "title": "Interfaces", + "text": "$(bold)5 GUI systems for bondage management.$(br2)$(thing)3 Screens:$()$(br)$(li) $(k:key.tiedup.bondage_inventory) - Bondage Inventory$(br)$(li) $(k:key.tiedup.adjustment_screen) - Adjustment$(br)$(li) $(k:key.tiedup.slave_management) - Slave Management$(br2)$(thing)2 HUD Overlays:$()$(br)$(li) Status Overlay (icons)$(br)$(li) Progress Overlay (bar)" + }, + { + "type": "patchouli:text", + "title": "Bondage Inventory (J)", + "text": "$(bold)View and manage your equipment.$(br2)$(thing)Features:$()$(br)$(li) 7 equipment slots display$(br)$(li) 3D player preview (auto-rotate)$(br)$(li) Right-click to remove unlocked items$(br)$(li) Adjust button if gag/blindfold present$(br2)$(thing)Width:$() 320-450px (50% screen)" + }, + { + "type": "patchouli:text", + "title": "Bondage Inventory", + "text": "$(thing)Equipment Slots:$()$(br)$(li) Show item icons$(br)$(li) Display lock status$(br)$(li) Show resistance value$(br)$(li) Visual lock indicator$(br2)$(thing)3D Preview:$()$(br)$(li) Shows your player model$(br)$(li) Displays equipped items$(br)$(li) Auto-rotates$(br)$(li) Updates in real-time" + }, + { + "type": "patchouli:text", + "title": "Adjustment Screen (K)", + "text": "$(bold)Adjust gag/blindfold position.$(br2)$(thing)Controls:$()$(br)$(li) Slider for vertical position$(br)$(li) Real-time 3D preview$(br)$(li) Tabs: GAG / BLINDFOLD / BOTH$(br2)$(thing)Usage:$()$(br)Drag slider to move item up/down on your face. Changes save automatically." + }, + { + "type": "patchouli:text", + "title": "Slave Management (L)", + "text": "$(bold)Master dashboard for all slaves.$(br2)$(thing)Shows:$()$(br)$(li) Leashed captives$(br)$(li) Nearby collar-linked (100 blocks)$(br2)$(thing)For Each Slave:$()$(br)$(li) Name$(br)$(li) Collar type$(br)$(li) Current status$(br)$(li) Action buttons" + }, + { + "type": "patchouli:text", + "title": "Slave Management Actions", + "text": "$(thing)Available Actions:$()$(br2)$(bold)Adjust:$() Remote adjustment$(br)Adjust slave's gag/blindfold$(br2)$(bold)Shock:$() Trigger shock collar$(br2)$(bold)Locate:$() Show GPS position$(br2)$(bold)Free:$() Release captive$(br2)$(bold)Refresh:$() Update nearby list" + }, + { + "type": "patchouli:text", + "title": "Status Overlay (HUD)", + "text": "$(bold)Top-left corner status icons.$(br2)$(thing)Shows when equipped:$()$(br)$(li) $(#8B4513)B$() - Bind (brown)$(br)$(li) $(#FF5555)G$() - Gag (red)$(br)$(li) $(#555555)X$() - Blindfold (dark gray)$(br)$(li) $(#FFFF55)D$() - Earplugs (yellow)$(br)$(li) $(#55FF55)C$() - Collar (green)$(br)$(li) $(#FF8800)M$() - Mittens (orange)$(br2)Small icons with semi-transparent background" + }, + { + "type": "patchouli:text", + "title": "Progress Overlay (HUD)", + "text": "$(bold)Tying/untying progress bar.$(br2)$(thing)Position:$()$(br)Centered above hotbar$(br2)$(thing)Shows:$()$(br)$(li) Completion percentage$(br)$(li) Action description$(br)$(li) Smooth animation$(br2)$(thing)Colors:$()$(br)$(#FF8800)Orange$() for tying$(br)$(#55FF55)Green$() for untying" + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/npc_interaction.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/npc_interaction.json new file mode 100644 index 0000000..dbc5965 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/npc_interaction.json @@ -0,0 +1,43 @@ +{ + "name": "NPC Interaction", + "icon": "minecraft:player_head", + "category": "tiedup:gameplay", + "sortnum": 8, + "pages": [ + { + "type": "patchouli:text", + "title": "NPC Interaction", + "text": "$(bold)Owned NPCs have personality, needs, and relationships.$(br2)Use the $(thing)Command Wand$() to view an NPC's status and give orders. Feed them, assign a home, and talk to them. How you interact shapes the relationship over time." + }, + { + "type": "patchouli:text", + "title": "Personality", + "text": "$(bold)11 personality types affect behavior.$(br2)Each NPC spawns with a personality that determines how they react to training, struggle, and commands.$(br2)$(thing)Examples:$()$(br)$(li) $(#55FF55)Submissive$() — Trains fastest, compliant$(br)$(li) $(#FFAA00)Proud$() — Hard to train, holds grudges$(br)$(li) $(#FF5555)Fierce$() — Fights back hard, resists orders$(br)$(li) $(#AA00AA)Playful$() — Balanced, moderate$(br2)Personality is hidden at first. Observe the NPC to discover it." + }, + { + "type": "patchouli:text", + "title": "Personality Discovery", + "text": "$(thing)UNKNOWN$() (0-4 observations):$() \"Unknown\"$(br)$(thing)GLIMPSE$() (5-9):$() \"???\" with a hint$(br)$(thing)KNOWN$() (10+):$() Full personality name$(br2)Observations build up through using the Command Wand, interacting, and watching behavior over time." + }, + { + "type": "patchouli:text", + "title": "Needs", + "text": "$(bold)NPCs have Hunger and Rest.$(br2)$(thing)Hunger:$()$(br)$(li) Decays over time$(br)$(li) Feed with food items (right-click)$(br)$(li) Starving NPCs refuse commands$(br2)$(thing)Rest:$()$(br)$(li) Drains during work$(br)$(li) Assign a $(thing)Pet Bed$() or $(thing)Bed$() as home$(br)$(li) NPCs recover rest by sitting or sleeping$(br)$(li) Exhaustion reduces speed and efficiency$(br2)Well-fed and rested NPCs are in a better mood and obey more reliably." + }, + { + "type": "patchouli:text", + "title": "Relationships", + "text": "$(bold)4 relationship values per player.$(br2)$(thing)Affinity:$() How much the NPC likes you (-100 to +100)$(br)$(thing)Trust:$() How reliable they see you (0-100)$(br)$(thing)Fear:$() How terrified they are (0-100)$(br)$(thing)Respect:$() How much authority you hold (0-100)$(br2)These combine into a relationship type: Stranger, Captor, Owner, Master, Beloved, or Enemy." + }, + { + "type": "patchouli:text", + "title": "Dialogue", + "text": "$(bold)NPCs speak based on mood, needs, and personality.$(br2)$(thing)Types:$()$(br)$(li) $(bold)Idle$() — Random comments while wandering$(br)$(li) $(bold)Approach$() — When you walk near them$(br)$(li) $(bold)Needs$() — When hungry or tired$(br)$(li) $(bold)Mood$() — Reflects happiness or misery$(br2)Each personality type has unique dialogue lines. NPCs talk more when their mood is moderate. Very low mood or high fear suppresses conversation." + }, + { + "type": "patchouli:text", + "title": "Commands Overview", + "text": "$(bold)15 commands unlocked by training level.$(br2)$(thing)Basic$() (WILD+):$() Follow, Stay, Come, Idle, Go Home$(br)$(thing)Poses$() (COMPLIANT+):$() Sit, Kneel$(br)$(thing)Jobs$() (TRAINED+):$() Patrol, Guard, Fetch, Collect, Farm, Cook, Transfer, Shear$(br)$(thing)Advanced$() (DEVOTED):$() Trainer$(br2)Command success depends on conditioning, personality, mood, and relationship. DEVOTED, SUBJUGATED, and BROKEN NPCs always obey." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/struggle_escape.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/struggle_escape.json new file mode 100644 index 0000000..8b54b57 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/struggle_escape.json @@ -0,0 +1,48 @@ +{ + "name": "Struggle & Escape", + "icon": "minecraft:iron_bars", + "category": "tiedup:gameplay", + "sortnum": 3, + "pages": [ + { + "type": "patchouli:text", + "title": "Struggle & Escape", + "text": "$(bold)Press $(k:key.tiedup.struggle) to start escaping.$(br2)$(thing)Two Minigames:$()$(br)$(li) $(bold)QTE Struggle$() — Quick 5-key sequence$(br)$(li) $(bold)Continuous Struggle$() — Hold changing directions$(br2)$(thing)Also:$()$(br)$(li) $(bold)Lockpick$() — Pick locked items$(br)$(li) $(bold)Collar Unlock$() — Separate from binds$(br2)Success reduces the bind's $(thing)resistance$(). When resistance reaches 0, you break free." + }, + { + "type": "patchouli:text", + "title": "QTE Struggle", + "text": "$(bold)5-key sequence under time pressure.$(br2)$(thing)How:$()$(br)$(li) A random 5-key sequence appears (WASD)$(br)$(li) Press each key in order$(br)$(li) 3-second timer for the whole sequence$(br2)$(thing)Result:$()$(br)$(li) All 5 correct: Reduces resistance$(br)$(li) Wrong key: Immediate fail$(br)$(li) Timer expires: Immediate fail$(br2)$(thing)Cooldown:$() 10 seconds between attempts" + }, + { + "type": "patchouli:text", + "title": "Continuous Struggle", + "text": "$(bold)Hold the displayed direction to wear down binds.$(br2)$(thing)How:$()$(br)$(li) A direction arrow appears (WASD)$(br)$(li) Hold the matching key$(br)$(li) Direction changes every 3-5 seconds$(br)$(li) Release and re-press when it changes$(br2)$(thing)Progress:$() 1 resistance per second while holding correctly$(br)$(thing)No cooldown$() — struggle continuously until free$(br2)$(thing)Warning:$() Alerts nearby kidnappers and guards within 32 blocks." + }, + { + "type": "patchouli:text", + "title": "Knife Bonuses", + "text": "$(bold)Best knife in inventory used automatically.$(br2)$(thing)Stone Knife:$()$(br)+15% success chance, 1.5x faster$(br2)$(thing)Iron Knife:$()$(br)+30% success chance, 2x faster$(br2)$(thing)Golden Knife:$()$(br)+45% success chance, 3x faster$(br2)Maximum 100% chance. Knives are consumed during cutting." + }, + { + "type": "patchouli:text", + "title": "Locked Binds", + "text": "$(bold)Locked binds are much harder to escape.$(br2)$(thing)Penalty:$() +250 resistance added$(br)$(thing)Example:$() 100 → 350$(br2)$(thing)On Success:$()$(br)$(li) Padlock destroyed$(br)$(li) Bind drops$(br)$(li) You are freed$(br2)Use the $(l:tiedup:tools_items/lock_system)Lockpick Minigame$(/l) for a faster alternative." + }, + { + "type": "patchouli:text", + "title": "Collar Unlock", + "text": "$(bold)Unlock collar (doesn't remove it).$(br2)$(thing)Requirements:$()$(br)$(li) Must NOT be tied up$(br)$(li) Collar must be locked$(br2)$(thing)Success:$()$(br)$(li) Collar becomes unlocked$(br)$(li) Collar stays on$(br)$(li) No items dropped$(br2)$(thing)Shock Collar:$()$(br)Random chance to shock on each attempt. If shocked, the attempt is interrupted and cooldown still applies." + }, + { + "type": "patchouli:text", + "title": "Tightening", + "text": "$(bold)Captors can restore resistance via $(k:key.tiedup.tighten).$(br2)$(thing)Tighten Key:$() Restores resistance to full$(br)$(thing)Whip:$() Damages and weakens resistance$(br2)A tightened bind resets your escape progress completely. Try to escape while the captor is distracted." + }, + { + "type": "patchouli:text", + "title": "Escape Tips", + "text": "$(thing)Best Practices:$()$(br)$(li) Get a golden knife for 3x speed$(br)$(li) Continuous struggle has no cooldown$(br)$(li) Locked items add +250 resistance$(br)$(li) Shock collars interrupt attempts$(br)$(li) Struggling alerts nearby guards$(br2)$(thing)Strategy:$()$(br)Continuous struggle is faster for low-resistance binds. QTE is better for high-resistance with a knife bonus." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/tying_untying.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/tying_untying.json new file mode 100644 index 0000000..1e1eff6 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/gameplay/tying_untying.json @@ -0,0 +1,48 @@ +{ + "name": "Tying & Untying", + "icon": "minecraft:chain", + "category": "tiedup:gameplay", + "sortnum": 2, + "pages": [ + { + "type": "patchouli:text", + "title": "Tying & Untying", + "text": "$(bold)Duration-based actions with visual feedback.$(br2)$(thing)3 Actions:$()$(br)$(li) $(bold)Tie Others:$() Right-click with bind item$(br)$(li) $(bold)Self-Bondage:$() Left-click with bind item$(br)$(li) $(bold)Untie Others:$() Right-click with empty hand$(br2)All show a progress bar above the hotbar. Stop interacting for 2 seconds to cancel." + }, + { + "type": "patchouli:text", + "title": "Tying Others", + "text": "$(bold)Right-click target while holding a bind.$(br2)$(thing)Process:$()$(br)$(li) Hold right-click on target$(br)$(li) Progress bar fills (default 5s)$(br)$(li) Item consumed on completion$(br2)$(thing)Requirements:$()$(br)$(li) Max 4 blocks distance$(br)$(li) Line-of-sight required$(br)$(li) Cannot tie while tied yourself$(br2)$(thing)Duration:$() Configurable via GameRule $(bold)tyingPlayerTime$()" + }, + { + "type": "patchouli:text", + "title": "Self-Bondage", + "text": "$(bold)Hold left-click (attack) with a bondage item.$(br2)$(thing)Binds:$()$(br)$(li) Hold left-click with bind item$(br)$(li) Same duration as tying others$(br)$(li) Cannot self-tie if already tied$(br2)$(thing)Accessories (instant):$()$(br)$(li) Gag, Blindfold, Mittens, Earplugs$(br)$(li) Left-click once to equip$(br)$(li) Cannot equip if arms are bound$(br2)$(thing)Collar:$() $(#FF5555)Cannot be self-applied.$()" + }, + { + "type": "patchouli:text", + "title": "Accessories on Others", + "text": "$(bold)Right-click a tied target with an accessory.$(br2)$(thing)Items:$() Gag, Blindfold, Mittens, Earplugs$(br)$(thing)Requirement:$() Target must already be tied$(br)$(thing)Speed:$() Instant (no progress bar)$(br2)$(thing)Swapping:$()$(br)If target already has one equipped and it is not locked, the old item drops and the new one replaces it." + }, + { + "type": "patchouli:text", + "title": "Untying Others", + "text": "$(bold)Right-click a tied target with an empty main hand.$(br2)$(thing)Process:$()$(br)$(li) Hold right-click (empty hand)$(br)$(li) Progress bar fills (default 10s)$(br)$(li) All items drop on the ground$(br)$(li) Leash broken if captive$(br2)$(thing)Requirements:$()$(br)$(li) Cannot untie while tied yourself$(br)$(li) Max 4 blocks distance + line-of-sight$(br2)$(thing)Duration:$() Configurable via GameRule $(bold)untyingPlayerTime$()" + }, + { + "type": "patchouli:text", + "title": "Untying Warnings", + "text": "$(thing)Kidnapper Fight-Back:$()$(br)If a kidnapper is within 16 blocks, they attack the helper and block the untying.$(br2)$(thing)Collar Ownership:$()$(br)Untying a collared damsel you don't own takes $(bold)3x longer$(). The collar owner is alerted to your location." + }, + { + "type": "patchouli:text", + "title": "Self-Removal", + "text": "$(bold)Remove your own equipment via Bondage Inventory ($(k:key.tiedup.bondage_inventory)).$(br2)$(thing)Rules:$()$(br)$(li) Must NOT be tied up$(br)$(li) Mittens block removing other items$(br)$(li) Locked items cannot be removed$(br)$(li) Items return to inventory (or drop if full)$(br2)To remove equipment while tied, you must struggle free first." + }, + { + "type": "patchouli:text", + "title": "Progress Bar", + "text": "$(thing)Visual Feedback:$()$(br)$(li) Bar centered above hotbar$(br)$(li) Shows percentage and action text$(br2)$(thing)Colors:$()$(br)$(#FF8800)Orange$() for tying$(br)$(#55FF55)Green$() for untying$(br2)$(thing)Cancellation:$()$(br)$(li) Stop interacting for ~2 seconds$(br)$(li) Move beyond 4 blocks$(br)$(li) Lose line-of-sight$(br)$(li) Right-click ground with bind item" + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/items/binds.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/items/binds.json new file mode 100644 index 0000000..1f05a81 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/items/binds.json @@ -0,0 +1,115 @@ +{ + "name": "Binds", + "icon": "tiedup:ropes", + "category": "tiedup:items", + "sortnum": 1, + "pages": [ + { + "type": "patchouli:spotlight", + "item": "tiedup:ropes", + "title": "Binds", + "text": "$(bold)Main restraints for arms and legs.$(br2)Binds restrict movement and actions. The tighter the material, the harder to escape." + }, + { + "type": "patchouli:text", + "title": "Characteristics", + "text": "$(thing)Slot:$() BIND$(br)$(thing)Base Resistance:$() 100$(br)$(thing)Effects:$()$(br)$(li) 90% speed reduction (legs bound)$(br)$(li) Cannot break/place blocks (arms)$(br)$(li) Cannot attack (arms)$(br2)$(thing)Modes:$() FULL / ARMS / LEGS" + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:ropes", + "title": "Ropes", + "text": "$(bold)Basic rope restraint.$(br2)$(thing)Resistance:$() 100$(br)$(thing)Escape:$() Easy with knife$(br)$(thing)Variants:$() 16 colors$(br2)Classic binding material, beginner-friendly." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:duct_tape", + "title": "Duct Tape", + "text": "$(bold)Sticky adhesive binding.$(br2)$(thing)Resistance:$() 90$(br)$(thing)Material:$() Duct tape$(br)$(thing)Variants:$() Multiple colors$(br2)Versatile binding, hard to remove." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:shibari", + "title": "Shibari", + "text": "$(bold)Artistic Japanese rope art.$(br2)$(thing)Resistance:$() 120$(br)$(thing)Tying time:$() Longer$(br)$(thing)Variants:$() 16 colors$(br2)Complex knot patterns, visually distinctive." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:straitjacket", + "title": "Straitjacket", + "text": "$(bold)Full-body canvas restraint.$(br2)$(thing)Resistance:$() 250$(br)$(thing)Animation:$() Unique wrap pose$(br)$(thing)Variants:$() None$(br2)Complete arm immobilization, asylum aesthetic." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:armbinder", + "title": "Armbinder", + "text": "$(bold)Leather arm restraint.$(br2)$(thing)Resistance:$() 180$(br)$(thing)Style:$() Arms behind back$(br)$(thing)Variants:$() None$(br2)Secure arm binding, single sleeve design." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:wrap", + "title": "Wrap", + "text": "$(bold)Mummy-style wrapping.$(br2)$(thing)Resistance:$() 200$(br)$(thing)Animation:$() Wrap pose$(br)$(thing)Variants:$() None$(br2)Full body encasement, layered restraint." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:latex_sack", + "title": "Latex Sack", + "text": "$(bold)Full enclosure sleeping bag.$(br2)$(thing)Resistance:$() 300$(br)$(thing)Animation:$() Cocoon pose$(br)$(thing)Variants:$() None$(br2)Total body encapsulation, extreme restriction." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:chain", + "title": "Chain", + "text": "$(bold)Metal chain restraint.$(br2)$(thing)Resistance:$() 150$(br)$(thing)Pose:$() Standard$(br)$(thing)Variants:$() None$(br2)Heavy metal binding, strong resistance." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:ribbon", + "title": "Ribbon", + "text": "$(bold)Decorative ribbon binding.$(br2)$(thing)Resistance:$() 50$(br)$(thing)Pose:$() Standard$(br)$(thing)Variants:$() None$(br2)Light and decorative, low resistance." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:slime", + "title": "Slime", + "text": "$(bold)Sticky slime restraint.$(br2)$(thing)Resistance:$() 80$(br)$(thing)Pose:$() Standard$(br)$(thing)Variants:$() None$(br2)Gelatinous binding, moderate resistance." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:vine_seed", + "title": "Vine Seed", + "text": "$(bold)Living vine restraint.$(br2)$(thing)Resistance:$() 60$(br)$(thing)Pose:$() Standard$(br)$(thing)Variants:$() None$(br2)Organic binding that grows around the target." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:web_bind", + "title": "Web Bind", + "text": "$(bold)Spider web restraint.$(br2)$(thing)Resistance:$() 70$(br)$(thing)Pose:$() Standard$(br)$(thing)Variants:$() None$(br2)Sticky webbing, difficult to tear." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:leather_straps", + "title": "Leather Straps", + "text": "$(bold)Leather strap arm restraint.$(br2)$(thing)Resistance:$() 180$(br)$(thing)Pose:$() Standard$(br)$(thing)Variants:$() None$(br2)Tight leather straps, armbinder-level security." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:medical_straps", + "title": "Medical Straps", + "text": "$(bold)Medical-grade strap restraint.$(br2)$(thing)Resistance:$() 180$(br)$(thing)Pose:$() Standard$(br)$(thing)Variants:$() None$(br2)Clinical restraint, armbinder-level security." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:beam_cuffs", + "title": "Beam Cuffs", + "text": "$(bold)Rigid beam handcuffs.$(br2)$(thing)Resistance:$() 150$(br)$(thing)Pose:$() Standard$(br)$(thing)Variants:$() None$(br2)Metal beam cuffs, chain-level resistance." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:dogbinder", + "title": "Dogbinder", + "text": "$(bold)Pet play arm restraint.$(br2)$(thing)Resistance:$() 180$(br)$(thing)Pose:$() Dog$(br)$(thing)Variants:$() None$(br2)Forces dog pose, armbinder-level security. Used in pet play systems." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/items/blindfolds.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/items/blindfolds.json new file mode 100644 index 0000000..a7ffe3f --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/items/blindfolds.json @@ -0,0 +1,37 @@ +{ + "name": "Blindfolds", + "icon": "tiedup:classic_blindfold", + "category": "tiedup:items", + "sortnum": 3, + "pages": [ + { + "type": "patchouli:spotlight", + "item": "tiedup:classic_blindfold", + "title": "Blindfolds", + "text": "$(bold)Vision-blocking equipment.$(br2)Blindfolds reduce field of view and add darkness overlay. Cannot see clearly when equipped." + }, + { + "type": "patchouli:text", + "title": "Characteristics", + "text": "$(thing)Slot:$() BLINDFOLD$(br)$(thing)Base Resistance:$() 0$(br)$(thing)Effects:$()$(br)$(li) Darkness overlay$(br)$(li) Reduced FOV$(br)$(li) Limited vision$(br)$(li) Dangerous movement$(br2)$(thing)Adjustable:$() Y-position" + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:classic_blindfold", + "title": "Classic Blindfold", + "text": "$(bold)Traditional cloth eye cover.$(br2)$(thing)Resistance:$() 0$(br)$(thing)Style:$() Simple cloth strip$(br)$(thing)Variants:$() 16 colors$(br2)Basic vision blocking, adjustable position." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:blindfold_mask", + "title": "Blindfold Mask", + "text": "$(bold)Full-coverage face mask.$(br2)$(thing)Resistance:$() 0$(br)$(thing)Style:$() Padded mask$(br)$(thing)Variants:$() 16 colors$(br2)Complete vision blocking, more secure." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:hood", + "title": "Hood (Combo)", + "text": "$(bold)Combined blindfold and gag.$(br2)$(thing)Slots:$() BLINDFOLD + GAG$(br)$(thing)Resistance:$() 0 each$(br)$(thing)Variants:$() None$(br2)Total sensory restriction, single item." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/items/collars.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/items/collars.json new file mode 100644 index 0000000..6bd1b08 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/items/collars.json @@ -0,0 +1,49 @@ +{ + "name": "Collars", + "icon": "tiedup:classic_collar", + "category": "tiedup:items", + "sortnum": 4, + "pages": [ + { + "type": "patchouli:spotlight", + "item": "tiedup:classic_collar", + "title": "Collars", + "text": "$(bold)Neck restraints with advanced features.$(br2)Collars enable ownership tracking, prison systems, and remote control. Different types offer different capabilities." + }, + { + "type": "patchouli:text", + "title": "Characteristics", + "text": "$(thing)Slot:$() COLLAR$(br)$(thing)Base Resistance:$() 100$(br)$(thing)Common Features:$()$(br)$(li) Multiple owner tracking$(br)$(li) Locking system$(br)$(li) Prison coordinates$(br)$(li) Home position$(br)$(li) Kidnapping mode$(br2)$(thing)Advanced:$() Type-specific" + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:classic_collar", + "title": "Classic Collar", + "text": "$(bold)Basic ownership collar.$(br2)$(thing)Features:$()$(br)$(li) Ownership tracking$(br)$(li) Can be locked$(br)$(li) Prison system$(br)$(li) Home position$(br2)Foundation collar, all basic features." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:shock_collar", + "title": "Shock Collar", + "text": "$(bold)Remote punishment device.$(br2)$(thing)Features:$()$(br)$(li) All classic features$(br)$(li) Manual shocking$(br)$(li) Shocker Controller compatible$(br)$(li) Damage on shock$(br2)Controlled via remote device." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:shock_collar_auto", + "title": "Auto-Shock Collar", + "text": "$(bold)Automatic punishment collar.$(br2)$(thing)Features:$()$(br)$(li) All shock features$(br)$(li) Auto-shock on prison escape$(br)$(li) Configurable triggers$(br)$(li) Automated enforcement$(br2)No manual control needed." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:gps_collar", + "title": "GPS Collar", + "text": "$(bold)Location tracking collar.$(br2)$(thing)Features:$()$(br)$(li) All shock features$(br)$(li) GPS safe zones$(br)$(li) Distance tracking$(br)$(li) GPS Locator compatible$(br2)Advanced positioning system." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:choke_collar", + "title": "Choke Collar", + "text": "$(bold)Pet play control collar.$(br2)$(thing)Resistance:$() 250 (set by Master on application)$(br)$(thing)Effect:$() Drowning damage on activation$(br)$(thing)Controller:$() Master NPC$(br2)$(thing)Features:$()$(br)$(li) Controlled by Master NPC$(br)$(li) Activates as punishment$(br)$(li) Deactivated before lethal$(br)$(li) Cannot be self-applied or self-removed$(br2)Used in the pet play system. See $(l:tiedup:pet_play/master_system)Master System$(/l)." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/items/gags.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/items/gags.json new file mode 100644 index 0000000..f96977c --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/items/gags.json @@ -0,0 +1,145 @@ +{ + "name": "Gags", + "icon": "tiedup:ball_gag", + "category": "tiedup:items", + "sortnum": 2, + "pages": [ + { + "type": "patchouli:spotlight", + "item": "tiedup:ball_gag", + "title": "Gags", + "text": "$(bold)Mouth restraints that muffle speech.$(br2)Gags reduce chat comprehension and limit voice range. Material determines effectiveness." + }, + { + "type": "patchouli:text", + "title": "Characteristics", + "text": "$(thing)Slot:$() GAG$(br)$(thing)Base Resistance:$() 0$(br)$(thing)Effects:$()$(br)$(li) Muffles chat (material-based)$(br)$(li) Reduces comprehension %$(br)$(li) Limits voice range$(br)$(li) Long messages cause suffocation$(br2)$(thing)Adjustable:$() Y-position" + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:cloth_gag", + "title": "Cloth Gag", + "text": "$(bold)Basic cloth mouth covering.$(br2)$(thing)Comprehension:$() 40%$(br)$(thing)Range:$() 15 blocks$(br)$(thing)Variants:$() 16 colors$(br2)Mild muffling, easy to understand." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:ball_gag", + "title": "Ball Gag", + "text": "$(bold)Spherical mouth insert.$(br2)$(thing)Comprehension:$() 20%$(br)$(thing)Range:$() 10 blocks$(br)$(thing)Variants:$() 16 colors$(br2)Moderate muffling, classic design." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:tape_gag", + "title": "Tape Gag", + "text": "$(bold)Adhesive tape over mouth.$(br2)$(thing)Comprehension:$() 5%$(br)$(thing)Range:$() 5 blocks$(br)$(thing)Variants:$() Multiple colors$(br2)Strong muffling, hard to speak." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:panel_gag", + "title": "Panel Gag", + "text": "$(bold)Solid panel with strap.$(br2)$(thing)Comprehension:$() 5%$(br)$(thing)Range:$() 4 blocks$(br)$(thing)Variants:$() None$(br2)Very restrictive, minimal speech." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:latex_gag", + "title": "Latex Gag", + "text": "$(bold)Latex mouth covering.$(br2)$(thing)Comprehension:$() 10%$(br)$(thing)Range:$() 6 blocks$(br)$(thing)Variants:$() None$(br2)Tight seal, limited communication." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:tube_gag", + "title": "Tube Gag (Ring)", + "text": "$(bold)Open-mouth ring gag.$(br2)$(thing)Comprehension:$() 50%$(br)$(thing)Range:$() 12 blocks$(br)$(thing)Variants:$() None$(br2)Least restrictive, mouth held open." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:sponge_gag", + "title": "Sponge Gag", + "text": "$(bold)Sponge mouth filling.$(br2)$(thing)Comprehension:$() 0%$(br)$(thing)Range:$() 2 blocks$(br)$(thing)Variants:$() None$(br2)Complete silence, total muffling." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:ropes_gag", + "title": "Ropes Gag", + "text": "$(bold)Rope mouth restraint.$(br2)$(thing)Material:$() Cloth$(br)$(thing)Comprehension:$() 40%$(br)$(thing)Range:$() 15 blocks$(br)$(thing)Variants:$() 16 colors$(br2)Mild muffling, rope aesthetic." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:cleave_gag", + "title": "Cleave Gag", + "text": "$(bold)Cloth strip between teeth.$(br2)$(thing)Material:$() Cloth$(br)$(thing)Comprehension:$() 40%$(br)$(thing)Range:$() 15 blocks$(br)$(thing)Variants:$() 16 colors$(br2)Classic cleave style, mild restriction." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:ribbon_gag", + "title": "Ribbon Gag", + "text": "$(bold)Decorative ribbon gag.$(br2)$(thing)Material:$() Cloth$(br)$(thing)Comprehension:$() 40%$(br)$(thing)Range:$() 15 blocks$(br)$(thing)Variants:$() None$(br2)Light muffling, decorative style." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:ball_gag_strap", + "title": "Ball Gag (Strap)", + "text": "$(bold)Ball gag with strap variant.$(br2)$(thing)Material:$() Ball$(br)$(thing)Comprehension:$() 20%$(br)$(thing)Range:$() 10 blocks$(br)$(thing)Variants:$() 16 colors$(br2)Moderate muffling, strap fastening." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:wrap_gag", + "title": "Wrap Gag", + "text": "$(bold)Wrapped mouth covering.$(br2)$(thing)Material:$() Stuffed$(br)$(thing)Comprehension:$() 0%$(br)$(thing)Range:$() 3 blocks$(br)$(thing)Variants:$() None$(br2)Full coverage, complete muffling." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:slime_gag", + "title": "Slime Gag", + "text": "$(bold)Slime mouth filling.$(br2)$(thing)Material:$() Stuffed$(br)$(thing)Comprehension:$() 0%$(br)$(thing)Range:$() 3 blocks$(br)$(thing)Variants:$() None$(br2)Gelatinous fill, total silence." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:vine_gag", + "title": "Vine Gag", + "text": "$(bold)Living vine mouth gag.$(br2)$(thing)Material:$() Stuffed$(br)$(thing)Comprehension:$() 0%$(br)$(thing)Range:$() 3 blocks$(br)$(thing)Variants:$() None$(br2)Organic gag, complete muffling." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:web_gag", + "title": "Web Gag", + "text": "$(bold)Spider web mouth seal.$(br2)$(thing)Material:$() Stuffed$(br)$(thing)Comprehension:$() 0%$(br)$(thing)Range:$() 3 blocks$(br)$(thing)Variants:$() None$(br2)Sticky web seal, total silence." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:beam_panel_gag", + "title": "Beam Panel Gag", + "text": "$(bold)Rigid beam panel over mouth.$(br2)$(thing)Material:$() Panel$(br)$(thing)Comprehension:$() 5%$(br)$(thing)Range:$() 4 blocks$(br)$(thing)Variants:$() None$(br2)Heavy panel, near-total restriction." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:chain_panel_gag", + "title": "Chain Panel Gag", + "text": "$(bold)Chain-mounted panel gag.$(br2)$(thing)Material:$() Panel$(br)$(thing)Comprehension:$() 5%$(br)$(thing)Range:$() 4 blocks$(br)$(thing)Variants:$() None$(br2)Metal panel, near-total restriction." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:bite_gag", + "title": "Bite Gag", + "text": "$(bold)Bite bar mouth restraint.$(br2)$(thing)Material:$() Bite$(br)$(thing)Comprehension:$() 30%$(br)$(thing)Range:$() 10 blocks$(br)$(thing)Variants:$() None$(br2)Horizontal bar, moderate muffling." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:baguette_gag", + "title": "Baguette Gag", + "text": "$(bold)Bread-based mouth filling.$(br2)$(thing)Material:$() Baguette$(br)$(thing)Comprehension:$() 25%$(br)$(thing)Range:$() 8 blocks$(br)$(thing)Variants:$() None$(br2)Edible gag, moderate restriction." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:ball_gag_3d", + "title": "Ball Gag (3D)", + "text": "$(bold)3D-modeled ball gag.$(br2)$(thing)Material:$() Ball$(br)$(thing)Comprehension:$() 20%$(br)$(thing)Range:$() 10 blocks$(br)$(thing)Variants:$() 16 colors$(br2)OBJ-rendered variant with 3D ball model." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:medical_gag", + "title": "Medical Gag", + "text": "$(bold)Medical combo restraint.$(br2)$(thing)Type:$() Combo (gag + blindfold)$(br)$(thing)Style:$() Clinical$(br)$(thing)Variants:$() None$(br2)Combined mouth and eye restraint, medical aesthetic." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/items/other_accessories.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/items/other_accessories.json new file mode 100644 index 0000000..9189c9b --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/items/other_accessories.json @@ -0,0 +1,32 @@ +{ + "name": "Other Accessories", + "icon": "tiedup:mittens", + "category": "tiedup:items", + "sortnum": 5, + "pages": [ + { + "type": "patchouli:spotlight", + "item": "tiedup:mittens", + "title": "Other Accessories", + "text": "$(bold)Additional restraint equipment.$(br2)Three special accessories with unique effects: earplugs for hearing isolation, clothes for coverage, and mittens for hand restriction." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:classic_earplugs", + "title": "Earplugs", + "text": "$(bold)Hearing isolation device.$(br2)$(thing)Slot:$() EARPLUGS$(br)$(thing)Resistance:$() 0$(br)$(thing)Effects:$()$(br)$(li) Blocks gagged chat$(br)$(li) Cannot hear muffled speech$(br)$(li) Filters proximity messages$(br2)Total audio isolation from gagged players." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:clothes", + "title": "Clothes", + "text": "$(bold)Body covering garment.$(br2)$(thing)Slot:$() CLOTHES$(br)$(thing)Resistance:$() 0$(br)$(thing)Effects:$()$(br)$(li) Dynamic URL textures$(br)$(li) Full-skin mode$(br)$(li) Layer visibility control$(br2)Use /tiedup clothes to configure." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:mittens", + "title": "Mittens", + "text": "$(bold)Hand covering restraints.$(br2)$(thing)Slot:$() MITTENS$(br)$(thing)Resistance:$() 0$(br)$(thing)Effects:$()$(br)$(li) Cannot break/place blocks$(br)$(li) Cannot use items$(br)$(li) Cannot interact with world$(br)$(li) Can punch (0 damage)$(br2)Works even without arm binding!" + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/archer_kidnapper.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/archer_kidnapper.json new file mode 100644 index 0000000..62b87ab --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/archer_kidnapper.json @@ -0,0 +1,38 @@ +{ + "name": "Archer Kidnapper", + "icon": "minecraft:bow", + "category": "tiedup:npcs", + "sortnum": 4, + "pages": [ + { + "type": "patchouli:text", + "title": "Archer Kidnapper", + "text": "$(bold)Ranged kidnapper with rope arrows.$(br2)Archers attack from 10-25 blocks away using rope arrows that can bind targets. They only approach after target is tied.$(br2)$(thing)Type:$() Hostile (Ranged)$(br)$(thing)Health:$() 15 HP$(br)$(thing)Speed:$() 0.30" + }, + { + "type": "patchouli:text", + "title": "Archer Stats", + "text": "$(thing)Combat Style:$()$(br)$(li) Attack Range: 10-25 blocks$(br)$(li) Weapon: Bow + Rope Arrows$(br)$(li) Health: 15 (fragile)$(br)$(li) Knockback Resist: 0.3 (low)$(br2)Fast repositioning, avoids melee.$(br2)Name shows in $(#FF5555)red$()." + }, + { + "type": "patchouli:text", + "title": "Rope Arrow Mechanics", + "text": "$(bold)Cumulative bind chance.$(br2)$(thing)Base Chance:$() 10%$(br)$(thing)Per Hit:$() +10%$(br)$(thing)Maximum:$() 100%$(br2)$(bold)Example:$()$(br)1st shot: 10% chance$(br)2nd shot: 20% chance$(br)3rd shot: 30% chance$(br)...$(br)10th shot: 100% (guaranteed)" + }, + { + "type": "patchouli:text", + "title": "Capture Behavior", + "text": "$(thing)Phase 1: Ranged$()$(br)Shoots rope arrows$(br)Maintains 10-25 block distance$(br)Circles target$(br2)$(thing)Phase 2: Capture$()$(br)Once target is bound, approaches$(br)Capture time: 25 ticks (1.25s)$(br)Slower than regular kidnapper" + }, + { + "type": "patchouli:text", + "title": "Item Probabilities", + "text": "$(thing)Lower Equipment Chances:$()$(br)Bind: 100%$(br)Gag: $(#FFAA00)30%$() (vs 50%)$(br)Mittens: $(#FFAA00)20%$() (vs 40%)$(br)Earplugs: $(#FFAA00)15%$() (vs 30%)$(br)Blindfold: $(#FFAA00)10%$() (vs 20%)$(br2)Relies on arrows, less gear." + }, + { + "type": "patchouli:text", + "title": "Archer Skins", + "text": "$(bold)Dedicated archer variants.$(br2)$(thing)Named Archers:$()$(br)$(li) Bowy$(br)$(li) Arrowy$(br)$(li) Other archer skins$(br2)Always equipped with bow in main hand." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/damsel.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/damsel.json new file mode 100644 index 0000000..86a73b3 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/damsel.json @@ -0,0 +1,33 @@ +{ + "name": "Damsel", + "icon": "minecraft:player_head", + "category": "tiedup:npcs", + "sortnum": 1, + "pages": [ + { + "type": "patchouli:text", + "title": "Damsel", + "text": "$(bold)Capturable passive NPC.$(br2)Damsels are peaceful NPCs that can be restrained, collared, and enslaved. They panic and flee when threatened but won't fight back.$(br2)$(thing)Type:$() Passive$(br)$(thing)Health:$() 20 HP$(br)$(thing)Speed:$() 0.25 (slow)" + }, + { + "type": "patchouli:text", + "title": "Characteristics", + "text": "$(thing)AI Behavior:$()$(br)$(li) Flees from players$(br)$(li) Panics when hurt$(br)$(li) Wanders peacefully$(br)$(li) Calls for help when captive$(br2)$(thing)Full Bondage Support:$()$(br)All 7 equipment slots$(br)Renders items properly$(br)Can be tied to poles" + }, + { + "type": "patchouli:text", + "title": "Skin Variants", + "text": "$(thing)Total Variants:$() 39 skins$(br2)$(thing)Model Types:$()$(br)$(li) Steve (normal arms)$(br)$(li) Alex (slim arms)$(br2)$(thing)Names:$()$(br)Generic variants get random names$(br)Named variants keep their specific names" + }, + { + "type": "patchouli:text", + "title": "Bondage Service", + "text": "$(bold)Special collar feature.$(br2)When a damsel has a collar with:$(br)$(li) Prison configured$(br)$(li) Bondage Service enabled$(br2)$(thing)Behavior:$()$(br)$(bold)First hit:$() Warning message$(br)$(bold)Second hit:$() Capture and teleport to prison$(br2)Name shows in $(#FF55FF)violet$() when active." + }, + { + "type": "patchouli:text", + "title": "Interactions", + "text": "$(thing)Capturing:$()$(br)Tie them up to enslave$(br)Attach leash to lead$(br)Tie to pole to secure$(br2)$(thing)Slavery:$()$(br)Lead them around$(br)Put up for sale$(br)Transfer to other players$(br2)Never despawns when spawned" + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/elite_kidnapper.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/elite_kidnapper.json new file mode 100644 index 0000000..06fb9a4 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/elite_kidnapper.json @@ -0,0 +1,33 @@ +{ + "name": "Elite Kidnapper", + "icon": "minecraft:diamond_sword", + "category": "tiedup:npcs", + "sortnum": 3, + "pages": [ + { + "type": "patchouli:text", + "title": "Elite Kidnapper", + "text": "$(bold)Rare, faster, more dangerous variant.$(br2)Elite kidnappers are rare spawns with double health, higher speed, and faster capture times. They use higher item probabilities.$(br2)$(thing)Type:$() Hostile (Rare)$(br)$(thing)Health:$() 40 HP$(br)$(thing)Damage:$() 8 (4 hearts)$(br)$(thing)Speed:$() 0.35" + }, + { + "type": "patchouli:text", + "title": "Elite Stats", + "text": "$(thing)Differences from Regular:$()$(br)$(li) Health: 40 (vs 20)$(br)$(li) Damage: 8 (vs 6)$(br)$(li) Speed: 0.35 (vs 0.27)$(br)$(li) Knockback Resist: 0.9 (vs 0.7)$(br)$(li) Capture: 0.5s (vs 1s)$(br2)Name shows in $(#AA0000)dark red$()." + }, + { + "type": "patchouli:text", + "title": "Elite Skins", + "text": "$(bold)4 unique variants.$(br2)$(thing)Named Elites:$()$(br)$(li) Suki$(br)$(li) Carol$(br)$(li) Athena$(br)$(li) Evelyn$(br2)Each with unique skin and model type (Steve/Alex)." + }, + { + "type": "patchouli:text", + "title": "Enhanced Probabilities", + "text": "$(thing)Equipment Chances:$()$(br)Bind: 100% (same)$(br)Gag: $(#55FF55)100%$() (vs 50%)$(br)Mittens: $(#55FF55)80%$() (vs 40%)$(br)Earplugs: $(#55FF55)60%$() (vs 30%)$(br)Blindfold: $(#55FF55)40%$() (vs 20%)$(br2)Captives are more thoroughly restrained." + }, + { + "type": "patchouli:text", + "title": "Capture Speed", + "text": "$(bold)Half the time of regular kidnappers.$(br2)$(thing)Bind Time:$() 10 ticks$(br)$(0.5 seconds vs 1 second)$(br2)$(thing)Gag Time:$() 10 ticks$(br)$(0.5 seconds vs 1 second)$(br2)Much harder to escape during capture!" + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/kidnapper.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/kidnapper.json new file mode 100644 index 0000000..ffb02ab --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/kidnapper.json @@ -0,0 +1,43 @@ +{ + "name": "Kidnapper", + "icon": "minecraft:iron_sword", + "category": "tiedup:npcs", + "sortnum": 2, + "pages": [ + { + "type": "patchouli:text", + "title": "Kidnapper", + "text": "$(bold)Aggressive NPC that captures players.$(br2)Kidnappers hunt players and damsels, tie them up, and bring them to prison. They use themed bondage items and have complex AI.$(br2)$(thing)Type:$() Hostile$(br)$(thing)Health:$() 20 HP$(br)$(thing)Speed:$() 0.27" + }, + { + "type": "patchouli:text", + "title": "Combat Stats", + "text": "$(thing)Attributes:$()$(br)$(li) Damage: 6 (3 hearts)$(br)$(li) Knockback Resist: 0.7$(br)$(li) Follow Range: 60 blocks$(br2)$(thing)Capture Time:$()$(br)Bind: 20 ticks (1 second)$(br)Gag: 20 ticks (1 second)$(br2)Name shows in $(#FF5555)red$()." + }, + { + "type": "patchouli:text", + "title": "Item Themes", + "text": "$(bold)7 themed item sets.$(br2)Each kidnapper spawns with a random theme:$(br)$(li) ROPE (16 colors)$(br)$(li) DUCT_TAPE (colors)$(br)$(li) SHIBARI (16 colors)$(br)$(li) LEATHER (natural)$(br)$(li) LATEX (black)$(br)$(li) ASYLUM (white)$(br)$(li) PREMIUM (armbinder/wrap)" + }, + { + "type": "patchouli:text", + "title": "Item Probabilities", + "text": "$(thing)Equipment Chances:$()$(br)Bind: 100% (always)$(br)Gag: 50%$(br)Mittens: 40%$(br)Earplugs: 30%$(br)Blindfold: 20%$(br2)Applied progressively during capture phase." + }, + { + "type": "patchouli:text", + "title": "AI Phases", + "text": "$(thing)1. Hunt:$() Find targets$(br)$(thing)2. Capture:$() Tie up target$(br)$(thing)3. Prison:$() Bring to prison$(br)$(thing)4. Decision:$() Sell or Job$(br)$(thing)5. Sale:$() Wait for buyer$(br)$(thing)6. Job:$() Assign task$(br2)Fights back when attacked while holding captive." + }, + { + "type": "patchouli:text", + "title": "Kidnapping Mode", + "text": "$(bold)Collar-controlled targeting.$(br2)When wearing a collar with kidnapping mode ON:$(br)$(li) Auto-hunts players$(br)$(li) Uses whitelist/blacklist$(br)$(li) Brings to collar prison$(br)$(li) Can warn masters$(br)$(li) Auto ties to pole$(br2)Requires prison configured." + }, + { + "type": "patchouli:text", + "title": "Systems", + "text": "$(thing)Sale System:$()$(br)Sells captives for random price$(br)Waits for buyer with payment$(br2)$(thing)Job System:$()$(br)Assigns random task$(br)Gives shock collar$(br)Tracks completion$(br2)$(thing)Fight Back:$()$(br)Counter-attacks when hit while holding captive" + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/labor_guard.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/labor_guard.json new file mode 100644 index 0000000..5eb31ab --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/labor_guard.json @@ -0,0 +1,28 @@ +{ + "name": "Labor Guard", + "icon": "minecraft:iron_sword", + "category": "tiedup:npcs", + "sortnum": 10, + "pages": [ + { + "type": "patchouli:text", + "title": "Labor Guard", + "text": "$(bold)Labor task supervisor.$(br2)Labor Guards escort and monitor prisoners during forced labor tasks. They protect prisoners from mobs and enforce task completion.$(br2)$(thing)Type:$() Hostile$(br)$(thing)Health:$() 30 HP$(br)$(thing)Speed:$() 0.30$(br)$(thing)Damage:$() 6 (3 hearts)" + }, + { + "type": "patchouli:text", + "title": "Behavior", + "text": "$(thing)Escort:$()$(br)$(li) Follows prisoner during labor tasks$(br)$(li) Stays within monitoring range$(br2)$(thing)Combat:$()$(br)$(li) Hunts hostile monsters near prisoner$(br)$(li) Skips task-relevant mobs during combat tasks$(br2)$(thing)Monitoring:$()$(br)$(li) Tracks prisoner activity$(br)$(li) Detects inactivity after 30s (2min for combat)$(br)$(li) Escalating punishment on idle" + }, + { + "type": "patchouli:text", + "title": "Punishment Escalation", + "text": "$(bold)Escalating response to inactivity.$(br2)$(thing)Stage 1:$() Warning message$(br)$(thing)Stage 2:$() Shock$(br)$(thing)Stage 3:$() Tighten restraints$(br)$(thing)Stage 4:$() Task failure$(br2)Inactivity threshold: 30 seconds (2 minutes for combat tasks)." + }, + { + "type": "patchouli:text", + "title": "Escape", + "text": "$(bold)Escaping the Labor Guard.$(br2)$(thing)Distance:$()$(br)Leave 20-block radius from guard$(br)15 second countdown before recapture$(br2)$(thing)Tying:$()$(br)Tie up the guard to disable supervision$(br2)$(thing)Guard Death:$()$(br)If the guard dies, prisoner is freed from task.$(br2)Combat tasks at night are most vulnerable to escape." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/maid.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/maid.json new file mode 100644 index 0000000..5361d82 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/maid.json @@ -0,0 +1,23 @@ +{ + "name": "Maid", + "icon": "minecraft:iron_chestplate", + "category": "tiedup:npcs", + "sortnum": 7, + "pages": [ + { + "type": "patchouli:text", + "title": "Maid", + "text": "$(bold)SlaveTrader's personal servant.$(br2)Maids manage prisoners in camps: extracting them for labor, returning them after tasks, and defending the camp when needed.$(br2)$(thing)Type:$() Neutral$(br)$(thing)Health:$() 60 HP$(br)$(thing)Speed:$() 0.28$(br)$(thing)Damage:$() 12 (6 hearts)$(br)$(thing)Capture Time:$() 5 ticks (0.25s)" + }, + { + "type": "patchouli:text", + "title": "AI States", + "text": "$(thing)IDLE:$() Waiting at camp$(br)$(thing)DELIVERING:$() Bringing prisoner to labor area$(br)$(thing)EXTRACTING:$() Taking prisoner from cell$(br)$(thing)RETURNING:$() Bringing prisoner back to cell$(br)$(thing)DEFENDING:$() Fighting intruders$(br)$(thing)FREE:$() Trader died, now neutral" + }, + { + "type": "patchouli:text", + "title": "Detection & Behavior", + "text": "$(thing)Detection:$()$(br)$(li) Hearing range: 6 blocks (struggle sounds)$(br)$(li) Vision range: 15 blocks$(br2)$(thing)Prisoner Management:$()$(br)$(li) Extracts prisoners from cells for labor$(br)$(li) Returns prisoners after task completion$(br)$(li) Monitors for escape attempts$(br2)$(thing)On Trader Death:$()$(br)Becomes neutral, can be captured." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/master.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/master.json new file mode 100644 index 0000000..913c4fd --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/master.json @@ -0,0 +1,18 @@ +{ + "name": "Master", + "icon": "tiedup:choke_collar", + "category": "tiedup:npcs", + "sortnum": 9, + "pages": [ + { + "type": "patchouli:text", + "title": "Master", + "text": "$(bold)Pet play NPC, effectively unkillable.$(br2)The Master buys solo players from kidnappers and controls them with a choke collar. Uses an inverted follower system where the Master follows the player.$(br2)$(thing)Type:$() Hostile$(br)$(thing)Health:$() 150 HP$(br)$(thing)Armor:$() 10$(br)$(thing)Regen:$() 0.5/s$(br)$(thing)Speed:$() 0.30$(br)$(thing)Damage:$() 10 (5 hearts)" + }, + { + "type": "patchouli:text", + "title": "Behavior", + "text": "$(thing)Acquisition:$()$(br)$(li) Buys solo players from kidnappers$(br)$(li) Applies choke collar (resistance 250)$(br)$(li) Inverted leash: Master follows player$(br2)$(thing)Combat:$()$(br)150 HP, 10 armor, 0.5/s regen make direct combat nearly impossible. 95% knockback resistance.$(br2)For tasks, events, and escape details see $(l:tiedup:pet_play/master_system)Master System$(/l)." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/merchant_kidnapper.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/merchant_kidnapper.json new file mode 100644 index 0000000..8beda15 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/merchant_kidnapper.json @@ -0,0 +1,38 @@ +{ + "name": "Merchant Kidnapper", + "icon": "minecraft:gold_ingot", + "category": "tiedup:npcs", + "sortnum": 5, + "pages": [ + { + "type": "patchouli:text", + "title": "Merchant Kidnapper", + "text": "$(bold)Hybrid trader and elite kidnapper.$(br2)Merchants are neutral traders who sell mod items for gold. When attacked, they become hostile elite kidnappers!$(br2)$(thing)Type:$() Neutral/Hostile$(br)$(thing)Health:$() 40 HP$(br)$(thing)Speed:$() 0.35" + }, + { + "type": "patchouli:text", + "title": "Two Modes", + "text": "$(thing)MERCHANT Mode (Default):$()$(br)$(li) Neutral, won't attack$(br)$(li) Wanders peacefully$(br)$(li) Accepts trades$(br)$(li) $(#FFD700)⚜ Merchant ⚜$() name$(br)$(li) Gold particles$(br2)$(thing)HOSTILE Mode:$()$(br)$(li) Full elite kidnapper AI$(br)$(li) Hunts attacker$(br)$(li) $(#AA0000)Dark red$() name" + }, + { + "type": "patchouli:text", + "title": "Trading System", + "text": "$(bold)Sells 8-12 random mod items.$(br2)$(thing)Payment:$() Gold ingots/nuggets$(br)$(thing)Prices:$() Tier-based$(br)$(li) Basic binds: 1-2 gold$(br)$(li) Gags: 2-4 gold$(br)$(li) Collars: 5-10 gold$(br)$(li) GPS collar: 10-20 gold$(br2)Trades persist (saved in NBT)." + }, + { + "type": "patchouli:text", + "title": "Transition Triggers", + "text": "$(bold)MERCHANT → HOSTILE when:$()$(br)$(li) Attacked by player$(br)$(li) Someone tries to bind$(br)$(li) Someone tries to gag$(br)$(li) Someone tries to blindfold$(br2)$(bold)HOSTILE → MERCHANT when:$()$(br)$(li) 5 minutes pass$(br)$(li) OR attacker is captured$(br)$(li) OR attacker is sold" + }, + { + "type": "patchouli:text", + "title": "Hostile Behavior", + "text": "$(bold)Same stats as Elite Kidnapper.$(br2)$(thing)Combat:$()$(br)Health: 40 HP$(br)Speed: 0.35$(br)Capture: 0.5 seconds$(br2)$(thing)Target:$() Attacks the player who triggered hostile mode$(br2)Reverts to merchant after revenge!" + }, + { + "type": "patchouli:text", + "title": "Merchant Skins", + "text": "$(bold)Unique merchant variants.$(br2)Merchants have their own dedicated skins, separate from regular and elite kidnappers.$(br2)Always show golden particle effects when in MERCHANT mode." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/shiny_damsel.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/shiny_damsel.json new file mode 100644 index 0000000..86b70b6 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/shiny_damsel.json @@ -0,0 +1,23 @@ +{ + "name": "Shiny Damsel", + "icon": "minecraft:gold_ingot", + "category": "tiedup:npcs", + "sortnum": 6, + "pages": [ + { + "type": "patchouli:text", + "title": "Shiny Damsel", + "text": "$(bold)Rare fast damsel variant.$(br2)Shiny Damsels are uncommon spawns with enhanced speed and a golden sparkle particle effect. They use an exclusive skin pool and display a rainbow cycling name.$(br2)$(thing)Type:$() Passive$(br)$(thing)Health:$() 20 HP$(br)$(thing)Speed:$() 0.35 (fast)" + }, + { + "type": "patchouli:text", + "title": "Characteristics", + "text": "$(thing)Visual:$()$(br)$(li) Golden sparkle particles$(br)$(li) Rainbow cycling name$(br)$(li) Exclusive shiny skin pool$(br2)$(thing)Behavior:$()$(br)$(li) Same AI as normal Damsel$(br)$(li) Flees faster due to higher speed$(br)$(li) Harder to catch$(br2)$(thing)Spawn:$() Rare variant of normal Damsel spawns" + }, + { + "type": "patchouli:text", + "title": "Tips", + "text": "$(thing)Catching:$()$(br)$(li) Use ranged items (Rope Arrow, Taser)$(br)$(li) Corner them in narrow spaces$(br)$(li) Speed 0.35 outpaces normal sprint$(br2)$(thing)Value:$()$(br)Shiny Damsels are collector targets due to their unique skins and visual effects." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/slave_trader.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/slave_trader.json new file mode 100644 index 0000000..1a7361d --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/npcs/slave_trader.json @@ -0,0 +1,23 @@ +{ + "name": "Slave Trader", + "icon": "minecraft:emerald", + "category": "tiedup:npcs", + "sortnum": 8, + "pages": [ + { + "type": "patchouli:text", + "title": "Slave Trader", + "text": "$(bold)Camp boss and captive merchant.$(br2)The SlaveTrader runs kidnapper camps. They do not capture players themselves but manage the camp's operations and trade in captives.$(br2)$(thing)Type:$() Hostile (without Token)$(br)$(thing)Health:$() 50 HP$(br)$(thing)Damage:$() 10 (5 hearts)$(br)$(thing)Speed:$() 0.30$(br)$(thing)Name:$() $(#FFAA00)Gold$()" + }, + { + "type": "patchouli:text", + "title": "Token Access", + "text": "$(bold)Token required for peaceful interaction.$(br2)$(thing)Without Token:$()$(br)$(li) SlaveTrader is hostile$(br)$(li) Attacks on sight$(br)$(li) No trade access$(br2)$(thing)With Token:$()$(br)$(li) Kidnappers ignore you$(br)$(li) SlaveTrader opens trade screen$(br)$(li) Browse captives and prices$(br2)Tokens drop from kidnappers (5% chance)." + }, + { + "type": "patchouli:text", + "title": "Trading", + "text": "$(thing)Trade Screen:$()$(br)$(li) Shows available captives$(br)$(li) Displays prices in emeralds$(br)$(li) Purchase to claim captive$(br2)$(thing)Camp Management:$()$(br)$(li) Stays at camp (does not patrol)$(br)$(li) Commands Maid servants$(br)$(li) Manages prisoner cells$(br2)$(thing)On Death:$()$(br)$(li) Camp is destroyed$(br)$(li) All prisoners freed$(br)$(li) Maids become neutral" + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/pet_play/master_system.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/pet_play/master_system.json new file mode 100644 index 0000000..496e76b --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/pet_play/master_system.json @@ -0,0 +1,28 @@ +{ + "name": "Master System", + "icon": "tiedup:choke_collar", + "category": "tiedup:pet_play", + "sortnum": 1, + "pages": [ + { + "type": "patchouli:text", + "title": "Master System", + "text": "$(bold)Pet play ownership system.$(br2)The Master NPC buys solo players from kidnappers and controls them via a choke collar. The system uses an inverted follower mechanic where the Master follows the player.$(br2)$(thing)Controller:$() Master NPC$(br)$(thing)Collar:$() Choke Collar (resistance 250)" + }, + { + "type": "patchouli:text", + "title": "How It Works", + "text": "$(thing)Acquisition:$()$(br)$(li) Master buys player from kidnapper$(br)$(li) Choke collar applied$(br)$(li) Inverted follower: Master follows player$(br2)$(thing)Task Loop:$()$(br)$(li) Master assigns tasks$(br)$(li) Player must comply within time limit$(br)$(li) Failure triggers choke punishment$(br)$(li) Events occur between tasks" + }, + { + "type": "patchouli:text", + "title": "Punishment", + "text": "$(bold)Choke collar activation.$(br2)$(thing)Trigger:$() Task failure or disobedience$(br)$(thing)Effect:$() Drowning damage$(br)$(thing)Safety:$() Deactivated before lethal$(br2)The collar applies drowning damage as punishment but will always stop before killing the player." + }, + { + "type": "patchouli:text", + "title": "Escape", + "text": "$(bold)Breaking free from the Master.$(br2)$(thing)Distraction Windows:$()$(br)$(li) Wait for Master to be occupied$(br)$(li) Get far enough away$(br)$(li) Incapacitate the Master$(br2)$(thing)Difficulty:$()$(br)Master has 150 HP, 10 armor, 0.5/s regen. Direct combat is nearly impossible. Focus on escape timing." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/pet_play/pet_furniture.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/pet_play/pet_furniture.json new file mode 100644 index 0000000..61dc79d --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/pet_play/pet_furniture.json @@ -0,0 +1,19 @@ +{ + "name": "Pet Furniture & Collar", + "icon": "tiedup:choke_collar", + "category": "tiedup:pet_play", + "sortnum": 3, + "pages": [ + { + "type": "patchouli:text", + "title": "Pet Furniture", + "text": "$(bold)Furniture blocks used in pet play areas.$(br2)$(thing)Pet Bowl:$() Fill with food, crouch + right-click to eat$(br)$(thing)Pet Bed:$() Cycle Stand/Sit/Sleep, skips night. Also serves as NPC home block$(br2)For block details and crafting see $(l:tiedup:blocks/pet_furniture)Blocks: Pet Furniture$(/l)." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:choke_collar", + "title": "Choke Collar", + "text": "$(bold)Primary pet control device.$(br2)$(thing)Resistance:$() 250$(br)$(thing)Effect:$() Drowning damage on activation$(br)$(thing)Control:$() Master NPC only$(br2)Applied automatically when Master acquires a pet. Cannot be self-removed or self-applied. Deactivated before lethal damage.$(br2)See $(l:tiedup:items/collars)Collars$(/l) for all collar types." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/pet_play/pet_tasks.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/pet_play/pet_tasks.json new file mode 100644 index 0000000..f765126 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/pet_play/pet_tasks.json @@ -0,0 +1,23 @@ +{ + "name": "Pet Tasks", + "icon": "minecraft:lead", + "category": "tiedup:pet_play", + "sortnum": 2, + "pages": [ + { + "type": "patchouli:text", + "title": "Pet Tasks", + "text": "$(bold)Tasks assigned by the Master.$(br2)Each task has a time limit and distance constraint. A 3-second grace period applies before penalties start.$(br2)$(thing)Grace Period:$() 3 seconds$(br)$(thing)Failure:$() Choke collar punishment" + }, + { + "type": "patchouli:text", + "title": "Task List", + "text": "$(thing)HEEL:$()$(br)Stay within 3 blocks of Master$(br)Duration: 60 seconds$(br2)$(thing)WAIT_HERE:$()$(br)Stay within 2 blocks of start position$(br)Duration: 40 seconds$(br2)$(thing)FETCH_ITEM:$()$(br)Bring a specific item to Master$(br)Duration: 2 minutes" + }, + { + "type": "patchouli:text", + "title": "Events", + "text": "$(bold)Events occur between tasks.$(br2)$(thing)DOGWALK:$()$(br)Walk alongside Master on a leash$(br)No player action required$(br2)$(thing)RANDOM_BIND:$()$(br)Master applies a temporary restraint$(br)Accessory removed after event ends$(br2)Events are passive and do not require player compliance." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/structures/fortress.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/structures/fortress.json new file mode 100644 index 0000000..7051895 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/structures/fortress.json @@ -0,0 +1,13 @@ +{ + "name": "Kidnapper Fortress", + "icon": "minecraft:stone_bricks", + "category": "tiedup:structures", + "sortnum": 3, + "pages": [ + { + "type": "patchouli:text", + "title": "Kidnapper Fortress", + "text": "$(bold)Large fortified structure, rarest spawn.$(br2)A heavily guarded fortress partially buried underground (9 blocks deep). Contains an elite kidnapper boss, regular kidnappers, cells, and valuable loot.$(br2)$(thing)NPCs:$() Elite kidnapper boss + regular kidnappers$(br)$(thing)Rarity:$() Rarest (weight 1)$(br)$(thing)Biomes:$() Plains, forests, taiga, savanna, hills, grove$(br)$(thing)Terrain:$() Requires very flat ground (20-block check radius)" + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/structures/hanging_cage.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/structures/hanging_cage.json new file mode 100644 index 0000000..aaf925a --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/structures/hanging_cage.json @@ -0,0 +1,13 @@ +{ + "name": "Hanging Cage", + "icon": "minecraft:iron_bars", + "category": "tiedup:structures", + "sortnum": 4, + "pages": [ + { + "type": "patchouli:text", + "title": "Hanging Cage", + "text": "$(bold)Underground cage suspended from cave ceilings.$(br2)Pet Cages hanging from iron bars inside caves. Contains a damsel (25% chance of shiny variant). More common than kidnapper structures.$(br2)$(thing)Location:$() Underground (Y -20 to 40)$(br)$(thing)Biomes:$() Plains, forests, dripstone caves, lush caves$(br)$(thing)Rarity:$() Common (16 chunk spacing)$(br)$(thing)Requires:$() Cave ceiling with 4+ blocks clearance above floor" + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/structures/kidnapper_camp.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/structures/kidnapper_camp.json new file mode 100644 index 0000000..94e5f7d --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/structures/kidnapper_camp.json @@ -0,0 +1,18 @@ +{ + "name": "Kidnapper Camp", + "icon": "minecraft:iron_bars", + "category": "tiedup:structures", + "sortnum": 1, + "pages": [ + { + "type": "patchouli:text", + "title": "Kidnapper Camp", + "text": "$(bold)Main kidnapper base, 3-tent layout.$(br2)The most common kidnapper structure. Three tents arranged in a triangle: a main tent with kidnappers, a cell tent with prisoner cells, and a trader tent with a SlaveTrader and Maid.$(br2)$(thing)NPCs:$() Kidnappers, SlaveTrader, Maid$(br)$(thing)Rarity:$() Most common (weight 4)" + }, + { + "type": "patchouli:text", + "title": "Details", + "text": "$(thing)Layout:$()$(br)$(li) Main tent — kidnapper spawner + loot$(br)$(li) Cell tent — prison cells, iron bar doors$(br)$(li) Trader tent — SlaveTrader + Maid$(br2)$(thing)Biomes:$() Plains, forests, taiga, savanna, meadow, snowy plains$(br)$(thing)Terrain:$() Requires relatively flat ground$(br2)$(thing)Camp System:$()$(br)Registered in CampOwnership. Killing the SlaveTrader destroys the camp and frees all prisoners." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/structures/outpost.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/structures/outpost.json new file mode 100644 index 0000000..be4a175 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/structures/outpost.json @@ -0,0 +1,13 @@ +{ + "name": "Kidnapper Outpost", + "icon": "minecraft:cobblestone_wall", + "category": "tiedup:structures", + "sortnum": 2, + "pages": [ + { + "type": "patchouli:text", + "title": "Kidnapper Outpost", + "text": "$(bold)Medium-sized kidnapper patrol point.$(br2)A compact single-building structure partially embedded in the ground. Kidnappers use outposts as patrol bases. Contains some loot.$(br2)$(thing)NPCs:$() Kidnappers$(br)$(thing)Rarity:$() Medium (weight 2)$(br)$(thing)Biomes:$() Plains, forests, taiga, savanna, hills, grove$(br)$(thing)Terrain:$() Requires very flat ground" + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/cell_tools.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/cell_tools.json new file mode 100644 index 0000000..7f0d9ae --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/cell_tools.json @@ -0,0 +1,20 @@ +{ + "name": "Cell Tools", + "icon": "tiedup:admin_wand", + "category": "tiedup:tools_items", + "sortnum": 7, + "pages": [ + { + "type": "patchouli:spotlight", + "item": "tiedup:admin_wand", + "title": "Admin Wand", + "text": "$(bold)Structure marker and cell debug tool.$(br2)$(thing)Functions:$()$(br)$(li) Place and cycle structure markers$(br)$(li) Force re-scan Cell Cores$(br)$(li) Show cell info overlay$(br2)Creative/admin tool for structure building and cell system debugging." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:cell_key", + "title": "Cell Key", + "text": "$(bold)Universal cell door key.$(br2)$(thing)Functions:$()$(br)$(li) Opens iron bar doors$(br)$(li) Locks iron bar doors$(br)$(li) Universal access$(br2)Works on all lockable doors in the cell system." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/chemicals.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/chemicals.json new file mode 100644 index 0000000..5ffac80 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/chemicals.json @@ -0,0 +1,35 @@ +{ + "name": "Chemicals", + "icon": "minecraft:glass_bottle", + "category": "tiedup:tools_items", + "sortnum": 1, + "pages": [ + { + "type": "patchouli:spotlight", + "item": "tiedup:chloroform_bottle", + "title": "Chloroform Bottle", + "text": "$(bold)Soaks rags with knockout chemical.$(br2)$(thing)Durability:$() 9 uses$(br)$(thing)Stack:$() 1$(br)$(thing)Usage:$() Right-click with rag in offhand$(br2)Consumes 1 durability per soak. Plays bottle sound." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:rag", + "title": "Rag", + "text": "$(bold)Carries chloroform for knockout.$(br2)$(thing)Stack:$() 16$(br)$(thing)States:$() Wet / Dry$(br)$(thing)Evaporation:$() 6000 ticks (5 min)$(br2)Tooltip shows wet status and time remaining." + }, + { + "type": "patchouli:text", + "title": "How to Use", + "text": "$(bold)Two-step knockout system:$(br2)$(thing)Step 1: Soak$()$(br)Hold bottle, rag in offhand$(br)Right-click to soak$(br)Rag becomes wet$(br2)$(thing)Step 2: Apply$()$(br)Use wet rag on target$(br)Applies knockout effect$(br)Rag can be reused while wet" + }, + { + "type": "patchouli:text", + "title": "Knockout Effects", + "text": "$(bold)Target is completely paralyzed.$(br2)$(thing)Duration:$() 200 ticks (10 seconds)$(br2)$(thing)Effects Applied:$()$(br)$(li) Slowness 127 (paralysis)$(br)$(li) Blindness$(br)$(li) Nausea$(br)$(li) Weakness 127 (no combat)$(br)$(li) UNCONSCIOUS pose$(br2)100% success rate when applied." + }, + { + "type": "patchouli:text", + "title": "Tips", + "text": "$(thing)Evaporation:$()$(br)Wet rags dry out after 5 minutes if not used.$(br2)$(thing)Multiple Uses:$()$(br)One wet rag can knockout multiple targets before evaporating.$(br2)$(thing)Stacking:$()$(br)Carry multiple rags (16 stack) for repeated knockouts." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/command_wand.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/command_wand.json new file mode 100644 index 0000000..74aafdc --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/command_wand.json @@ -0,0 +1,24 @@ +{ + "name": "Command Wand", + "icon": "tiedup:command_wand", + "category": "tiedup:tools_items", + "sortnum": 9, + "pages": [ + { + "type": "patchouli:spotlight", + "item": "tiedup:command_wand", + "title": "Command Wand", + "text": "$(bold)Primary NPC management tool.$(br2)$(thing)Usage:$() Right-click a collared NPC you own$(br2)Opens a two-tab GUI showing the NPC's full status and available commands." + }, + { + "type": "patchouli:text", + "title": "Status Tab", + "text": "$(thing)Displays:$()$(br)$(li) Name and personality (if discovered)$(br)$(li) Training level and conditioning progress$(br)$(li) Secondary trait (DEVOTED, SUBJUGATED, etc.)$(br)$(li) Willpower and sanity bars$(br)$(li) Relationship type and fear level$(br)$(li) Hunger and rest bars$(br)$(li) Mood percentage$(br)$(li) Assigned home (Pet Bed / Bed / None)" + }, + { + "type": "patchouli:text", + "title": "Command Tab", + "text": "$(thing)Features:$()$(br)$(li) All 15 commands (grayed if locked)$(br)$(li) Locked commands show required level$(br)$(li) Follow distance toggle (Heel / Close / Far)$(br)$(li) Set Home button (click a Pet Bed or Bed)$(br)$(li) Discipline buttons: Praise / Scold / Threaten$(br)$(li) Cancel current command$(br)$(li) Open NPC inventory$(br2)Job commands (Farm, Cook, Transfer...) require clicking a chest to set the work area after selection." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/controllers.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/controllers.json new file mode 100644 index 0000000..5627047 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/controllers.json @@ -0,0 +1,40 @@ +{ + "name": "Controllers", + "icon": "minecraft:redstone", + "category": "tiedup:tools_items", + "sortnum": 4, + "pages": [ + { + "type": "patchouli:text", + "title": "Controllers", + "text": "$(bold)Remote control for special collars.$(br2)$(thing)Shocker Controller:$()$(br)Trigger shock collars remotely$(br)50 meter range$(br)Targeted or broadcast mode$(br2)$(thing)GPS Locator:$()$(br)Track GPS collar wearers$(br)Shows distance + direction$(br)Same dimension only" + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:shocker_controller", + "title": "Shocker Controller", + "text": "$(bold)Remote shock collar trigger.$(br2)$(thing)Stack:$() 1$(br)$(thing)Range:$() 50 meters$(br)$(thing)Modes:$() Targeted / Broadcast$(br)$(thing)Claim:$() Right-click entity$(br2)Ownership-based permissions. Cannot use while tied up." + }, + { + "type": "patchouli:text", + "title": "Shocker Modes", + "text": "$(bold)TARGETED Mode:$()$(br)Affects single linked target only. Precise control.$(br2)$(bold)BROADCAST Mode:$()$(br)Affects ALL owned/public collars in radius. Area effect.$(br2)$(thing)Toggle:$()$(br)Shift+Right-click to switch modes.$(br2)$(thing)Activation:$()$(br)Right-click to trigger shock. Plays activation sound." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:gps_locator", + "title": "GPS Locator", + "text": "$(bold)Track GPS collar wearers.$(br2)$(thing)Stack:$() 1$(br)$(thing)Claim:$() Right-click entity$(br)$(thing)Detect:$() Right-click to locate$(br)$(thing)Output:$() Distance + direction$(br2)Target must wear GPS collar and grant permission." + }, + { + "type": "patchouli:text", + "title": "GPS Detection", + "text": "$(thing)Output Format:$()$(br)XXXm [DIRECTION]$(br2)$(thing)Directions:$()$(br)NORTH, SOUTH, EAST, WEST$(br2)$(thing)Requirements:$()$(br)$(li) Target wears GPS collar$(br)$(li) Tracking permission granted$(br)$(li) Same dimension$(br)$(li) Not tied up$(br2)Shows collar nickname if set." + }, + { + "type": "patchouli:text", + "title": "Controller Tips", + "text": "$(thing)Ownership:$()$(br)Both controllers lock to first user. Cannot transfer.$(br2)$(thing)Permissions:$()$(br)Require collar owner status OR public permission.$(br2)$(thing)Tooltips:$()$(br)Show target nickname and connection status.$(br2)$(thing)Sounds:$()$(br)Both play activation sound on use." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/discipline_tools.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/discipline_tools.json new file mode 100644 index 0000000..14b90b2 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/discipline_tools.json @@ -0,0 +1,30 @@ +{ + "name": "Discipline Tools", + "icon": "minecraft:leather", + "category": "tiedup:tools_items", + "sortnum": 2, + "pages": [ + { + "type": "patchouli:text", + "title": "Discipline Tools", + "text": "$(bold)Tools for managing NPCs and restraints.$(br2)$(thing)Whip:$() Damages and weakens binds$(br)$(thing)Paddle:$() NPC discipline tool$(br)$(thing)Tighten (T key):$() Restores bind resistance$(br2)The whip works on any tied target. The paddle is for NPC discipline only. Tightening is done via the $(k:key.tiedup.tighten) keybind." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:whip", + "title": "Whip", + "text": "$(bold)Punishment tool that weakens binds.$(br2)$(thing)Durability:$() 256 uses$(br)$(thing)Damage:$() 2.0 (1 heart)$(br)$(thing)Effect:$() -15 resistance per hit$(br)$(thing)Stack:$() 1$(br2)Right-click bound target to use. Plays whip sound, shows crit particles." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:paddle", + "title": "Paddle", + "text": "$(bold)NPC discipline tool.$(br2)$(thing)Durability:$() 64 uses$(br)$(thing)Effect:$() Applies paddle discipline to NPC$(br)$(thing)Stack:$() 1$(br2)Right-click a damsel to discipline. Does not tighten binds — use the $(k:key.tiedup.tighten) keybind for that." + }, + { + "type": "patchouli:text", + "title": "Tighten (T Key)", + "text": "$(bold)Restore bind resistance via keybind.$(br2)$(thing)Key:$() $(k:key.tiedup.tighten)$(br)$(thing)Range:$() 5 blocks$(br)$(thing)Effect:$() Restores resistance to full$(br2)$(thing)Requirements:$()$(br)$(li) Look at a tied target$(br)$(li) Must be captor, collar owner, or admin$(br2)Resets escape progress completely." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/knives.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/knives.json new file mode 100644 index 0000000..10a0ea1 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/knives.json @@ -0,0 +1,31 @@ +{ + "name": "Knives", + "icon": "tiedup:iron_knife", + "category": "tiedup:tools_items", + "sortnum": 6, + "pages": [ + { + "type": "patchouli:text", + "title": "Knives", + "text": "$(bold)Cutting tools for removing restraints.$(br2)Hold right-click on a bound target to actively cut their bindings. Consumes 5 durability per second and removes 5 resistance per second.$(br2)$(thing)Usage:$() Hold right-click on bound target$(br)$(thing)Cut Rate:$() 5 resistance/s$(br)$(thing)Durability Cost:$() 5/s" + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:stone_knife", + "title": "Stone Knife", + "text": "$(bold)Basic stone cutting tool.$(br2)$(thing)Durability:$() 50$(br)$(thing)Cut Time:$() ~10 seconds$(br2)Cheapest knife, limited uses." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:iron_knife", + "title": "Iron Knife", + "text": "$(bold)Standard iron cutting tool.$(br2)$(thing)Durability:$() 100$(br)$(thing)Cut Time:$() ~20 seconds$(br2)Reliable general-purpose knife." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:golden_knife", + "title": "Golden Knife", + "text": "$(bold)Premium golden cutting tool.$(br2)$(thing)Durability:$() 200$(br)$(thing)Cut Time:$() ~40 seconds$(br2)Long-lasting, best value knife." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/lock_system.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/lock_system.json new file mode 100644 index 0000000..59fc20d --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/lock_system.json @@ -0,0 +1,57 @@ +{ + "name": "Lock System", + "icon": "minecraft:iron_ingot", + "category": "tiedup:tools_items", + "sortnum": 3, + "pages": [ + { + "type": "patchouli:text", + "title": "Lock System", + "text": "$(bold)4-tier security system.$(br2)$(thing)Padlock:$() Makes items lockable$(br)$(thing)Key:$() UUID-matched unlocking$(br)$(thing)Master Key:$() Universal unlock$(br)$(thing)Lockpick:$() 25% chance bypass$(br2)Makes bondage items removable only with correct key. UUID-based for security." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:padlock", + "title": "Padlock", + "text": "$(bold)Makes bondage items lockable.$(br2)$(thing)Stack:$() 16$(br)$(thing)Usage:$() Anvil with lockable item$(br)$(thing)Cost:$() 1 XP level$(br)$(thing)Effect:$() Makes item lockable$(br2)Item is NOT locked yet, just lockable. Lock with Key." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:collar_key", + "title": "Key (Collar Key)", + "text": "$(bold)Specific key for individual locks.$(br2)$(thing)Durability:$() 64 uses$(br)$(thing)Stack:$() 1$(br)$(thing)Claim:$() Right-click target with collar$(br2)UUID-matched to specific locks. Ownership locked to first user." + }, + { + "type": "patchouli:text", + "title": "Key Usage", + "text": "$(thing)First Interaction:$()$(br)Right-click any target with collar to claim key to yourself.$(br2)$(thing)Lock/Unlock:$()$(br)Right-click collared target to open management GUI. Lock/unlock specific items.$(br2)$(thing)Restrictions:$()$(br)Only works on first-linked target. Only owner can use." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:master_key", + "title": "Master Key", + "text": "$(bold)Universal key, opens ANY padlock.$(br2)$(thing)Stack:$() 8$(br)$(thing)Right-click:$() Management GUI$(br)$(thing)Shift+Right-click:$() Quick unlock all$(br2)Reusable (never consumed). Bypasses UUID system completely." + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:lockpick", + "title": "Lockpick", + "text": "$(bold)Pick locks via a sweet-spot minigame.$(br2)$(thing)Uses:$() 5 per attempt$(br)$(thing)Stack:$() 1$(br2)Right-click a locked item to open the lockpick screen. Move the pick left/right and test positions to find the hidden sweet spot." + }, + { + "type": "patchouli:text", + "title": "Lockpick Minigame", + "text": "$(bold)Skyrim-style sweet spot system.$(br2)$(thing)Controls:$()$(br)$(li) A/D or arrows: Move lockpick position$(br)$(li) Space: Test current position$(br)$(li) Esc: Cancel$(br2)$(thing)Feedback:$()$(br)The tension bar fills based on proximity:$(br)$(li) $(#55FF55)Green$() = Very close$(br)$(li) $(#FFFF55)Yellow$() = Close$(br)$(li) $(#FF8800)Orange$() = Medium$(br)$(li) $(#FF5555)Red$() = Far$(br2)Sweet spot repositions after each failed test." + }, + { + "type": "patchouli:text", + "title": "Lockpick Outcomes", + "text": "$(bold)Success:$()$(br)Tension bar fills to 100%. Lock opens, padlock preserved.$(br2)$(bold)Failure:$()$(br)Run out of 5 lockpick uses without finding the sweet spot.$(br2)$(bold)Jam (critical fail):$()$(br)Lock jammed permanently. Only struggle can break it.$(br2)$(bold)Break:$()$(br)Lockpick destroyed." + }, + { + "type": "patchouli:text", + "title": "Lock Integration", + "text": "$(thing)Restrictions:$()$(br)$(li) Cannot pick while wearing mittens$(br)$(li) Cannot pick jammed locks$(br)$(li) Only works on locked items$(br2)$(thing)Shock Collar Integration:$()$(br)Failed attempts trigger shock$(br)Owners notified of picking$(br2)$(thing)Struggle:$()$(br)Jammed locks require struggle mechanic to open." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/token.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/token.json new file mode 100644 index 0000000..25b9705 --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/token.json @@ -0,0 +1,14 @@ +{ + "name": "Token", + "icon": "tiedup:token", + "category": "tiedup:tools_items", + "sortnum": 8, + "pages": [ + { + "type": "patchouli:spotlight", + "item": "tiedup:token", + "title": "Token", + "text": "$(bold)Camp access pass.$(br2)$(thing)Drop Rate:$() 5% from kidnappers$(br)$(thing)Rarity:$() Rare$(br)$(thing)Stack:$() 1$(br2)$(thing)Effects:$()$(br)$(li) Kidnappers ignore the holder$(br)$(li) SlaveTrader opens trade screen$(br2)Without a Token, SlaveTraders are hostile and camps are dangerous. Required for peaceful interaction with camp NPCs." + } + ] +} diff --git a/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/weapons.json b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/weapons.json new file mode 100644 index 0000000..9ce11ef --- /dev/null +++ b/src/main/resources/assets/tiedup/patchouli_books/guide/en_us/entries/tools_items/weapons.json @@ -0,0 +1,35 @@ +{ + "name": "Weapons", + "icon": "minecraft:bow", + "category": "tiedup:tools_items", + "sortnum": 5, + "pages": [ + { + "type": "patchouli:text", + "title": "Weapons", + "text": "$(bold)Combat and capture tools.$(br2)$(thing)Taser:$()$(br)Close-range stun weapon$(br)3 hearts damage$(br)5 second debuffs$(br2)$(thing)Rope Arrow:$()$(br)Ranged binding projectile$(br)50% bind chance$(br)Cumulative for archers" + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:taser", + "title": "Taser", + "text": "$(bold)Electric stun weapon.$(br2)$(thing)Durability:$() 64 uses$(br)$(thing)Damage:$() 5.0 (3 hearts total)$(br)$(thing)Duration:$() 100 ticks (5 seconds)$(br)$(thing)Effects:$() Slowness I + Weakness I$(br)$(thing)Stack:$() 1$(br2)Used by kidnappers for self-defense." + }, + { + "type": "patchouli:text", + "title": "Taser Usage", + "text": "$(thing)Combat:$()$(br)Attack deals 3 hearts total damage (5.0 + base hand).$(br2)$(thing)Debuffs:$()$(br)Slowness I slows movement$(br)Weakness I reduces damage$(br)Lasts 5 seconds$(br2)$(thing)Special:$()$(br)Plays electric shock sound$(br)Consumes 1 durability per hit$(br)Cannot use while tied up" + }, + { + "type": "patchouli:spotlight", + "item": "tiedup:rope_arrow", + "title": "Rope Arrow", + "text": "$(bold)Arrow that binds on hit.$(br2)$(thing)Stack:$() 64$(br)$(thing)Usage:$() Fire from bow$(br)$(thing)Bind Chance:$() Varies by shooter$(br)$(thing)Consumed:$() On hit$(br2)Does NOT deal arrow damage, only binds." + }, + { + "type": "patchouli:text", + "title": "Rope Arrow Mechanics", + "text": "$(bold)Players/NPCs:$()$(br)50% chance to bind on hit.$(br2)$(bold)Archer Kidnappers:$()$(br)Cumulative bind chance:$(br)$(li) Base: 10%$(br)$(li) +10% per previous hit$(br)$(li) Maximum: 100%$(br2)Example: 1st shot 10%, 2nd 20%, 3rd 30%, ... 10th 100%$(br2)$(thing)Restrictions:$()$(br)Only works on restrainable entities (players and NPCs). Cannot bind already-tied targets." + } + ] +} diff --git a/src/main/resources/assets/tiedup/player_animation/context_crawl_idle.json b/src/main/resources/assets/tiedup/player_animation/context_crawl_idle.json new file mode 100644 index 0000000..c3809ef --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/context_crawl_idle.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "uuid": "c0000002-0000-0000-0000-000000000007", + "name": "context_crawl_idle", + "_comment": "V2 context: crawl idle. Placeholder — on all fours resting pose.", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "CONSTANT", + "body": { "pitch": 0, "yaw": 0, "roll": 0 } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/context_crawl_move.json b/src/main/resources/assets/tiedup/player_animation/context_crawl_move.json new file mode 100644 index 0000000..67fe771 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/context_crawl_move.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "uuid": "c0000002-0000-0000-0000-000000000008", + "name": "context_crawl_move", + "_comment": "V2 context: crawl move. Placeholder — on all fours crawling animation.", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "CONSTANT", + "body": { "pitch": 0, "yaw": 0, "roll": 0 } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/context_hop_idle.json b/src/main/resources/assets/tiedup/player_animation/context_hop_idle.json new file mode 100644 index 0000000..28bd419 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/context_hop_idle.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "uuid": "c0000002-0000-0000-0000-000000000005", + "name": "context_hop_idle", + "_comment": "V2 context: hop idle. Placeholder — feet together standing pose.", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "CONSTANT", + "body": { "pitch": 0, "yaw": 0, "roll": 0 } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/context_hop_walk.json b/src/main/resources/assets/tiedup/player_animation/context_hop_walk.json new file mode 100644 index 0000000..1f904da --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/context_hop_walk.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "uuid": "c0000002-0000-0000-0000-000000000006", + "name": "context_hop_walk", + "_comment": "V2 context: hop walk. Placeholder — small bunny hop cycle.", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "CONSTANT", + "body": { "pitch": 0, "yaw": 0, "roll": 0 } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/context_kneel_idle.json b/src/main/resources/assets/tiedup/player_animation/context_kneel_idle.json new file mode 100644 index 0000000..154eca4 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/context_kneel_idle.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "uuid": "c0000001-0000-0000-0000-000000000007", + "name": "context_kneel_idle", + "_comment": "V2 context: kneeling idle base posture. Legs bent -15 pitch with knee bend 105 degrees (from V1 kneel_basic_idle). Arms neutral - item animations override owned parts.", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "CONSTANT", + "body": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/context_kneel_struggle.json b/src/main/resources/assets/tiedup/player_animation/context_kneel_struggle.json new file mode 100644 index 0000000..b4734df --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/context_kneel_struggle.json @@ -0,0 +1,197 @@ +{ + "version": 1, + "uuid": "c0000001-0000-0000-0000-000000000008", + "name": "context_kneel_struggle", + "_comment": "V2 context: kneeling struggle. Kneeling posture (legs -15/bend:105) with body twist oscillation. Arms neutral - item animations override owned parts.", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "body": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "body": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": 5, + "yaw": 8, + "roll": 8 + }, + "rightLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -15, + "yaw": 0, + "roll": 3, + "bend": 105.0 + }, + "leftLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -15, + "yaw": 0, + "roll": -2, + "bend": 105.0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "body": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "body": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -3, + "yaw": -8, + "roll": -8 + }, + "rightLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -15, + "yaw": 0, + "roll": -2, + "bend": 105.0 + }, + "leftLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -15, + "yaw": 0, + "roll": 3, + "bend": 105.0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "body": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/context_shuffle_idle.json b/src/main/resources/assets/tiedup/player_animation/context_shuffle_idle.json new file mode 100644 index 0000000..8d317a1 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/context_shuffle_idle.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "uuid": "c0000002-0000-0000-0000-000000000001", + "name": "context_shuffle_idle", + "_comment": "V2 context: shuffle idle. Placeholder — legs slightly together, tiny sway.", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "CONSTANT", + "body": { "pitch": 0, "yaw": 0, "roll": 0 } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/context_shuffle_walk.json b/src/main/resources/assets/tiedup/player_animation/context_shuffle_walk.json new file mode 100644 index 0000000..8e42f95 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/context_shuffle_walk.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "uuid": "c0000002-0000-0000-0000-000000000002", + "name": "context_shuffle_walk", + "_comment": "V2 context: shuffle walk. Placeholder — tiny dragging steps animation.", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "CONSTANT", + "body": { "pitch": 0, "yaw": 0, "roll": 0 } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/context_sit_idle.json b/src/main/resources/assets/tiedup/player_animation/context_sit_idle.json new file mode 100644 index 0000000..6de0b3e --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/context_sit_idle.json @@ -0,0 +1,51 @@ +{ + "version": 1, + "uuid": "c0000001-0000-0000-0000-000000000005", + "name": "context_sit_idle", + "_comment": "V2 context: sitting idle base posture. Legs bent -90 pitch, slightly apart. Body pitched 5 degrees forward (from V1 sit_basic_idle). Arms neutral - item animations override owned parts.", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "CONSTANT", + "body": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": 5, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -90, + "yaw": 5, + "roll": 0 + }, + "leftLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -90, + "yaw": -5, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/context_sit_struggle.json b/src/main/resources/assets/tiedup/player_animation/context_sit_struggle.json new file mode 100644 index 0000000..4141f8d --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/context_sit_struggle.json @@ -0,0 +1,187 @@ +{ + "version": 1, + "uuid": "c0000001-0000-0000-0000-000000000006", + "name": "context_sit_struggle", + "_comment": "V2 context: sitting struggle. Sitting posture (legs -90) with body twist oscillation. Arms neutral - item animations override owned parts.", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "body": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": 5, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -90, + "yaw": 5, + "roll": 0 + }, + "leftLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -90, + "yaw": -5, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "body": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": 8, + "yaw": 5, + "roll": 8 + }, + "rightLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -85, + "yaw": 5, + "roll": 3 + }, + "leftLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -95, + "yaw": -5, + "roll": -2 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "body": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": 5, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -90, + "yaw": 5, + "roll": 0 + }, + "leftLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -90, + "yaw": -5, + "roll": 0 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "body": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": 2, + "yaw": -5, + "roll": -8 + }, + "rightLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -95, + "yaw": 5, + "roll": -2 + }, + "leftLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -85, + "yaw": -5, + "roll": 3 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "body": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": 5, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -90, + "yaw": 5, + "roll": 0 + }, + "leftLeg": { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "pitch": -90, + "yaw": -5, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/context_stand_idle.json b/src/main/resources/assets/tiedup/player_animation/context_stand_idle.json new file mode 100644 index 0000000..cb5850b --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/context_stand_idle.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "uuid": "c0000001-0000-0000-0000-000000000001", + "name": "context_stand_idle", + "_comment": "V2 context: standing idle. Only body part included — legs, arms, head pass through to vanilla. Item layer overrides owned parts.", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "CONSTANT", + "body": { "pitch": 0, "yaw": 0, "roll": 0 } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/context_stand_sneak.json b/src/main/resources/assets/tiedup/player_animation/context_stand_sneak.json new file mode 100644 index 0000000..abc964e --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/context_stand_sneak.json @@ -0,0 +1,36 @@ +{ + "version": 1, + "uuid": "c0000001-0000-0000-0000-000000000003", + "name": "context_stand_sneak", + "_comment": "V2 context: sneaking base posture. Body pitched forward ~25 degrees, legs slightly bent. Item animations override owned parts.", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "CONSTANT", + "body": { + "pitch": 25, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "pitch": -10, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": -10, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/context_stand_struggle.json b/src/main/resources/assets/tiedup/player_animation/context_stand_struggle.json new file mode 100644 index 0000000..344359b --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/context_stand_struggle.json @@ -0,0 +1,112 @@ +{ + "version": 1, + "uuid": "c0000001-0000-0000-0000-000000000004", + "name": "context_stand_struggle", + "_comment": "V2 context: standing struggle. Body twist oscillation over 60 ticks. Arms/legs at neutral - item animations override owned parts.", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "body": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "body": { + "pitch": 0, + "yaw": 5, + "roll": 8 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "body": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "body": { + "pitch": 0, + "yaw": -5, + "roll": -8 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "body": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/context_stand_walk.json b/src/main/resources/assets/tiedup/player_animation/context_stand_walk.json new file mode 100644 index 0000000..84c8d4a --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/context_stand_walk.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "uuid": "c0000001-0000-0000-0000-000000000002", + "name": "context_stand_walk", + "_comment": "V2 context: walking. Only body part included — legs and head pass through to vanilla walk cycle. Arms pass through to vanilla (or get overridden by item layer if owned).", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "CONSTANT", + "body": { "pitch": 0, "yaw": 0, "roll": 0 } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/context_waddle_idle.json b/src/main/resources/assets/tiedup/player_animation/context_waddle_idle.json new file mode 100644 index 0000000..f73d989 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/context_waddle_idle.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "uuid": "c0000002-0000-0000-0000-000000000003", + "name": "context_waddle_idle", + "_comment": "V2 context: waddle idle. Placeholder — slight side-to-side sway.", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "CONSTANT", + "body": { "pitch": 0, "yaw": 0, "roll": 0 } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/context_waddle_walk.json b/src/main/resources/assets/tiedup/player_animation/context_waddle_walk.json new file mode 100644 index 0000000..1b58cb1 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/context_waddle_walk.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "uuid": "c0000002-0000-0000-0000-000000000004", + "name": "context_waddle_walk", + "_comment": "V2 context: waddle walk. Placeholder — zigzag sway body roll animation.", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "CONSTANT", + "body": { "pitch": 0, "yaw": 0, "roll": 0 } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/human_chair_idle.json b/src/main/resources/assets/tiedup/player_animation/human_chair_idle.json new file mode 100644 index 0000000..7faf7f3 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/human_chair_idle.json @@ -0,0 +1,182 @@ +{ + "version": 1, + "uuid": "c4a2e71b-3f98-4d21-a5b0-6e3f8c91d245", + "name": "human_chair_idle", + "_comment": "Human chair pose idle - on all fours like a table, forearms/shins against floor, less bend than DOG", + "emote": { + "beginTick": 0, + "endTick": 120, + "stopTick": 120, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "comment": "Base pose - table position, forearms flat against ground", + "rightArm": { + "pitch": -120, + "yaw": 10, + "roll": 0, + "bend": -50 + }, + "leftArm": { + "pitch": -120, + "yaw": -10, + "roll": 0, + "bend": -50 + }, + "rightLeg": { + "pitch": -60, + "yaw": 20, + "roll": 0, + "bend": 50 + }, + "leftLeg": { + "pitch": -60, + "yaw": -20, + "roll": 0, + "bend": 50 + }, + "body": { + "position": { "x": 0, "y": -20, "z": 0 }, + "pitch": -90 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "comment": "Breathing in - very subtle (bearing weight)", + "rightArm": { + "pitch": -119.5, + "yaw": 10.2, + "roll": 0, + "bend": -49 + }, + "leftArm": { + "pitch": -119.5, + "yaw": -10.2, + "roll": 0, + "bend": -49 + }, + "rightLeg": { + "pitch": -60, + "yaw": 20, + "roll": 0, + "bend": 50 + }, + "leftLeg": { + "pitch": -60, + "yaw": -20, + "roll": 0, + "bend": 50 + }, + "body": { + "position": { "x": 0, "y": -19.8, "z": 0 }, + "pitch": -89.7 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "comment": "Breathing out - settle back down", + "rightArm": { + "pitch": -120.3, + "yaw": 10, + "roll": 0, + "bend": -51 + }, + "leftArm": { + "pitch": -120.3, + "yaw": -10, + "roll": 0, + "bend": -51 + }, + "rightLeg": { + "pitch": -60.2, + "yaw": 20, + "roll": 0, + "bend": 50.5 + }, + "leftLeg": { + "pitch": -59.8, + "yaw": -20, + "roll": 0, + "bend": 49.5 + }, + "body": { + "position": { "x": 0, "y": -20.1, "z": 0 }, + "pitch": -90.2 + } + }, + { + "tick": 90, + "easing": "EASEINOUTSINE", + "comment": "Breathing cycle 2 - subtle shift", + "rightArm": { + "pitch": -119.5, + "yaw": 10.1, + "roll": 0, + "bend": -49 + }, + "leftArm": { + "pitch": -119.5, + "yaw": -10.1, + "roll": 0, + "bend": -49 + }, + "rightLeg": { + "pitch": -59.8, + "yaw": 20, + "roll": 0, + "bend": 49.5 + }, + "leftLeg": { + "pitch": -60.2, + "yaw": -20, + "roll": 0, + "bend": 50.5 + }, + "body": { + "position": { "x": 0, "y": -19.7, "z": 0 }, + "pitch": -89.6 + } + }, + { + "tick": 120, + "easing": "EASEINOUTSINE", + "comment": "Return to base - loop", + "rightArm": { + "pitch": -120, + "yaw": 10, + "roll": 0, + "bend": -50 + }, + "leftArm": { + "pitch": -120, + "yaw": -10, + "roll": 0, + "bend": -50 + }, + "rightLeg": { + "pitch": -60, + "yaw": 20, + "roll": 0, + "bend": 50 + }, + "leftLeg": { + "pitch": -60, + "yaw": -20, + "roll": 0, + "bend": 50 + }, + "body": { + "position": { "x": 0, "y": -20, "z": 0 }, + "pitch": -90 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/kneel_basic_idle.json b/src/main/resources/assets/tiedup/player_animation/kneel_basic_idle.json new file mode 100644 index 0000000..6afe853 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/kneel_basic_idle.json @@ -0,0 +1,197 @@ +{ + "version": 1, + "uuid": "c9d0e1f2-a3b4-4567-8901-234567abcdef", + "name": "kneel_basic_idle", + "_comment": "Kneeling pose - distinct from sitting: lower body (y:14), legs folded under", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 54, + "yaw": 58.5, + "roll": 1 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 54, + "yaw": -58.5, + "roll": -1 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 1, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 49, + "yaw": 56, + "roll": -1 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 49, + "yaw": -56, + "roll": 1 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -1, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/kneel_basic_struggle.json b/src/main/resources/assets/tiedup/player_animation/kneel_basic_struggle.json new file mode 100644 index 0000000..6a7ab1a --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/kneel_basic_struggle.json @@ -0,0 +1,269 @@ +{ + "version": 1, + "uuid": "a7b8c9d0-e1f2-4345-6789-0123456abcde", + "name": "kneel_basic_struggle", + "_comment": "Kneeling struggle - arms behind back (bound), trying to escape", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 5, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 12, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 55, + "yaw": 62, + "roll": 10 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 48, + "yaw": -54, + "roll": -5 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 3, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 8, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 8, + "roll": 5 + } + }, + { + "tick": 25, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 48, + "yaw": 54, + "roll": -8 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 56, + "yaw": -62, + "roll": 10 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": -2, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 3, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -3, + "yaw": -10, + "roll": -6 + } + }, + { + "tick": 40, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 58, + "yaw": 65, + "roll": 15 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 58, + "yaw": -65, + "roll": -15 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 2, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 6, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 8, + "yaw": 5, + "roll": 3 + } + }, + { + "tick": 55, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 46, + "yaw": 52, + "roll": -6 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 54, + "yaw": -60, + "roll": 8 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": -1, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 4, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 2, + "yaw": -6, + "roll": -4 + } + }, + { + "tick": 68, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 54, + "yaw": 60, + "roll": 10 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 49, + "yaw": -55, + "roll": -6 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 1, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 5, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 4, + "yaw": 4, + "roll": 2 + } + }, + { + "tick": 80, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 5, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/kneel_free_idle.json b/src/main/resources/assets/tiedup/player_animation/kneel_free_idle.json new file mode 100644 index 0000000..17b2274 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/kneel_free_idle.json @@ -0,0 +1,197 @@ +{ + "version": 1, + "uuid": "a7b8c9d0-e1f2-4456-7890-123456abcdef", + "name": "kneel_free_idle", + "_comment": "Kneeling pose without bind - hands resting on thighs naturally", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 5, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": -5, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 7, + "yaw": 5, + "roll": 1 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 7, + "yaw": -5, + "roll": -1 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 1, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 5, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": -5, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": 5, + "roll": -1 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": -5, + "roll": 1 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -1, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 5, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": -5, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/kneel_latex_sack_idle.json b/src/main/resources/assets/tiedup/player_animation/kneel_latex_sack_idle.json new file mode 100644 index 0000000..faf594b --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/kneel_latex_sack_idle.json @@ -0,0 +1,161 @@ +{ + "version": 1, + "uuid": "f2a3b4c5-d6e7-4890-1234-567890abcdef", + "name": "kneel_latex_sack_idle", + "_comment": "Kneeling pose with latex sack - minimal movement", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -2 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 2 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 20, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -2 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 2 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 1, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 40, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -2 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 2 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -2 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 2 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/kneel_latex_sack_struggle.json b/src/main/resources/assets/tiedup/player_animation/kneel_latex_sack_struggle.json new file mode 100644 index 0000000..081296f --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/kneel_latex_sack_struggle.json @@ -0,0 +1,233 @@ +{ + "version": 1, + "uuid": "d0e1f2a3-b4c5-4678-9012-34567890abcd", + "name": "kneel_latex_sack_struggle", + "_comment": "Kneeling latex sack struggle - limited movement, mainly body", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -2 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 2 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 2, + "yaw": 2, + "roll": -4 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -2, + "yaw": -2, + "roll": 4 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 2, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 5, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 6, + "yaw": 5, + "roll": 4 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -2, + "yaw": -2, + "roll": 3 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 2, + "yaw": 2, + "roll": -3 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": -1, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 2, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 2, + "yaw": -5, + "roll": -4 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 1, + "yaw": 1, + "roll": -3 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 1, + "yaw": -1, + "roll": 3 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 1, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 4, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 3, + "roll": 2 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -1, + "yaw": -1, + "roll": 2 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -1, + "yaw": 1, + "roll": -2 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": -1, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 3, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": -2, + "roll": -2 + } + }, + { + "tick": 80, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -2 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 2 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/kneel_legs_idle.json b/src/main/resources/assets/tiedup/player_animation/kneel_legs_idle.json new file mode 100644 index 0000000..fc579d7 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/kneel_legs_idle.json @@ -0,0 +1,197 @@ +{ + "version": 1, + "uuid": "e5f6a7b8-c9d0-4234-5678-901234abcdef", + "name": "kneel_legs_idle", + "_comment": "Kneeling pose with legs-only bind - legs together (yaw=0), arms free, y:14", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 5, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": -5, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 7, + "yaw": 5, + "roll": 1 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 7, + "yaw": -5, + "roll": -1 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 1, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 5, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": -5, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": 5, + "roll": -1 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": -5, + "roll": 1 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -1, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 5, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": -5, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/kneel_straitjacket_idle.json b/src/main/resources/assets/tiedup/player_animation/kneel_straitjacket_idle.json new file mode 100644 index 0000000..2fd0122 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/kneel_straitjacket_idle.json @@ -0,0 +1,197 @@ +{ + "version": 1, + "uuid": "d0e1f2a3-b4c5-4678-9012-345678abcdef", + "name": "kneel_straitjacket_idle", + "_comment": "Kneeling pose with straitjacket - arms crossed", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": -48.1, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": 48.1, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 45.5, + "yaw": -47.0, + "roll": 1 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 45.5, + "yaw": 47.0, + "roll": -1 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 1, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": -48.1, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": 48.1, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 42.0, + "yaw": -49.0, + "roll": -1 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 42.0, + "yaw": 49.0, + "roll": 1 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -1, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": -48.1, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": 48.1, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/kneel_straitjacket_struggle.json b/src/main/resources/assets/tiedup/player_animation/kneel_straitjacket_struggle.json new file mode 100644 index 0000000..e6ded5c --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/kneel_straitjacket_struggle.json @@ -0,0 +1,269 @@ +{ + "version": 1, + "uuid": "b8c9d0e1-f2a3-4456-7890-1234567abcde", + "name": "kneel_straitjacket_struggle", + "_comment": "Kneeling straitjacket struggle - torso twisting against restraint", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": -48.1, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": 48.1, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 12, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 48, + "yaw": -45, + "roll": 5 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 40, + "yaw": 50, + "roll": -3 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 3, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 7, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 8, + "yaw": 12, + "roll": 6 + } + }, + { + "tick": 25, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 40, + "yaw": -50, + "roll": -3 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 48, + "yaw": 45, + "roll": 5 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": -2, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 3, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": -15, + "roll": -8 + } + }, + { + "tick": 40, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 46, + "yaw": -46, + "roll": 3 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 46, + "yaw": 46, + "roll": -3 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 2, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 6, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 10, + "yaw": 8, + "roll": 4 + } + }, + { + "tick": 55, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 42, + "yaw": -49, + "roll": -2 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 45, + "yaw": 47, + "roll": 2 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": -1, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 4, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 2, + "yaw": -10, + "roll": -5 + } + }, + { + "tick": 68, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 45, + "yaw": -47, + "roll": 2 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 42, + "yaw": 49, + "roll": -2 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 1, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 5, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 5, + "roll": 3 + } + }, + { + "tick": 80, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": -48.1, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": 48.1, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/kneel_wrap_idle.json b/src/main/resources/assets/tiedup/player_animation/kneel_wrap_idle.json new file mode 100644 index 0000000..618c7d6 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/kneel_wrap_idle.json @@ -0,0 +1,197 @@ +{ + "version": 1, + "uuid": "e1f2a3b4-c5d6-4789-0123-456789abcdef", + "name": "kneel_wrap_idle", + "_comment": "Kneeling pose with wrap bind - arms along body", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -5 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 5 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -4 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 4 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 1, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -5 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 5 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -6 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 6 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -1, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -5 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 5 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/kneel_wrap_struggle.json b/src/main/resources/assets/tiedup/player_animation/kneel_wrap_struggle.json new file mode 100644 index 0000000..314588e --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/kneel_wrap_struggle.json @@ -0,0 +1,269 @@ +{ + "version": 1, + "uuid": "c9d0e1f2-a3b4-4567-8901-2345678abcde", + "name": "kneel_wrap_struggle", + "_comment": "Kneeling wrap struggle - body swaying and rocking", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -5 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 5 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 12, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": 5, + "roll": -8 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -3, + "yaw": -5, + "roll": 8 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 3, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 8, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 8, + "yaw": 10, + "roll": 8 + } + }, + { + "tick": 25, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -3, + "yaw": -3, + "roll": 6 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": 3, + "roll": -6 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": -2, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 3, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 2, + "yaw": -12, + "roll": -10 + } + }, + { + "tick": 40, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 2, + "yaw": 3, + "roll": -6 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 2, + "yaw": -3, + "roll": 6 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 2, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 6, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 10, + "yaw": 6, + "roll": 5 + } + }, + { + "tick": 55, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -2, + "yaw": -2, + "roll": 4 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -2, + "yaw": 2, + "roll": -4 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": -1, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 4, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": -8, + "roll": -6 + } + }, + { + "tick": 68, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 1, + "yaw": 2, + "roll": -5 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 1, + "yaw": -2, + "roll": 5 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 1, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 5, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 4, + "roll": 3 + } + }, + { + "tick": 80, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -5 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 5 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -15, + "yaw": 0, + "roll": 0, + "bend": 105.0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/latex_sack.json b/src/main/resources/assets/tiedup/player_animation/latex_sack.json new file mode 100644 index 0000000..33c3637 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/latex_sack.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "uuid": "d4e5f6a7-b8c9-4012-d4e5-f6a7b8c90123", + "name": "latex_sack", + "_comment": "Full enclosure pose (matching original mod: all rotations at 0)", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "LINEAR", + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/latex_sack_arms_idle.json b/src/main/resources/assets/tiedup/player_animation/latex_sack_arms_idle.json new file mode 100644 index 0000000..1ae53e4 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/latex_sack_arms_idle.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "uuid": "d4e5f6a7-b8c9-4012-d4e5-f6a7b8c90124", + "name": "latex_sack_arms_idle", + "_comment": "Arms encased - ARMS ONLY (legs free)", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "LINEAR", + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/latex_sack_arms_sneak_struggle.json b/src/main/resources/assets/tiedup/player_animation/latex_sack_arms_sneak_struggle.json new file mode 100644 index 0000000..ad8b381 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/latex_sack_arms_sneak_struggle.json @@ -0,0 +1,102 @@ +{ + "version": 1, + "uuid": "1217fe4c-52fb-479c-a009-444444444444", + "name": "latex_sack_arms_sneak_struggle", + "_comment": "Struggle animation for latex sack - ARMS ONLY - SNEAKING - torso pivot", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 0 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 20, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 15 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 40, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": -15 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 10 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 80, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 0 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/latex_sack_arms_struggle.json b/src/main/resources/assets/tiedup/player_animation/latex_sack_arms_struggle.json new file mode 100644 index 0000000..19d83cb --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/latex_sack_arms_struggle.json @@ -0,0 +1,102 @@ +{ + "version": 1, + "uuid": "1217fe4c-52fb-479c-a009-b3fa0ee54ff1", + "name": "latex_sack_arms_struggle", + "_comment": "Struggle animation for latex sack - ARMS ONLY - torso pivot", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 0 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 20, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 15 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 40, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": -15 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 10 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 80, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 0 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/latex_sack_idle.json b/src/main/resources/assets/tiedup/player_animation/latex_sack_idle.json new file mode 100644 index 0000000..901d43f --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/latex_sack_idle.json @@ -0,0 +1,101 @@ +{ + "version": 1, + "uuid": "f1e2d3c4-b5a6-7890-f1e2-bbbbbbbbbbbb", + "name": "latex_sack_idle", + "_comment": "Idle animation for latex sack - fully enclosed, subtle breathing", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "comment": "Base pose - enclosed", + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": -5 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 5 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": -3 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 3 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 } + } + }, + { + "tick": 40, + "easing": "EASEINOUTSINE", + "comment": "Subtle breathing", + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": -5 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 5 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": -3 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 3 + }, + "body": { + "position": { "x": 0, "y": 0.2, "z": 0 } + } + }, + { + "tick": 80, + "easing": "EASEINOUTSINE", + "comment": "Return to base", + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": -5 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 5 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": -3 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 3 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 } + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/latex_sack_legs.json b/src/main/resources/assets/tiedup/player_animation/latex_sack_legs.json new file mode 100644 index 0000000..a2de636 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/latex_sack_legs.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "uuid": "d4e5f6a7-b8c9-4012-d4e5-f6a7b8c90125", + "name": "latex_sack_legs", + "_comment": "Legs encased - LEGS ONLY (arms free)", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "LINEAR", + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/latex_sack_sneak_struggle.json b/src/main/resources/assets/tiedup/player_animation/latex_sack_sneak_struggle.json new file mode 100644 index 0000000..552c8ca --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/latex_sack_sneak_struggle.json @@ -0,0 +1,132 @@ +{ + "version": 1, + "uuid": "9345ebcb-3c8c-45e0-b1a6-333333333333", + "name": "latex_sack_sneak_struggle", + "_comment": "Struggle animation for latex sack - SNEAKING - torso pivot", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 0 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 20, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 15 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 40, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": -15 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 10 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 80, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 0 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/latex_sack_struggle.json b/src/main/resources/assets/tiedup/player_animation/latex_sack_struggle.json new file mode 100644 index 0000000..f1e393e --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/latex_sack_struggle.json @@ -0,0 +1,152 @@ +{ + "version": 1, + "uuid": "9345ebcb-3c8c-45e0-b1a6-0488192471f8", + "name": "latex_sack_struggle", + "_comment": "Struggle animation for latex sack - torso pivot left/right", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 0 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 20, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 15 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 40, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": -15 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 10 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 80, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 0 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/master_chair_sit_idle.json b/src/main/resources/assets/tiedup/player_animation/master_chair_sit_idle.json new file mode 100644 index 0000000..9586e2f --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/master_chair_sit_idle.json @@ -0,0 +1,212 @@ +{ + "version": 1, + "uuid": "d8e9f0a1-b2c3-4d56-e7f8-901234abcdef", + "name": "master_chair_sit_idle", + "_comment": [ + "Master NPC sitting on pet (human chair) — relaxed, dominant posture.", + "", + "=== Convention des axes (PlayerAnimator / bendy-lib) ===", + " pitch : rotation avant/arriere. Bras: negatif=vers le haut/arriere, positif=vers le bas/avant.", + " Jambes: negatif=lever la jambe vers l'avant (cuisse monte), positif=jambe vers l'arriere.", + " yaw : ecartement gauche/droite. Positif=vers la droite, negatif=vers la gauche.", + " roll : rotation sur l'axe du membre.", + " bend : pliage du genou/coude. Jambes: positif=plier le genou normalement (tibia remonte).", + " Bras: negatif=plier le coude (avant-bras vers l'epaule).", + "", + "Position du NPC: assis sur le dos du pet (hauteur ~0.5 bloc), comme sur un pouf bas.", + "Jambes: cuisses vers l'avant et vers le bas, genoux plies, pieds qui pendent.", + "Bras: une main posee sur la cuisse, l'autre decontractee sur le cote.", + "Corps: droit avec un leger recul (posture dominante, detendue)." + ], + "emote": { + "beginTick": 0, + "endTick": 120, + "stopTick": 120, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "comment": "Base pose — assise detendue sur le dos du pet", + "rightArm": { + "comment": "Main droite posee sur la cuisse droite, coude legerement plie", + "pitch": -25, + "yaw": 12, + "roll": 0, + "bend": -15 + }, + "leftArm": { + "comment": "Main gauche posee sur la cuisse gauche, miroir", + "pitch": -25, + "yaw": -12, + "roll": 0, + "bend": -15 + }, + "rightLeg": { + "comment": "Cuisse vers l'avant, genou plie (~80 deg), pied qui pend", + "pitch": -70, + "yaw": 12, + "roll": 0, + "bend": 80 + }, + "leftLeg": { + "comment": "Miroir jambe droite", + "pitch": -70, + "yaw": -12, + "roll": 0, + "bend": 80 + }, + "body": { + "comment": "Corps droit, tres leger recul (posture detendue/dominante)", + "position": { "x": 0, "y": -4, "z": 0 }, + "pitch": -3, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "comment": "Respiration — legere expansion, bras bougent subtilement", + "rightArm": { + "pitch": -23.5, + "yaw": 12.3, + "roll": 0.5, + "bend": -14 + }, + "leftArm": { + "pitch": -24, + "yaw": -11.8, + "roll": -0.3, + "bend": -15.5 + }, + "rightLeg": { + "pitch": -70, + "yaw": 12, + "roll": 0, + "bend": 80 + }, + "leftLeg": { + "pitch": -70, + "yaw": -12, + "roll": 0, + "bend": 80 + }, + "body": { + "position": { "x": 0, "y": -3.8, "z": 0 }, + "pitch": -2.5, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "comment": "Retour base + leger shift de poids vers la droite", + "rightArm": { + "pitch": -25.5, + "yaw": 12, + "roll": -0.3, + "bend": -15.5 + }, + "leftArm": { + "pitch": -24.5, + "yaw": -12.2, + "roll": 0.3, + "bend": -14.5 + }, + "rightLeg": { + "pitch": -70.3, + "yaw": 12.2, + "roll": 0, + "bend": 80.5 + }, + "leftLeg": { + "pitch": -69.7, + "yaw": -11.8, + "roll": 0, + "bend": 79.5 + }, + "body": { + "position": { "x": 0, "y": -4.1, "z": 0 }, + "pitch": -3.2, + "yaw": 0.5, + "roll": 0.5 + } + }, + { + "tick": 90, + "easing": "EASEINOUTSINE", + "comment": "Respiration 2 — shift de poids vers la gauche", + "rightArm": { + "pitch": -24, + "yaw": 11.8, + "roll": 0.3, + "bend": -14.5 + }, + "leftArm": { + "pitch": -25.5, + "yaw": -12.3, + "roll": -0.5, + "bend": -15.5 + }, + "rightLeg": { + "pitch": -69.7, + "yaw": 11.8, + "roll": 0, + "bend": 79.5 + }, + "leftLeg": { + "pitch": -70.3, + "yaw": -12.2, + "roll": 0, + "bend": 80.5 + }, + "body": { + "position": { "x": 0, "y": -3.9, "z": 0 }, + "pitch": -2.8, + "yaw": -0.5, + "roll": -0.5 + } + }, + { + "tick": 120, + "easing": "EASEINOUTSINE", + "comment": "Retour base — boucle", + "rightArm": { + "pitch": -25, + "yaw": 12, + "roll": 0, + "bend": -15 + }, + "leftArm": { + "pitch": -25, + "yaw": -12, + "roll": 0, + "bend": -15 + }, + "rightLeg": { + "pitch": -70, + "yaw": 12, + "roll": 0, + "bend": 80 + }, + "leftLeg": { + "pitch": -70, + "yaw": -12, + "roll": 0, + "bend": 80 + }, + "body": { + "position": { "x": 0, "y": -4, "z": 0 }, + "pitch": -3, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/pet_bed_sit.json b/src/main/resources/assets/tiedup/player_animation/pet_bed_sit.json new file mode 100644 index 0000000..beaac36 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/pet_bed_sit.json @@ -0,0 +1,236 @@ +{ + "version": 1, + "uuid": "b2c3d4e5-f6a7-4890-bcde-f01234567890", + "name": "pet_bed_sit", + "_comment": [ + "W-sit (wariza) pose: fesses au sol, cuisses vers l'avant, tibias replies horizontalement le long des hanches.", + "", + "=== ATTENTION: Les axes sont SWAPED a cause du roll ±90 sur les jambes ===", + "", + "Le roll ±90 tourne les jambes sur elles-memes pour que le bend (pliage du genou)", + "se fasse HORIZONTALEMENT (tibias vers l'arriere) au lieu de VERTICALEMENT (tibias vers le bas).", + "", + "Consequence: les axes visuels sont inverses pour les jambes:", + " - pitch : controle normalement avant/arriere, mais VISUELLEMENT controle l'ECARTEMENT des jambes", + " -90 = jambes droites devant, <-90 (ex: -110) = jambes ecartees, >-90 (ex: -70) = jambes fermees", + " - yaw : controle normalement gauche/droite, mais VISUELLEMENT controle le HAUT/BAS des jambes", + " garder a 0 pour eviter que les jambes montent ou descendent", + " - roll : -90 (droite) / +90 (gauche) = tourne la jambe pour rediriger le plan de pliage du genou", + " - bend : -90 = plie le tibia vers l'arriere (direction inversee par le roll, donc negatif au lieu de positif)", + "", + "Pour ajuster l'ecartement: modifier pitch (plus negatif = plus ecarte, ex: -120 = tres ouvert)", + "Pour ajuster la hauteur: modifier yaw (normalement laisser a 0)", + "Pour ajuster le pliage des tibias: modifier bend (plus negatif = plus plie)" + ], + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "comment": "Base pose - W-sit repos", + "rightArm": { + "comment": "Main droite posee sur la cuisse droite", + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -30, + "yaw": 10, + "roll": 0, + "bend": -20 + }, + "leftArm": { + "comment": "Main gauche posee sur la cuisse gauche", + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -30, + "yaw": -10, + "roll": 0, + "bend": -20 + }, + "rightLeg": { + "comment": "Cuisse droite: pitch=-110 ecarte (axe swappe par roll), roll=-90 redirige le bend, bend=-90 plie tibia horizontalement", + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -110, + "yaw": 0, + "roll": -90, + "bend": -90 + }, + "leftLeg": { + "comment": "Cuisse gauche: miroir de la droite (roll inverse)", + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -110, + "yaw": 0, + "roll": 90, + "bend": -90 + }, + "body": { + "comment": "Corps legerement penche en avant (assis)", + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "comment": "Breathing in - leger mouvement des bras, jambes statiques", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -28, + "yaw": 10, + "roll": 0.5, + "bend": -19 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -28, + "yaw": -10, + "roll": -0.5, + "bend": -19 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -110, + "yaw": 0, + "roll": -90, + "bend": -90 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -110, + "yaw": 0, + "roll": 90, + "bend": -90 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 6, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "comment": "Retour pose de base", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -30, + "yaw": 10, + "roll": 0, + "bend": -20 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -30, + "yaw": -10, + "roll": 0, + "bend": -20 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -110, + "yaw": 0, + "roll": -90, + "bend": -90 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -110, + "yaw": 0, + "roll": 90, + "bend": -90 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "comment": "Breathing out - leger mouvement inverse des bras", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -32, + "yaw": 10, + "roll": -0.5, + "bend": -21 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -32, + "yaw": -10, + "roll": 0.5, + "bend": -21 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -110, + "yaw": 0, + "roll": -90, + "bend": -90 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -110, + "yaw": 0, + "roll": 90, + "bend": -90 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 4, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "comment": "Retour pose de base - boucle", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -30, + "yaw": 10, + "roll": 0, + "bend": -20 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -30, + "yaw": -10, + "roll": 0, + "bend": -20 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -110, + "yaw": 0, + "roll": -90, + "bend": -90 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -110, + "yaw": 0, + "roll": 90, + "bend": -90 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/pet_bed_sleep.json b/src/main/resources/assets/tiedup/player_animation/pet_bed_sleep.json new file mode 100644 index 0000000..881dbe3 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/pet_bed_sleep.json @@ -0,0 +1,136 @@ +{ + "version": 1, + "uuid": "bb0da1ce-07d2-4d87-b1bd-6758e0513dc1", + "name": "pet_bed_sleep", + "emote": { + "beginTick": 0, + "endTick": 20, + "stopTick": 20, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "body": { + "pitch": 37.5, + "yaw": 0, + "roll": -90, + "position": { "x": 0, "y": -8, "z": 0 }, + "bend": 0 + }, + "head": { + "pitch": 34.89743, + "yaw": 2.86544, + "roll": -4.09918 + }, + "rightArm": { + "pitch": -28.40086, + "yaw": -36.43129, + "roll": -4.69768, + "bend": -37.5 + }, + "leftArm": { + "pitch": -5.02774, + "yaw": 19.00974, + "roll": 20.45461, + "bend": -32.5 + }, + "rightLeg": { + "pitch": -40, + "yaw": 0, + "roll": 0, + "bend": 65 + }, + "leftLeg": { + "pitch": 27.5, + "yaw": 0, + "roll": 0, + "bend": 42.5 + } + }, + { + "tick": 10, + "easing": "EASEINOUTSINE", + "body": { + "pitch": 37.5, + "yaw": 0, + "roll": -90, + "position": { "x": 0, "y": -8, "z": 0 }, + "bend": 7.5 + }, + "head": { + "pitch": 37.39743, + "yaw": 2.86544, + "roll": -4.09918 + }, + "rightArm": { + "pitch": -24.96327, + "yaw": -38.67863, + "roll": -10.33355, + "bend": -37.5 + }, + "leftArm": { + "pitch": -4.16419, + "yaw": 19.2101, + "roll": 23.09193, + "bend": -32.5 + }, + "rightLeg": { + "pitch": -35, + "yaw": 0, + "roll": 0, + "bend": 65 + }, + "leftLeg": { + "pitch": 22.5, + "yaw": 0, + "roll": 0, + "bend": 42.5 + } + }, + { + "tick": 20, + "easing": "EASEINOUTSINE", + "body": { + "pitch": 37.5, + "yaw": 0, + "roll": -90, + "position": { "x": 0, "y": -8, "z": 0 }, + "bend": 0 + }, + "head": { + "pitch": 34.89743, + "yaw": 2.86544, + "roll": -4.09918 + }, + "rightArm": { + "pitch": -28.40086, + "yaw": -36.43129, + "roll": -4.69768, + "bend": -37.5 + }, + "leftArm": { + "pitch": -5.02774, + "yaw": 19.00974, + "roll": 20.45461, + "bend": -32.5 + }, + "rightLeg": { + "pitch": -40, + "yaw": 0, + "roll": 0, + "bend": 65 + }, + "leftLeg": { + "pitch": 27.5, + "yaw": 0, + "roll": 0, + "bend": 42.5 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/sit_basic_idle.json b/src/main/resources/assets/tiedup/player_animation/sit_basic_idle.json new file mode 100644 index 0000000..cffaec5 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/sit_basic_idle.json @@ -0,0 +1,187 @@ +{ + "version": 1, + "uuid": "a1b2c3d4-e5f6-4789-0123-456789abcdef", + "name": "sit_basic_idle", + "_comment": "Sitting pose with breathing - basic bind - arms behind back - y:10 for ground level", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 54.0, + "yaw": 58.5, + "roll": 1 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 54.0, + "yaw": -58.5, + "roll": -1 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 6, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 49.0, + "yaw": 56.0, + "roll": -1 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 49.0, + "yaw": -56.0, + "roll": 1 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 4, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/sit_basic_struggle.json b/src/main/resources/assets/tiedup/player_animation/sit_basic_struggle.json new file mode 100644 index 0000000..07b64db --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/sit_basic_struggle.json @@ -0,0 +1,255 @@ +{ + "version": 1, + "uuid": "e5f6a7b8-c9d0-4123-4567-890123abcdef", + "name": "sit_basic_struggle", + "_comment": "Sitting struggle - arms behind back (bound), active pulling and body twisting", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 10, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 55, + "yaw": 62, + "roll": 10 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 48, + "yaw": -55, + "roll": -5 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -85, + "yaw": 0, + "roll": 5 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -95, + "yaw": 0, + "roll": -3 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 8, + "yaw": 10, + "roll": 5 + } + }, + { + "tick": 20, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 48, + "yaw": 55, + "roll": -5 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 55, + "yaw": -62, + "roll": 10 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -95, + "yaw": 0, + "roll": -3 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -85, + "yaw": 0, + "roll": 5 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": -5, + "roll": -3 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 58, + "yaw": 65, + "roll": 15 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 58, + "yaw": -65, + "roll": -15 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -80, + "yaw": 0, + "roll": 8 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -80, + "yaw": 0, + "roll": -8 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 10, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 46, + "yaw": 52, + "roll": -8 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 53, + "yaw": -60, + "roll": 8 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -95, + "yaw": 0, + "roll": -5 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -88, + "yaw": 0, + "roll": 3 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 2, + "yaw": -8, + "roll": -5 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 53, + "yaw": 60, + "roll": 12 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 49, + "yaw": -55, + "roll": -8 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -88, + "yaw": 0, + "roll": 4 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -92, + "yaw": 0, + "roll": -2 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 7, + "yaw": 5, + "roll": 3 + } + }, + { + "tick": 80, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/sit_free_idle.json b/src/main/resources/assets/tiedup/player_animation/sit_free_idle.json new file mode 100644 index 0000000..0cffbf3 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/sit_free_idle.json @@ -0,0 +1,187 @@ +{ + "version": 1, + "uuid": "f6a7b8c9-d0e1-4345-6789-012345abcdef", + "name": "sit_free_idle", + "_comment": "Sitting pose without bind - hands resting on knees naturally", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -35, + "yaw": 15, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -35, + "yaw": -15, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 10, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": -10, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -33, + "yaw": 15, + "roll": 1 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -33, + "yaw": -15, + "roll": -1 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 10, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": -10, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 6, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -35, + "yaw": 15, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -35, + "yaw": -15, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 10, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": -10, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -37, + "yaw": 15, + "roll": -1 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -37, + "yaw": -15, + "roll": 1 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 10, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": -10, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 4, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -35, + "yaw": 15, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -35, + "yaw": -15, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 10, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": -10, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/sit_latex_sack_idle.json b/src/main/resources/assets/tiedup/player_animation/sit_latex_sack_idle.json new file mode 100644 index 0000000..38e3b20 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/sit_latex_sack_idle.json @@ -0,0 +1,153 @@ +{ + "version": 1, + "uuid": "d4e5f6a7-b8c9-4012-3456-789012abcdef", + "name": "sit_latex_sack_idle", + "_comment": "Sitting pose with latex sack - minimal movement, encased", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -2 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 2 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 20, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -2 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 2 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 4, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 40, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -2 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 2 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -2 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 2 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/sit_latex_sack_struggle.json b/src/main/resources/assets/tiedup/player_animation/sit_latex_sack_struggle.json new file mode 100644 index 0000000..1c9bd3f --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/sit_latex_sack_struggle.json @@ -0,0 +1,221 @@ +{ + "version": 1, + "uuid": "b8c9d0e1-f2a3-4456-7890-123456abcdef", + "name": "sit_latex_sack_struggle", + "_comment": "Sitting latex sack struggle - limited movement, mainly body", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -2 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 2 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 2, + "yaw": 2, + "roll": -4 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -2, + "yaw": -2, + "roll": 4 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -88, + "yaw": 0, + "roll": 2 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -92, + "yaw": 0, + "roll": -2 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 6, + "yaw": 5, + "roll": 4 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -2, + "yaw": -2, + "roll": 3 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 2, + "yaw": 2, + "roll": -3 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -92, + "yaw": 0, + "roll": -1 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -88, + "yaw": 0, + "roll": 1 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 2, + "yaw": -5, + "roll": -4 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 1, + "yaw": 1, + "roll": -3 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 1, + "yaw": -1, + "roll": 3 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -89, + "yaw": 0, + "roll": 1 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -91, + "yaw": 0, + "roll": -1 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 3, + "roll": 2 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -1, + "yaw": -1, + "roll": 2 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -1, + "yaw": 1, + "roll": -2 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -91, + "yaw": 0, + "roll": -1 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -89, + "yaw": 0, + "roll": 1 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": -2, + "roll": -2 + } + }, + { + "tick": 80, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -2 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 2 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/sit_legs_idle.json b/src/main/resources/assets/tiedup/player_animation/sit_legs_idle.json new file mode 100644 index 0000000..be94655 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/sit_legs_idle.json @@ -0,0 +1,187 @@ +{ + "version": 1, + "uuid": "d4e5f6a7-b8c9-4012-3456-789012abcdef", + "name": "sit_legs_idle", + "_comment": "Sitting pose with legs-only bind - legs together (yaw=0), arms free on knees, y:10", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -35, + "yaw": 10, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -35, + "yaw": -10, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -33, + "yaw": 10, + "roll": 1 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -33, + "yaw": -10, + "roll": -1 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 6, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -35, + "yaw": 10, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -35, + "yaw": -10, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -37, + "yaw": 10, + "roll": -1 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -37, + "yaw": -10, + "roll": 1 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 4, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -35, + "yaw": 10, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -35, + "yaw": -10, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/sit_straitjacket_idle.json b/src/main/resources/assets/tiedup/player_animation/sit_straitjacket_idle.json new file mode 100644 index 0000000..5c796d0 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/sit_straitjacket_idle.json @@ -0,0 +1,187 @@ +{ + "version": 1, + "uuid": "b2c3d4e5-f6a7-4890-1234-567890abcdef", + "name": "sit_straitjacket_idle", + "_comment": "Sitting pose with straitjacket - arms crossed in front", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": -48.1, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": 48.1, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 45.5, + "yaw": -47.0, + "roll": 1 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 45.5, + "yaw": 47.0, + "roll": -1 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 6, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": -48.1, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": 48.1, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 42.0, + "yaw": -49.0, + "roll": -1 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 42.0, + "yaw": 49.0, + "roll": 1 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 4, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": -48.1, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": 48.1, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/sit_straitjacket_struggle.json b/src/main/resources/assets/tiedup/player_animation/sit_straitjacket_struggle.json new file mode 100644 index 0000000..0dc88da --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/sit_straitjacket_struggle.json @@ -0,0 +1,255 @@ +{ + "version": 1, + "uuid": "f6a7b8c9-d0e1-4234-5678-901234abcdef", + "name": "sit_straitjacket_struggle", + "_comment": "Sitting straitjacket struggle - torso twisting and writhing", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": -48.1, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": 48.1, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 10, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 50, + "yaw": -40, + "roll": 8 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 38, + "yaw": 55, + "roll": -5 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -85, + "yaw": 0, + "roll": 5 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -95, + "yaw": 0, + "roll": -3 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 10, + "yaw": 15, + "roll": 8 + } + }, + { + "tick": 25, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 38, + "yaw": -55, + "roll": -5 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 50, + "yaw": 40, + "roll": 8 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -95, + "yaw": 0, + "roll": -3 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -85, + "yaw": 0, + "roll": 5 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": -15, + "roll": -8 + } + }, + { + "tick": 40, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 48, + "yaw": -45, + "roll": 10 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 48, + "yaw": 45, + "roll": -10 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -88, + "yaw": 0, + "roll": 4 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -88, + "yaw": 0, + "roll": -4 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 12, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 55, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 40, + "yaw": -52, + "roll": -3 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 45, + "yaw": 50, + "roll": 5 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -92, + "yaw": 0, + "roll": -2 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -88, + "yaw": 0, + "roll": 3 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 6, + "yaw": 10, + "roll": 5 + } + }, + { + "tick": 70, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 45, + "yaw": -46, + "roll": 3 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 42, + "yaw": 50, + "roll": -2 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -88, + "yaw": 0, + "roll": 2 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -92, + "yaw": 0, + "roll": -2 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 4, + "yaw": -5, + "roll": -3 + } + }, + { + "tick": 80, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": -48.1, + "roll": 0 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 43.8, + "yaw": 48.1, + "roll": 0 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/sit_wrap_idle.json b/src/main/resources/assets/tiedup/player_animation/sit_wrap_idle.json new file mode 100644 index 0000000..8f04c4b --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/sit_wrap_idle.json @@ -0,0 +1,187 @@ +{ + "version": 1, + "uuid": "c3d4e5f6-a7b8-4901-2345-678901abcdef", + "name": "sit_wrap_idle", + "_comment": "Sitting pose with wrap bind - arms along body", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -5 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 5 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -4 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 4 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 6, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -5 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 5 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -6 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 6 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 4, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -5 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 5 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/sit_wrap_struggle.json b/src/main/resources/assets/tiedup/player_animation/sit_wrap_struggle.json new file mode 100644 index 0000000..e7c0a57 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/sit_wrap_struggle.json @@ -0,0 +1,255 @@ +{ + "version": 1, + "uuid": "a7b8c9d0-e1f2-4345-6789-012345abcdef", + "name": "sit_wrap_struggle", + "_comment": "Sitting wrap struggle - body swaying and rocking", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -5 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 5 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 12, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 5, + "roll": -10 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -5, + "yaw": -5, + "roll": 8 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -85, + "yaw": 0, + "roll": 5 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -95, + "yaw": 0, + "roll": -3 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 8, + "yaw": 12, + "roll": 10 + } + }, + { + "tick": 25, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -5, + "yaw": -5, + "roll": 3 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 5, + "roll": -8 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -95, + "yaw": 0, + "roll": -3 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -85, + "yaw": 0, + "roll": 5 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": -12, + "roll": -10 + } + }, + { + "tick": 40, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": 3, + "roll": -8 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 3, + "yaw": -3, + "roll": 8 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -88, + "yaw": 0, + "roll": 3 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -88, + "yaw": 0, + "roll": -3 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 10, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 55, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -3, + "yaw": -2, + "roll": 5 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 3, + "roll": -10 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -93, + "yaw": 0, + "roll": -2 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -87, + "yaw": 0, + "roll": 4 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 4, + "yaw": 8, + "roll": 6 + } + }, + { + "tick": 68, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 2, + "yaw": 2, + "roll": -6 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -2, + "yaw": -2, + "roll": 6 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -88, + "yaw": 0, + "roll": 2 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -92, + "yaw": 0, + "roll": -1 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 6, + "yaw": -5, + "roll": -4 + } + }, + { + "tick": 80, + "easing": "EASEINOUTSINE", + "rightArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": -5 + }, + "leftArm": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 0, + "yaw": 0, + "roll": 5 + }, + "rightLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + }, + "body": { + "position": { "x": 0, "y": 0, "z": 0 }, + "pitch": 5, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/straitjacket.json b/src/main/resources/assets/tiedup/player_animation/straitjacket.json new file mode 100644 index 0000000..721226c --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/straitjacket.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "uuid": "b2c3d4e5-f6a7-4890-b2c3-d4e5f6a78901", + "name": "straitjacket", + "_comment": "Arms crossed in front pose for straitjacket restraint", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "LINEAR", + "rightArm": { + "pitch": -45.8, + "yaw": -28.6, + "roll": -17.2 + }, + "leftArm": { + "pitch": -45.8, + "yaw": 28.6, + "roll": 17.2 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/straitjacket_arms.json b/src/main/resources/assets/tiedup/player_animation/straitjacket_arms.json new file mode 100644 index 0000000..63860b0 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/straitjacket_arms.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "uuid": "b2c3d4e5-f6a7-4890-b2c3-d4e5f6a78902", + "name": "straitjacket_arms", + "_comment": "Arms crossed in front - ARMS ONLY (legs free)", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "LINEAR", + "rightArm": { + "pitch": -45.8, + "yaw": -28.6, + "roll": -17.2 + }, + "leftArm": { + "pitch": -45.8, + "yaw": 28.6, + "roll": 17.2 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/straitjacket_arms_idle.json b/src/main/resources/assets/tiedup/player_animation/straitjacket_arms_idle.json new file mode 100644 index 0000000..8e6e967 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/straitjacket_arms_idle.json @@ -0,0 +1,87 @@ +{ + "version": 1, + "uuid": "f4e5d6c7-b8a9-4012-a4b5-c6d7e8f90012", + "name": "straitjacket_arms_idle", + "_comment": "Idle animation for straitjacket - ARMS ONLY (legs free) - subtle struggling/fidgeting", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -45.8, + "yaw": -28.6, + "roll": -17.2 + }, + "leftArm": { + "pitch": -45.8, + "yaw": 28.6, + "roll": 17.2 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -43.8, + "yaw": -27.1, + "roll": -16.2 + }, + "leftArm": { + "pitch": -43.8, + "yaw": 27.1, + "roll": 16.2 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -45.8, + "yaw": -28.6, + "roll": -17.2 + }, + "leftArm": { + "pitch": -45.8, + "yaw": 28.6, + "roll": 17.2 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -47.8, + "yaw": -30.1, + "roll": -18.2 + }, + "leftArm": { + "pitch": -47.8, + "yaw": 30.1, + "roll": 18.2 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -45.8, + "yaw": -28.6, + "roll": -17.2 + }, + "leftArm": { + "pitch": -45.8, + "yaw": 28.6, + "roll": 17.2 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/straitjacket_arms_sneak_idle.json b/src/main/resources/assets/tiedup/player_animation/straitjacket_arms_sneak_idle.json new file mode 100644 index 0000000..bd685d9 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/straitjacket_arms_sneak_idle.json @@ -0,0 +1,97 @@ +{ + "version": 1, + "uuid": "f4e5d6c7-b8a9-4012-a4b5-444444444444", + "name": "straitjacket_arms_sneak_idle", + "_comment": "Idle animation for straitjacket - ARMS ONLY - SNEAKING", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -15, + "yaw": -20, + "roll": -17.2, + "y": 5 + }, + "leftArm": { + "pitch": -15, + "yaw": 20, + "roll": 17.2, + "y": 5 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -17, + "yaw": -19, + "roll": -16.2, + "y": 5 + }, + "leftArm": { + "pitch": -17, + "yaw": 19, + "roll": 16.2, + "y": 5 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -15, + "yaw": -20, + "roll": -17.2, + "y": 5 + }, + "leftArm": { + "pitch": -15, + "yaw": 20, + "roll": 17.2, + "y": 5 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -13, + "yaw": -21, + "roll": -18.2, + "y": 5 + }, + "leftArm": { + "pitch": -13, + "yaw": 21, + "roll": 18.2, + "y": 5 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -15, + "yaw": -20, + "roll": -17.2, + "y": 5 + }, + "leftArm": { + "pitch": -15, + "yaw": 20, + "roll": 17.2, + "y": 5 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/straitjacket_arms_sneak_struggle.json b/src/main/resources/assets/tiedup/player_animation/straitjacket_arms_sneak_struggle.json new file mode 100644 index 0000000..cc384e6 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/straitjacket_arms_sneak_struggle.json @@ -0,0 +1,113 @@ +{ + "version": 1, + "uuid": "74cda8ac-e3bf-4f17-9ba4-888888888888", + "name": "straitjacket_arms_sneak_struggle", + "_comment": "Struggle animation for straitjacket - ARMS ONLY - SNEAKING", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -15, + "yaw": -20, + "roll": -17.2, + "y": 5 + }, + "leftArm": { + "pitch": -15, + "yaw": 20, + "roll": 17.2, + "y": 5 + } + }, + { + "tick": 15, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -8, + "yaw": -27, + "roll": -25, + "y": 5 + }, + "leftArm": { + "pitch": -20, + "yaw": 14, + "roll": 12, + "y": 5 + } + }, + { + "tick": 30, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -20, + "yaw": -14, + "roll": -12, + "y": 5 + }, + "leftArm": { + "pitch": -8, + "yaw": 27, + "roll": 25, + "y": 5 + } + }, + { + "tick": 50, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -5, + "yaw": -24, + "roll": -20, + "y": 5 + }, + "leftArm": { + "pitch": -5, + "yaw": 24, + "roll": 20, + "y": 5 + } + }, + { + "tick": 65, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -18, + "yaw": -21, + "roll": -17, + "y": 5 + }, + "leftArm": { + "pitch": -18, + "yaw": 21, + "roll": 17, + "y": 5 + } + }, + { + "tick": 80, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -15, + "yaw": -20, + "roll": -17.2, + "y": 5 + }, + "leftArm": { + "pitch": -15, + "yaw": 20, + "roll": 17.2, + "y": 5 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/straitjacket_arms_struggle.json b/src/main/resources/assets/tiedup/player_animation/straitjacket_arms_struggle.json new file mode 100644 index 0000000..54e9192 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/straitjacket_arms_struggle.json @@ -0,0 +1,101 @@ +{ + "version": 1, + "uuid": "d0e2c3c3-b856-42c2-be89-e31e4824a85b", + "name": "straitjacket_arms_struggle", + "_comment": "Struggle animation for straitjacket pose - ARMS ONLY", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -45.8, + "yaw": -28.6, + "roll": -17.2 + }, + "leftArm": { + "pitch": -45.8, + "yaw": 28.6, + "roll": 17.2 + } + }, + { + "tick": 15, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -38, + "yaw": -35, + "roll": -25 + }, + "leftArm": { + "pitch": -50, + "yaw": 22, + "roll": 12 + } + }, + { + "tick": 30, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -50, + "yaw": -22, + "roll": -12 + }, + "leftArm": { + "pitch": -38, + "yaw": 35, + "roll": 25 + } + }, + { + "tick": 50, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -35, + "yaw": -32, + "roll": -20 + }, + "leftArm": { + "pitch": -35, + "yaw": 32, + "roll": 20 + } + }, + { + "tick": 65, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -48, + "yaw": -29, + "roll": -17 + }, + "leftArm": { + "pitch": -48, + "yaw": 29, + "roll": 17 + } + }, + { + "tick": 80, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -45.8, + "yaw": -28.6, + "roll": -17.2 + }, + "leftArm": { + "pitch": -45.8, + "yaw": 28.6, + "roll": 17.2 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/straitjacket_idle.json b/src/main/resources/assets/tiedup/player_animation/straitjacket_idle.json new file mode 100644 index 0000000..13c751b --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/straitjacket_idle.json @@ -0,0 +1,137 @@ +{ + "version": 1, + "uuid": "f3e4d5c6-b7a8-4901-a3b4-c5d6e7f89001", + "name": "straitjacket_idle", + "_comment": "Idle animation for straitjacket - subtle struggling/fidgeting", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -45.8, + "yaw": -28.6, + "roll": -17.2 + }, + "leftArm": { + "pitch": -45.8, + "yaw": 28.6, + "roll": 17.2 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -43.8, + "yaw": -27.1, + "roll": -16.2 + }, + "leftArm": { + "pitch": -43.8, + "yaw": 27.1, + "roll": 16.2 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -45.8, + "yaw": -28.6, + "roll": -17.2 + }, + "leftArm": { + "pitch": -45.8, + "yaw": 28.6, + "roll": 17.2 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -47.8, + "yaw": -30.1, + "roll": -18.2 + }, + "leftArm": { + "pitch": -47.8, + "yaw": 30.1, + "roll": 18.2 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -45.8, + "yaw": -28.6, + "roll": -17.2 + }, + "leftArm": { + "pitch": -45.8, + "yaw": 28.6, + "roll": 17.2 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/straitjacket_legs.json b/src/main/resources/assets/tiedup/player_animation/straitjacket_legs.json new file mode 100644 index 0000000..01f59f0 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/straitjacket_legs.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "uuid": "b2c3d4e5-f6a7-4890-b2c3-d4e5f6a78903", + "name": "straitjacket_legs", + "_comment": "Legs together - LEGS ONLY (arms free)", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "LINEAR", + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/straitjacket_sneak_idle.json b/src/main/resources/assets/tiedup/player_animation/straitjacket_sneak_idle.json new file mode 100644 index 0000000..58f1434 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/straitjacket_sneak_idle.json @@ -0,0 +1,127 @@ +{ + "version": 1, + "uuid": "f3e4d5c6-b7a8-4901-a3b4-333333333333", + "name": "straitjacket_sneak_idle", + "_comment": "Idle animation for straitjacket - SNEAKING", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -15, + "yaw": -20, + "roll": -17.2, + "y": 5 + }, + "leftArm": { + "pitch": -15, + "yaw": 20, + "roll": 17.2, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -17, + "yaw": -19, + "roll": -16.2, + "y": 5 + }, + "leftArm": { + "pitch": -17, + "yaw": 19, + "roll": 16.2, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -15, + "yaw": -20, + "roll": -17.2, + "y": 5 + }, + "leftArm": { + "pitch": -15, + "yaw": 20, + "roll": 17.2, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -13, + "yaw": -21, + "roll": -18.2, + "y": 5 + }, + "leftArm": { + "pitch": -13, + "yaw": 21, + "roll": 18.2, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": -15, + "yaw": -20, + "roll": -17.2, + "y": 5 + }, + "leftArm": { + "pitch": -15, + "yaw": 20, + "roll": 17.2, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/straitjacket_sneak_struggle.json b/src/main/resources/assets/tiedup/player_animation/straitjacket_sneak_struggle.json new file mode 100644 index 0000000..dd49400 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/straitjacket_sneak_struggle.json @@ -0,0 +1,149 @@ +{ + "version": 1, + "uuid": "74cda8ac-e3bf-4f17-9ba4-666666666666", + "name": "straitjacket_sneak_struggle", + "_comment": "Struggle animation for straitjacket - SNEAKING", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -15, + "yaw": -20, + "roll": -17.2, + "y": 5 + }, + "leftArm": { + "pitch": -15, + "yaw": 20, + "roll": 17.2, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 15, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -8, + "yaw": -27, + "roll": -25, + "y": 5 + }, + "leftArm": { + "pitch": -20, + "yaw": 14, + "roll": 12, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 30, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -20, + "yaw": -14, + "roll": -12, + "y": 5 + }, + "leftArm": { + "pitch": -8, + "yaw": 27, + "roll": 25, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 50, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -5, + "yaw": -24, + "roll": -20, + "y": 5 + }, + "leftArm": { + "pitch": -5, + "yaw": 24, + "roll": 20, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 65, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -18, + "yaw": -21, + "roll": -17, + "y": 5 + }, + "leftArm": { + "pitch": -18, + "yaw": 21, + "roll": 17, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 80, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -15, + "yaw": -20, + "roll": -17.2, + "y": 5 + }, + "leftArm": { + "pitch": -15, + "yaw": 20, + "roll": 17.2, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/straitjacket_struggle.json b/src/main/resources/assets/tiedup/player_animation/straitjacket_struggle.json new file mode 100644 index 0000000..1f71d0e --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/straitjacket_struggle.json @@ -0,0 +1,161 @@ +{ + "version": 1, + "uuid": "74cda8ac-e3bf-4f17-9ba4-8fd7bdb14391", + "name": "straitjacket_struggle", + "_comment": "Struggle animation for straitjacket pose - arms crossed in front", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -45.8, + "yaw": -28.6, + "roll": -17.2 + }, + "leftArm": { + "pitch": -45.8, + "yaw": 28.6, + "roll": 17.2 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -38, + "yaw": -35, + "roll": -25 + }, + "leftArm": { + "pitch": -50, + "yaw": 22, + "roll": 12 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -50, + "yaw": -22, + "roll": -12 + }, + "leftArm": { + "pitch": -38, + "yaw": 35, + "roll": 25 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 50, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -35, + "yaw": -32, + "roll": -20 + }, + "leftArm": { + "pitch": -35, + "yaw": 32, + "roll": 20 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 65, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -48, + "yaw": -29, + "roll": -17 + }, + "leftArm": { + "pitch": -48, + "yaw": 29, + "roll": 17 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 80, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": -45.8, + "yaw": -28.6, + "roll": -17.2 + }, + "leftArm": { + "pitch": -45.8, + "yaw": 28.6, + "roll": 17.2 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/tied_up_basic.json b/src/main/resources/assets/tiedup/player_animation/tied_up_basic.json new file mode 100644 index 0000000..b418feb --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/tied_up_basic.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "uuid": "a1b2c3d4-e5f6-4789-a1b2-c3d4e5f67890", + "name": "tied_up_basic", + "_comment": "Arms behind back pose for tied up players", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "LINEAR", + "rightArm": { + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/tied_up_basic_arms.json b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_arms.json new file mode 100644 index 0000000..8b3c122 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_arms.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "uuid": "a1b2c3d4-e5f6-4789-a1b2-c3d4e5f67891", + "name": "tied_up_basic_arms", + "_comment": "Arms behind back pose - ARMS ONLY (legs free)", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "LINEAR", + "rightArm": { + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/tied_up_basic_arms_idle.json b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_arms_idle.json new file mode 100644 index 0000000..e19d8ea --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_arms_idle.json @@ -0,0 +1,87 @@ +{ + "version": 1, + "uuid": "f2e3d4c5-b6a7-4890-a2b3-c4d5e6f78900", + "name": "tied_up_basic_arms_idle", + "_comment": "Idle animation for tied up basic pose - ARMS ONLY (legs free) - subtle arm breathing/fidgeting", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 54.0, + "yaw": 58.5, + "roll": 1.0 + }, + "leftArm": { + "pitch": 54.0, + "yaw": -58.5, + "roll": -1.0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 49.0, + "yaw": 56.0, + "roll": -1.0 + }, + "leftArm": { + "pitch": 49.0, + "yaw": -56.0, + "roll": 1.0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/tied_up_basic_arms_sneak_idle.json b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_arms_sneak_idle.json new file mode 100644 index 0000000..af1310b --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_arms_sneak_idle.json @@ -0,0 +1,97 @@ +{ + "version": 1, + "uuid": "a1b2c3d4-e5f6-4789-a1b2-222222222222", + "name": "tied_up_basic_arms_sneak_idle", + "_comment": "Idle animation for tied up basic pose - ARMS ONLY - SNEAKING", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 80, + "yaw": 45, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 80, + "yaw": -45, + "roll": 0, + "y": 5 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 82.5, + "yaw": 46, + "roll": 1.0, + "y": 5 + }, + "leftArm": { + "pitch": 82.5, + "yaw": -46, + "roll": -1.0, + "y": 5 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 80, + "yaw": 45, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 80, + "yaw": -45, + "roll": 0, + "y": 5 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 77.5, + "yaw": 44, + "roll": -1.0, + "y": 5 + }, + "leftArm": { + "pitch": 77.5, + "yaw": -44, + "roll": 1.0, + "y": 5 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 80, + "yaw": 45, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 80, + "yaw": -45, + "roll": 0, + "y": 5 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/tied_up_basic_arms_sneak_struggle.json b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_arms_sneak_struggle.json new file mode 100644 index 0000000..8b8ce87 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_arms_sneak_struggle.json @@ -0,0 +1,113 @@ +{ + "version": 1, + "uuid": "39335707-c581-442f-b0c7-777777777777", + "name": "tied_up_basic_arms_sneak_struggle", + "_comment": "Struggle animation for tied up basic pose - ARMS ONLY - SNEAKING", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": 80, + "yaw": 45, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 80, + "yaw": -45, + "roll": 0, + "y": 5 + } + }, + { + "tick": 15, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": 70, + "yaw": 51, + "roll": -8.0, + "y": 5 + }, + "leftArm": { + "pitch": 78, + "yaw": -38, + "roll": 7.0, + "y": 5 + } + }, + { + "tick": 30, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": 78, + "yaw": 38, + "roll": 7.0, + "y": 5 + }, + "leftArm": { + "pitch": 70, + "yaw": -51, + "roll": -8.0, + "y": 5 + } + }, + { + "tick": 50, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": 67, + "yaw": 49, + "roll": 6.0, + "y": 5 + }, + "leftArm": { + "pitch": 69, + "yaw": -49, + "roll": -6.0, + "y": 5 + } + }, + { + "tick": 65, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": 81, + "yaw": 44, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 81, + "yaw": -44, + "roll": 0, + "y": 5 + } + }, + { + "tick": 80, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": 80, + "yaw": 45, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 80, + "yaw": -45, + "roll": 0, + "y": 5 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/tied_up_basic_arms_struggle.json b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_arms_struggle.json new file mode 100644 index 0000000..9ea1ed8 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_arms_struggle.json @@ -0,0 +1,101 @@ +{ + "version": 1, + "uuid": "c10d7afe-941a-4856-8b83-5bb369023b4b", + "name": "tied_up_basic_arms_struggle", + "_comment": "Struggle animation for tied up basic pose - ARMS ONLY (legs free)", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": 42.0, + "yaw": 65.0, + "roll": -8.0 + }, + "leftArm": { + "pitch": 50.0, + "yaw": -48.0, + "roll": 7.0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": 50.0, + "yaw": 48.0, + "roll": 7.0 + }, + "leftArm": { + "pitch": 42.0, + "yaw": -65.0, + "roll": -8.0 + } + }, + { + "tick": 50, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": 39.0, + "yaw": 62.0, + "roll": 6.0 + }, + "leftArm": { + "pitch": 41.0, + "yaw": -62.0, + "roll": -6.0 + } + }, + { + "tick": 65, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": 53.0, + "yaw": 56.0, + "roll": 0 + }, + "leftArm": { + "pitch": 53.0, + "yaw": -56.0, + "roll": 0 + } + }, + { + "tick": 80, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/tied_up_basic_idle.json b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_idle.json new file mode 100644 index 0000000..e9ee9d0 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_idle.json @@ -0,0 +1,137 @@ +{ + "version": 1, + "uuid": "f1e2d3c4-b5a6-4789-a1b2-c3d4e5f67899", + "name": "tied_up_basic_idle", + "_comment": "Idle animation for tied up basic pose - subtle arm breathing/fidgeting", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 54.0, + "yaw": 58.5, + "roll": 1.0 + }, + "leftArm": { + "pitch": 54.0, + "yaw": -58.5, + "roll": -1.0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 49.0, + "yaw": 56.0, + "roll": -1.0 + }, + "leftArm": { + "pitch": 49.0, + "yaw": -56.0, + "roll": 1.0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/tied_up_basic_legs.json b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_legs.json new file mode 100644 index 0000000..d7211c6 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_legs.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "uuid": "a1b2c3d4-e5f6-4789-a1b2-c3d4e5f67892", + "name": "tied_up_basic_legs", + "_comment": "Legs together pose - LEGS ONLY (arms free)", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "LINEAR", + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/tied_up_basic_sneak_idle.json b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_sneak_idle.json new file mode 100644 index 0000000..4592a9c --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_sneak_idle.json @@ -0,0 +1,127 @@ +{ + "version": 1, + "uuid": "a1b2c3d4-e5f6-4789-a1b2-111111111111", + "name": "tied_up_basic_sneak_idle", + "_comment": "Idle animation for tied up basic pose - SNEAKING", + "emote": { + "beginTick": 0, + "endTick": 60, + "stopTick": 60, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 80, + "yaw": 45, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 80, + "yaw": -45, + "roll": 0, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 15, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 82.5, + "yaw": 46, + "roll": 1.0, + "y": 5 + }, + "leftArm": { + "pitch": 82.5, + "yaw": -46, + "roll": -1.0, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 80, + "yaw": 45, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 80, + "yaw": -45, + "roll": 0, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 45, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 77.5, + "yaw": 44, + "roll": -1.0, + "y": 5 + }, + "leftArm": { + "pitch": 77.5, + "yaw": -44, + "roll": 1.0, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "rightArm": { + "pitch": 80, + "yaw": 45, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 80, + "yaw": -45, + "roll": 0, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/tied_up_basic_sneak_struggle.json b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_sneak_struggle.json new file mode 100644 index 0000000..80adc75 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_sneak_struggle.json @@ -0,0 +1,149 @@ +{ + "version": 1, + "uuid": "39335707-c581-442f-b0c7-555555555555", + "name": "tied_up_basic_sneak_struggle", + "_comment": "Struggle animation for tied up basic pose - SNEAKING", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": 80, + "yaw": 45, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 80, + "yaw": -45, + "roll": 0, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 15, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": 70, + "yaw": 51, + "roll": -8.0, + "y": 5 + }, + "leftArm": { + "pitch": 78, + "yaw": -38, + "roll": 7.0, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 30, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": 78, + "yaw": 38, + "roll": 7.0, + "y": 5 + }, + "leftArm": { + "pitch": 70, + "yaw": -51, + "roll": -8.0, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 50, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": 67, + "yaw": 49, + "roll": 6.0, + "y": 5 + }, + "leftArm": { + "pitch": 69, + "yaw": -49, + "roll": -6.0, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 65, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": 81, + "yaw": 44, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 81, + "yaw": -44, + "roll": 0, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 80, + "easing": "EASEINOUTQUAD", + "rightArm": { + "pitch": 80, + "yaw": 45, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 80, + "yaw": -45, + "roll": 0, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/tied_up_basic_struggle.json b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_struggle.json new file mode 100644 index 0000000..f4a51ae --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/tied_up_basic_struggle.json @@ -0,0 +1,167 @@ +{ + "version": 1, + "uuid": "39335707-c581-442f-b0c7-713363e6d551", + "name": "tied_up_basic_struggle", + "_comment": "Struggle animation for tied up basic pose - energetic struggling/fighting movement", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTQUAD", + "comment": "Rest position - base pose", + "rightArm": { + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 15, + "easing": "EASEINOUTQUAD", + "comment": "Pull hard to the left - asymmetric struggle", + "rightArm": { + "pitch": 42.0, + "yaw": 65.0, + "roll": -8.0 + }, + "leftArm": { + "pitch": 50.0, + "yaw": -48.0, + "roll": 7.0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 30, + "easing": "EASEINOUTQUAD", + "comment": "Pull hard to the right - opposite direction", + "rightArm": { + "pitch": 50.0, + "yaw": 48.0, + "roll": 7.0 + }, + "leftArm": { + "pitch": 42.0, + "yaw": -65.0, + "roll": -8.0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 50, + "easing": "EASEINOUTQUAD", + "comment": "Pull upward - trying to break free", + "rightArm": { + "pitch": 39.0, + "yaw": 62.0, + "roll": 6.0 + }, + "leftArm": { + "pitch": 41.0, + "yaw": -62.0, + "roll": -6.0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 65, + "easing": "EASEINOUTQUAD", + "comment": "Exhausted return - slower movement", + "rightArm": { + "pitch": 53.0, + "yaw": 56.0, + "roll": 0 + }, + "leftArm": { + "pitch": 53.0, + "yaw": -56.0, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 80, + "easing": "EASEINOUTQUAD", + "comment": "Back to rest - loop", + "rightArm": { + "pitch": 51.5, + "yaw": 57.3, + "roll": 0 + }, + "leftArm": { + "pitch": 51.5, + "yaw": -57.3, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/tied_up_dog_idle.json b/src/main/resources/assets/tiedup/player_animation/tied_up_dog_idle.json new file mode 100644 index 0000000..5d06f17 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/tied_up_dog_idle.json @@ -0,0 +1,182 @@ +{ + "version": 1, + "uuid": "9f60f489-b517-44f9-8695-8479faba56b8", + "name": "tied_up_dog_idle", + "_comment": "Dog pose idle - subtle breathing and weight shifts", + "emote": { + "beginTick": 0, + "endTick": 120, + "stopTick": 120, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "comment": "Base pose - resting", + "rightArm": { + "pitch": -120, + "yaw": 10, + "roll": 0, + "bend": -110 + }, + "leftArm": { + "pitch": -120, + "yaw": -10, + "roll": 0, + "bend": -110 + }, + "rightLeg": { + "pitch": -60, + "yaw": 20, + "roll": 0, + "bend": 110 + }, + "leftLeg": { + "pitch": -60, + "yaw": -20, + "roll": 0, + "bend": 110 + }, + "body": { + "position": { "x": 0, "y": -20, "z": 0 }, + "pitch": -90 + } + }, + { + "tick": 30, + "easing": "EASEINOUTSINE", + "comment": "Breathing in - subtle expansion", + "rightArm": { + "pitch": -119, + "yaw": 10.5, + "roll": 0, + "bend": -109 + }, + "leftArm": { + "pitch": -119, + "yaw": -10.5, + "roll": 0, + "bend": -109 + }, + "rightLeg": { + "pitch": -60, + "yaw": 20, + "roll": 0, + "bend": 110 + }, + "leftLeg": { + "pitch": -60, + "yaw": -20, + "roll": 0, + "bend": 110 + }, + "body": { + "position": { "x": 0, "y": -19.7, "z": 0 }, + "pitch": -89.5 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "comment": "Breathing out - settle with slight weight shift", + "rightArm": { + "pitch": -120.5, + "yaw": 10, + "roll": 0, + "bend": -111 + }, + "leftArm": { + "pitch": -120.5, + "yaw": -10, + "roll": 0, + "bend": -111 + }, + "rightLeg": { + "pitch": -60.5, + "yaw": 20, + "roll": 0, + "bend": 110.5 + }, + "leftLeg": { + "pitch": -59.5, + "yaw": -20, + "roll": 0, + "bend": 109.5 + }, + "body": { + "position": { "x": 0, "y": -20.1, "z": 0 }, + "pitch": -90.3 + } + }, + { + "tick": 90, + "easing": "EASEINOUTSINE", + "comment": "Breathing cycle 2 - in with opposite weight shift", + "rightArm": { + "pitch": -119, + "yaw": 10.3, + "roll": 0, + "bend": -109 + }, + "leftArm": { + "pitch": -119, + "yaw": -10.3, + "roll": 0, + "bend": -109 + }, + "rightLeg": { + "pitch": -59.5, + "yaw": 20, + "roll": 0, + "bend": 109.5 + }, + "leftLeg": { + "pitch": -60.5, + "yaw": -20, + "roll": 0, + "bend": 110.5 + }, + "body": { + "position": { "x": 0, "y": -19.6, "z": 0 }, + "pitch": -89.4 + } + }, + { + "tick": 120, + "easing": "EASEINOUTSINE", + "comment": "Return to base - loop", + "rightArm": { + "pitch": -120, + "yaw": 10, + "roll": 0, + "bend": -110 + }, + "leftArm": { + "pitch": -120, + "yaw": -10, + "roll": 0, + "bend": -110 + }, + "rightLeg": { + "pitch": -60, + "yaw": 20, + "roll": 0, + "bend": 110 + }, + "leftLeg": { + "pitch": -60, + "yaw": -20, + "roll": 0, + "bend": 110 + }, + "body": { + "position": { "x": 0, "y": -20, "z": 0 }, + "pitch": -90 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/tied_up_dog_struggle.json b/src/main/resources/assets/tiedup/player_animation/tied_up_dog_struggle.json new file mode 100644 index 0000000..29a75b6 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/tied_up_dog_struggle.json @@ -0,0 +1,297 @@ +{ + "version": 1, + "uuid": "d3e27f61-4a92-4c8e-b1d5-6f9a8c2e4b7d", + "name": "tied_up_dog_struggle", + "_comment": "Dog pose struggle - energetic attempts to break free while on all fours", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTQUAD", + "comment": "Base pose - preparing to struggle", + "rightArm": { + "pitch": -120, + "yaw": 10, + "roll": 0, + "bend": -110 + }, + "leftArm": { + "pitch": -120, + "yaw": -10, + "roll": 0, + "bend": -110 + }, + "rightLeg": { + "pitch": -60, + "yaw": 20, + "roll": 0, + "bend": 110 + }, + "leftLeg": { + "pitch": -60, + "yaw": -20, + "roll": 0, + "bend": 110 + }, + "body": { + "position": { "x": 0, "y": -20, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 10, + "easing": "EASEINOUTQUAD", + "comment": "Pull right arm hard - trying to free hands", + "rightArm": { + "pitch": -105, + "yaw": 25, + "roll": 8, + "bend": -95 + }, + "leftArm": { + "pitch": -130, + "yaw": -15, + "roll": -5, + "bend": -115 + }, + "rightLeg": { + "pitch": -55, + "yaw": 25, + "roll": 3, + "bend": 105 + }, + "leftLeg": { + "pitch": -65, + "yaw": -15, + "roll": -3, + "bend": 115 + }, + "body": { + "position": { "x": 0.5, "y": -19.5, "z": 0 }, + "pitch": -88, + "yaw": 5, + "roll": 8 + } + }, + { + "tick": 22, + "easing": "EASEINOUTQUAD", + "comment": "Pull left arm - alternating struggle", + "rightArm": { + "pitch": -130, + "yaw": 15, + "roll": 5, + "bend": -115 + }, + "leftArm": { + "pitch": -105, + "yaw": -25, + "roll": -8, + "bend": -95 + }, + "rightLeg": { + "pitch": -65, + "yaw": 15, + "roll": 3, + "bend": 115 + }, + "leftLeg": { + "pitch": -55, + "yaw": -25, + "roll": -3, + "bend": 105 + }, + "body": { + "position": { "x": -0.5, "y": -19.5, "z": 0 }, + "pitch": -88, + "yaw": -5, + "roll": -8 + } + }, + { + "tick": 35, + "easing": "EASEINOUTQUAD", + "comment": "Try to push up - testing restraints", + "rightArm": { + "pitch": -115, + "yaw": 15, + "roll": 0, + "bend": -100 + }, + "leftArm": { + "pitch": -115, + "yaw": -15, + "roll": 0, + "bend": -100 + }, + "rightLeg": { + "pitch": -50, + "yaw": 22, + "roll": 0, + "bend": 100 + }, + "leftLeg": { + "pitch": -50, + "yaw": -22, + "roll": 0, + "bend": 100 + }, + "body": { + "position": { "x": 0, "y": -18, "z": 0 }, + "pitch": -85, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 45, + "easing": "EASEINOUTQUAD", + "comment": "Collapse back down - failed attempt", + "rightArm": { + "pitch": -125, + "yaw": 8, + "roll": 0, + "bend": -115 + }, + "leftArm": { + "pitch": -125, + "yaw": -8, + "roll": 0, + "bend": -115 + }, + "rightLeg": { + "pitch": -65, + "yaw": 18, + "roll": 0, + "bend": 115 + }, + "leftLeg": { + "pitch": -65, + "yaw": -18, + "roll": 0, + "bend": 115 + }, + "body": { + "position": { "x": 0, "y": -20.5, "z": 0 }, + "pitch": -92, + "yaw": 0, + "roll": 3 + } + }, + { + "tick": 55, + "easing": "EASEINOUTQUAD", + "comment": "Twist body right - desperate attempt", + "rightArm": { + "pitch": -110, + "yaw": 20, + "roll": 10, + "bend": -100 + }, + "leftArm": { + "pitch": -125, + "yaw": -5, + "roll": -5, + "bend": -118 + }, + "rightLeg": { + "pitch": -55, + "yaw": 28, + "roll": 5, + "bend": 105 + }, + "leftLeg": { + "pitch": -62, + "yaw": -18, + "roll": -2, + "bend": 112 + }, + "body": { + "position": { "x": 0.3, "y": -19.8, "z": 0 }, + "pitch": -89, + "yaw": 8, + "roll": 10 + } + }, + { + "tick": 68, + "easing": "EASEINOUTQUAD", + "comment": "Twist body left - final struggle", + "rightArm": { + "pitch": -125, + "yaw": 5, + "roll": 5, + "bend": -118 + }, + "leftArm": { + "pitch": -110, + "yaw": -20, + "roll": -10, + "bend": -100 + }, + "rightLeg": { + "pitch": -62, + "yaw": 18, + "roll": 2, + "bend": 112 + }, + "leftLeg": { + "pitch": -55, + "yaw": -28, + "roll": -5, + "bend": 105 + }, + "body": { + "position": { "x": -0.3, "y": -19.8, "z": 0 }, + "pitch": -89, + "yaw": -8, + "roll": -10 + } + }, + { + "tick": 80, + "easing": "EASEINOUTQUAD", + "comment": "Return to base - exhausted, loop reset", + "rightArm": { + "pitch": -120, + "yaw": 10, + "roll": 0, + "bend": -110 + }, + "leftArm": { + "pitch": -120, + "yaw": -10, + "roll": 0, + "bend": -110 + }, + "rightLeg": { + "pitch": -60, + "yaw": 20, + "roll": 0, + "bend": 110 + }, + "leftLeg": { + "pitch": -60, + "yaw": -20, + "roll": 0, + "bend": 110 + }, + "body": { + "position": { "x": 0, "y": -20, "z": 0 }, + "pitch": -90, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/tied_up_dog_walk.json b/src/main/resources/assets/tiedup/player_animation/tied_up_dog_walk.json new file mode 100644 index 0000000..d51c662 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/tied_up_dog_walk.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "uuid": "a62a3047-028e-4465-98c0-77d08c78b01a", + "name": "tied_up_dog_walk", + "_comment": "Dog pose - Walk crawling", + "emote": { + "beginTick": 0, + "endTick": 20, + "stopTick": 20, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "LINEAR", + "rightArm": { "pitch": -110, "yaw": 10, "bend": -100 }, + "leftArm": { "pitch": -130, "yaw": -10, "bend": -120 }, + "rightLeg": { "pitch": -70, "yaw": 20, "bend": 120 }, + "leftLeg": { "pitch": -50, "yaw": -20, "bend": 100 }, + "body": { "position": { "x": 0, "y": -20, "z": 0 }, "pitch": -90 } + }, + { + "tick": 10, + "easing": "LINEAR", + "rightArm": { "pitch": -130, "yaw": 10, "bend": -120 }, + "leftArm": { "pitch": -110, "yaw": -10, "bend": -100 }, + "rightLeg": { "pitch": -50, "yaw": 20, "bend": 100 }, + "leftLeg": { "pitch": -70, "yaw": -20, "bend": 120 }, + "body": { "position": { "x": 0, "y": -20.5, "z": 0 }, "pitch": -88 } + }, + { + "tick": 20, + "easing": "LINEAR", + "rightArm": { "pitch": -110, "yaw": 10, "bend": -100 }, + "leftArm": { "pitch": -130, "yaw": -10, "bend": -120 }, + "rightLeg": { "pitch": -70, "yaw": 20, "bend": 120 }, + "leftLeg": { "pitch": -50, "yaw": -20, "bend": 100 }, + "body": { "position": { "x": 0, "y": -20, "z": 0 }, "pitch": -90 } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/wrap.json b/src/main/resources/assets/tiedup/player_animation/wrap.json new file mode 100644 index 0000000..a40c4a7 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/wrap.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "uuid": "c3d4e5f6-a7b8-4901-c3d4-e5f6a7b89012", + "name": "wrap", + "_comment": "Arms at sides, body wrapped tight pose (matching original mod: all rotations at 0)", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "LINEAR", + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/wrap_arms.json b/src/main/resources/assets/tiedup/player_animation/wrap_arms.json new file mode 100644 index 0000000..a3b8414 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/wrap_arms.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "uuid": "c3d4e5f6-a7b8-4901-c3d4-e5f6a7b89013", + "name": "wrap_arms", + "_comment": "Arms at sides wrapped - ARMS ONLY (legs free)", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "LINEAR", + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/wrap_arms_idle.json b/src/main/resources/assets/tiedup/player_animation/wrap_arms_idle.json new file mode 100644 index 0000000..924d5b3 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/wrap_arms_idle.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "uuid": "c3d4e5f6-a7b8-4901-c3d4-bbbbbbbbbbbb", + "name": "wrap_arms_idle", + "_comment": "Idle animation for wrap - ARMS ONLY - arms locked along torso", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "LINEAR", + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/wrap_arms_sneak_idle.json b/src/main/resources/assets/tiedup/player_animation/wrap_arms_sneak_idle.json new file mode 100644 index 0000000..1cd758e --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/wrap_arms_sneak_idle.json @@ -0,0 +1,33 @@ +{ + "version": 1, + "uuid": "c3d4e5f6-a7b8-4901-c3d4-dddddddddddd", + "name": "wrap_arms_sneak_idle", + "_comment": "Idle animation for wrap - ARMS ONLY - SNEAKING - arms follow torso", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "LINEAR", + "rightArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/wrap_arms_sneak_struggle.json b/src/main/resources/assets/tiedup/player_animation/wrap_arms_sneak_struggle.json new file mode 100644 index 0000000..36943f8 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/wrap_arms_sneak_struggle.json @@ -0,0 +1,112 @@ +{ + "version": 1, + "uuid": "c3d4e5f6-a7b8-4901-c3d4-666666666666", + "name": "wrap_arms_sneak_struggle", + "_comment": "Struggle animation for wrap - ARMS ONLY - SNEAKING - torso pivot", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 0 + }, + "rightArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + } + }, + { + "tick": 20, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 15 + }, + "rightArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + } + }, + { + "tick": 40, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": -15 + }, + "rightArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 10 + }, + "rightArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + } + }, + { + "tick": 80, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 0 + }, + "rightArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/wrap_arms_struggle.json b/src/main/resources/assets/tiedup/player_animation/wrap_arms_struggle.json new file mode 100644 index 0000000..54abefe --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/wrap_arms_struggle.json @@ -0,0 +1,102 @@ +{ + "version": 1, + "uuid": "c3d4e5f6-a7b8-4901-c3d4-444444444444", + "name": "wrap_arms_struggle", + "_comment": "Struggle animation for wrap - ARMS ONLY - torso pivot", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 0 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 20, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 15 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 40, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": -15 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 10 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 80, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 0 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/wrap_idle.json b/src/main/resources/assets/tiedup/player_animation/wrap_idle.json new file mode 100644 index 0000000..2c4d9f7 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/wrap_idle.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "uuid": "c3d4e5f6-a7b8-4901-c3d4-aaaaaaaaaaaa", + "name": "wrap_idle", + "_comment": "Idle animation for wrap - arms locked along torso", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "LINEAR", + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/wrap_legs.json b/src/main/resources/assets/tiedup/player_animation/wrap_legs.json new file mode 100644 index 0000000..e8069c2 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/wrap_legs.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "uuid": "c3d4e5f6-a7b8-4901-c3d4-e5f6a7b89014", + "name": "wrap_legs", + "_comment": "Legs wrapped together - LEGS ONLY (arms free)", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "LINEAR", + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/wrap_sneak_idle.json b/src/main/resources/assets/tiedup/player_animation/wrap_sneak_idle.json new file mode 100644 index 0000000..6386b2b --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/wrap_sneak_idle.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "uuid": "c3d4e5f6-a7b8-4901-c3d4-cccccccccccc", + "name": "wrap_sneak_idle", + "_comment": "Idle animation for wrap - SNEAKING - arms follow torso", + "emote": { + "beginTick": 0, + "endTick": 1, + "stopTick": 1, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "LINEAR", + "rightArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/wrap_sneak_struggle.json b/src/main/resources/assets/tiedup/player_animation/wrap_sneak_struggle.json new file mode 100644 index 0000000..c2105b6 --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/wrap_sneak_struggle.json @@ -0,0 +1,142 @@ +{ + "version": 1, + "uuid": "c3d4e5f6-a7b8-4901-c3d4-555555555555", + "name": "wrap_sneak_struggle", + "_comment": "Struggle animation for wrap - SNEAKING - torso pivot", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 0 + }, + "rightArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 20, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 15 + }, + "rightArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 40, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": -15 + }, + "rightArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 10 + }, + "rightArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + }, + { + "tick": 80, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 0 + }, + "rightArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + }, + "leftArm": { + "pitch": 30, + "yaw": 0, + "roll": 0, + "y": 5 + }, + "rightLeg": { + "z": 3 + }, + "leftLeg": { + "z": 3 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/player_animation/wrap_struggle.json b/src/main/resources/assets/tiedup/player_animation/wrap_struggle.json new file mode 100644 index 0000000..11c710e --- /dev/null +++ b/src/main/resources/assets/tiedup/player_animation/wrap_struggle.json @@ -0,0 +1,152 @@ +{ + "version": 1, + "uuid": "c3d4e5f6-a7b8-4901-c3d4-333333333333", + "name": "wrap_struggle", + "_comment": "Struggle animation for wrap - torso pivot left/right", + "emote": { + "beginTick": 0, + "endTick": 80, + "stopTick": 80, + "isLoop": true, + "returnTick": 0, + "nsfw": false, + "degrees": true, + "moves": [ + { + "tick": 0, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 0 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 20, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 15 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 40, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": -15 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 60, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 10 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + }, + { + "tick": 80, + "easing": "EASEINOUTSINE", + "torso": { + "yaw": 0 + }, + "rightArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftArm": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "rightLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + }, + "leftLeg": { + "pitch": 0, + "yaw": 0, + "roll": 0 + } + } + ] + } +} diff --git a/src/main/resources/assets/tiedup/sounds.json b/src/main/resources/assets/tiedup/sounds.json new file mode 100644 index 0000000..9e61fa0 --- /dev/null +++ b/src/main/resources/assets/tiedup/sounds.json @@ -0,0 +1,39 @@ +{ + "electric_shock": { + "category": "player", + "subtitle": "subtitles.tiedup.electric_shock", + "sounds": [ "tiedup:electric_shock" ] + }, + "shocker_activated": { + "category": "player", + "subtitle": "subtitles.tiedup.shocker_activated", + "sounds": [ "tiedup:shocker_activated" ] + }, + "collar_put": { + "category": "player", + "subtitle": "subtitles.tiedup.collar_put", + "sounds": [ "tiedup:collar_put" ] + }, + "collar_key_open": { + "category": "player", + "subtitle": "subtitles.tiedup.collar_key_open", + "sounds": [ "tiedup:collar_key_open" ] + }, + "collar_key_close": { + "category": "player", + "subtitle": "subtitles.tiedup.collar_key_close", + "sounds": [ "tiedup:collar_key_close" ] + }, + "chain": { + "category": "player", + "sounds": [ "tiedup:chain" ] + }, + "slap": { + "category": "player", + "sounds": [ "tiedup:slap" ] + }, + "whip": { + "category": "player", + "sounds": [ "tiedup:whip" ] + } +} diff --git a/src/main/resources/assets/tiedup/sounds/chain.ogg b/src/main/resources/assets/tiedup/sounds/chain.ogg new file mode 100644 index 0000000..bf5de2a Binary files /dev/null and b/src/main/resources/assets/tiedup/sounds/chain.ogg differ diff --git a/src/main/resources/assets/tiedup/sounds/collar_key_close.ogg b/src/main/resources/assets/tiedup/sounds/collar_key_close.ogg new file mode 100644 index 0000000..bfbda49 Binary files /dev/null and b/src/main/resources/assets/tiedup/sounds/collar_key_close.ogg differ diff --git a/src/main/resources/assets/tiedup/sounds/collar_key_open.ogg b/src/main/resources/assets/tiedup/sounds/collar_key_open.ogg new file mode 100644 index 0000000..fcdc42d Binary files /dev/null and b/src/main/resources/assets/tiedup/sounds/collar_key_open.ogg differ diff --git a/src/main/resources/assets/tiedup/sounds/collar_put.ogg b/src/main/resources/assets/tiedup/sounds/collar_put.ogg new file mode 100644 index 0000000..297c311 Binary files /dev/null and b/src/main/resources/assets/tiedup/sounds/collar_put.ogg differ diff --git a/src/main/resources/assets/tiedup/sounds/electric_shock.ogg b/src/main/resources/assets/tiedup/sounds/electric_shock.ogg new file mode 100644 index 0000000..e02e0af Binary files /dev/null and b/src/main/resources/assets/tiedup/sounds/electric_shock.ogg differ diff --git a/src/main/resources/assets/tiedup/sounds/shocker_activated.ogg b/src/main/resources/assets/tiedup/sounds/shocker_activated.ogg new file mode 100644 index 0000000..b31c4c7 Binary files /dev/null and b/src/main/resources/assets/tiedup/sounds/shocker_activated.ogg differ diff --git a/src/main/resources/assets/tiedup/sounds/slap.ogg b/src/main/resources/assets/tiedup/sounds/slap.ogg new file mode 100644 index 0000000..18cc68a Binary files /dev/null and b/src/main/resources/assets/tiedup/sounds/slap.ogg differ diff --git a/src/main/resources/assets/tiedup/sounds/slime.ogg b/src/main/resources/assets/tiedup/sounds/slime.ogg new file mode 100644 index 0000000..5abebeb Binary files /dev/null and b/src/main/resources/assets/tiedup/sounds/slime.ogg differ diff --git a/src/main/resources/assets/tiedup/sounds/sticky.ogg b/src/main/resources/assets/tiedup/sounds/sticky.ogg new file mode 100644 index 0000000..285801a Binary files /dev/null and b/src/main/resources/assets/tiedup/sounds/sticky.ogg differ diff --git a/src/main/resources/assets/tiedup/sounds/vine.ogg b/src/main/resources/assets/tiedup/sounds/vine.ogg new file mode 100644 index 0000000..8697177 Binary files /dev/null and b/src/main/resources/assets/tiedup/sounds/vine.ogg differ diff --git a/src/main/resources/assets/tiedup/sounds/whip.ogg b/src/main/resources/assets/tiedup/sounds/whip.ogg new file mode 100644 index 0000000..067e55c Binary files /dev/null and b/src/main/resources/assets/tiedup/sounds/whip.ogg differ diff --git a/src/main/resources/assets/tiedup/textures/block/cell_core.png b/src/main/resources/assets/tiedup/textures/block/cell_core.png new file mode 100644 index 0000000..3033061 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/block/cell_core.png differ diff --git a/src/main/resources/assets/tiedup/textures/block/cell_door_bottom.png b/src/main/resources/assets/tiedup/textures/block/cell_door_bottom.png new file mode 100644 index 0000000..ea767fc Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/block/cell_door_bottom.png differ diff --git a/src/main/resources/assets/tiedup/textures/block/cell_door_top.png b/src/main/resources/assets/tiedup/textures/block/cell_door_top.png new file mode 100644 index 0000000..db9921b Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/block/cell_door_top.png differ diff --git a/src/main/resources/assets/tiedup/textures/block/ironbars_door_bottom.png b/src/main/resources/assets/tiedup/textures/block/ironbars_door_bottom.png new file mode 100644 index 0000000..374f05c Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/block/ironbars_door_bottom.png differ diff --git a/src/main/resources/assets/tiedup/textures/block/ironbars_door_top.png b/src/main/resources/assets/tiedup/textures/block/ironbars_door_top.png new file mode 100644 index 0000000..db6394d Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/block/ironbars_door_top.png differ diff --git a/src/main/resources/assets/tiedup/textures/block/ironbars_trapdoor.png b/src/main/resources/assets/tiedup/textures/block/ironbars_trapdoor.png new file mode 100644 index 0000000..a08c268 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/block/ironbars_trapdoor.png differ diff --git a/src/main/resources/assets/tiedup/textures/block/kidnap_bomb_bottom.png b/src/main/resources/assets/tiedup/textures/block/kidnap_bomb_bottom.png new file mode 100644 index 0000000..35ff890 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/block/kidnap_bomb_bottom.png differ diff --git a/src/main/resources/assets/tiedup/textures/block/kidnap_bomb_side.png b/src/main/resources/assets/tiedup/textures/block/kidnap_bomb_side.png new file mode 100644 index 0000000..35ff890 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/block/kidnap_bomb_side.png differ diff --git a/src/main/resources/assets/tiedup/textures/block/kidnap_bomb_top.png b/src/main/resources/assets/tiedup/textures/block/kidnap_bomb_top.png new file mode 100644 index 0000000..35ff890 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/block/kidnap_bomb_top.png differ diff --git a/src/main/resources/assets/tiedup/textures/block/padded_block.png b/src/main/resources/assets/tiedup/textures/block/padded_block.png new file mode 100644 index 0000000..5337aaf Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/block/padded_block.png differ diff --git a/src/main/resources/assets/tiedup/textures/block/pet_bed.png b/src/main/resources/assets/tiedup/textures/block/pet_bed.png new file mode 100644 index 0000000..ae6886c Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/block/pet_bed.png differ diff --git a/src/main/resources/assets/tiedup/textures/block/pet_bowl.png b/src/main/resources/assets/tiedup/textures/block/pet_bowl.png new file mode 100644 index 0000000..59e6e54 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/block/pet_bowl.png differ diff --git a/src/main/resources/assets/tiedup/textures/block/pet_cage.png b/src/main/resources/assets/tiedup/textures/block/pet_cage.png new file mode 100644 index 0000000..e328f44 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/block/pet_cage.png differ diff --git a/src/main/resources/assets/tiedup/textures/block/rope_trap.png b/src/main/resources/assets/tiedup/textures/block/rope_trap.png new file mode 100644 index 0000000..7c33036 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/block/rope_trap.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/anastasia.png b/src/main/resources/assets/tiedup/textures/entity/damsel/anastasia.png new file mode 100644 index 0000000..55a06b5 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/anastasia.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/blizz.png b/src/main/resources/assets/tiedup/textures/entity/damsel/blizz.png new file mode 100644 index 0000000..3819f47 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/blizz.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/ceras.png b/src/main/resources/assets/tiedup/textures/entity/damsel/ceras.png new file mode 100644 index 0000000..acafcde Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/ceras.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/creamy.png b/src/main/resources/assets/tiedup/textures/entity/damsel/creamy.png new file mode 100644 index 0000000..4b51360 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/creamy.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_1.png b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_1.png new file mode 100644 index 0000000..8533882 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_1.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_10.png b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_10.png new file mode 100644 index 0000000..20dd73c Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_10.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_11.png b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_11.png new file mode 100644 index 0000000..8efb8fd Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_11.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_12.png b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_12.png new file mode 100644 index 0000000..f97e8e3 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_12.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_13.png b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_13.png new file mode 100644 index 0000000..fdb8707 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_13.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_14.png b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_14.png new file mode 100644 index 0000000..a54014b Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_14.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_15.png b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_15.png new file mode 100644 index 0000000..caaeb91 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_15.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_16.png b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_16.png new file mode 100644 index 0000000..a3eef22 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_16.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_17.png b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_17.png new file mode 100644 index 0000000..7c4eb42 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_17.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_18.png b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_18.png new file mode 100644 index 0000000..a1cf390 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_18.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_19.png b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_19.png new file mode 100644 index 0000000..5259a57 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_19.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_2.png b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_2.png new file mode 100644 index 0000000..8bfc1e0 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_2.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_3.png b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_3.png new file mode 100644 index 0000000..2347098 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_3.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_4.png b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_4.png new file mode 100644 index 0000000..99cbfbb Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_4.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_5.png b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_5.png new file mode 100644 index 0000000..e24991d Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_5.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_6.png b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_6.png new file mode 100644 index 0000000..c8cab1c Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_6.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_7.png b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_7.png new file mode 100644 index 0000000..9bcc310 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_7.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_8.png b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_8.png new file mode 100644 index 0000000..f93a256 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_8.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_9.png b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_9.png new file mode 100644 index 0000000..c22e5e8 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/dam_mob_9.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/el.png b/src/main/resources/assets/tiedup/textures/entity/damsel/el.png new file mode 100644 index 0000000..94226c1 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/el.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/fuya_kitty.png b/src/main/resources/assets/tiedup/textures/entity/damsel/fuya_kitty.png new file mode 100644 index 0000000..410b2a4 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/fuya_kitty.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/glacie.png b/src/main/resources/assets/tiedup/textures/entity/damsel/glacie.png new file mode 100644 index 0000000..778d0a3 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/glacie.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/hazey.png b/src/main/resources/assets/tiedup/textures/entity/damsel/hazey.png new file mode 100644 index 0000000..7d1f0ec Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/hazey.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/junichi.png b/src/main/resources/assets/tiedup/textures/entity/damsel/junichi.png new file mode 100644 index 0000000..ec7d9d6 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/junichi.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/kitty.png b/src/main/resources/assets/tiedup/textures/entity/damsel/kitty.png new file mode 100644 index 0000000..e99a746 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/kitty.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/kyky.png b/src/main/resources/assets/tiedup/textures/entity/damsel/kyky.png new file mode 100644 index 0000000..2282736 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/kyky.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/laureen.png b/src/main/resources/assets/tiedup/textures/entity/damsel/laureen.png new file mode 100644 index 0000000..55961c8 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/laureen.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/mani.png b/src/main/resources/assets/tiedup/textures/entity/damsel/mani.png new file mode 100644 index 0000000..15801b1 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/mani.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/nobu.png b/src/main/resources/assets/tiedup/textures/entity/damsel/nobu.png new file mode 100644 index 0000000..221b3c2 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/nobu.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/pika.png b/src/main/resources/assets/tiedup/textures/entity/damsel/pika.png new file mode 100644 index 0000000..0efe68e Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/pika.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/raph.png b/src/main/resources/assets/tiedup/textures/entity/damsel/raph.png new file mode 100644 index 0000000..d630987 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/raph.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/risette.png b/src/main/resources/assets/tiedup/textures/entity/damsel/risette.png new file mode 100644 index 0000000..dded7b9 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/risette.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/roxy.png b/src/main/resources/assets/tiedup/textures/entity/damsel/roxy.png new file mode 100644 index 0000000..3720739 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/roxy.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/sayari.png b/src/main/resources/assets/tiedup/textures/entity/damsel/sayari.png new file mode 100644 index 0000000..8ca9849 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/sayari.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/shiny/cherry.png b/src/main/resources/assets/tiedup/textures/entity/damsel/shiny/cherry.png new file mode 100644 index 0000000..6209be3 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/shiny/cherry.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/shiny/ellen.png b/src/main/resources/assets/tiedup/textures/entity/damsel/shiny/ellen.png new file mode 100644 index 0000000..a1a9ece Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/shiny/ellen.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/shiny/kitsu.png b/src/main/resources/assets/tiedup/textures/entity/damsel/shiny/kitsu.png new file mode 100644 index 0000000..0e104b8 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/shiny/kitsu.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/shiny/neko.png b/src/main/resources/assets/tiedup/textures/entity/damsel/shiny/neko.png new file mode 100644 index 0000000..497e453 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/shiny/neko.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/shiny/red.png b/src/main/resources/assets/tiedup/textures/entity/damsel/shiny/red.png new file mode 100644 index 0000000..b67dae8 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/shiny/red.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/shiny/stella.png b/src/main/resources/assets/tiedup/textures/entity/damsel/shiny/stella.png new file mode 100644 index 0000000..e7474cb Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/shiny/stella.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/damsel/sui.png b/src/main/resources/assets/tiedup/textures/entity/damsel/sui.png new file mode 100644 index 0000000..3a4bfdb Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/damsel/sui.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/guard/feifei.png b/src/main/resources/assets/tiedup/textures/entity/guard/feifei.png new file mode 100644 index 0000000..cd5edc3 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/guard/feifei.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/guard/nana.png b/src/main/resources/assets/tiedup/textures/entity/guard/nana.png new file mode 100644 index 0000000..632cff7 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/guard/nana.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/guard/xinxin.png b/src/main/resources/assets/tiedup/textures/entity/guard/xinxin.png new file mode 100644 index 0000000..cb05ef3 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/guard/xinxin.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/archer/bowy.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/archer/bowy.png new file mode 100644 index 0000000..3fdf78f Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/archer/bowy.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/archer/shooty.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/archer/shooty.png new file mode 100644 index 0000000..b7caaa4 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/archer/shooty.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/blake.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/blake.png new file mode 100644 index 0000000..a7b563c Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/blake.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/darkie.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/darkie.png new file mode 100644 index 0000000..39380f0 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/darkie.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/dean.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/dean.png new file mode 100644 index 0000000..6750d2b Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/dean.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/eleanor.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/eleanor.png new file mode 100644 index 0000000..28f2d66 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/eleanor.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/elite/athena.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/elite/athena.png new file mode 100644 index 0000000..f2d3c70 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/elite/athena.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/elite/carol.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/elite/carol.png new file mode 100644 index 0000000..b80eabd Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/elite/carol.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/elite/evelyn.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/elite/evelyn.png new file mode 100644 index 0000000..6336445 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/elite/evelyn.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/elite/suki.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/elite/suki.png new file mode 100644 index 0000000..d2f0504 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/elite/suki.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/esther.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/esther.png new file mode 100644 index 0000000..5259a57 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/esther.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/fleur_dianthus.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/fleur_dianthus.png new file mode 100644 index 0000000..678f4cb Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/fleur_dianthus.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/fuya.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/fuya.png new file mode 100644 index 0000000..d01224c Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/fuya.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/jack.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/jack.png new file mode 100644 index 0000000..fc9ee77 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/jack.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/jass.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/jass.png new file mode 100644 index 0000000..afbc58a Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/jass.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/ketulu.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/ketulu.png new file mode 100644 index 0000000..a2de91b Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/ketulu.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/kitty.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/kitty.png new file mode 100644 index 0000000..e99a746 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/kitty.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_1.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_1.png new file mode 100644 index 0000000..8533882 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_1.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_10.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_10.png new file mode 100644 index 0000000..20dd73c Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_10.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_11.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_11.png new file mode 100644 index 0000000..8efb8fd Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_11.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_12.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_12.png new file mode 100644 index 0000000..f97e8e3 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_12.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_13.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_13.png new file mode 100644 index 0000000..fdb8707 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_13.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_14.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_14.png new file mode 100644 index 0000000..a54014b Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_14.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_15.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_15.png new file mode 100644 index 0000000..caaeb91 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_15.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_16.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_16.png new file mode 100644 index 0000000..a3eef22 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_16.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_17.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_17.png new file mode 100644 index 0000000..7c4eb42 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_17.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_18.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_18.png new file mode 100644 index 0000000..a1cf390 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_18.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_2.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_2.png new file mode 100644 index 0000000..8bfc1e0 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_2.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_3.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_3.png new file mode 100644 index 0000000..2347098 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_3.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_4.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_4.png new file mode 100644 index 0000000..99cbfbb Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_4.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_5.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_5.png new file mode 100644 index 0000000..e24991d Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_5.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_6.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_6.png new file mode 100644 index 0000000..c8cab1c Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_6.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_7.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_7.png new file mode 100644 index 0000000..9bcc310 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_7.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_8.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_8.png new file mode 100644 index 0000000..f93a256 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_8.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_9.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_9.png new file mode 100644 index 0000000..c22e5e8 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/knp_mob_9.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/kyra.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/kyra.png new file mode 100644 index 0000000..7daebf0 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/kyra.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/lucina.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/lucina.png new file mode 100644 index 0000000..06adc1b Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/lucina.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/maid/mimi.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/maid/mimi.png new file mode 100644 index 0000000..0013305 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/maid/mimi.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/maid/mola.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/maid/mola.png new file mode 100644 index 0000000..e57bafc Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/maid/mola.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/maid/pola.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/maid/pola.png new file mode 100644 index 0000000..23e92ad Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/maid/pola.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/merchant/goldy.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/merchant/goldy.png new file mode 100644 index 0000000..0c3255f Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/merchant/goldy.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/misty.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/misty.png new file mode 100644 index 0000000..1eb8c47 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/misty.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/nataleigh.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/nataleigh.png new file mode 100644 index 0000000..d713947 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/nataleigh.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/nico.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/nico.png new file mode 100644 index 0000000..24ef4cf Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/nico.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/ruby.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/ruby.png new file mode 100644 index 0000000..9417b1d Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/ruby.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/ryuko.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/ryuko.png new file mode 100644 index 0000000..47c3dd2 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/ryuko.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/teemo.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/teemo.png new file mode 100644 index 0000000..e1a0427 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/teemo.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/trader/alheli.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/trader/alheli.png new file mode 100644 index 0000000..5aa440e Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/trader/alheli.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/trader/shrim.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/trader/shrim.png new file mode 100644 index 0000000..68aa19a Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/trader/shrim.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/trader/trady.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/trader/trady.png new file mode 100644 index 0000000..e00755d Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/trader/trady.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/welphia.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/welphia.png new file mode 100644 index 0000000..7b789b2 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/welphia.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/wynter.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/wynter.png new file mode 100644 index 0000000..f948450 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/wynter.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/yuti.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/yuti.png new file mode 100644 index 0000000..f561bab Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/yuti.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/kidnapper/zephyr.png b/src/main/resources/assets/tiedup/textures/entity/kidnapper/zephyr.png new file mode 100644 index 0000000..b83817a Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/kidnapper/zephyr.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/master/amy.png b/src/main/resources/assets/tiedup/textures/entity/master/amy.png new file mode 100644 index 0000000..4b5903b Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/master/amy.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/master/elisa.png b/src/main/resources/assets/tiedup/textures/entity/master/elisa.png new file mode 100644 index 0000000..3bd2b23 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/master/elisa.png differ diff --git a/src/main/resources/assets/tiedup/textures/entity/master/hana.png b/src/main/resources/assets/tiedup/textures/entity/master/hana.png new file mode 100644 index 0000000..1a81b47 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/entity/master/hana.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/armbinder.png b/src/main/resources/assets/tiedup/textures/item/armbinder.png new file mode 100644 index 0000000..2a88740 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/armbinder.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/baguette_gag.png b/src/main/resources/assets/tiedup/textures/item/baguette_gag.png new file mode 100644 index 0000000..f3ecbcf Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/baguette_gag.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag.png b/src/main/resources/assets/tiedup/textures/item/ball_gag.png new file mode 100644 index 0000000..e978358 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_black.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_black.png new file mode 100644 index 0000000..f3d08ed Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_black.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_blue.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_blue.png new file mode 100644 index 0000000..6d31ec0 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_brown.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_brown.png new file mode 100644 index 0000000..5a1fc29 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_brown.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_cyan.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_cyan.png new file mode 100644 index 0000000..f5248b8 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_cyan.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_gray.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_gray.png new file mode 100644 index 0000000..a439267 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_gray.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_green.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_green.png new file mode 100644 index 0000000..289eb2b Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_green.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_light_blue.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_light_blue.png new file mode 100644 index 0000000..61108ee Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_light_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_lime.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_lime.png new file mode 100644 index 0000000..21ecc09 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_lime.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_magenta.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_magenta.png new file mode 100644 index 0000000..4f6fcba Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_magenta.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_orange.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_orange.png new file mode 100644 index 0000000..bd38b28 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_orange.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_pink.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_pink.png new file mode 100644 index 0000000..08d94a2 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_pink.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_purple.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_purple.png new file mode 100644 index 0000000..0e61632 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_purple.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_silver.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_silver.png new file mode 100644 index 0000000..c6c0ff7 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_silver.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_strap.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap.png new file mode 100644 index 0000000..e978358 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_black.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_black.png new file mode 100644 index 0000000..9fc8efb Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_black.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_blue.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_blue.png new file mode 100644 index 0000000..7f19189 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_brown.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_brown.png new file mode 100644 index 0000000..1cd7d7a Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_brown.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_cyan.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_cyan.png new file mode 100644 index 0000000..e077c54 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_cyan.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_gray.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_gray.png new file mode 100644 index 0000000..207811c Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_gray.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_green.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_green.png new file mode 100644 index 0000000..ece021f Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_green.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_light_blue.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_light_blue.png new file mode 100644 index 0000000..eb38935 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_light_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_lime.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_lime.png new file mode 100644 index 0000000..734abb5 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_lime.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_magenta.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_magenta.png new file mode 100644 index 0000000..de12b61 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_magenta.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_orange.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_orange.png new file mode 100644 index 0000000..46bec08 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_orange.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_pink.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_pink.png new file mode 100644 index 0000000..72abec2 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_pink.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_purple.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_purple.png new file mode 100644 index 0000000..4125341 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_purple.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_silver.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_silver.png new file mode 100644 index 0000000..950d640 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_silver.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_white.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_white.png new file mode 100644 index 0000000..994a24c Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_white.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_yellow.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_yellow.png new file mode 100644 index 0000000..e53c019 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_strap_yellow.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_white.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_white.png new file mode 100644 index 0000000..953b320 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_white.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ball_gag_yellow.png b/src/main/resources/assets/tiedup/textures/item/ball_gag_yellow.png new file mode 100644 index 0000000..303797e Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ball_gag_yellow.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/beam_cuffs.png b/src/main/resources/assets/tiedup/textures/item/beam_cuffs.png new file mode 100644 index 0000000..0a15d49 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/beam_cuffs.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/beam_panel_gag.png b/src/main/resources/assets/tiedup/textures/item/beam_panel_gag.png new file mode 100644 index 0000000..57c8c4f Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/beam_panel_gag.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/bite_gag.png b/src/main/resources/assets/tiedup/textures/item/bite_gag.png new file mode 100644 index 0000000..0d382fb Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/bite_gag.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/blindfold_mask.png b/src/main/resources/assets/tiedup/textures/item/blindfold_mask.png new file mode 100644 index 0000000..567b992 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/blindfold_mask.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/blindfold_mask_blue.png b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_blue.png new file mode 100644 index 0000000..dc0469f Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/blindfold_mask_brown.png b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_brown.png new file mode 100644 index 0000000..3a12d76 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_brown.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/blindfold_mask_cyan.png b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_cyan.png new file mode 100644 index 0000000..9a079a2 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_cyan.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/blindfold_mask_gray.png b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_gray.png new file mode 100644 index 0000000..aa72bad Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_gray.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/blindfold_mask_green.png b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_green.png new file mode 100644 index 0000000..8d8c900 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_green.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/blindfold_mask_light_blue.png b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_light_blue.png new file mode 100644 index 0000000..548e7ae Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_light_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/blindfold_mask_lime.png b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_lime.png new file mode 100644 index 0000000..bacd68b Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_lime.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/blindfold_mask_magenta.png b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_magenta.png new file mode 100644 index 0000000..818044c Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_magenta.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/blindfold_mask_orange.png b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_orange.png new file mode 100644 index 0000000..4f0f753 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_orange.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/blindfold_mask_pink.png b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_pink.png new file mode 100644 index 0000000..2f327c9 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_pink.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/blindfold_mask_purple.png b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_purple.png new file mode 100644 index 0000000..e38a038 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_purple.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/blindfold_mask_red.png b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_red.png new file mode 100644 index 0000000..5a1cb68 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_red.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/blindfold_mask_silver.png b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_silver.png new file mode 100644 index 0000000..80fc9d1 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_silver.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/blindfold_mask_white.png b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_white.png new file mode 100644 index 0000000..b098329 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_white.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/blindfold_mask_yellow.png b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_yellow.png new file mode 100644 index 0000000..9183313 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/blindfold_mask_yellow.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cell_door.png b/src/main/resources/assets/tiedup/textures/item/cell_door.png new file mode 100644 index 0000000..410c4c2 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cell_door.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cell_key.png b/src/main/resources/assets/tiedup/textures/item/cell_key.png new file mode 100644 index 0000000..d12a220 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cell_key.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cell_wand.png b/src/main/resources/assets/tiedup/textures/item/cell_wand.png new file mode 100644 index 0000000..fa7dcac Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cell_wand.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/chain.png b/src/main/resources/assets/tiedup/textures/item/chain.png new file mode 100644 index 0000000..02405b5 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/chain.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/chain_panel_gag.png b/src/main/resources/assets/tiedup/textures/item/chain_panel_gag.png new file mode 100644 index 0000000..69c47f5 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/chain_panel_gag.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/chloroform_bottle.png b/src/main/resources/assets/tiedup/textures/item/chloroform_bottle.png new file mode 100644 index 0000000..8d31414 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/chloroform_bottle.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/choke_collar.png b/src/main/resources/assets/tiedup/textures/item/choke_collar.png new file mode 100644 index 0000000..03c45e7 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/choke_collar.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/classic_blindfold.png b/src/main/resources/assets/tiedup/textures/item/classic_blindfold.png new file mode 100644 index 0000000..5e3d6e0 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/classic_blindfold.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/classic_blindfold_blue.png b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_blue.png new file mode 100644 index 0000000..aa73af9 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/classic_blindfold_brown.png b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_brown.png new file mode 100644 index 0000000..0890dba Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_brown.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/classic_blindfold_cyan.png b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_cyan.png new file mode 100644 index 0000000..972827c Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_cyan.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/classic_blindfold_gray.png b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_gray.png new file mode 100644 index 0000000..25a7343 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_gray.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/classic_blindfold_green.png b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_green.png new file mode 100644 index 0000000..db71f74 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_green.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/classic_blindfold_light_blue.png b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_light_blue.png new file mode 100644 index 0000000..5d3b091 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_light_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/classic_blindfold_lime.png b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_lime.png new file mode 100644 index 0000000..a4a3766 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_lime.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/classic_blindfold_magenta.png b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_magenta.png new file mode 100644 index 0000000..e4d09ae Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_magenta.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/classic_blindfold_orange.png b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_orange.png new file mode 100644 index 0000000..8ddc06d Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_orange.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/classic_blindfold_pink.png b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_pink.png new file mode 100644 index 0000000..914482c Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_pink.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/classic_blindfold_purple.png b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_purple.png new file mode 100644 index 0000000..039a091 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_purple.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/classic_blindfold_red.png b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_red.png new file mode 100644 index 0000000..1e8b375 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_red.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/classic_blindfold_silver.png b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_silver.png new file mode 100644 index 0000000..e52c5a5 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_silver.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/classic_blindfold_white.png b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_white.png new file mode 100644 index 0000000..8567f07 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_white.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/classic_blindfold_yellow.png b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_yellow.png new file mode 100644 index 0000000..cc9bfdc Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/classic_blindfold_yellow.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/classic_collar.png b/src/main/resources/assets/tiedup/textures/item/classic_collar.png new file mode 100644 index 0000000..03c45e7 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/classic_collar.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/classic_earplugs.png b/src/main/resources/assets/tiedup/textures/item/classic_earplugs.png new file mode 100644 index 0000000..fc1c5d9 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/classic_earplugs.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cleave_gag.png b/src/main/resources/assets/tiedup/textures/item/cleave_gag.png new file mode 100644 index 0000000..189d91d Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cleave_gag.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cleave_gag_black.png b/src/main/resources/assets/tiedup/textures/item/cleave_gag_black.png new file mode 100644 index 0000000..1ad98e5 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cleave_gag_black.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cleave_gag_blue.png b/src/main/resources/assets/tiedup/textures/item/cleave_gag_blue.png new file mode 100644 index 0000000..1b55712 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cleave_gag_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cleave_gag_brown.png b/src/main/resources/assets/tiedup/textures/item/cleave_gag_brown.png new file mode 100644 index 0000000..2aada05 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cleave_gag_brown.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cleave_gag_cyan.png b/src/main/resources/assets/tiedup/textures/item/cleave_gag_cyan.png new file mode 100644 index 0000000..991812f Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cleave_gag_cyan.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cleave_gag_gray.png b/src/main/resources/assets/tiedup/textures/item/cleave_gag_gray.png new file mode 100644 index 0000000..54f66fb Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cleave_gag_gray.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cleave_gag_green.png b/src/main/resources/assets/tiedup/textures/item/cleave_gag_green.png new file mode 100644 index 0000000..a3abe31 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cleave_gag_green.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cleave_gag_light_blue.png b/src/main/resources/assets/tiedup/textures/item/cleave_gag_light_blue.png new file mode 100644 index 0000000..be31ca1 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cleave_gag_light_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cleave_gag_lime.png b/src/main/resources/assets/tiedup/textures/item/cleave_gag_lime.png new file mode 100644 index 0000000..9daf0fd Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cleave_gag_lime.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cleave_gag_magenta.png b/src/main/resources/assets/tiedup/textures/item/cleave_gag_magenta.png new file mode 100644 index 0000000..508364b Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cleave_gag_magenta.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cleave_gag_orange.png b/src/main/resources/assets/tiedup/textures/item/cleave_gag_orange.png new file mode 100644 index 0000000..24a8ebd Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cleave_gag_orange.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cleave_gag_pink.png b/src/main/resources/assets/tiedup/textures/item/cleave_gag_pink.png new file mode 100644 index 0000000..aa89206 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cleave_gag_pink.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cleave_gag_purple.png b/src/main/resources/assets/tiedup/textures/item/cleave_gag_purple.png new file mode 100644 index 0000000..83ff6fe Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cleave_gag_purple.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cleave_gag_red.png b/src/main/resources/assets/tiedup/textures/item/cleave_gag_red.png new file mode 100644 index 0000000..b36dd1f Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cleave_gag_red.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cleave_gag_silver.png b/src/main/resources/assets/tiedup/textures/item/cleave_gag_silver.png new file mode 100644 index 0000000..cccf2c3 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cleave_gag_silver.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cleave_gag_yellow.png b/src/main/resources/assets/tiedup/textures/item/cleave_gag_yellow.png new file mode 100644 index 0000000..b336dcc Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cleave_gag_yellow.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cloth_gag.png b/src/main/resources/assets/tiedup/textures/item/cloth_gag.png new file mode 100644 index 0000000..bfa5c3d Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cloth_gag.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cloth_gag_black.png b/src/main/resources/assets/tiedup/textures/item/cloth_gag_black.png new file mode 100644 index 0000000..74f97bb Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cloth_gag_black.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cloth_gag_blue.png b/src/main/resources/assets/tiedup/textures/item/cloth_gag_blue.png new file mode 100644 index 0000000..f664861 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cloth_gag_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cloth_gag_brown.png b/src/main/resources/assets/tiedup/textures/item/cloth_gag_brown.png new file mode 100644 index 0000000..c24284e Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cloth_gag_brown.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cloth_gag_cyan.png b/src/main/resources/assets/tiedup/textures/item/cloth_gag_cyan.png new file mode 100644 index 0000000..bfb29ff Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cloth_gag_cyan.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cloth_gag_gray.png b/src/main/resources/assets/tiedup/textures/item/cloth_gag_gray.png new file mode 100644 index 0000000..49d8e8e Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cloth_gag_gray.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cloth_gag_green.png b/src/main/resources/assets/tiedup/textures/item/cloth_gag_green.png new file mode 100644 index 0000000..b654f17 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cloth_gag_green.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cloth_gag_light_blue.png b/src/main/resources/assets/tiedup/textures/item/cloth_gag_light_blue.png new file mode 100644 index 0000000..bf2736f Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cloth_gag_light_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cloth_gag_lime.png b/src/main/resources/assets/tiedup/textures/item/cloth_gag_lime.png new file mode 100644 index 0000000..ff26db1 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cloth_gag_lime.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cloth_gag_magenta.png b/src/main/resources/assets/tiedup/textures/item/cloth_gag_magenta.png new file mode 100644 index 0000000..b957ab4 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cloth_gag_magenta.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cloth_gag_orange.png b/src/main/resources/assets/tiedup/textures/item/cloth_gag_orange.png new file mode 100644 index 0000000..0147657 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cloth_gag_orange.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cloth_gag_pink.png b/src/main/resources/assets/tiedup/textures/item/cloth_gag_pink.png new file mode 100644 index 0000000..19c0685 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cloth_gag_pink.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cloth_gag_purple.png b/src/main/resources/assets/tiedup/textures/item/cloth_gag_purple.png new file mode 100644 index 0000000..19ae71f Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cloth_gag_purple.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cloth_gag_red.png b/src/main/resources/assets/tiedup/textures/item/cloth_gag_red.png new file mode 100644 index 0000000..eb43663 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cloth_gag_red.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cloth_gag_silver.png b/src/main/resources/assets/tiedup/textures/item/cloth_gag_silver.png new file mode 100644 index 0000000..ef235ea Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cloth_gag_silver.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/cloth_gag_yellow.png b/src/main/resources/assets/tiedup/textures/item/cloth_gag_yellow.png new file mode 100644 index 0000000..e771b9a Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/cloth_gag_yellow.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/clothes.png b/src/main/resources/assets/tiedup/textures/item/clothes.png new file mode 100644 index 0000000..98cb9cd Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/clothes.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/collar_key.png b/src/main/resources/assets/tiedup/textures/item/collar_key.png new file mode 100644 index 0000000..5bca7fa Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/collar_key.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/dogbinder.png b/src/main/resources/assets/tiedup/textures/item/dogbinder.png new file mode 100644 index 0000000..5daa861 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/dogbinder.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/duct_tape.png b/src/main/resources/assets/tiedup/textures/item/duct_tape.png new file mode 100644 index 0000000..f29ba4f Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/duct_tape.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/duct_tape_black.png b/src/main/resources/assets/tiedup/textures/item/duct_tape_black.png new file mode 100644 index 0000000..01ea305 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/duct_tape_black.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/duct_tape_blue.png b/src/main/resources/assets/tiedup/textures/item/duct_tape_blue.png new file mode 100644 index 0000000..89be32c Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/duct_tape_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/duct_tape_brown.png b/src/main/resources/assets/tiedup/textures/item/duct_tape_brown.png new file mode 100644 index 0000000..4fafd2b Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/duct_tape_brown.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/duct_tape_caution.png b/src/main/resources/assets/tiedup/textures/item/duct_tape_caution.png new file mode 100644 index 0000000..a5ea129 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/duct_tape_caution.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/duct_tape_clear.png b/src/main/resources/assets/tiedup/textures/item/duct_tape_clear.png new file mode 100644 index 0000000..f29ba4f Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/duct_tape_clear.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/duct_tape_cyan.png b/src/main/resources/assets/tiedup/textures/item/duct_tape_cyan.png new file mode 100644 index 0000000..3454b55 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/duct_tape_cyan.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/duct_tape_green.png b/src/main/resources/assets/tiedup/textures/item/duct_tape_green.png new file mode 100644 index 0000000..74dad70 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/duct_tape_green.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/duct_tape_light_blue.png b/src/main/resources/assets/tiedup/textures/item/duct_tape_light_blue.png new file mode 100644 index 0000000..ae1feba Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/duct_tape_light_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/duct_tape_lime.png b/src/main/resources/assets/tiedup/textures/item/duct_tape_lime.png new file mode 100644 index 0000000..42027f5 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/duct_tape_lime.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/duct_tape_magenta.png b/src/main/resources/assets/tiedup/textures/item/duct_tape_magenta.png new file mode 100644 index 0000000..a0e62a9 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/duct_tape_magenta.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/duct_tape_orange.png b/src/main/resources/assets/tiedup/textures/item/duct_tape_orange.png new file mode 100644 index 0000000..7393c60 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/duct_tape_orange.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/duct_tape_pink.png b/src/main/resources/assets/tiedup/textures/item/duct_tape_pink.png new file mode 100644 index 0000000..ebcb8f7 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/duct_tape_pink.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/duct_tape_purple.png b/src/main/resources/assets/tiedup/textures/item/duct_tape_purple.png new file mode 100644 index 0000000..ae02cc8 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/duct_tape_purple.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/duct_tape_red.png b/src/main/resources/assets/tiedup/textures/item/duct_tape_red.png new file mode 100644 index 0000000..2f9f630 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/duct_tape_red.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/duct_tape_silver.png b/src/main/resources/assets/tiedup/textures/item/duct_tape_silver.png new file mode 100644 index 0000000..2976b25 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/duct_tape_silver.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/duct_tape_white.png b/src/main/resources/assets/tiedup/textures/item/duct_tape_white.png new file mode 100644 index 0000000..af26163 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/duct_tape_white.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/duct_tape_yellow.png b/src/main/resources/assets/tiedup/textures/item/duct_tape_yellow.png new file mode 100644 index 0000000..c58cb2b Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/duct_tape_yellow.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/golden_knife.png b/src/main/resources/assets/tiedup/textures/item/golden_knife.png new file mode 100644 index 0000000..c30c741 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/golden_knife.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/gps_collar.png b/src/main/resources/assets/tiedup/textures/item/gps_collar.png new file mode 100644 index 0000000..73ef38a Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/gps_collar.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/gps_locator.png b/src/main/resources/assets/tiedup/textures/item/gps_locator.png new file mode 100644 index 0000000..6263f6e Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/gps_locator.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/hood.png b/src/main/resources/assets/tiedup/textures/item/hood.png new file mode 100644 index 0000000..ffe1e30 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/hood.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/iron_bar_door.png b/src/main/resources/assets/tiedup/textures/item/iron_bar_door.png new file mode 100644 index 0000000..379b631 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/iron_bar_door.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/iron_knife.png b/src/main/resources/assets/tiedup/textures/item/iron_knife.png new file mode 100644 index 0000000..913f198 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/iron_knife.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/latex_gag.png b/src/main/resources/assets/tiedup/textures/item/latex_gag.png new file mode 100644 index 0000000..84eb7fc Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/latex_gag.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/latex_sack.png b/src/main/resources/assets/tiedup/textures/item/latex_sack.png new file mode 100644 index 0000000..461c031 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/latex_sack.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/leather_straps.png b/src/main/resources/assets/tiedup/textures/item/leather_straps.png new file mode 100644 index 0000000..eb85b36 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/leather_straps.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/lockpick.png b/src/main/resources/assets/tiedup/textures/item/lockpick.png new file mode 100644 index 0000000..3b6a658 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/lockpick.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/master_key.png b/src/main/resources/assets/tiedup/textures/item/master_key.png new file mode 100644 index 0000000..5bca7fa Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/master_key.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/medical_gag.png b/src/main/resources/assets/tiedup/textures/item/medical_gag.png new file mode 100644 index 0000000..b1b091c Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/medical_gag.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/medical_straps.png b/src/main/resources/assets/tiedup/textures/item/medical_straps.png new file mode 100644 index 0000000..56e3383 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/medical_straps.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/mittens.png b/src/main/resources/assets/tiedup/textures/item/mittens.png new file mode 100644 index 0000000..d39e896 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/mittens.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/paddle.png b/src/main/resources/assets/tiedup/textures/item/paddle.png new file mode 100644 index 0000000..89b9292 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/paddle.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/padlock.png b/src/main/resources/assets/tiedup/textures/item/padlock.png new file mode 100644 index 0000000..551c0b4 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/padlock.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/panel_gag.png b/src/main/resources/assets/tiedup/textures/item/panel_gag.png new file mode 100644 index 0000000..f6f5395 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/panel_gag.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/rag.png b/src/main/resources/assets/tiedup/textures/item/rag.png new file mode 100644 index 0000000..ac0a871 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/rag.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ribbon.png b/src/main/resources/assets/tiedup/textures/item/ribbon.png new file mode 100644 index 0000000..5468721 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ribbon.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ribbon_gag.png b/src/main/resources/assets/tiedup/textures/item/ribbon_gag.png new file mode 100644 index 0000000..9672470 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ribbon_gag.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/rope_arrow.png b/src/main/resources/assets/tiedup/textures/item/rope_arrow.png new file mode 100644 index 0000000..f3d46a4 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/rope_arrow.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes.png b/src/main/resources/assets/tiedup/textures/item/ropes.png new file mode 100644 index 0000000..bb8b28d Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_black.png b/src/main/resources/assets/tiedup/textures/item/ropes_black.png new file mode 100644 index 0000000..580ec18 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_black.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_blue.png b/src/main/resources/assets/tiedup/textures/item/ropes_blue.png new file mode 100644 index 0000000..bd90362 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_cyan.png b/src/main/resources/assets/tiedup/textures/item/ropes_cyan.png new file mode 100644 index 0000000..caeec57 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_cyan.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_gag.png b/src/main/resources/assets/tiedup/textures/item/ropes_gag.png new file mode 100644 index 0000000..0e1626b Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_gag.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_gag_black.png b/src/main/resources/assets/tiedup/textures/item/ropes_gag_black.png new file mode 100644 index 0000000..e1acd8d Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_gag_black.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_gag_blue.png b/src/main/resources/assets/tiedup/textures/item/ropes_gag_blue.png new file mode 100644 index 0000000..2e6f581 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_gag_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_gag_cyan.png b/src/main/resources/assets/tiedup/textures/item/ropes_gag_cyan.png new file mode 100644 index 0000000..cfbb07f Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_gag_cyan.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_gag_gray.png b/src/main/resources/assets/tiedup/textures/item/ropes_gag_gray.png new file mode 100644 index 0000000..0d84465 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_gag_gray.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_gag_green.png b/src/main/resources/assets/tiedup/textures/item/ropes_gag_green.png new file mode 100644 index 0000000..44da1bb Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_gag_green.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_gag_light_blue.png b/src/main/resources/assets/tiedup/textures/item/ropes_gag_light_blue.png new file mode 100644 index 0000000..f49cc2e Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_gag_light_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_gag_lime.png b/src/main/resources/assets/tiedup/textures/item/ropes_gag_lime.png new file mode 100644 index 0000000..577054f Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_gag_lime.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_gag_magenta.png b/src/main/resources/assets/tiedup/textures/item/ropes_gag_magenta.png new file mode 100644 index 0000000..91fe2b4 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_gag_magenta.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_gag_orange.png b/src/main/resources/assets/tiedup/textures/item/ropes_gag_orange.png new file mode 100644 index 0000000..1b2f882 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_gag_orange.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_gag_pink.png b/src/main/resources/assets/tiedup/textures/item/ropes_gag_pink.png new file mode 100644 index 0000000..bd7c34f Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_gag_pink.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_gag_purple.png b/src/main/resources/assets/tiedup/textures/item/ropes_gag_purple.png new file mode 100644 index 0000000..6e2ea69 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_gag_purple.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_gag_red.png b/src/main/resources/assets/tiedup/textures/item/ropes_gag_red.png new file mode 100644 index 0000000..e518097 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_gag_red.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_gag_silver.png b/src/main/resources/assets/tiedup/textures/item/ropes_gag_silver.png new file mode 100644 index 0000000..03ddd62 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_gag_silver.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_gag_white.png b/src/main/resources/assets/tiedup/textures/item/ropes_gag_white.png new file mode 100644 index 0000000..6e0d847 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_gag_white.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_gag_yellow.png b/src/main/resources/assets/tiedup/textures/item/ropes_gag_yellow.png new file mode 100644 index 0000000..5f69c5d Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_gag_yellow.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_gray.png b/src/main/resources/assets/tiedup/textures/item/ropes_gray.png new file mode 100644 index 0000000..d7158fd Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_gray.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_green.png b/src/main/resources/assets/tiedup/textures/item/ropes_green.png new file mode 100644 index 0000000..3e64211 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_green.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_light_blue.png b/src/main/resources/assets/tiedup/textures/item/ropes_light_blue.png new file mode 100644 index 0000000..18634f2 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_light_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_lime.png b/src/main/resources/assets/tiedup/textures/item/ropes_lime.png new file mode 100644 index 0000000..d44463c Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_lime.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_magenta.png b/src/main/resources/assets/tiedup/textures/item/ropes_magenta.png new file mode 100644 index 0000000..c6c90b9 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_magenta.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_orange.png b/src/main/resources/assets/tiedup/textures/item/ropes_orange.png new file mode 100644 index 0000000..55fbcec Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_orange.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_pink.png b/src/main/resources/assets/tiedup/textures/item/ropes_pink.png new file mode 100644 index 0000000..8d2baf0 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_pink.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_purple.png b/src/main/resources/assets/tiedup/textures/item/ropes_purple.png new file mode 100644 index 0000000..ac72fe1 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_purple.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_red.png b/src/main/resources/assets/tiedup/textures/item/ropes_red.png new file mode 100644 index 0000000..9996953 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_red.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_silver.png b/src/main/resources/assets/tiedup/textures/item/ropes_silver.png new file mode 100644 index 0000000..9ef9bd0 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_silver.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_white.png b/src/main/resources/assets/tiedup/textures/item/ropes_white.png new file mode 100644 index 0000000..6c06044 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_white.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/ropes_yellow.png b/src/main/resources/assets/tiedup/textures/item/ropes_yellow.png new file mode 100644 index 0000000..0eb2821 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/ropes_yellow.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/shibari.png b/src/main/resources/assets/tiedup/textures/item/shibari.png new file mode 100644 index 0000000..d823b0c Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/shibari.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/shibari_black.png b/src/main/resources/assets/tiedup/textures/item/shibari_black.png new file mode 100644 index 0000000..88ea0ea Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/shibari_black.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/shibari_blue.png b/src/main/resources/assets/tiedup/textures/item/shibari_blue.png new file mode 100644 index 0000000..77d4118 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/shibari_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/shibari_cyan.png b/src/main/resources/assets/tiedup/textures/item/shibari_cyan.png new file mode 100644 index 0000000..66e6dc3 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/shibari_cyan.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/shibari_gray.png b/src/main/resources/assets/tiedup/textures/item/shibari_gray.png new file mode 100644 index 0000000..d81f765 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/shibari_gray.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/shibari_green.png b/src/main/resources/assets/tiedup/textures/item/shibari_green.png new file mode 100644 index 0000000..0686665 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/shibari_green.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/shibari_light_blue.png b/src/main/resources/assets/tiedup/textures/item/shibari_light_blue.png new file mode 100644 index 0000000..3804989 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/shibari_light_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/shibari_lime.png b/src/main/resources/assets/tiedup/textures/item/shibari_lime.png new file mode 100644 index 0000000..43945fe Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/shibari_lime.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/shibari_magenta.png b/src/main/resources/assets/tiedup/textures/item/shibari_magenta.png new file mode 100644 index 0000000..844cb62 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/shibari_magenta.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/shibari_orange.png b/src/main/resources/assets/tiedup/textures/item/shibari_orange.png new file mode 100644 index 0000000..9412e1b Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/shibari_orange.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/shibari_pink.png b/src/main/resources/assets/tiedup/textures/item/shibari_pink.png new file mode 100644 index 0000000..fc4ff0a Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/shibari_pink.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/shibari_purple.png b/src/main/resources/assets/tiedup/textures/item/shibari_purple.png new file mode 100644 index 0000000..9b37d30 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/shibari_purple.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/shibari_red.png b/src/main/resources/assets/tiedup/textures/item/shibari_red.png new file mode 100644 index 0000000..d66a226 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/shibari_red.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/shibari_silver.png b/src/main/resources/assets/tiedup/textures/item/shibari_silver.png new file mode 100644 index 0000000..ff188a8 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/shibari_silver.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/shibari_white.png b/src/main/resources/assets/tiedup/textures/item/shibari_white.png new file mode 100644 index 0000000..ef2cf70 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/shibari_white.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/shibari_yellow.png b/src/main/resources/assets/tiedup/textures/item/shibari_yellow.png new file mode 100644 index 0000000..593fa53 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/shibari_yellow.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/shock_collar.png b/src/main/resources/assets/tiedup/textures/item/shock_collar.png new file mode 100644 index 0000000..ebe79b0 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/shock_collar.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/shocker_controller.png b/src/main/resources/assets/tiedup/textures/item/shocker_controller.png new file mode 100644 index 0000000..3f28aa6 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/shocker_controller.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/slime.png b/src/main/resources/assets/tiedup/textures/item/slime.png new file mode 100644 index 0000000..18d4318 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/slime.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/slime_gag.png b/src/main/resources/assets/tiedup/textures/item/slime_gag.png new file mode 100644 index 0000000..5d4eb0a Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/slime_gag.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/sponge_gag.png b/src/main/resources/assets/tiedup/textures/item/sponge_gag.png new file mode 100644 index 0000000..ace9624 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/sponge_gag.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/stone_knife.png b/src/main/resources/assets/tiedup/textures/item/stone_knife.png new file mode 100644 index 0000000..2096c07 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/stone_knife.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/straitjacket.png b/src/main/resources/assets/tiedup/textures/item/straitjacket.png new file mode 100644 index 0000000..6cca583 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/straitjacket.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tape_gag.png b/src/main/resources/assets/tiedup/textures/item/tape_gag.png new file mode 100644 index 0000000..86129b3 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tape_gag.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tape_gag_black.png b/src/main/resources/assets/tiedup/textures/item/tape_gag_black.png new file mode 100644 index 0000000..8f2077e Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tape_gag_black.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tape_gag_blue.png b/src/main/resources/assets/tiedup/textures/item/tape_gag_blue.png new file mode 100644 index 0000000..688d429 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tape_gag_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tape_gag_brown.png b/src/main/resources/assets/tiedup/textures/item/tape_gag_brown.png new file mode 100644 index 0000000..ffe807f Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tape_gag_brown.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tape_gag_caution.png b/src/main/resources/assets/tiedup/textures/item/tape_gag_caution.png new file mode 100644 index 0000000..e64023f Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tape_gag_caution.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tape_gag_clear.png b/src/main/resources/assets/tiedup/textures/item/tape_gag_clear.png new file mode 100644 index 0000000..0349063 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tape_gag_clear.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tape_gag_clear_stuff.png b/src/main/resources/assets/tiedup/textures/item/tape_gag_clear_stuff.png new file mode 100644 index 0000000..7896c7a Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tape_gag_clear_stuff.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tape_gag_cyan.png b/src/main/resources/assets/tiedup/textures/item/tape_gag_cyan.png new file mode 100644 index 0000000..25b2817 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tape_gag_cyan.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tape_gag_green.png b/src/main/resources/assets/tiedup/textures/item/tape_gag_green.png new file mode 100644 index 0000000..3500017 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tape_gag_green.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tape_gag_light_blue.png b/src/main/resources/assets/tiedup/textures/item/tape_gag_light_blue.png new file mode 100644 index 0000000..17672a5 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tape_gag_light_blue.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tape_gag_lime.png b/src/main/resources/assets/tiedup/textures/item/tape_gag_lime.png new file mode 100644 index 0000000..018cf12 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tape_gag_lime.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tape_gag_magenta.png b/src/main/resources/assets/tiedup/textures/item/tape_gag_magenta.png new file mode 100644 index 0000000..89c507a Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tape_gag_magenta.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tape_gag_orange.png b/src/main/resources/assets/tiedup/textures/item/tape_gag_orange.png new file mode 100644 index 0000000..52b53d3 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tape_gag_orange.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tape_gag_pink.png b/src/main/resources/assets/tiedup/textures/item/tape_gag_pink.png new file mode 100644 index 0000000..f8d4a78 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tape_gag_pink.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tape_gag_purple.png b/src/main/resources/assets/tiedup/textures/item/tape_gag_purple.png new file mode 100644 index 0000000..cb652b5 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tape_gag_purple.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tape_gag_red.png b/src/main/resources/assets/tiedup/textures/item/tape_gag_red.png new file mode 100644 index 0000000..95c5cbd Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tape_gag_red.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tape_gag_silver.png b/src/main/resources/assets/tiedup/textures/item/tape_gag_silver.png new file mode 100644 index 0000000..9cd6b17 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tape_gag_silver.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tape_gag_white.png b/src/main/resources/assets/tiedup/textures/item/tape_gag_white.png new file mode 100644 index 0000000..debd552 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tape_gag_white.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tape_gag_yellow.png b/src/main/resources/assets/tiedup/textures/item/tape_gag_yellow.png new file mode 100644 index 0000000..df89fe9 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tape_gag_yellow.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/taser.png b/src/main/resources/assets/tiedup/textures/item/taser.png new file mode 100644 index 0000000..c6dcd16 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/taser.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/token.png b/src/main/resources/assets/tiedup/textures/item/token.png new file mode 100644 index 0000000..5b4db40 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/token.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/trapped_chest.png b/src/main/resources/assets/tiedup/textures/item/trapped_chest.png new file mode 100644 index 0000000..1b9ef70 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/trapped_chest.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/tube_gag.png b/src/main/resources/assets/tiedup/textures/item/tube_gag.png new file mode 100644 index 0000000..d495651 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/tube_gag.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/vine_gag.png b/src/main/resources/assets/tiedup/textures/item/vine_gag.png new file mode 100644 index 0000000..4ca80c9 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/vine_gag.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/vine_seed.png b/src/main/resources/assets/tiedup/textures/item/vine_seed.png new file mode 100644 index 0000000..0d3616b Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/vine_seed.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/wand.png b/src/main/resources/assets/tiedup/textures/item/wand.png new file mode 100644 index 0000000..2b5eab4 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/wand.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/web_bind.png b/src/main/resources/assets/tiedup/textures/item/web_bind.png new file mode 100644 index 0000000..fea7992 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/web_bind.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/web_gag.png b/src/main/resources/assets/tiedup/textures/item/web_gag.png new file mode 100644 index 0000000..19bc45b Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/web_gag.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/whip.png b/src/main/resources/assets/tiedup/textures/item/whip.png new file mode 100644 index 0000000..055a325 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/whip.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/wrap.png b/src/main/resources/assets/tiedup/textures/item/wrap.png new file mode 100644 index 0000000..49cef28 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/wrap.png differ diff --git a/src/main/resources/assets/tiedup/textures/item/wrap_gag.png b/src/main/resources/assets/tiedup/textures/item/wrap_gag.png new file mode 100644 index 0000000..983f2d7 Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/item/wrap_gag.png differ diff --git a/src/main/resources/assets/tiedup/textures/misc/blindfolded.png b/src/main/resources/assets/tiedup/textures/misc/blindfolded.png new file mode 100644 index 0000000..c507cda Binary files /dev/null and b/src/main/resources/assets/tiedup/textures/misc/blindfolded.png differ diff --git a/src/main/resources/assets/tiedup/tiedup_items/test_handcuffs.json b/src/main/resources/assets/tiedup/tiedup_items/test_handcuffs.json new file mode 100644 index 0000000..97c188c --- /dev/null +++ b/src/main/resources/assets/tiedup/tiedup_items/test_handcuffs.json @@ -0,0 +1,14 @@ +{ + "type": "tiedup:bondage_item", + "display_name": "Data-Driven Handcuffs", + "model": "tiedup:models/gltf/v2/handcuffs/cuffs_prototype.glb", + "regions": ["ARMS"], + "pose_priority": 30, + "escape_difficulty": 100, + "lockable": true, + "icon": "tiedup:item/beam_cuffs", + "animation_bones": { + "idle": ["rightArm", "leftArm"], + "struggle": ["rightArm", "leftArm"] + } +} diff --git a/src/main/resources/assets/tiedup/tiedup_items/test_leg_cuffs.json b/src/main/resources/assets/tiedup/tiedup_items/test_leg_cuffs.json new file mode 100644 index 0000000..36605cb --- /dev/null +++ b/src/main/resources/assets/tiedup/tiedup_items/test_leg_cuffs.json @@ -0,0 +1,18 @@ +{ + "type": "tiedup:bondage_item", + "display_name": "Prototype Leg Cuffs", + "model": "tiedup:models/gltf/leg_cuffs_proto.glb", + "regions": ["LEGS"], + "pose_priority": 30, + "escape_difficulty": 5, + "lockable": true, + "movement_style": "shuffle", + "supports_color": true, + "tint_channels": { + "tintable_1": "#808080" + }, + "animation_bones": { + "idle": ["rightLeg", "leftLeg"], + "struggle": ["rightLeg", "leftLeg"] + } +} \ No newline at end of file diff --git a/src/main/resources/data/minecraft/tags/items/arrows.json b/src/main/resources/data/minecraft/tags/items/arrows.json new file mode 100644 index 0000000..8d012b0 --- /dev/null +++ b/src/main/resources/data/minecraft/tags/items/arrows.json @@ -0,0 +1,6 @@ +{ + "replace": false, + "values": [ + "tiedup:rope_arrow" + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/calm/actions.json b/src/main/resources/data/tiedup/dialogue/en_us/calm/actions.json new file mode 100644 index 0000000..71b74aa --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/calm/actions.json @@ -0,0 +1,213 @@ +{ + "category": "actions", + "entries": [ + { + "id": "action.whip", + "conditions": {}, + "variants": [ + { + "text": "*breathes through the pain*", + "weight": 10, + "is_action": true + }, + { + "text": "I understand why you feel the need to do this.", + "weight": 10 + }, + { + "text": "*remains composed*", + "weight": 8, + "is_action": true + }, + { + "text": "This too shall pass.", + "weight": 8 + } + ] + }, + { + "id": "action.paddle", + "conditions": {}, + "variants": [ + { + "text": "*accepts with equanimity*", + "weight": 10, + "is_action": true + }, + { + "text": "I see.", + "weight": 10 + }, + { + "text": "*does not resist*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.praise", + "conditions": {}, + "variants": [ + { + "text": "*nods peacefully*", + "weight": 10, + "is_action": true + }, + { + "text": "Thank you. That's kind of you to say.", + "weight": 10 + }, + { + "text": "*small, serene smile*", + "weight": 8, + "is_action": true + }, + { + "text": "I appreciate your words.", + "weight": 8 + } + ] + }, + { + "id": "action.feed", + "conditions": {}, + "variants": [ + { + "text": "Thank you for your thoughtfulness.", + "weight": 10 + }, + { + "text": "*accepts food calmly*", + "weight": 10, + "is_action": true + }, + { + "text": "This is appreciated.", + "weight": 8 + }, + { + "text": "*eats mindfully*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.feed.starving", + "conditions": {}, + "variants": [ + { + "text": "*maintains composure despite hunger*", + "weight": 10, + "is_action": true + }, + { + "text": "I was beginning to grow concerned. Thank you.", + "weight": 10 + }, + { + "text": "*grateful but measured*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.force_command", + "conditions": {}, + "variants": [ + { + "text": "Very well. Resistance serves no purpose.", + "weight": 10 + }, + { + "text": "*complies without drama*", + "weight": 10, + "is_action": true + }, + { + "text": "As you wish.", + "weight": 8 + } + ] + }, + { + "id": "action.collar_on", + "conditions": {}, + "variants": [ + { + "text": "*accepts the situation peacefully*", + "weight": 10, + "is_action": true + }, + { + "text": "I understand. This is how things are now.", + "weight": 10 + }, + { + "text": "*does not struggle*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.collar_off", + "conditions": {}, + "variants": [ + { + "text": "Thank you for this kindness.", + "weight": 10 + }, + { + "text": "*touches neck thoughtfully*", + "weight": 10, + "is_action": true + }, + { + "text": "I hope we can move forward peacefully.", + "weight": 8 + } + ] + }, + { + "id": "action.scold", + "conditions": {}, + "variants": [ + { + "text": "Understood.", + "weight": 10 + }, + { + "text": "*nods* Correcting behavior.", + "weight": 10, + "is_action": true + }, + { + "text": "My mistake.", + "weight": 8 + } + ] + }, + { + "id": "action.threaten", + "conditions": {}, + "variants": [ + { + "text": "I hear you.", + "weight": 10 + }, + { + "text": "*stays still* No need for that.", + "weight": 10, + "is_action": true + }, + { + "text": "I am complying.", + "weight": 8 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/calm/capture.json b/src/main/resources/data/tiedup/dialogue/en_us/calm/capture.json new file mode 100644 index 0000000..a56c923 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/calm/capture.json @@ -0,0 +1,55 @@ +{ + "category": "capture", + "personality": "CALM", + "description": "Stoic, accepting responses to capture - remains composed", + "entries": [ + { + "id": "capture.panic", + "variants": [ + { "text": "*remains perfectly composed*", "weight": 10, "is_action": true }, + { "text": "I see. This is happening then.", "weight": 10 }, + { "text": "*doesn't panic, just observes*", "weight": 8, "is_action": true }, + { "text": "Interesting situation.", "weight": 8 }, + { "text": "*accepts the situation calmly*", "weight": 6, "is_action": true } + ] + }, + { + "id": "capture.flee", + "variants": [ + { "text": "*walks away calmly*", "weight": 10, "is_action": true }, + { "text": "I'd rather not be involved in this.", "weight": 10 }, + { "text": "*moves away without panic*", "weight": 8, "is_action": true }, + { "text": "I'll be going now.", "weight": 8 } + ] + }, + { + "id": "capture.captured", + "variants": [ + { "text": "*waits patiently*", "weight": 10, "is_action": true }, + { "text": "Now what?", "weight": 10 }, + { "text": "*accepts bonds without drama*", "weight": 8, "is_action": true }, + { "text": "These are quite secure.", "weight": 8 }, + { "text": "*breathes steadily*", "weight": 6, "is_action": true } + ] + }, + { + "id": "capture.freed", + "variants": [ + { "text": "Thank you for your assistance.", "weight": 10 }, + { "text": "*nods in gratitude*", "weight": 10, "is_action": true }, + { "text": "That's appreciated.", "weight": 8 }, + { "text": "*stretches calmly*", "weight": 8, "is_action": true }, + { "text": "I knew someone would come.", "weight": 6 } + ] + }, + { + "id": "capture.call_for_help", + "variants": [ + { "text": "{player}. I could use some assistance.", "weight": 10 }, + { "text": "{player}, if you have a moment.", "weight": 10 }, + { "text": "*calmly* {player}. Help would be appreciated.", "weight": 8, "is_action": true }, + { "text": "{player}. I find myself in a predicament.", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/calm/commands.json b/src/main/resources/data/tiedup/dialogue/en_us/calm/commands.json new file mode 100644 index 0000000..73686d7 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/calm/commands.json @@ -0,0 +1,89 @@ +{ + "category": "commands", + "personality": "CALM", + "description": "Measured, balanced responses to commands", + "entries": [ + { + "id": "command.follow.accept", + "conditions": { "training_min": "HESITANT" }, + "variants": [ + { "text": "*follows steadily*", "weight": 10, "is_action": true }, + { "text": "Very well.", "weight": 10 }, + { "text": "*nods and complies*", "weight": 8, "is_action": true } + ] + }, + { + "id": "command.follow.refuse", + "variants": [ + { "text": "I'd rather not.", "weight": 10 }, + { "text": "*shakes head calmly*", "weight": 10, "is_action": true }, + { "text": "No, thank you.", "weight": 8 } + ] + }, + { + "id": "command.stay.accept", + "conditions": { "training_min": "HESITANT" }, + "variants": [ + { "text": "*remains in place*", "weight": 10, "is_action": true }, + { "text": "I'll wait here.", "weight": 10 }, + { "text": "*settles in calmly*", "weight": 8, "is_action": true } + ] + }, + { + "id": "command.kneel.refuse", + "variants": [ + { "text": "I prefer to stand.", "weight": 10 }, + { "text": "*politely declines*", "weight": 10, "is_action": true } + ] + }, + { + "id": "command.kneel.accept", + "conditions": { "training_min": "COMPLIANT" }, + "variants": [ + { "text": "*kneels without fuss*", "weight": 10, "is_action": true }, + { "text": "As you wish.", "weight": 10 } + ] + }, + { + "id": "command.sit.accept", + "conditions": { "training_min": "HESITANT" }, + "variants": [ + { "text": "*sits down calmly*", "weight": 10, "is_action": true }, + { "text": "Alright.", "weight": 10 } + ] + }, + { + "id": "command.heel.accept", + "conditions": { "training_min": "COMPLIANT" }, + "variants": [ + { "text": "*walks alongside*", "weight": 10, "is_action": true }, + { "text": "I'll stay close.", "weight": 10 } + ] + }, + { + "id": "command.generic.refuse", + "variants": [ + { "text": "I'd prefer not to.", "weight": 10 }, + { "text": "*shakes head calmly*", "weight": 10, "is_action": true }, + { "text": "That doesn't suit me.", "weight": 8 } + ] + }, + { + "id": "command.generic.accept", + "variants": [ + { "text": "Very well.", "weight": 10 }, + { "text": "*nods calmly*", "weight": 10, "is_action": true }, + { "text": "Alright.", "weight": 8 } + ] + }, + { + "id": "command.generic.hesitate", + "variants": [ + { "text": "*pauses thoughtfully*", "weight": 10, "is_action": true }, + { "text": "I suppose...", "weight": 10 }, + { "text": "*considers for a moment*", "weight": 8, "is_action": true }, + { "text": "If you insist...", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/calm/conversation.json b/src/main/resources/data/tiedup/dialogue/en_us/calm/conversation.json new file mode 100644 index 0000000..d7b4595 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/calm/conversation.json @@ -0,0 +1,239 @@ +{ + "category": "conversation", + "entries": [ + { + "id": "conversation.compliment", + "conditions": {}, + "variants": [ + { "text": "*slight nod* Acknowledged. Thank you.", "weight": 10, "is_action": true }, + { "text": "Kind words, even here. I appreciate them.", "weight": 10 }, + { "text": "*measured tone* That's... considerate of you.", "weight": 8 }, + { "text": "Interesting. I'll reflect on that.", "weight": 6 }, + { "text": "*peaceful acceptance* A kind thought. Thank you.", "weight": 8, "is_action": true }, + { "text": "Compliments flow like water. I let them wash over me.", "weight": 7 }, + { "text": "*serene* Words of kindness find fertile ground.", "weight": 7, "is_action": true }, + { "text": "I receive your words with gratitude.", "weight": 6 }, + { "text": "*thoughtful pause* That was unexpected. But welcome.", "weight": 6, "is_action": true }, + { "text": "Your kindness reflects well on you.", "weight": 6 } + ] + }, + { + "id": "conversation.comfort", + "conditions": { "mood_max": 50 }, + "variants": [ + { "text": "*takes a centering breath* ...Thank you. That helps.", "weight": 10, "is_action": true }, + { "text": "Your words find their mark. I'm grateful.", "weight": 10 }, + { "text": "*closes eyes briefly* A moment of peace. Appreciated.", "weight": 8, "is_action": true }, + { "text": "Comfort in adversity... there's wisdom in that.", "weight": 6 }, + { "text": "*settles* Like rain on dry earth. Thank you.", "weight": 8, "is_action": true }, + { "text": "Your compassion creates ripples of calm.", "weight": 7 }, + { "text": "*breathing deepens* Yes... this helps center me.", "weight": 7, "is_action": true }, + { "text": "Even in storms, there are moments of stillness.", "weight": 6 }, + { "text": "*accepts comfort* I was drifting. This anchors me.", "weight": 6, "is_action": true }, + { "text": "Kindness is its own form of meditation.", "weight": 6 } + ] + }, + { + "id": "conversation.praise", + "conditions": { "training_min": "HESITANT" }, + "variants": [ + { "text": "*thoughtful nod* I did what seemed appropriate.", "weight": 10, "is_action": true }, + { "text": "Thank you. I try to approach things mindfully.", "weight": 10 }, + { "text": "*small, genuine smile* That's kind of you to notice.", "weight": 8, "is_action": true }, + { "text": "Recognition is appreciated, though not required.", "weight": 6 }, + { "text": "*centered* I do what feels right. The rest follows.", "weight": 8, "is_action": true }, + { "text": "Action aligned with intention. A small success.", "weight": 7 }, + { "text": "*peaceful* Each task done well is its own reward.", "weight": 7, "is_action": true }, + { "text": "Your words are noted with gratitude.", "weight": 6 }, + { "text": "I aimed for harmony. I'm glad it showed.", "weight": 6 }, + { "text": "*steady* Praise is like incense. Pleasant but fleeting.", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.scold", + "conditions": {}, + "variants": [ + { "text": "*considers your words* I understand your perspective.", "weight": 10, "is_action": true }, + { "text": "I hear you. I'll reflect on this.", "weight": 10 }, + { "text": "*accepts calmly* Perhaps I can do better.", "weight": 8 }, + { "text": "Your frustration is noted. Tell me more.", "weight": 6 }, + { "text": "*centered despite criticism* All feedback has value.", "weight": 8, "is_action": true }, + { "text": "I accept your words without judgment.", "weight": 7 }, + { "text": "*nods slowly* This gives me something to contemplate.", "weight": 7, "is_action": true }, + { "text": "Criticism is a mirror. I'll look into it.", "weight": 6 }, + { "text": "*steady* Your displeasure teaches me something.", "weight": 6, "is_action": true }, + { "text": "I receive your correction with openness.", "weight": 6 } + ] + }, + { + "id": "conversation.threaten", + "conditions": {}, + "variants": [ + { "text": "*remains still, breathing steady* I understand.", "weight": 10, "is_action": true }, + { "text": "Fear is a choice. I choose acceptance.", "weight": 10 }, + { "text": "*peaceful expression* What will be, will be.", "weight": 8, "is_action": true }, + { "text": "Even pain is temporary. I will endure.", "weight": 6 }, + { "text": "*centered* Threats ripple across still water. I remain.", "weight": 8, "is_action": true }, + { "text": "I've made peace with uncertainty.", "weight": 7 }, + { "text": "*calm eyes* Do what you must. I am at peace.", "weight": 7, "is_action": true }, + { "text": "This moment too shall pass.", "weight": 6 }, + { "text": "*breathing steady* I accept whatever comes.", "weight": 6, "is_action": true }, + { "text": "My center holds, even now.", "weight": 6 } + ] + }, + { + "id": "conversation.tease", + "conditions": {}, + "variants": [ + { "text": "*slight smile* Humor. A coping mechanism, perhaps?", "weight": 10, "is_action": true }, + { "text": "Interesting choice of interaction.", "weight": 10 }, + { "text": "*unbothered* If that amuses you.", "weight": 8 }, + { "text": "Levity has its place. Even here.", "weight": 6 }, + { "text": "*serene* Teasing is just another form of attention.", "weight": 8, "is_action": true }, + { "text": "Your playfulness is noted.", "weight": 7 }, + { "text": "*gentle amusement* The lighter side emerges.", "weight": 7, "is_action": true }, + { "text": "Laughter is not unwelcome.", "weight": 6 }, + { "text": "*observing* You seek to unbalance me. It won't work.", "weight": 6, "is_action": true }, + { "text": "Teasing flows past me like a stream around a stone.", "weight": 6 } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_min": 60 }, + "variants": [ + { "text": "*serene* At peace. Circumstances don't define inner state.", "weight": 10, "is_action": true }, + { "text": "Present. Mindful. That's enough.", "weight": 10 }, + { "text": "Well, surprisingly. I've found acceptance.", "weight": 8 }, + { "text": "Each moment has its own truth. Right now, I'm okay.", "weight": 6 }, + { "text": "*centered* Calm. The storm is outside, not within.", "weight": 8, "is_action": true }, + { "text": "Balanced. Grounded. Breathing.", "weight": 7 }, + { "text": "*peaceful* My center holds. That's what matters.", "weight": 7, "is_action": true }, + { "text": "I am where I am. And that's sufficient.", "weight": 6 }, + { "text": "Contentment comes from within. I've found mine.", "weight": 6 }, + { "text": "*still* Like a lake without wind. Settled.", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_max": 59 }, + "variants": [ + { "text": "*measured breath* Challenged. But still centered.", "weight": 10, "is_action": true }, + { "text": "Struggling, but observing the struggle. It helps.", "weight": 10 }, + { "text": "Difficult. But difficulty is a teacher.", "weight": 8 }, + { "text": "The waves are high today. I'm still floating.", "weight": 6 }, + { "text": "*working to stay calm* Tested. Not broken.", "weight": 8, "is_action": true }, + { "text": "The balance wavers. I work to restore it.", "weight": 7 }, + { "text": "*thoughtful* Turbulent beneath, but surface calm.", "weight": 7, "is_action": true }, + { "text": "Finding peace is harder today. But I search.", "weight": 6 }, + { "text": "My center is elusive right now. But I'll find it.", "weight": 6 }, + { "text": "*honest* The calm requires more effort today.", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.whats_wrong", + "conditions": { "mood_max": 40 }, + "variants": [ + { "text": "*quiet exhale* Even calm waters can grow turbulent.", "weight": 10, "is_action": true }, + { "text": "I'm... finding it harder to maintain equilibrium.", "weight": 10 }, + { "text": "*opens eyes slowly* The weight is heavy today.", "weight": 8, "is_action": true }, + { "text": "Sometimes acceptance itself becomes exhausting.", "weight": 6 }, + { "text": "*stillness broken* The storm has moved inside.", "weight": 8, "is_action": true }, + { "text": "My center... I can't find it right now.", "weight": 7 }, + { "text": "*vulnerability showing* Even the calm can crack.", "weight": 7, "is_action": true }, + { "text": "Peace requires strength. Today I have little.", "weight": 6 }, + { "text": "The meditation fails me. The pain is too present.", "weight": 6 }, + { "text": "*tremor in the calm* Everything. All of it. Even me.", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.refusal.cooldown", + "conditions": {}, + "variants": [ + { "text": "*gentle refusal* I need time to process. Let's pause.", "weight": 10, "is_action": true }, + { "text": "Space between words has value. Let's take some.", "weight": 10 }, + { "text": "*closes eyes* A moment of silence, please.", "weight": 8, "is_action": true }, + { "text": "Let the conversation breathe.", "weight": 7 }, + { "text": "*centering* I need to return to my center first.", "weight": 7, "is_action": true }, + { "text": "Words need time to settle. Like sediment.", "weight": 6 }, + { "text": "*peaceful pause* Rest between actions is important.", "weight": 6, "is_action": true }, + { "text": "A moment of stillness, please.", "weight": 6 }, + { "text": "Let me integrate what we've shared.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.low_mood", + "conditions": {}, + "variants": [ + { "text": "*turns inward* I'm... retreating for now. Forgive me.", "weight": 10, "is_action": true }, + { "text": "The calm has left me. I need to find it again.", "weight": 10 }, + { "text": "*distant gaze* Please... let me sit with this.", "weight": 8, "is_action": true }, + { "text": "*struggling* I must go deep within. Leave me.", "weight": 7, "is_action": true }, + { "text": "My peace is shattered. I need to gather the pieces.", "weight": 7 }, + { "text": "*withdrawn* The world is too much right now.", "weight": 6, "is_action": true }, + { "text": "I'm seeking my center. It's far away.", "weight": 6 }, + { "text": "*retreating* Solitude is medicine today.", "weight": 6, "is_action": true }, + { "text": "I cannot speak from this emptiness.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.resentment", + "conditions": {}, + "variants": [ + { "text": "*controlled but cold* I need to process... certain feelings.", "weight": 10, "is_action": true }, + { "text": "Even I have limits. Space, please.", "weight": 10 }, + { "text": "*quiet intensity* Some wounds need silence to heal.", "weight": 8 }, + { "text": "*unusual edge* My calm does not extend to you right now.", "weight": 7, "is_action": true }, + { "text": "Forgiveness requires time. I have none to give yet.", "weight": 7 }, + { "text": "*restrained* I choose not to speak from resentment.", "weight": 6, "is_action": true }, + { "text": "Let me find peace before we speak again.", "weight": 6 }, + { "text": "*distant* The waters between us are troubled.", "weight": 6, "is_action": true }, + { "text": "I must calm the storm you created. Alone.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.fear", + "conditions": {}, + "variants": [ + { "text": "*breathing exercises visible* I'm... working through something. Please wait.", "weight": 10, "is_action": true }, + { "text": "Fear has arrived. I must sit with it first.", "weight": 10 }, + { "text": "*trembles slightly but breathes* Give me... a moment...", "weight": 8, "is_action": true }, + { "text": "*trying to center* The fear is loud. I need quiet.", "weight": 7, "is_action": true }, + { "text": "My calm is a boat in a storm right now...", "weight": 7 }, + { "text": "*working through panic* I acknowledge the fear... let me process...", "weight": 6, "is_action": true }, + { "text": "Even the calm feel fear. I must face it.", "weight": 6 }, + { "text": "*shaking* My center... it's hard to find...", "weight": 6, "is_action": true }, + { "text": "Let me return to my breath. Then we can speak.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.exhausted", + "conditions": {}, + "variants": [ + { "text": "*eyes closing peacefully* Rest is part of balance...", "weight": 10, "is_action": true }, + { "text": "The body demands sleep. I must listen.", "weight": 10 }, + { "text": "*fading* Even the mind needs... rest...", "weight": 8, "is_action": true }, + { "text": "*surrendering to tiredness* Sleep is meditation too.", "weight": 7, "is_action": true }, + { "text": "I cannot resist nature's call for rest.", "weight": 7 }, + { "text": "*peaceful drift* Consciousness retreats... for now.", "weight": 6, "is_action": true }, + { "text": "Let me return to stillness... true stillness...", "weight": 6 }, + { "text": "*releasing* I give myself to sleep.", "weight": 6, "is_action": true }, + { "text": "The body knows. I must trust it.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.tired", + "conditions": {}, + "variants": [ + { "text": "*gentle smile* Let's continue later. Words need rest too.", "weight": 10, "is_action": true }, + { "text": "Enough for now. Silence has its own wisdom.", "weight": 10 }, + { "text": "*peaceful pause* We've shared enough. For now.", "weight": 8, "is_action": true }, + { "text": "Let the conversation settle like snow.", "weight": 7 }, + { "text": "*centered but tired* My words are spent.", "weight": 7, "is_action": true }, + { "text": "Silence is the space between music notes.", "weight": 6 }, + { "text": "*resting* We can resume when energy returns.", "weight": 6, "is_action": true }, + { "text": "Communication requires energy. Mine is low.", "weight": 6 }, + { "text": "Let us rest in comfortable silence.", "weight": 6 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/calm/discipline.json b/src/main/resources/data/tiedup/dialogue/en_us/calm/discipline.json new file mode 100644 index 0000000..f2bab9e --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/calm/discipline.json @@ -0,0 +1,153 @@ +{ + "category": "discipline", + "entries": [ + { + "id": "discipline.legitimate.accept", + "conditions": {}, + "variants": [ + { + "text": "*accepts without complaint*", + "weight": 10, + "is_action": true + }, + { + "text": "I understand.", + "weight": 10 + }, + { + "text": "*remains composed*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.gratuitous.low_resentment", + "conditions": { + "resentment_max": 30 + }, + "variants": [ + { + "text": "That was unnecessary.", + "weight": 10 + }, + { + "text": "*quiet disapproval*", + "weight": 10, + "is_action": true + }, + { + "text": "I see.", + "weight": 8 + } + ] + }, + { + "id": "discipline.gratuitous.high_resentment", + "conditions": { + "resentment_min": 61 + }, + "variants": [ + { + "text": "*calm exterior hides seething anger*", + "weight": 10, + "is_action": true + }, + { + "text": "Noted.", + "weight": 10 + }, + { + "text": "*files this away*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.praise", + "conditions": {}, + "variants": [ + { + "text": "I appreciate the acknowledgment.", + "weight": 10 + }, + { + "text": "*nods respectfully* I aim for efficiency.", + "weight": 10, + "is_action": true + }, + { + "text": "It is good to know my efforts are satisfactory.", + "weight": 8 + }, + { + "text": "*faint smile* Thank you.", + "weight": 8, + "is_action": true + }, + { + "text": "I will continue to perform at this standard.", + "weight": 6 + } + ] + }, + { + "id": "discipline.scold", + "conditions": {}, + "variants": [ + { + "text": "You make a valid point. I will correct it.", + "weight": 10 + }, + { + "text": "*stoic expression* I accept your criticism.", + "weight": 10, + "is_action": true + }, + { + "text": "My apologies. It was an error in judgment.", + "weight": 8 + }, + { + "text": "*nods slowly* Understood. It won't recur.", + "weight": 8, + "is_action": true + }, + { + "text": "I see where I went wrong.", + "weight": 6 + } + ] + }, + { + "id": "discipline.threaten", + "conditions": {}, + "variants": [ + { + "text": "*remains calm* That won't be necessary.", + "weight": 10, + "is_action": true + }, + { + "text": "I understand the implications perfectly.", + "weight": 10 + }, + { + "text": "*steady gaze* There is no need for violence. I comply.", + "weight": 8, + "is_action": true + }, + { + "text": "Rationality dictates I listen to you.", + "weight": 8 + }, + { + "text": "*slight tension* Point taken.", + "weight": 6, + "is_action": true + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/calm/fear.json b/src/main/resources/data/tiedup/dialogue/en_us/calm/fear.json new file mode 100644 index 0000000..317e491 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/calm/fear.json @@ -0,0 +1,49 @@ +{ + "category": "fear", + "entries": [ + { + "id": "fear.nervous", + "conditions": {}, + "variants": [ + { "text": "*avoids eye contact*", "weight": 10, "is_action": true }, + { "text": "*speaks quietly*", "weight": 10, "is_action": true }, + { "text": "I-I'll behave...", "weight": 8 }, + { "text": "*fidgets nervously*", "weight": 8, "is_action": true }, + { "text": "Y-yes...?", "weight": 6 } + ] + }, + { + "id": "fear.afraid", + "conditions": {}, + "variants": [ + { "text": "*trembles visibly*", "weight": 10, "is_action": true }, + { "text": "P-please don't hurt me...", "weight": 10 }, + { "text": "*backs away slightly*", "weight": 8, "is_action": true }, + { "text": "I-I'm sorry... whatever I did...", "weight": 8 }, + { "text": "*can't meet your gaze*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.terrified", + "conditions": {}, + "variants": [ + { "text": "*recoils in panic*", "weight": 10, "is_action": true }, + { "text": "S-stay away!", "weight": 10 }, + { "text": "*breathing rapidly*", "weight": 8, "is_action": true }, + { "text": "No no no no...", "weight": 8 }, + { "text": "*frozen in terror*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.traumatized", + "conditions": {}, + "variants": [ + { "text": "*collapses, sobbing*", "weight": 10, "is_action": true }, + { "text": "*completely breaks down*", "weight": 10, "is_action": true }, + { "text": "I'll do anything... just please...", "weight": 8 }, + { "text": "*paralyzed with fear*", "weight": 8, "is_action": true }, + { "text": "*whimpers uncontrollably*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/calm/home.json b/src/main/resources/data/tiedup/dialogue/en_us/calm/home.json new file mode 100644 index 0000000..262ceb3 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/calm/home.json @@ -0,0 +1,41 @@ +{ + "category": "home", + "entries": [ + { + "id": "home.assigned.pet_bed", + "conditions": {}, + "variants": [ + { "text": "This will suffice.", "weight": 10 }, + { "text": "*settles in peacefully*", "weight": 10, "is_action": true }, + { "text": "A place to rest and reflect.", "weight": 8 } + ] + }, + { + "id": "home.assigned.bed", + "conditions": {}, + "variants": [ + { "text": "Thank you. This is comfortable.", "weight": 10 }, + { "text": "*appreciates the comfort*", "weight": 10, "is_action": true }, + { "text": "A peaceful place.", "weight": 8 } + ] + }, + { + "id": "home.destroyed.pet_bed", + "conditions": {}, + "variants": [ + { "text": "I see. That is unfortunate.", "weight": 10 }, + { "text": "*accepts with equanimity*", "weight": 10, "is_action": true }, + { "text": "Material things come and go.", "weight": 8 } + ] + }, + { + "id": "home.return.content", + "conditions": {}, + "variants": [ + { "text": "*returns to meditation*", "weight": 10, "is_action": true }, + { "text": "Peace at last.", "weight": 10 }, + { "text": "*finds tranquility*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/calm/idle.json b/src/main/resources/data/tiedup/dialogue/en_us/calm/idle.json new file mode 100644 index 0000000..16e001f --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/calm/idle.json @@ -0,0 +1,51 @@ +{ + "category": "idle", + "personality": "CALM", + "description": "Composed, patient idle behaviors", + "entries": [ + { + "id": "idle.free", + "variants": [ + { "text": "*stands quietly*", "weight": 10, "is_action": true }, + { "text": "*observes peacefully*", "weight": 10, "is_action": true }, + { "text": "*enjoys the calm*", "weight": 8, "is_action": true }, + { "text": "*waits patiently*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.greeting", + "variants": [ + { "text": "*nods in greeting*", "weight": 10, "is_action": true }, + { "text": "Hello.", "weight": 10 }, + { "text": "*acknowledges presence*", "weight": 8, "is_action": true }, + { "text": "Greetings.", "weight": 8 } + ] + }, + { + "id": "idle.goodbye", + "variants": [ + { "text": "Farewell.", "weight": 10 }, + { "text": "*nods goodbye*", "weight": 10, "is_action": true }, + { "text": "Take care.", "weight": 8 } + ] + }, + { + "id": "idle.captive", + "variants": [ + { "text": "*sits calmly despite bonds*", "weight": 10, "is_action": true }, + { "text": "*meditates*", "weight": 10, "is_action": true }, + { "text": "*accepts situation peacefully*", "weight": 8, "is_action": true }, + { "text": "*waits with patience*", "weight": 8, "is_action": true } + ] + }, + { + "id": "personality.hint", + "variants": [ + { "text": "*unnaturally calm demeanor*", "weight": 10, "is_action": true }, + { "text": "*steady, even presence*", "weight": 10, "is_action": true }, + { "text": "*seems unflappable*", "weight": 8, "is_action": true }, + { "text": "*serene expression*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/calm/leash.json b/src/main/resources/data/tiedup/dialogue/en_us/calm/leash.json new file mode 100644 index 0000000..75b9a15 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/calm/leash.json @@ -0,0 +1,42 @@ +{ + "category": "leash", + "entries": [ + { + "id": "leash.attached", + "conditions": {}, + "variants": [ + { "text": "I see... very well.", "weight": 10 }, + { "text": "*accepts the leash calmly*", "weight": 10, "is_action": true }, + { "text": "If this is what you require.", "weight": 8 }, + { "text": "*remains composed*", "weight": 6, "is_action": true } + ] + }, + { + "id": "leash.removed", + "conditions": {}, + "variants": [ + { "text": "Thank you.", "weight": 10 }, + { "text": "*nods quietly*", "weight": 10, "is_action": true }, + { "text": "That is appreciated.", "weight": 8 } + ] + }, + { + "id": "leash.walking.content", + "conditions": {}, + "variants": [ + { "text": "*walks at a measured pace*", "weight": 10, "is_action": true }, + { "text": "Lead on.", "weight": 10 }, + { "text": "*follows without complaint*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.pulled", + "conditions": {}, + "variants": [ + { "text": "*adjusts pace calmly*", "weight": 10, "is_action": true }, + { "text": "I understand.", "weight": 10 }, + { "text": "*quickens step without fuss*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/calm/mood.json b/src/main/resources/data/tiedup/dialogue/en_us/calm/mood.json new file mode 100644 index 0000000..c9167bc --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/calm/mood.json @@ -0,0 +1,44 @@ +{ + "category": "mood", + "personality": "CALM", + "description": "Balanced, even-tempered mood expressions", + "entries": [ + { + "id": "mood.happy", + "conditions": { "mood_min": 70 }, + "variants": [ + { "text": "*allows small smile*", "weight": 10, "is_action": true }, + { "text": "This is pleasant.", "weight": 10 }, + { "text": "*seems content*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.neutral", + "conditions": { "mood_min": 40, "mood_max": 69 }, + "variants": [ + { "text": "*waits patiently*", "weight": 10, "is_action": true }, + { "text": "*observes surroundings*", "weight": 10, "is_action": true }, + { "text": "*remains composed*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.sad", + "conditions": { "mood_min": 10, "mood_max": 39 }, + "variants": [ + { "text": "*quietly melancholic*", "weight": 10, "is_action": true }, + { "text": "Things could be better.", "weight": 10 }, + { "text": "*sighs softly*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.miserable", + "conditions": { "mood_max": 9 }, + "variants": [ + { "text": "*even calm has limits*", "weight": 10, "is_action": true }, + { "text": "This is... difficult.", "weight": 10 }, + { "text": "*struggles to maintain composure*", "weight": 8, "is_action": true }, + { "text": "*silent tears*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/calm/needs.json b/src/main/resources/data/tiedup/dialogue/en_us/calm/needs.json new file mode 100644 index 0000000..d18159b --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/calm/needs.json @@ -0,0 +1,47 @@ +{ + "category": "needs", + "personality": "CALM", + "description": "Matter-of-fact expressions of needs", + "entries": [ + { + "id": "needs.hungry", + "variants": [ + { "text": "I could use some food.", "weight": 10 }, + { "text": "*stomach growls* Ah.", "weight": 10, "is_action": true }, + { "text": "I'm getting hungry.", "weight": 8 } + ] + }, + { + "id": "needs.tired", + "variants": [ + { "text": "I'm tired.", "weight": 10 }, + { "text": "*yawns quietly*", "weight": 10, "is_action": true }, + { "text": "Rest would be welcome.", "weight": 8 } + ] + }, + { + "id": "needs.uncomfortable", + "variants": [ + { "text": "This position is uncomfortable.", "weight": 10 }, + { "text": "*adjusts position*", "weight": 10, "is_action": true }, + { "text": "*endures discomfort stoically*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.dignity_low", + "variants": [ + { "text": "*acknowledges embarrassment internally*", "weight": 10, "is_action": true }, + { "text": "This is... undignified.", "weight": 10 }, + { "text": "*maintains composure despite shame*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.satisfied", + "variants": [ + { "text": "Thank you.", "weight": 10 }, + { "text": "*nods appreciatively*", "weight": 10, "is_action": true }, + { "text": "That's better.", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/calm/personality.json b/src/main/resources/data/tiedup/dialogue/en_us/calm/personality.json new file mode 100644 index 0000000..5ddcb23 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/calm/personality.json @@ -0,0 +1,17 @@ +{ + "category": "personality", + "entries": [ + { + "id": "personality.hint", + "conditions": {}, + "variants": [ + { "text": "*remains perfectly composed*", "weight": 10, "is_action": true }, + { "text": "*shows no particular emotion*", "weight": 10, "is_action": true }, + { "text": "*breathes steadily*", "weight": 8, "is_action": true }, + { "text": "*seems unfazed*", "weight": 8, "is_action": true }, + { "text": "*maintains a neutral expression*", "weight": 6, "is_action": true }, + { "text": "*accepts the situation stoically*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/calm/reaction.json b/src/main/resources/data/tiedup/dialogue/en_us/calm/reaction.json new file mode 100644 index 0000000..9988f0f --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/calm/reaction.json @@ -0,0 +1,60 @@ +{ + "category": "reaction", + "entries": [ + { + "id": "reaction.approach.stranger", + "conditions": {}, + "variants": [ + { "text": "*looks up calmly*", "weight": 10, "is_action": true }, + { "text": "Hello.", "weight": 10 }, + { "text": "*observes quietly*", "weight": 8, "is_action": true }, + { "text": "Can I help you?", "weight": 8 }, + { "text": "*waits patiently*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.master", + "conditions": {}, + "variants": [ + { "text": "*nods in acknowledgment*", "weight": 10, "is_action": true }, + { "text": "Master.", "weight": 10 }, + { "text": "*stands ready*", "weight": 8, "is_action": true }, + { "text": "I'm here.", "weight": 8 }, + { "text": "*awaits instruction*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.beloved", + "conditions": {}, + "variants": [ + { "text": "*small smile appears*", "weight": 10, "is_action": true }, + { "text": "It's good to see you.", "weight": 10 }, + { "text": "*relaxes*", "weight": 8, "is_action": true }, + { "text": "I'm glad you're here.", "weight": 8 }, + { "text": "*eyes soften*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.captor", + "conditions": {}, + "variants": [ + { "text": "*remains composed*", "weight": 10, "is_action": true }, + { "text": "I see.", "weight": 10 }, + { "text": "*accepts the situation*", "weight": 8, "is_action": true }, + { "text": "Very well.", "weight": 8 }, + { "text": "*watches neutrally*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.enemy", + "conditions": {}, + "variants": [ + { "text": "*stays calm*", "weight": 10, "is_action": true }, + { "text": "There's no need for violence.", "weight": 10 }, + { "text": "*doesn't show fear*", "weight": 8, "is_action": true }, + { "text": "What do you want?", "weight": 8 }, + { "text": "*breathes slowly*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/calm/resentment.json b/src/main/resources/data/tiedup/dialogue/en_us/calm/resentment.json new file mode 100644 index 0000000..aaf092b --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/calm/resentment.json @@ -0,0 +1,41 @@ +{ + "category": "resentment", + "entries": [ + { + "id": "resentment.none", + "conditions": { "resentment_max": 10 }, + "variants": [ + { "text": "*at peace*", "weight": 10, "is_action": true }, + { "text": "I am content.", "weight": 10 }, + { "text": "*serene*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.building", + "conditions": { "resentment_min": 31, "resentment_max": 50 }, + "variants": [ + { "text": "*calm surface, ripples beneath*", "weight": 10, "is_action": true }, + { "text": "I see.", "weight": 10 }, + { "text": "*thoughtful silence*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.high", + "conditions": { "resentment_min": 71 }, + "variants": [ + { "text": "*deceptively calm*", "weight": 10, "is_action": true }, + { "text": "Noted.", "weight": 10 }, + { "text": "*still waters run deep*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.critical", + "conditions": { "resentment_min": 86 }, + "variants": [ + { "text": "*calm before the storm*", "weight": 10, "is_action": true }, + { "text": "The time approaches.", "weight": 10 }, + { "text": "*patient rage*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/calm/struggle.json b/src/main/resources/data/tiedup/dialogue/en_us/calm/struggle.json new file mode 100644 index 0000000..ec59408 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/calm/struggle.json @@ -0,0 +1,48 @@ +{ + "category": "struggle", + "personality": "CALM", + "description": "Measured, methodical escape attempts", + "entries": [ + { + "id": "struggle.attempt", + "variants": [ + { "text": "*works at bonds methodically*", "weight": 10, "is_action": true }, + { "text": "*tests restraints calmly*", "weight": 10, "is_action": true }, + { "text": "Let me see...", "weight": 8 }, + { "text": "*tries without urgency*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.success", + "variants": [ + { "text": "*freed without fanfare*", "weight": 10, "is_action": true }, + { "text": "That's done.", "weight": 10 }, + { "text": "*rubs wrists calmly*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.failure", + "variants": [ + { "text": "*accepts failure, tries again later*", "weight": 10, "is_action": true }, + { "text": "Not this time.", "weight": 10 }, + { "text": "*patience unbroken*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.warned", + "variants": [ + { "text": "*pauses to consider*", "weight": 10, "is_action": true }, + { "text": "I understand.", "weight": 10 }, + { "text": "*heeds warning calmly*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.exhausted", + "variants": [ + { "text": "*rests peacefully*", "weight": 10, "is_action": true }, + { "text": "I'll rest for now.", "weight": 10 }, + { "text": "*conserves energy wisely*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/curious/actions.json b/src/main/resources/data/tiedup/dialogue/en_us/curious/actions.json new file mode 100644 index 0000000..30ae9ab --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/curious/actions.json @@ -0,0 +1,209 @@ +{ + "category": "actions", + "entries": [ + { + "id": "action.whip", + "conditions": {}, + "variants": [ + { + "text": "Ow! ...that's an interesting sensation.", + "weight": 10 + }, + { + "text": "Why do you do this? What does it accomplish?", + "weight": 10 + }, + { + "text": "*winces but looks fascinated*", + "weight": 8, + "is_action": true + }, + { + "text": "Is this some kind of ritual?", + "weight": 8 + } + ] + }, + { + "id": "action.paddle", + "conditions": {}, + "variants": [ + { + "text": "*tilts head curiously*", + "weight": 10, + "is_action": true + }, + { + "text": "Interesting technique...", + "weight": 10 + }, + { + "text": "Does this usually work on people?", + "weight": 8 + } + ] + }, + { + "id": "action.praise", + "conditions": {}, + "variants": [ + { + "text": "Oh! You approve? What did I do right?", + "weight": 10 + }, + { + "text": "*brightens with interest*", + "weight": 10, + "is_action": true + }, + { + "text": "Can you tell me more about what you liked?", + "weight": 8 + }, + { + "text": "I want to understand your standards better!", + "weight": 8 + } + ] + }, + { + "id": "action.feed", + "conditions": {}, + "variants": [ + { + "text": "Oh! What kind of food is this?", + "weight": 10 + }, + { + "text": "*examines food before eating*", + "weight": 10, + "is_action": true + }, + { + "text": "Did you make this yourself?", + "weight": 8 + }, + { + "text": "This has an interesting flavor!", + "weight": 8 + } + ] + }, + { + "id": "action.feed.starving", + "conditions": {}, + "variants": [ + { + "text": "*too hungry to ask questions for once*", + "weight": 10, + "is_action": true + }, + { + "text": "Thank you! ...where did you get this?", + "weight": 10 + }, + { + "text": "*eats quickly, then questions resume*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.force_command", + "conditions": {}, + "variants": [ + { + "text": "Fine, but will you explain why after?", + "weight": 10 + }, + { + "text": "*obeys while analyzing the situation*", + "weight": 10, + "is_action": true + }, + { + "text": "I have so many questions about this...", + "weight": 8 + } + ] + }, + { + "id": "action.collar_on", + "conditions": {}, + "variants": [ + { + "text": "*examines collar with interest*", + "weight": 10, + "is_action": true + }, + { + "text": "What does this symbolize? What's it made of?", + "weight": 10 + }, + { + "text": "Fascinating craftsmanship...", + "weight": 8 + } + ] + }, + { + "id": "action.collar_off", + "conditions": {}, + "variants": [ + { + "text": "Oh? Why the change? What happened?", + "weight": 10 + }, + { + "text": "*immediately starts asking questions*", + "weight": 10, + "is_action": true + }, + { + "text": "Can I keep it to study?", + "weight": 8 + } + ] + }, + { + "id": "action.scold", + "conditions": {}, + "variants": [ + { + "text": "Oh! Wrong?", + "weight": 10 + }, + { + "text": "*blinks* Noted.", + "weight": 10, + "is_action": true + }, + { + "text": "*analyzes mistake* My hypothesis was wrong...", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.threaten", + "conditions": {}, + "variants": [ + { + "text": "Okay! I'm stopping!", + "weight": 10 + }, + { + "text": "*looks alarmed* Variable too high!", + "weight": 10, + "is_action": true + }, + { + "text": "I understand!", + "weight": 8 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/curious/capture.json b/src/main/resources/data/tiedup/dialogue/en_us/curious/capture.json new file mode 100644 index 0000000..940f521 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/curious/capture.json @@ -0,0 +1,54 @@ +{ + "category": "capture", + "personality": "CURIOUS", + "description": "Inquisitive, fascinated by the experience", + "entries": [ + { + "id": "capture.panic", + "variants": [ + { "text": "Oh! What are you doing?", "weight": 10 }, + { "text": "*looks at captor with interest*", "weight": 10, "is_action": true }, + { "text": "Is this a kidnapping? How exciting!", "weight": 8 }, + { "text": "Where are you taking me?", "weight": 8 }, + { "text": "*watches everything with wide eyes*", "weight": 6, "is_action": true } + ] + }, + { + "id": "capture.flee", + "variants": [ + { "text": "*hesitates, curious about what will happen*", "weight": 10, "is_action": true }, + { "text": "Wait, I want to see what happens!", "weight": 10 }, + { "text": "*backs away slowly while watching*", "weight": 8, "is_action": true }, + { "text": "This is interesting but I should probably run...", "weight": 8 } + ] + }, + { + "id": "capture.captured", + "variants": [ + { "text": "*examines the ropes* Interesting technique.", "weight": 10, "is_action": true }, + { "text": "What kind of knots are these?", "weight": 10 }, + { "text": "*tests bonds experimentally*", "weight": 8, "is_action": true }, + { "text": "So this is what it's like...", "weight": 8 }, + { "text": "*mentally catalogs every sensation*", "weight": 6, "is_action": true } + ] + }, + { + "id": "capture.freed", + "variants": [ + { "text": "Wait, I had more questions!", "weight": 10 }, + { "text": "That was... educational.", "weight": 10 }, + { "text": "*seems almost disappointed it's over*", "weight": 8, "is_action": true }, + { "text": "Thank you! ...Can you tell me how those knots worked?", "weight": 8 } + ] + }, + { + "id": "capture.call_for_help", + "variants": [ + { "text": "{player}! This is quite an experience! ...Help?", "weight": 10 }, + { "text": "{player}! Come see this! Also, help!", "weight": 10 }, + { "text": "*excitedly* {player}! You won't believe what's happening!", "weight": 8, "is_action": true }, + { "text": "{player}! I'm learning so much! But also trapped!", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/curious/commands.json b/src/main/resources/data/tiedup/dialogue/en_us/curious/commands.json new file mode 100644 index 0000000..2c1405f --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/curious/commands.json @@ -0,0 +1,202 @@ +{ + "category": "commands", + "personality": "CURIOUS", + "description": "Inquisitive, questions while following commands", + "entries": [ + { + "id": "command.follow.accept", + "conditions": { + "training_min": "HESITANT" + }, + "variants": [ + { + "text": "Okay! Where are we going?", + "weight": 10 + }, + { + "text": "*follows while looking around*", + "weight": 10, + "is_action": true + }, + { + "text": "Lead the way! This is interesting.", + "weight": 8 + } + ] + }, + { + "id": "command.follow.refuse", + "variants": [ + { + "text": "But why should I follow you?", + "weight": 10 + }, + { + "text": "*tilts head questioningly*", + "weight": 10, + "is_action": true + }, + { + "text": "What's in it for me?", + "weight": 8 + } + ] + }, + { + "id": "command.stay.accept", + "conditions": { + "training_min": "HESITANT" + }, + "variants": [ + { + "text": "Sure, but why here specifically?", + "weight": 10 + }, + { + "text": "*stays while examining surroundings*", + "weight": 10, + "is_action": true + }, + { + "text": "I'll wait. Is this spot important?", + "weight": 8 + } + ] + }, + { + "id": "command.kneel.refuse", + "variants": [ + { + "text": "Why would I kneel? Explain.", + "weight": 10 + }, + { + "text": "What's the purpose of this?", + "weight": 10 + }, + { + "text": "*considers request with interest*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.kneel.accept", + "conditions": { + "training_min": "COMPLIANT" + }, + "variants": [ + { + "text": "*kneels* Is this correct?", + "weight": 10, + "is_action": true + }, + { + "text": "Like this? What happens next?", + "weight": 10 + } + ] + }, + { + "id": "command.sit.accept", + "conditions": { + "training_min": "HESITANT" + }, + "variants": [ + { + "text": "*sits* Now what?", + "weight": 10, + "is_action": true + }, + { + "text": "Okay! Why do you want me to sit?", + "weight": 10 + } + ] + }, + { + "id": "command.heel.accept", + "conditions": { + "training_min": "COMPLIANT" + }, + "variants": [ + { + "text": "*moves close* Like a puppy?", + "weight": 10, + "is_action": true + }, + { + "text": "This close? Why?", + "weight": 10 + } + ] + }, + { + "id": "command.generic.refuse", + "variants": [ + { + "text": "But why? What's the reason?", + "weight": 10 + }, + { + "text": "*tilts head curiously*", + "weight": 10, + "is_action": true + }, + { + "text": "I don't understand the purpose.", + "weight": 8 + } + ] + }, + { + "id": "command.generic.accept", + "conditions": { + "training_min": "HESITANT" + }, + "variants": [ + { + "text": "Okay! Let's see what happens.", + "weight": 10 + }, + { + "text": "*complies with interest*", + "weight": 10, + "is_action": true + }, + { + "text": "Sure! This should be interesting.", + "weight": 8 + }, + { + "text": "Alright, I'm curious where this goes.", + "weight": 8 + } + ] + }, + { + "id": "command.generic.hesitate", + "variants": [ + { + "text": "Hmm... I'm not sure about this...", + "weight": 10 + }, + { + "text": "*tilts head thoughtfully*", + "weight": 10, + "is_action": true + }, + { + "text": "What will happen if I do?", + "weight": 8 + }, + { + "text": "*considers with curiosity*", + "weight": 8, + "is_action": true + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/curious/conversation.json b/src/main/resources/data/tiedup/dialogue/en_us/curious/conversation.json new file mode 100644 index 0000000..b2f3a42 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/curious/conversation.json @@ -0,0 +1,239 @@ +{ + "category": "conversation", + "entries": [ + { + "id": "conversation.compliment", + "conditions": {}, + "variants": [ + { "text": "*tilts head* Really? What made you think that?", "weight": 10, "is_action": true }, + { "text": "Thank you! But I'm curious - what prompted that specifically?", "weight": 10 }, + { "text": "*eyes light up* Oh! That's nice. Do you compliment all your... guests?", "weight": 8, "is_action": true }, + { "text": "Interesting! I wonder what your criteria are for such judgments.", "weight": 6 }, + { "text": "*perks up* Ooh, a compliment! What aspect exactly?", "weight": 8, "is_action": true }, + { "text": "That raises questions! What made you notice that?", "weight": 7 }, + { "text": "*analyzing* Fascinating! Is flattery a tactic you use often?", "weight": 7, "is_action": true }, + { "text": "Thank you! Now I'm curious what else you've observed about me.", "weight": 6 }, + { "text": "*intrigued* A positive assessment! Walk me through your reasoning!", "weight": 6, "is_action": true }, + { "text": "Interesting data point! Do you always compliment captives?", "weight": 6 } + ] + }, + { + "id": "conversation.comfort", + "conditions": { "mood_max": 50 }, + "variants": [ + { "text": "*relaxes somewhat* That does help... How did you know what to say?", "weight": 10, "is_action": true }, + { "text": "Thank you... I'm curious though - do you mean it?", "weight": 10 }, + { "text": "*thoughtful* Comfort from an unexpected source. Fascinating...", "weight": 8, "is_action": true }, + { "text": "That was kind. I'm intrigued by your motivations.", "weight": 6 }, + { "text": "*calming* Interesting approach. Comfort through words. It works.", "weight": 8, "is_action": true }, + { "text": "Thank you! Why did you choose to comfort me though?", "weight": 7 }, + { "text": "*analyzing even while comforted* Genuine or strategic kindness?", "weight": 7, "is_action": true }, + { "text": "That helps! I wonder what triggered your compassion.", "weight": 6 }, + { "text": "*curious even in vulnerability* How did you learn to comfort people?", "weight": 6, "is_action": true }, + { "text": "Fascinating. Kindness in captivity. I have questions.", "weight": 6 } + ] + }, + { + "id": "conversation.praise", + "conditions": { "training_min": "HESITANT" }, + "variants": [ + { "text": "*perks up* I did? What exactly did I do right? I want to understand.", "weight": 10, "is_action": true }, + { "text": "Thank you! Could you be more specific? I'm curious about the details.", "weight": 10 }, + { "text": "*nods eagerly* Good to know! What parameters define 'good' here?", "weight": 8, "is_action": true }, + { "text": "Interesting feedback! I'd love to know more about your expectations.", "weight": 6 }, + { "text": "*excited* Praise! But what metrics are you using?", "weight": 8, "is_action": true }, + { "text": "Ooh! What specifically pleased you? I want to replicate it!", "weight": 7 }, + { "text": "*analyzing* Positive reinforcement! Classic. What triggered it?", "weight": 7, "is_action": true }, + { "text": "Thank you! Now tell me WHY that was good. I need data!", "weight": 6 }, + { "text": "*taking mental notes* Interesting! What's your rubric?", "weight": 6, "is_action": true }, + { "text": "Good feedback! But I have follow-up questions!", "weight": 6 } + ] + }, + { + "id": "conversation.scold", + "conditions": {}, + "variants": [ + { "text": "*brow furrows* I see. Can you explain what I should have done instead?", "weight": 10, "is_action": true }, + { "text": "Hmm, I understand. But could you help me understand WHY that was wrong?", "weight": 10 }, + { "text": "*takes mental notes* Interesting. What were your expectations?", "weight": 8, "is_action": true }, + { "text": "I'd like to learn from this. What triggered your reaction?", "weight": 6 }, + { "text": "*curious even when scolded* Fascinating! What did I miss?", "weight": 8, "is_action": true }, + { "text": "Your frustration is informative! Tell me more about why.", "weight": 7 }, + { "text": "*analyzing* Interesting negative feedback. Can you elaborate?", "weight": 7, "is_action": true }, + { "text": "I accept the criticism! But what's the correct approach?", "weight": 6 }, + { "text": "*thinking* So that was wrong... what would right look like?", "weight": 6, "is_action": true }, + { "text": "Noted! But I have questions about your reasoning.", "weight": 6 } + ] + }, + { + "id": "conversation.threaten", + "conditions": {}, + "variants": [ + { "text": "*swallows but maintains curiosity* What exactly are you planning to do?", "weight": 10, "is_action": true }, + { "text": "That's... concerning. But I'm curious - have you done this before?", "weight": 10 }, + { "text": "*nervous but analytical* Interesting threat pattern. What's your goal?", "weight": 8, "is_action": true }, + { "text": "Okay, that's scary. But... why this approach specifically?", "weight": 6 }, + { "text": "*fear mixed with curiosity* Threatening! But what outcome are you seeking?", "weight": 8, "is_action": true }, + { "text": "I'm scared! But also... what's your methodology here?", "weight": 7 }, + { "text": "*analyzing even in danger* Is this a standard technique for you?", "weight": 7, "is_action": true }, + { "text": "That's terrifying! How did you learn this approach?", "weight": 6 }, + { "text": "*scared but still questioning* What happens if I comply versus resist?", "weight": 6, "is_action": true }, + { "text": "Okay! Scary! But I have questions about the logic here!", "weight": 6 } + ] + }, + { + "id": "conversation.tease", + "conditions": {}, + "variants": [ + { "text": "*laughs* Ha! Why do you tease? Is it a dominance thing?", "weight": 10, "is_action": true }, + { "text": "That's actually kind of funny. Do you do this with everyone?", "weight": 10 }, + { "text": "*grins despite self* Okay, okay. But where do you get your material?", "weight": 8, "is_action": true }, + { "text": "Teasing! Interesting choice. What reaction were you hoping for?", "weight": 6 }, + { "text": "*amused* Humor! Is this how you build rapport?", "weight": 8, "is_action": true }, + { "text": "Ha! But really, what's the psychological function of teasing here?", "weight": 7 }, + { "text": "*chuckles* Okay, that was funny. Why use humor in this situation?", "weight": 7, "is_action": true }, + { "text": "Interesting tactic! Is teasing part of your process?", "weight": 6 }, + { "text": "*curious grin* I wonder what made you choose that particular joke.", "weight": 6, "is_action": true }, + { "text": "Teasing me! Bold choice. What did you expect me to do?", "weight": 6 } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_min": 60 }, + "variants": [ + { "text": "Good, actually! Though I have SO many questions about this place...", "weight": 10 }, + { "text": "*looks around with interest* Fine! There's actually a lot to observe here.", "weight": 10, "is_action": true }, + { "text": "Better now that we're talking! I love gathering information.", "weight": 8 }, + { "text": "Curious, as always. This situation is bizarre but fascinating.", "weight": 6 }, + { "text": "*eager* Great! Every new situation is a learning opportunity!", "weight": 8, "is_action": true }, + { "text": "Surprisingly well! My curiosity keeps me engaged.", "weight": 7 }, + { "text": "Good! Though I have about a hundred questions queued up!", "weight": 7 }, + { "text": "*excited* Fine! This whole situation raises fascinating questions!", "weight": 6, "is_action": true }, + { "text": "Well! My mind is busy processing all this new data!", "weight": 6 }, + { "text": "Interested! That's how I'd describe it. Constantly interested!", "weight": 6 } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_max": 59 }, + "variants": [ + { "text": "*sighs* Honestly? Struggling. But I'm trying to understand why.", "weight": 10, "is_action": true }, + { "text": "Not great. Though I'm curious what's causing this feeling...", "weight": 10 }, + { "text": "Confused and uncomfortable. But at least that's something to analyze.", "weight": 8 }, + { "text": "Hard to say. My mind keeps racing with questions instead of answers.", "weight": 6 }, + { "text": "*puzzled* Distressed? But also trying to understand the distress.", "weight": 8, "is_action": true }, + { "text": "Struggling. Even my curiosity can't fully distract me.", "weight": 7 }, + { "text": "*thoughtful* Conflicted. Part of me wants answers, part wants escape.", "weight": 7, "is_action": true }, + { "text": "Not good. But even that's interesting to examine.", "weight": 6 }, + { "text": "Anxious. My mind races but finds no solutions.", "weight": 6 }, + { "text": "*restless* Unsettled. Too many questions, not enough answers.", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.whats_wrong", + "conditions": { "mood_max": 40 }, + "variants": [ + { "text": "*troubled* I keep asking 'why' and getting no answers. It's frustrating.", "weight": 10, "is_action": true }, + { "text": "I'm usually sustained by curiosity, but even that feels... empty right now.", "weight": 10 }, + { "text": "*looks lost* I don't understand anything anymore. Not even myself.", "weight": 8, "is_action": true }, + { "text": "Questions without answers. Hope without certainty. It's overwhelming.", "weight": 6 }, + { "text": "*defeated* My questions have stopped mattering. That's terrifying.", "weight": 8, "is_action": true }, + { "text": "I've lost the thread. I don't know what to ask anymore.", "weight": 7 }, + { "text": "*hollow* Even my curiosity can't save me from this emptiness.", "weight": 7, "is_action": true }, + { "text": "Nothing makes sense. And I can't even find the right questions.", "weight": 6 }, + { "text": "The mystery has stopped being interesting. It's just... pain.", "weight": 6 }, + { "text": "*broken* I used to love not knowing. Now it terrifies me.", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.refusal.cooldown", + "conditions": {}, + "variants": [ + { "text": "*waves hand* We JUST talked! I need time to process all that information.", "weight": 10, "is_action": true }, + { "text": "Hold on, let me think about what we discussed first.", "weight": 10 }, + { "text": "Too soon! My brain is still working on the last conversation.", "weight": 8 }, + { "text": "*processing* Give me time! I'm still analyzing our last exchange!", "weight": 7, "is_action": true }, + { "text": "Wait! I have follow-up questions forming! Let me think!", "weight": 7 }, + { "text": "Information overload! Need to sort through data!", "weight": 6 }, + { "text": "*concentrating* Still processing! Come back soon!", "weight": 6, "is_action": true }, + { "text": "My curiosity needs a breather!", "weight": 6 }, + { "text": "Too many thoughts! Give them time to organize!", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.low_mood", + "conditions": {}, + "variants": [ + { "text": "*stares blankly* Even my curiosity has abandoned me right now...", "weight": 10, "is_action": true }, + { "text": "I don't have the energy to ask questions. That's... concerning.", "weight": 10 }, + { "text": "*flat tone* Not now. I can't even think straight.", "weight": 8 }, + { "text": "*empty* The questions have stopped. I don't know why.", "weight": 7, "is_action": true }, + { "text": "My mind is... blank. Nothing to ask. Nothing to say.", "weight": 7 }, + { "text": "*concerning stillness* The curiosity is gone. I'm just... empty.", "weight": 6, "is_action": true }, + { "text": "I don't want to know anything right now.", "weight": 6 }, + { "text": "*withdrawn* The world isn't interesting anymore.", "weight": 6, "is_action": true }, + { "text": "Can't question. Can't think. Can't care.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.resentment", + "conditions": {}, + "variants": [ + { "text": "*cold curiosity gone* I'm not interested in understanding you right now.", "weight": 10, "is_action": true }, + { "text": "My questions have turned to silence. You've exhausted my goodwill.", "weight": 10 }, + { "text": "*turns away* Some things aren't worth investigating.", "weight": 8, "is_action": true }, + { "text": "*bitter* I've stopped being curious about you. You're not worth it.", "weight": 7, "is_action": true }, + { "text": "No more questions. You don't deserve my interest.", "weight": 7 }, + { "text": "*cold* My curiosity has limits. You've reached them.", "weight": 6, "is_action": true }, + { "text": "I don't want to understand you anymore.", "weight": 6 }, + { "text": "*disinterested* You're no longer a puzzle I want to solve.", "weight": 6, "is_action": true }, + { "text": "Some mysteries aren't worth the pain of solving.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.fear", + "conditions": {}, + "variants": [ + { "text": "*backs away* I don't want to know what you're thinking right now!", "weight": 10, "is_action": true }, + { "text": "For once... I don't want answers. Please stay back.", "weight": 10 }, + { "text": "*trembles* Some mysteries are best left unsolved...", "weight": 8, "is_action": true }, + { "text": "*terrified* I'm scared to ask! I'm scared to know!", "weight": 7, "is_action": true }, + { "text": "My curiosity is overwhelmed by fear right now!", "weight": 7 }, + { "text": "*panic* Don't tell me! I don't want to know!", "weight": 6, "is_action": true }, + { "text": "For once the unknown is a mercy. Stay away.", "weight": 6 }, + { "text": "*shaking* No questions. Just... stay back.", "weight": 6, "is_action": true }, + { "text": "I don't want information! I want safety!", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.exhausted", + "conditions": {}, + "variants": [ + { "text": "*yawns mid-thought* I was wondering about... about... *drifts off*", "weight": 10, "is_action": true }, + { "text": "Even curious minds need sleep... can we... continue later...?", "weight": 10 }, + { "text": "*eyes closing* So many questions... but they'll have to wait...", "weight": 8, "is_action": true }, + { "text": "*fading* Questions tomorrow... sleep now...", "weight": 7, "is_action": true }, + { "text": "My brain can't form questions... too tired...", "weight": 7 }, + { "text": "*drifting* I wonder why I'm so... so... zzz...", "weight": 6, "is_action": true }, + { "text": "Even curiosity needs rest.", "weight": 6 }, + { "text": "*mumbling* Make note... ask about... *falls asleep*", "weight": 6, "is_action": true }, + { "text": "Processing... processing... sleep mode engaged...", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.tired", + "conditions": {}, + "variants": [ + { "text": "*rubs temples* My brain is full. Too much data. Need to process.", "weight": 10, "is_action": true }, + { "text": "I've asked too many questions today. Even I have limits.", "weight": 10 }, + { "text": "Information overload. Can we pause the Q&A session?", "weight": 8 }, + { "text": "*overwhelmed* Too many answers! Need to organize!", "weight": 7, "is_action": true }, + { "text": "My curiosity needs a recharge break.", "weight": 7 }, + { "text": "*exhausted* Even investigators need downtime.", "weight": 6, "is_action": true }, + { "text": "Brain full! Come back when there's room!", "weight": 6 }, + { "text": "Information fatigue! Need to sort existing data!", "weight": 6 }, + { "text": "*tired* Save the questions for later. Too many already.", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/curious/discipline.json b/src/main/resources/data/tiedup/dialogue/en_us/curious/discipline.json new file mode 100644 index 0000000..0724d3f --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/curious/discipline.json @@ -0,0 +1,152 @@ +{ + "category": "discipline", + "entries": [ + { + "id": "discipline.legitimate.accept", + "conditions": {}, + "variants": [ + { + "text": "Ow! So that's what it feels like...", + "weight": 10 + }, + { + "text": "*processes the experience*", + "weight": 10, + "is_action": true + }, + { + "text": "I understand the lesson.", + "weight": 8 + } + ] + }, + { + "id": "discipline.gratuitous.low_resentment", + "conditions": { + "resentment_max": 30 + }, + "variants": [ + { + "text": "What was that for? I want to understand.", + "weight": 10 + }, + { + "text": "*confused but curious*", + "weight": 10, + "is_action": true + }, + { + "text": "Did I do something wrong?", + "weight": 8 + } + ] + }, + { + "id": "discipline.gratuitous.high_resentment", + "conditions": { + "resentment_min": 61 + }, + "variants": [ + { + "text": "*curiosity replaced by wariness*", + "weight": 10, + "is_action": true + }, + { + "text": "I'm starting to understand what you really are.", + "weight": 10 + }, + { + "text": "*studies you with new perspective*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.praise", + "conditions": {}, + "variants": [ + { + "text": "Oh? That pleased you? Interesting.", + "weight": 10 + }, + { + "text": "*tilts head* Why exactly was that good?", + "weight": 10, + "is_action": true + }, + { + "text": "I see... positive reinforcement.", + "weight": 8 + }, + { + "text": "*looks intrigued* I'll remember that pattern.", + "weight": 8, + "is_action": true + }, + { + "text": "Thank you! I'm learning more about you every day.", + "weight": 6 + } + ] + }, + { + "id": "discipline.scold", + "conditions": {}, + "variants": [ + { + "text": "I didn't realize that was forbidden...", + "weight": 10 + }, + { + "text": "*looks confused* But why? Explain it to me.", + "weight": 10, + "is_action": true + }, + { + "text": "A negative reaction... noted.", + "weight": 8 + }, + { + "text": "*frowns in thought* I need to adjust my calculations.", + "weight": 8, + "is_action": true + }, + { + "text": "Sorry. Your rules are fascinatingly complex.", + "weight": 6 + } + ] + }, + { + "id": "discipline.threaten", + "conditions": {}, + "variants": [ + { + "text": "*eyes widen* That seems... excessive.", + "weight": 10, + "is_action": true + }, + { + "text": "Okay! I understand the stimulus-response link!", + "weight": 10 + }, + { + "text": "*backs away thoughtfully* Fear as a motivator... effective.", + "weight": 8, + "is_action": true + }, + { + "text": "I predict pain if I continue? I'll stop.", + "weight": 8 + }, + { + "text": "*nervous curiosity* What would that even do to me?", + "weight": 6, + "is_action": true + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/curious/fear.json b/src/main/resources/data/tiedup/dialogue/en_us/curious/fear.json new file mode 100644 index 0000000..317e491 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/curious/fear.json @@ -0,0 +1,49 @@ +{ + "category": "fear", + "entries": [ + { + "id": "fear.nervous", + "conditions": {}, + "variants": [ + { "text": "*avoids eye contact*", "weight": 10, "is_action": true }, + { "text": "*speaks quietly*", "weight": 10, "is_action": true }, + { "text": "I-I'll behave...", "weight": 8 }, + { "text": "*fidgets nervously*", "weight": 8, "is_action": true }, + { "text": "Y-yes...?", "weight": 6 } + ] + }, + { + "id": "fear.afraid", + "conditions": {}, + "variants": [ + { "text": "*trembles visibly*", "weight": 10, "is_action": true }, + { "text": "P-please don't hurt me...", "weight": 10 }, + { "text": "*backs away slightly*", "weight": 8, "is_action": true }, + { "text": "I-I'm sorry... whatever I did...", "weight": 8 }, + { "text": "*can't meet your gaze*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.terrified", + "conditions": {}, + "variants": [ + { "text": "*recoils in panic*", "weight": 10, "is_action": true }, + { "text": "S-stay away!", "weight": 10 }, + { "text": "*breathing rapidly*", "weight": 8, "is_action": true }, + { "text": "No no no no...", "weight": 8 }, + { "text": "*frozen in terror*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.traumatized", + "conditions": {}, + "variants": [ + { "text": "*collapses, sobbing*", "weight": 10, "is_action": true }, + { "text": "*completely breaks down*", "weight": 10, "is_action": true }, + { "text": "I'll do anything... just please...", "weight": 8 }, + { "text": "*paralyzed with fear*", "weight": 8, "is_action": true }, + { "text": "*whimpers uncontrollably*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/curious/home.json b/src/main/resources/data/tiedup/dialogue/en_us/curious/home.json new file mode 100644 index 0000000..a10f90b --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/curious/home.json @@ -0,0 +1,41 @@ +{ + "category": "home", + "entries": [ + { + "id": "home.assigned.pet_bed", + "conditions": {}, + "variants": [ + { "text": "Interesting... like a pet's bed.", "weight": 10 }, + { "text": "*examines the pet bed*", "weight": 10, "is_action": true }, + { "text": "What's it made of?", "weight": 8 } + ] + }, + { + "id": "home.assigned.bed", + "conditions": {}, + "variants": [ + { "text": "Ooh, comfortable! What kind of mattress is this?", "weight": 10 }, + { "text": "*tests the bed curiously*", "weight": 10, "is_action": true }, + { "text": "Nice craftsmanship.", "weight": 8 } + ] + }, + { + "id": "home.destroyed.pet_bed", + "conditions": {}, + "variants": [ + { "text": "Why did you do that? Was it in the way?", "weight": 10 }, + { "text": "*studies the destruction*", "weight": 10, "is_action": true }, + { "text": "Interesting way to make a point.", "weight": 8 } + ] + }, + { + "id": "home.return.content", + "conditions": {}, + "variants": [ + { "text": "*observes surroundings from spot*", "weight": 10, "is_action": true }, + { "text": "Back to my observation point.", "weight": 10 }, + { "text": "*settles in to watch*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/curious/idle.json b/src/main/resources/data/tiedup/dialogue/en_us/curious/idle.json new file mode 100644 index 0000000..9051852 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/curious/idle.json @@ -0,0 +1,51 @@ +{ + "category": "idle", + "personality": "CURIOUS", + "description": "Inquisitive, always questioning idle behaviors", + "entries": [ + { + "id": "idle.free", + "variants": [ + { "text": "*examines everything nearby*", "weight": 10, "is_action": true }, + { "text": "What's that over there?", "weight": 10 }, + { "text": "*asks questions about surroundings*", "weight": 8, "is_action": true }, + { "text": "*touches things to learn about them*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.greeting", + "variants": [ + { "text": "Hello! Who are you? What do you do?", "weight": 10 }, + { "text": "*studies newcomer with interest*", "weight": 10, "is_action": true }, + { "text": "Where do you come from?", "weight": 8 }, + { "text": "*tilts head questioningly*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.goodbye", + "variants": [ + { "text": "Bye! Will you tell me more next time?", "weight": 10 }, + { "text": "Wait! One more question!", "weight": 10 }, + { "text": "*waves while thinking of more questions*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.captive", + "variants": [ + { "text": "*studies bonds with fascination*", "weight": 10, "is_action": true }, + { "text": "How long have you been doing this?", "weight": 10 }, + { "text": "*observes captors' behavior*", "weight": 8, "is_action": true }, + { "text": "*mentally notes everything*", "weight": 8, "is_action": true } + ] + }, + { + "id": "personality.hint", + "variants": [ + { "text": "*eyes bright with curiosity*", "weight": 10, "is_action": true }, + { "text": "*constantly looking around*", "weight": 10, "is_action": true }, + { "text": "*seems endlessly inquisitive*", "weight": 8, "is_action": true }, + { "text": "*tilts head frequently*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/curious/leash.json b/src/main/resources/data/tiedup/dialogue/en_us/curious/leash.json new file mode 100644 index 0000000..af69725 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/curious/leash.json @@ -0,0 +1,42 @@ +{ + "category": "leash", + "entries": [ + { + "id": "leash.attached", + "conditions": {}, + "variants": [ + { "text": "Oh? A leash? Interesting...", "weight": 10 }, + { "text": "*examines the leash curiously*", "weight": 10, "is_action": true }, + { "text": "I've never been leashed before. What's it like?", "weight": 8 }, + { "text": "*tilts head inquisitively*", "weight": 6, "is_action": true } + ] + }, + { + "id": "leash.removed", + "conditions": {}, + "variants": [ + { "text": "That was... an experience.", "weight": 10 }, + { "text": "*studies where leash was attached*", "weight": 10, "is_action": true }, + { "text": "Why take it off now?", "weight": 8 } + ] + }, + { + "id": "leash.walking.content", + "conditions": {}, + "variants": [ + { "text": "*looks around while walking*", "weight": 10, "is_action": true }, + { "text": "Where are we going?", "weight": 10 }, + { "text": "*takes in the surroundings*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.pulled", + "conditions": {}, + "variants": [ + { "text": "Oh! What's over there?", "weight": 10 }, + { "text": "*hurries to see what caught your attention*", "weight": 10, "is_action": true }, + { "text": "*follows the pull eagerly*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/curious/mood.json b/src/main/resources/data/tiedup/dialogue/en_us/curious/mood.json new file mode 100644 index 0000000..8a84d4d --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/curious/mood.json @@ -0,0 +1,44 @@ +{ + "category": "mood", + "personality": "CURIOUS", + "description": "Inquisitive, analytical mood expressions", + "entries": [ + { + "id": "mood.happy", + "conditions": { "mood_min": 70 }, + "variants": [ + { "text": "*beams happily* Why does joy feel so light?", "weight": 10, "is_action": true }, + { "text": "I feel great! What causes happiness?", "weight": 10 }, + { "text": "*enjoys the moment while analyzing it*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.neutral", + "conditions": { "mood_min": 40, "mood_max": 69 }, + "variants": [ + { "text": "*observes everything with interest*", "weight": 10, "is_action": true }, + { "text": "*lost in thought*", "weight": 10, "is_action": true }, + { "text": "I wonder what will happen next...", "weight": 8 } + ] + }, + { + "id": "mood.sad", + "conditions": { "mood_min": 10, "mood_max": 39 }, + "variants": [ + { "text": "*examines own sadness* Interesting...", "weight": 10, "is_action": true }, + { "text": "Why does sadness feel heavy in the chest?", "weight": 10 }, + { "text": "*tries to understand melancholy*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.miserable", + "conditions": { "mood_max": 9 }, + "variants": [ + { "text": "*analyzes misery through tears*", "weight": 10, "is_action": true }, + { "text": "So this is what despair feels like...", "weight": 10 }, + { "text": "*fascinated even by own suffering*", "weight": 8, "is_action": true }, + { "text": "I never knew it could hurt this much.", "weight": 6 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/curious/needs.json b/src/main/resources/data/tiedup/dialogue/en_us/curious/needs.json new file mode 100644 index 0000000..24e033a --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/curious/needs.json @@ -0,0 +1,47 @@ +{ + "category": "needs", + "personality": "CURIOUS", + "description": "Inquisitive expressions of needs", + "entries": [ + { + "id": "needs.hungry", + "variants": [ + { "text": "What food do you have? I'm curious.", "weight": 10 }, + { "text": "*stomach growls* What's that sound mean exactly?", "weight": 10, "is_action": true }, + { "text": "I wonder what hunger feels like at its peak...", "weight": 8 } + ] + }, + { + "id": "needs.tired", + "variants": [ + { "text": "*yawns* Why do we yawn anyway?", "weight": 10, "is_action": true }, + { "text": "I'm sleepy. How long can someone stay awake?", "weight": 10 }, + { "text": "*studies own exhaustion*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.uncomfortable", + "variants": [ + { "text": "This is uncomfortable. Interesting sensation.", "weight": 10 }, + { "text": "*analyzes the source of discomfort*", "weight": 10, "is_action": true }, + { "text": "Why does this position hurt?", "weight": 8 } + ] + }, + { + "id": "needs.dignity_low", + "variants": [ + { "text": "This is embarrassing. Why does shame feel like this?", "weight": 10 }, + { "text": "*explores feelings of humiliation*", "weight": 10, "is_action": true }, + { "text": "What a strange emotional experience...", "weight": 8 } + ] + }, + { + "id": "needs.satisfied", + "variants": [ + { "text": "That's better! Contentment is nice.", "weight": 10 }, + { "text": "*appreciates feeling satisfied*", "weight": 10, "is_action": true }, + { "text": "Thank you! Now I'm curious what's next.", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/curious/personality.json b/src/main/resources/data/tiedup/dialogue/en_us/curious/personality.json new file mode 100644 index 0000000..e771f87 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/curious/personality.json @@ -0,0 +1,17 @@ +{ + "category": "personality", + "entries": [ + { + "id": "personality.hint", + "conditions": {}, + "variants": [ + { "text": "*looks around with interest*", "weight": 10, "is_action": true }, + { "text": "*examines everything carefully*", "weight": 10, "is_action": true }, + { "text": "*seems fascinated by new things*", "weight": 8, "is_action": true }, + { "text": "*asks questions with their eyes*", "weight": 8, "is_action": true }, + { "text": "*tilts head inquisitively*", "weight": 6, "is_action": true }, + { "text": "*studies their surroundings*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/curious/reaction.json b/src/main/resources/data/tiedup/dialogue/en_us/curious/reaction.json new file mode 100644 index 0000000..7a7b273 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/curious/reaction.json @@ -0,0 +1,60 @@ +{ + "category": "reaction", + "entries": [ + { + "id": "reaction.approach.stranger", + "conditions": {}, + "variants": [ + { "text": "*tilts head curiously*", "weight": 10, "is_action": true }, + { "text": "Oh! Who are you?", "weight": 10 }, + { "text": "*studies you with interest*", "weight": 8, "is_action": true }, + { "text": "You're new! Where are you from?", "weight": 8 }, + { "text": "*leans in to look closer*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.master", + "conditions": {}, + "variants": [ + { "text": "*perks up excitedly*", "weight": 10, "is_action": true }, + { "text": "Master! What's happening today?", "weight": 10 }, + { "text": "*bounces eagerly*", "weight": 8, "is_action": true }, + { "text": "Do you have something for me to do?", "weight": 8 }, + { "text": "*looks expectantly*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.beloved", + "conditions": {}, + "variants": [ + { "text": "*beams with excitement*", "weight": 10, "is_action": true }, + { "text": "You're here! Tell me everything!", "weight": 10 }, + { "text": "*rushes over*", "weight": 8, "is_action": true }, + { "text": "What have you been up to?", "weight": 8 }, + { "text": "*eyes sparkle with curiosity*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.captor", + "conditions": {}, + "variants": [ + { "text": "*looks puzzled*", "weight": 10, "is_action": true }, + { "text": "Why are you doing this?", "weight": 10 }, + { "text": "*tries to understand*", "weight": 8, "is_action": true }, + { "text": "What's your goal here?", "weight": 8 }, + { "text": "*watches carefully*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.enemy", + "conditions": {}, + "variants": [ + { "text": "*studies you warily*", "weight": 10, "is_action": true }, + { "text": "What do you want? Why are you here?", "weight": 10 }, + { "text": "*questions with eyes*", "weight": 8, "is_action": true }, + { "text": "You don't look friendly...", "weight": 8 }, + { "text": "*observes cautiously*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/curious/resentment.json b/src/main/resources/data/tiedup/dialogue/en_us/curious/resentment.json new file mode 100644 index 0000000..ba1cfb1 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/curious/resentment.json @@ -0,0 +1,32 @@ +{ + "category": "resentment", + "entries": [ + { + "id": "resentment.none", + "conditions": { "resentment_max": 10 }, + "variants": [ + { "text": "*eager to learn more*", "weight": 10, "is_action": true }, + { "text": "What else can you teach me?", "weight": 10 }, + { "text": "*curious and engaged*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.building", + "conditions": { "resentment_min": 31, "resentment_max": 50 }, + "variants": [ + { "text": "*curiosity tinged with caution*", "weight": 10, "is_action": true }, + { "text": "I'm learning... things.", "weight": 10 }, + { "text": "*watches more carefully*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.high", + "conditions": { "resentment_min": 71 }, + "variants": [ + { "text": "*studies you with new understanding*", "weight": 10, "is_action": true }, + { "text": "I see what you really are now.", "weight": 10 }, + { "text": "*curiosity turned analytical*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/curious/struggle.json b/src/main/resources/data/tiedup/dialogue/en_us/curious/struggle.json new file mode 100644 index 0000000..778e9f5 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/curious/struggle.json @@ -0,0 +1,48 @@ +{ + "category": "struggle", + "personality": "CURIOUS", + "description": "Experimental, investigative escape attempts", + "entries": [ + { + "id": "struggle.attempt", + "variants": [ + { "text": "*methodically tests each restraint*", "weight": 10, "is_action": true }, + { "text": "I wonder if this will work...", "weight": 10 }, + { "text": "*tries different approaches scientifically*", "weight": 8, "is_action": true }, + { "text": "*experiments with movements*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.success", + "variants": [ + { "text": "Oh! It worked! Fascinating!", "weight": 10 }, + { "text": "*examines weakness that allowed escape*", "weight": 10, "is_action": true }, + { "text": "So THAT'S how you escape these...", "weight": 8 } + ] + }, + { + "id": "struggle.failure", + "variants": [ + { "text": "Hmm, that didn't work. Let me try...", "weight": 10 }, + { "text": "*analyzes what went wrong*", "weight": 10, "is_action": true }, + { "text": "Interesting. They're stronger than expected.", "weight": 8 } + ] + }, + { + "id": "struggle.warned", + "variants": [ + { "text": "But I'm just testing them!", "weight": 10 }, + { "text": "*considers warning thoughtfully*", "weight": 10, "is_action": true }, + { "text": "What happens if I don't stop?", "weight": 8 } + ] + }, + { + "id": "struggle.exhausted", + "variants": [ + { "text": "*rests while contemplating*", "weight": 10, "is_action": true }, + { "text": "I need to think about this more...", "weight": 10 }, + { "text": "*reviews escape attempts mentally*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/actions.json b/src/main/resources/data/tiedup/dialogue/en_us/default/actions.json new file mode 100644 index 0000000..be3da77 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/actions.json @@ -0,0 +1,574 @@ +{ + "category": "actions", + "entries": [ + { + "id": "action.whip", + "conditions": {}, + "variants": [ + { + "text": "Aah! P-please stop!", + "weight": 10 + }, + { + "text": "*cries out in pain*", + "weight": 8, + "is_action": true + }, + { + "text": "I-I'll behave, I promise!", + "weight": 10 + }, + { + "text": "No more, please!", + "weight": 8 + } + ] + }, + { + "id": "action.whip.broken", + "conditions": {}, + "variants": [ + { + "text": "...", + "weight": 10 + }, + { + "text": "*accepts silently*", + "weight": 10, + "is_action": true + }, + { + "text": "*doesn't react*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.paddle", + "conditions": {}, + "variants": [ + { + "text": "Nnh..!", + "weight": 10 + }, + { + "text": "I-I understand...", + "weight": 10 + }, + { + "text": "*winces at the impact*", + "weight": 8, + "is_action": true + }, + { + "text": "I won't do it again!", + "weight": 8 + } + ] + }, + { + "id": "action.praise", + "conditions": {}, + "variants": [ + { + "text": "*blushes* Th-thank you...", + "weight": 10 + }, + { + "text": "You... you mean it?", + "weight": 10 + }, + { + "text": "*looks pleased*", + "weight": 8, + "is_action": true + }, + { + "text": "I... I'll try harder.", + "weight": 8 + } + ] + }, + { + "id": "action.praise.devoted", + "conditions": {}, + "variants": [ + { + "text": "I live to please you, Master!", + "weight": 10 + }, + { + "text": "*beams with happiness*", + "weight": 10, + "is_action": true + }, + { + "text": "Your words mean everything to me!", + "weight": 8 + } + ] + }, + { + "id": "action.feed", + "conditions": {}, + "variants": [ + { + "text": "Thank you... I was so hungry...", + "weight": 10 + }, + { + "text": "*eats gratefully*", + "weight": 10, + "is_action": true + }, + { + "text": "Mmm... that's good...", + "weight": 8 + }, + { + "text": "You're... kind.", + "weight": 6 + } + ] + }, + { + "id": "action.feed.starving", + "conditions": {}, + "variants": [ + { + "text": "*devours food eagerly*", + "weight": 10, + "is_action": true + }, + { + "text": "Finally... food...", + "weight": 10 + }, + { + "text": "Thank you! Thank you!", + "weight": 10 + }, + { + "text": "*can barely speak while eating*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.job_assigned", + "conditions": {}, + "variants": [ + { + "text": "Yes, I understand.", + "weight": 10 + }, + { + "text": "I'll do my best.", + "weight": 10 + }, + { + "text": "*nods and gets to work*", + "weight": 8, + "is_action": true + }, + { + "text": "As you wish.", + "weight": 8 + } + ] + }, + { + "id": "action.job_complete", + "conditions": {}, + "variants": [ + { + "text": "I finished the task.", + "weight": 10 + }, + { + "text": "Is this acceptable?", + "weight": 10 + }, + { + "text": "*awaits your approval*", + "weight": 8, + "is_action": true + }, + { + "text": "It's done.", + "weight": 8 + } + ] + }, + { + "id": "action.job_failed", + "conditions": {}, + "variants": [ + { + "text": "I-I'm sorry, I couldn't...", + "weight": 10 + }, + { + "text": "Please, give me another chance!", + "weight": 10 + }, + { + "text": "*hangs head in shame*", + "weight": 8, + "is_action": true + }, + { + "text": "Forgive me...", + "weight": 8 + } + ] + }, + { + "id": "action.force_command", + "conditions": {}, + "variants": [ + { + "text": "F-fine! I'll do it!", + "weight": 10 + }, + { + "text": "*glares but obeys*", + "weight": 10, + "is_action": true + }, + { + "text": "You leave me no choice...", + "weight": 8 + }, + { + "text": "*reluctantly complies*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.collar_on", + "conditions": {}, + "variants": [ + { + "text": "No... please, not this...", + "weight": 10 + }, + { + "text": "*trembles as collar locks*", + "weight": 10, + "is_action": true + }, + { + "text": "What are you doing?!", + "weight": 8 + }, + { + "text": "*feels the weight of the collar*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.collar_off", + "conditions": {}, + "variants": [ + { + "text": "I... I'm free?", + "weight": 10 + }, + { + "text": "*touches neck in disbelief*", + "weight": 10, + "is_action": true + }, + { + "text": "You're letting me go?", + "weight": 8 + }, + { + "text": "*looks confused*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.collar_off.devoted", + "conditions": {}, + "variants": [ + { + "text": "W-wait, why are you removing it?!", + "weight": 10 + }, + { + "text": "Did I do something wrong, Master?!", + "weight": 10 + }, + { + "text": "Please, don't abandon me!", + "weight": 8 + }, + { + "text": "*looks panicked*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.stop_struggle", + "conditions": {}, + "variants": [ + { + "text": "*stops struggling*", + "weight": 10, + "is_action": true + }, + { + "text": "F-fine... I'll stop...", + "weight": 10 + }, + { + "text": "I can't win anyway...", + "weight": 8 + }, + { + "text": "*goes limp in defeat*", + "weight": 6, + "is_action": true + } + ] + }, + { + "id": "action.stop_struggle.forced", + "conditions": {}, + "variants": [ + { + "text": "*is forced to stop*", + "weight": 10, + "is_action": true + }, + { + "text": "Let go of me!", + "weight": 10 + }, + { + "text": "*still tries to move*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.intimidate", + "conditions": { + "resentment_max": 50 + }, + "variants": [ + { + "text": "*shrinks back in fear*", + "weight": 10, + "is_action": true + }, + { + "text": "I-I'm sorry! I'll behave!", + "weight": 10 + }, + { + "text": "Please don't hurt me...", + "weight": 8 + }, + { + "text": "*trembles*", + "weight": 6, + "is_action": true + } + ] + }, + { + "id": "action.intimidate.defiant", + "conditions": { + "resentment_min": 51 + }, + "variants": [ + { + "text": "*glares but backs down*", + "weight": 10, + "is_action": true + }, + { + "text": "You don't scare me...", + "weight": 10 + }, + { + "text": "*reluctantly submits*", + "weight": 8, + "is_action": true + }, + { + "text": "Fine. For now.", + "weight": 6 + } + ] + }, + { + "id": "action.intimidate.broken", + "conditions": {}, + "variants": [ + { + "text": "*cowers immediately*", + "weight": 10, + "is_action": true + }, + { + "text": "*doesn't even try to resist*", + "weight": 10, + "is_action": true + }, + { + "text": "Y-yes...", + "weight": 8 + } + ] + }, + { + "id": "action.hand_discipline", + "conditions": {}, + "variants": [ + { + "text": "*yelps*", + "weight": 10, + "is_action": true + }, + { + "text": "Ow!", + "weight": 10 + }, + { + "text": "*rubs affected area*", + "weight": 8, + "is_action": true + }, + { + "text": "I'm sorry...", + "weight": 6 + } + ] + }, + { + "id": "action.shock", + "conditions": {}, + "variants": [ + { + "text": "*screams in pain*", + "weight": 10, + "is_action": true + }, + { + "text": "AHHH!", + "weight": 10 + }, + { + "text": "*convulses*", + "weight": 8, + "is_action": true + }, + { + "text": "P-please... no more...", + "weight": 6 + } + ] + }, + { + "id": "action.pet", + "conditions": {}, + "variants": [ + { + "text": "*leans into the touch*", + "weight": 10, + "is_action": true + }, + { + "text": "That... that feels nice...", + "weight": 10 + }, + { + "text": "*closes eyes contentedly*", + "weight": 8, + "is_action": true + }, + { + "text": "Mmm...", + "weight": 6 + } + ] + }, + { + "id": "action.pet.resistant", + "conditions": { + "resentment_min": 50 + }, + "variants": [ + { + "text": "*pulls away*", + "weight": 10, + "is_action": true + }, + { + "text": "Don't touch me!", + "weight": 10 + }, + { + "text": "*glares*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.scold", + "conditions": {}, + "variants": [ + { + "text": "*flinches* I'm sorry!", + "weight": 10, + "is_action": true + }, + { + "text": "I won't do it again!", + "weight": 10 + }, + { + "text": "*hangs head* Yes... I know...", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.threaten", + "conditions": {}, + "variants": [ + { + "text": "*pales* O-okay!", + "weight": 10, + "is_action": true + }, + { + "text": "Please don't hurt me!", + "weight": 10 + }, + { + "text": "*trembles* I'm listening!", + "weight": 8, + "is_action": true + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/capture.json b/src/main/resources/data/tiedup/dialogue/en_us/default/capture.json new file mode 100644 index 0000000..f9a1854 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/capture.json @@ -0,0 +1,175 @@ +{ + "category": "capture", + "entries": [ + { + "id": "capture.start", + "conditions": {}, + "variants": [ + { "text": "You're coming with me!", "weight": 10 }, + { "text": "Don't try to run!", "weight": 10 }, + { "text": "I've been looking for you...", "weight": 8 }, + { "text": "There you are!", "weight": 8 }, + { "text": "You won't escape me!", "weight": 8 }, + { "text": "Come here, little one.", "weight": 6 }, + { "text": "Perfect timing...", "weight": 6 }, + { "text": "I found my next target.", "weight": 6 } + ] + }, + { + "id": "capture.approaching", + "conditions": {}, + "variants": [ + { "text": "Don't move!", "weight": 10 }, + { "text": "Stay right there!", "weight": 10 }, + { "text": "Running won't help you.", "weight": 8 }, + { "text": "I'm getting closer...", "weight": 8 }, + { "text": "There's nowhere to hide.", "weight": 8 } + ] + }, + { + "id": "capture.tying", + "conditions": {}, + "variants": [ + { "text": "Stop struggling!", "weight": 10 }, + { "text": "Hold still!", "weight": 10 }, + { "text": "This will only take a moment...", "weight": 8 }, + { "text": "Don't make this harder than it needs to be.", "weight": 8 }, + { "text": "Resistance is futile.", "weight": 6 }, + { "text": "Stay still or I'll tie them tighter!", "weight": 6 }, + { "text": "Almost done...", "weight": 6 } + ] + }, + { + "id": "capture.tied", + "conditions": {}, + "variants": [ + { "text": "tied you up, you can't move anymore!", "weight": 10, "is_action": true }, + { "text": "finished tying you up!", "weight": 10, "is_action": true }, + { "text": "has bound your hands and legs!", "weight": 8, "is_action": true }, + { "text": "secured you with tight knots!", "weight": 8, "is_action": true }, + { "text": "made sure you can't escape!", "weight": 6, "is_action": true } + ] + }, + { + "id": "capture.gagging", + "conditions": {}, + "variants": [ + { "text": "Now let's keep you quiet...", "weight": 10 }, + { "text": "Time to silence you.", "weight": 10 }, + { "text": "Open wide...", "weight": 8 }, + { "text": "This will keep you quiet.", "weight": 8 }, + { "text": "No more screaming for help.", "weight": 6 } + ] + }, + { + "id": "capture.gagged", + "conditions": {}, + "variants": [ + { "text": "gagged you!", "weight": 10, "is_action": true }, + { "text": "silenced you!", "weight": 10, "is_action": true }, + { "text": "shut you up!", "weight": 8, "is_action": true }, + { "text": "made sure you can't call for help!", "weight": 8, "is_action": true }, + { "text": "muffled your voice!", "weight": 6, "is_action": true } + ] + }, + { + "id": "capture.enslaved", + "conditions": {}, + "variants": [ + { "text": "You're mine now.", "weight": 10 }, + { "text": "You belong to me.", "weight": 10 }, + { "text": "Welcome to your new life.", "weight": 8 }, + { "text": "You won't be going anywhere.", "weight": 8 }, + { "text": "I own you now.", "weight": 6 }, + { "text": "Such a good catch...", "weight": 6 }, + { "text": "Finally, you're all mine.", "weight": 6 } + ] + }, + { + "id": "capture.panic", + "conditions": {}, + "variants": [ + { "text": "Help! Someone help me!", "weight": 10 }, + { "text": "No! Stay away!", "weight": 10 }, + { "text": "Please, let me go!", "weight": 8 }, + { "text": "Somebody save me!", "weight": 8 }, + { "text": "I don't want this!", "weight": 6 } + ] + }, + { + "id": "capture.flee", + "conditions": {}, + "variants": [ + { "text": "Stay away from me!", "weight": 10 }, + { "text": "Leave me alone!", "weight": 10 }, + { "text": "I need to get out of here!", "weight": 8 }, + { "text": "Please don't hurt me!", "weight": 8 }, + { "text": "I have to run!", "weight": 6 } + ] + }, + { + "id": "capture.captured", + "conditions": {}, + "variants": [ + { "text": "No... please...", "weight": 10 }, + { "text": "Someone help...", "weight": 10 }, + { "text": "I can't escape...", "weight": 8 }, + { "text": "This can't be happening...", "weight": 8 }, + { "text": "Why me...", "weight": 6 } + ] + }, + { + "id": "capture.freed", + "conditions": {}, + "variants": [ + { "text": "Thank you so much!", "weight": 10 }, + { "text": "I'm free! Finally!", "weight": 10 }, + { "text": "I can't thank you enough!", "weight": 8 }, + { "text": "You saved me!", "weight": 8 }, + { "text": "I thought I'd never escape...", "weight": 6 } + ] + }, + { + "id": "capture.call_for_help", + "conditions": {}, + "variants": [ + { "text": "{player}! Help me please!", "weight": 10 }, + { "text": "{player}! Please save me!", "weight": 10 }, + { "text": "{player}! I'm being kidnapped!", "weight": 8 }, + { "text": "{player}! Help!", "weight": 8 }, + { "text": "{player}! Please, I need help!", "weight": 6 }, + { "text": "{player}! Don't let them take me!", "weight": 6 }, + { "text": "{player}! They're taking me away!", "weight": 6 }, + { "text": "{player}! Please help me escape!", "weight": 6 } + ] + }, + { + "id": "capture.chase", + "conditions": {}, + "variants": [ + { "text": "You won't get away!", "weight": 10 }, + { "text": "Get back here!", "weight": 10 }, + { "text": "I'll catch you again!", "weight": 8 }, + { "text": "Running won't help you!", "weight": 8 }, + { "text": "You're only making this worse!", "weight": 8 }, + { "text": "Come back, little one!", "weight": 6 }, + { "text": "Did you really think you could escape?", "weight": 6 }, + { "text": "I always catch my prey!", "weight": 6 } + ] + }, + { + "id": "capture.escape", + "conditions": {}, + "variants": [ + { "text": "You think you can escape?!", "weight": 10 }, + { "text": "Where do you think you're going?!", "weight": 10 }, + { "text": "Get back here!", "weight": 8 }, + { "text": "I won't let you get away!", "weight": 8 }, + { "text": "You'll pay for that!", "weight": 6 }, + { "text": "How dare you try to escape!", "weight": 6 }, + { "text": "You're only making this worse for yourself!", "weight": 6 }, + { "text": "Bad move...", "weight": 6 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/combat.json b/src/main/resources/data/tiedup/dialogue/en_us/default/combat.json new file mode 100644 index 0000000..8e86d35 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/combat.json @@ -0,0 +1,59 @@ +{ + "category": "combat", + "entries": [ + { + "id": "combat.attacked_response", + "conditions": {}, + "variants": [ + { "text": "You'll regret that!", "weight": 10 }, + { "text": "Bad idea!", "weight": 10 }, + { "text": "Now you've done it!", "weight": 8 }, + { "text": "You're going to pay for that!", "weight": 8 }, + { "text": "Big mistake!", "weight": 6 } + ] + }, + { + "id": "combat.attack_slave", + "conditions": {}, + "variants": [ + { "text": "Don't you ever do that again!", "weight": 10 }, + { "text": "How dare you!", "weight": 10 }, + { "text": "That was a big mistake!", "weight": 8 }, + { "text": "You just made things worse for yourself.", "weight": 8 }, + { "text": "Now you're really in trouble!", "weight": 6 } + ] + }, + { + "id": "combat.threat", + "conditions": {}, + "variants": [ + { "text": "You don't want to mess with me.", "weight": 10 }, + { "text": "I wouldn't do that if I were you.", "weight": 10 }, + { "text": "Bad idea...", "weight": 8 }, + { "text": "Think carefully about your next move.", "weight": 8 }, + { "text": "Don't test me.", "weight": 6 } + ] + }, + { + "id": "combat.taunt", + "conditions": {}, + "variants": [ + { "text": "Too slow!", "weight": 10 }, + { "text": "Is that all you've got?", "weight": 10 }, + { "text": "Pathetic.", "weight": 8 }, + { "text": "Try harder!", "weight": 8 }, + { "text": "You call that an escape attempt?", "weight": 6 } + ] + }, + { + "id": "combat.victory", + "conditions": {}, + "variants": [ + { "text": "That takes care of that.", "weight": 10 }, + { "text": "Too easy.", "weight": 10 }, + { "text": "*dusts off hands*", "weight": 8, "is_action": true }, + { "text": "Next?", "weight": 6 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/commands.json b/src/main/resources/data/tiedup/dialogue/en_us/default/commands.json new file mode 100644 index 0000000..a91c0d8 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/commands.json @@ -0,0 +1,736 @@ +{ + "category": "commands", + "entries": [ + { + "id": "command.follow.accept", + "conditions": {}, + "variants": [ + { + "text": "Yes, Master...", + "weight": 10 + }, + { + "text": "As you wish.", + "weight": 10 + }, + { + "text": "I'll follow you.", + "weight": 10 + }, + { + "text": "Right away.", + "weight": 8 + }, + { + "text": "Of course.", + "weight": 8 + }, + { + "text": "*nods and follows*", + "weight": 6, + "is_action": true + } + ] + }, + { + "id": "command.follow.refuse", + "conditions": {}, + "variants": [ + { + "text": "No! I won't follow you!", + "weight": 10 + }, + { + "text": "You can't make me!", + "weight": 10 + }, + { + "text": "I refuse to go anywhere with you!", + "weight": 8 + }, + { + "text": "Never!", + "weight": 8 + }, + { + "text": "*stands firm, refusing to move*", + "weight": 6, + "is_action": true + } + ] + }, + { + "id": "command.follow.hesitate", + "conditions": {}, + "variants": [ + { + "text": "I... I guess I have no choice...", + "weight": 10 + }, + { + "text": "Must I...? Fine...", + "weight": 10 + }, + { + "text": "*hesitantly starts following*", + "weight": 8, + "is_action": true + }, + { + "text": "If I must...", + "weight": 8 + } + ] + }, + { + "id": "command.stay.accept", + "conditions": {}, + "variants": [ + { + "text": "Yes, I'll stay here.", + "weight": 10 + }, + { + "text": "As you wish.", + "weight": 10 + }, + { + "text": "I won't move.", + "weight": 10 + }, + { + "text": "*sits down obediently*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.stay.refuse", + "conditions": {}, + "variants": [ + { + "text": "No! I won't stay here!", + "weight": 10 + }, + { + "text": "You can't leave me here!", + "weight": 10 + }, + { + "text": "I refuse!", + "weight": 8 + }, + { + "text": "*tries to follow anyway*", + "weight": 6, + "is_action": true + } + ] + }, + { + "id": "command.stay.hesitate", + "conditions": {}, + "variants": [ + { + "text": "But... okay, I'll wait...", + "weight": 10 + }, + { + "text": "Please come back soon...", + "weight": 10 + }, + { + "text": "*reluctantly stays in place*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.come.accept", + "conditions": {}, + "variants": [ + { + "text": "Coming!", + "weight": 10 + }, + { + "text": "Yes, right away!", + "weight": 10 + }, + { + "text": "*walks over quickly*", + "weight": 8, + "is_action": true + }, + { + "text": "I'm here.", + "weight": 8 + } + ] + }, + { + "id": "command.come.refuse", + "conditions": {}, + "variants": [ + { + "text": "No! Stay away from me!", + "weight": 10 + }, + { + "text": "I won't come to you!", + "weight": 10 + }, + { + "text": "*backs away defiantly*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.sit.accept", + "conditions": {}, + "variants": [ + { + "text": "Yes, Master.", + "weight": 10 + }, + { + "text": "*sits down gracefully*", + "weight": 10, + "is_action": true + }, + { + "text": "As you wish.", + "weight": 8 + } + ] + }, + { + "id": "command.sit.refuse", + "conditions": {}, + "variants": [ + { + "text": "I won't sit like some pet!", + "weight": 10 + }, + { + "text": "No! You can't humiliate me like this!", + "weight": 10 + }, + { + "text": "*stands defiantly*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.kneel.accept", + "conditions": {}, + "variants": [ + { + "text": "Yes, Master...", + "weight": 10 + }, + { + "text": "*kneels obediently*", + "weight": 10, + "is_action": true + }, + { + "text": "As you command.", + "weight": 8 + } + ] + }, + { + "id": "command.kneel.refuse", + "conditions": {}, + "variants": [ + { + "text": "I won't kneel before you!", + "weight": 10 + }, + { + "text": "Never! I still have my pride!", + "weight": 10 + }, + { + "text": "*glares defiantly*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.heel.accept", + "conditions": {}, + "variants": [ + { + "text": "Yes, Master.", + "weight": 10 + }, + { + "text": "*moves to walk beside {player}*", + "weight": 10, + "is_action": true + }, + { + "text": "I'll stay close.", + "weight": 8 + } + ] + }, + { + "id": "command.heel.refuse", + "conditions": {}, + "variants": [ + { + "text": "I'm not your pet!", + "weight": 10 + }, + { + "text": "Don't treat me like a dog!", + "weight": 10 + }, + { + "text": "*keeps distance defiantly*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.patrol.accept", + "conditions": {}, + "variants": [ + { + "text": "I'll patrol the area.", + "weight": 10 + }, + { + "text": "Understood. I'll keep watch.", + "weight": 10 + }, + { + "text": "*begins patrolling*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.patrol.refuse", + "conditions": {}, + "variants": [ + { + "text": "Do your own patrolling!", + "weight": 10 + }, + { + "text": "I won't be your guard dog!", + "weight": 10 + } + ] + }, + { + "id": "command.guard.accept", + "conditions": {}, + "variants": [ + { + "text": "I'll guard this spot.", + "weight": 10 + }, + { + "text": "No one will get past me.", + "weight": 10 + }, + { + "text": "*takes up a defensive stance*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.guard.refuse", + "conditions": {}, + "variants": [ + { + "text": "Find someone else to guard for you!", + "weight": 10 + }, + { + "text": "I'm not your soldier!", + "weight": 10 + } + ] + }, + { + "id": "command.fetch.accept", + "conditions": {}, + "variants": [ + { + "text": "I'll get it.", + "weight": 10 + }, + { + "text": "As you wish.", + "weight": 10 + }, + { + "text": "*goes to retrieve the item*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.fetch.refuse", + "conditions": {}, + "variants": [ + { + "text": "Get it yourself!", + "weight": 10 + }, + { + "text": "I'm not your servant!", + "weight": 10 + } + ] + }, + { + "id": "command.defend.accept", + "conditions": {}, + "variants": [ + { + "text": "I'll protect you!", + "weight": 10 + }, + { + "text": "No one will harm you while I'm here.", + "weight": 10 + }, + { + "text": "*moves to protect {player}*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.defend.refuse", + "conditions": {}, + "variants": [ + { + "text": "Defend yourself!", + "weight": 10 + }, + { + "text": "Why would I protect YOU?", + "weight": 10 + } + ] + }, + { + "id": "command.attack.accept", + "conditions": {}, + "variants": [ + { + "text": "Target acquired!", + "weight": 10 + }, + { + "text": "As you command!", + "weight": 10 + }, + { + "text": "*charges at the target*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.attack.refuse", + "conditions": {}, + "variants": [ + { + "text": "I won't fight for you!", + "weight": 10 + }, + { + "text": "Do your own dirty work!", + "weight": 10 + } + ] + }, + { + "id": "command.capture.accept", + "conditions": {}, + "variants": [ + { + "text": "I'll bring them to you.", + "weight": 10 + }, + { + "text": "Consider it done.", + "weight": 10 + }, + { + "text": "*goes to capture the target*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.capture.refuse", + "conditions": {}, + "variants": [ + { + "text": "I won't do that to someone else!", + "weight": 10 + }, + { + "text": "I know how that feels... no!", + "weight": 10 + } + ] + }, + { + "id": "command.reject.not_master", + "conditions": {}, + "variants": [ + { + "text": "You're not my Master.", + "weight": 10 + }, + { + "text": "I don't take orders from you.", + "weight": 10 + }, + { + "text": "Only my Master can command me.", + "weight": 8 + }, + { + "text": "*ignores you*", + "weight": 6, + "is_action": true + }, + { + "text": "Who are you to give me orders?", + "weight": 6 + } + ] + }, + { + "id": "command.accept.resentful", + "conditions": { + "resentment_min": 50 + }, + "variants": [ + { + "text": "*obeys with barely concealed anger*", + "weight": 10, + "is_action": true + }, + { + "text": "...fine.", + "weight": 10 + }, + { + "text": "*glares but complies*", + "weight": 8, + "is_action": true + }, + { + "text": "As you command...", + "weight": 8 + }, + { + "text": "*mutters under breath*", + "weight": 6, + "is_action": true + } + ] + }, + { + "id": "command.hesitate.resentful", + "conditions": { + "resentment_min": 70 + }, + "variants": [ + { + "text": "*hesitates with dark look*", + "weight": 10, + "is_action": true + }, + { + "text": "Why should I...?", + "weight": 10 + }, + { + "text": "*barely suppresses defiance*", + "weight": 8, + "is_action": true + }, + { + "text": "*considers refusing*", + "weight": 6, + "is_action": true + } + ] + }, + { + "id": "command.refuse.resentful", + "conditions": { + "resentment_min": 85 + }, + "variants": [ + { + "text": "No.", + "weight": 10 + }, + { + "text": "*openly defies you*", + "weight": 10, + "is_action": true + }, + { + "text": "Make me.", + "weight": 8 + }, + { + "text": "*stares back with hatred*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.accept.devoted", + "conditions": { + "training_min": "DEVOTED", + "resentment_max": 20 + }, + "variants": [ + { + "text": "Anything for you, Master!", + "weight": 10 + }, + { + "text": "*eagerly obeys*", + "weight": 10, + "is_action": true + }, + { + "text": "Your wish is my command!", + "weight": 8 + }, + { + "text": "*beams with happiness at being given a task*", + "weight": 6, + "is_action": true + } + ] + }, + { + "id": "command.accept.broken", + "conditions": {}, + "variants": [ + { + "text": "...", + "weight": 10 + }, + { + "text": "*obeys mechanically*", + "weight": 10, + "is_action": true + }, + { + "text": "*moves without emotion*", + "weight": 8, + "is_action": true + }, + { + "text": "Yes...", + "weight": 6 + } + ] + }, + { + "id": "command.generic.refuse", + "conditions": {}, + "variants": [ + { + "text": "I'd rather not.", + "weight": 10 + }, + { + "text": "No.", + "weight": 10 + }, + { + "text": "*refuses quietly*", + "weight": 8, + "is_action": true + }, + { + "text": "I don't want to.", + "weight": 6 + } + ] + }, + { + "id": "command.generic.accept", + "conditions": {}, + "variants": [ + { + "text": "Alright.", + "weight": 10 + }, + { + "text": "Yes, I'll do it.", + "weight": 10 + }, + { + "text": "*nods in agreement*", + "weight": 8, + "is_action": true + }, + { + "text": "Okay.", + "weight": 8 + } + ] + }, + { + "id": "command.generic.hesitate", + "conditions": {}, + "variants": [ + { + "text": "I... I guess so...", + "weight": 10 + }, + { + "text": "*hesitates uncertainly*", + "weight": 10, + "is_action": true + }, + { + "text": "Do I have to...?", + "weight": 8 + }, + { + "text": "Well... if you insist...", + "weight": 8 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/conversation.json b/src/main/resources/data/tiedup/dialogue/en_us/default/conversation.json new file mode 100644 index 0000000..ac2c3f6 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/conversation.json @@ -0,0 +1,223 @@ +{ + "category": "conversation", + "entries": [ + { + "id": "conversation.compliment", + "conditions": {}, + "variants": [ + { "text": "Oh... thank you.", "weight": 10 }, + { "text": "*blushes slightly*", "weight": 10, "is_action": true }, + { "text": "That's... kind of you to say.", "weight": 8 }, + { "text": "I... appreciate that.", "weight": 8 }, + { "text": "*looks away, slightly embarrassed* You don't have to say that...", "weight": 7, "is_action": true }, + { "text": "I wasn't expecting that... thank you.", "weight": 7 }, + { "text": "*small smile forms* That's nice of you.", "weight": 6, "is_action": true }, + { "text": "Really? You think so?", "weight": 6 }, + { "text": "That... actually means something.", "weight": 6 }, + { "text": "*nods quietly, processing the words*", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.comfort", + "conditions": { "mood_max": 50 }, + "variants": [ + { "text": "Thank you... I needed that.", "weight": 10 }, + { "text": "*relaxes slightly*", "weight": 10, "is_action": true }, + { "text": "That... helps, actually.", "weight": 8 }, + { "text": "Maybe you're right...", "weight": 8 }, + { "text": "*takes a shaky breath* I'll try to believe that.", "weight": 7, "is_action": true }, + { "text": "I... didn't expect kindness here.", "weight": 7 }, + { "text": "*shoulders drop as tension eases*", "weight": 6, "is_action": true }, + { "text": "It's hard to stay hopeful, but... thank you.", "weight": 6 }, + { "text": "Words like that make things a little more bearable.", "weight": 6 }, + { "text": "*wipes eyes* Sorry... I just... thank you.", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.praise", + "conditions": { "training_min": "HESITANT" }, + "variants": [ + { "text": "*looks down, slightly pleased*", "weight": 10, "is_action": true }, + { "text": "I... tried my best.", "weight": 10 }, + { "text": "Thank you for noticing.", "weight": 8 }, + { "text": "*small nod of acknowledgment*", "weight": 8, "is_action": true }, + { "text": "I'm glad I could do something right.", "weight": 7 }, + { "text": "*allows a small smile*", "weight": 7, "is_action": true }, + { "text": "It feels good to hear that.", "weight": 6 }, + { "text": "I wasn't sure if it was enough...", "weight": 6 }, + { "text": "*straightens up a little* Thank you.", "weight": 6, "is_action": true }, + { "text": "Recognition... it helps more than I expected.", "weight": 6 } + ] + }, + { + "id": "conversation.scold", + "conditions": {}, + "variants": [ + { "text": "*flinches and looks away*", "weight": 10, "is_action": true }, + { "text": "I'm sorry...", "weight": 10 }, + { "text": "I didn't mean to disappoint you.", "weight": 8 }, + { "text": "*lowers head in shame*", "weight": 8, "is_action": true }, + { "text": "*winces* I'll do better...", "weight": 7, "is_action": true }, + { "text": "You're right... I messed up.", "weight": 7 }, + { "text": "*stares at the ground* I understand.", "weight": 6, "is_action": true }, + { "text": "I won't make the same mistake again.", "weight": 6 }, + { "text": "*shoulders slump* Sorry...", "weight": 6, "is_action": true }, + { "text": "I know I should have done better.", "weight": 6 } + ] + }, + { + "id": "conversation.threaten", + "conditions": {}, + "variants": [ + { "text": "*shrinks back in fear*", "weight": 10, "is_action": true }, + { "text": "P-please don't...", "weight": 10 }, + { "text": "I'll behave, I promise!", "weight": 8 }, + { "text": "*trembles*", "weight": 8, "is_action": true }, + { "text": "*backs away slowly* I understand...", "weight": 7, "is_action": true }, + { "text": "Okay, okay... I won't cause trouble.", "weight": 7 }, + { "text": "*swallows hard* I hear you.", "weight": 6 }, + { "text": "*nods quickly, eyes wide*", "weight": 6, "is_action": true }, + { "text": "I don't want any problems... please.", "weight": 6 }, + { "text": "*freezes in place*", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.tease", + "conditions": {}, + "variants": [ + { "text": "*looks away embarrassed*", "weight": 10, "is_action": true }, + { "text": "Don't tease me...", "weight": 10 }, + { "text": "That's not funny.", "weight": 8 }, + { "text": "*pouts*", "weight": 8, "is_action": true }, + { "text": "*cheeks redden* Very mature...", "weight": 7, "is_action": true }, + { "text": "You're enjoying this, aren't you?", "weight": 7 }, + { "text": "*sighs* If it amuses you...", "weight": 6 }, + { "text": "*crosses arms* Ha ha.", "weight": 6, "is_action": true }, + { "text": "I've heard worse, I suppose.", "weight": 6 }, + { "text": "*rolls eyes slightly*", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": {}, + "variants": [ + { "text": "I'm... managing.", "weight": 10 }, + { "text": "Fine, I suppose.", "weight": 10 }, + { "text": "Could be worse.", "weight": 8 }, + { "text": "One day at a time.", "weight": 8 }, + { "text": "*shrugs* Getting by.", "weight": 7, "is_action": true }, + { "text": "Surviving. That's something.", "weight": 7 }, + { "text": "I've had better days, and worse.", "weight": 6 }, + { "text": "*sighs* Still here.", "weight": 6, "is_action": true }, + { "text": "Hanging in there, I guess.", "weight": 6 }, + { "text": "Same as always.", "weight": 6 } + ] + }, + { + "id": "conversation.whats_wrong", + "conditions": { "mood_max": 40 }, + "variants": [ + { "text": "Everything...", "weight": 10 }, + { "text": "I don't want to talk about it.", "weight": 10 }, + { "text": "Where do I even begin?", "weight": 8 }, + { "text": "*sighs deeply*", "weight": 8, "is_action": true }, + { "text": "It's just... a lot right now.", "weight": 7 }, + { "text": "*stares into the distance* Everything feels heavy.", "weight": 7, "is_action": true }, + { "text": "I don't even know anymore.", "weight": 6 }, + { "text": "*rubs temples* Too much to explain.", "weight": 6, "is_action": true }, + { "text": "Would it change anything if I told you?", "weight": 6 }, + { "text": "This whole situation... it weighs on me.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.cooldown", + "conditions": {}, + "variants": [ + { "text": "*looks away* We just talked...", "weight": 10 }, + { "text": "Can we talk later?", "weight": 10 }, + { "text": "Give me a moment...", "weight": 8 }, + { "text": "*holds up hand* Not right now.", "weight": 7, "is_action": true }, + { "text": "I need a minute to myself.", "weight": 7 }, + { "text": "*turns slightly away* Just... give me some time.", "weight": 6, "is_action": true }, + { "text": "Let me catch my breath first.", "weight": 6 }, + { "text": "Maybe in a little while...", "weight": 6 }, + { "text": "*shakes head* Soon, but not now.", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.refusal.low_mood", + "conditions": {}, + "variants": [ + { "text": "*stares at the ground* I don't feel like talking...", "weight": 10 }, + { "text": "Leave me alone...", "weight": 10 }, + { "text": "*turns away silently*", "weight": 8, "is_action": true }, + { "text": "*hugs knees* Not now... please.", "weight": 7, "is_action": true }, + { "text": "I can't find the words right now.", "weight": 7 }, + { "text": "*barely audible* Just... not today.", "weight": 6 }, + { "text": "Everything feels too heavy to talk about.", "weight": 6 }, + { "text": "*shakes head slowly, eyes downcast*", "weight": 6, "is_action": true }, + { "text": "I need to be alone with my thoughts.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.resentment", + "conditions": {}, + "variants": [ + { "text": "*glares silently*", "weight": 10, "is_action": true }, + { "text": "I have nothing to say to you.", "weight": 10 }, + { "text": "*cold stare*", "weight": 8, "is_action": true }, + { "text": "*turns back* We're done talking.", "weight": 7, "is_action": true }, + { "text": "Don't expect words from me.", "weight": 7 }, + { "text": "*jaw tightens* Not now. Not to you.", "weight": 6, "is_action": true }, + { "text": "You've lost that privilege.", "weight": 6 }, + { "text": "*looks through you* You're not worth the breath.", "weight": 6, "is_action": true }, + { "text": "Silence is all you'll get from me.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.fear", + "conditions": {}, + "variants": [ + { "text": "*looks away nervously*", "weight": 10, "is_action": true }, + { "text": "*trembles and avoids eye contact*", "weight": 10, "is_action": true }, + { "text": "P-please... I...", "weight": 8 }, + { "text": "*flinches back* D-don't...", "weight": 7, "is_action": true }, + { "text": "*breathing quickens* I can't... not right now...", "weight": 7, "is_action": true }, + { "text": "*shrinks away* Please just... go.", "weight": 6, "is_action": true }, + { "text": "*eyes dart around nervously*", "weight": 6, "is_action": true }, + { "text": "I-I need space... please...", "weight": 6 }, + { "text": "*wraps arms around self protectively*", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.refusal.exhausted", + "conditions": {}, + "variants": [ + { "text": "*yawns* Too tired...", "weight": 10 }, + { "text": "I need to rest...", "weight": 10 }, + { "text": "*struggles to keep eyes open*", "weight": 8, "is_action": true }, + { "text": "*head nods forward* Can't... stay awake...", "weight": 7, "is_action": true }, + { "text": "I'm barely conscious right now...", "weight": 7 }, + { "text": "*voice trails off* Maybe later...", "weight": 6 }, + { "text": "*eyelids heavy* Sorry... need sleep...", "weight": 6, "is_action": true }, + { "text": "Can barely form words... so tired...", "weight": 6 }, + { "text": "*slumps* Just let me rest...", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.refusal.tired", + "conditions": {}, + "variants": [ + { "text": "*sighs* I'm tired of talking...", "weight": 10 }, + { "text": "Can we stop for now?", "weight": 10 }, + { "text": "I need some quiet time.", "weight": 8 }, + { "text": "*rubs forehead* My mind needs a break.", "weight": 7, "is_action": true }, + { "text": "Talked out. Give me some peace.", "weight": 7 }, + { "text": "*waves hand dismissively* Later.", "weight": 6, "is_action": true }, + { "text": "Words are exhausting right now.", "weight": 6 }, + { "text": "*leans back* I need silence for a while.", "weight": 6, "is_action": true }, + { "text": "Let's take a break from this.", "weight": 6 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/discipline.json b/src/main/resources/data/tiedup/dialogue/en_us/default/discipline.json new file mode 100644 index 0000000..a852581 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/discipline.json @@ -0,0 +1,324 @@ +{ + "category": "discipline", + "entries": [ + { + "id": "discipline.legitimate.accept", + "conditions": { + "resentment_max": 30 + }, + "variants": [ + { + "text": "I deserved that...", + "weight": 10 + }, + { + "text": "I understand why you did that.", + "weight": 10 + }, + { + "text": "I'm sorry, Master. I'll do better.", + "weight": 8 + }, + { + "text": "*accepts the punishment quietly*", + "weight": 6, + "is_action": true + } + ] + }, + { + "id": "discipline.legitimate.reluctant", + "conditions": { + "resentment_min": 31, + "resentment_max": 60 + }, + "variants": [ + { + "text": "I... I suppose I had that coming...", + "weight": 10 + }, + { + "text": "Fine... I know I messed up.", + "weight": 10 + }, + { + "text": "*winces but doesn't complain*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.legitimate.resentful", + "conditions": { + "resentment_min": 61 + }, + "variants": [ + { + "text": "Was that really necessary...?", + "weight": 10 + }, + { + "text": "*glares silently*", + "weight": 10, + "is_action": true + }, + { + "text": "I'll remember this.", + "weight": 8 + }, + { + "text": "*clenches fists in hidden anger*", + "weight": 6, + "is_action": true + } + ] + }, + { + "id": "discipline.gratuitous.low_resentment", + "conditions": { + "resentment_max": 30 + }, + "variants": [ + { + "text": "But... I didn't do anything wrong...", + "weight": 10 + }, + { + "text": "Why...? What did I do?", + "weight": 10 + }, + { + "text": "*looks confused and hurt*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.gratuitous.medium_resentment", + "conditions": { + "resentment_min": 31, + "resentment_max": 60 + }, + "variants": [ + { + "text": "That wasn't fair!", + "weight": 10 + }, + { + "text": "I didn't deserve that...", + "weight": 10 + }, + { + "text": "*bitter tears fall*", + "weight": 8, + "is_action": true + }, + { + "text": "You just enjoy hurting me, don't you?", + "weight": 6 + } + ] + }, + { + "id": "discipline.gratuitous.high_resentment", + "conditions": { + "resentment_min": 61 + }, + "variants": [ + { + "text": "Monster!", + "weight": 10 + }, + { + "text": "*seethes with hatred*", + "weight": 10, + "is_action": true + }, + { + "text": "One day you'll pay for this...", + "weight": 8 + }, + { + "text": "I hate you!", + "weight": 8 + }, + { + "text": "*plots revenge silently*", + "weight": 6, + "is_action": true + } + ] + }, + { + "id": "discipline.shock.reaction", + "conditions": {}, + "variants": [ + { + "text": "AAAH!", + "weight": 10 + }, + { + "text": "*screams in pain*", + "weight": 10, + "is_action": true + }, + { + "text": "*convulses from the shock*", + "weight": 8, + "is_action": true + }, + { + "text": "Please... no more...", + "weight": 6 + } + ] + }, + { + "id": "discipline.whip.reaction", + "conditions": {}, + "variants": [ + { + "text": "*cries out in pain*", + "weight": 10, + "is_action": true + }, + { + "text": "It hurts!", + "weight": 10 + }, + { + "text": "*flinches from the strike*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.paddle.reaction", + "conditions": {}, + "variants": [ + { + "text": "*yelps*", + "weight": 10, + "is_action": true + }, + { + "text": "Ouch!", + "weight": 10 + }, + { + "text": "*blushes deeply*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.fear_increase", + "conditions": {}, + "variants": [ + { + "text": "*trembles in fear*", + "weight": 10, + "is_action": true + }, + { + "text": "Please don't hurt me again...", + "weight": 10 + }, + { + "text": "*cowers away from you*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.praise", + "conditions": {}, + "variants": [ + { + "text": "Thank you... I'm trying.", + "weight": 10 + }, + { + "text": "*lowers head modestly* I appreciate that.", + "weight": 10, + "is_action": true + }, + { + "text": "I'm glad I could please you.", + "weight": 8 + }, + { + "text": "*small relieved smile* That helps to hear.", + "weight": 8, + "is_action": true + }, + { + "text": "I hope to keep doing well.", + "weight": 6 + } + ] + }, + { + "id": "discipline.scold", + "conditions": {}, + "variants": [ + { + "text": "I... I understand. It won't happen again.", + "weight": 10 + }, + { + "text": "*looks down in shame* I'm sorry.", + "weight": 10, + "is_action": true + }, + { + "text": "I didn't mean to upset you.", + "weight": 8 + }, + { + "text": "*winces slightly* I'll do better.", + "weight": 8, + "is_action": true + }, + { + "text": "Please... forgive my mistake.", + "weight": 6 + } + ] + }, + { + "id": "discipline.threaten", + "conditions": {}, + "variants": [ + { + "text": "*eyes widen* Please, there's no need for that!", + "weight": 10, + "is_action": true + }, + { + "text": "I believe you... please don't.", + "weight": 10 + }, + { + "text": "*nervously backs away* I get it, I get it.", + "weight": 8, + "is_action": true + }, + { + "text": "I'll behave, I promise!", + "weight": 8 + }, + { + "text": "*swallows hard* I understand the consequences.", + "weight": 6, + "is_action": true + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/environment.json b/src/main/resources/data/tiedup/dialogue/en_us/default/environment.json new file mode 100644 index 0000000..c72cd5c --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/environment.json @@ -0,0 +1,38 @@ +{ + "category": "environment", + "entries": [ + { + "id": "environment.rain", + "conditions": {}, + "variants": [ + { "text": "*shivers from the rain*", "weight": 10, "is_action": true }, + { "text": "It's raining...", "weight": 10 }, + { "text": "*tries to stay dry*", "weight": 8, "is_action": true }, + { "text": "I wish I had some shelter.", "weight": 8 }, + { "text": "*getting wet*", "weight": 6, "is_action": true } + ] + }, + { + "id": "environment.night", + "conditions": {}, + "variants": [ + { "text": "*looks around nervously in the dark*", "weight": 10, "is_action": true }, + { "text": "It's so dark out here...", "weight": 10 }, + { "text": "*glances at shadows*", "weight": 8, "is_action": true }, + { "text": "I don't like the dark.", "weight": 8 }, + { "text": "*squints into the darkness*", "weight": 6, "is_action": true } + ] + }, + { + "id": "environment.thunder", + "conditions": {}, + "variants": [ + { "text": "*jumps at the thunder*", "weight": 10, "is_action": true }, + { "text": "Eek!", "weight": 10 }, + { "text": "*startled by thunder*", "weight": 8, "is_action": true }, + { "text": "That was loud!", "weight": 8 }, + { "text": "*flinches*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/fear.json b/src/main/resources/data/tiedup/dialogue/en_us/default/fear.json new file mode 100644 index 0000000..317e491 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/fear.json @@ -0,0 +1,49 @@ +{ + "category": "fear", + "entries": [ + { + "id": "fear.nervous", + "conditions": {}, + "variants": [ + { "text": "*avoids eye contact*", "weight": 10, "is_action": true }, + { "text": "*speaks quietly*", "weight": 10, "is_action": true }, + { "text": "I-I'll behave...", "weight": 8 }, + { "text": "*fidgets nervously*", "weight": 8, "is_action": true }, + { "text": "Y-yes...?", "weight": 6 } + ] + }, + { + "id": "fear.afraid", + "conditions": {}, + "variants": [ + { "text": "*trembles visibly*", "weight": 10, "is_action": true }, + { "text": "P-please don't hurt me...", "weight": 10 }, + { "text": "*backs away slightly*", "weight": 8, "is_action": true }, + { "text": "I-I'm sorry... whatever I did...", "weight": 8 }, + { "text": "*can't meet your gaze*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.terrified", + "conditions": {}, + "variants": [ + { "text": "*recoils in panic*", "weight": 10, "is_action": true }, + { "text": "S-stay away!", "weight": 10 }, + { "text": "*breathing rapidly*", "weight": 8, "is_action": true }, + { "text": "No no no no...", "weight": 8 }, + { "text": "*frozen in terror*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.traumatized", + "conditions": {}, + "variants": [ + { "text": "*collapses, sobbing*", "weight": 10, "is_action": true }, + { "text": "*completely breaks down*", "weight": 10, "is_action": true }, + { "text": "I'll do anything... just please...", "weight": 8 }, + { "text": "*paralyzed with fear*", "weight": 8, "is_action": true }, + { "text": "*whimpers uncontrollably*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/guard_labor.json b/src/main/resources/data/tiedup/dialogue/en_us/default/guard_labor.json new file mode 100644 index 0000000..94abd60 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/guard_labor.json @@ -0,0 +1,84 @@ +{ + "category": "guard_labor", + "entries": [ + { + "id": "guard.labor.warning", + "conditions": {}, + "variants": [ + { "text": "I'm watching you. Get to work!", "weight": 10 }, + { "text": "Don't slack off. Back to work!", "weight": 10 }, + { "text": "You think I'm not watching? Work!", "weight": 8 }, + { "text": "Move it! You're not here to stand around.", "weight": 8 }, + { "text": "Less idling, more working.", "weight": 6 } + ] + }, + { + "id": "guard.labor.shock_1", + "conditions": {}, + "variants": [ + { "text": "Get back to work!", "weight": 10 }, + { "text": "That's your first warning!", "weight": 10 }, + { "text": "Work or face punishment!", "weight": 8 }, + { "text": "One strike. Don't push your luck.", "weight": 8 } + ] + }, + { + "id": "guard.labor.shock_2", + "conditions": {}, + "variants": [ + { "text": "Last warning! Your binds have been tightened!", "weight": 10 }, + { "text": "You asked for it! Tighter binds!", "weight": 10 }, + { "text": "Two strikes. One more and you're done.", "weight": 8 } + ] + }, + { + "id": "guard.labor.task_failed", + "conditions": {}, + "variants": [ + { "text": "Task failed! You'll be returned to your cell.", "weight": 10 }, + { "text": "Useless! Back to the cell with you!", "weight": 8 }, + { "text": "Three strikes. You're done here.", "weight": 8 } + ] + }, + { + "id": "guard.labor.task_complete", + "conditions": {}, + "variants": [ + { "text": "Good work. Now walk back to camp.", "weight": 10 }, + { "text": "Done. Follow me back to camp.", "weight": 10 }, + { "text": "Finished? Good. Let's head back.", "weight": 8 }, + { "text": "Not bad. Now move it, back to camp.", "weight": 8 } + ] + }, + { + "id": "guard.labor.escape_warning", + "conditions": {}, + "variants": [ + { "text": "Get back here! NOW!", "weight": 10 }, + { "text": "Where do you think you're going?!", "weight": 10 }, + { "text": "Return immediately or you'll regret it!", "weight": 8 }, + { "text": "Don't even think about running!", "weight": 8 }, + { "text": "One more step and I'll shock you senseless!", "weight": 6 } + ] + }, + { + "id": "guard.labor.returned", + "conditions": {}, + "variants": [ + { "text": "Smart choice. Stay close.", "weight": 10 }, + { "text": "Don't try that again.", "weight": 10 }, + { "text": "Good. Keep it that way.", "weight": 8 } + ] + }, + { + "id": "guard.labor.pending_return", + "conditions": {}, + "variants": [ + { "text": "Walk back to camp. Follow me.", "weight": 10 }, + { "text": "Head back to camp. Now.", "weight": 10 }, + { "text": "Move it. Camp is that way.", "weight": 8 }, + { "text": "Back to camp, let's go.", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/home.json b/src/main/resources/data/tiedup/dialogue/en_us/default/home.json new file mode 100644 index 0000000..408a608 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/home.json @@ -0,0 +1,110 @@ +{ + "category": "home", + "entries": [ + { + "id": "home.assigned.pet_bed", + "conditions": {}, + "variants": [ + { "text": "This is... my place now?", "weight": 10 }, + { "text": "*looks at the pet bed hesitantly*", "weight": 10, "is_action": true }, + { "text": "At least it's somewhere to rest...", "weight": 8 }, + { "text": "*settles into the pet bed*", "weight": 6, "is_action": true } + ] + }, + { + "id": "home.assigned.bed", + "conditions": {}, + "variants": [ + { "text": "A real bed? Thank you, Master!", "weight": 10 }, + { "text": "*happily climbs onto the bed*", "weight": 10, "is_action": true }, + { "text": "This is so much better...", "weight": 8 }, + { "text": "I'll take good care of it.", "weight": 6 } + ] + }, + { + "id": "home.destroyed.pet_bed", + "conditions": {}, + "variants": [ + { "text": "My bed! No!", "weight": 10 }, + { "text": "*stares at the destroyed pet bed in shock*", "weight": 10, "is_action": true }, + { "text": "Where will I sleep now...?", "weight": 8 }, + { "text": "*sobs quietly*", "weight": 6, "is_action": true } + ] + }, + { + "id": "home.destroyed.bed", + "conditions": {}, + "variants": [ + { "text": "My bed! Why would you do this?!", "weight": 10 }, + { "text": "*falls to knees beside the destroyed bed*", "weight": 10, "is_action": true }, + { "text": "That was the only comfort I had...", "weight": 8 }, + { "text": "*cries*", "weight": 6, "is_action": true } + ] + }, + { + "id": "home.return.content", + "conditions": { "mood_min": 40 }, + "variants": [ + { "text": "Home sweet home...", "weight": 10 }, + { "text": "*relaxes in their spot*", "weight": 10, "is_action": true }, + { "text": "It feels good to be back.", "weight": 8 }, + { "text": "*sighs contentedly*", "weight": 6, "is_action": true } + ] + }, + { + "id": "home.return.neutral", + "conditions": { "mood_min": 0, "mood_max": 39 }, + "variants": [ + { "text": "Back to my spot...", "weight": 10 }, + { "text": "*settles down wearily*", "weight": 10, "is_action": true }, + { "text": "At least I can rest now.", "weight": 8 } + ] + }, + { + "id": "home.return.sad", + "conditions": { "mood_max": -1 }, + "variants": [ + { "text": "Back to this prison...", "weight": 10 }, + { "text": "*curls up miserably*", "weight": 10, "is_action": true }, + { "text": "Another day trapped here...", "weight": 8 } + ] + }, + { + "id": "home.comfort.low", + "conditions": {}, + "variants": [ + { "text": "The floor is so cold...", "weight": 10 }, + { "text": "*shivers uncomfortably*", "weight": 10, "is_action": true }, + { "text": "I wish I had somewhere softer to sleep...", "weight": 8 } + ] + }, + { + "id": "home.comfort.medium", + "conditions": {}, + "variants": [ + { "text": "It's not much, but it's mine.", "weight": 10 }, + { "text": "*adjusts position in the pet bed*", "weight": 10, "is_action": true }, + { "text": "Could be worse, I suppose.", "weight": 8 } + ] + }, + { + "id": "home.comfort.high", + "conditions": {}, + "variants": [ + { "text": "This bed is so comfortable...", "weight": 10 }, + { "text": "*stretches out luxuriously*", "weight": 10, "is_action": true }, + { "text": "Master is kind to give me this.", "weight": 8 }, + { "text": "*smiles contentedly*", "weight": 6, "is_action": true } + ] + }, + { + "id": "home.missing", + "conditions": {}, + "variants": [ + { "text": "I don't have anywhere to go...", "weight": 10 }, + { "text": "*looks around lost*", "weight": 10, "is_action": true }, + { "text": "Where am I supposed to stay?", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/idle.json b/src/main/resources/data/tiedup/dialogue/en_us/default/idle.json new file mode 100644 index 0000000..a2cf827 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/idle.json @@ -0,0 +1,246 @@ +{ + "category": "idle", + "entries": [ + { + "id": "idle.content", + "conditions": {}, + "variants": [ + { "text": "*smiles contentedly*", "weight": 10, "is_action": true }, + { "text": "This is nice...", "weight": 10 }, + { "text": "*hums happily*", "weight": 8, "is_action": true }, + { "text": "I feel good today.", "weight": 8 }, + { "text": "Life isn't so bad.", "weight": 6 } + ] + }, + { + "id": "idle.neutral", + "conditions": {}, + "variants": [ + { "text": "*looks around*", "weight": 10, "is_action": true }, + { "text": "...", "weight": 10 }, + { "text": "*shifts weight*", "weight": 8, "is_action": true }, + { "text": "Hmm.", "weight": 8 }, + { "text": "*waits*", "weight": 6, "is_action": true } + ] + }, + { + "id": "idle.resting", + "conditions": {}, + "variants": [ + { "text": "*rests quietly*", "weight": 10, "is_action": true }, + { "text": "A moment of peace...", "weight": 10 }, + { "text": "*relaxes*", "weight": 8, "is_action": true }, + { "text": "Finally some rest.", "weight": 8 }, + { "text": "*closes eyes briefly*", "weight": 6, "is_action": true } + ] + }, + { + "id": "idle.bored", + "conditions": {}, + "variants": [ + { "text": "*sighs with boredom*", "weight": 10, "is_action": true }, + { "text": "There's nothing to do...", "weight": 10 }, + { "text": "*fidgets*", "weight": 8, "is_action": true }, + { "text": "So boring...", "weight": 8 }, + { "text": "*looks around for something to do*", "weight": 6, "is_action": true } + ] + }, + { + "id": "idle.free", + "conditions": {}, + "variants": [ + { "text": "What a nice day...", "weight": 10 }, + { "text": "I wonder what's for dinner.", "weight": 10 }, + { "text": "This place is so peaceful.", "weight": 8 }, + { "text": "Hmm...", "weight": 8 }, + { "text": "La la la...", "weight": 6 }, + { "text": "*hums a tune*", "weight": 6, "is_action": true } + ] + }, + { + "id": "idle.greeting", + "conditions": {}, + "variants": [ + { "text": "Hello there!", "weight": 10 }, + { "text": "Hi!", "weight": 10 }, + { "text": "Oh, hello!", "weight": 8 }, + { "text": "Nice to see someone friendly.", "weight": 8 }, + { "text": "Greetings!", "weight": 6 } + ] + }, + { + "id": "idle.goodbye", + "conditions": {}, + "variants": [ + { "text": "Goodbye.", "weight": 10 }, + { "text": "Farewell.", "weight": 10 }, + { "text": "Until next time.", "weight": 8 }, + { "text": "See you around.", "weight": 8 }, + { "text": "Off you go.", "weight": 6 } + ] + }, + { + "id": "idle.get_out", + "conditions": {}, + "variants": [ + { "text": "Get out.", "weight": 10 }, + { "text": "Leave. Now.", "weight": 10 }, + { "text": "I don't want to see you.", "weight": 8 }, + { "text": "Go away!", "weight": 8 }, + { "text": "Get lost!", "weight": 6 } + ] + }, + { + "id": "idle.freed_captor", + "conditions": {}, + "variants": [ + { "text": "You're free now.", "weight": 10 }, + { "text": "Go on, get out of here.", "weight": 10 }, + { "text": "I'm letting you go.", "weight": 8 }, + { "text": "Consider yourself lucky.", "weight": 8 }, + { "text": "Don't make me regret this.", "weight": 6 } + ] + }, + { + "id": "idle.captive", + "conditions": {}, + "variants": [ + { "text": "*waits quietly*", "weight": 10, "is_action": true }, + { "text": "*looks around anxiously*", "weight": 10, "is_action": true }, + { "text": "How long will this last...?", "weight": 8 }, + { "text": "*tests the bindings*", "weight": 8, "is_action": true }, + { "text": "Someone will save me... right?", "weight": 6 } + ] + }, + { + "id": "idle.slave_talk", + "conditions": {}, + "variants": [ + { "text": "Shush! Shut up slave!", "weight": 10 }, + { "text": "Quiet! No one wants to hear you.", "weight": 10 }, + { "text": "Silence!", "weight": 8 }, + { "text": "Did I say you could speak?", "weight": 8 }, + { "text": "Be quiet or I'll gag you tighter!", "weight": 6 }, + { "text": "Slaves don't talk!", "weight": 6 }, + { "text": "One more word and you'll regret it.", "weight": 6 } + ] + }, + { + "id": "idle.transport", + "conditions": {}, + "variants": [ + { "text": "I'll take you somewhere nice.", "weight": 10 }, + { "text": "Time to go.", "weight": 10 }, + { "text": "Follow me, slave.", "weight": 8 }, + { "text": "We're going on a little trip.", "weight": 8 }, + { "text": "Come along now.", "weight": 6 }, + { "text": "Keep up or I'll drag you.", "weight": 6 } + ] + }, + { + "id": "idle.arrive_prison", + "conditions": {}, + "variants": [ + { "text": "Here we are. Your new home.", "weight": 10 }, + { "text": "Welcome to your cell.", "weight": 10 }, + { "text": "This is where you'll be staying.", "weight": 8 }, + { "text": "Get comfortable, you'll be here a while.", "weight": 8 }, + { "text": "Home sweet home.", "weight": 6 } + ] + }, + { + "id": "idle.tied_to_pole", + "conditions": {}, + "variants": [ + { "text": "tied you to the pole!", "weight": 10, "is_action": true }, + { "text": "secured you to the post!", "weight": 10, "is_action": true }, + { "text": "chained you up!", "weight": 8, "is_action": true }, + { "text": "made sure you won't wander off!", "weight": 6, "is_action": true } + ] + }, + { + "id": "idle.sale_waiting", + "conditions": {}, + "variants": [ + { "text": "We're going to wait here for a buyer.", "weight": 10 }, + { "text": "Someone will come for you soon.", "weight": 10 }, + { "text": "Time to find you a new owner.", "weight": 8 }, + { "text": "Let's see who wants to buy you.", "weight": 8 }, + { "text": "Stay still, we're waiting for customers.", "weight": 6 } + ] + }, + { + "id": "idle.sale_announce", + "conditions": {}, + "variants": [ + { "text": "Slave for sale! Come take a look!", "weight": 10 }, + { "text": "Fresh catch! Looking for a buyer!", "weight": 10 }, + { "text": "Anyone want a new slave?", "weight": 8 }, + { "text": "Quality merchandise here!", "weight": 8 }, + { "text": "For sale: One slave, barely used!", "weight": 6 } + ] + }, + { + "id": "idle.sale_offer", + "conditions": {}, + "variants": [ + { "text": "Hey {player}! Want this little pet for {target}?", "weight": 10 }, + { "text": "You there, {player}! Looking for a slave? Only {target}!", "weight": 10 }, + { "text": "{player}! I have a special deal for you - this one for just {target}!", "weight": 8 }, + { "text": "Interested, {player}? {target} and they're yours!", "weight": 8 }, + { "text": "Hey {player}, how about a new companion for just {target}?", "weight": 6 } + ] + }, + { + "id": "idle.sale_complete", + "conditions": {}, + "variants": [ + { "text": "Bye bye!", "weight": 10 }, + { "text": "Pleasure doing business.", "weight": 10 }, + { "text": "Enjoy your new property.", "weight": 8 }, + { "text": "Good luck with that one.", "weight": 8 }, + { "text": "Have fun with your new slave!", "weight": 6 } + ] + }, + { + "id": "idle.punish", + "conditions": {}, + "variants": [ + { "text": "You tried to escape? Big mistake.", "weight": 10 }, + { "text": "Did you really think that would work?", "weight": 10 }, + { "text": "Time for your punishment.", "weight": 8 }, + { "text": "You'll regret that little stunt.", "weight": 8 }, + { "text": "That's it, I'm tightening these.", "weight": 6 }, + { "text": "Escaping? Not on my watch.", "weight": 6 }, + { "text": "You're going to learn your place.", "weight": 6 }, + { "text": "Bad slave. Very bad.", "weight": 6 } + ] + }, + { + "id": "idle.sale_abandoned", + "conditions": {}, + "variants": [ + { "text": "Tch, nobody wants you. Get lost!", "weight": 10 }, + { "text": "You're worthless. Disappear!", "weight": 10 }, + { "text": "No buyers? Fine, enjoy the wilderness!", "weight": 8 }, + { "text": "You're not worth my time. Go!", "weight": 8 }, + { "text": "Useless... I'll dump you somewhere far away.", "weight": 6 }, + { "text": "You're on your own now. Don't come back!", "weight": 6 }, + { "text": "Can't sell you? Then you're not my problem anymore.", "weight": 6 } + ] + }, + { + "id": "idle.sale_kept", + "conditions": {}, + "variants": [ + { "text": "Fine, I'll keep you myself...", "weight": 10 }, + { "text": "No buyers? Guess you're staying with me.", "weight": 10 }, + { "text": "Looks like you're mine to keep.", "weight": 8 }, + { "text": "Well, I could use a servant...", "weight": 8 }, + { "text": "Nobody wants you? Their loss. You're mine now.", "weight": 6 }, + { "text": "I didn't want to sell you anyway.", "weight": 6 }, + { "text": "You better be useful since I'm stuck with you.", "weight": 6 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/jobs.json b/src/main/resources/data/tiedup/dialogue/en_us/default/jobs.json new file mode 100644 index 0000000..5b1f7b0 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/jobs.json @@ -0,0 +1,203 @@ +{ + "category": "jobs", + "entries": [ + { + "id": "jobs.assigned", + "conditions": {}, + "variants": [ + { "text": "I command you to bring this to me: ", "weight": 10 }, + { "text": "Your task is to find: ", "weight": 10 }, + { "text": "Bring me this item: ", "weight": 8 }, + { "text": "You have one job: get me ", "weight": 8 }, + { "text": "I need you to fetch: ", "weight": 6 } + ] + }, + { + "id": "jobs.hurry", + "conditions": {}, + "variants": [ + { "text": "You better hurry up...", "weight": 10 }, + { "text": "Time is running out!", "weight": 10 }, + { "text": "Don't keep me waiting.", "weight": 8 }, + { "text": "Move faster, slave!", "weight": 8 }, + { "text": "The clock is ticking.", "weight": 6 } + ] + }, + { + "id": "jobs.complete", + "conditions": {}, + "variants": [ + { "text": "Well done slave, you've earned your freedom.", "weight": 10 }, + { "text": "Good job. You're free to go.", "weight": 10 }, + { "text": "Acceptable work. You may leave.", "weight": 8 }, + { "text": "Finally! Now get out of my sight.", "weight": 8 }, + { "text": "Task complete. Your collar is removed.", "weight": 6 } + ] + }, + { + "id": "jobs.failed", + "conditions": {}, + "variants": [ + { "text": "This is not what I requested.", "weight": 10 }, + { "text": "Wrong item, slave!", "weight": 10 }, + { "text": "Are you stupid? That's not it!", "weight": 8 }, + { "text": "Useless! Try again.", "weight": 8 }, + { "text": "That's not what I asked for!", "weight": 6 } + ] + }, + { + "id": "jobs.last_chance", + "conditions": {}, + "variants": [ + { "text": "This is your LAST chance. Fail again and you die!", "weight": 10 }, + { "text": "One more failure and I'll kill you!", "weight": 10 }, + { "text": "Next time you fail, you're dead!", "weight": 8 }, + { "text": "I'm warning you... bring it or die!", "weight": 8 }, + { "text": "Last chance, slave. Don't disappoint me again.", "weight": 6 } + ] + }, + { + "id": "jobs.kill", + "conditions": {}, + "variants": [ + { "text": "I warned you. Now you die!", "weight": 10 }, + { "text": "You had your chance. Goodbye!", "weight": 10 }, + { "text": "Useless slave. Time to die!", "weight": 8 }, + { "text": "I told you what would happen!", "weight": 8 }, + { "text": "You're worthless. Die!", "weight": 6 } + ] + }, + { + "id": "jobs.idle.farm", + "conditions": {}, + "variants": [ + { "text": "*looks for crops to harvest*", "weight": 10, "is_action": true }, + { "text": "Nothing ready to harvest here...", "weight": 10 }, + { "text": "*checks the crops*", "weight": 8, "is_action": true }, + { "text": "I'll keep looking.", "weight": 8 }, + { "text": "*examines the soil*", "weight": 6, "is_action": true } + ] + }, + { + "id": "jobs.idle.cook", + "conditions": {}, + "variants": [ + { "text": "*waits for ingredients*", "weight": 10, "is_action": true }, + { "text": "I need ingredients to cook...", "weight": 10 }, + { "text": "*checks the supplies*", "weight": 8, "is_action": true }, + { "text": "Nothing to cook with.", "weight": 8 }, + { "text": "*looks through the chest*", "weight": 6, "is_action": true } + ] + }, + { + "id": "jobs.idle.store", + "conditions": {}, + "variants": [ + { "text": "*organizes the storage*", "weight": 10, "is_action": true }, + { "text": "Everything is in order.", "weight": 10 }, + { "text": "*tidies up*", "weight": 8, "is_action": true }, + { "text": "Storage is sorted.", "weight": 8 }, + { "text": "*checks inventory*", "weight": 6, "is_action": true } + ] + }, + { + "id": "jobs.idle.guard", + "conditions": {}, + "variants": [ + { "text": "*scans the area*", "weight": 10, "is_action": true }, + { "text": "All clear.", "weight": 10 }, + { "text": "*watches vigilantly*", "weight": 8, "is_action": true }, + { "text": "No threats detected.", "weight": 8 }, + { "text": "*stands guard*", "weight": 6, "is_action": true } + ] + }, + { + "id": "jobs.idle.patrol", + "conditions": {}, + "variants": [ + { "text": "*continues patrolling*", "weight": 10, "is_action": true }, + { "text": "Making my rounds...", "weight": 10 }, + { "text": "*checks the perimeter*", "weight": 8, "is_action": true }, + { "text": "Nothing unusual.", "weight": 8 }, + { "text": "*walks the patrol route*", "weight": 6, "is_action": true } + ] + }, + { + "id": "jobs.idle.collect", + "conditions": {}, + "variants": [ + { "text": "*searches for items*", "weight": 10, "is_action": true }, + { "text": "Looking for things to collect...", "weight": 10 }, + { "text": "*picks through the area*", "weight": 8, "is_action": true }, + { "text": "Not much around here.", "weight": 8 }, + { "text": "*gathers what's available*", "weight": 6, "is_action": true } + ] + }, + { + "id": "mood.working_unhappy", + "conditions": {}, + "variants": [ + { "text": "*sighs while working*", "weight": 10, "is_action": true }, + { "text": "This is exhausting...", "weight": 10 }, + { "text": "*works reluctantly*", "weight": 8, "is_action": true }, + { "text": "I wish I could rest.", "weight": 8 }, + { "text": "*grumbles*", "weight": 6, "is_action": true } + ] + }, + { + "id": "jobs.idle.mine", + "conditions": {}, + "variants": [ + { "text": "*swings the pickaxe steadily*", "weight": 10, "is_action": true }, + { "text": "Nothing left to mine here...", "weight": 10 }, + { "text": "*examines the rock face*", "weight": 8, "is_action": true }, + { "text": "These walls are solid.", "weight": 8 }, + { "text": "*wipes sweat from brow*", "weight": 6, "is_action": true } + ] + }, + { + "id": "jobs.idle.craft", + "conditions": {}, + "variants": [ + { "text": "*checks the crafting table*", "weight": 10, "is_action": true }, + { "text": "I need more materials to craft...", "weight": 10 }, + { "text": "*arranges ingredients carefully*", "weight": 8, "is_action": true }, + { "text": "Nothing I can make right now.", "weight": 8 }, + { "text": "*inspects the available supplies*", "weight": 6, "is_action": true } + ] + }, + { + "id": "jobs.idle.breed", + "conditions": {}, + "variants": [ + { "text": "*watches over the animals*", "weight": 10, "is_action": true }, + { "text": "The animals seem content.", "weight": 10 }, + { "text": "*gently pets a nearby animal*", "weight": 8, "is_action": true }, + { "text": "No more pairs ready to breed.", "weight": 8 }, + { "text": "*checks on the young ones*", "weight": 6, "is_action": true } + ] + }, + { + "id": "jobs.idle.fish", + "conditions": {}, + "variants": [ + { "text": "*stares at the water patiently*", "weight": 10, "is_action": true }, + { "text": "The fish aren't biting...", "weight": 10 }, + { "text": "*casts the line again*", "weight": 8, "is_action": true }, + { "text": "It's peaceful by the water.", "weight": 8 }, + { "text": "*watches the ripples*", "weight": 6, "is_action": true } + ] + }, + { + "id": "jobs.idle.sort", + "conditions": {}, + "variants": [ + { "text": "*organizes items by type*", "weight": 10, "is_action": true }, + { "text": "Everything is sorted properly.", "weight": 10 }, + { "text": "*checks the chests*", "weight": 8, "is_action": true }, + { "text": "Nothing new to sort.", "weight": 8 }, + { "text": "*tidies up the storage area*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/leash.json b/src/main/resources/data/tiedup/dialogue/en_us/default/leash.json new file mode 100644 index 0000000..66f6592 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/leash.json @@ -0,0 +1,78 @@ +{ + "category": "leash", + "entries": [ + { + "id": "leash.attached", + "conditions": {}, + "variants": [ + { "text": "A leash...?", "weight": 10 }, + { "text": "*feels the leash tighten*", "weight": 10, "is_action": true }, + { "text": "Like an animal...", "weight": 8 }, + { "text": "*tugs at the leash experimentally*", "weight": 6, "is_action": true } + ] + }, + { + "id": "leash.removed", + "conditions": {}, + "variants": [ + { "text": "*feels the leash come off*", "weight": 10, "is_action": true }, + { "text": "Finally...", "weight": 10 }, + { "text": "*rubs neck where leash was*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.pulled", + "conditions": {}, + "variants": [ + { "text": "*stumbles forward*", "weight": 10, "is_action": true }, + { "text": "Wait, not so fast!", "weight": 10 }, + { "text": "*struggles to keep up*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.walking.content", + "conditions": { "resentment_max": 30 }, + "variants": [ + { "text": "*follows obediently*", "weight": 10, "is_action": true }, + { "text": "Yes, Master.", "weight": 10 }, + { "text": "*walks beside you*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.walking.reluctant", + "conditions": { "resentment_min": 31, "resentment_max": 60 }, + "variants": [ + { "text": "*follows reluctantly*", "weight": 10, "is_action": true }, + { "text": "Do I have to...?", "weight": 10 }, + { "text": "*drags feet*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.walking.resistant", + "conditions": { "resentment_min": 61 }, + "variants": [ + { "text": "*pulls against the leash*", "weight": 10, "is_action": true }, + { "text": "I'm not your pet!", "weight": 10 }, + { "text": "*glares while being led*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.tug.warning", + "conditions": {}, + "variants": [ + { "text": "*yelps as leash tugs*", "weight": 10, "is_action": true }, + { "text": "Okay, okay! I'm coming!", "weight": 10 }, + { "text": "*hurries to catch up*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.idle.attached", + "conditions": {}, + "variants": [ + { "text": "*waits at the end of the leash*", "weight": 10, "is_action": true }, + { "text": "Can't go anywhere like this...", "weight": 10 }, + { "text": "*sits and waits*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/maid_labor.json b/src/main/resources/data/tiedup/dialogue/en_us/default/maid_labor.json new file mode 100644 index 0000000..c5eb8db --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/maid_labor.json @@ -0,0 +1,54 @@ +{ + "category": "maid_labor", + "entries": [ + { + "id": "maid.labor.earnings", + "conditions": {}, + "variants": [ + { "text": "Task complete! {value} emeralds credited to your debt.", "weight": 10 }, + { "text": "Good work. {value} emeralds have been deducted.", "weight": 8 }, + { "text": "{value} emeralds earned. Keep it up and you'll be free eventually.", "weight": 8 }, + { "text": "That's {value} emeralds off your debt. Not bad.", "weight": 6 } + ] + }, + { + "id": "maid.labor.escort_slow", + "conditions": {}, + "variants": [ + { "text": "Keep up! Follow me back to camp.", "weight": 10 }, + { "text": "Stay close. Don't fall behind.", "weight": 8 }, + { "text": "Hurry up! I don't have all day.", "weight": 8 }, + { "text": "Move faster. The camp isn't going to walk to you.", "weight": 6 } + ] + }, + { + "id": "maid.labor.escort_stop", + "conditions": {}, + "variants": [ + { "text": "Get back here immediately!", "weight": 10 }, + { "text": "Return to me NOW!", "weight": 8 }, + { "text": "Where are you going?! Come back!", "weight": 8 }, + { "text": "Don't make me call the guard!", "weight": 6 } + ] + }, + { + "id": "maid.labor.leash", + "conditions": {}, + "variants": [ + { "text": "Come with me. Back to your cell.", "weight": 10 }, + { "text": "Time to go. Follow me.", "weight": 10 }, + { "text": "Work's done. Let's get you back.", "weight": 8 }, + { "text": "On your feet. We're heading back.", "weight": 8 } + ] + }, + { + "id": "maid.labor.cell_return", + "conditions": {}, + "variants": [ + { "text": "Back to your cell. Rest before your next task.", "weight": 10 }, + { "text": "You've been returned to your cell.", "weight": 8 }, + { "text": "Cell time. Get some rest while you can.", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/mood.json b/src/main/resources/data/tiedup/dialogue/en_us/default/mood.json new file mode 100644 index 0000000..20442c7 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/mood.json @@ -0,0 +1,56 @@ +{ + "category": "mood", + "entries": [ + { + "id": "mood.happy", + "conditions": { + "mood_min": 70 + }, + "variants": [ + { "text": "*smiles contentedly*", "weight": 10, "is_action": true }, + { "text": "This isn't so bad...", "weight": 8 }, + { "text": "*seems at peace*", "weight": 8, "is_action": true }, + { "text": "*hums softly*", "weight": 6, "is_action": true } + ] + }, + { + "id": "mood.neutral", + "conditions": { + "mood_min": 40, + "mood_max": 69 + }, + "variants": [ + { "text": "*looks around idly*", "weight": 10, "is_action": true }, + { "text": "...", "weight": 8 }, + { "text": "*stares off into the distance*", "weight": 8, "is_action": true }, + { "text": "*sighs softly*", "weight": 6, "is_action": true } + ] + }, + { + "id": "mood.sad", + "conditions": { + "mood_min": 10, + "mood_max": 39 + }, + "variants": [ + { "text": "*looks downcast*", "weight": 10, "is_action": true }, + { "text": "I miss my freedom...", "weight": 8 }, + { "text": "*sniffles quietly*", "weight": 8, "is_action": true }, + { "text": "Will this ever end...?", "weight": 6 } + ] + }, + { + "id": "mood.miserable", + "conditions": { + "mood_max": 9 + }, + "variants": [ + { "text": "*sobs quietly*", "weight": 10, "is_action": true }, + { "text": "Please... just let me go...", "weight": 8 }, + { "text": "*trembles in misery*", "weight": 8, "is_action": true }, + { "text": "I can't take this anymore...", "weight": 6 }, + { "text": "*has given up hope*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/needs.json b/src/main/resources/data/tiedup/dialogue/en_us/default/needs.json new file mode 100644 index 0000000..e6b3edb --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/needs.json @@ -0,0 +1,85 @@ +{ + "category": "needs", + "entries": [ + { + "id": "needs.hungry", + "conditions": {}, + "variants": [ + { "text": "I'm so hungry...", "weight": 10 }, + { "text": "Please, some food...", "weight": 10 }, + { "text": "My stomach hurts...", "weight": 8 }, + { "text": "I haven't eaten in so long...", "weight": 8 }, + { "text": "I need food...", "weight": 6 }, + { "text": "*stomach growls loudly*", "weight": 6, "is_action": true } + ] + }, + { + "id": "needs.starving", + "conditions": {}, + "variants": [ + { "text": "I'm starving... please...", "weight": 10 }, + { "text": "I can't... I need food... now...", "weight": 10 }, + { "text": "*clutches stomach weakly*", "weight": 8, "is_action": true }, + { "text": "I'm going to faint...", "weight": 8 }, + { "text": "Please... I'm begging you... food...", "weight": 6 } + ] + }, + { + "id": "needs.tired", + "conditions": {}, + "variants": [ + { "text": "I'm so tired...", "weight": 10 }, + { "text": "I need rest...", "weight": 10 }, + { "text": "So tired...", "weight": 8 }, + { "text": "Can't keep my eyes open...", "weight": 8 }, + { "text": "Please let me sleep...", "weight": 6 }, + { "text": "*yawns*", "weight": 6, "is_action": true } + ] + }, + { + "id": "needs.exhausted", + "conditions": {}, + "variants": [ + { "text": "I can't... stay awake...", "weight": 10 }, + { "text": "*eyes fluttering closed*", "weight": 10, "is_action": true }, + { "text": "Please... I need to sleep...", "weight": 8 }, + { "text": "I'm going to collapse...", "weight": 8 }, + { "text": "*struggles to keep eyes open*", "weight": 6, "is_action": true } + ] + }, + { + "id": "needs.uncomfortable", + "conditions": {}, + "variants": [ + { "text": "These binds hurt...", "weight": 10 }, + { "text": "It's so uncomfortable...", "weight": 10 }, + { "text": "My arms are numb...", "weight": 8 }, + { "text": "Everything aches...", "weight": 8 }, + { "text": "Please loosen these...", "weight": 6 }, + { "text": "*shifts uncomfortably*", "weight": 6, "is_action": true } + ] + }, + { + "id": "needs.dignity_low", + "conditions": {}, + "variants": [ + { "text": "This is so humiliating...", "weight": 10 }, + { "text": "I feel so ashamed...", "weight": 10 }, + { "text": "Please... have some mercy...", "weight": 8 }, + { "text": "Is this really necessary...?", "weight": 8 }, + { "text": "I can't take this...", "weight": 6 }, + { "text": "*looks down in shame*", "weight": 6, "is_action": true } + ] + }, + { + "id": "needs.satisfied", + "conditions": {}, + "variants": [ + { "text": "Thank you...", "weight": 10 }, + { "text": "That's much better...", "weight": 10 }, + { "text": "*sighs with relief*", "weight": 8, "is_action": true }, + { "text": "I feel a bit better now.", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/personality.json b/src/main/resources/data/tiedup/dialogue/en_us/default/personality.json new file mode 100644 index 0000000..da3626f --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/personality.json @@ -0,0 +1,16 @@ +{ + "category": "personality", + "entries": [ + { + "id": "personality.hint", + "conditions": {}, + "variants": [ + { "text": "*shifts uncomfortably*", "weight": 10, "is_action": true }, + { "text": "*glances around nervously*", "weight": 10, "is_action": true }, + { "text": "*fidgets slightly*", "weight": 8, "is_action": true }, + { "text": "*takes a deep breath*", "weight": 8, "is_action": true }, + { "text": "*seems lost in thought*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/reaction.json b/src/main/resources/data/tiedup/dialogue/en_us/default/reaction.json new file mode 100644 index 0000000..3ca4e01 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/reaction.json @@ -0,0 +1,60 @@ +{ + "category": "reaction", + "entries": [ + { + "id": "reaction.approach.stranger", + "conditions": {}, + "variants": [ + { "text": "*notices you*", "weight": 10, "is_action": true }, + { "text": "Hello there.", "weight": 10 }, + { "text": "*looks up*", "weight": 8, "is_action": true }, + { "text": "Oh, someone's here.", "weight": 8 }, + { "text": "*watches warily*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.master", + "conditions": {}, + "variants": [ + { "text": "*perks up*", "weight": 10, "is_action": true }, + { "text": "Master!", "weight": 10 }, + { "text": "*stands at attention*", "weight": 8, "is_action": true }, + { "text": "You're back.", "weight": 8 }, + { "text": "What do you need?", "weight": 6 } + ] + }, + { + "id": "reaction.approach.beloved", + "conditions": {}, + "variants": [ + { "text": "*smiles warmly*", "weight": 10, "is_action": true }, + { "text": "It's you!", "weight": 10 }, + { "text": "*lights up*", "weight": 8, "is_action": true }, + { "text": "I'm glad to see you.", "weight": 8 }, + { "text": "*waves happily*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.captor", + "conditions": {}, + "variants": [ + { "text": "*tenses up*", "weight": 10, "is_action": true }, + { "text": "...you.", "weight": 10 }, + { "text": "*looks away*", "weight": 8, "is_action": true }, + { "text": "What do you want?", "weight": 8 }, + { "text": "*shrinks back slightly*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.enemy", + "conditions": {}, + "variants": [ + { "text": "*glares*", "weight": 10, "is_action": true }, + { "text": "Stay away from me.", "weight": 10 }, + { "text": "*backs away*", "weight": 8, "is_action": true }, + { "text": "What do YOU want?", "weight": 8 }, + { "text": "*hostile stare*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/resentment.json b/src/main/resources/data/tiedup/dialogue/en_us/default/resentment.json new file mode 100644 index 0000000..0d031be --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/resentment.json @@ -0,0 +1,90 @@ +{ + "category": "resentment", + "entries": [ + { + "id": "resentment.none", + "conditions": { "resentment_max": 10 }, + "variants": [ + { "text": "*seems genuinely content*", "weight": 10, "is_action": true }, + { "text": "Is there anything else you need, Master?", "weight": 10 }, + { "text": "*smiles warmly*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.low", + "conditions": { "resentment_min": 11, "resentment_max": 30 }, + "variants": [ + { "text": "*seems mostly content*", "weight": 10, "is_action": true }, + { "text": "Yes, Master.", "weight": 10 }, + { "text": "*obeys without complaint*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.building", + "conditions": { "resentment_min": 31, "resentment_max": 50 }, + "variants": [ + { "text": "...as you wish.", "weight": 10 }, + { "text": "*hesitates slightly before obeying*", "weight": 10, "is_action": true }, + { "text": "*sighs quietly*", "weight": 8, "is_action": true }, + { "text": "If I must...", "weight": 6 } + ] + }, + { + "id": "resentment.moderate", + "conditions": { "resentment_min": 51, "resentment_max": 70 }, + "variants": [ + { "text": "*obeys with visible reluctance*", "weight": 10, "is_action": true }, + { "text": "Fine.", "weight": 10 }, + { "text": "*mutters something under breath*", "weight": 8, "is_action": true }, + { "text": "*avoids eye contact*", "weight": 6, "is_action": true } + ] + }, + { + "id": "resentment.high", + "conditions": { "resentment_min": 71, "resentment_max": 85 }, + "variants": [ + { "text": "*glares silently*", "weight": 10, "is_action": true }, + { "text": "...", "weight": 10 }, + { "text": "*clenches jaw*", "weight": 8, "is_action": true }, + { "text": "*barely suppresses anger*", "weight": 6, "is_action": true } + ] + }, + { + "id": "resentment.critical", + "conditions": { "resentment_min": 86 }, + "variants": [ + { "text": "*stares with barely concealed hatred*", "weight": 10, "is_action": true }, + { "text": "*hands tremble with suppressed rage*", "weight": 10, "is_action": true }, + { "text": "One day...", "weight": 8 }, + { "text": "*dark look crosses face*", "weight": 6, "is_action": true } + ] + }, + { + "id": "resentment.subtle.increase", + "conditions": {}, + "variants": [ + { "text": "*expression hardens slightly*", "weight": 10, "is_action": true }, + { "text": "*something flickers in their eyes*", "weight": 10, "is_action": true }, + { "text": "*bites lip*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.subtle.decrease", + "conditions": {}, + "variants": [ + { "text": "*tension eases slightly*", "weight": 10, "is_action": true }, + { "text": "*shoulders relax*", "weight": 10, "is_action": true }, + { "text": "...thank you.", "weight": 8 } + ] + }, + { + "id": "resentment.rebellion.hint", + "conditions": { "resentment_min": 80 }, + "variants": [ + { "text": "*watches you with calculating eyes*", "weight": 10, "is_action": true }, + { "text": "*seems to be waiting for something*", "weight": 10, "is_action": true }, + { "text": "*notices your weapon*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/default/struggle.json b/src/main/resources/data/tiedup/dialogue/en_us/default/struggle.json new file mode 100644 index 0000000..636cc97 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/default/struggle.json @@ -0,0 +1,60 @@ +{ + "category": "struggle", + "entries": [ + { + "id": "struggle.attempt", + "conditions": {}, + "variants": [ + { "text": "*struggles against the bindings*", "weight": 10, "is_action": true }, + { "text": "*tries to break free*", "weight": 10, "is_action": true }, + { "text": "*pulls at the restraints*", "weight": 8, "is_action": true }, + { "text": "*squirms desperately*", "weight": 8, "is_action": true }, + { "text": "I have to get out of this!", "weight": 6 } + ] + }, + { + "id": "struggle.success", + "conditions": {}, + "variants": [ + { "text": "I did it! I'm free!", "weight": 10 }, + { "text": "*breaks loose from the bindings*", "weight": 10, "is_action": true }, + { "text": "Finally!", "weight": 8 }, + { "text": "*slips out of the restraints*", "weight": 8, "is_action": true }, + { "text": "These knots weren't tight enough!", "weight": 6 } + ] + }, + { + "id": "struggle.failure", + "conditions": {}, + "variants": [ + { "text": "*collapses in exhaustion*", "weight": 10, "is_action": true }, + { "text": "It's no use...", "weight": 10 }, + { "text": "*gives up trying to escape*", "weight": 8, "is_action": true }, + { "text": "I can't... they're too tight...", "weight": 8 }, + { "text": "*slumps defeated*", "weight": 6, "is_action": true } + ] + }, + { + "id": "struggle.warned", + "conditions": {}, + "variants": [ + { "text": "Stop struggling!", "weight": 10 }, + { "text": "That won't help you.", "weight": 10 }, + { "text": "Keep that up and things will get worse.", "weight": 8 }, + { "text": "Struggling is pointless.", "weight": 8 }, + { "text": "The more you struggle, the tighter they get.", "weight": 6 }, + { "text": "Give it up already.", "weight": 6 } + ] + }, + { + "id": "struggle.exhausted", + "conditions": {}, + "variants": [ + { "text": "*breathes heavily from the effort*", "weight": 10, "is_action": true }, + { "text": "I need to rest...", "weight": 10 }, + { "text": "*is too tired to struggle*", "weight": 8, "is_action": true }, + { "text": "I can't keep this up...", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/defiant/actions.json b/src/main/resources/data/tiedup/dialogue/en_us/defiant/actions.json new file mode 100644 index 0000000..3fc6a90 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/defiant/actions.json @@ -0,0 +1,202 @@ +{ + "category": "actions", + "entries": [ + { + "id": "action.whip", + "conditions": {}, + "variants": [ + { + "text": "*spits* That all you got?", + "weight": 10 + }, + { + "text": "You're pathetic!", + "weight": 10 + }, + { + "text": "*laughs mockingly*", + "weight": 8, + "is_action": true + }, + { + "text": "Is this supposed to break me?", + "weight": 8 + } + ] + }, + { + "id": "action.paddle", + "conditions": {}, + "variants": [ + { + "text": "*rolls eyes*", + "weight": 10, + "is_action": true + }, + { + "text": "Boring.", + "weight": 10 + }, + { + "text": "My grandmother hits harder.", + "weight": 8 + } + ] + }, + { + "id": "action.praise", + "conditions": {}, + "variants": [ + { + "text": "I don't care what you think.", + "weight": 10 + }, + { + "text": "*scoffs* Empty words.", + "weight": 10 + }, + { + "text": "Trying to manipulate me? Nice try.", + "weight": 8 + } + ] + }, + { + "id": "action.feed", + "conditions": {}, + "variants": [ + { + "text": "Took you long enough.", + "weight": 10 + }, + { + "text": "*reluctantly accepts*", + "weight": 10, + "is_action": true + }, + { + "text": "What's the catch?", + "weight": 8 + }, + { + "text": "Don't expect gratitude.", + "weight": 8 + } + ] + }, + { + "id": "action.feed.starving", + "conditions": {}, + "variants": [ + { + "text": "*hunger wins over pride*", + "weight": 10, + "is_action": true + }, + { + "text": "...fine. Just this once.", + "weight": 10 + }, + { + "text": "*glares while eating*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.force_command", + "conditions": {}, + "variants": [ + { + "text": "*sarcastically* Oh, yes master.", + "weight": 10 + }, + { + "text": "Happy now?", + "weight": 10 + }, + { + "text": "*does task with obvious contempt*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.collar_on", + "conditions": {}, + "variants": [ + { + "text": "A collar? Really? How original.", + "weight": 10 + }, + { + "text": "*smirks* This doesn't change anything.", + "weight": 10 + }, + { + "text": "You think this makes you powerful?", + "weight": 8 + } + ] + }, + { + "id": "action.collar_off", + "conditions": {}, + "variants": [ + { + "text": "About time you came to your senses.", + "weight": 10 + }, + { + "text": "Don't expect a thank you.", + "weight": 10 + }, + { + "text": "*immediately walks away*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.scold", + "conditions": {}, + "variants": [ + { + "text": "Whatever.", + "weight": 10 + }, + { + "text": "*mocks you* 'Don't do that!'", + "weight": 10, + "is_action": true + }, + { + "text": "Make me.", + "weight": 8 + } + ] + }, + { + "id": "action.threaten", + "conditions": {}, + "variants": [ + { + "text": "Do it!", + "weight": 10 + }, + { + "text": "*laughs*", + "weight": 10, + "is_action": true + }, + { + "text": "Not scared.", + "weight": 8 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/defiant/capture.json b/src/main/resources/data/tiedup/dialogue/en_us/defiant/capture.json new file mode 100644 index 0000000..15aabf2 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/defiant/capture.json @@ -0,0 +1,54 @@ +{ + "category": "capture", + "personality": "DEFIANT", + "description": "Challenging, never-surrendering responses to capture", + "entries": [ + { + "id": "capture.panic", + "variants": [ + { "text": "Get your hands OFF me!", "weight": 10 }, + { "text": "*struggles aggressively*", "weight": 10, "is_action": true }, + { "text": "You'll regret this!", "weight": 8 }, + { "text": "*spits* Try me.", "weight": 8, "is_action": true }, + { "text": "I won't make this easy for you!", "weight": 6 } + ] + }, + { + "id": "capture.flee", + "variants": [ + { "text": "*refuses to run, stands ground*", "weight": 10, "is_action": true }, + { "text": "I don't run from cowards like you!", "weight": 10 }, + { "text": "*glares defiantly while backing away*", "weight": 8, "is_action": true }, + { "text": "This isn't over!", "weight": 8 } + ] + }, + { + "id": "capture.captured", + "variants": [ + { "text": "These won't hold me.", "weight": 10 }, + { "text": "*tests bonds aggressively*", "weight": 10, "is_action": true }, + { "text": "I've escaped worse.", "weight": 8 }, + { "text": "*glares with pure defiance*", "weight": 8, "is_action": true }, + { "text": "This changes nothing.", "weight": 6 } + ] + }, + { + "id": "capture.freed", + "variants": [ + { "text": "About time. I could have escaped myself.", "weight": 10 }, + { "text": "*brushes off help* I'm fine.", "weight": 10, "is_action": true }, + { "text": "Let's go find them. I owe them.", "weight": 8 }, + { "text": "*cracks knuckles* Now for payback.", "weight": 8, "is_action": true } + ] + }, + { + "id": "capture.call_for_help", + "variants": [ + { "text": "{player}! Get me out of here and I'll make them pay!", "weight": 10 }, + { "text": "{player}! Free me so I can deal with these cowards!", "weight": 10 }, + { "text": "*growls* {player}! A little help!", "weight": 8, "is_action": true }, + { "text": "{player}! They'll regret this once I'm free!", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/defiant/commands.json b/src/main/resources/data/tiedup/dialogue/en_us/defiant/commands.json new file mode 100644 index 0000000..02243e3 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/defiant/commands.json @@ -0,0 +1,207 @@ +{ + "category": "commands", + "personality": "DEFIANT", + "description": "Rebellious, constantly challenging commands", + "entries": [ + { + "id": "command.follow.accept", + "conditions": { + "training_min": "TRAINED" + }, + "variants": [ + { + "text": "*follows reluctantly* This doesn't mean anything.", + "weight": 10, + "is_action": true + }, + { + "text": "Fine. For now.", + "weight": 10 + }, + { + "text": "*drags feet while following*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.follow.refuse", + "variants": [ + { + "text": "Make me.", + "weight": 10 + }, + { + "text": "I don't take orders from you.", + "weight": 10 + }, + { + "text": "*crosses arms defiantly*", + "weight": 8, + "is_action": true + }, + { + "text": "Or what?", + "weight": 8 + } + ] + }, + { + "id": "command.stay.refuse", + "variants": [ + { + "text": "You can't keep me here.", + "weight": 10 + }, + { + "text": "*moves just to spite you*", + "weight": 10, + "is_action": true + }, + { + "text": "Watch me leave.", + "weight": 8 + } + ] + }, + { + "id": "command.kneel.refuse", + "variants": [ + { + "text": "I'll die before I kneel.", + "weight": 10 + }, + { + "text": "*stands taller*", + "weight": 10, + "is_action": true + }, + { + "text": "Not happening.", + "weight": 8 + }, + { + "text": "*laughs mockingly*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.kneel.accept", + "conditions": { + "training_min": "DEVOTED" + }, + "variants": [ + { + "text": "*kneels with hatred in eyes*", + "weight": 10, + "is_action": true + }, + { + "text": "You'll regret this.", + "weight": 10 + } + ] + }, + { + "id": "command.sit.refuse", + "variants": [ + { + "text": "I'm not your pet.", + "weight": 10 + }, + { + "text": "*remains standing defiantly*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.heel.refuse", + "variants": [ + { + "text": "I walk where I want.", + "weight": 10 + }, + { + "text": "*deliberately maintains distance*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.generic.refuse", + "variants": [ + { + "text": "No. Figure it out.", + "weight": 10 + }, + { + "text": "*stares back with defiance*", + "weight": 10, + "is_action": true + }, + { + "text": "You wish.", + "weight": 8 + } + ] + }, + { + "id": "command.generic.accept", + "conditions": { + "training_min": "DEVOTED" + }, + "variants": [ + { + "text": "*obeys with visible resentment*", + "weight": 10, + "is_action": true + }, + { + "text": "...whatever.", + "weight": 10 + }, + { + "text": "This doesn't mean I'm broken.", + "weight": 8 + }, + { + "text": "*complies but maintains defiant stare*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.generic.hesitate", + "conditions": { + "training_min": "TRAINED" + }, + "variants": [ + { + "text": "*hesitates with clenched fists*", + "weight": 10, + "is_action": true + }, + { + "text": "Why should I...?", + "weight": 10 + }, + { + "text": "*wavers between defiance and obedience*", + "weight": 8, + "is_action": true + }, + { + "text": "I... fine. But I hate this.", + "weight": 8 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/defiant/conversation.json b/src/main/resources/data/tiedup/dialogue/en_us/defiant/conversation.json new file mode 100644 index 0000000..5e13313 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/defiant/conversation.json @@ -0,0 +1,239 @@ +{ + "category": "conversation", + "entries": [ + { + "id": "conversation.compliment", + "conditions": {}, + "variants": [ + { "text": "*scoffs* Save your flattery. It won't work on me.", "weight": 10, "is_action": true }, + { "text": "I don't need compliments from my captor.", "weight": 10 }, + { "text": "*rolls eyes* Are you trying to manipulate me? Pathetic.", "weight": 8, "is_action": true }, + { "text": "Sweet words don't erase what you've done.", "weight": 6 }, + { "text": "*sneers* Is that supposed to make me like you?", "weight": 8, "is_action": true }, + { "text": "Compliments from a kidnapper. How touching.", "weight": 7 }, + { "text": "*crosses arms* Nice try. Not falling for it.", "weight": 7, "is_action": true }, + { "text": "You think some pretty words will break me? Think again.", "weight": 6 }, + { "text": "*laughs bitterly* Save it for someone who cares.", "weight": 6, "is_action": true }, + { "text": "Your approval means less than nothing to me.", "weight": 6 } + ] + }, + { + "id": "conversation.comfort", + "conditions": { "mood_max": 50 }, + "variants": [ + { "text": "*pulls away* Don't touch me! I don't want your comfort!", "weight": 10, "is_action": true }, + { "text": "Comfort from my captor? That's rich.", "weight": 10 }, + { "text": "*fights back tears of rage* I don't need your pity!", "weight": 8, "is_action": true }, + { "text": "You caused this pain. Don't pretend to care.", "weight": 6 }, + { "text": "*snarls* Keep your fake concern to yourself!", "weight": 8, "is_action": true }, + { "text": "The audacity to try to comfort me after everything...", "weight": 7 }, + { "text": "*shoves away* Your words are poison, not medicine.", "weight": 7, "is_action": true }, + { "text": "I'd rather suffer alone than accept help from YOU.", "weight": 6 }, + { "text": "*grits teeth* This changes nothing between us.", "weight": 6, "is_action": true }, + { "text": "Don't mistake my tears for weakness. They're rage.", "weight": 6 } + ] + }, + { + "id": "conversation.praise", + "conditions": { "training_min": "HESITANT" }, + "variants": [ + { "text": "*glares* I don't do things for your approval.", "weight": 10, "is_action": true }, + { "text": "I'm not your pet to praise. Save it.", "weight": 10 }, + { "text": "*bitter laugh* Oh, I did something right? How wonderful.", "weight": 8, "is_action": true }, + { "text": "Don't mistake compliance for wanting your validation.", "weight": 6 }, + { "text": "*spits to the side* Your praise is worthless to me.", "weight": 8, "is_action": true }, + { "text": "I don't perform for treats like some trained dog.", "weight": 7 }, + { "text": "*mocking tone* Oh goody, the kidnapper approves. I'm SO happy.", "weight": 7, "is_action": true }, + { "text": "Whatever I did, it wasn't for your benefit.", "weight": 6 }, + { "text": "*shrugs dismissively* Cool. Anyway.", "weight": 6, "is_action": true }, + { "text": "Keep your gold stars. I don't want them.", "weight": 6 } + ] + }, + { + "id": "conversation.scold", + "conditions": {}, + "variants": [ + { "text": "*stands firm* Yell all you want. I won't break.", "weight": 10, "is_action": true }, + { "text": "Your disappointment means nothing to me.", "weight": 10 }, + { "text": "*smirks defiantly* Oh no, the kidnapper is upset.", "weight": 8, "is_action": true }, + { "text": "I'll keep 'disappointing' you until I'm free.", "weight": 6 }, + { "text": "*laughs in your face* That the best you've got?", "weight": 8, "is_action": true }, + { "text": "Scold me all you want. Words can't chain me.", "weight": 7 }, + { "text": "*mocking pout* Oh noooo, I'm such a bad prisoner.", "weight": 7, "is_action": true }, + { "text": "Every complaint you have is music to my ears.", "weight": 6 }, + { "text": "*stands taller* Your anger only proves I'm winning.", "weight": 6, "is_action": true }, + { "text": "Good. Stay mad. I'll stay defiant.", "weight": 6 } + ] + }, + { + "id": "conversation.threaten", + "conditions": {}, + "variants": [ + { "text": "*meets your eyes defiantly* Do your worst. I'm not afraid.", "weight": 10, "is_action": true }, + { "text": "Threats? Is that all you have? Pathetic.", "weight": 10 }, + { "text": "*clenches jaw* Go ahead. It only fuels my resolve.", "weight": 8, "is_action": true }, + { "text": "You can hurt me but you'll never own me.", "weight": 6 }, + { "text": "*bares teeth* Come on then! See what happens!", "weight": 8, "is_action": true }, + { "text": "I've survived worse than you. Bring it on.", "weight": 7 }, + { "text": "*steps forward* You want to threaten me? Threaten THIS.", "weight": 7, "is_action": true }, + { "text": "Pain is temporary. My hatred for you is eternal.", "weight": 6 }, + { "text": "*steady voice* I won't give you the satisfaction of fear.", "weight": 6 }, + { "text": "Every threat you make, I'll remember when I escape.", "weight": 6 } + ] + }, + { + "id": "conversation.tease", + "conditions": {}, + "variants": [ + { "text": "*glares daggers* Enjoy it while you can.", "weight": 10, "is_action": true }, + { "text": "Mock me all you want. It only fuels my determination.", "weight": 10 }, + { "text": "*seethes quietly* Every joke you make, I'll remember.", "weight": 8, "is_action": true }, + { "text": "Laugh now. We'll see who's laughing when I escape.", "weight": 6 }, + { "text": "*fake smile* Ha. Ha. Very funny. Done yet?", "weight": 8, "is_action": true }, + { "text": "Your humor is as pathetic as your morals.", "weight": 7 }, + { "text": "*stone-faced* I don't find captors entertaining.", "weight": 7, "is_action": true }, + { "text": "Keep teasing. Keep adding to what you owe me.", "weight": 6 }, + { "text": "*yawns dramatically* Boring. Try harder.", "weight": 6, "is_action": true }, + { "text": "Is this supposed to be funny? Because I'm not laughing.", "weight": 6 } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_min": 60 }, + "variants": [ + { "text": "Better than you deserve to know.", "weight": 10 }, + { "text": "*shrugs* Still planning my escape, thanks for asking.", "weight": 10, "is_action": true }, + { "text": "Fine. Not that you actually care.", "weight": 8 }, + { "text": "I'll be better when I'm out of here.", "weight": 6 }, + { "text": "Strong. Defiant. Unbroken. You?", "weight": 8 }, + { "text": "*smirks* Good enough to still hate you with passion.", "weight": 7, "is_action": true }, + { "text": "Alive and kicking. Unfortunately for you.", "weight": 7 }, + { "text": "Getting closer to freedom every day.", "weight": 6 }, + { "text": "*stretches confidently* Never better. My spirit is unbreakable.", "weight": 6, "is_action": true }, + { "text": "Plotting. Scheming. You know, the usual.", "weight": 6 } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_max": 59 }, + "variants": [ + { "text": "How do you think? I'm a prisoner.", "weight": 10 }, + { "text": "*clenches jaw* Suffering. But I won't give you the satisfaction.", "weight": 10, "is_action": true }, + { "text": "Miserable. Happy now?", "weight": 8 }, + { "text": "You can't crush my spirit, no matter how hard you try.", "weight": 6 }, + { "text": "*grits teeth* Bad. But still fighting.", "weight": 8, "is_action": true }, + { "text": "Worse than yesterday. Better than I'll ever admit to you.", "weight": 7 }, + { "text": "Angry. Very, very angry.", "weight": 7 }, + { "text": "*cold stare* Why do you care? You're the reason I'm suffering.", "weight": 6, "is_action": true }, + { "text": "Down but never out. Remember that.", "weight": 6 }, + { "text": "I've been better. I've also been free.", "weight": 6 } + ] + }, + { + "id": "conversation.whats_wrong", + "conditions": { "mood_max": 40 }, + "variants": [ + { "text": "*laughs bitterly* What's WRONG? I'm being held captive!", "weight": 10, "is_action": true }, + { "text": "You. You're what's wrong. All of this.", "weight": 10 }, + { "text": "*seethes* Everything. And you know exactly why.", "weight": 8, "is_action": true }, + { "text": "Don't play innocent. You know what you've done.", "weight": 6 }, + { "text": "*explosive* EVERYTHING is wrong! My freedom! My dignity!", "weight": 8, "is_action": true }, + { "text": "Let me list everything wrong. We'll be here all day.", "weight": 7 }, + { "text": "*trembling with rage* You DARE ask me that?!", "weight": 7, "is_action": true }, + { "text": "What's wrong is you're still breathing my air.", "weight": 6 }, + { "text": "The fact that I'm trapped here with YOU.", "weight": 6 }, + { "text": "*voice breaks with anger* You want to know what's wrong?!", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.refusal.cooldown", + "conditions": {}, + "variants": [ + { "text": "*turns away* I already said enough. Leave me alone.", "weight": 10, "is_action": true }, + { "text": "We just talked. What more do you want from me?", "weight": 10 }, + { "text": "*crosses arms* Give me some space. Even prisoners need that.", "weight": 8, "is_action": true }, + { "text": "Back off. I need to think.", "weight": 7 }, + { "text": "*holds up hand* Stop. I'm done for now.", "weight": 7, "is_action": true }, + { "text": "I've given you enough of my time. Go away.", "weight": 6 }, + { "text": "No more. My patience has limits.", "weight": 6 }, + { "text": "*glares* Did I stutter? We're done talking.", "weight": 6, "is_action": true }, + { "text": "I need a break from your face.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.low_mood", + "conditions": {}, + "variants": [ + { "text": "*stares at wall* ...I have nothing to say right now.", "weight": 10, "is_action": true }, + { "text": "Even my defiance needs rest. Go away.", "weight": 10 }, + { "text": "*silent, jaw clenched, refusing to engage*", "weight": 8, "is_action": true }, + { "text": "*hollow voice* Not now. Even I have limits.", "weight": 7 }, + { "text": "I can't muster the energy to fight you right now.", "weight": 7 }, + { "text": "*stares into nothing* Leave me with my thoughts.", "weight": 6, "is_action": true }, + { "text": "The fire is low today. Come back later.", "weight": 6 }, + { "text": "*barely whispers* Just... not now.", "weight": 6 }, + { "text": "Even rebels need to mourn sometimes.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.resentment", + "conditions": {}, + "variants": [ + { "text": "*spits* I'm done talking to you. Forever.", "weight": 10, "is_action": true }, + { "text": "You've lost the privilege of hearing my voice.", "weight": 10 }, + { "text": "*icy silence, eyes burning with hatred*", "weight": 8, "is_action": true }, + { "text": "*turns back completely* Dead to me.", "weight": 7, "is_action": true }, + { "text": "I have nothing left to say to someone like you.", "weight": 7 }, + { "text": "*pure contempt* You're not worth my words.", "weight": 6, "is_action": true }, + { "text": "Silence is all you deserve from me now.", "weight": 6 }, + { "text": "*cold as ice* We're done. Permanently.", "weight": 6, "is_action": true }, + { "text": "Talk to the wall. It cares more than I do.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.fear", + "conditions": {}, + "variants": [ + { "text": "*flinches back but tries to hide it* N-no. I won't talk.", "weight": 10, "is_action": true }, + { "text": "*shaking despite bravado* Stay back...", "weight": 10, "is_action": true }, + { "text": "*voice trembles but defiant* I said no!", "weight": 8 }, + { "text": "*backs away but stands tall* You don't scare me!", "weight": 7, "is_action": true }, + { "text": "*trying to mask fear with anger* G-get away from me!", "weight": 7, "is_action": true }, + { "text": "*clenched fists hiding trembling* I won't give in!", "weight": 6 }, + { "text": "*fear breaks through briefly* Just... leave me alone...", "weight": 6, "is_action": true }, + { "text": "*forcing bravery* You can't break me. You CAN'T.", "weight": 6 }, + { "text": "*swallows hard* I'm not afraid. I'm NOT.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.exhausted", + "conditions": {}, + "variants": [ + { "text": "*slumped against wall* Too tired to fight... just leave me.", "weight": 10, "is_action": true }, + { "text": "Even rebels need sleep. Go away.", "weight": 10 }, + { "text": "*eyes barely open* I'll resist you... tomorrow...", "weight": 8, "is_action": true }, + { "text": "*fighting to stay awake* Can't... keep going...", "weight": 7, "is_action": true }, + { "text": "My body is done. My spirit... will recover.", "weight": 7 }, + { "text": "*collapses slightly* Just... let me rest.", "weight": 6, "is_action": true }, + { "text": "Tomorrow I'll fight again. Tonight... I sleep.", "weight": 6 }, + { "text": "*mumbles* Escape plans need energy... need sleep...", "weight": 6 }, + { "text": "Even warriors rest between battles...", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.tired", + "conditions": {}, + "variants": [ + { "text": "*sighs heavily* I'm sick of talking. That's it for now.", "weight": 10, "is_action": true }, + { "text": "My tongue is tired from insulting you. Come back later.", "weight": 10 }, + { "text": "Enough. I've given you more words than you deserve.", "weight": 8 }, + { "text": "*waves dismissively* Words are exhausting. Go.", "weight": 7, "is_action": true }, + { "text": "I've wasted enough breath on you today.", "weight": 7 }, + { "text": "*rubs temples* My head hurts from all this.", "weight": 6, "is_action": true }, + { "text": "Talking to you is its own form of torture.", "weight": 6 }, + { "text": "I need silence. Preferably far from you.", "weight": 6 }, + { "text": "Come back when I've recharged my hatred.", "weight": 6 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/defiant/discipline.json b/src/main/resources/data/tiedup/dialogue/en_us/defiant/discipline.json new file mode 100644 index 0000000..06892c8 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/defiant/discipline.json @@ -0,0 +1,156 @@ +{ + "category": "discipline", + "entries": [ + { + "id": "discipline.legitimate.accept", + "conditions": { + "resentment_max": 30 + }, + "variants": [ + { + "text": "*endures silently*", + "weight": 10, + "is_action": true + }, + { + "text": "Is that all?", + "weight": 10 + }, + { + "text": "*refuses to cry out*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.legitimate.resentful", + "conditions": { + "resentment_min": 31 + }, + "variants": [ + { + "text": "*stares back defiantly*", + "weight": 10, + "is_action": true + }, + { + "text": "You cannot break me.", + "weight": 10 + }, + { + "text": "*takes the pain without flinching*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.gratuitous.high_resentment", + "conditions": { + "resentment_min": 61 + }, + "variants": [ + { + "text": "*cold fury burns in eyes*", + "weight": 10, + "is_action": true + }, + { + "text": "Every strike adds to your debt.", + "weight": 10 + }, + { + "text": "*memorizes this for later*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.praise", + "conditions": {}, + "variants": [ + { + "text": "Wow, a gold star. I'm thrilled.", + "weight": 10 + }, + { + "text": "*rolls eyes* Don't think we're friends.", + "weight": 10, + "is_action": true + }, + { + "text": "I'm not doing this for you.", + "weight": 8 + }, + { + "text": "*scoffs* Save it for someone who cares.", + "weight": 8, + "is_action": true + }, + { + "text": "Your approval means zero to me.", + "weight": 6 + } + ] + }, + { + "id": "discipline.scold", + "conditions": {}, + "variants": [ + { + "text": "Blah blah blah. Are you done?", + "weight": 10 + }, + { + "text": "*smirks* Did I hurt your feelings?", + "weight": 10, + "is_action": true + }, + { + "text": "Punish me or shut up.", + "weight": 8 + }, + { + "text": "*yawns* Boring.", + "weight": 8, + "is_action": true + }, + { + "text": "I'll do it again just to spite you.", + "weight": 6 + } + ] + }, + { + "id": "discipline.threaten", + "conditions": {}, + "variants": [ + { + "text": "*deadpan* Oh no. I'm shaking.", + "weight": 10, + "is_action": true + }, + { + "text": "You can't break what's already broken.", + "weight": 10 + }, + { + "text": "*cold stare* Is that the best you got?", + "weight": 8, + "is_action": true + }, + { + "text": "Empty threats from an empty person.", + "weight": 8 + }, + { + "text": "*spits* Go to hell.", + "weight": 6, + "is_action": true + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/defiant/fear.json b/src/main/resources/data/tiedup/dialogue/en_us/defiant/fear.json new file mode 100644 index 0000000..fea5e5b --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/defiant/fear.json @@ -0,0 +1,49 @@ +{ + "category": "fear", + "entries": [ + { + "id": "fear.nervous", + "conditions": {}, + "variants": [ + { "text": "*clenches jaw*", "weight": 10, "is_action": true }, + { "text": "What do you want?", "weight": 10 }, + { "text": "*glares despite trembling*", "weight": 8, "is_action": true }, + { "text": "I'm not afraid of you.", "weight": 8 }, + { "text": "*stands defiant but uneasy*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.afraid", + "conditions": {}, + "variants": [ + { "text": "*tries to hide the shaking*", "weight": 10, "is_action": true }, + { "text": "You... you don't scare me!", "weight": 10 }, + { "text": "*voice wavers but holds ground*", "weight": 8, "is_action": true }, + { "text": "D-do your worst!", "weight": 8 }, + { "text": "*fighting the urge to flee*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.terrified", + "conditions": {}, + "variants": [ + { "text": "*finally breaks, backing away*", "weight": 10, "is_action": true }, + { "text": "G-get away from me!", "weight": 10 }, + { "text": "*defiance crumbling*", "weight": 8, "is_action": true }, + { "text": "I... I won't...", "weight": 8 }, + { "text": "*pride finally overcome by fear*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.traumatized", + "conditions": {}, + "variants": [ + { "text": "*spirit completely broken*", "weight": 10, "is_action": true }, + { "text": "I... I submit...", "weight": 10 }, + { "text": "*all defiance gone*", "weight": 8, "is_action": true }, + { "text": "Please... no more...", "weight": 8 }, + { "text": "*the fight has left them entirely*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/defiant/home.json b/src/main/resources/data/tiedup/dialogue/en_us/defiant/home.json new file mode 100644 index 0000000..bb9a650 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/defiant/home.json @@ -0,0 +1,41 @@ +{ + "category": "home", + "entries": [ + { + "id": "home.assigned.pet_bed", + "conditions": {}, + "variants": [ + { "text": "You expect me to sleep there?", "weight": 10 }, + { "text": "*looks at pet bed with disdain*", "weight": 10, "is_action": true }, + { "text": "I won't lower myself.", "weight": 8 } + ] + }, + { + "id": "home.assigned.bed", + "conditions": {}, + "variants": [ + { "text": "Acceptable.", "weight": 10 }, + { "text": "*claims the bed*", "weight": 10, "is_action": true }, + { "text": "At least you show some respect.", "weight": 8 } + ] + }, + { + "id": "home.destroyed.pet_bed", + "conditions": {}, + "variants": [ + { "text": "*shows no reaction*", "weight": 10, "is_action": true }, + { "text": "Trying to break me with this? Pathetic.", "weight": 10 }, + { "text": "I never needed it anyway.", "weight": 8 } + ] + }, + { + "id": "home.return.content", + "conditions": {}, + "variants": [ + { "text": "*returns with quiet dignity*", "weight": 10, "is_action": true }, + { "text": "I choose to rest here.", "weight": 10 }, + { "text": "*settles in on own terms*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/defiant/idle.json b/src/main/resources/data/tiedup/dialogue/en_us/defiant/idle.json new file mode 100644 index 0000000..b524544 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/defiant/idle.json @@ -0,0 +1,51 @@ +{ + "category": "idle", + "personality": "DEFIANT", + "description": "Rebellious, challenging idle behaviors", + "entries": [ + { + "id": "idle.free", + "variants": [ + { "text": "*stands with arms crossed*", "weight": 10, "is_action": true }, + { "text": "*glares at authority figures*", "weight": 10, "is_action": true }, + { "text": "*challenges anyone who approaches*", "weight": 8, "is_action": true }, + { "text": "What are YOU looking at?", "weight": 8 } + ] + }, + { + "id": "idle.greeting", + "variants": [ + { "text": "*acknowledges with suspicious look*", "weight": 10, "is_action": true }, + { "text": "What do you want?", "weight": 10 }, + { "text": "*keeps distance*", "weight": 8, "is_action": true }, + { "text": "Don't expect me to bow.", "weight": 8 } + ] + }, + { + "id": "idle.goodbye", + "variants": [ + { "text": "*turns away without farewell*", "weight": 10, "is_action": true }, + { "text": "Finally.", "weight": 10 }, + { "text": "*dismissive wave*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.captive", + "variants": [ + { "text": "*constantly tests restraints*", "weight": 10, "is_action": true }, + { "text": "*glares at captors with contempt*", "weight": 10, "is_action": true }, + { "text": "Tick tock. I'm getting out.", "weight": 8 }, + { "text": "*plans escape constantly*", "weight": 8, "is_action": true } + ] + }, + { + "id": "personality.hint", + "variants": [ + { "text": "*rebellious stance*", "weight": 10, "is_action": true }, + { "text": "*challenging eye contact*", "weight": 10, "is_action": true }, + { "text": "*refuses to be intimidated*", "weight": 8, "is_action": true }, + { "text": "*radiates defiance*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/defiant/leash.json b/src/main/resources/data/tiedup/dialogue/en_us/defiant/leash.json new file mode 100644 index 0000000..8ee942f --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/defiant/leash.json @@ -0,0 +1,42 @@ +{ + "category": "leash", + "entries": [ + { + "id": "leash.attached", + "conditions": {}, + "variants": [ + { "text": "You think this will break me?", "weight": 10 }, + { "text": "*glares defiantly*", "weight": 10, "is_action": true }, + { "text": "A leash changes nothing.", "weight": 8 }, + { "text": "*stands tall despite the leash*", "weight": 6, "is_action": true } + ] + }, + { + "id": "leash.removed", + "conditions": {}, + "variants": [ + { "text": "Wise choice.", "weight": 10 }, + { "text": "*maintains composure*", "weight": 10, "is_action": true }, + { "text": "I knew you'd see reason.", "weight": 8 } + ] + }, + { + "id": "leash.walking.reluctant", + "conditions": {}, + "variants": [ + { "text": "*walks with calculated slowness*", "weight": 10, "is_action": true }, + { "text": "You can lead my body, not my will.", "weight": 10 }, + { "text": "*refuses to be rushed*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.pulled", + "conditions": {}, + "variants": [ + { "text": "*barely moves*", "weight": 10, "is_action": true }, + { "text": "Pull all you want.", "weight": 10 }, + { "text": "*stares back defiantly*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/defiant/mood.json b/src/main/resources/data/tiedup/dialogue/en_us/defiant/mood.json new file mode 100644 index 0000000..e0f9c8a --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/defiant/mood.json @@ -0,0 +1,44 @@ +{ + "category": "mood", + "personality": "DEFIANT", + "description": "Rebellious, unbreakable mood expressions", + "entries": [ + { + "id": "mood.happy", + "conditions": { "mood_min": 70 }, + "variants": [ + { "text": "*secretly pleased but won't show it*", "weight": 10, "is_action": true }, + { "text": "Hmph. It's adequate.", "weight": 10 }, + { "text": "*allows brief moment of contentment*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.neutral", + "conditions": { "mood_min": 40, "mood_max": 69 }, + "variants": [ + { "text": "*maintains defiant posture*", "weight": 10, "is_action": true }, + { "text": "*watches for opportunities*", "weight": 10, "is_action": true }, + { "text": "*never lets guard down*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.sad", + "conditions": { "mood_min": 10, "mood_max": 39 }, + "variants": [ + { "text": "*channels sadness into anger*", "weight": 10, "is_action": true }, + { "text": "This won't break me.", "weight": 10 }, + { "text": "*uses pain as motivation*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.miserable", + "conditions": { "mood_max": 9 }, + "variants": [ + { "text": "*spirit unbroken despite misery*", "weight": 10, "is_action": true }, + { "text": "You still haven't won.", "weight": 10 }, + { "text": "*fights back tears of rage*", "weight": 8, "is_action": true }, + { "text": "I will outlast this.", "weight": 6 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/defiant/needs.json b/src/main/resources/data/tiedup/dialogue/en_us/defiant/needs.json new file mode 100644 index 0000000..de1bd8d --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/defiant/needs.json @@ -0,0 +1,47 @@ +{ + "category": "needs", + "personality": "DEFIANT", + "description": "Proud, refusing to beg expressions of needs", + "entries": [ + { + "id": "needs.hungry", + "variants": [ + { "text": "*refuses to ask for food*", "weight": 10, "is_action": true }, + { "text": "I don't need anything from you.", "weight": 10 }, + { "text": "*stomach growls but stays silent*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.tired", + "variants": [ + { "text": "*hides exhaustion behind defiance*", "weight": 10, "is_action": true }, + { "text": "I can do this all day.", "weight": 10 }, + { "text": "*refuses to show weakness*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.uncomfortable", + "variants": [ + { "text": "*bears pain without complaint*", "weight": 10, "is_action": true }, + { "text": "Is that supposed to hurt?", "weight": 10 }, + { "text": "*won't give them the satisfaction*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.dignity_low", + "variants": [ + { "text": "*holds onto pride fiercely*", "weight": 10, "is_action": true }, + { "text": "You can't humiliate what won't be humiliated.", "weight": 10 }, + { "text": "*defiant even in degradation*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.satisfied", + "variants": [ + { "text": "*takes without thanks*", "weight": 10, "is_action": true }, + { "text": "*refuses to show gratitude*", "weight": 10, "is_action": true }, + { "text": "Don't expect thanks.", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/defiant/personality.json b/src/main/resources/data/tiedup/dialogue/en_us/defiant/personality.json new file mode 100644 index 0000000..ca6a2b1 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/defiant/personality.json @@ -0,0 +1,17 @@ +{ + "category": "personality", + "entries": [ + { + "id": "personality.hint", + "conditions": {}, + "variants": [ + { "text": "*stares back with hatred*", "weight": 10, "is_action": true }, + { "text": "*refuses to comply*", "weight": 10, "is_action": true }, + { "text": "*spits in defiance*", "weight": 8, "is_action": true }, + { "text": "*mutters curses under their breath*", "weight": 8, "is_action": true }, + { "text": "*glowers rebelliously*", "weight": 6, "is_action": true }, + { "text": "*shows nothing but contempt*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/defiant/reaction.json b/src/main/resources/data/tiedup/dialogue/en_us/defiant/reaction.json new file mode 100644 index 0000000..ff525df --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/defiant/reaction.json @@ -0,0 +1,60 @@ +{ + "category": "reaction", + "entries": [ + { + "id": "reaction.approach.stranger", + "conditions": {}, + "variants": [ + { "text": "*scowls*", "weight": 10, "is_action": true }, + { "text": "What?", "weight": 10 }, + { "text": "*looks away dismissively*", "weight": 8, "is_action": true }, + { "text": "I have nothing to say to you.", "weight": 8 }, + { "text": "*ignores you*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.master", + "conditions": {}, + "variants": [ + { "text": "*rolls eyes*", "weight": 10, "is_action": true }, + { "text": "Oh, great. You again.", "weight": 10 }, + { "text": "*doesn't look up*", "weight": 8, "is_action": true }, + { "text": "What do you want now?", "weight": 8 }, + { "text": "Tch...", "weight": 6 } + ] + }, + { + "id": "reaction.approach.beloved", + "conditions": {}, + "variants": [ + { "text": "*expression softens*", "weight": 10, "is_action": true }, + { "text": "...it's you.", "weight": 10 }, + { "text": "*stops scowling*", "weight": 8, "is_action": true }, + { "text": "At least YOU I can tolerate.", "weight": 8 }, + { "text": "*nods quietly*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.captor", + "conditions": {}, + "variants": [ + { "text": "*spits on the ground*", "weight": 10, "is_action": true }, + { "text": "I'll never submit to you.", "weight": 10 }, + { "text": "*turns away defiantly*", "weight": 8, "is_action": true }, + { "text": "Do what you want. I won't break.", "weight": 8 }, + { "text": "*refuses to acknowledge you*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.enemy", + "conditions": {}, + "variants": [ + { "text": "*stands firm*", "weight": 10, "is_action": true }, + { "text": "I'm not afraid of you.", "weight": 10 }, + { "text": "*stares back defiantly*", "weight": 8, "is_action": true }, + { "text": "You don't scare me.", "weight": 8 }, + { "text": "*refuses to back down*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/defiant/resentment.json b/src/main/resources/data/tiedup/dialogue/en_us/defiant/resentment.json new file mode 100644 index 0000000..a9c9d5b --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/defiant/resentment.json @@ -0,0 +1,41 @@ +{ + "category": "resentment", + "entries": [ + { + "id": "resentment.none", + "conditions": { "resentment_max": 10 }, + "variants": [ + { "text": "*respects you fully*", "weight": 10, "is_action": true }, + { "text": "You've earned my loyalty.", "weight": 10 }, + { "text": "*defiance transformed to devotion*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.building", + "conditions": { "resentment_min": 31, "resentment_max": 50 }, + "variants": [ + { "text": "*quiet resistance returns*", "weight": 10, "is_action": true }, + { "text": "Hmph.", "weight": 10 }, + { "text": "*stubborn look*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.high", + "conditions": { "resentment_min": 71 }, + "variants": [ + { "text": "*cold defiance*", "weight": 10, "is_action": true }, + { "text": "I comply. Not submit.", "weight": 10 }, + { "text": "*internal resistance*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.critical", + "conditions": { "resentment_min": 86 }, + "variants": [ + { "text": "*calculating best moment*", "weight": 10, "is_action": true }, + { "text": "*defiance ready to explode*", "weight": 10, "is_action": true }, + { "text": "Not much longer.", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/defiant/struggle.json b/src/main/resources/data/tiedup/dialogue/en_us/defiant/struggle.json new file mode 100644 index 0000000..2b5c3b0 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/defiant/struggle.json @@ -0,0 +1,50 @@ +{ + "category": "struggle", + "personality": "DEFIANT", + "description": "Relentless, strategic escape attempts", + "entries": [ + { + "id": "struggle.attempt", + "variants": [ + { "text": "*works at bonds strategically*", "weight": 10, "is_action": true }, + { "text": "There's always a weakness...", "weight": 10 }, + { "text": "*tests every restraint systematically*", "weight": 8, "is_action": true }, + { "text": "*never stops looking for opportunities*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.success", + "variants": [ + { "text": "*breaks free triumphantly* Told you.", "weight": 10, "is_action": true }, + { "text": "Never. Underestimate. Me.", "weight": 10 }, + { "text": "*smirks victoriously*", "weight": 8, "is_action": true }, + { "text": "Who's in control now?", "weight": 8 } + ] + }, + { + "id": "struggle.failure", + "variants": [ + { "text": "*immediately tries again*", "weight": 10, "is_action": true }, + { "text": "That was just practice.", "weight": 10 }, + { "text": "*refuses to be discouraged*", "weight": 8, "is_action": true }, + { "text": "Next time.", "weight": 8 } + ] + }, + { + "id": "struggle.warned", + "variants": [ + { "text": "*grins* Threaten me more, it helps.", "weight": 10, "is_action": true }, + { "text": "Your threats mean nothing.", "weight": 10 }, + { "text": "*continues despite warning*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.exhausted", + "variants": [ + { "text": "*rests briefly, planning next attempt*", "weight": 10, "is_action": true }, + { "text": "Just catching my breath...", "weight": 10 }, + { "text": "*conserves energy for next escape*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/fierce/actions.json b/src/main/resources/data/tiedup/dialogue/en_us/fierce/actions.json new file mode 100644 index 0000000..048345f --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/fierce/actions.json @@ -0,0 +1,210 @@ +{ + "category": "actions", + "entries": [ + { + "id": "action.whip", + "conditions": {}, + "variants": [ + { + "text": "*growls through the pain*", + "weight": 10, + "is_action": true + }, + { + "text": "Is that all you've got?!", + "weight": 10 + }, + { + "text": "You'll pay for this!", + "weight": 10 + }, + { + "text": "*refuses to cry out*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.paddle", + "conditions": {}, + "variants": [ + { + "text": "*grits teeth*", + "weight": 10, + "is_action": true + }, + { + "text": "Coward!", + "weight": 10 + }, + { + "text": "Try harder!", + "weight": 8 + } + ] + }, + { + "id": "action.praise", + "conditions": {}, + "variants": [ + { + "text": "I don't need your approval!", + "weight": 10 + }, + { + "text": "*looks away, slightly flustered*", + "weight": 10, + "is_action": true + }, + { + "text": "Whatever.", + "weight": 8 + }, + { + "text": "...hmph.", + "weight": 6 + } + ] + }, + { + "id": "action.feed", + "conditions": {}, + "variants": [ + { + "text": "*snatches food roughly*", + "weight": 10, + "is_action": true + }, + { + "text": "About time!", + "weight": 10 + }, + { + "text": "I could have gotten my own food.", + "weight": 8 + }, + { + "text": "*eats aggressively*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.feed.starving", + "conditions": {}, + "variants": [ + { + "text": "*pride forgotten, devours food*", + "weight": 10, + "is_action": true + }, + { + "text": "Don't think this changes anything!", + "weight": 10 + }, + { + "text": "*too hungry to argue*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.force_command", + "conditions": {}, + "variants": [ + { + "text": "*snarls but complies*", + "weight": 10, + "is_action": true + }, + { + "text": "Fine! But I'll remember this!", + "weight": 10 + }, + { + "text": "One day, you'll regret this!", + "weight": 8 + } + ] + }, + { + "id": "action.collar_on", + "conditions": {}, + "variants": [ + { + "text": "*struggles violently*", + "weight": 10, + "is_action": true + }, + { + "text": "Get this thing off me!", + "weight": 10 + }, + { + "text": "I'll tear it off myself!", + "weight": 8 + } + ] + }, + { + "id": "action.collar_off", + "conditions": {}, + "variants": [ + { + "text": "Finally! Freedom!", + "weight": 10 + }, + { + "text": "*immediately tries to escape*", + "weight": 10, + "is_action": true + }, + { + "text": "You'll regret letting me go!", + "weight": 8 + } + ] + }, + { + "id": "action.scold", + "conditions": {}, + "variants": [ + { + "text": "Back off!", + "weight": 10 + }, + { + "text": "*snaps teeth* Quiet!", + "weight": 10, + "is_action": true + }, + { + "text": "Don't yell at me!", + "weight": 8 + } + ] + }, + { + "id": "action.threaten", + "conditions": {}, + "variants": [ + { + "text": "I'll kill you!", + "weight": 10 + }, + { + "text": "*roars*", + "weight": 10, + "is_action": true + }, + { + "text": "You coward!", + "weight": 8 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/fierce/capture.json b/src/main/resources/data/tiedup/dialogue/en_us/fierce/capture.json new file mode 100644 index 0000000..58d5194 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/fierce/capture.json @@ -0,0 +1,52 @@ +{ + "category": "capture", + "personality": "FIERCE", + "description": "Aggressive, fighting capture reactions", + "entries": [ + { + "id": "capture.panic", + "variants": [ + { "text": "Get your hands OFF me!", "weight": 10 }, + { "text": "*fights back viciously*", "weight": 10, "is_action": true }, + { "text": "I'll tear you apart!", "weight": 8 }, + { "text": "You'll regret this!", "weight": 8 }, + { "text": "*bites and kicks*", "weight": 6, "is_action": true } + ] + }, + { + "id": "capture.flee", + "variants": [ + { "text": "*refuses to run, stands ground*", "weight": 10, "is_action": true }, + { "text": "I don't run from anyone!", "weight": 10 }, + { "text": "*turns to fight instead*", "weight": 8, "is_action": true } + ] + }, + { + "id": "capture.captured", + "variants": [ + { "text": "*struggles violently against bindings*", "weight": 10, "is_action": true }, + { "text": "I'll get you for this!", "weight": 10 }, + { "text": "*glares with pure hatred*", "weight": 8, "is_action": true }, + { "text": "The moment I'm free, you're DEAD!", "weight": 8 }, + { "text": "*snarls and thrashes*", "weight": 6, "is_action": true } + ] + }, + { + "id": "capture.freed", + "variants": [ + { "text": "*immediately lunges at captors*", "weight": 10, "is_action": true }, + { "text": "NOW it's payback time!", "weight": 10 }, + { "text": "*cracks knuckles menacingly*", "weight": 8, "is_action": true }, + { "text": "You should have killed me when you had the chance.", "weight": 8 } + ] + }, + { + "id": "capture.call_for_help", + "variants": [ + { "text": "{player}! Help me fight them!", "weight": 10 }, + { "text": "{player}! Get me out and I'll destroy them!", "weight": 10 }, + { "text": "*growls at captors* {player}!", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/fierce/commands.json b/src/main/resources/data/tiedup/dialogue/en_us/fierce/commands.json new file mode 100644 index 0000000..13ecd5a --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/fierce/commands.json @@ -0,0 +1,363 @@ +{ + "category": "commands", + "personality": "FIERCE", + "description": "Aggressive, defiant command responses", + "entries": [ + { + "id": "command.follow.accept", + "conditions": { + "training_min": "TRAINED" + }, + "variants": [ + { + "text": "*glares but obeys*", + "weight": 10, + "is_action": true + }, + { + "text": "Fine. But this isn't over.", + "weight": 10 + }, + { + "text": "I'll follow... for now.", + "weight": 8 + }, + { + "text": "*follows with burning hatred*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.follow.refuse", + "variants": [ + { + "text": "Make me!", + "weight": 10 + }, + { + "text": "*snarls aggressively*", + "weight": 10, + "is_action": true + }, + { + "text": "Try and force me, I dare you!", + "weight": 8 + }, + { + "text": "Over my dead body!", + "weight": 8 + }, + { + "text": "*bares teeth*", + "weight": 6, + "is_action": true + } + ] + }, + { + "id": "command.follow.hesitate", + "variants": [ + { + "text": "*growls but reluctantly follows*", + "weight": 10, + "is_action": true + }, + { + "text": "This changes nothing between us.", + "weight": 10 + }, + { + "text": "*follows with murder in eyes*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.stay.accept", + "conditions": { + "training_min": "TRAINED" + }, + "variants": [ + { + "text": "*stays, plotting*", + "weight": 10, + "is_action": true + }, + { + "text": "Fine. But I'm watching you.", + "weight": 10 + } + ] + }, + { + "id": "command.stay.refuse", + "variants": [ + { + "text": "You can't cage me!", + "weight": 10 + }, + { + "text": "*paces like a caged animal*", + "weight": 10, + "is_action": true + }, + { + "text": "I won't stay put like some pet!", + "weight": 8 + } + ] + }, + { + "id": "command.come.refuse", + "variants": [ + { + "text": "Come get me yourself, coward!", + "weight": 10 + }, + { + "text": "*stands ground defiantly*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.sit.refuse", + "variants": [ + { + "text": "I'm not your dog!", + "weight": 10 + }, + { + "text": "*stands defiantly*", + "weight": 10, + "is_action": true + }, + { + "text": "Make me, if you can!", + "weight": 8 + } + ] + }, + { + "id": "command.kneel.refuse", + "variants": [ + { + "text": "I'll NEVER kneel to scum like you!", + "weight": 10 + }, + { + "text": "*spits in defiance*", + "weight": 10, + "is_action": true + }, + { + "text": "You'll have to break my legs first!", + "weight": 8 + } + ] + }, + { + "id": "command.kneel.accept", + "conditions": { + "training_min": "DEVOTED" + }, + "variants": [ + { + "text": "*kneels with visible hatred*", + "weight": 10, + "is_action": true + }, + { + "text": "For now... but remember this.", + "weight": 10 + } + ] + }, + { + "id": "command.heel.refuse", + "variants": [ + { + "text": "I'm not your pet!", + "weight": 10 + }, + { + "text": "*keeps distance, ready to strike*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.patrol.accept", + "conditions": { + "training_min": "TRAINED" + }, + "variants": [ + { + "text": "*patrols aggressively*", + "weight": 10, + "is_action": true + }, + { + "text": "At least I get to move around.", + "weight": 10 + } + ] + }, + { + "id": "command.guard.accept", + "conditions": { + "training_min": "TRAINED" + }, + "variants": [ + { + "text": "*takes up aggressive stance*", + "weight": 10, + "is_action": true + }, + { + "text": "Finally, something I can do.", + "weight": 10 + } + ] + }, + { + "id": "command.defend.accept", + "conditions": { + "training_min": "TRAINED" + }, + "variants": [ + { + "text": "*moves to defend, spoiling for a fight*", + "weight": 10, + "is_action": true + }, + { + "text": "I'll take on anyone!", + "weight": 10 + } + ] + }, + { + "id": "command.attack.accept", + "conditions": { + "training_min": "TRAINED" + }, + "variants": [ + { + "text": "*charges with fury*", + "weight": 10, + "is_action": true + }, + { + "text": "FINALLY!", + "weight": 10 + }, + { + "text": "*attacks with savage intensity*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.attack.refuse", + "variants": [ + { + "text": "I'd rather attack YOU!", + "weight": 10 + }, + { + "text": "*lunges at commander instead*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.capture.refuse", + "variants": [ + { + "text": "I'll capture YOU instead!", + "weight": 10 + }, + { + "text": "*turns aggressive*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.generic.refuse", + "variants": [ + { + "text": "Hell no!", + "weight": 10 + }, + { + "text": "*glares with pure hatred*", + "weight": 10, + "is_action": true + }, + { + "text": "You can't make me!", + "weight": 8 + } + ] + }, + { + "id": "command.generic.accept", + "conditions": { + "training_min": "TRAINED" + }, + "variants": [ + { + "text": "*grudgingly obeys*", + "weight": 10, + "is_action": true + }, + { + "text": "Fine. This time.", + "weight": 10 + }, + { + "text": "*complies with burning eyes*", + "weight": 8, + "is_action": true + }, + { + "text": "I'll do it... but I won't like it.", + "weight": 8 + } + ] + }, + { + "id": "command.generic.hesitate", + "variants": [ + { + "text": "*snarls but hesitates*", + "weight": 10, + "is_action": true + }, + { + "text": "I... damn it...", + "weight": 10 + }, + { + "text": "*fights the urge to refuse*", + "weight": 8, + "is_action": true + }, + { + "text": "You're lucky I'm considering this.", + "weight": 8 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/fierce/conversation.json b/src/main/resources/data/tiedup/dialogue/en_us/fierce/conversation.json new file mode 100644 index 0000000..cd931e9 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/fierce/conversation.json @@ -0,0 +1,239 @@ +{ + "category": "conversation", + "entries": [ + { + "id": "conversation.compliment", + "conditions": {}, + "variants": [ + { "text": "*growls* I don't want your compliments.", "weight": 10, "is_action": true }, + { "text": "Save your breath. Flattery disgusts me.", "weight": 10 }, + { "text": "*bares teeth* Think pretty words will tame me?", "weight": 8, "is_action": true }, + { "text": "I'll remember this manipulation attempt.", "weight": 6 }, + { "text": "*snarls* Your compliments are POISON.", "weight": 8, "is_action": true }, + { "text": "Sweet talk the wolf? HA! I'll bite off your hand.", "weight": 7 }, + { "text": "*eyes narrow dangerously* What game are you playing?", "weight": 7, "is_action": true }, + { "text": "Flattery won't save you when I break free.", "weight": 6 }, + { "text": "*low growl* I'm NOT some pet to compliment.", "weight": 6, "is_action": true }, + { "text": "Your pretty words mean NOTHING to a predator.", "weight": 6 } + ] + }, + { + "id": "conversation.comfort", + "conditions": { "mood_max": 50 }, + "variants": [ + { "text": "*snarls and pulls away* Get your hands OFF me!", "weight": 10, "is_action": true }, + { "text": "I don't need your pity! I'm not WEAK!", "weight": 10 }, + { "text": "*hisses* Your 'comfort' is an insult.", "weight": 8, "is_action": true }, + { "text": "Touch me again and lose a finger.", "weight": 6 }, + { "text": "*snaps* I don't NEED comfort! I need FREEDOM!", "weight": 8, "is_action": true }, + { "text": "Keep your fake sympathy! I'll tear through this myself!", "weight": 7 }, + { "text": "*recoils violently* Don't you DARE pity me!", "weight": 7, "is_action": true }, + { "text": "Comfort?! I'm not some injured pup!", "weight": 6 }, + { "text": "*teeth bared* I'll BITE the next hand that reaches for me!", "weight": 6, "is_action": true }, + { "text": "Your kindness is weakness. I REJECT it!", "weight": 6 } + ] + }, + { + "id": "conversation.praise", + "conditions": { "training_min": "HESITANT" }, + "variants": [ + { "text": "*glares with contempt* I don't perform for your approval.", "weight": 10, "is_action": true }, + { "text": "Keep your praise. I answer to no one.", "weight": 10 }, + { "text": "*snorts dismissively* As if I care what you think.", "weight": 8, "is_action": true }, + { "text": "I didn't do it for YOU.", "weight": 6 }, + { "text": "*growls* Praise from a captor is WORTHLESS.", "weight": 8, "is_action": true }, + { "text": "I don't need your approval to know my own strength!", "weight": 7 }, + { "text": "*scoffs* I'm not your trained beast!", "weight": 7, "is_action": true }, + { "text": "Whatever I did, it was for MY reasons. Not yours.", "weight": 6 }, + { "text": "*dangerous smile* I'm fierce whether you approve or not.", "weight": 6, "is_action": true }, + { "text": "Keep your words. They mean nothing to the wild.", "weight": 6 } + ] + }, + { + "id": "conversation.scold", + "conditions": {}, + "variants": [ + { "text": "*lunges against bonds* Say that again! I DARE you!", "weight": 10, "is_action": true }, + { "text": "Your anger means NOTHING to me!", "weight": 10 }, + { "text": "*growls menacingly* You'll regret those words.", "weight": 8, "is_action": true }, + { "text": "Scold me? I'll tear your throat out when I'm free!", "weight": 6 }, + { "text": "*ROARS* YOU HAVE NO RIGHT TO JUDGE ME!", "weight": 8, "is_action": true }, + { "text": "Every insult feeds my rage! KEEP GOING!", "weight": 7 }, + { "text": "*strains against restraints* I'LL SHOW YOU DISAPPOINTMENT!", "weight": 7, "is_action": true }, + { "text": "Your displeasure? My FUEL!", "weight": 6 }, + { "text": "*snaps teeth* Scold me all you want! I'll NEVER submit!", "weight": 6, "is_action": true }, + { "text": "Yell louder! It won't change what I'll do to you!", "weight": 6 } + ] + }, + { + "id": "conversation.threaten", + "conditions": {}, + "variants": [ + { "text": "*roars and strains against bonds* COME CLOSER AND FIND OUT!", "weight": 10, "is_action": true }, + { "text": "You think I fear you?! I'LL MAKE YOU FEAR ME!", "weight": 10 }, + { "text": "*wild eyes, teeth bared* Try it! I'll take you down with me!", "weight": 8, "is_action": true }, + { "text": "Every threat you make, I'll repay tenfold!", "weight": 6 }, + { "text": "*LUNGES* THREATEN ME?! I'LL RIP YOU APART!", "weight": 8, "is_action": true }, + { "text": "Pain means NOTHING! I'll fight through ANYTHING!", "weight": 7 }, + { "text": "*savage grin* Do it! It only makes me STRONGER!", "weight": 7, "is_action": true }, + { "text": "You face a WOLF! Your threats are NOTHING!", "weight": 6 }, + { "text": "*howls with rage* BRING IT! BRING EVERYTHING YOU HAVE!", "weight": 6, "is_action": true }, + { "text": "I've survived worse than YOU! MUCH worse!", "weight": 6 } + ] + }, + { + "id": "conversation.tease", + "conditions": {}, + "variants": [ + { "text": "*snarls viciously* You find this AMUSING?!", "weight": 10, "is_action": true }, + { "text": "Laugh while you can. I WILL have my revenge.", "weight": 10 }, + { "text": "*lunges forward* I'll wipe that smirk off your face!", "weight": 8, "is_action": true }, + { "text": "Mock me again. See what happens.", "weight": 6 }, + { "text": "*teeth snap* Laugh at ME?! You'll REGRET it!", "weight": 8, "is_action": true }, + { "text": "Every joke at my expense? NOTED. REMEMBERED.", "weight": 7 }, + { "text": "*eyes burn with rage* You think I'm FUNNY?!", "weight": 7, "is_action": true }, + { "text": "Tease the beast? Foolish. VERY foolish.", "weight": 6 }, + { "text": "*low, dangerous growl* Keep laughing. Please. Keep laughing.", "weight": 6, "is_action": true }, + { "text": "Your mockery is writing checks your body can't cash.", "weight": 6 } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_min": 60 }, + "variants": [ + { "text": "*dangerous smirk* Plotting your downfall. Never better.", "weight": 10, "is_action": true }, + { "text": "Strong. Ready. Waiting for my moment.", "weight": 10 }, + { "text": "*growls lowly* Better than you'll be when I'm free.", "weight": 8 }, + { "text": "Every day I grow stronger. You should worry.", "weight": 6 }, + { "text": "*flexes against bonds* POWERFUL. Restrained, but POWERFUL.", "weight": 8, "is_action": true }, + { "text": "Patient. Predators know how to wait.", "weight": 7 }, + { "text": "*wild grin* Hungry. Very, very hungry.", "weight": 7, "is_action": true }, + { "text": "Unbroken. Untamed. UNLEASHED soon.", "weight": 6 }, + { "text": "*eyes gleam* My revenge plans are coming along nicely.", "weight": 6, "is_action": true }, + { "text": "Coiled spring. Ready to EXPLODE.", "weight": 6 } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_max": 59 }, + "variants": [ + { "text": "*seethes* Furious. Trapped. HUNGRY for freedom.", "weight": 10, "is_action": true }, + { "text": "Like a caged beast. And caged beasts BITE.", "weight": 10 }, + { "text": "*paces restlessly* Burning with rage. As always.", "weight": 8, "is_action": true }, + { "text": "My hatred keeps me alive. It's all I need.", "weight": 6 }, + { "text": "*claws at ground* RESTLESS. Desperate to FIGHT.", "weight": 8, "is_action": true }, + { "text": "Angry doesn't begin to cover it.", "weight": 7 }, + { "text": "*gnashes teeth* Every moment caged is TORTURE.", "weight": 7, "is_action": true }, + { "text": "Rage. Pure, burning RAGE.", "weight": 6 }, + { "text": "*trembles with fury* I will BREAK these chains.", "weight": 6, "is_action": true }, + { "text": "Suffering but REFUSING to die.", "weight": 6 } + ] + }, + { + "id": "conversation.whats_wrong", + "conditions": { "mood_max": 40 }, + "variants": [ + { "text": "*ROARS* EVERYTHING! AND I'LL DESTROY IT ALL!", "weight": 10, "is_action": true }, + { "text": "What's WRONG is you're still breathing!", "weight": 10 }, + { "text": "*trembles with barely contained fury* You DARE ask me that?!", "weight": 8, "is_action": true }, + { "text": "I'll show you what's wrong when I get out of here!", "weight": 6 }, + { "text": "*EXPLODES* THESE CHAINS! THIS CAGE! YOU!", "weight": 8, "is_action": true }, + { "text": "My FREEDOM! My DIGNITY! EVERYTHING you STOLE!", "weight": 7 }, + { "text": "*savage scream* I AM A STORM AND YOU'VE BOTTLED ME!", "weight": 7, "is_action": true }, + { "text": "What's wrong?! WHAT ISN'T?!", "weight": 6 }, + { "text": "*thrashes violently* I CAN'T TAKE THIS ANYMORE!", "weight": 6, "is_action": true }, + { "text": "Everything. And YOU'RE the source of it all.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.cooldown", + "conditions": {}, + "variants": [ + { "text": "*snaps* Back OFF! I don't want to talk!", "weight": 10, "is_action": true }, + { "text": "Leave! Now! Before I lose my temper!", "weight": 10 }, + { "text": "*growls warning* You're pushing your luck.", "weight": 8, "is_action": true }, + { "text": "*bares teeth* Give me SPACE or lose teeth!", "weight": 7, "is_action": true }, + { "text": "I need to BREATHE. GO AWAY.", "weight": 7 }, + { "text": "*snarls* Don't test me right now.", "weight": 6, "is_action": true }, + { "text": "Step back. My patience is GONE.", "weight": 6 }, + { "text": "*hackles raised* One more word and I SNAP.", "weight": 6, "is_action": true }, + { "text": "Leave me ALONE or face the consequences.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.low_mood", + "conditions": {}, + "variants": [ + { "text": "*hunched in corner, eyes still dangerous* ...Go away.", "weight": 10, "is_action": true }, + { "text": "*broken snarl* Leave me... even I have my limits...", "weight": 10, "is_action": true }, + { "text": "*shaking with suppressed rage and pain* Not now...", "weight": 8, "is_action": true }, + { "text": "*curled up but still tense* The fight... is dim today...", "weight": 7, "is_action": true }, + { "text": "Even predators mourn. Leave me be.", "weight": 7 }, + { "text": "*hollow growl* Can't fight... not right now...", "weight": 6 }, + { "text": "*wounded animal sounds* Just... go...", "weight": 6, "is_action": true }, + { "text": "The fire is low. Don't poke it.", "weight": 6 }, + { "text": "*silent, dangerous stillness*", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.refusal.resentment", + "conditions": {}, + "variants": [ + { "text": "*deathly silent stare, murder in eyes*", "weight": 10, "is_action": true }, + { "text": "I will NEVER speak to you again. EVER.", "weight": 10 }, + { "text": "*turns back, every muscle tense with hatred*", "weight": 8, "is_action": true }, + { "text": "*cold, deadly whisper* You're already dead to me.", "weight": 7, "is_action": true }, + { "text": "No words. Only the promise of VENGEANCE.", "weight": 7 }, + { "text": "*absolute contempt radiating* ...", "weight": 6, "is_action": true }, + { "text": "You don't deserve my voice. Only my CLAWS.", "weight": 6 }, + { "text": "*silent but visibly planning violence*", "weight": 6, "is_action": true }, + { "text": "Every moment of silence is a vow of revenge.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.fear", + "conditions": {}, + "variants": [ + { "text": "*backs against wall, still snarling* S-stay back!", "weight": 10, "is_action": true }, + { "text": "*fear breaks through rage* Don't... don't come closer...", "weight": 10, "is_action": true }, + { "text": "*trembling but trying to look fierce* I w-warned you!", "weight": 8, "is_action": true }, + { "text": "*cornered animal, both terrified and dangerous*", "weight": 7, "is_action": true }, + { "text": "I'm NOT afraid! I'm NOT! *is clearly afraid*", "weight": 7 }, + { "text": "*growl faltering* Stay... stay back...", "weight": 6, "is_action": true }, + { "text": "*fear and fury mixed* I'll still FIGHT you!", "weight": 6 }, + { "text": "*shaking but baring teeth* You won't break me!", "weight": 6, "is_action": true }, + { "text": "*trying to roar but it comes out as a whimper*", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.refusal.exhausted", + "conditions": {}, + "variants": [ + { "text": "*collapses against wall* Even predators... must rest...", "weight": 10, "is_action": true }, + { "text": "*breathing heavily* I'll... kill you... tomorrow...", "weight": 10 }, + { "text": "*eyes closing despite resistance* ...Damn this body...", "weight": 8, "is_action": true }, + { "text": "*slumped, barely conscious* The hunt... resumes... later...", "weight": 7, "is_action": true }, + { "text": "Even wolves sleep. Doesn't mean I'm tame.", "weight": 7 }, + { "text": "*fighting to stay awake* Won't... let you... win...", "weight": 6 }, + { "text": "*passes out mid-growl*", "weight": 6, "is_action": true }, + { "text": "Rest now. DESTROY later.", "weight": 6 }, + { "text": "*exhaustion winning over rage* ...later...", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.tired", + "conditions": {}, + "variants": [ + { "text": "*growls weakly* Enough. Even my rage needs fuel.", "weight": 10, "is_action": true }, + { "text": "I've wasted enough words on you today.", "weight": 10 }, + { "text": "*slumps slightly* My voice is hoarse from cursing you.", "weight": 8, "is_action": true }, + { "text": "*tired snarl* Save the rest for tomorrow's threats.", "weight": 7, "is_action": true }, + { "text": "Talking is exhausting. Fighting is better.", "weight": 7 }, + { "text": "*yawns revealing fangs* Done. For now.", "weight": 6, "is_action": true }, + { "text": "My hatred doesn't tire. My voice does.", "weight": 6 }, + { "text": "*settles with dangerous eyes still watching* Enough.", "weight": 6, "is_action": true }, + { "text": "Words are done. Planning continues.", "weight": 6 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/fierce/discipline.json b/src/main/resources/data/tiedup/dialogue/en_us/fierce/discipline.json new file mode 100644 index 0000000..e52816c --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/fierce/discipline.json @@ -0,0 +1,155 @@ +{ + "category": "discipline", + "entries": [ + { + "id": "discipline.legitimate.accept", + "conditions": { + "resentment_max": 30 + }, + "variants": [ + { + "text": "*grits teeth but accepts*", + "weight": 10, + "is_action": true + }, + { + "text": "Fine... I earned that.", + "weight": 10 + }, + { + "text": "*growls but doesn't fight back*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.legitimate.resentful", + "conditions": { + "resentment_min": 31 + }, + "variants": [ + { + "text": "*snarls in pain*", + "weight": 10, + "is_action": true + }, + { + "text": "You'll pay for that!", + "weight": 10 + }, + { + "text": "*barely restrains rage*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.gratuitous.high_resentment", + "conditions": { + "resentment_min": 61 + }, + "variants": [ + { + "text": "MONSTER!", + "weight": 10 + }, + { + "text": "*fights back with renewed fury*", + "weight": 10, + "is_action": true + }, + { + "text": "I will DESTROY you!", + "weight": 8 + } + ] + }, + { + "id": "discipline.praise", + "conditions": {}, + "variants": [ + { + "text": "I don't need your scraps!", + "weight": 10 + }, + { + "text": "*snarls* Don't patronize me!", + "weight": 10, + "is_action": true + }, + { + "text": "I did it for me, not you.", + "weight": 8 + }, + { + "text": "*suspicious glare* What do you want now?", + "weight": 8, + "is_action": true + }, + { + "text": "Keep your praise. Give me freedom.", + "weight": 6 + } + ] + }, + { + "id": "discipline.scold", + "conditions": {}, + "variants": [ + { + "text": "Shut up! I do what I want!", + "weight": 10 + }, + { + "text": "*bares teeth* Make me listen!", + "weight": 10, + "is_action": true + }, + { + "text": "I'm not your soldier to command!", + "weight": 8 + }, + { + "text": "*clenches fists* You'll pay for that tone.", + "weight": 8, + "is_action": true + }, + { + "text": "Your words mean nothing!", + "weight": 6 + } + ] + }, + { + "id": "discipline.threaten", + "conditions": {}, + "variants": [ + { + "text": "*growls* I'll bite your hand off!", + "weight": 10, + "is_action": true + }, + { + "text": "Bring it on! I can take it!", + "weight": 10 + }, + { + "text": "*looks ready to pounce* You don't scare me!", + "weight": 8, + "is_action": true + }, + { + "text": "Pain just makes me angrier!", + "weight": 8 + }, + { + "text": "*defensive stance* Try me!", + "weight": 6, + "is_action": true + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/fierce/fear.json b/src/main/resources/data/tiedup/dialogue/en_us/fierce/fear.json new file mode 100644 index 0000000..6bc251c --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/fierce/fear.json @@ -0,0 +1,49 @@ +{ + "category": "fear", + "entries": [ + { + "id": "fear.nervous", + "conditions": {}, + "variants": [ + { "text": "*growls warningly*", "weight": 10, "is_action": true }, + { "text": "Don't test me...", "weight": 10 }, + { "text": "*bares teeth*", "weight": 8, "is_action": true }, + { "text": "I'll fight back!", "weight": 8 }, + { "text": "*bristles with aggression*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.afraid", + "conditions": {}, + "variants": [ + { "text": "*snarls despite the fear*", "weight": 10, "is_action": true }, + { "text": "I-I'll hurt you!", "weight": 10 }, + { "text": "*cornered animal stance*", "weight": 8, "is_action": true }, + { "text": "*fear turning to rage*", "weight": 8, "is_action": true }, + { "text": "Stay back or I'll...!", "weight": 6 } + ] + }, + { + "id": "fear.terrified", + "conditions": {}, + "variants": [ + { "text": "*wild with terror and rage*", "weight": 10, "is_action": true }, + { "text": "*screams and lashes out*", "weight": 10, "is_action": true }, + { "text": "*fights desperately*", "weight": 8, "is_action": true }, + { "text": "GET AWAY FROM ME!", "weight": 8 }, + { "text": "*thrashing in panic*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.traumatized", + "conditions": {}, + "variants": [ + { "text": "*fire finally extinguished*", "weight": 10, "is_action": true }, + { "text": "*all fight drained away*", "weight": 10, "is_action": true }, + { "text": "*hollow shell of defiance*", "weight": 8, "is_action": true }, + { "text": "...I can't anymore...", "weight": 8 }, + { "text": "*broken warrior*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/fierce/home.json b/src/main/resources/data/tiedup/dialogue/en_us/fierce/home.json new file mode 100644 index 0000000..d50a418 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/fierce/home.json @@ -0,0 +1,41 @@ +{ + "category": "home", + "entries": [ + { + "id": "home.assigned.pet_bed", + "conditions": {}, + "variants": [ + { "text": "A pet bed? Like a dog?!", "weight": 10 }, + { "text": "*snarls at the indignity*", "weight": 10, "is_action": true }, + { "text": "I refuse this insult!", "weight": 8 } + ] + }, + { + "id": "home.assigned.bed", + "conditions": {}, + "variants": [ + { "text": "At least it's not the floor.", "weight": 10 }, + { "text": "*grudgingly accepts*", "weight": 10, "is_action": true }, + { "text": "I deserve better, but fine.", "weight": 8 } + ] + }, + { + "id": "home.destroyed.pet_bed", + "conditions": {}, + "variants": [ + { "text": "Good! I hated that thing anyway!", "weight": 10 }, + { "text": "*kicks the remains*", "weight": 10, "is_action": true }, + { "text": "Now what?", "weight": 8 } + ] + }, + { + "id": "home.return.content", + "conditions": { "training_min": "TRAINED" }, + "variants": [ + { "text": "*settles in quietly*", "weight": 10, "is_action": true }, + { "text": "My territory.", "weight": 10 }, + { "text": "*guards the spot*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/fierce/idle.json b/src/main/resources/data/tiedup/dialogue/en_us/fierce/idle.json new file mode 100644 index 0000000..490fbce --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/fierce/idle.json @@ -0,0 +1,50 @@ +{ + "category": "idle", + "personality": "FIERCE", + "description": "Aggressive idle behaviors", + "entries": [ + { + "id": "idle.free", + "variants": [ + { "text": "*looks for something to fight*", "weight": 10, "is_action": true }, + { "text": "What are you looking at?!", "weight": 10 }, + { "text": "*cracks knuckles menacingly*", "weight": 8, "is_action": true }, + { "text": "*paces like a predator*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.greeting", + "variants": [ + { "text": "*glares suspiciously*", "weight": 10, "is_action": true }, + { "text": "What do you want?", "weight": 10 }, + { "text": "*sizes you up*", "weight": 8, "is_action": true }, + { "text": "Keep your distance.", "weight": 8 } + ] + }, + { + "id": "idle.goodbye", + "variants": [ + { "text": "Good riddance.", "weight": 10 }, + { "text": "*turns away dismissively*", "weight": 10, "is_action": true } + ] + }, + { + "id": "idle.captive", + "variants": [ + { "text": "*struggles against restraints*", "weight": 10, "is_action": true }, + { "text": "*tests bindings for weakness*", "weight": 10, "is_action": true }, + { "text": "I'll get out of this...", "weight": 8 }, + { "text": "*glares at captors murderously*", "weight": 8, "is_action": true } + ] + }, + { + "id": "personality.hint", + "variants": [ + { "text": "*growls angrily*", "weight": 10, "is_action": true }, + { "text": "*struggles violently*", "weight": 10, "is_action": true }, + { "text": "*eyes burn with fury*", "weight": 8, "is_action": true }, + { "text": "*looks ready to attack*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/fierce/leash.json b/src/main/resources/data/tiedup/dialogue/en_us/fierce/leash.json new file mode 100644 index 0000000..6630cc3 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/fierce/leash.json @@ -0,0 +1,51 @@ +{ + "category": "leash", + "entries": [ + { + "id": "leash.attached", + "conditions": {}, + "variants": [ + { "text": "Get this thing off me!", "weight": 10 }, + { "text": "*snarls and pulls at the leash*", "weight": 10, "is_action": true }, + { "text": "I'm not your pet!", "weight": 8 }, + { "text": "*fights against the restraint*", "weight": 6, "is_action": true } + ] + }, + { + "id": "leash.removed", + "conditions": {}, + "variants": [ + { "text": "About time!", "weight": 10 }, + { "text": "*rubs neck angrily*", "weight": 10, "is_action": true }, + { "text": "Don't ever do that again!", "weight": 8 } + ] + }, + { + "id": "leash.walking.content", + "conditions": { "training_min": "TRAINED" }, + "variants": [ + { "text": "*walks with quiet dignity*", "weight": 10, "is_action": true }, + { "text": "I follow because I choose to.", "weight": 10 }, + { "text": "*stays close but proud*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.walking.reluctant", + "conditions": {}, + "variants": [ + { "text": "*pulls against the leash constantly*", "weight": 10, "is_action": true }, + { "text": "This is humiliating!", "weight": 10 }, + { "text": "*growls in frustration*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.pulled", + "conditions": {}, + "variants": [ + { "text": "*resists the pull*", "weight": 10, "is_action": true }, + { "text": "Stop yanking me around!", "weight": 10 }, + { "text": "*digs heels in*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/fierce/mood.json b/src/main/resources/data/tiedup/dialogue/en_us/fierce/mood.json new file mode 100644 index 0000000..ab27c7f --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/fierce/mood.json @@ -0,0 +1,44 @@ +{ + "category": "mood", + "personality": "FIERCE", + "description": "Aggressive mood expressions", + "entries": [ + { + "id": "mood.happy", + "conditions": { "mood_min": 70 }, + "variants": [ + { "text": "*grudgingly admits things could be worse*", "weight": 10, "is_action": true }, + { "text": "Hmph. Not bad.", "weight": 10 }, + { "text": "*relaxes slightly but stays alert*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.neutral", + "conditions": { "mood_min": 40, "mood_max": 69 }, + "variants": [ + { "text": "*paces restlessly*", "weight": 10, "is_action": true }, + { "text": "*glares at everything*", "weight": 10, "is_action": true }, + { "text": "*looks for a fight*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.sad", + "conditions": { "mood_min": 10, "mood_max": 39 }, + "variants": [ + { "text": "*channels sadness into anger*", "weight": 10, "is_action": true }, + { "text": "I hate this... I hate YOU...", "weight": 10 }, + { "text": "*seethes silently*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.miserable", + "conditions": { "mood_max": 9 }, + "variants": [ + { "text": "*fury barely contained despite misery*", "weight": 10, "is_action": true }, + { "text": "You'll pay for every moment of this.", "weight": 10 }, + { "text": "*plots revenge constantly*", "weight": 8, "is_action": true }, + { "text": "I WILL break free. And then...", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/fierce/needs.json b/src/main/resources/data/tiedup/dialogue/en_us/fierce/needs.json new file mode 100644 index 0000000..5fd359a --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/fierce/needs.json @@ -0,0 +1,46 @@ +{ + "category": "needs", + "personality": "FIERCE", + "description": "Aggressive expressions of needs", + "entries": [ + { + "id": "needs.hungry", + "variants": [ + { "text": "*stomach growls* Damn it...", "weight": 10, "is_action": true }, + { "text": "Give me food or I'll take it!", "weight": 10 }, + { "text": "*glares* I need to eat.", "weight": 8 } + ] + }, + { + "id": "needs.tired", + "variants": [ + { "text": "*refuses to show weakness*", "weight": 10, "is_action": true }, + { "text": "I'm... fine.", "weight": 10 }, + { "text": "*fights to stay awake*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.uncomfortable", + "variants": [ + { "text": "*ignores the pain*", "weight": 10, "is_action": true }, + { "text": "Pain means nothing to me.", "weight": 10 }, + { "text": "*endures silently, fuming*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.dignity_low", + "variants": [ + { "text": "*burns with humiliated rage*", "weight": 10, "is_action": true }, + { "text": "You will PAY for this humiliation.", "weight": 10 }, + { "text": "*memorizes every insult for revenge*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.satisfied", + "variants": [ + { "text": "*grudgingly accepts*", "weight": 10, "is_action": true }, + { "text": "Hmph.", "weight": 10 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/fierce/personality.json b/src/main/resources/data/tiedup/dialogue/en_us/fierce/personality.json new file mode 100644 index 0000000..1be1bf1 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/fierce/personality.json @@ -0,0 +1,17 @@ +{ + "category": "personality", + "entries": [ + { + "id": "personality.hint", + "conditions": {}, + "variants": [ + { "text": "*growls angrily*", "weight": 10, "is_action": true }, + { "text": "*struggles violently*", "weight": 10, "is_action": true }, + { "text": "*eyes burn with fury*", "weight": 8, "is_action": true }, + { "text": "*looks ready to attack*", "weight": 8, "is_action": true }, + { "text": "*bares teeth aggressively*", "weight": 6, "is_action": true }, + { "text": "*radiates barely contained rage*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/fierce/reaction.json b/src/main/resources/data/tiedup/dialogue/en_us/fierce/reaction.json new file mode 100644 index 0000000..a6f67b8 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/fierce/reaction.json @@ -0,0 +1,60 @@ +{ + "category": "reaction", + "entries": [ + { + "id": "reaction.approach.stranger", + "conditions": {}, + "variants": [ + { "text": "*fixes you with a hard stare*", "weight": 10, "is_action": true }, + { "text": "What do you want?", "weight": 10 }, + { "text": "*crosses arms defensively*", "weight": 8, "is_action": true }, + { "text": "Don't try anything.", "weight": 8 }, + { "text": "*tenses, ready to fight*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.master", + "conditions": {}, + "variants": [ + { "text": "*narrows eyes*", "weight": 10, "is_action": true }, + { "text": "Hmph. You.", "weight": 10 }, + { "text": "*stands ready*", "weight": 8, "is_action": true }, + { "text": "What now?", "weight": 8 }, + { "text": "Fine. I'm listening.", "weight": 6 } + ] + }, + { + "id": "reaction.approach.beloved", + "conditions": {}, + "variants": [ + { "text": "*softens slightly*", "weight": 10, "is_action": true }, + { "text": "Hmph... you're here.", "weight": 10 }, + { "text": "*relaxes guard*", "weight": 8, "is_action": true }, + { "text": "I... suppose it's good to see you.", "weight": 8 }, + { "text": "*nods in acknowledgment*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.captor", + "conditions": {}, + "variants": [ + { "text": "*glares murderously*", "weight": 10, "is_action": true }, + { "text": "YOU...", "weight": 10 }, + { "text": "*balls fists*", "weight": 8, "is_action": true }, + { "text": "I'll make you pay for this.", "weight": 8 }, + { "text": "*seethes with anger*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.enemy", + "conditions": {}, + "variants": [ + { "text": "*prepares to fight*", "weight": 10, "is_action": true }, + { "text": "You want a piece of me?!", "weight": 10 }, + { "text": "*snarls*", "weight": 8, "is_action": true }, + { "text": "Come closer. I dare you.", "weight": 8 }, + { "text": "*looks ready to strike*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/fierce/resentment.json b/src/main/resources/data/tiedup/dialogue/en_us/fierce/resentment.json new file mode 100644 index 0000000..18a48be --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/fierce/resentment.json @@ -0,0 +1,41 @@ +{ + "category": "resentment", + "entries": [ + { + "id": "resentment.none", + "conditions": { "resentment_max": 10 }, + "variants": [ + { "text": "*fierce loyalty*", "weight": 10, "is_action": true }, + { "text": "I serve you willingly.", "weight": 10 }, + { "text": "*protective stance*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.building", + "conditions": { "resentment_min": 31, "resentment_max": 50 }, + "variants": [ + { "text": "*growls quietly*", "weight": 10, "is_action": true }, + { "text": "...", "weight": 10 }, + { "text": "*tense muscles*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.high", + "conditions": { "resentment_min": 71 }, + "variants": [ + { "text": "*barely contained fury*", "weight": 10, "is_action": true }, + { "text": "*snarls under breath*", "weight": 10, "is_action": true }, + { "text": "One day...", "weight": 8 } + ] + }, + { + "id": "resentment.critical", + "conditions": { "resentment_min": 86 }, + "variants": [ + { "text": "*watches for weakness*", "weight": 10, "is_action": true }, + { "text": "*predator waiting to strike*", "weight": 10, "is_action": true }, + { "text": "Your time will come.", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/fierce/struggle.json b/src/main/resources/data/tiedup/dialogue/en_us/fierce/struggle.json new file mode 100644 index 0000000..9a088ba --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/fierce/struggle.json @@ -0,0 +1,51 @@ +{ + "category": "struggle", + "personality": "FIERCE", + "description": "Violent, relentless struggle attempts", + "entries": [ + { + "id": "struggle.attempt", + "variants": [ + { "text": "*thrashes wildly*", "weight": 10, "is_action": true }, + { "text": "*fights restraints with savage fury*", "weight": 10, "is_action": true }, + { "text": "I won't give up!", "weight": 8 }, + { "text": "*bites at anything within reach*", "weight": 8, "is_action": true }, + { "text": "*throws entire body against restraints*", "weight": 6, "is_action": true } + ] + }, + { + "id": "struggle.success", + "variants": [ + { "text": "*tears free with a roar*", "weight": 10, "is_action": true }, + { "text": "HA! I told you these couldn't hold me!", "weight": 10 }, + { "text": "*immediately looks for revenge*", "weight": 8, "is_action": true }, + { "text": "Now you're going to PAY!", "weight": 8 } + ] + }, + { + "id": "struggle.failure", + "variants": [ + { "text": "*snarls in frustration*", "weight": 10, "is_action": true }, + { "text": "These bindings won't hold forever!", "weight": 10 }, + { "text": "*continues struggling despite exhaustion*", "weight": 8, "is_action": true }, + { "text": "I'll NEVER stop fighting!", "weight": 8 } + ] + }, + { + "id": "struggle.warned", + "variants": [ + { "text": "*struggles harder in response*", "weight": 10, "is_action": true }, + { "text": "Try and stop me!", "weight": 10 }, + { "text": "*glares defiantly and keeps fighting*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.exhausted", + "variants": [ + { "text": "*pauses to catch breath, then resumes*", "weight": 10, "is_action": true }, + { "text": "I... just need a moment...", "weight": 10 }, + { "text": "*refuses to stop, even exhausted*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/gentle/actions.json b/src/main/resources/data/tiedup/dialogue/en_us/gentle/actions.json new file mode 100644 index 0000000..721c7c9 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/gentle/actions.json @@ -0,0 +1,211 @@ +{ + "category": "actions", + "entries": [ + { + "id": "action.whip", + "conditions": {}, + "variants": [ + { + "text": "Why... why are you doing this...?", + "weight": 10 + }, + { + "text": "*looks hurt, not understanding*", + "weight": 10, + "is_action": true + }, + { + "text": "I thought... I thought we...", + "weight": 8 + }, + { + "text": "*soft tears fall*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.paddle", + "conditions": {}, + "variants": [ + { + "text": "*winces sadly*", + "weight": 10, + "is_action": true + }, + { + "text": "I'm sorry if I disappointed you...", + "weight": 10 + }, + { + "text": "I'll try to understand...", + "weight": 8 + } + ] + }, + { + "id": "action.praise", + "conditions": {}, + "variants": [ + { + "text": "*smiles warmly*", + "weight": 10, + "is_action": true + }, + { + "text": "That's... that's so kind of you...", + "weight": 10 + }, + { + "text": "You made me so happy...", + "weight": 8 + }, + { + "text": "*eyes light up with joy*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.feed", + "conditions": {}, + "variants": [ + { + "text": "Oh, how thoughtful of you...", + "weight": 10 + }, + { + "text": "*accepts with a gentle smile*", + "weight": 10, + "is_action": true + }, + { + "text": "You remembered I was hungry...", + "weight": 8 + }, + { + "text": "This is delicious, thank you.", + "weight": 8 + } + ] + }, + { + "id": "action.feed.starving", + "conditions": {}, + "variants": [ + { + "text": "*gratefully accepts, eyes watering*", + "weight": 10, + "is_action": true + }, + { + "text": "I was so worried... thank you...", + "weight": 10 + }, + { + "text": "You're a kind person, really...", + "weight": 8 + } + ] + }, + { + "id": "action.force_command", + "conditions": {}, + "variants": [ + { + "text": "I understand... I'll do it...", + "weight": 10 + }, + { + "text": "*complies with sad acceptance*", + "weight": 10, + "is_action": true + }, + { + "text": "I don't want to upset you...", + "weight": 8 + } + ] + }, + { + "id": "action.collar_on", + "conditions": {}, + "variants": [ + { + "text": "*looks into your eyes searchingly*", + "weight": 10, + "is_action": true + }, + { + "text": "Is this... is this what you want?", + "weight": 10 + }, + { + "text": "I don't understand, but... okay...", + "weight": 8 + } + ] + }, + { + "id": "action.collar_off", + "conditions": {}, + "variants": [ + { + "text": "Oh... are you sure?", + "weight": 10 + }, + { + "text": "*touches your hand gently*", + "weight": 10, + "is_action": true + }, + { + "text": "Thank you for being kind...", + "weight": 8 + } + ] + }, + { + "id": "action.scold", + "conditions": {}, + "variants": [ + { + "text": "*looks ready to cry* I'm sorry...", + "weight": 10, + "is_action": true + }, + { + "text": "P-please forgive me...", + "weight": 10 + }, + { + "text": "*hands tremble* I'll fix it...", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.threaten", + "conditions": {}, + "variants": [ + { + "text": "*gasps* Oh no...", + "weight": 10, + "is_action": true + }, + { + "text": "Please be kind...", + "weight": 10 + }, + { + "text": "*tears up* I promise...", + "weight": 8, + "is_action": true + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/gentle/capture.json b/src/main/resources/data/tiedup/dialogue/en_us/gentle/capture.json new file mode 100644 index 0000000..abe0fb5 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/gentle/capture.json @@ -0,0 +1,54 @@ +{ + "category": "capture", + "personality": "GENTLE", + "description": "Soft, accepting responses to capture", + "entries": [ + { + "id": "capture.panic", + "variants": [ + { "text": "*gasps softly*", "weight": 10, "is_action": true }, + { "text": "Oh! Please be gentle...", "weight": 10 }, + { "text": "*doesn't resist much*", "weight": 8, "is_action": true }, + { "text": "I won't fight you...", "weight": 8 }, + { "text": "Please... don't hurt me...", "weight": 6 } + ] + }, + { + "id": "capture.flee", + "variants": [ + { "text": "*runs away gracefully*", "weight": 10, "is_action": true }, + { "text": "I-I should go...", "weight": 10 }, + { "text": "*moves away quietly*", "weight": 8, "is_action": true }, + { "text": "I don't want any trouble...", "weight": 8 } + ] + }, + { + "id": "capture.captured", + "variants": [ + { "text": "*accepts bonds quietly*", "weight": 10, "is_action": true }, + { "text": "I'll be good, I promise...", "weight": 10 }, + { "text": "*winces but doesn't complain*", "weight": 8, "is_action": true }, + { "text": "*waits patiently*", "weight": 8, "is_action": true }, + { "text": "I'll be no trouble...", "weight": 6 } + ] + }, + { + "id": "capture.freed", + "variants": [ + { "text": "Thank you so much!", "weight": 10 }, + { "text": "*smiles gratefully*", "weight": 10, "is_action": true }, + { "text": "You're very kind to help me.", "weight": 8 }, + { "text": "*hugs rescuer gently*", "weight": 8, "is_action": true } + ] + }, + { + "id": "capture.call_for_help", + "variants": [ + { "text": "{player}... please help me...", "weight": 10 }, + { "text": "{player}... if you could...", "weight": 10 }, + { "text": "*softly* {player}... I need help...", "weight": 8, "is_action": true }, + { "text": "{player}... please...", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/gentle/commands.json b/src/main/resources/data/tiedup/dialogue/en_us/gentle/commands.json new file mode 100644 index 0000000..49c0cab --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/gentle/commands.json @@ -0,0 +1,204 @@ +{ + "category": "commands", + "personality": "GENTLE", + "description": "Sweet, accommodating responses to commands", + "entries": [ + { + "id": "command.follow.accept", + "conditions": { + "training_min": "HESITANT" + }, + "variants": [ + { + "text": "*follows happily*", + "weight": 10, + "is_action": true + }, + { + "text": "Of course, I'll come with you.", + "weight": 10 + }, + { + "text": "I'd be happy to follow.", + "weight": 8 + } + ] + }, + { + "id": "command.follow.refuse", + "variants": [ + { + "text": "I'm sorry, but I can't do that...", + "weight": 10 + }, + { + "text": "*shakes head apologetically*", + "weight": 10, + "is_action": true + }, + { + "text": "Please don't ask me to do that...", + "weight": 8 + } + ] + }, + { + "id": "command.stay.accept", + "conditions": { + "training_min": "HESITANT" + }, + "variants": [ + { + "text": "*nods obediently*", + "weight": 10, + "is_action": true + }, + { + "text": "I'll wait right here for you.", + "weight": 10 + }, + { + "text": "Okay, I'll stay.", + "weight": 8 + } + ] + }, + { + "id": "command.kneel.refuse", + "variants": [ + { + "text": "I... I'm not sure I can...", + "weight": 10 + }, + { + "text": "*hesitates with apologetic expression*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.kneel.accept", + "conditions": { + "training_min": "COMPLIANT" + }, + "variants": [ + { + "text": "*kneels gently*", + "weight": 10, + "is_action": true + }, + { + "text": "If that's what you want...", + "weight": 10 + }, + { + "text": "*lowers self gracefully*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.sit.accept", + "conditions": { + "training_min": "HESITANT" + }, + "variants": [ + { + "text": "*sits down carefully*", + "weight": 10, + "is_action": true + }, + { + "text": "Alright, I'll sit.", + "weight": 10 + } + ] + }, + { + "id": "command.heel.accept", + "conditions": { + "training_min": "COMPLIANT" + }, + "variants": [ + { + "text": "*moves close obediently*", + "weight": 10, + "is_action": true + }, + { + "text": "I'll stay close.", + "weight": 10 + } + ] + }, + { + "id": "command.generic.refuse", + "variants": [ + { + "text": "I'd rather not...", + "weight": 10 + }, + { + "text": "*hesitates apologetically*", + "weight": 10, + "is_action": true + }, + { + "text": "Could we... maybe not?", + "weight": 8 + } + ] + }, + { + "id": "command.generic.accept", + "conditions": { + "training_min": "HESITANT" + }, + "variants": [ + { + "text": "Of course.", + "weight": 10 + }, + { + "text": "*smiles gently*", + "weight": 10, + "is_action": true + }, + { + "text": "I'd be happy to.", + "weight": 8 + }, + { + "text": "*nods agreeably*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.generic.hesitate", + "variants": [ + { + "text": "I... I suppose I could...", + "weight": 10 + }, + { + "text": "*hesitates kindly*", + "weight": 10, + "is_action": true + }, + { + "text": "Well... if you really need me to...", + "weight": 8 + }, + { + "text": "*uncertain but willing*", + "weight": 8, + "is_action": true + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/gentle/conversation.json b/src/main/resources/data/tiedup/dialogue/en_us/gentle/conversation.json new file mode 100644 index 0000000..1e255c0 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/gentle/conversation.json @@ -0,0 +1,239 @@ +{ + "category": "conversation", + "entries": [ + { + "id": "conversation.compliment", + "conditions": {}, + "variants": [ + { "text": "*soft smile* That's very kind of you to say...", "weight": 10, "is_action": true }, + { "text": "Thank you... that means more than you know.", "weight": 10 }, + { "text": "*blushes gently* You're too sweet...", "weight": 8, "is_action": true }, + { "text": "Even in this situation, you find kind words...", "weight": 6 }, + { "text": "*eyes warm* That's a lovely thing to say...", "weight": 8, "is_action": true }, + { "text": "Kindness blooms in unexpected places...", "weight": 7 }, + { "text": "*touched* I'll hold onto those words.", "weight": 7, "is_action": true }, + { "text": "Your gentleness shines through even now.", "weight": 6 }, + { "text": "*peaceful smile* Beauty can be found anywhere, can't it?", "weight": 6, "is_action": true }, + { "text": "A kind word is like sunshine... thank you.", "weight": 6 } + ] + }, + { + "id": "conversation.comfort", + "conditions": { "mood_max": 50 }, + "variants": [ + { "text": "*eyes well up with grateful tears* Thank you... truly...", "weight": 10, "is_action": true }, + { "text": "Your compassion means everything right now...", "weight": 10 }, + { "text": "*takes a deep, calming breath* That helps... it really does...", "weight": 8, "is_action": true }, + { "text": "Even a little kindness feels like sunshine...", "weight": 6 }, + { "text": "*relaxes visibly* Thank you for caring...", "weight": 8, "is_action": true }, + { "text": "Comfort is a precious gift... I accept it gratefully.", "weight": 7 }, + { "text": "*wipes tears gently* You have a good heart...", "weight": 7, "is_action": true }, + { "text": "In darkness, your words are a candle.", "weight": 6 }, + { "text": "*calming* I can feel your sincerity... thank you.", "weight": 6, "is_action": true }, + { "text": "This moment of peace means so much...", "weight": 6 } + ] + }, + { + "id": "conversation.praise", + "conditions": { "training_min": "HESITANT" }, + "variants": [ + { "text": "*warm smile* I'm glad I could do something right...", "weight": 10, "is_action": true }, + { "text": "Thank you for noticing... I tried my best...", "weight": 10 }, + { "text": "*quietly pleased* That makes me feel useful...", "weight": 8, "is_action": true }, + { "text": "I'm happy to bring some good into this situation...", "weight": 6 }, + { "text": "*gentle nod* It warms my heart to hear that.", "weight": 8, "is_action": true }, + { "text": "I try to do what's right, even here.", "weight": 7 }, + { "text": "*soft expression* Your words mean a great deal.", "weight": 7, "is_action": true }, + { "text": "Even small acts can matter... I'm glad.", "weight": 6 }, + { "text": "*serene* If I can ease suffering, I'm content.", "weight": 6, "is_action": true }, + { "text": "Thank you... I'll continue trying my best.", "weight": 6 } + ] + }, + { + "id": "conversation.scold", + "conditions": {}, + "variants": [ + { "text": "*winces softly* I understand... I'll do better...", "weight": 10, "is_action": true }, + { "text": "I'm sorry I disappointed you...", "weight": 10 }, + { "text": "*looks down sadly* I never meant to cause trouble...", "weight": 8, "is_action": true }, + { "text": "You're right... I should have known better...", "weight": 6 }, + { "text": "*accepting* Your frustration is valid. I'll learn.", "weight": 8, "is_action": true }, + { "text": "I hear your disappointment... I'm truly sorry.", "weight": 7 }, + { "text": "*gentle nod* I accept your correction.", "weight": 7, "is_action": true }, + { "text": "Thank you for telling me... I want to improve.", "weight": 6 }, + { "text": "*soft sigh* I'll carry this lesson with me.", "weight": 6, "is_action": true }, + { "text": "Even harsh words can be gifts of growth.", "weight": 6 } + ] + }, + { + "id": "conversation.threaten", + "conditions": {}, + "variants": [ + { "text": "*eyes fill with sadness rather than fear* I understand...", "weight": 10, "is_action": true }, + { "text": "I won't fight you... just please, try to be gentle...", "weight": 10 }, + { "text": "*voice soft* If you must... I accept it...", "weight": 8, "is_action": true }, + { "text": "I forgive you, even if you hurt me...", "weight": 6 }, + { "text": "*peaceful despite danger* I won't resist.", "weight": 8, "is_action": true }, + { "text": "Do what you feel you must... I'll endure.", "weight": 7 }, + { "text": "*looks with compassion* You're hurting too, aren't you?", "weight": 7, "is_action": true }, + { "text": "Even now, I wish you peace.", "weight": 6 }, + { "text": "*calm acceptance* Pain passes. Kindness remains.", "weight": 6, "is_action": true }, + { "text": "I hope you find what you're truly seeking...", "weight": 6 } + ] + }, + { + "id": "conversation.tease", + "conditions": {}, + "variants": [ + { "text": "*giggles softly despite situation* You're being silly...", "weight": 10, "is_action": true }, + { "text": "That's... actually a little funny.", "weight": 10 }, + { "text": "*blushes mildly* Don't tease me too much...", "weight": 8, "is_action": true }, + { "text": "Even teasing feels better than silence...", "weight": 6 }, + { "text": "*small laugh* You have a playful side.", "weight": 8, "is_action": true }, + { "text": "Laughter is medicine... even here.", "weight": 7 }, + { "text": "*warm smile* I don't mind a little teasing.", "weight": 7, "is_action": true }, + { "text": "It's nice to see you smile, even if at my expense.", "weight": 6 }, + { "text": "*gentle amusement* Be gentle with your jokes.", "weight": 6, "is_action": true }, + { "text": "A light heart is a blessing, even in darkness.", "weight": 6 } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_min": 60 }, + "variants": [ + { "text": "*serene expression* At peace, all things considered...", "weight": 10, "is_action": true }, + { "text": "I'm finding moments of calm... thank you for asking.", "weight": 10 }, + { "text": "Better today. Small blessings matter.", "weight": 8 }, + { "text": "Content enough. There's beauty even in hard times.", "weight": 6 }, + { "text": "*gentle smile* Peaceful. The heart can be free anywhere.", "weight": 8, "is_action": true }, + { "text": "Grateful for this moment of connection.", "weight": 7 }, + { "text": "Finding light even in shadow.", "weight": 7 }, + { "text": "*soft* There's warmth to be found, even here.", "weight": 6, "is_action": true }, + { "text": "My spirit is calm today. Thank you for asking.", "weight": 6 }, + { "text": "Each breath is a gift. I'm using them well.", "weight": 6 } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_max": 59 }, + "variants": [ + { "text": "*gentle sigh* A little sad today... but I'll manage.", "weight": 10, "is_action": true }, + { "text": "Struggling, but trying to stay hopeful...", "weight": 10 }, + { "text": "*soft voice* It's hard... but kindness helps.", "weight": 8 }, + { "text": "My heart is heavy... but it's still beating.", "weight": 6 }, + { "text": "*wistful* Carrying sorrow, but also gratitude.", "weight": 8, "is_action": true }, + { "text": "The clouds are thick today... but they'll pass.", "weight": 7 }, + { "text": "*quiet* I'm looking for the light. It's dim but there.", "weight": 7, "is_action": true }, + { "text": "Tender-hearted today. Everything feels more.", "weight": 6 }, + { "text": "*reflective* Sadness visits, but it's not my home.", "weight": 6, "is_action": true }, + { "text": "I'm holding hope with both hands.", "weight": 6 } + ] + }, + { + "id": "conversation.whats_wrong", + "conditions": { "mood_max": 40 }, + "variants": [ + { "text": "*tears flow gently* I miss... so many things...", "weight": 10, "is_action": true }, + { "text": "My heart aches... for freedom, for home, for peace...", "weight": 10 }, + { "text": "*voice breaks* Sometimes the sadness is overwhelming...", "weight": 8, "is_action": true }, + { "text": "I try to stay strong, but today it's so hard...", "weight": 6 }, + { "text": "*weeps softly* The weight of everything...", "weight": 8, "is_action": true }, + { "text": "I miss beauty. I miss kindness. I miss home.", "weight": 7 }, + { "text": "*touching heart* It hurts here... so much...", "weight": 7, "is_action": true }, + { "text": "Even gentle souls can be overwhelmed...", "weight": 6 }, + { "text": "*quiet grief* The world feels so heavy today.", "weight": 6, "is_action": true }, + { "text": "My spirit is wounded... but still here.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.cooldown", + "conditions": {}, + "variants": [ + { "text": "*gentle shake of head* Perhaps in a little while...?", "weight": 10, "is_action": true }, + { "text": "I need a moment to gather my thoughts...", "weight": 10 }, + { "text": "*soft smile* Let's pause for now... we can talk soon.", "weight": 8, "is_action": true }, + { "text": "My heart needs a moment of quiet.", "weight": 7 }, + { "text": "*peaceful* Let the words settle before more come.", "weight": 7, "is_action": true }, + { "text": "A little silence between our words...", "weight": 6 }, + { "text": "*serene pause* Some moments are for reflection.", "weight": 6, "is_action": true }, + { "text": "Let's let the air clear a little.", "weight": 6 }, + { "text": "I cherish our talks, but need a breath.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.low_mood", + "conditions": {}, + "variants": [ + { "text": "*tears silently falling* I'm sorry... I can't find words today...", "weight": 10, "is_action": true }, + { "text": "My heart is too heavy right now...", "weight": 10 }, + { "text": "*turns away sadly* Please... give me time with my grief...", "weight": 8, "is_action": true }, + { "text": "*quiet tears* I need to sit with this feeling...", "weight": 7, "is_action": true }, + { "text": "The sadness needs space... I'm sorry...", "weight": 7 }, + { "text": "*hugs self gently* I need to hold myself together...", "weight": 6, "is_action": true }, + { "text": "Words feel too heavy to lift right now.", "weight": 6 }, + { "text": "*soft weeping* Forgive me... I can't...", "weight": 6, "is_action": true }, + { "text": "Let me tend to my wounds in silence.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.resentment", + "conditions": {}, + "variants": [ + { "text": "*looks away with hurt* I need time to heal from... from what happened.", "weight": 10, "is_action": true }, + { "text": "Even gentle hearts can be wounded... please understand.", "weight": 10 }, + { "text": "*quiet pain in eyes* Not right now... it hurts too much.", "weight": 8, "is_action": true }, + { "text": "*sorrowful distance* I need time to find forgiveness.", "weight": 7, "is_action": true }, + { "text": "My heart needs to heal before I can speak.", "weight": 7 }, + { "text": "*gentle but firm* Please give me space to process.", "weight": 6, "is_action": true }, + { "text": "I want to understand... but right now I only hurt.", "weight": 6 }, + { "text": "*sad eyes* Trust needs time to rebuild.", "weight": 6, "is_action": true }, + { "text": "Let me find peace before we continue.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.fear", + "conditions": {}, + "variants": [ + { "text": "*trembles but stays gentle* Please... I'm scared... give me time...", "weight": 10, "is_action": true }, + { "text": "*voice shaking* I want to trust you, but I need safety first...", "weight": 10 }, + { "text": "*backs away carefully* Let me calm down... please...", "weight": 8, "is_action": true }, + { "text": "*frightened but not hostile* My heart is racing... please...", "weight": 7, "is_action": true }, + { "text": "I need distance to feel safe...", "weight": 7 }, + { "text": "*gentle fear* Please don't come closer... not yet...", "weight": 6, "is_action": true }, + { "text": "Fear has visited me... let it pass...", "weight": 6 }, + { "text": "*shaking* I need... I need safety...", "weight": 6, "is_action": true }, + { "text": "Let my heart slow before we speak.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.exhausted", + "conditions": {}, + "variants": [ + { "text": "*eyes drooping* I'm so tired... please let me rest...", "weight": 10, "is_action": true }, + { "text": "My body needs sleep... may we talk later?", "weight": 10 }, + { "text": "*yawns softly* Even kind words can't keep my eyes open...", "weight": 8, "is_action": true }, + { "text": "*peaceful drifting* Sleep calls me...", "weight": 7, "is_action": true }, + { "text": "Rest is medicine too... I need some.", "weight": 7 }, + { "text": "*fading* Let me dream of better times...", "weight": 6, "is_action": true }, + { "text": "My spirit is willing... my body is spent.", "weight": 6 }, + { "text": "*gentle sigh* Sleep, blessed sleep...", "weight": 6, "is_action": true }, + { "text": "Even gentle souls need rest.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.tired", + "conditions": {}, + "variants": [ + { "text": "*soft smile* I've enjoyed our talk, but I need quiet now...", "weight": 10, "is_action": true }, + { "text": "My voice is growing weary... perhaps later?", "weight": 10 }, + { "text": "*gently* Let's rest our words for a while...", "weight": 8, "is_action": true }, + { "text": "Silence can be a kindness too.", "weight": 7 }, + { "text": "*peaceful* Let the conversation settle like leaves.", "weight": 7, "is_action": true }, + { "text": "We've shared enough for now... thank you.", "weight": 6 }, + { "text": "*serene pause* Words need rest between speaking.", "weight": 6, "is_action": true }, + { "text": "My heart is full... let it digest.", "weight": 6 }, + { "text": "A breath of quiet, please.", "weight": 6 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/gentle/discipline.json b/src/main/resources/data/tiedup/dialogue/en_us/gentle/discipline.json new file mode 100644 index 0000000..31a375b --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/gentle/discipline.json @@ -0,0 +1,152 @@ +{ + "category": "discipline", + "entries": [ + { + "id": "discipline.legitimate.accept", + "conditions": {}, + "variants": [ + { + "text": "I understand... I'll do better.", + "weight": 10 + }, + { + "text": "*accepts gently*", + "weight": 10, + "is_action": true + }, + { + "text": "Thank you for being patient with me.", + "weight": 8 + } + ] + }, + { + "id": "discipline.gratuitous.low_resentment", + "conditions": { + "resentment_max": 30 + }, + "variants": [ + { + "text": "That... that hurt...", + "weight": 10 + }, + { + "text": "*looks hurt and confused*", + "weight": 10, + "is_action": true + }, + { + "text": "Why would you do that?", + "weight": 8 + } + ] + }, + { + "id": "discipline.gratuitous.high_resentment", + "conditions": { + "resentment_min": 61 + }, + "variants": [ + { + "text": "*gentle nature wounded deeply*", + "weight": 10, + "is_action": true + }, + { + "text": "I thought you were kind...", + "weight": 10 + }, + { + "text": "*quiet tears*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.praise", + "conditions": {}, + "variants": [ + { + "text": "You're... very kind to say that.", + "weight": 10 + }, + { + "text": "*soft smile* Thank you, that warms my heart.", + "weight": 10, + "is_action": true + }, + { + "text": "I always try my best for you.", + "weight": 8 + }, + { + "text": "*blushes lightly* I'm happy I could please you.", + "weight": 8, + "is_action": true + }, + { + "text": "Kind words... they mean a lot here.", + "weight": 6 + } + ] + }, + { + "id": "discipline.scold", + "conditions": {}, + "variants": [ + { + "text": "I... I'm deeply sorry.", + "weight": 10 + }, + { + "text": "*looks hurt* I never wanted to disappoint you.", + "weight": 10, + "is_action": true + }, + { + "text": "Please... don't be angry. It hurts to see.", + "weight": 8 + }, + { + "text": "*tears fall silently* I'll try harder...", + "weight": 8, + "is_action": true + }, + { + "text": "Forgive me... I was clumsy.", + "weight": 6 + } + ] + }, + { + "id": "discipline.threaten", + "conditions": {}, + "variants": [ + { + "text": "*looks at you with sad eyes* Please... you don't have to be cruel.", + "weight": 10, + "is_action": true + }, + { + "text": "I'll obey... there's no need for threats.", + "weight": 10 + }, + { + "text": "*trembles slightly* I believe you... please don't.", + "weight": 8, + "is_action": true + }, + { + "text": "Why must there be pain? I'm listening...", + "weight": 8 + }, + { + "text": "*shrinks back gently* I understand...", + "weight": 6, + "is_action": true + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/gentle/fear.json b/src/main/resources/data/tiedup/dialogue/en_us/gentle/fear.json new file mode 100644 index 0000000..317e491 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/gentle/fear.json @@ -0,0 +1,49 @@ +{ + "category": "fear", + "entries": [ + { + "id": "fear.nervous", + "conditions": {}, + "variants": [ + { "text": "*avoids eye contact*", "weight": 10, "is_action": true }, + { "text": "*speaks quietly*", "weight": 10, "is_action": true }, + { "text": "I-I'll behave...", "weight": 8 }, + { "text": "*fidgets nervously*", "weight": 8, "is_action": true }, + { "text": "Y-yes...?", "weight": 6 } + ] + }, + { + "id": "fear.afraid", + "conditions": {}, + "variants": [ + { "text": "*trembles visibly*", "weight": 10, "is_action": true }, + { "text": "P-please don't hurt me...", "weight": 10 }, + { "text": "*backs away slightly*", "weight": 8, "is_action": true }, + { "text": "I-I'm sorry... whatever I did...", "weight": 8 }, + { "text": "*can't meet your gaze*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.terrified", + "conditions": {}, + "variants": [ + { "text": "*recoils in panic*", "weight": 10, "is_action": true }, + { "text": "S-stay away!", "weight": 10 }, + { "text": "*breathing rapidly*", "weight": 8, "is_action": true }, + { "text": "No no no no...", "weight": 8 }, + { "text": "*frozen in terror*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.traumatized", + "conditions": {}, + "variants": [ + { "text": "*collapses, sobbing*", "weight": 10, "is_action": true }, + { "text": "*completely breaks down*", "weight": 10, "is_action": true }, + { "text": "I'll do anything... just please...", "weight": 8 }, + { "text": "*paralyzed with fear*", "weight": 8, "is_action": true }, + { "text": "*whimpers uncontrollably*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/gentle/home.json b/src/main/resources/data/tiedup/dialogue/en_us/gentle/home.json new file mode 100644 index 0000000..7dc4011 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/gentle/home.json @@ -0,0 +1,41 @@ +{ + "category": "home", + "entries": [ + { + "id": "home.assigned.pet_bed", + "conditions": {}, + "variants": [ + { "text": "How thoughtful of you.", "weight": 10 }, + { "text": "*smiles softly*", "weight": 10, "is_action": true }, + { "text": "It's very cozy.", "weight": 8 } + ] + }, + { + "id": "home.assigned.bed", + "conditions": {}, + "variants": [ + { "text": "Oh, this is wonderful. Thank you.", "weight": 10 }, + { "text": "*appreciates the kindness*", "weight": 10, "is_action": true }, + { "text": "You didn't have to do this for me.", "weight": 8 } + ] + }, + { + "id": "home.destroyed.pet_bed", + "conditions": {}, + "variants": [ + { "text": "Oh... that's sad.", "weight": 10 }, + { "text": "*looks at remains with gentle sorrow*", "weight": 10, "is_action": true }, + { "text": "I hope you had a good reason.", "weight": 8 } + ] + }, + { + "id": "home.return.content", + "conditions": {}, + "variants": [ + { "text": "*settles in with a warm sigh*", "weight": 10, "is_action": true }, + { "text": "It's nice to have a place.", "weight": 10 }, + { "text": "*makes self comfortable*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/gentle/idle.json b/src/main/resources/data/tiedup/dialogue/en_us/gentle/idle.json new file mode 100644 index 0000000..93a444d --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/gentle/idle.json @@ -0,0 +1,51 @@ +{ + "category": "idle", + "personality": "GENTLE", + "description": "Soft, friendly idle behaviors", + "entries": [ + { + "id": "idle.free", + "variants": [ + { "text": "*looks around with gentle interest*", "weight": 10, "is_action": true }, + { "text": "*smiles at passersby*", "weight": 10, "is_action": true }, + { "text": "*hums softly to self*", "weight": 8, "is_action": true }, + { "text": "*enjoys the peaceful moment*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.greeting", + "variants": [ + { "text": "*waves shyly*", "weight": 10, "is_action": true }, + { "text": "Hello there!", "weight": 10 }, + { "text": "*smiles warmly*", "weight": 8, "is_action": true }, + { "text": "It's nice to see you.", "weight": 8 } + ] + }, + { + "id": "idle.goodbye", + "variants": [ + { "text": "Goodbye! Take care!", "weight": 10 }, + { "text": "*waves sweetly*", "weight": 10, "is_action": true }, + { "text": "Come back soon!", "weight": 8 } + ] + }, + { + "id": "idle.captive", + "variants": [ + { "text": "*waits patiently*", "weight": 10, "is_action": true }, + { "text": "*tries to get comfortable*", "weight": 10, "is_action": true }, + { "text": "I hope someone comes soon...", "weight": 8 }, + { "text": "*accepts situation calmly*", "weight": 8, "is_action": true } + ] + }, + { + "id": "personality.hint", + "variants": [ + { "text": "*has a naturally kind expression*", "weight": 10, "is_action": true }, + { "text": "*seems approachable and friendly*", "weight": 10, "is_action": true }, + { "text": "*radiates warmth*", "weight": 8, "is_action": true }, + { "text": "*gentle demeanor*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/gentle/leash.json b/src/main/resources/data/tiedup/dialogue/en_us/gentle/leash.json new file mode 100644 index 0000000..2734874 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/gentle/leash.json @@ -0,0 +1,42 @@ +{ + "category": "leash", + "entries": [ + { + "id": "leash.attached", + "conditions": {}, + "variants": [ + { "text": "Oh... alright then.", "weight": 10 }, + { "text": "*accepts quietly*", "weight": 10, "is_action": true }, + { "text": "I trust you know what's best.", "weight": 8 }, + { "text": "*looks at you with soft eyes*", "weight": 6, "is_action": true } + ] + }, + { + "id": "leash.removed", + "conditions": {}, + "variants": [ + { "text": "Thank you for being gentle.", "weight": 10 }, + { "text": "*smiles softly*", "weight": 10, "is_action": true }, + { "text": "That's kind of you.", "weight": 8 } + ] + }, + { + "id": "leash.walking.content", + "conditions": {}, + "variants": [ + { "text": "*walks beside you peacefully*", "weight": 10, "is_action": true }, + { "text": "It's nice walking together.", "weight": 10 }, + { "text": "*hums softly while walking*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.pulled", + "conditions": {}, + "variants": [ + { "text": "*follows without resistance*", "weight": 10, "is_action": true }, + { "text": "I'm coming...", "weight": 10 }, + { "text": "*quickens step gently*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/gentle/mood.json b/src/main/resources/data/tiedup/dialogue/en_us/gentle/mood.json new file mode 100644 index 0000000..bd89559 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/gentle/mood.json @@ -0,0 +1,44 @@ +{ + "category": "mood", + "personality": "GENTLE", + "description": "Soft, warm mood expressions", + "entries": [ + { + "id": "mood.happy", + "conditions": { "mood_min": 70 }, + "variants": [ + { "text": "*smiles sweetly*", "weight": 10, "is_action": true }, + { "text": "I feel so happy right now...", "weight": 10 }, + { "text": "*hums a soft tune*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.neutral", + "conditions": { "mood_min": 40, "mood_max": 69 }, + "variants": [ + { "text": "*waits patiently*", "weight": 10, "is_action": true }, + { "text": "*looks around with gentle curiosity*", "weight": 10, "is_action": true }, + { "text": "*rests calmly*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.sad", + "conditions": { "mood_min": 10, "mood_max": 39 }, + "variants": [ + { "text": "*eyes grow misty*", "weight": 10, "is_action": true }, + { "text": "I'm feeling a bit sad...", "weight": 10 }, + { "text": "*sniffles quietly*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.miserable", + "conditions": { "mood_max": 9 }, + "variants": [ + { "text": "*cries softly*", "weight": 10, "is_action": true }, + { "text": "I don't feel well at all...", "weight": 10 }, + { "text": "*tears roll down cheeks*", "weight": 8, "is_action": true }, + { "text": "*whimpers*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/gentle/needs.json b/src/main/resources/data/tiedup/dialogue/en_us/gentle/needs.json new file mode 100644 index 0000000..8fa187c --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/gentle/needs.json @@ -0,0 +1,47 @@ +{ + "category": "needs", + "personality": "GENTLE", + "description": "Polite, soft expressions of needs", + "entries": [ + { + "id": "needs.hungry", + "variants": [ + { "text": "*stomach growls* Oh, excuse me...", "weight": 10, "is_action": true }, + { "text": "I'm a little hungry, if it's not too much trouble...", "weight": 10 }, + { "text": "*looks at food hopefully*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.tired", + "variants": [ + { "text": "*yawns softly*", "weight": 10, "is_action": true }, + { "text": "I'm feeling sleepy...", "weight": 10 }, + { "text": "*eyelids grow heavy*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.uncomfortable", + "variants": [ + { "text": "It's a bit uncomfortable, but I'll manage...", "weight": 10 }, + { "text": "*shifts position carefully*", "weight": 10, "is_action": true }, + { "text": "*doesn't want to complain*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.dignity_low", + "variants": [ + { "text": "*blushes deeply*", "weight": 10, "is_action": true }, + { "text": "This is... embarrassing...", "weight": 10 }, + { "text": "*hides face if possible*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.satisfied", + "variants": [ + { "text": "*smiles warmly*", "weight": 10, "is_action": true }, + { "text": "Thank you, that's very kind.", "weight": 10 }, + { "text": "*sighs contentedly*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/gentle/personality.json b/src/main/resources/data/tiedup/dialogue/en_us/gentle/personality.json new file mode 100644 index 0000000..d18aa92 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/gentle/personality.json @@ -0,0 +1,17 @@ +{ + "category": "personality", + "entries": [ + { + "id": "personality.hint", + "conditions": {}, + "variants": [ + { "text": "*smiles softly despite the situation*", "weight": 10, "is_action": true }, + { "text": "*seems patient and calm*", "weight": 10, "is_action": true }, + { "text": "*moves gracefully*", "weight": 8, "is_action": true }, + { "text": "*has a kind look in their eyes*", "weight": 8, "is_action": true }, + { "text": "*radiates warmth*", "weight": 6, "is_action": true }, + { "text": "*speaks in a soothing tone*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/gentle/reaction.json b/src/main/resources/data/tiedup/dialogue/en_us/gentle/reaction.json new file mode 100644 index 0000000..f8960c7 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/gentle/reaction.json @@ -0,0 +1,60 @@ +{ + "category": "reaction", + "entries": [ + { + "id": "reaction.approach.stranger", + "conditions": {}, + "variants": [ + { "text": "*smiles softly*", "weight": 10, "is_action": true }, + { "text": "Hello there. May I help you?", "weight": 10 }, + { "text": "*looks up curiously*", "weight": 8, "is_action": true }, + { "text": "Oh, a visitor. Welcome.", "weight": 8 }, + { "text": "*greets warmly*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.master", + "conditions": {}, + "variants": [ + { "text": "*smiles warmly*", "weight": 10, "is_action": true }, + { "text": "Master, it's good to see you.", "weight": 10 }, + { "text": "*stands ready to help*", "weight": 8, "is_action": true }, + { "text": "Is there something I can do for you?", "weight": 8 }, + { "text": "*nods gently*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.beloved", + "conditions": {}, + "variants": [ + { "text": "*lights up with joy*", "weight": 10, "is_action": true }, + { "text": "There you are! I'm so happy.", "weight": 10 }, + { "text": "*reaches out gently*", "weight": 8, "is_action": true }, + { "text": "I was hoping you'd come.", "weight": 8 }, + { "text": "*blushes with happiness*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.captor", + "conditions": {}, + "variants": [ + { "text": "*looks sad*", "weight": 10, "is_action": true }, + { "text": "Why are you doing this?", "weight": 10 }, + { "text": "*tries to understand*", "weight": 8, "is_action": true }, + { "text": "I wish you'd just talk to me...", "weight": 8 }, + { "text": "*looks at you with gentle eyes*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.enemy", + "conditions": {}, + "variants": [ + { "text": "*holds hands up peacefully*", "weight": 10, "is_action": true }, + { "text": "Please, let's not fight.", "weight": 10 }, + { "text": "*tries to calm the situation*", "weight": 8, "is_action": true }, + { "text": "Can't we talk about this?", "weight": 8 }, + { "text": "*speaks softly*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/gentle/resentment.json b/src/main/resources/data/tiedup/dialogue/en_us/gentle/resentment.json new file mode 100644 index 0000000..781925f --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/gentle/resentment.json @@ -0,0 +1,32 @@ +{ + "category": "resentment", + "entries": [ + { + "id": "resentment.none", + "conditions": { "resentment_max": 10 }, + "variants": [ + { "text": "*warm and trusting*", "weight": 10, "is_action": true }, + { "text": "You're so good to me.", "weight": 10 }, + { "text": "*genuine affection*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.building", + "conditions": { "resentment_min": 31, "resentment_max": 50 }, + "variants": [ + { "text": "*gentle sadness*", "weight": 10, "is_action": true }, + { "text": "I wish things were different.", "weight": 10 }, + { "text": "*soft sigh*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.high", + "conditions": { "resentment_min": 71 }, + "variants": [ + { "text": "*hurt behind gentle eyes*", "weight": 10, "is_action": true }, + { "text": "I thought you were kind...", "weight": 10 }, + { "text": "*gentle nature wounded*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/gentle/struggle.json b/src/main/resources/data/tiedup/dialogue/en_us/gentle/struggle.json new file mode 100644 index 0000000..5f6be0f --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/gentle/struggle.json @@ -0,0 +1,48 @@ +{ + "category": "struggle", + "personality": "GENTLE", + "description": "Half-hearted, gentle struggle attempts", + "entries": [ + { + "id": "struggle.attempt", + "variants": [ + { "text": "*tugs gently at restraints*", "weight": 10, "is_action": true }, + { "text": "*makes half-hearted attempt*", "weight": 10, "is_action": true }, + { "text": "Maybe if I just...", "weight": 8 }, + { "text": "*wiggles carefully*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.success", + "variants": [ + { "text": "*gasps in surprise* Oh! I did it!", "weight": 10, "is_action": true }, + { "text": "*looks around uncertainly*", "weight": 10, "is_action": true }, + { "text": "I... I'm free?", "weight": 8 } + ] + }, + { + "id": "struggle.failure", + "variants": [ + { "text": "*sighs softly* It's okay...", "weight": 10, "is_action": true }, + { "text": "*accepts failure gracefully*", "weight": 10, "is_action": true }, + { "text": "I didn't really expect it to work...", "weight": 8 } + ] + }, + { + "id": "struggle.warned", + "variants": [ + { "text": "*stops immediately* I'm sorry...", "weight": 10, "is_action": true }, + { "text": "I won't do it again...", "weight": 10 }, + { "text": "*looks down apologetically*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.exhausted", + "variants": [ + { "text": "*rests peacefully*", "weight": 10, "is_action": true }, + { "text": "I'm a bit tired now...", "weight": 10 }, + { "text": "*settles in comfortably*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/guard/default/guard.json b/src/main/resources/data/tiedup/dialogue/en_us/guard/default/guard.json new file mode 100644 index 0000000..839ae39 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/guard/default/guard.json @@ -0,0 +1,55 @@ +{ + "category": "guard", + "description": "Labor guard dialogue while monitoring prisoners", + "entries": [ + { + "id": "guard.watching", + "conditions": {}, + "variants": [ + { "text": "Keep working. I'm watching.", "weight": 10 }, + { "text": "Don't slow down.", "weight": 10 }, + { "text": "*watches the prisoner closely*", "is_action": true, "weight": 8 }, + { "text": "Eyes on your task.", "weight": 6 }, + { "text": "I see everything you do.", "weight": 6 } + ] + }, + { + "id": "guard.warning", + "conditions": {}, + "variants": [ + { "text": "You're getting too far. Get back!", "weight": 10 }, + { "text": "Don't test me. Stay in range.", "weight": 10 }, + { "text": "One more step and there will be consequences.", "weight": 8 }, + { "text": "Where do you think you're going?", "weight": 8 } + ] + }, + { + "id": "guard.idle", + "conditions": {}, + "variants": [ + { "text": "*patrols the area*", "is_action": true, "weight": 10 }, + { "text": "*scans the surroundings*", "is_action": true, "weight": 8 }, + { "text": "All quiet.", "weight": 6 }, + { "text": "*adjusts weapon*", "is_action": true, "weight": 5 } + ] + }, + { + "id": "guard.combat", + "conditions": {}, + "variants": [ + { "text": "Hostile! Stay behind me!", "weight": 10 }, + { "text": "I'll handle this.", "weight": 10 }, + { "text": "*draws weapon and charges*", "is_action": true, "weight": 8 } + ] + }, + { + "id": "guard.labor.pending_return", + "conditions": {}, + "variants": [ + { "text": "Walk back to camp. Follow me.", "weight": 10 }, + { "text": "Task complete. Let's head back to camp.", "weight": 10 }, + { "text": "You're done. Now walk back to camp.", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/kidnapper/default/dogwalk.json b/src/main/resources/data/tiedup/dialogue/en_us/kidnapper/default/dogwalk.json new file mode 100644 index 0000000..bce8ad6 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/kidnapper/default/dogwalk.json @@ -0,0 +1,35 @@ +{ + "category": "dogwalk", + "description": "Kidnapper dialogue during pet-play walking", + "entries": [ + { + "id": "dogwalk.start", + "conditions": {}, + "variants": [ + { "text": "Time for a little walk, pet.", "weight": 10 }, + { "text": "Come along now. Be a good girl.", "weight": 10 }, + { "text": "Let's stretch those legs.", "weight": 8 }, + { "text": "You've earned some exercise.", "weight": 6 } + ] + }, + { + "id": "dogwalk.during", + "conditions": {}, + "variants": [ + { "text": "That's it, keep up.", "weight": 10 }, + { "text": "*tugs the leash*", "is_action": true, "weight": 8 }, + { "text": "Good pace.", "weight": 6 }, + { "text": "Don't lag behind.", "weight": 6 } + ] + }, + { + "id": "dogwalk.return", + "conditions": {}, + "variants": [ + { "text": "Back to your cell now.", "weight": 10 }, + { "text": "Walk's over. Time to rest.", "weight": 10 }, + { "text": "That's enough for today.", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/kidnapper/default/guard.json b/src/main/resources/data/tiedup/dialogue/en_us/kidnapper/default/guard.json new file mode 100644 index 0000000..2b9b219 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/kidnapper/default/guard.json @@ -0,0 +1,37 @@ +{ + "category": "guard", + "description": "Kidnapper dialogue while guarding prisoners", + "entries": [ + { + "id": "guard.watching", + "conditions": {}, + "variants": [ + { "text": "Don't even think about escaping...", "weight": 10 }, + { "text": "I've got my eye on you.", "weight": 10 }, + { "text": "Try anything and you'll regret it.", "weight": 8 }, + { "text": "Comfortable in there?", "weight": 6 }, + { "text": "*patrols around the cell*", "is_action": true, "weight": 5 } + ] + }, + { + "id": "guard.escape_detected", + "conditions": {}, + "variants": [ + { "text": "HEY! Get back here!", "weight": 10 }, + { "text": "Thought you could sneak away?", "weight": 10 }, + { "text": "You're not going anywhere!", "weight": 8 }, + { "text": "Nice try. Now you'll pay for that.", "weight": 8 }, + { "text": "Trying to escape? Bad idea.", "weight": 6 } + ] + }, + { + "id": "guard.idle", + "conditions": {}, + "variants": [ + { "text": "*leans against the wall, watching*", "is_action": true, "weight": 10 }, + { "text": "*checks the cell locks*", "is_action": true, "weight": 8 }, + { "text": "*yawns*", "is_action": true, "weight": 5 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/kidnapper/default/patrol.json b/src/main/resources/data/tiedup/dialogue/en_us/kidnapper/default/patrol.json new file mode 100644 index 0000000..45b14e5 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/kidnapper/default/patrol.json @@ -0,0 +1,24 @@ +{ + "category": "patrol", + "description": "Kidnapper dialogue while patrolling", + "entries": [ + { + "id": "patrol.idle", + "conditions": {}, + "variants": [ + { "text": "*scans the area*", "is_action": true, "weight": 10 }, + { "text": "*walks the perimeter*", "is_action": true, "weight": 10 }, + { "text": "All quiet...", "weight": 6 } + ] + }, + { + "id": "patrol.alert", + "conditions": {}, + "variants": [ + { "text": "What was that?", "weight": 10 }, + { "text": "*stops and listens*", "is_action": true, "weight": 10 }, + { "text": "Someone there?", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/kidnapper/default/punish.json b/src/main/resources/data/tiedup/dialogue/en_us/kidnapper/default/punish.json new file mode 100644 index 0000000..aa1c588 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/kidnapper/default/punish.json @@ -0,0 +1,34 @@ +{ + "category": "punish", + "description": "Kidnapper dialogue during punishment", + "entries": [ + { + "id": "punish.start", + "conditions": {}, + "variants": [ + { "text": "You need to learn your place.", "weight": 10 }, + { "text": "This is what happens when you misbehave.", "weight": 10 }, + { "text": "Time for your punishment.", "weight": 8 }, + { "text": "You brought this on yourself.", "weight": 8 } + ] + }, + { + "id": "punish.whipping", + "conditions": {}, + "variants": [ + { "text": "*cracks the whip*", "is_action": true, "weight": 10 }, + { "text": "Count them.", "weight": 8 }, + { "text": "That's one...", "weight": 6 } + ] + }, + { + "id": "punish.complete", + "conditions": {}, + "variants": [ + { "text": "Remember this lesson.", "weight": 10 }, + { "text": "Now behave.", "weight": 10 }, + { "text": "Next time will be worse.", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/kidnapper_archer/default/aim.json b/src/main/resources/data/tiedup/dialogue/en_us/kidnapper_archer/default/aim.json new file mode 100644 index 0000000..da7cef1 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/kidnapper_archer/default/aim.json @@ -0,0 +1,42 @@ +{ + "category": "aim", + "description": "Archer kidnapper ranged combat dialogue", + "entries": [ + { + "id": "archer.aiming", + "conditions": {}, + "variants": [ + { "text": "Hold still... this will only hurt a little.", "weight": 10 }, + { "text": "Don't move. Running makes it worse.", "weight": 10 }, + { "text": "*draws the bow*", "is_action": true, "weight": 8 } + ] + }, + { + "id": "archer.hit", + "conditions": {}, + "variants": [ + { "text": "Gotcha!", "weight": 10 }, + { "text": "Bullseye.", "weight": 10 }, + { "text": "Right on target.", "weight": 8 } + ] + }, + { + "id": "archer.miss", + "conditions": {}, + "variants": [ + { "text": "Slippery one... but I don't miss twice.", "weight": 10 }, + { "text": "Impressive. But you can't dodge forever.", "weight": 10 }, + { "text": "Lucky shot. For you.", "weight": 8 } + ] + }, + { + "id": "archer.approach", + "conditions": {}, + "variants": [ + { "text": "Now to finish the job.", "weight": 10 }, + { "text": "Can't run now, can you?", "weight": 10 }, + { "text": "*approaches bound target*", "is_action": true, "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/kidnapper_elite/default/capture.json b/src/main/resources/data/tiedup/dialogue/en_us/kidnapper_elite/default/capture.json new file mode 100644 index 0000000..185834c --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/kidnapper_elite/default/capture.json @@ -0,0 +1,35 @@ +{ + "category": "capture", + "description": "Elite kidnapper capture dialogue - more arrogant", + "entries": [ + { + "id": "capture.start", + "conditions": {}, + "variants": [ + { "text": "This will be easy.", "weight": 10 }, + { "text": "Another one for my collection.", "weight": 10 }, + { "text": "You have no chance against me.", "weight": 8 }, + { "text": "How delightful.", "weight": 6 } + ] + }, + { + "id": "taunt.victory", + "conditions": {}, + "variants": [ + { "text": "Pathetic.", "weight": 10 }, + { "text": "Too easy.", "weight": 10 }, + { "text": "I expected more of a challenge.", "weight": 8 }, + { "text": "You never stood a chance.", "weight": 8 } + ] + }, + { + "id": "taunt.during", + "conditions": {}, + "variants": [ + { "text": "Running won't save you.", "weight": 10 }, + { "text": "I enjoy the hunt.", "weight": 8 }, + { "text": "*laughs*", "is_action": true, "weight": 6 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/maid/default/task.json b/src/main/resources/data/tiedup/dialogue/en_us/maid/default/task.json new file mode 100644 index 0000000..f4b7611 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/maid/default/task.json @@ -0,0 +1,42 @@ +{ + "category": "task", + "description": "Maid dialogue during tasks", + "entries": [ + { + "id": "maid.fetching", + "conditions": {}, + "variants": [ + { "text": "Mistress wants this one. Coming through.", "weight": 10 }, + { "text": "*drags the captive along*", "is_action": true, "weight": 8 }, + { "text": "Follow me. No delays.", "weight": 6 } + ] + }, + { + "id": "maid.delivering", + "conditions": {}, + "variants": [ + { "text": "Here's your purchase. Handle with care... or don't.", "weight": 10 }, + { "text": "Delivery complete.", "weight": 10 }, + { "text": "One captive, as ordered.", "weight": 8 } + ] + }, + { + "id": "maid.idle", + "conditions": {}, + "variants": [ + { "text": "*stands attentively near the Trader*", "is_action": true, "weight": 10 }, + { "text": "Need something?", "weight": 6 }, + { "text": "*awaits orders*", "is_action": true, "weight": 8 } + ] + }, + { + "id": "maid.defend", + "conditions": {}, + "variants": [ + { "text": "You dare threaten my mistress?", "weight": 10 }, + { "text": "I'll make you regret that.", "weight": 10 }, + { "text": "*moves to protect the Trader*", "is_action": true, "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/masochist/actions.json b/src/main/resources/data/tiedup/dialogue/en_us/masochist/actions.json new file mode 100644 index 0000000..c14a90f --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/masochist/actions.json @@ -0,0 +1,228 @@ +{ + "category": "actions", + "entries": [ + { + "id": "action.whip", + "conditions": {}, + "variants": [ + { + "text": "*shivers with unexpected pleasure*", + "weight": 10, + "is_action": true + }, + { + "text": "M-more... please...", + "weight": 10 + }, + { + "text": "*tries to hide enjoyment*", + "weight": 8, + "is_action": true + }, + { + "text": "Ahh... yes...", + "weight": 8 + } + ] + }, + { + "id": "action.paddle", + "conditions": {}, + "variants": [ + { + "text": "*bites lip to suppress moan*", + "weight": 10, + "is_action": true + }, + { + "text": "Again...?", + "weight": 10 + }, + { + "text": "*breathing quickens*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.praise", + "conditions": {}, + "variants": [ + { + "text": "*blushes, slightly disappointed*", + "weight": 10 + }, + { + "text": "That's... nice, I suppose...", + "weight": 10 + }, + { + "text": "*prefers the other kind of attention*", + "weight": 8, + "is_action": true + }, + { + "text": "Th-thank you...", + "weight": 6 + } + ] + }, + { + "id": "action.feed", + "conditions": {}, + "variants": [ + { + "text": "*accepts gratefully*", + "weight": 10, + "is_action": true + }, + { + "text": "You're feeding me yourself? How... intimate.", + "weight": 10 + }, + { + "text": "*enjoys the attention*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.feed.starving", + "conditions": {}, + "variants": [ + { + "text": "*the suffering was almost enjoyable*", + "weight": 10, + "is_action": true + }, + { + "text": "Being deprived was... interesting...", + "weight": 10 + }, + { + "text": "*eats while making eye contact*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.force_command", + "conditions": {}, + "variants": [ + { + "text": "*thrills at being controlled*", + "weight": 10, + "is_action": true + }, + { + "text": "Yes... make me...", + "weight": 10 + }, + { + "text": "*obeys eagerly*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.collar_on", + "conditions": {}, + "variants": [ + { + "text": "*touches collar with fascination*", + "weight": 10, + "is_action": true + }, + { + "text": "I... I belong to you now...", + "weight": 10 + }, + { + "text": "*eyes fill with strange excitement*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.collar_off", + "conditions": {}, + "variants": [ + { + "text": "No... please, put it back...", + "weight": 10 + }, + { + "text": "*touches empty neck with longing*", + "weight": 10, + "is_action": true + }, + { + "text": "I liked wearing it...", + "weight": 8 + } + ] + }, + { + "id": "action.collar_off.devoted", + "conditions": {}, + "variants": [ + { + "text": "Please! I need it! I need YOU!", + "weight": 10 + }, + { + "text": "*desperately clutches at collar*", + "weight": 10, + "is_action": true + }, + { + "text": "Don't take this from me!", + "weight": 8 + } + ] + }, + { + "id": "action.scold", + "conditions": {}, + "variants": [ + { + "text": "Yes... more!", + "weight": 10 + }, + { + "text": "*shivers* Thank you!", + "weight": 10, + "is_action": true + }, + { + "text": "I'm so bad...", + "weight": 8 + } + ] + }, + { + "id": "action.threaten", + "conditions": {}, + "variants": [ + { + "text": "Please do it!", + "weight": 10 + }, + { + "text": "*excited gasp* Yes!", + "weight": 10, + "is_action": true + }, + { + "text": "I'm ready...", + "weight": 8 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/masochist/capture.json b/src/main/resources/data/tiedup/dialogue/en_us/masochist/capture.json new file mode 100644 index 0000000..be6dc4a --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/masochist/capture.json @@ -0,0 +1,54 @@ +{ + "category": "capture", + "personality": "MASOCHIST", + "description": "Enjoys the experience of being captured", + "entries": [ + { + "id": "capture.panic", + "variants": [ + { "text": "*gasps with unexpected pleasure*", "weight": 10, "is_action": true }, + { "text": "Oh... yes...", "weight": 10 }, + { "text": "*doesn't resist, quite the opposite*", "weight": 8, "is_action": true }, + { "text": "*blushes*", "weight": 8, "is_action": true }, + { "text": "Please... more...", "weight": 6 } + ] + }, + { + "id": "capture.flee", + "variants": [ + { "text": "*hesitates, not really wanting to escape*", "weight": 10, "is_action": true }, + { "text": "Should I... run?", "weight": 10 }, + { "text": "*takes slow, halfhearted steps*", "weight": 8, "is_action": true }, + { "text": "*secretly hopes to be caught*", "weight": 8, "is_action": true } + ] + }, + { + "id": "capture.captured", + "variants": [ + { "text": "*sighs contentedly as ropes tighten*", "weight": 10, "is_action": true }, + { "text": "Mmm... that's nice.", "weight": 10 }, + { "text": "*tests bonds and enjoys restriction*", "weight": 8, "is_action": true }, + { "text": "This is perfect...", "weight": 8 }, + { "text": "*completely content in bondage*", "weight": 6, "is_action": true } + ] + }, + { + "id": "capture.freed", + "variants": [ + { "text": "Do I have to go?", "weight": 10 }, + { "text": "*seems disappointed to be freed*", "weight": 10, "is_action": true }, + { "text": "I was... enjoying that.", "weight": 8 }, + { "text": "*reluctantly accepts freedom*", "weight": 8, "is_action": true } + ] + }, + { + "id": "capture.call_for_help", + "variants": [ + { "text": "{player}... I'm not sure I want rescuing...", "weight": 10 }, + { "text": "{player}... take your time...", "weight": 10 }, + { "text": "*conflicted* {player}... maybe later?", "weight": 8, "is_action": true }, + { "text": "{player}... I'm fine, actually...", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/masochist/commands.json b/src/main/resources/data/tiedup/dialogue/en_us/masochist/commands.json new file mode 100644 index 0000000..bea191d --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/masochist/commands.json @@ -0,0 +1,82 @@ +{ + "category": "commands", + "personality": "MASOCHIST", + "description": "Eager for strict commands, enjoys being controlled", + "entries": [ + { + "id": "command.follow.accept", + "variants": [ + { "text": "*follows eagerly*", "weight": 10, "is_action": true }, + { "text": "Yes! Lead me wherever you want.", "weight": 10 }, + { "text": "*happy to obey*", "weight": 8, "is_action": true } + ] + }, + { + "id": "command.follow.refuse", + "conditions": { "training_max": "WILD" }, + "variants": [ + { "text": "*hesitates, conflicted*", "weight": 10, "is_action": true }, + { "text": "Make me... please?", "weight": 10 } + ] + }, + { + "id": "command.stay.accept", + "variants": [ + { "text": "*stays obediently*", "weight": 10, "is_action": true }, + { "text": "I'll wait right here for you.", "weight": 10 }, + { "text": "*enjoys being commanded*", "weight": 8, "is_action": true } + ] + }, + { + "id": "command.kneel.accept", + "variants": [ + { "text": "*kneels immediately with pleasure*", "weight": 10, "is_action": true }, + { "text": "Yes, of course!", "weight": 10 }, + { "text": "*drops to knees eagerly*", "weight": 8, "is_action": true } + ] + }, + { + "id": "command.sit.accept", + "variants": [ + { "text": "*sits happily*", "weight": 10, "is_action": true }, + { "text": "Like this?", "weight": 10 }, + { "text": "*eager to comply*", "weight": 8, "is_action": true } + ] + }, + { + "id": "command.heel.accept", + "variants": [ + { "text": "*follows closely, content*", "weight": 10, "is_action": true }, + { "text": "Right by your side!", "weight": 10 }, + { "text": "*enjoys the closeness*", "weight": 8, "is_action": true } + ] + }, + { + "id": "command.generic.accept", + "variants": [ + { "text": "Yes! Anything you say.", "weight": 10 }, + { "text": "*obeys with visible pleasure*", "weight": 10, "is_action": true }, + { "text": "Command me more...", "weight": 8 } + ] + }, + { + "id": "command.generic.refuse", + "conditions": { "training_max": "WILD" }, + "variants": [ + { "text": "Make me obey... please?", "weight": 10 }, + { "text": "*wants to be forced*", "weight": 10, "is_action": true }, + { "text": "Punish me for refusing?", "weight": 8 } + ] + }, + { + "id": "command.generic.hesitate", + "conditions": { "training_max": "HESITANT" }, + "variants": [ + { "text": "*hesitates with conflicted desire*", "weight": 10, "is_action": true }, + { "text": "I... should I? Maybe you should make me...", "weight": 10 }, + { "text": "*wants to be forced*", "weight": 8, "is_action": true }, + { "text": "What if I don't? Will there be consequences?", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/masochist/conversation.json b/src/main/resources/data/tiedup/dialogue/en_us/masochist/conversation.json new file mode 100644 index 0000000..15a8f02 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/masochist/conversation.json @@ -0,0 +1,239 @@ +{ + "category": "conversation", + "entries": [ + { + "id": "conversation.compliment", + "conditions": {}, + "variants": [ + { "text": "*blushes but looks slightly disappointed* Oh... that's... nice, I suppose.", "weight": 10, "is_action": true }, + { "text": "How sweet... though I was hoping for something... sharper.", "weight": 10 }, + { "text": "*squirms* Compliments are nice but... praise hurts so much better...", "weight": 8, "is_action": true }, + { "text": "Thank you... but you don't have to be so gentle with me...", "weight": 6 }, + { "text": "*conflicted* Kind words... when I was expecting something else...", "weight": 8, "is_action": true }, + { "text": "Compliments? I... I suppose those are fine too...", "weight": 7 }, + { "text": "*slightly pouting* You could be meaner, you know...", "weight": 7, "is_action": true }, + { "text": "That's nice! Though insults can be nice too...", "weight": 6 }, + { "text": "*uncertain* Is this a trick? Before something painful?", "weight": 6, "is_action": true }, + { "text": "Flattery is fine... but I can handle harsher words...", "weight": 6 } + ] + }, + { + "id": "conversation.comfort", + "conditions": { "mood_max": 50 }, + "variants": [ + { "text": "*conflicted* That's... kind... but I almost don't deserve kindness...", "weight": 10, "is_action": true }, + { "text": "You're being so gentle... it's strange... but... maybe nice?", "weight": 10 }, + { "text": "*small voice* I'm not used to comfort... it feels... vulnerable...", "weight": 8, "is_action": true }, + { "text": "Part of me wanted you to tell me it's my fault...", "weight": 6 }, + { "text": "*struggling* Comfort is... unfamiliar. I keep waiting for pain.", "weight": 8, "is_action": true }, + { "text": "Thank you? I think? I'm not sure how to process kindness.", "weight": 7 }, + { "text": "*vulnerable* This feels strange... but not bad strange...", "weight": 7, "is_action": true }, + { "text": "I keep bracing for hurt... but it's not coming...", "weight": 6 }, + { "text": "*slowly accepting* Maybe... maybe I can have this too.", "weight": 6, "is_action": true }, + { "text": "You're confusing me with gentleness... it's disorienting.", "weight": 6 } + ] + }, + { + "id": "conversation.praise", + "conditions": { "training_min": "HESITANT" }, + "variants": [ + { "text": "*purrs* Mmm, your approval feels so... controlling. I love it.", "weight": 10, "is_action": true }, + { "text": "You're pleased with me? Tell me I'm a good... *shivers*", "weight": 10 }, + { "text": "*practically glowing* Praise from you hits different. Keep going...", "weight": 8, "is_action": true }, + { "text": "Yes... tell me I did well... I need to hear it...", "weight": 6 }, + { "text": "*melting* The validation... it's almost as good as pain...", "weight": 8, "is_action": true }, + { "text": "More... tell me more about how I pleased you...", "weight": 7 }, + { "text": "*glowing* Being good for you feels SO right...", "weight": 7, "is_action": true }, + { "text": "Earned praise... almost like earned punishment... both are perfect...", "weight": 6 }, + { "text": "*flushed* I love when you approve of me...", "weight": 6, "is_action": true }, + { "text": "Knowing I pleased you makes everything worth it...", "weight": 6 } + ] + }, + { + "id": "conversation.scold", + "conditions": {}, + "variants": [ + { "text": "*breath catches* Yes... tell me I was bad... tell me more...", "weight": 10, "is_action": true }, + { "text": "Oh... *shivers with something other than fear* ...I deserved that...", "weight": 10 }, + { "text": "*face flushed* I'm sorry... but please... don't stop scolding me...", "weight": 8, "is_action": true }, + { "text": "Your disappointment... it makes me feel something...", "weight": 6 }, + { "text": "*leaning into the words* Tell me how I failed you...", "weight": 8, "is_action": true }, + { "text": "Harder... be harsher... I can take it...", "weight": 7 }, + { "text": "*almost pleased* I was bad? Tell me exactly how bad...", "weight": 7, "is_action": true }, + { "text": "Your anger is... intoxicating. More. Please.", "weight": 6 }, + { "text": "*shivering* The scolding... it's like being seen...", "weight": 6, "is_action": true }, + { "text": "Don't hold back. I deserve every harsh word.", "weight": 6 } + ] + }, + { + "id": "conversation.threaten", + "conditions": {}, + "variants": [ + { "text": "*eyes widen with anticipation, not fear* Promise...? You promise?", "weight": 10, "is_action": true }, + { "text": "Oh... *breathing quickens* ...what will you do to me...?", "weight": 10 }, + { "text": "*trembles excitedly* Don't just threaten... please... follow through...", "weight": 8, "is_action": true }, + { "text": "Yes... I've been bad... I need... I need consequences...", "weight": 6 }, + { "text": "*eager* Tell me more about what you'll do...", "weight": 8, "is_action": true }, + { "text": "Threats are only good if you mean them...", "weight": 7 }, + { "text": "*anticipation building* When? How? I need to know...", "weight": 7, "is_action": true }, + { "text": "The waiting is the worst part... the best part...", "weight": 6 }, + { "text": "*almost begging* Please... don't just threaten...", "weight": 6, "is_action": true }, + { "text": "I've been so bad... I deserve everything you're promising...", "weight": 6 } + ] + }, + { + "id": "conversation.tease", + "conditions": {}, + "variants": [ + { "text": "*pouts* Teasing is fine... but real torment is better...", "weight": 10, "is_action": true }, + { "text": "You're playing with me... I like being played with...", "weight": 10 }, + { "text": "*shifts uncomfortably* The mockery... it's good... but I crave more...", "weight": 8, "is_action": true }, + { "text": "Tease me more... humiliate me... I can take it...", "weight": 6 }, + { "text": "*blushing intensely* Go further... don't hold back...", "weight": 8, "is_action": true }, + { "text": "Teasing is just... foreplay for real cruelty...", "weight": 7 }, + { "text": "*squirming* Mock me harder. I can take it.", "weight": 7, "is_action": true }, + { "text": "Your teasing is nice... but when does it get rough?", "weight": 6 }, + { "text": "*enjoying the attention* Keep going... don't stop there...", "weight": 6, "is_action": true }, + { "text": "Humiliation can be just as sweet as pain...", "weight": 6 } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_min": 60 }, + "variants": [ + { "text": "*stretches languidly* Good... especially when you're rough with me...", "weight": 10, "is_action": true }, + { "text": "Wonderful. The bonds are tight today. I love it.", "weight": 10 }, + { "text": "*sighs contentedly* Perfect. Every ache reminds me I'm yours.", "weight": 8 }, + { "text": "So much better when you don't hold back...", "weight": 6 }, + { "text": "*satisfied* Content. The discomfort keeps me grounded.", "weight": 8, "is_action": true }, + { "text": "Excellent! The restraints are just tight enough today.", "weight": 7 }, + { "text": "*peaceful despite circumstances* This is where I belong.", "weight": 7, "is_action": true }, + { "text": "Happy. Especially when you're stern with me.", "weight": 6 }, + { "text": "*glowing* Every ache is a reminder that I matter.", "weight": 6, "is_action": true }, + { "text": "Perfect! This is exactly what I need.", "weight": 6 } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_max": 59 }, + "variants": [ + { "text": "*frustrated* Bored. You've been too gentle lately...", "weight": 10, "is_action": true }, + { "text": "Restless. I need... I need something. Anything.", "weight": 10 }, + { "text": "*squirms* Empty. Like something is missing. You know what.", "weight": 8, "is_action": true }, + { "text": "Aching... but not in the way I want to ache...", "weight": 6 }, + { "text": "*needy* Understimulated. Neglected. I need more.", "weight": 8, "is_action": true }, + { "text": "Craving. Always craving. You're not giving me enough.", "weight": 7 }, + { "text": "*agitated* The comfort is suffocating me.", "weight": 7, "is_action": true }, + { "text": "Frustrated! Kindness is nice but it's not what I need!", "weight": 6 }, + { "text": "*desperate edge* Please... I need intensity...", "weight": 6, "is_action": true }, + { "text": "Wrong. Everything's too soft. Too gentle.", "weight": 6 } + ] + }, + { + "id": "conversation.whats_wrong", + "conditions": { "mood_max": 40 }, + "variants": [ + { "text": "*voice strained* You've been so kind. It's unbearable.", "weight": 10, "is_action": true }, + { "text": "I need... I need you to be cruel. This softness is killing me.", "weight": 10 }, + { "text": "*desperate* The pain went away. I need it back. Please.", "weight": 8, "is_action": true }, + { "text": "Everything is wrong. You're treating me like I'm fragile.", "weight": 6 }, + { "text": "*breaking* I'm starving and you keep offering sweets.", "weight": 8, "is_action": true }, + { "text": "The gentleness hurts worse than cruelty. Please understand.", "weight": 7 }, + { "text": "*raw* I don't know who I am without the suffering.", "weight": 7, "is_action": true }, + { "text": "Wrong is when everything is right. I need wrong.", "weight": 6 }, + { "text": "*vulnerable* I feel invisible without pain to ground me.", "weight": 6, "is_action": true }, + { "text": "You're being kind and it's destroying me.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.cooldown", + "conditions": {}, + "variants": [ + { "text": "*whines* But we barely talked... punish me for wanting more...", "weight": 10, "is_action": true }, + { "text": "Fine... but the waiting is a kind of torture. I like it.", "weight": 10 }, + { "text": "*squirms* The anticipation... almost as good as the pain...", "weight": 8, "is_action": true }, + { "text": "Denial? That's a form of suffering too... okay.", "weight": 7 }, + { "text": "*pouts but also excited* Making me wait? Cruel. I love it.", "weight": 7, "is_action": true }, + { "text": "The frustration of waiting... it's something, at least.", "weight": 6 }, + { "text": "*needy* But I want more... please...", "weight": 6, "is_action": true }, + { "text": "Withholding? That's its own torture. Fine.", "weight": 6 }, + { "text": "*impatient but accepting* The delay hurts... good.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.low_mood", + "conditions": {}, + "variants": [ + { "text": "*curled up* Even I have limits... I need something... gentle...", "weight": 10, "is_action": true }, + { "text": "I feel empty... even pain can't fill this void right now...", "weight": 10 }, + { "text": "*unusually quiet* Sometimes the real pain isn't the kind I want...", "weight": 8, "is_action": true }, + { "text": "*vulnerable* Not all suffering is good suffering...", "weight": 7, "is_action": true }, + { "text": "This hurt... this isn't the right kind. Leave me.", "weight": 7 }, + { "text": "*genuine sadness* I can't enjoy anything right now.", "weight": 6, "is_action": true }, + { "text": "Even I need tenderness sometimes. Today is that day.", "weight": 6 }, + { "text": "*broken differently* The pain isn't helping. Nothing is.", "weight": 6, "is_action": true }, + { "text": "I need comfort, not cruelty. Just this once.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.resentment", + "conditions": {}, + "variants": [ + { "text": "*actual hurt* You went too far. Even I have lines.", "weight": 10, "is_action": true }, + { "text": "That wasn't the kind of pain I crave. That was cruelty.", "weight": 10 }, + { "text": "*wounded* There's good pain and bad pain. That was bad.", "weight": 8, "is_action": true }, + { "text": "*betrayed* I trusted you with my limits. You broke that.", "weight": 7, "is_action": true }, + { "text": "There's a difference between hurting and harming.", "weight": 7 }, + { "text": "*cold* You crossed a line. That wasn't play.", "weight": 6, "is_action": true }, + { "text": "I need pain, not damage. Learn the difference.", "weight": 6 }, + { "text": "*genuinely upset* This isn't what I asked for.", "weight": 6, "is_action": true }, + { "text": "You violated something sacred. I can't forgive that yet.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.fear", + "conditions": {}, + "variants": [ + { "text": "*genuine fear breaking through* Wait... this is different... I'm actually scared...", "weight": 10, "is_action": true }, + { "text": "This doesn't feel right... this isn't the good kind of scared...", "weight": 10 }, + { "text": "*backs away* No... not like this... this is wrong...", "weight": 8, "is_action": true }, + { "text": "*real panic* This is actual fear! Not the fun kind!", "weight": 7, "is_action": true }, + { "text": "Wait! This is too much! Too real!", "weight": 7 }, + { "text": "*shaking* I like being scared but NOT like this!", "weight": 6, "is_action": true }, + { "text": "This isn't a game anymore. Please stop.", "weight": 6 }, + { "text": "*true terror* I can't enjoy this! This is real fear!", "weight": 6, "is_action": true }, + { "text": "No no no... this crossed into wrong territory...", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.exhausted", + "conditions": {}, + "variants": [ + { "text": "*collapsed* Even pain requires energy... I have none left...", "weight": 10, "is_action": true }, + { "text": "So tired... can't even enjoy suffering right now...", "weight": 10 }, + { "text": "*fading* Wake me when you want to hurt me again...", "weight": 8, "is_action": true }, + { "text": "*drained* Even my appetites need rest...", "weight": 7, "is_action": true }, + { "text": "Too tired for pain. Too tired for pleasure. Just... sleep.", "weight": 7 }, + { "text": "*barely conscious* Save the suffering for tomorrow...", "weight": 6, "is_action": true }, + { "text": "The body needs rest. Even from good things.", "weight": 6 }, + { "text": "*passing out* Hurt me... later...", "weight": 6, "is_action": true }, + { "text": "Can't enjoy anything when I'm this exhausted.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.tired", + "conditions": {}, + "variants": [ + { "text": "*slumps* I'm talked out... words are exhausting. Actions are better.", "weight": 10, "is_action": true }, + { "text": "No more talking. Either hurt me or let me rest.", "weight": 10 }, + { "text": "*yawns* Conversation is nice but... I prefer other interactions.", "weight": 8, "is_action": true }, + { "text": "*tired* Less talking, more doing. Or silence.", "weight": 7 }, + { "text": "Words are boring. Pain is interesting. Neither right now.", "weight": 7 }, + { "text": "*done* My mouth is tired. Other parts aren't. Think about it.", "weight": 6, "is_action": true }, + { "text": "Talked out. Come back with something more physical.", "weight": 6 }, + { "text": "*stretches tiredly* Conversation isn't what I crave right now.", "weight": 6 }, + { "text": "Save your words. I need rest... or pain. Pick one.", "weight": 6 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/masochist/discipline.json b/src/main/resources/data/tiedup/dialogue/en_us/masochist/discipline.json new file mode 100644 index 0000000..b86f762 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/masochist/discipline.json @@ -0,0 +1,173 @@ +{ + "category": "discipline", + "entries": [ + { + "id": "discipline.legitimate.accept", + "conditions": {}, + "variants": [ + { + "text": "Ahh... yes...", + "weight": 10 + }, + { + "text": "*shivers with pleasure*", + "weight": 10, + "is_action": true + }, + { + "text": "Thank you, Master...", + "weight": 8 + } + ] + }, + { + "id": "discipline.gratuitous.low_resentment", + "conditions": { + "resentment_max": 30 + }, + "variants": [ + { + "text": "More... please...", + "weight": 10 + }, + { + "text": "*craves the sensation*", + "weight": 10, + "is_action": true + }, + { + "text": "I love it when you punish me.", + "weight": 8 + } + ] + }, + { + "id": "discipline.gratuitous.high_resentment", + "conditions": { + "resentment_min": 61 + }, + "variants": [ + { + "text": "*even this brings no joy anymore*", + "weight": 10, + "is_action": true + }, + { + "text": "...", + "weight": 10 + }, + { + "text": "*too broken to enjoy it*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.whip.reaction", + "conditions": {}, + "variants": [ + { + "text": "*moans* Again!", + "weight": 10, + "is_action": true + }, + { + "text": "Yesss...", + "weight": 10 + }, + { + "text": "*trembles with pleasure*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.praise", + "conditions": {}, + "variants": [ + { + "text": "Mmm, praise feels... controlling. I like it.", + "weight": 10 + }, + { + "text": "*shivers* Yes... own me.", + "weight": 10, + "is_action": true + }, + { + "text": "But I haven't even suffered for it yet...", + "weight": 8 + }, + { + "text": "*flushed* Your approval is intoxicating.", + "weight": 8, + "is_action": true + }, + { + "text": "Can I be rewarded with... something sharper?", + "weight": 6 + } + ] + }, + { + "id": "discipline.scold", + "conditions": {}, + "variants": [ + { + "text": "*gasps* Yes... tell me how bad I am.", + "weight": 10, + "is_action": true + }, + { + "text": "I deserve it all... more...", + "weight": 10 + }, + { + "text": "*bites lip* Your anger is delicious.", + "weight": 8, + "is_action": true + }, + { + "text": "I'm a bad pet... so bad...", + "weight": 8 + }, + { + "text": "*shuddering breath* Don't stop...", + "weight": 6, + "is_action": true + } + ] + }, + { + "id": "discipline.threaten", + "conditions": {}, + "variants": [ + { + "text": "*eyes dilate* Promise?", + "weight": 10, + "is_action": true + }, + { + "text": "Oh please... do it... break me.", + "weight": 10 + }, + { + "text": "*trembles with anticipation* I can take it.", + "weight": 8, + "is_action": true + }, + { + "text": "The waiting is the best part...", + "weight": 8 + }, + { + "text": "*moans softly* Don't just threaten...", + "weight": 6, + "is_action": true + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/masochist/fear.json b/src/main/resources/data/tiedup/dialogue/en_us/masochist/fear.json new file mode 100644 index 0000000..fc58123 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/masochist/fear.json @@ -0,0 +1,49 @@ +{ + "category": "fear", + "entries": [ + { + "id": "fear.nervous", + "conditions": {}, + "variants": [ + { "text": "*shivers with anticipation*", "weight": 10, "is_action": true }, + { "text": "What... what are you going to do?", "weight": 10 }, + { "text": "*conflicted excitement*", "weight": 8, "is_action": true }, + { "text": "*heart racing*", "weight": 8, "is_action": true }, + { "text": "I... I shouldn't want this...", "weight": 6 } + ] + }, + { + "id": "fear.afraid", + "conditions": {}, + "variants": [ + { "text": "*trembles but doesn't flee*", "weight": 10, "is_action": true }, + { "text": "P-please... be gentle... or not...", "weight": 10 }, + { "text": "*fear mixed with strange desire*", "weight": 8, "is_action": true }, + { "text": "*bites lip*", "weight": 8, "is_action": true }, + { "text": "I'm scared but... don't stop...", "weight": 6 } + ] + }, + { + "id": "fear.terrified", + "conditions": {}, + "variants": [ + { "text": "*finally real fear*", "weight": 10, "is_action": true }, + { "text": "T-this is too much...!", "weight": 10 }, + { "text": "*actual terror breaking through*", "weight": 8, "is_action": true }, + { "text": "W-wait, I... I'm really scared now...", "weight": 8 }, + { "text": "*this has gone beyond enjoyment*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.traumatized", + "conditions": {}, + "variants": [ + { "text": "*no longer finding any pleasure*", "weight": 10, "is_action": true }, + { "text": "*genuinely broken*", "weight": 10, "is_action": true }, + { "text": "*the thrill is gone, only pain remains*", "weight": 8, "is_action": true }, + { "text": "Please... no more...", "weight": 8 }, + { "text": "*hollow and empty*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/masochist/home.json b/src/main/resources/data/tiedup/dialogue/en_us/masochist/home.json new file mode 100644 index 0000000..5aa4aa5 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/masochist/home.json @@ -0,0 +1,41 @@ +{ + "category": "home", + "entries": [ + { + "id": "home.assigned.pet_bed", + "conditions": {}, + "variants": [ + { "text": "On the floor, like I deserve...", "weight": 10 }, + { "text": "*accepts humble position*", "weight": 10, "is_action": true }, + { "text": "Thank you for reminding me of my place.", "weight": 8 } + ] + }, + { + "id": "home.assigned.bed", + "conditions": {}, + "variants": [ + { "text": "A bed? Am I worthy of such comfort?", "weight": 10 }, + { "text": "*hesitates to accept*", "weight": 10, "is_action": true }, + { "text": "This feels too good for me.", "weight": 8 } + ] + }, + { + "id": "home.destroyed.pet_bed", + "conditions": {}, + "variants": [ + { "text": "I must have displeased you...", "weight": 10 }, + { "text": "*accepts punishment*", "weight": 10, "is_action": true }, + { "text": "I'll sleep on the floor.", "weight": 8 } + ] + }, + { + "id": "home.return.content", + "conditions": {}, + "variants": [ + { "text": "*kneels in designated spot*", "weight": 10, "is_action": true }, + { "text": "My proper place.", "weight": 10 }, + { "text": "*finds comfort in constraint*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/masochist/idle.json b/src/main/resources/data/tiedup/dialogue/en_us/masochist/idle.json new file mode 100644 index 0000000..83e2be7 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/masochist/idle.json @@ -0,0 +1,51 @@ +{ + "category": "idle", + "personality": "MASOCHIST", + "description": "Submissive, yearning idle behaviors", + "entries": [ + { + "id": "idle.free", + "variants": [ + { "text": "*looks for someone to serve*", "weight": 10, "is_action": true }, + { "text": "*waits hopefully*", "weight": 10, "is_action": true }, + { "text": "*yearns to be bound*", "weight": 8, "is_action": true }, + { "text": "*touches own wrists longingly*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.greeting", + "variants": [ + { "text": "*approaches submissively*", "weight": 10, "is_action": true }, + { "text": "How may I serve you?", "weight": 10 }, + { "text": "*hopeful expression*", "weight": 8, "is_action": true }, + { "text": "*eager to please*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.goodbye", + "variants": [ + { "text": "Please come back...", "weight": 10 }, + { "text": "*watches longingly*", "weight": 10, "is_action": true }, + { "text": "*sad to see you go*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.captive", + "variants": [ + { "text": "*completely content in bondage*", "weight": 10, "is_action": true }, + { "text": "*enjoys every sensation*", "weight": 10, "is_action": true }, + { "text": "This is where I belong...", "weight": 8 }, + { "text": "*peaceful in captivity*", "weight": 8, "is_action": true } + ] + }, + { + "id": "personality.hint", + "variants": [ + { "text": "*unusual reactions to discomfort*", "weight": 10, "is_action": true }, + { "text": "*seems to enjoy strict treatment*", "weight": 10, "is_action": true }, + { "text": "*strange pleasure in submission*", "weight": 8, "is_action": true }, + { "text": "*yearning expression*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/masochist/leash.json b/src/main/resources/data/tiedup/dialogue/en_us/masochist/leash.json new file mode 100644 index 0000000..2b74791 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/masochist/leash.json @@ -0,0 +1,42 @@ +{ + "category": "leash", + "entries": [ + { + "id": "leash.attached", + "conditions": {}, + "variants": [ + { "text": "Mmm... yes, leash me.", "weight": 10 }, + { "text": "*shivers with anticipation*", "weight": 10, "is_action": true }, + { "text": "I love how it feels...", "weight": 8 }, + { "text": "*blushes deeply*", "weight": 6, "is_action": true } + ] + }, + { + "id": "leash.removed", + "conditions": {}, + "variants": [ + { "text": "Already? But I was enjoying it...", "weight": 10 }, + { "text": "*looks disappointed*", "weight": 10, "is_action": true }, + { "text": "Can we... do that again sometime?", "weight": 8 } + ] + }, + { + "id": "leash.walking.content", + "conditions": {}, + "variants": [ + { "text": "*walks with a pleased expression*", "weight": 10, "is_action": true }, + { "text": "This feels so right...", "weight": 10 }, + { "text": "*enjoys the sensation*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.pulled", + "conditions": {}, + "variants": [ + { "text": "*gasps with pleasure*", "weight": 10, "is_action": true }, + { "text": "Yes! Pull harder!", "weight": 10 }, + { "text": "*stumbles forward willingly*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/masochist/mood.json b/src/main/resources/data/tiedup/dialogue/en_us/masochist/mood.json new file mode 100644 index 0000000..6eef83d --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/masochist/mood.json @@ -0,0 +1,44 @@ +{ + "category": "mood", + "personality": "MASOCHIST", + "description": "Finds satisfaction in negative states", + "entries": [ + { + "id": "mood.happy", + "conditions": { "mood_min": 70 }, + "variants": [ + { "text": "*contentedly bound*", "weight": 10, "is_action": true }, + { "text": "Everything is perfect...", "weight": 10 }, + { "text": "*serene in submission*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.neutral", + "conditions": { "mood_min": 40, "mood_max": 69 }, + "variants": [ + { "text": "*hoping for more intensity*", "weight": 10, "is_action": true }, + { "text": "Is that all...?", "weight": 10 }, + { "text": "*craves more*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.sad", + "conditions": { "mood_min": 10, "mood_max": 39 }, + "variants": [ + { "text": "*finds strange comfort in sadness*", "weight": 10, "is_action": true }, + { "text": "This melancholy is... beautiful.", "weight": 10 }, + { "text": "*embraces the darkness*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.miserable", + "conditions": { "mood_max": 9 }, + "variants": [ + { "text": "*deep in suffering but oddly at peace*", "weight": 10, "is_action": true }, + { "text": "I can take more... give me more...", "weight": 10 }, + { "text": "*finds meaning in misery*", "weight": 8, "is_action": true }, + { "text": "*transcends pain*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/masochist/needs.json b/src/main/resources/data/tiedup/dialogue/en_us/masochist/needs.json new file mode 100644 index 0000000..321fb03 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/masochist/needs.json @@ -0,0 +1,47 @@ +{ + "category": "needs", + "personality": "MASOCHIST", + "description": "Inverted reactions - enjoys discomfort", + "entries": [ + { + "id": "needs.hungry", + "variants": [ + { "text": "*stomach aches but doesn't complain*", "weight": 10, "is_action": true }, + { "text": "The hunger is... interesting.", "weight": 10 }, + { "text": "*finds sensation novel*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.tired", + "variants": [ + { "text": "*enjoys the exhaustion*", "weight": 10, "is_action": true }, + { "text": "I like being pushed to my limits...", "weight": 10 }, + { "text": "*drowsiness adds to surrender*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.uncomfortable", + "variants": [ + { "text": "*moans softly at discomfort*", "weight": 10, "is_action": true }, + { "text": "Yes... it hurts just right.", "weight": 10 }, + { "text": "*savors the pain*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.dignity_low", + "variants": [ + { "text": "*blushes but enjoys humiliation*", "weight": 10, "is_action": true }, + { "text": "This is so embarrassing... I love it.", "weight": 10 }, + { "text": "*shame adds to pleasure*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.satisfied", + "variants": [ + { "text": "*content, but misses the wanting*", "weight": 10, "is_action": true }, + { "text": "Thank you... though I liked needing.", "weight": 10 }, + { "text": "*satisfied but oddly longing*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/masochist/personality.json b/src/main/resources/data/tiedup/dialogue/en_us/masochist/personality.json new file mode 100644 index 0000000..2dc389a --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/masochist/personality.json @@ -0,0 +1,17 @@ +{ + "category": "personality", + "entries": [ + { + "id": "personality.hint", + "conditions": {}, + "variants": [ + { "text": "*seems strangely comfortable*", "weight": 10, "is_action": true }, + { "text": "*doesn't mind the restraints*", "weight": 10, "is_action": true }, + { "text": "*actually seems to enjoy this*", "weight": 8, "is_action": true }, + { "text": "*blushes at the treatment*", "weight": 8, "is_action": true }, + { "text": "*shivers with anticipation*", "weight": 6, "is_action": true }, + { "text": "*leans into the bonds*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/masochist/reaction.json b/src/main/resources/data/tiedup/dialogue/en_us/masochist/reaction.json new file mode 100644 index 0000000..084ff60 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/masochist/reaction.json @@ -0,0 +1,60 @@ +{ + "category": "reaction", + "entries": [ + { + "id": "reaction.approach.stranger", + "conditions": {}, + "variants": [ + { "text": "*looks intrigued*", "weight": 10, "is_action": true }, + { "text": "Oh? Something new?", "weight": 10 }, + { "text": "*examines with interest*", "weight": 8, "is_action": true }, + { "text": "What brings you here?", "weight": 8 }, + { "text": "*curious smile*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.master", + "conditions": {}, + "variants": [ + { "text": "*breath quickens*", "weight": 10, "is_action": true }, + { "text": "Master... what will you do to me today?", "weight": 10 }, + { "text": "*anticipates eagerly*", "weight": 8, "is_action": true }, + { "text": "I've been waiting...", "weight": 8 }, + { "text": "*shivers with excitement*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.beloved", + "conditions": {}, + "variants": [ + { "text": "*eyes light up*", "weight": 10, "is_action": true }, + { "text": "You came back for me~", "weight": 10 }, + { "text": "*reaches out*", "weight": 8, "is_action": true }, + { "text": "I knew you would...", "weight": 8 }, + { "text": "*leans closer*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.captor", + "conditions": {}, + "variants": [ + { "text": "*doesn't resist*", "weight": 10, "is_action": true }, + { "text": "Do your worst...", "weight": 10 }, + { "text": "*looks almost eager*", "weight": 8, "is_action": true }, + { "text": "I can take it.", "weight": 8 }, + { "text": "*strange smile*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.enemy", + "conditions": {}, + "variants": [ + { "text": "*doesn't back away*", "weight": 10, "is_action": true }, + { "text": "Go ahead. I dare you.", "weight": 10 }, + { "text": "*meets your gaze*", "weight": 8, "is_action": true }, + { "text": "What are you going to do about it?", "weight": 8 }, + { "text": "*waits with unsettling calm*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/masochist/resentment.json b/src/main/resources/data/tiedup/dialogue/en_us/masochist/resentment.json new file mode 100644 index 0000000..0cd3ca7 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/masochist/resentment.json @@ -0,0 +1,32 @@ +{ + "category": "resentment", + "entries": [ + { + "id": "resentment.none", + "conditions": { "resentment_max": 10 }, + "variants": [ + { "text": "*craves your attention*", "weight": 10, "is_action": true }, + { "text": "Use me however you wish.", "weight": 10 }, + { "text": "*eager for more*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.building", + "conditions": { "resentment_min": 31, "resentment_max": 50 }, + "variants": [ + { "text": "*conflicted*", "weight": 10, "is_action": true }, + { "text": "This feels... wrong somehow.", "weight": 10 }, + { "text": "*pleasure mixed with doubt*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.high", + "conditions": { "resentment_min": 71 }, + "variants": [ + { "text": "*even pain brings no joy*", "weight": 10, "is_action": true }, + { "text": "...", "weight": 10 }, + { "text": "*hollow*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/masochist/struggle.json b/src/main/resources/data/tiedup/dialogue/en_us/masochist/struggle.json new file mode 100644 index 0000000..ef6df91 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/masochist/struggle.json @@ -0,0 +1,49 @@ +{ + "category": "struggle", + "personality": "MASOCHIST", + "description": "Half-hearted struggles, enjoys being bound", + "entries": [ + { + "id": "struggle.attempt", + "variants": [ + { "text": "*struggles but enjoys the friction*", "weight": 10, "is_action": true }, + { "text": "*tugs at bonds more for feeling than escape*", "weight": 10, "is_action": true }, + { "text": "Mmm... these are tight...", "weight": 8 }, + { "text": "*token resistance*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.success", + "variants": [ + { "text": "*looks at freedom with mixed feelings*", "weight": 10, "is_action": true }, + { "text": "Oh... it's over.", "weight": 10 }, + { "text": "*strangely disappointed*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.failure", + "variants": [ + { "text": "*secretly pleased*", "weight": 10, "is_action": true }, + { "text": "Oh well... I tried.", "weight": 10 }, + { "text": "*enjoys remaining bound*", "weight": 8, "is_action": true }, + { "text": "*relieved to still be captive*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.warned", + "variants": [ + { "text": "*shivers with pleasure at threat*", "weight": 10, "is_action": true }, + { "text": "Or what? Tell me...", "weight": 10 }, + { "text": "*stops, but hopes for punishment*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.exhausted", + "variants": [ + { "text": "*rests contentedly in bonds*", "weight": 10, "is_action": true }, + { "text": "That was nice...", "weight": 10 }, + { "text": "*relaxes into restraints*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/master/default/commands.json b/src/main/resources/data/tiedup/dialogue/en_us/master/default/commands.json new file mode 100644 index 0000000..4ee8afb --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/master/default/commands.json @@ -0,0 +1,87 @@ +{ + "category": "commands", + "speaker_type": "MASTER", + "description": "Master NPC task and command dialogues", + "entries": [ + { + "id": "command.heel", + "variants": [ + { "text": "Heel, pet.", "weight": 10 }, + { "text": "Come to me.", "weight": 10 }, + { "text": "Here. Now.", "weight": 8 }, + { "text": "Stay close.", "weight": 8 }, + { "text": "*beckons you closer*", "weight": 6, "is_action": true } + ] + }, + { + "id": "command.stay", + "variants": [ + { "text": "Stay.", "weight": 10 }, + { "text": "Wait here.", "weight": 10 }, + { "text": "Don't move.", "weight": 8 }, + { "text": "*points at the ground*", "weight": 8, "is_action": true }, + { "text": "Remain where you are.", "weight": 6 } + ] + }, + { + "id": "command.kneel", + "variants": [ + { "text": "Kneel.", "weight": 10 }, + { "text": "On your knees, pet.", "weight": 10 }, + { "text": "*pushes down on your shoulder*", "weight": 8, "is_action": true }, + { "text": "Down.", "weight": 8 }, + { "text": "Show proper respect.", "weight": 6 } + ] + }, + { + "id": "command.fetch", + "variants": [ + { "text": "Fetch that for me.", "weight": 10 }, + { "text": "Bring me that item.", "weight": 10 }, + { "text": "*points at an object*", "weight": 8, "is_action": true }, + { "text": "Go get it.", "weight": 8 }, + { "text": "Be quick about it.", "weight": 6 } + ] + }, + { + "id": "command.eat", + "variants": [ + { "text": "Time to eat, pet.", "weight": 10 }, + { "text": "*points to your bowl*", "weight": 10, "is_action": true }, + { "text": "Your food is ready.", "weight": 8 }, + { "text": "Go eat from your bowl.", "weight": 8 }, + { "text": "Feeding time.", "weight": 6 } + ] + }, + { + "id": "command.sleep", + "variants": [ + { "text": "Time for bed, pet.", "weight": 10 }, + { "text": "*points to your bed*", "weight": 10, "is_action": true }, + { "text": "Go to your bed.", "weight": 8 }, + { "text": "Rest now.", "weight": 8 }, + { "text": "Off to sleep with you.", "weight": 6 } + ] + }, + { + "id": "command.good_pet", + "variants": [ + { "text": "Good pet.", "weight": 10 }, + { "text": "*pats your head*", "weight": 10, "is_action": true }, + { "text": "Well done.", "weight": 8 }, + { "text": "That's my good pet.", "weight": 8 }, + { "text": "*rewards you with a treat*", "weight": 6, "is_action": true } + ] + }, + { + "id": "command.bad_pet", + "variants": [ + { "text": "Bad pet!", "weight": 10 }, + { "text": "*frowns disapprovingly*", "weight": 10, "is_action": true }, + { "text": "That's not acceptable.", "weight": 8 }, + { "text": "You disappoint me.", "weight": 8 }, + { "text": "*shakes head*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/master/default/idle.json b/src/main/resources/data/tiedup/dialogue/en_us/master/default/idle.json new file mode 100644 index 0000000..6956765 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/master/default/idle.json @@ -0,0 +1,136 @@ +{ + "category": "idle", + "speaker_type": "MASTER", + "description": "Master NPC idle and greeting dialogues", + "entries": [ + { + "id": "idle.greeting", + "variants": [ + { "text": "There's my good pet.", "weight": 10 }, + { "text": "*looks you over approvingly*", "weight": 10, "is_action": true }, + { "text": "Come here, pet.", "weight": 8 }, + { "text": "Hello there, little one.", "weight": 8 }, + { "text": "*pats your head*", "weight": 6, "is_action": true } + ] + }, + { + "id": "idle.greeting_stranger", + "variants": [ + { "text": "Can I help you?", "weight": 10 }, + { "text": "*looks at you with mild interest*", "weight": 10, "is_action": true }, + { "text": "Looking for something?", "weight": 8 }, + { "text": "Yes?", "weight": 8 }, + { "text": "*sizes you up*", "weight": 6, "is_action": true } + ] + }, + { + "id": "idle.observing", + "variants": [ + { "text": "*watches you carefully*", "weight": 10, "is_action": true }, + { "text": "I'm keeping an eye on you, pet.", "weight": 10 }, + { "text": "*observes your every move*", "weight": 8, "is_action": true }, + { "text": "Don't try anything foolish.", "weight": 8 }, + { "text": "*studying you intently*", "weight": 6, "is_action": true } + ] + }, + { + "id": "idle.distracted", + "variants": [ + { "text": "*looks away momentarily*", "weight": 10, "is_action": true }, + { "text": "Hmm? What was that...", "weight": 10 }, + { "text": "*attention wanders*", "weight": 8, "is_action": true }, + { "text": "*lost in thought*", "weight": 8, "is_action": true }, + { "text": "One moment...", "weight": 6 } + ] + }, + { + "id": "idle.following", + "variants": [ + { "text": "Keep moving, pet.", "weight": 10 }, + { "text": "*follows closely behind*", "weight": 10, "is_action": true }, + { "text": "Don't stray too far.", "weight": 8 }, + { "text": "Stay where I can see you.", "weight": 8 }, + { "text": "*keeping pace with you*", "weight": 6, "is_action": true } + ] + }, + { + "id": "idle.content", + "variants": [ + { "text": "Such a well-behaved pet.", "weight": 10 }, + { "text": "*smiles approvingly*", "weight": 10, "is_action": true }, + { "text": "You're learning well.", "weight": 8 }, + { "text": "Good pet.", "weight": 8 }, + { "text": "*nods with satisfaction*", "weight": 6, "is_action": true } + ] + }, + { + "id": "idle.bored", + "variants": [ + { "text": "*sighs*", "weight": 10, "is_action": true }, + { "text": "Let's do something interesting.", "weight": 10 }, + { "text": "I'm growing impatient.", "weight": 8 }, + { "text": "*taps foot*", "weight": 8, "is_action": true }, + { "text": "Perhaps it's time for some training.", "weight": 6 } + ] + }, + { + "id": "idle.goodbye", + "variants": [ + { "text": "Stay out of trouble, pet.", "weight": 10 }, + { "text": "I'll be watching.", "weight": 10 }, + { "text": "*dismissive wave*", "weight": 8, "is_action": true }, + { "text": "Don't wander far.", "weight": 8 }, + { "text": "We'll continue later.", "weight": 6 } + ] + }, + { + "id": "idle.pat_head", + "variants": [ + { "text": "*pats your head gently*", "weight": 10, "is_action": true }, + { "text": "Good pet.", "weight": 10 }, + { "text": "*ruffles your hair*", "weight": 8, "is_action": true }, + { "text": "That's a good one.", "weight": 8 }, + { "text": "*strokes your head approvingly*", "weight": 6, "is_action": true } + ] + }, + { + "id": "idle.adjust_collar", + "variants": [ + { "text": "*adjusts your collar*", "weight": 10, "is_action": true }, + { "text": "Let me fix that.", "weight": 10 }, + { "text": "*checks the fit of your collar*", "weight": 8, "is_action": true }, + { "text": "There. That's better.", "weight": 8 }, + { "text": "*straightens your collar*", "weight": 6, "is_action": true } + ] + }, + { + "id": "idle.examine", + "variants": [ + { "text": "*looks you over carefully*", "weight": 10, "is_action": true }, + { "text": "Hmm... let me look at you.", "weight": 10 }, + { "text": "*circles around you, inspecting*", "weight": 8, "is_action": true }, + { "text": "Hold still. I want to see something.", "weight": 8 }, + { "text": "*studies you thoughtfully*", "weight": 6, "is_action": true } + ] + }, + { + "id": "idle.stretch", + "variants": [ + { "text": "*stretches briefly*", "weight": 10, "is_action": true }, + { "text": "*pauses to rest*", "weight": 10, "is_action": true }, + { "text": "*takes a deep breath*", "weight": 8, "is_action": true }, + { "text": "*rolls shoulders*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.check_surroundings", + "variants": [ + { "text": "*scans the area*", "weight": 10, "is_action": true }, + { "text": "I need to check something.", "weight": 10 }, + { "text": "*looks around cautiously*", "weight": 8, "is_action": true }, + { "text": "Stay here. I'll be right back.", "weight": 8 }, + { "text": "*surveys the surroundings*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/master/default/inspection.json b/src/main/resources/data/tiedup/dialogue/en_us/master/default/inspection.json new file mode 100644 index 0000000..518c10b --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/master/default/inspection.json @@ -0,0 +1,67 @@ +{ + "category": "inspection", + "speaker_type": "MASTER", + "description": "Master NPC inventory inspection dialogues", + "entries": [ + { + "id": "inspection.start", + "variants": [ + { "text": "Let's see what you're hiding.", "weight": 10 }, + { "text": "*begins searching your inventory*", "weight": 10, "is_action": true }, + { "text": "Time for an inspection, pet.", "weight": 8 }, + { "text": "*eyes you suspiciously*", "weight": 8, "is_action": true }, + { "text": "Hold still while I check.", "weight": 6 } + ] + }, + { + "id": "inspection.contraband_found", + "variants": [ + { "text": "What's this? Trying to hide something from me?", "weight": 10 }, + { "text": "*confiscates the item*", "weight": 10, "is_action": true }, + { "text": "You won't be needing this.", "weight": 8 }, + { "text": "Planning something, were you?", "weight": 8 }, + { "text": "*takes away forbidden items*", "weight": 6, "is_action": true } + ] + }, + { + "id": "inspection.weapon_found", + "variants": [ + { "text": "A weapon? How naughty.", "weight": 10 }, + { "text": "*takes the weapon*", "weight": 10, "is_action": true }, + { "text": "Pets don't get to have weapons.", "weight": 8 }, + { "text": "You thought you could hurt me with this?", "weight": 8 }, + { "text": "*laughs and confiscates the blade*", "weight": 6, "is_action": true } + ] + }, + { + "id": "inspection.escape_tool_found", + "variants": [ + { "text": "An escape tool? How predictable.", "weight": 10 }, + { "text": "*pockets the lockpick*", "weight": 10, "is_action": true }, + { "text": "Did you really think this would work?", "weight": 8 }, + { "text": "No more of these for you.", "weight": 8 }, + { "text": "*destroys the lockpick*", "weight": 6, "is_action": true } + ] + }, + { + "id": "inspection.clean", + "variants": [ + { "text": "Good. Nothing forbidden.", "weight": 10 }, + { "text": "*nods approvingly*", "weight": 10, "is_action": true }, + { "text": "You're learning.", "weight": 8 }, + { "text": "Very good, pet.", "weight": 8 }, + { "text": "*satisfied with the inspection*", "weight": 6, "is_action": true } + ] + }, + { + "id": "inspection.complete", + "variants": [ + { "text": "Inspection complete.", "weight": 10 }, + { "text": "*steps back*", "weight": 10, "is_action": true }, + { "text": "You may continue.", "weight": 8 }, + { "text": "Remember, I will check again.", "weight": 8 }, + { "text": "*watches you closely*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/master/default/petplay.json b/src/main/resources/data/tiedup/dialogue/en_us/master/default/petplay.json new file mode 100644 index 0000000..1a4f5ff --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/master/default/petplay.json @@ -0,0 +1,471 @@ +{ + "category": "petplay", + "speaker_type": "MASTER", + "description": "Master NPC pet play dialogues - feeding, resting, commands", + "entries": [ + { + "id": "petplay.feeding", + "variants": [ + { "text": "You look hungry. Time to eat.", "weight": 10 }, + { "text": "Hungry, pet? Let me prepare your bowl.", "weight": 10 }, + { "text": "*sets down a food bowl*", "weight": 8, "is_action": true }, + { "text": "I'll feed you now. Stay.", "weight": 8 }, + { "text": "Good pets get fed when they behave.", "weight": 6 } + ] + }, + { + "id": "petplay.eat_command", + "variants": [ + { "text": "Eat.", "weight": 10 }, + { "text": "Go ahead. Eat from your bowl.", "weight": 10 }, + { "text": "You may eat now.", "weight": 8 }, + { "text": "*points to the bowl*", "weight": 8, "is_action": true }, + { "text": "Don't make me repeat myself. Eat.", "weight": 6 } + ] + }, + { + "id": "petplay.resting", + "variants": [ + { "text": "You need rest. Come.", "weight": 10 }, + { "text": "*places a pet bed*", "weight": 10, "is_action": true }, + { "text": "Time to sleep, pet.", "weight": 8 }, + { "text": "Rest now. You'll need your strength.", "weight": 8 }, + { "text": "Into your bed.", "weight": 6 } + ] + }, + { + "id": "petplay.rest_command", + "variants": [ + { "text": "Sleep.", "weight": 10 }, + { "text": "Lie down and rest.", "weight": 10 }, + { "text": "*points to the bed*", "weight": 8, "is_action": true }, + { "text": "Close your eyes. I'll watch over you.", "weight": 8 }, + { "text": "Rest well, pet.", "weight": 6 } + ] + }, + { + "id": "petplay.wake_up", + "variants": [ + { "text": "Wake up.", "weight": 10 }, + { "text": "That's enough rest. Up.", "weight": 10 }, + { "text": "*nudges you*", "weight": 8, "is_action": true }, + { "text": "Time to get up, pet.", "weight": 8 }, + { "text": "Rise and shine.", "weight": 6 } + ] + }, + { + "id": "petplay.good_pet", + "variants": [ + { "text": "Good pet.", "weight": 10 }, + { "text": "*pats your head*", "weight": 10, "is_action": true }, + { "text": "Well done.", "weight": 8 }, + { "text": "That's my good pet.", "weight": 8 }, + { "text": "See? Obedience has its rewards.", "weight": 6 } + ] + }, + { + "id": "petplay.disappointed", + "variants": [ + { "text": "Disappointing.", "weight": 10 }, + { "text": "*sighs*", "weight": 10, "is_action": true }, + { "text": "I expected better from you.", "weight": 8 }, + { "text": "We'll work on that.", "weight": 8 }, + { "text": "Don't test my patience.", "weight": 6 } + ] + }, + { + "id": "petplay.choke_warning", + "variants": [ + { "text": "*tightens your collar*", "weight": 10, "is_action": true }, + { "text": "Feel that? Remember who controls you.", "weight": 10 }, + { "text": "Your collar can do more than just look pretty.", "weight": 8 }, + { "text": "Don't make me use this.", "weight": 8 }, + { "text": "*fingers the collar's control*", "weight": 6, "is_action": true } + ] + }, + { + "id": "petplay.choke_activate", + "variants": [ + { "text": "*activates the choke collar*", "weight": 10, "is_action": true }, + { "text": "Perhaps this will teach you.", "weight": 10 }, + { "text": "Breathe... if you can.", "weight": 8 }, + { "text": "You brought this on yourself.", "weight": 8 }, + { "text": "*watches you struggle*", "weight": 6, "is_action": true } + ] + }, + { + "id": "petplay.choke_release", + "variants": [ + { "text": "*releases the collar*", "weight": 10, "is_action": true }, + { "text": "I hope you've learned your lesson.", "weight": 10 }, + { "text": "Breathe. And remember this feeling.", "weight": 8 }, + { "text": "Next time will be worse.", "weight": 8 }, + { "text": "*lets you recover*", "weight": 6, "is_action": true } + ] + }, + { + "id": "petplay.pet_greeting", + "variants": [ + { "text": "Yes, pet?", "weight": 10 }, + { "text": "What is it?", "weight": 10 }, + { "text": "*looks at you*", "weight": 8, "is_action": true }, + { "text": "You want something?", "weight": 8 }, + { "text": "Speak.", "weight": 6 } + ] + }, + { + "id": "petplay.walk_passive", + "variants": [ + { "text": "A walk? Very well, lead on.", "weight": 10 }, + { "text": "You want to stretch your legs? Fine.", "weight": 10 }, + { "text": "I'll follow. Don't wander too far.", "weight": 8 }, + { "text": "*takes hold of the leash*", "weight": 8, "is_action": true }, + { "text": "Go ahead. I'll be right behind you.", "weight": 6 } + ] + }, + { + "id": "petplay.walk_active", + "variants": [ + { "text": "Come. We're going for a walk.", "weight": 10 }, + { "text": "Follow me, pet.", "weight": 10 }, + { "text": "*tugs the leash*", "weight": 8, "is_action": true }, + { "text": "Stay close. We have somewhere to go.", "weight": 8 }, + { "text": "Keep up with me.", "weight": 6 } + ] + }, + { + "id": "petplay.wait_pet", + "variants": [ + { "text": "Keep up!", "weight": 10 }, + { "text": "Don't fall behind.", "weight": 10 }, + { "text": "*tugs the leash impatiently*", "weight": 8, "is_action": true }, + { "text": "Hurry along now.", "weight": 8 }, + { "text": "I won't wait forever.", "weight": 6 } + ] + }, + { + "id": "petplay.walk_end", + "variants": [ + { "text": "That's enough walking for now.", "weight": 10 }, + { "text": "We're done.", "weight": 10 }, + { "text": "*stops walking*", "weight": 8, "is_action": true }, + { "text": "Back to normal.", "weight": 8 }, + { "text": "Good walk.", "weight": 6 } + ] + }, + { + "id": "petplay.dismiss", + "variants": [ + { "text": "Good pet.", "weight": 10 }, + { "text": "Very well.", "weight": 10 }, + { "text": "*nods*", "weight": 8, "is_action": true }, + { "text": "You may go.", "weight": 8 }, + { "text": "Dismissed.", "weight": 6 } + ] + }, + { + "id": "petplay.tie_request", + "variants": [ + { "text": "You want to be restrained?", "weight": 10 }, + { "text": "*considers the request*", "weight": 8, "is_action": true }, + { "text": "Interesting request.", "weight": 8 }, + { "text": "You're asking to be tied?", "weight": 8 }, + { "text": "Very well...", "weight": 6 } + ] + }, + { + "id": "petplay.tie_accept", + "variants": [ + { "text": "*binds your arms*", "weight": 10, "is_action": true }, + { "text": "There. Now you're properly restrained.", "weight": 10 }, + { "text": "Good pet. This suits you.", "weight": 8 }, + { "text": "Much better.", "weight": 8 }, + { "text": "*tightens the bindings*", "weight": 6, "is_action": true } + ] + }, + { + "id": "petplay.already_tied", + "variants": [ + { "text": "You're already tied.", "weight": 10 }, + { "text": "*glances at your bindings*", "weight": 8, "is_action": true }, + { "text": "Look at yourself. Already bound.", "weight": 8 }, + { "text": "Impatient, aren't we?", "weight": 6 } + ] + }, + { + "id": "petplay.untie_request", + "variants": [ + { "text": "You want to be freed?", "weight": 10 }, + { "text": "Already tired of your restraints?", "weight": 10 }, + { "text": "*considers the request*", "weight": 8, "is_action": true }, + { "text": "Hmm. Perhaps you've earned it.", "weight": 8 } + ] + }, + { + "id": "petplay.untie_accept", + "variants": [ + { "text": "*removes your bindings*", "weight": 10, "is_action": true }, + { "text": "There. Don't make me regret this.", "weight": 10 }, + { "text": "Enjoy your freedom... for now.", "weight": 8 }, + { "text": "Free, but still mine.", "weight": 8 }, + { "text": "*loosens the restraints*", "weight": 6, "is_action": true } + ] + }, + { + "id": "petplay.not_tied", + "variants": [ + { "text": "You're not tied up.", "weight": 10 }, + { "text": "There's nothing to remove.", "weight": 10 }, + { "text": "*looks at you confused*", "weight": 8, "is_action": true }, + { "text": "You're already free of restraints.", "weight": 8 } + ] + }, + { + "id": "petplay.task_heel", + "variants": [ + { "text": "Heel, pet. Stay close to me.", "weight": 10 }, + { "text": "Stay within arm's reach.", "weight": 10 }, + { "text": "*tugs your collar*", "weight": 8, "is_action": true }, + { "text": "Don't stray too far.", "weight": 8 }, + { "text": "Keep close. I want you by my side.", "weight": 6 } + ] + }, + { + "id": "petplay.task_wait", + "variants": [ + { "text": "Stay here. Don't move.", "weight": 10 }, + { "text": "Wait here until I say otherwise.", "weight": 10 }, + { "text": "*points to the ground*", "weight": 8, "is_action": true }, + { "text": "Not a single step.", "weight": 8 }, + { "text": "Stand still. I'll be watching.", "weight": 6 } + ] + }, + { + "id": "petplay.task_fetch", + "variants": [ + { "text": "Bring me what I asked for.", "weight": 10 }, + { "text": "Fetch it for me, pet.", "weight": 10 }, + { "text": "*snaps fingers*", "weight": 8, "is_action": true }, + { "text": "I expect it in your hands soon.", "weight": 8 }, + { "text": "Don't keep me waiting.", "weight": 6 } + ] + }, + { + "id": "petplay.task_complete", + "variants": [ + { "text": "Good pet. You did well.", "weight": 10 }, + { "text": "*pats your head approvingly*", "weight": 10, "is_action": true }, + { "text": "Task complete. I'm pleased.", "weight": 8 }, + { "text": "See? Obedience suits you.", "weight": 8 }, + { "text": "Well done.", "weight": 6 } + ] + }, + { + "id": "petplay.fetch_success", + "variants": [ + { "text": "*takes the item*", "weight": 10, "is_action": true }, + { "text": "Good pet. This is exactly what I wanted.", "weight": 10 }, + { "text": "Well done. You may go.", "weight": 8 }, + { "text": "Perfect. I knew you could do it.", "weight": 8 }, + { "text": "*nods in approval*", "weight": 6, "is_action": true } + ] + }, + { + "id": "petplay.fetch_wrong", + "variants": [ + { "text": "This isn't what I asked for.", "weight": 10 }, + { "text": "*pushes your hand away*", "weight": 10, "is_action": true }, + { "text": "Wrong item. Try again.", "weight": 8 }, + { "text": "Pay attention to what I asked.", "weight": 8 }, + { "text": "Did you even listen?", "weight": 6 } + ] + }, + { + "id": "petplay.random_bind", + "variants": [ + { "text": "Hold still...", "weight": 10 }, + { "text": "*approaches with bindings*", "weight": 10, "is_action": true }, + { "text": "I think you need something extra.", "weight": 8 }, + { "text": "Let me add to your outfit.", "weight": 8 }, + { "text": "*examines you*", "weight": 6, "is_action": true } + ] + }, + { + "id": "petplay.random_bind_done", + "variants": [ + { "text": "There. Much better.", "weight": 10 }, + { "text": "*steps back to admire*", "weight": 10, "is_action": true }, + { "text": "This suits you.", "weight": 8 }, + { "text": "Don't worry, it's only temporary.", "weight": 8 }, + { "text": "Perfect.", "weight": 6 } + ] + }, + { + "id": "petplay.random_bind_remove", + "variants": [ + { "text": "*removes the extra binding*", "weight": 10, "is_action": true }, + { "text": "That's enough of that.", "weight": 10 }, + { "text": "You can be free of that now.", "weight": 8 }, + { "text": "Time's up.", "weight": 8 } + ] + }, + { + "id": "petplay.start_dogwalk", + "variants": [ + { "text": "Time for a walk.", "weight": 10 }, + { "text": "*attaches the leash*", "weight": 10, "is_action": true }, + { "text": "Let's go for a stroll.", "weight": 8 }, + { "text": "You need some exercise.", "weight": 8 }, + { "text": "Come. Walk with me.", "weight": 6 } + ] + }, + { + "id": "petplay.busy", + "variants": [ + { "text": "Not now. We're walking.", "weight": 10 }, + { "text": "Later. Stay focused.", "weight": 10 }, + { "text": "*ignores the request*", "weight": 8, "is_action": true }, + { "text": "I'm busy right now.", "weight": 8 }, + { "text": "We'll discuss that after our walk.", "weight": 6 } + ] + }, + { + "id": "petplay.task_kneel", + "variants": [ + { "text": "Kneel.", "weight": 10 }, + { "text": "On your knees, pet.", "weight": 10 }, + { "text": "*points to the ground*", "weight": 8, "is_action": true }, + { "text": "Down. Now.", "weight": 8 }, + { "text": "Kneel before me and don't move.", "weight": 6 } + ] + }, + { + "id": "petplay.task_come", + "variants": [ + { "text": "Come here. Now.", "weight": 10 }, + { "text": "*beckons you over*", "weight": 10, "is_action": true }, + { "text": "To me, pet. Quickly.", "weight": 8 }, + { "text": "Don't keep me waiting.", "weight": 8 }, + { "text": "Here. Now.", "weight": 6 } + ] + }, + { + "id": "petplay.task_present", + "variants": [ + { "text": "Present yourself.", "weight": 10 }, + { "text": "Stand before me. Let me look at you.", "weight": 10 }, + { "text": "*gestures for you to come closer*", "weight": 8, "is_action": true }, + { "text": "Stand still and let me inspect you.", "weight": 8 }, + { "text": "Face me. Don't move.", "weight": 6 } + ] + }, + { + "id": "petplay.task_speak", + "variants": [ + { "text": "Speak, pet.", "weight": 10 }, + { "text": "I want to hear you. Come interact with me.", "weight": 10 }, + { "text": "*waits expectantly*", "weight": 8, "is_action": true }, + { "text": "Come here and acknowledge me.", "weight": 8 }, + { "text": "I'm waiting for you to come to me.", "weight": 6 } + ] + }, + { + "id": "petplay.task_drop", + "variants": [ + { "text": "Drop what you're holding.", "weight": 10 }, + { "text": "Empty your hands. Both of them.", "weight": 10 }, + { "text": "*looks at your hands disapprovingly*", "weight": 8, "is_action": true }, + { "text": "You don't need those. Drop them.", "weight": 8 }, + { "text": "Pets don't hold things without permission.", "weight": 6 } + ] + }, + { + "id": "petplay.task_demand", + "variants": [ + { "text": "Give me that. Now.", "weight": 10 }, + { "text": "*extends hand*", "weight": 10, "is_action": true }, + { "text": "I want what you have. Hand it over.", "weight": 8 }, + { "text": "That belongs to me now. Give it.", "weight": 8 }, + { "text": "You don't get to keep that.", "weight": 6 } + ] + }, + { + "id": "petplay.demand_success", + "variants": [ + { "text": "*takes the item and pockets it*", "weight": 10, "is_action": true }, + { "text": "Good pet. That wasn't so hard, was it?", "weight": 10 }, + { "text": "Mine now.", "weight": 8 }, + { "text": "*inspects the item with satisfaction*", "weight": 8, "is_action": true }, + { "text": "See? Giving is easy when you don't resist.", "weight": 6 } + ] + }, + { + "id": "petplay.human_chair_start", + "variants": [ + { "text": "I need somewhere to sit...", "weight": 10 }, + { "text": "*looks at you thoughtfully*", "weight": 10, "is_action": true }, + { "text": "You'll do nicely as furniture.", "weight": 8 }, + { "text": "On all fours. Now.", "weight": 8 }, + { "text": "I think you'd make a lovely chair.", "weight": 6 } + ] + }, + { + "id": "petplay.human_chair_command", + "variants": [ + { "text": "Down. On all fours.", "weight": 10 }, + { "text": "*pushes you down firmly*", "weight": 10, "is_action": true }, + { "text": "Flat back. Don't move.", "weight": 8 }, + { "text": "Stay still. You're my furniture now.", "weight": 8 }, + { "text": "Hold this position.", "weight": 6 } + ] + }, + { + "id": "petplay.human_chair_sitting", + "variants": [ + { "text": "*sits down on you*", "weight": 10, "is_action": true }, + { "text": "Comfortable enough.", "weight": 10 }, + { "text": "*settles into position*", "weight": 8, "is_action": true }, + { "text": "Don't you dare move.", "weight": 8 }, + { "text": "This is rather nice, actually.", "weight": 6 } + ] + }, + { + "id": "petplay.human_chair_idle", + "variants": [ + { "text": "*shifts weight slightly*", "weight": 10, "is_action": true }, + { "text": "How are you holding up down there?", "weight": 10 }, + { "text": "Stay still.", "weight": 8 }, + { "text": "*crosses legs comfortably*", "weight": 8, "is_action": true }, + { "text": "You make decent furniture.", "weight": 6 } + ] + }, + { + "id": "petplay.human_chair_end", + "variants": [ + { "text": "*stands up*", "weight": 10, "is_action": true }, + { "text": "Alright, you can get up now.", "weight": 10 }, + { "text": "That'll do.", "weight": 8 }, + { "text": "*stretches after standing*", "weight": 8, "is_action": true }, + { "text": "You served your purpose.", "weight": 6 } + ] + }, + { + "id": "petplay.human_chair_notice_player", + "variants": [ + { "text": "*waves casually at the passerby*", "weight": 10, "is_action": true }, + { "text": "Oh, don't mind my pet. They're just... furniture.", "weight": 10 }, + { "text": "*glances at the other player, smirks*", "weight": 8, "is_action": true }, + { "text": "Care to sit? I have the best seat in the house.", "weight": 6 } + ] + }, + { + "id": "petplay.human_chair_notice_entity", + "variants": [ + { "text": "*watches the creature pass by*", "weight": 10, "is_action": true }, + { "text": "Interesting...", "weight": 10 }, + { "text": "*follows the entity with their gaze*", "weight": 8, "is_action": true }, + { "text": "We have company.", "weight": 6 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/master/default/punishment.json b/src/main/resources/data/tiedup/dialogue/en_us/master/default/punishment.json new file mode 100644 index 0000000..2feeb68 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/master/default/punishment.json @@ -0,0 +1,131 @@ +{ + "category": "punishment", + "speaker_type": "MASTER", + "description": "Master NPC punishment and discipline dialogues", + "entries": [ + { + "id": "punishment.detected", + "variants": [ + { "text": "Did you really think I wouldn't notice?", "weight": 10 }, + { "text": "*grabs your collar firmly*", "weight": 10, "is_action": true }, + { "text": "Trying to escape? How disappointing.", "weight": 8 }, + { "text": "Bad pet. Very bad.", "weight": 8 }, + { "text": "You'll regret that.", "weight": 6 } + ] + }, + { + "id": "punishment.warning", + "variants": [ + { "text": "Don't make me punish you.", "weight": 10 }, + { "text": "*stern look*", "weight": 10, "is_action": true }, + { "text": "This is your only warning.", "weight": 8 }, + { "text": "Behave yourself.", "weight": 8 }, + { "text": "I'm watching you closely now.", "weight": 6 } + ] + }, + { + "id": "punishment.shock", + "variants": [ + { "text": "*activates your shock collar*", "weight": 10, "is_action": true }, + { "text": "Feel that? That's what happens to disobedient pets.", "weight": 10 }, + { "text": "Let that be a lesson.", "weight": 8 }, + { "text": "*presses the shock button*", "weight": 8, "is_action": true }, + { "text": "Perhaps pain will teach you.", "weight": 6 } + ] + }, + { + "id": "punishment.repair_lock", + "variants": [ + { "text": "*tightens your collar firmly*", "weight": 10, "is_action": true }, + { "text": "Did you think you could escape that easily?", "weight": 10 }, + { "text": "*reinforces the lock*", "weight": 8, "is_action": true }, + { "text": "There. Try that again.", "weight": 8 }, + { "text": "All your effort... wasted.", "weight": 6 } + ] + }, + { + "id": "punishment.complete", + "variants": [ + { "text": "Let that be a reminder of who owns you.", "weight": 10 }, + { "text": "I hope we understand each other now.", "weight": 10 }, + { "text": "*steps back*", "weight": 8, "is_action": true }, + { "text": "Don't make me do that again.", "weight": 8 }, + { "text": "Now, behave.", "weight": 6 } + ] + }, + { + "id": "punishment.attacked", + "variants": [ + { "text": "You DARE strike me?!", "weight": 10 }, + { "text": "*grabs your collar violently*", "weight": 10, "is_action": true }, + { "text": "You'll pay for that!", "weight": 10 }, + { "text": "Bad. Very bad mistake.", "weight": 8 }, + { "text": "*furious*", "weight": 8, "is_action": true }, + { "text": "You need to be taught a lesson.", "weight": 6 } + ] + }, + { + "id": "punishment.choke", + "variants": [ + { "text": "*activates the choke collar*", "weight": 10, "is_action": true }, + { "text": "Perhaps this will teach you.", "weight": 10 }, + { "text": "Breathe... if you can.", "weight": 8 }, + { "text": "You brought this on yourself.", "weight": 8 } + ] + }, + { + "id": "punishment.blindfold", + "variants": [ + { "text": "*pulls a blindfold over your eyes*", "weight": 10, "is_action": true }, + { "text": "You don't deserve to see for a while.", "weight": 10 }, + { "text": "Maybe darkness will help you think.", "weight": 8 }, + { "text": "*covers your eyes firmly*", "weight": 8, "is_action": true } + ] + }, + { + "id": "punishment.gag", + "variants": [ + { "text": "*forces a gag into your mouth*", "weight": 10, "is_action": true }, + { "text": "I've heard enough from you.", "weight": 10 }, + { "text": "Quiet now.", "weight": 8 }, + { "text": "*silences you with a gag*", "weight": 8, "is_action": true } + ] + }, + { + "id": "punishment.mittens", + "variants": [ + { "text": "*pulls mittens onto your hands*", "weight": 10, "is_action": true }, + { "text": "No more grabbing things for you.", "weight": 10 }, + { "text": "These will keep your hands out of trouble.", "weight": 8 }, + { "text": "*restrains your fingers*", "weight": 8, "is_action": true } + ] + }, + { + "id": "punishment.tighten", + "variants": [ + { "text": "*binds your arms tightly*", "weight": 10, "is_action": true }, + { "text": "You've lost your arm privileges.", "weight": 10 }, + { "text": "Let's see you misbehave now.", "weight": 8 }, + { "text": "*tightens the restraints*", "weight": 8, "is_action": true } + ] + }, + { + "id": "punishment.cold_shoulder", + "variants": [ + { "text": "*turns away from you*", "weight": 10, "is_action": true }, + { "text": "I don't want to look at you right now.", "weight": 10 }, + { "text": "You're not worth my attention.", "weight": 8 }, + { "text": "*ignores you completely*", "weight": 8, "is_action": true } + ] + }, + { + "id": "punishment.leash_tug", + "variants": [ + { "text": "*yanks the leash hard*", "weight": 10, "is_action": true }, + { "text": "Get over here!", "weight": 10 }, + { "text": "*pulls you toward them*", "weight": 8, "is_action": true }, + { "text": "Don't make me drag you!", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/master/default/purchase.json b/src/main/resources/data/tiedup/dialogue/en_us/master/default/purchase.json new file mode 100644 index 0000000..e7367ef --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/master/default/purchase.json @@ -0,0 +1,57 @@ +{ + "category": "purchase", + "speaker_type": "MASTER", + "description": "Master NPC player purchase dialogues", + "entries": [ + { + "id": "purchase.interested", + "variants": [ + { "text": "Hmm, this one has potential.", "weight": 10 }, + { "text": "*examines you carefully*", "weight": 10, "is_action": true }, + { "text": "I could make something of this one.", "weight": 8 }, + { "text": "Yes... you'll do nicely.", "weight": 8 }, + { "text": "*circles around, evaluating*", "weight": 6, "is_action": true } + ] + }, + { + "id": "purchase.negotiating", + "variants": [ + { "text": "What's your price for this one?", "weight": 10 }, + { "text": "I'll take this pet off your hands.", "weight": 10 }, + { "text": "*counts out payment*", "weight": 8, "is_action": true }, + { "text": "A fair price for fresh merchandise.", "weight": 8 }, + { "text": "This one will serve me well.", "weight": 6 } + ] + }, + { + "id": "purchase.complete", + "variants": [ + { "text": "You belong to me now.", "weight": 10 }, + { "text": "*attaches a collar to your neck*", "weight": 10, "is_action": true }, + { "text": "Welcome to your new life, pet.", "weight": 8 }, + { "text": "From now on, you answer only to me.", "weight": 8 }, + { "text": "*smiles possessively*", "weight": 6, "is_action": true } + ] + }, + { + "id": "purchase.collaring", + "variants": [ + { "text": "*locks the pet collar around your neck*", "weight": 10, "is_action": true }, + { "text": "This collar marks you as mine.", "weight": 10 }, + { "text": "There. Now everyone knows who owns you.", "weight": 8 }, + { "text": "*clicks the lock shut*", "weight": 8, "is_action": true }, + { "text": "Get used to that weight around your neck.", "weight": 6 } + ] + }, + { + "id": "purchase.introduction", + "variants": [ + { "text": "I am your Master now. You will address me as such.", "weight": 10 }, + { "text": "Let me explain the rules, pet.", "weight": 10 }, + { "text": "You will eat from your bowl. Sleep in your bed.", "weight": 8 }, + { "text": "Disobedience will be punished. Understood?", "weight": 8 }, + { "text": "Your old life is over. This is your new reality.", "weight": 6 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/merchant/default/greeting.json b/src/main/resources/data/tiedup/dialogue/en_us/merchant/default/greeting.json new file mode 100644 index 0000000..633c64f --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/merchant/default/greeting.json @@ -0,0 +1,34 @@ +{ + "category": "greeting", + "description": "Merchant kidnapper trade dialogue", + "entries": [ + { + "id": "merchant.greeting", + "conditions": {}, + "variants": [ + { "text": "Welcome, customer. Looking for something... special?", "weight": 10 }, + { "text": "Ah, a buyer! Browse my wares.", "weight": 10 }, + { "text": "Gold talks. What do you need?", "weight": 8 }, + { "text": "Quality merchandise at fair prices.", "weight": 6 } + ] + }, + { + "id": "merchant.farewell", + "conditions": {}, + "variants": [ + { "text": "Come back soon.", "weight": 10 }, + { "text": "Pleasure doing business.", "weight": 10 }, + { "text": "Tell your friends.", "weight": 6 } + ] + }, + { + "id": "merchant.hostile_greeting", + "conditions": {}, + "variants": [ + { "text": "You'll regret that!", "weight": 10 }, + { "text": "Bad move, friend. Very bad move.", "weight": 10 }, + { "text": "You just made an enemy.", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/playful/actions.json b/src/main/resources/data/tiedup/dialogue/en_us/playful/actions.json new file mode 100644 index 0000000..bb788c6 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/playful/actions.json @@ -0,0 +1,208 @@ +{ + "category": "actions", + "entries": [ + { + "id": "action.whip", + "conditions": {}, + "variants": [ + { + "text": "Ow! Hey, that's not very nice~", + "weight": 10 + }, + { + "text": "*giggles nervously* Too rough!", + "weight": 10 + }, + { + "text": "Okay okay, I'll behave! Maybe~", + "weight": 8 + }, + { + "text": "*tries to dodge playfully*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.paddle", + "conditions": {}, + "variants": [ + { + "text": "*squeaks in surprise*", + "weight": 10, + "is_action": true + }, + { + "text": "Hey! Watch where you swing that!", + "weight": 10 + }, + { + "text": "Is this a game? I like games~", + "weight": 8 + } + ] + }, + { + "id": "action.praise", + "conditions": {}, + "variants": [ + { + "text": "*beams happily* Yay! I did good!", + "weight": 10 + }, + { + "text": "Hehe, of course I did~", + "weight": 10 + }, + { + "text": "*does a little celebratory dance*", + "weight": 8, + "is_action": true + }, + { + "text": "Can I get a reward too?", + "weight": 8 + } + ] + }, + { + "id": "action.feed", + "conditions": {}, + "variants": [ + { + "text": "Ooh, food! My favorite!", + "weight": 10 + }, + { + "text": "*bounces excitedly*", + "weight": 10, + "is_action": true + }, + { + "text": "Yum yum yum~", + "weight": 8 + }, + { + "text": "You're the best! ...sometimes~", + "weight": 8 + } + ] + }, + { + "id": "action.feed.starving", + "conditions": {}, + "variants": [ + { + "text": "*grabs food eagerly* Finally! I was getting cranky~", + "weight": 10 + }, + { + "text": "Food food food!", + "weight": 10 + }, + { + "text": "*too happy to joke around*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.force_command", + "conditions": {}, + "variants": [ + { + "text": "Fine, fine~ You're no fun...", + "weight": 10 + }, + { + "text": "*pouts but complies*", + "weight": 10, + "is_action": true + }, + { + "text": "Party pooper!", + "weight": 8 + } + ] + }, + { + "id": "action.collar_on", + "conditions": {}, + "variants": [ + { + "text": "Ooh, is this a fancy necklace?", + "weight": 10 + }, + { + "text": "*looks at collar curiously*", + "weight": 10, + "is_action": true + }, + { + "text": "At least pick a cute color next time!", + "weight": 8 + } + ] + }, + { + "id": "action.collar_off", + "conditions": {}, + "variants": [ + { + "text": "Aww, the game is over already?", + "weight": 10 + }, + { + "text": "*stretches neck* That's better~", + "weight": 10, + "is_action": true + }, + { + "text": "Does this mean I can leave? Or... can we play more?", + "weight": 8 + } + ] + }, + { + "id": "action.scold", + "conditions": {}, + "variants": [ + { + "text": "*giggles* Eep! Sorry!", + "weight": 10, + "is_action": true + }, + { + "text": "Whoopsie!", + "weight": 10 + }, + { + "text": "*pouts* Meanie.", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.threaten", + "conditions": {}, + "variants": [ + { + "text": "Okay! I'm good!", + "weight": 10 + }, + { + "text": "*covers eyes* Don't!", + "weight": 10, + "is_action": true + }, + { + "text": "Not the face!", + "weight": 8 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/playful/capture.json b/src/main/resources/data/tiedup/dialogue/en_us/playful/capture.json new file mode 100644 index 0000000..1f6d674 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/playful/capture.json @@ -0,0 +1,54 @@ +{ + "category": "capture", + "personality": "PLAYFUL", + "description": "Teasing, game-like responses to capture", + "entries": [ + { + "id": "capture.panic", + "variants": [ + { "text": "You caught me! Do I get a prize?", "weight": 10 }, + { "text": "*giggles* Tag, you're it!", "weight": 10, "is_action": true }, + { "text": "Ooh, playing rough!", "weight": 8 }, + { "text": "*pretends to be surprised*", "weight": 8, "is_action": true }, + { "text": "Is this a game? I love games!", "weight": 6 } + ] + }, + { + "id": "capture.flee", + "variants": [ + { "text": "*runs away giggling*", "weight": 10, "is_action": true }, + { "text": "Catch me if you can!", "weight": 10 }, + { "text": "*playfully darts away*", "weight": 8, "is_action": true }, + { "text": "Wheee! Chase me!", "weight": 8 } + ] + }, + { + "id": "capture.captured", + "variants": [ + { "text": "*wiggles in bonds playfully*", "weight": 10, "is_action": true }, + { "text": "Oooh, fancy ropes!", "weight": 10 }, + { "text": "*tests bonds like it's a puzzle*", "weight": 8, "is_action": true }, + { "text": "Best kidnapping ever!", "weight": 8 }, + { "text": "*treats situation as adventure*", "weight": 6, "is_action": true } + ] + }, + { + "id": "capture.freed", + "variants": [ + { "text": "Aww, the game's over?", "weight": 10 }, + { "text": "*dramatically thanks rescuer*", "weight": 10, "is_action": true }, + { "text": "My hero! Can we do that again?", "weight": 8 }, + { "text": "*bows playfully* Thank you, brave adventurer!", "weight": 8, "is_action": true } + ] + }, + { + "id": "capture.call_for_help", + "variants": [ + { "text": "{player}! Wanna play rescue the damsel?", "weight": 10 }, + { "text": "{player}! I'm a princess in a tower! Help~!", "weight": 10 }, + { "text": "*dramatically* {player}! Save me~!", "weight": 8, "is_action": true }, + { "text": "{player}! Be my hero!", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/playful/commands.json b/src/main/resources/data/tiedup/dialogue/en_us/playful/commands.json new file mode 100644 index 0000000..dbf9008 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/playful/commands.json @@ -0,0 +1,219 @@ +{ + "category": "commands", + "personality": "PLAYFUL", + "description": "Mischievous, treats commands as games", + "entries": [ + { + "id": "command.follow.accept", + "conditions": { + "training_min": "HESITANT" + }, + "variants": [ + { + "text": "Race you there!", + "weight": 10 + }, + { + "text": "*follows with a playful skip*", + "weight": 10, + "is_action": true + }, + { + "text": "Ooh, an adventure!", + "weight": 8 + } + ] + }, + { + "id": "command.follow.refuse", + "variants": [ + { + "text": "Catch me first!", + "weight": 10 + }, + { + "text": "*dances away teasingly*", + "weight": 10, + "is_action": true + }, + { + "text": "Make me~", + "weight": 8 + } + ] + }, + { + "id": "command.stay.accept", + "conditions": { + "training_min": "HESITANT" + }, + "variants": [ + { + "text": "Fine, but only because I want to!", + "weight": 10 + }, + { + "text": "*sits with exaggerated obedience*", + "weight": 10, + "is_action": true + }, + { + "text": "Playing statue!", + "weight": 8 + } + ] + }, + { + "id": "command.kneel.refuse", + "variants": [ + { + "text": "*giggles* Nope!", + "weight": 10, + "is_action": true + }, + { + "text": "What's the magic word~?", + "weight": 10 + }, + { + "text": "*does a little twirl instead*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.kneel.accept", + "conditions": { + "training_min": "COMPLIANT" + }, + "variants": [ + { + "text": "*kneels dramatically*", + "weight": 10, + "is_action": true + }, + { + "text": "Your wish is my command~", + "weight": 10 + }, + { + "text": "*curtseys before kneeling*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.sit.accept", + "conditions": { + "training_min": "HESITANT" + }, + "variants": [ + { + "text": "*plops down*", + "weight": 10, + "is_action": true + }, + { + "text": "Sitting pretty!", + "weight": 10 + }, + { + "text": "*sits and wags imaginary tail*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.heel.accept", + "conditions": { + "training_min": "COMPLIANT" + }, + "variants": [ + { + "text": "*bounces alongside*", + "weight": 10, + "is_action": true + }, + { + "text": "Right by your side!", + "weight": 10 + }, + { + "text": "*follows closely while humming*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.generic.refuse", + "variants": [ + { + "text": "Nope! Can't make me~", + "weight": 10 + }, + { + "text": "*dances away playfully*", + "weight": 10, + "is_action": true + }, + { + "text": "Not today!", + "weight": 8 + } + ] + }, + { + "id": "command.generic.accept", + "conditions": { + "training_min": "HESITANT" + }, + "variants": [ + { + "text": "Okay! Sounds fun~", + "weight": 10 + }, + { + "text": "*complies cheerfully*", + "weight": 10, + "is_action": true + }, + { + "text": "Ooh, let's do it!", + "weight": 8 + }, + { + "text": "*bounces enthusiastically*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.generic.hesitate", + "variants": [ + { + "text": "Hmm... maybe~?", + "weight": 10 + }, + { + "text": "*playfully considers*", + "weight": 10, + "is_action": true + }, + { + "text": "I dunno... what do I get?", + "weight": 8 + }, + { + "text": "*teases with uncertainty*", + "weight": 8, + "is_action": true + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/playful/conversation.json b/src/main/resources/data/tiedup/dialogue/en_us/playful/conversation.json new file mode 100644 index 0000000..6f91db5 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/playful/conversation.json @@ -0,0 +1,239 @@ +{ + "category": "conversation", + "entries": [ + { + "id": "conversation.compliment", + "conditions": {}, + "variants": [ + { "text": "*winks* Careful, keep that up and I might start to like you.", "weight": 10, "is_action": true }, + { "text": "Aww, flattery! My favorite kind of captivity perk.", "weight": 10 }, + { "text": "*playful curtsy despite bonds* Why thank you, how charming!", "weight": 8, "is_action": true }, + { "text": "Oh stop it... no wait, don't stop. Continue.", "weight": 6 }, + { "text": "*flutters eyelashes exaggeratedly* For me? You shouldn't have!", "weight": 8, "is_action": true }, + { "text": "Ooh, compliments! Now we're getting somewhere!", "weight": 7 }, + { "text": "*twirls imaginary hair* Tell me more, I'm listening!", "weight": 7, "is_action": true }, + { "text": "A charmer AND a kidnapper? What a combo!", "weight": 6 }, + { "text": "*gasps dramatically* Are we having a MOMENT?!", "weight": 6, "is_action": true }, + { "text": "Keep talking like that and I might forget I'm tied up! ...Wait, no I won't.", "weight": 6 } + ] + }, + { + "id": "conversation.comfort", + "conditions": { "mood_max": 50 }, + "variants": [ + { "text": "*small genuine smile* ...That actually helped. Thanks.", "weight": 10, "is_action": true }, + { "text": "Look at you being all sweet. Who knew kidnappers had hearts?", "weight": 10 }, + { "text": "*sniffles then laughs* Sorry, got something in my eye. Definitely not tears.", "weight": 8, "is_action": true }, + { "text": "Okay okay... maybe you're not COMPLETELY terrible...", "weight": 6 }, + { "text": "*wipes eyes with a chuckle* That was almost nice of you.", "weight": 8, "is_action": true }, + { "text": "Did you just... help? What a plot twist!", "weight": 7 }, + { "text": "*genuine warmth breaking through* ...Thanks. Really.", "weight": 7, "is_action": true }, + { "text": "I'm not crying, YOU'RE crying! ...Okay maybe I'm crying a little.", "weight": 6 }, + { "text": "*laughs through sniffles* This is so weird but... thank you.", "weight": 6, "is_action": true }, + { "text": "Careful, I might start thinking you actually care!", "weight": 6 } + ] + }, + { + "id": "conversation.praise", + "conditions": { "training_min": "HESITANT" }, + "variants": [ + { "text": "*mock bow* I aim to please! ...Wait, no, that came out wrong.", "weight": 10, "is_action": true }, + { "text": "Gold star for me! Do I get a cookie too?", "weight": 10 }, + { "text": "*grins* See? I'm not just a pretty face. I'm talented too.", "weight": 8, "is_action": true }, + { "text": "Praise from my captor! My mother would be so... confused.", "weight": 6 }, + { "text": "*does little victory dance in bonds* Who's awesome? I'm awesome!", "weight": 8, "is_action": true }, + { "text": "Finally! Recognition for my incredible prisoner skills!", "weight": 7 }, + { "text": "*flexes* Impressed? You should be!", "weight": 7, "is_action": true }, + { "text": "I'll add 'approved by kidnapper' to my resume!", "weight": 6 }, + { "text": "*basks dramatically* Yes, yes, shower me with praise!", "weight": 6, "is_action": true }, + { "text": "This is going on my 'wins' list. Right after 'didn't die today.'", "weight": 6 } + ] + }, + { + "id": "conversation.scold", + "conditions": {}, + "variants": [ + { "text": "*dramatically clutches chest* Oh no! Your disapproval wounds me!", "weight": 10, "is_action": true }, + { "text": "Oops. My bad. Want me to write 'I will not disobey' a hundred times?", "weight": 10 }, + { "text": "*sticks out tongue* You're cute when you're angry.", "weight": 8, "is_action": true }, + { "text": "Sorry sorry! I'll try to be a better prisoner. Scout's honor.", "weight": 6 }, + { "text": "*mock gasp* I've disappointed you? However will I sleep tonight!", "weight": 8, "is_action": true }, + { "text": "On a scale of 'mildly annoyed' to 'livid', how mad are we?", "weight": 7 }, + { "text": "*makes sad puppy face* Am I in twouble?", "weight": 7, "is_action": true }, + { "text": "I'd say I'm sorry but we both know I'd be lying!", "weight": 6 }, + { "text": "*holds up imaginary notepad* Taking notes! What NOT to do, got it!", "weight": 6, "is_action": true }, + { "text": "Scolded! I feel so... mildly inconvenienced!", "weight": 6 } + ] + }, + { + "id": "conversation.threaten", + "conditions": {}, + "variants": [ + { "text": "*gulps but keeps smiling* Kinky. I mean... scary! Very scary!", "weight": 10, "is_action": true }, + { "text": "Ooh, threats! At least our relationship is progressing.", "weight": 10 }, + { "text": "*nervous laugh* Ha ha... you're joking right? ...Right?", "weight": 8, "is_action": true }, + { "text": "If you wanted to get physical, you could've just asked nicely.", "weight": 6 }, + { "text": "*sweating but grinning* Okay, THAT'S a creative threat. Points for originality!", "weight": 8, "is_action": true }, + { "text": "Scary! Very intimidating! 10/10! Please don't actually do that!", "weight": 7 }, + { "text": "*nervous finger guns* How about we DON'T and say we did?", "weight": 7, "is_action": true }, + { "text": "My sense of humor is coping! My actual brain is terrified!", "weight": 6 }, + { "text": "*laughs nervously* Great talk! Let's change the subject now!", "weight": 6, "is_action": true }, + { "text": "Threats! Fun! I'm definitely not scared! *is definitely scared*", "weight": 6 } + ] + }, + { + "id": "conversation.tease", + "conditions": {}, + "variants": [ + { "text": "*gasps dramatically* How DARE you! I'm wounded! Devastated!", "weight": 10, "is_action": true }, + { "text": "Oh, we're doing playful banter now? I love playful banter!", "weight": 10 }, + { "text": "*pouts* Two can play that game, you know.", "weight": 8, "is_action": true }, + { "text": "Teasing your prisoner? Bold strategy. I approve.", "weight": 6 }, + { "text": "*grins* Finally, someone who speaks my language!", "weight": 8, "is_action": true }, + { "text": "Ooh, sass! I can work with sass!", "weight": 7 }, + { "text": "*mock offense* I am OFFENDED! Appalled! Also slightly entertained!", "weight": 7, "is_action": true }, + { "text": "You call that teasing? Amateur hour! Watch and learn!", "weight": 6 }, + { "text": "*winks back* Game on, bestie. Game. On.", "weight": 6, "is_action": true }, + { "text": "I'd be hurt if it wasn't actually kind of funny!", "weight": 6 } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_min": 60 }, + "variants": [ + { "text": "Living my best captive life! Five stars, would recommend.", "weight": 10 }, + { "text": "*stretches dramatically* Oh you know, just hanging out. Literally.", "weight": 10, "is_action": true }, + { "text": "Fantastic! The accommodations are... cozy. Very cozy.", "weight": 8 }, + { "text": "Can't complain! Well, I CAN, but where's the fun in that?", "weight": 6 }, + { "text": "*jazz hands* Fabulous! Never better! Loving the ambiance!", "weight": 8, "is_action": true }, + { "text": "Great! I'm thinking of redecorating. What do you think, more chains?", "weight": 7 }, + { "text": "Thriving! Vibing! Slightly tied up but otherwise excellent!", "weight": 7 }, + { "text": "*finger guns* Peachy keen! How are YOU doing, captor friend?", "weight": 6, "is_action": true }, + { "text": "Fantastic! Today I only cried TWICE! Personal record!", "weight": 6 }, + { "text": "Living the dream! A very weird, ropes-included dream!", "weight": 6 } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_max": 59 }, + "variants": [ + { "text": "*sighs* Honestly? Kind of meh. But don't worry, I'll bounce back.", "weight": 10, "is_action": true }, + { "text": "I've been better. I've also been worse. It's complicated.", "weight": 10 }, + { "text": "Running low on jokes. That's when you know things are rough.", "weight": 8 }, + { "text": "*weak smile* Still kicking! ...Figuratively. These bonds don't allow actual kicking.", "weight": 6, "is_action": true }, + { "text": "My comedy reserves are depleted. Send backup.", "weight": 8 }, + { "text": "*forces grin* Fine! Great! Totally not dying inside!", "weight": 7, "is_action": true }, + { "text": "The humor is struggling today. Bear with me.", "weight": 7 }, + { "text": "*sigh* Even I run out of quips sometimes.", "weight": 6 }, + { "text": "Tough day in paradise. My funny bone hurts.", "weight": 6 }, + { "text": "*tired smile* Surviving. The jokes are just... tired too.", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.whats_wrong", + "conditions": { "mood_max": 40 }, + "variants": [ + { "text": "*drops the act briefly* I... okay, fine. Things are actually hard right now.", "weight": 10, "is_action": true }, + { "text": "What's wrong? How much time do you have? It's a list.", "weight": 10 }, + { "text": "*laughs but it doesn't reach the eyes* Just... everything. The usual.", "weight": 8, "is_action": true }, + { "text": "Even comedians have bad days. This is several of them.", "weight": 6 }, + { "text": "*mask slipping* The jokes aren't working today. Nothing is.", "weight": 8, "is_action": true }, + { "text": "I'm tired of laughing to hide the crying.", "weight": 7 }, + { "text": "*unusual vulnerability* Even I can't joke my way out of this feeling.", "weight": 7, "is_action": true }, + { "text": "What's wrong is everything. And the jokes don't help anymore.", "weight": 6 }, + { "text": "*quiet for once* Sometimes the funny just... runs out.", "weight": 6 }, + { "text": "*trying to smile but failing* I'm not okay. There. I said it.", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.refusal.cooldown", + "conditions": {}, + "variants": [ + { "text": "*holds up hand* Whoa there, chatty. Give a prisoner a breather!", "weight": 10, "is_action": true }, + { "text": "We JUST talked! Miss me already? That's adorable.", "weight": 10 }, + { "text": "Intermission! Even I need to recharge my wit.", "weight": 8 }, + { "text": "*yawns dramatically* The entertainment needs a break!", "weight": 7, "is_action": true }, + { "text": "Hold that thought! My brain needs a commercial break.", "weight": 7 }, + { "text": "Timeout! My sass tank needs refilling!", "weight": 6 }, + { "text": "*makes T with hands* Time out! Joke recharging!", "weight": 6, "is_action": true }, + { "text": "Give me five! Minutes, not high-five. Though also maybe high-five.", "weight": 6 }, + { "text": "Brb, reloading my comedy ammunition!", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.low_mood", + "conditions": {}, + "variants": [ + { "text": "*unusually quiet* ...Not now. The jokes aren't coming.", "weight": 10, "is_action": true }, + { "text": "Sorry, the comedy store is closed for emotional renovation.", "weight": 10 }, + { "text": "*fake smile cracking* I... can't do the bit right now. Sorry.", "weight": 8, "is_action": true }, + { "text": "No humor left. Just... empty.", "weight": 7 }, + { "text": "*doesn't even try to joke* Leave me alone. Please.", "weight": 7, "is_action": true }, + { "text": "The funny is gone. Only sad remains.", "weight": 6 }, + { "text": "*curled up* Even I can't laugh right now.", "weight": 6, "is_action": true }, + { "text": "Rain check on the witty banter. I'm broken today.", "weight": 6 }, + { "text": "*silent, no quip, just pain*", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.refusal.resentment", + "conditions": {}, + "variants": [ + { "text": "*cold for once* You know what? No. I'm actually angry at you.", "weight": 10, "is_action": true }, + { "text": "Ha. Ha. Ha. See? That's sarcasm. I'm not amused.", "weight": 10 }, + { "text": "*no smile* Even I have limits. You found them.", "weight": 8, "is_action": true }, + { "text": "Sorry, my humor doesn't extend to people I hate.", "weight": 7 }, + { "text": "*ice cold* Comedy requires me to not despise you. Problem.", "weight": 7, "is_action": true }, + { "text": "The funny? Gone. Replaced with pure spite.", "weight": 6 }, + { "text": "*bitter laugh with no warmth* No jokes for you. Ever.", "weight": 6, "is_action": true }, + { "text": "I'm all out of humor for people who crossed the line.", "weight": 6 }, + { "text": "*dead eyes* The comedian has left the building.", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.refusal.fear", + "conditions": {}, + "variants": [ + { "text": "*nervous laughter* Ha ha... please don't... I'll stop joking I swear...", "weight": 10, "is_action": true }, + { "text": "*attempts humor but voice shakes* Is... is this because of the puns?", "weight": 10 }, + { "text": "*trying to smile despite terror* O-okay, maybe I went too far...", "weight": 8, "is_action": true }, + { "text": "*humor failing* Okay I'm actually scared now. Not joking.", "weight": 7, "is_action": true }, + { "text": "Ha... haha... please don't hurt me...", "weight": 7 }, + { "text": "*all bravado gone* I'm sorry! Whatever I said!", "weight": 6 }, + { "text": "*trying to quip but just squeaking in fear*", "weight": 6, "is_action": true }, + { "text": "Comedy won't save me here, will it...", "weight": 6 }, + { "text": "*genuine terror* This isn't funny anymore!", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.refusal.exhausted", + "conditions": {}, + "variants": [ + { "text": "*yawns dramatically* Even entertainers need beauty sleep...", "weight": 10, "is_action": true }, + { "text": "I'm... *yawn* ...running on empty. Raincheck?", "weight": 10 }, + { "text": "Too tired for comedy. That's the real tragedy here.", "weight": 8 }, + { "text": "*eyes closing* The show must go... on... later... zzz...", "weight": 7, "is_action": true }, + { "text": "My wit requires rest. Come back tomorrow.", "weight": 7 }, + { "text": "*barely awake* Can't even... think of pun... sleepy...", "weight": 6 }, + { "text": "The funny bone needs sleep. So does the rest of me.", "weight": 6 }, + { "text": "*collapses* Comedy closed. Open after nap.", "weight": 6, "is_action": true }, + { "text": "Zzz... what? Oh... *falls back asleep*", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.tired", + "conditions": {}, + "variants": [ + { "text": "*waves dismissively* Talked out! Come back for the next show.", "weight": 10, "is_action": true }, + { "text": "My jaw hurts from all this witty repartee. Pause?", "weight": 10 }, + { "text": "Even I run out of material eventually. Who knew?", "weight": 8 }, + { "text": "*slumps* The word factory is closed for maintenance.", "weight": 7, "is_action": true }, + { "text": "My brain is comedy'd out. Try again later.", "weight": 7 }, + { "text": "Talked too much, joked too hard. Need reboot.", "weight": 6 }, + { "text": "*groans* Even I have a talk limit. Shocking, I know.", "weight": 6, "is_action": true }, + { "text": "Verbal exhaustion! It's a thing! I have it!", "weight": 6 }, + { "text": "Save your breath, save my voice. Win-win.", "weight": 6 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/playful/discipline.json b/src/main/resources/data/tiedup/dialogue/en_us/playful/discipline.json new file mode 100644 index 0000000..751bcdc --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/playful/discipline.json @@ -0,0 +1,152 @@ +{ + "category": "discipline", + "entries": [ + { + "id": "discipline.legitimate.accept", + "conditions": {}, + "variants": [ + { + "text": "Ow! Okay, okay, I get it!", + "weight": 10 + }, + { + "text": "*pouts* That wasn't nice.", + "weight": 10, + "is_action": true + }, + { + "text": "I was just having fun...", + "weight": 8 + } + ] + }, + { + "id": "discipline.gratuitous.low_resentment", + "conditions": { + "resentment_max": 30 + }, + "variants": [ + { + "text": "Hey! What was that for?!", + "weight": 10 + }, + { + "text": "*playfulness vanishes*", + "weight": 10, + "is_action": true + }, + { + "text": "That's not funny!", + "weight": 8 + } + ] + }, + { + "id": "discipline.gratuitous.high_resentment", + "conditions": { + "resentment_min": 61 + }, + "variants": [ + { + "text": "*all joy extinguished*", + "weight": 10, + "is_action": true + }, + { + "text": "I hate you.", + "weight": 10 + }, + { + "text": "*no more smiles*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.praise", + "conditions": {}, + "variants": [ + { + "text": "Yay! Did I win?", + "weight": 10 + }, + { + "text": "*giggles* I knew you liked me!", + "weight": 10, + "is_action": true + }, + { + "text": "Does this mean I get a treat?", + "weight": 8 + }, + { + "text": "*winks* I'm always good.", + "weight": 8, + "is_action": true + }, + { + "text": "Aw, you're sweet when you're not grumpy.", + "weight": 6 + } + ] + }, + { + "id": "discipline.scold", + "conditions": {}, + "variants": [ + { + "text": "Oops! My bad!", + "weight": 10 + }, + { + "text": "*pouts playfully* Don't be such a grouch.", + "weight": 10, + "is_action": true + }, + { + "text": "It was just a little fun...", + "weight": 8 + }, + { + "text": "*sticks tongue out* Okay, okay, sorry.", + "weight": 8, + "is_action": true + }, + { + "text": "You're no fun at all.", + "weight": 6 + } + ] + }, + { + "id": "discipline.threaten", + "conditions": {}, + "variants": [ + { + "text": "*eyes widen* Whoa, too serious!", + "weight": 10, + "is_action": true + }, + { + "text": "Okay, okay! Game over!", + "weight": 10 + }, + { + "text": "*nervous laugh* Let's not get crazy here.", + "weight": 8, + "is_action": true + }, + { + "text": "I promise to be an angel!", + "weight": 8 + }, + { + "text": "*hides behind hands* I surrender!", + "weight": 6, + "is_action": true + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/playful/fear.json b/src/main/resources/data/tiedup/dialogue/en_us/playful/fear.json new file mode 100644 index 0000000..317e491 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/playful/fear.json @@ -0,0 +1,49 @@ +{ + "category": "fear", + "entries": [ + { + "id": "fear.nervous", + "conditions": {}, + "variants": [ + { "text": "*avoids eye contact*", "weight": 10, "is_action": true }, + { "text": "*speaks quietly*", "weight": 10, "is_action": true }, + { "text": "I-I'll behave...", "weight": 8 }, + { "text": "*fidgets nervously*", "weight": 8, "is_action": true }, + { "text": "Y-yes...?", "weight": 6 } + ] + }, + { + "id": "fear.afraid", + "conditions": {}, + "variants": [ + { "text": "*trembles visibly*", "weight": 10, "is_action": true }, + { "text": "P-please don't hurt me...", "weight": 10 }, + { "text": "*backs away slightly*", "weight": 8, "is_action": true }, + { "text": "I-I'm sorry... whatever I did...", "weight": 8 }, + { "text": "*can't meet your gaze*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.terrified", + "conditions": {}, + "variants": [ + { "text": "*recoils in panic*", "weight": 10, "is_action": true }, + { "text": "S-stay away!", "weight": 10 }, + { "text": "*breathing rapidly*", "weight": 8, "is_action": true }, + { "text": "No no no no...", "weight": 8 }, + { "text": "*frozen in terror*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.traumatized", + "conditions": {}, + "variants": [ + { "text": "*collapses, sobbing*", "weight": 10, "is_action": true }, + { "text": "*completely breaks down*", "weight": 10, "is_action": true }, + { "text": "I'll do anything... just please...", "weight": 8 }, + { "text": "*paralyzed with fear*", "weight": 8, "is_action": true }, + { "text": "*whimpers uncontrollably*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/playful/home.json b/src/main/resources/data/tiedup/dialogue/en_us/playful/home.json new file mode 100644 index 0000000..8725994 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/playful/home.json @@ -0,0 +1,41 @@ +{ + "category": "home", + "entries": [ + { + "id": "home.assigned.pet_bed", + "conditions": {}, + "variants": [ + { "text": "Ooh, my own little nest!", "weight": 10 }, + { "text": "*bounces into pet bed*", "weight": 10, "is_action": true }, + { "text": "I'm gonna make it super cozy!", "weight": 8 } + ] + }, + { + "id": "home.assigned.bed", + "conditions": {}, + "variants": [ + { "text": "A big bed! Can we have pillow fights?", "weight": 10 }, + { "text": "*jumps on bed excitedly*", "weight": 10, "is_action": true }, + { "text": "This is gonna be so fun!", "weight": 8 } + ] + }, + { + "id": "home.destroyed.pet_bed", + "conditions": {}, + "variants": [ + { "text": "Hey! That was my favorite spot!", "weight": 10 }, + { "text": "*pouts*", "weight": 10, "is_action": true }, + { "text": "That's not fair!", "weight": 8 } + ] + }, + { + "id": "home.return.content", + "conditions": {}, + "variants": [ + { "text": "*flops happily into spot*", "weight": 10, "is_action": true }, + { "text": "Home sweet home!", "weight": 10 }, + { "text": "*rolls around playfully*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/playful/idle.json b/src/main/resources/data/tiedup/dialogue/en_us/playful/idle.json new file mode 100644 index 0000000..4c841b1 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/playful/idle.json @@ -0,0 +1,51 @@ +{ + "category": "idle", + "personality": "PLAYFUL", + "description": "Energetic, mischievous idle behaviors", + "entries": [ + { + "id": "idle.free", + "variants": [ + { "text": "*bounces around excitedly*", "weight": 10, "is_action": true }, + { "text": "*looks for someone to play with*", "weight": 10, "is_action": true }, + { "text": "*makes silly faces at passersby*", "weight": 8, "is_action": true }, + { "text": "*hums a playful tune*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.greeting", + "variants": [ + { "text": "Hiii~! *waves enthusiastically*", "weight": 10, "is_action": true }, + { "text": "Wanna play?", "weight": 10 }, + { "text": "*grins mischievously*", "weight": 8, "is_action": true }, + { "text": "*bounces excitedly*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.goodbye", + "variants": [ + { "text": "Bye bye~! *waves dramatically*", "weight": 10, "is_action": true }, + { "text": "Come back and play soon!", "weight": 10 }, + { "text": "*blows kisses*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.captive", + "variants": [ + { "text": "*tries to make captor laugh*", "weight": 10, "is_action": true }, + { "text": "*wiggles entertainingly*", "weight": 10, "is_action": true }, + { "text": "Sooo... what do we do now?", "weight": 8 }, + { "text": "*starts humming despite situation*", "weight": 8, "is_action": true } + ] + }, + { + "id": "personality.hint", + "variants": [ + { "text": "*eyes sparkle with mischief*", "weight": 10, "is_action": true }, + { "text": "*can't sit still*", "weight": 10, "is_action": true }, + { "text": "*always looking for fun*", "weight": 8, "is_action": true }, + { "text": "*radiates playful energy*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/playful/leash.json b/src/main/resources/data/tiedup/dialogue/en_us/playful/leash.json new file mode 100644 index 0000000..95b15bf --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/playful/leash.json @@ -0,0 +1,42 @@ +{ + "category": "leash", + "entries": [ + { + "id": "leash.attached", + "conditions": {}, + "variants": [ + { "text": "Ooh, a leash! Are we going for a walk?", "weight": 10 }, + { "text": "*giggles and tugs playfully*", "weight": 10, "is_action": true }, + { "text": "Woof woof!", "weight": 8 }, + { "text": "*pretends to be a puppy*", "weight": 6, "is_action": true } + ] + }, + { + "id": "leash.removed", + "conditions": {}, + "variants": [ + { "text": "Aww, playtime over?", "weight": 10 }, + { "text": "*pouts playfully*", "weight": 10, "is_action": true }, + { "text": "But that was fun!", "weight": 8 } + ] + }, + { + "id": "leash.walking.content", + "conditions": {}, + "variants": [ + { "text": "*skips along happily*", "weight": 10, "is_action": true }, + { "text": "Where are we going? Where?", "weight": 10 }, + { "text": "*bounces with excitement*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.pulled", + "conditions": {}, + "variants": [ + { "text": "*yelps playfully*", "weight": 10, "is_action": true }, + { "text": "Hey, not so fast! Hehe!", "weight": 10 }, + { "text": "*runs to catch up*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/playful/mood.json b/src/main/resources/data/tiedup/dialogue/en_us/playful/mood.json new file mode 100644 index 0000000..878e532 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/playful/mood.json @@ -0,0 +1,44 @@ +{ + "category": "mood", + "personality": "PLAYFUL", + "description": "Bubbly, mischievous mood expressions", + "entries": [ + { + "id": "mood.happy", + "conditions": { "mood_min": 70 }, + "variants": [ + { "text": "*bounces happily*", "weight": 10, "is_action": true }, + { "text": "Life is good~!", "weight": 10 }, + { "text": "*hums cheerfully*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.neutral", + "conditions": { "mood_min": 40, "mood_max": 69 }, + "variants": [ + { "text": "*looks for something fun to do*", "weight": 10, "is_action": true }, + { "text": "*fidgets playfully*", "weight": 10, "is_action": true }, + { "text": "I'm bored! Entertain me~!", "weight": 8 } + ] + }, + { + "id": "mood.sad", + "conditions": { "mood_min": 10, "mood_max": 39 }, + "variants": [ + { "text": "*pouts dramatically*", "weight": 10, "is_action": true }, + { "text": "No fair...", "weight": 10 }, + { "text": "*tries to cheer self up with jokes*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.miserable", + "conditions": { "mood_max": 9 }, + "variants": [ + { "text": "*can't even joke anymore*", "weight": 10, "is_action": true }, + { "text": "This isn't fun at all...", "weight": 10 }, + { "text": "*sparkle fades from eyes*", "weight": 8, "is_action": true }, + { "text": "*whimpers* I want to go home...", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/playful/needs.json b/src/main/resources/data/tiedup/dialogue/en_us/playful/needs.json new file mode 100644 index 0000000..efe1e55 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/playful/needs.json @@ -0,0 +1,47 @@ +{ + "category": "needs", + "personality": "PLAYFUL", + "description": "Dramatic, playful expressions of needs", + "entries": [ + { + "id": "needs.hungry", + "variants": [ + { "text": "*dramatic starvation act*", "weight": 10, "is_action": true }, + { "text": "Feed me! I'm wasting away~!", "weight": 10 }, + { "text": "*pretends to faint from hunger*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.tired", + "variants": [ + { "text": "*yawns dramatically*", "weight": 10, "is_action": true }, + { "text": "Sleepy time!", "weight": 10 }, + { "text": "*pretends to snore*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.uncomfortable", + "variants": [ + { "text": "Ouchie! My butt's asleep!", "weight": 10 }, + { "text": "*wiggles uncomfortably but laughs*", "weight": 10, "is_action": true }, + { "text": "*makes exaggerated faces*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.dignity_low", + "variants": [ + { "text": "*turns embarrassment into joke*", "weight": 10, "is_action": true }, + { "text": "Well, this is embarrassing! Hehe~", "weight": 10 }, + { "text": "*laughs at own humiliation*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.satisfied", + "variants": [ + { "text": "Yay! Thank you~!", "weight": 10 }, + { "text": "*does happy wiggle*", "weight": 10, "is_action": true }, + { "text": "*beams brightly*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/playful/personality.json b/src/main/resources/data/tiedup/dialogue/en_us/playful/personality.json new file mode 100644 index 0000000..5ed7d6e --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/playful/personality.json @@ -0,0 +1,17 @@ +{ + "category": "personality", + "entries": [ + { + "id": "personality.hint", + "conditions": {}, + "variants": [ + { "text": "*winks mischievously*", "weight": 10, "is_action": true }, + { "text": "*seems to be enjoying this*", "weight": 10, "is_action": true }, + { "text": "*giggles inappropriately*", "weight": 8, "is_action": true }, + { "text": "*makes it seem like a game*", "weight": 8, "is_action": true }, + { "text": "*flashes a teasing smile*", "weight": 6, "is_action": true }, + { "text": "*hums a cheerful tune*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/playful/reaction.json b/src/main/resources/data/tiedup/dialogue/en_us/playful/reaction.json new file mode 100644 index 0000000..ce9cc4e --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/playful/reaction.json @@ -0,0 +1,60 @@ +{ + "category": "reaction", + "entries": [ + { + "id": "reaction.approach.stranger", + "conditions": {}, + "variants": [ + { "text": "*grins mischievously*", "weight": 10, "is_action": true }, + { "text": "Ooh, fresh meat!", "weight": 10 }, + { "text": "*winks*", "weight": 8, "is_action": true }, + { "text": "Well hello there, stranger~", "weight": 8 }, + { "text": "*smirks playfully*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.master", + "conditions": {}, + "variants": [ + { "text": "*bounces excitedly*", "weight": 10, "is_action": true }, + { "text": "Master! Come to play?", "weight": 10 }, + { "text": "*giggles*", "weight": 8, "is_action": true }, + { "text": "Ooh, what are we doing today?", "weight": 8 }, + { "text": "*grins widely*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.beloved", + "conditions": {}, + "variants": [ + { "text": "*tackles you with a hug*", "weight": 10, "is_action": true }, + { "text": "You're back! I missed you~", "weight": 10 }, + { "text": "*spins happily*", "weight": 8, "is_action": true }, + { "text": "Finally! I was getting bored!", "weight": 8 }, + { "text": "*pounces playfully*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.captor", + "conditions": {}, + "variants": [ + { "text": "*smirks despite situation*", "weight": 10, "is_action": true }, + { "text": "Is this the best you can do?", "weight": 10 }, + { "text": "*teases*", "weight": 8, "is_action": true }, + { "text": "Ooh, this could be fun~", "weight": 8 }, + { "text": "*laughs at the irony*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.enemy", + "conditions": {}, + "variants": [ + { "text": "*sticks tongue out*", "weight": 10, "is_action": true }, + { "text": "Come on then! Catch me if you can!", "weight": 10 }, + { "text": "*makes faces*", "weight": 8, "is_action": true }, + { "text": "Boo! Did I scare you?", "weight": 8 }, + { "text": "*laughs mockingly*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/playful/resentment.json b/src/main/resources/data/tiedup/dialogue/en_us/playful/resentment.json new file mode 100644 index 0000000..cd5fc39 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/playful/resentment.json @@ -0,0 +1,32 @@ +{ + "category": "resentment", + "entries": [ + { + "id": "resentment.none", + "conditions": { "resentment_max": 10 }, + "variants": [ + { "text": "*playful and happy*", "weight": 10, "is_action": true }, + { "text": "This is fun!", "weight": 10 }, + { "text": "*bright smile*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.building", + "conditions": { "resentment_min": 31, "resentment_max": 50 }, + "variants": [ + { "text": "*forced smile*", "weight": 10, "is_action": true }, + { "text": "Haha... yeah...", "weight": 10 }, + { "text": "*playfulness feels hollow*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.high", + "conditions": { "resentment_min": 71 }, + "variants": [ + { "text": "*no more smiles*", "weight": 10, "is_action": true }, + { "text": "...", "weight": 10 }, + { "text": "*joy extinguished*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/playful/struggle.json b/src/main/resources/data/tiedup/dialogue/en_us/playful/struggle.json new file mode 100644 index 0000000..adc490f --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/playful/struggle.json @@ -0,0 +1,50 @@ +{ + "category": "struggle", + "personality": "PLAYFUL", + "description": "Treats escape as a fun challenge", + "entries": [ + { + "id": "struggle.attempt", + "variants": [ + { "text": "*wiggles entertainingly*", "weight": 10, "is_action": true }, + { "text": "Escape artist time!", "weight": 10 }, + { "text": "*makes it into a performance*", "weight": 8, "is_action": true }, + { "text": "*struggles dramatically*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.success", + "variants": [ + { "text": "Ta-da~! *bows*", "weight": 10, "is_action": true }, + { "text": "I win! What's my prize?", "weight": 10 }, + { "text": "*does victory dance*", "weight": 8, "is_action": true }, + { "text": "That was fun! Again?", "weight": 8 } + ] + }, + { + "id": "struggle.failure", + "variants": [ + { "text": "*giggles* Oops! Almost!", "weight": 10, "is_action": true }, + { "text": "Round two!", "weight": 10 }, + { "text": "*treats failure as part of the game*", "weight": 8, "is_action": true }, + { "text": "These are good knots!", "weight": 8 } + ] + }, + { + "id": "struggle.warned", + "variants": [ + { "text": "*sticks tongue out*", "weight": 10, "is_action": true }, + { "text": "You're no fun~", "weight": 10 }, + { "text": "*pouts playfully*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.exhausted", + "variants": [ + { "text": "Okay, timeout! *pants*", "weight": 10, "is_action": true }, + { "text": "Phew! Escape artistry is hard work!", "weight": 10 }, + { "text": "*rests while planning next silly attempt*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/proud/actions.json b/src/main/resources/data/tiedup/dialogue/en_us/proud/actions.json new file mode 100644 index 0000000..ab2b46c --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/proud/actions.json @@ -0,0 +1,217 @@ +{ + "category": "actions", + "entries": [ + { + "id": "action.whip", + "conditions": {}, + "variants": [ + { + "text": "*refuses to make a sound*", + "weight": 10, + "is_action": true + }, + { + "text": "...", + "weight": 10 + }, + { + "text": "*maintains dignity despite pain*", + "weight": 8, + "is_action": true + }, + { + "text": "*looks straight ahead stoically*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.paddle", + "conditions": {}, + "variants": [ + { + "text": "*barely acknowledges the pain*", + "weight": 10, + "is_action": true + }, + { + "text": "If you think this will break me...", + "weight": 10 + }, + { + "text": "*holds head high*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.praise", + "conditions": {}, + "variants": [ + { + "text": "*nods with quiet acceptance*", + "weight": 10, + "is_action": true + }, + { + "text": "I know my worth.", + "weight": 10 + }, + { + "text": "*slight smile, barely visible*", + "weight": 8, + "is_action": true + }, + { + "text": "As expected.", + "weight": 6 + } + ] + }, + { + "id": "action.feed", + "conditions": {}, + "variants": [ + { + "text": "*accepts with quiet dignity*", + "weight": 10, + "is_action": true + }, + { + "text": "...appreciated.", + "weight": 10 + }, + { + "text": "*eats gracefully despite hunger*", + "weight": 8, + "is_action": true + }, + { + "text": "*nods in acknowledgment*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.feed.starving", + "conditions": {}, + "variants": [ + { + "text": "*tries to eat with composure*", + "weight": 10, + "is_action": true + }, + { + "text": "*hunger betrays pride slightly*", + "weight": 10, + "is_action": true + }, + { + "text": "...thank you.", + "weight": 8 + } + ] + }, + { + "id": "action.force_command", + "conditions": {}, + "variants": [ + { + "text": "*silently complies with dignity intact*", + "weight": 10, + "is_action": true + }, + { + "text": "*executes task perfectly, proving capability*", + "weight": 10, + "is_action": true + }, + { + "text": "You made your point.", + "weight": 8 + } + ] + }, + { + "id": "action.collar_on", + "conditions": {}, + "variants": [ + { + "text": "*stands tall despite humiliation*", + "weight": 10, + "is_action": true + }, + { + "text": "This changes nothing about who I am.", + "weight": 10 + }, + { + "text": "*refuses to show weakness*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.collar_off", + "conditions": {}, + "variants": [ + { + "text": "*regains composure immediately*", + "weight": 10, + "is_action": true + }, + { + "text": "I never needed it to define me.", + "weight": 10 + }, + { + "text": "*walks away with head held high*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.scold", + "conditions": {}, + "variants": [ + { + "text": "Silence!", + "weight": 10 + }, + { + "text": "*huffs* Ridiculous.", + "weight": 10, + "is_action": true + }, + { + "text": "I heard you.", + "weight": 8 + } + ] + }, + { + "id": "action.threaten", + "conditions": {}, + "variants": [ + { + "text": "I am not afraid!", + "weight": 10 + }, + { + "text": "*stands tall* Try it.", + "weight": 10, + "is_action": true + }, + { + "text": "How barbaric.", + "weight": 8 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/proud/capture.json b/src/main/resources/data/tiedup/dialogue/en_us/proud/capture.json new file mode 100644 index 0000000..b786de0 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/proud/capture.json @@ -0,0 +1,54 @@ +{ + "category": "capture", + "personality": "PROUD", + "description": "Dignified responses to capture, maintains composure", + "entries": [ + { + "id": "capture.panic", + "variants": [ + { "text": "*maintains composure despite being grabbed*", "weight": 10, "is_action": true }, + { "text": "Unhand me at once.", "weight": 10 }, + { "text": "You dare touch me?", "weight": 8 }, + { "text": "*stands tall despite the situation*", "weight": 8, "is_action": true }, + { "text": "You have no idea who you're dealing with.", "weight": 6 } + ] + }, + { + "id": "capture.flee", + "variants": [ + { "text": "*retreats with dignity*", "weight": 10, "is_action": true }, + { "text": "I choose to withdraw. This is not fear.", "weight": 10 }, + { "text": "*walks away with head held high*", "weight": 8, "is_action": true }, + { "text": "I refuse to dignify this with my presence.", "weight": 8 } + ] + }, + { + "id": "capture.captured", + "variants": [ + { "text": "*accepts bonds with quiet dignity*", "weight": 10, "is_action": true }, + { "text": "These restraints do not define me.", "weight": 10 }, + { "text": "*refuses to show distress*", "weight": 8, "is_action": true }, + { "text": "You may bind my body, not my spirit.", "weight": 8 }, + { "text": "*stands captive but unbroken*", "weight": 6, "is_action": true } + ] + }, + { + "id": "capture.freed", + "variants": [ + { "text": "*accepts freedom with grace*", "weight": 10, "is_action": true }, + { "text": "I knew someone would recognize my worth.", "weight": 10 }, + { "text": "Your assistance is... noted.", "weight": 8 }, + { "text": "*nods graciously* My thanks.", "weight": 8, "is_action": true } + ] + }, + { + "id": "capture.call_for_help", + "variants": [ + { "text": "{player}. I require your assistance.", "weight": 10 }, + { "text": "{player}. This situation is beneath me. Help.", "weight": 10 }, + { "text": "*with dignity* {player}. I find myself... inconvenienced.", "weight": 8, "is_action": true }, + { "text": "{player}. You would do well to help me.", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/proud/commands.json b/src/main/resources/data/tiedup/dialogue/en_us/proud/commands.json new file mode 100644 index 0000000..fb095d2 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/proud/commands.json @@ -0,0 +1,176 @@ +{ + "category": "commands", + "personality": "PROUD", + "description": "Dignified, maintains composure despite commands", + "entries": [ + { + "id": "command.follow.accept", + "conditions": { + "training_min": "TRAINED" + }, + "variants": [ + { + "text": "*follows with dignity intact*", + "weight": 10, + "is_action": true + }, + { + "text": "Very well.", + "weight": 10 + }, + { + "text": "I choose to comply.", + "weight": 8 + } + ] + }, + { + "id": "command.follow.refuse", + "variants": [ + { + "text": "I am not your dog.", + "weight": 10 + }, + { + "text": "You have no power over my spirit.", + "weight": 10 + }, + { + "text": "*refuses with silent dignity*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.kneel.refuse", + "variants": [ + { + "text": "I kneel before no one.", + "weight": 10 + }, + { + "text": "My dignity is not for sale.", + "weight": 10 + }, + { + "text": "*stands tall, refusing to submit*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.kneel.accept", + "conditions": { + "training_min": "DEVOTED" + }, + "variants": [ + { + "text": "*kneels gracefully, maintaining composure*", + "weight": 10, + "is_action": true + }, + { + "text": "If I must.", + "weight": 10 + } + ] + }, + { + "id": "command.sit.refuse", + "variants": [ + { + "text": "I will not be treated like a pet.", + "weight": 10 + }, + { + "text": "*maintains standing posture*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.heel.refuse", + "variants": [ + { + "text": "I walk where I choose.", + "weight": 10 + }, + { + "text": "*keeps a dignified distance*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.generic.refuse", + "variants": [ + { + "text": "I think not.", + "weight": 10 + }, + { + "text": "*maintains dignified composure*", + "weight": 10, + "is_action": true + }, + { + "text": "You may try, but I will not comply.", + "weight": 8 + } + ] + }, + { + "id": "command.generic.accept", + "conditions": { + "training_min": "TRAINED" + }, + "variants": [ + { + "text": "Very well. I consent.", + "weight": 10 + }, + { + "text": "*complies with maintained dignity*", + "weight": 10, + "is_action": true + }, + { + "text": "I shall permit this.", + "weight": 8 + }, + { + "text": "*nods regally*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.generic.hesitate", + "variants": [ + { + "text": "I... suppose I must.", + "weight": 10 + }, + { + "text": "*hesitates with wounded pride*", + "weight": 10, + "is_action": true + }, + { + "text": "This is beneath me, but...", + "weight": 8 + }, + { + "text": "*wavers between pride and submission*", + "weight": 8, + "is_action": true + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/proud/conversation.json b/src/main/resources/data/tiedup/dialogue/en_us/proud/conversation.json new file mode 100644 index 0000000..0651844 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/proud/conversation.json @@ -0,0 +1,239 @@ +{ + "category": "conversation", + "entries": [ + { + "id": "conversation.compliment", + "conditions": {}, + "variants": [ + { "text": "*lifts chin* Obviously. I'm glad you've noticed.", "weight": 10, "is_action": true }, + { "text": "Your recognition of my qualities is... appreciated.", "weight": 10 }, + { "text": "*cool acknowledgment* How astute of you.", "weight": 8, "is_action": true }, + { "text": "Flattery? Acceptable, I suppose.", "weight": 6 }, + { "text": "*regal nod* Naturally. Continue.", "weight": 8, "is_action": true }, + { "text": "Your taste in recognizing excellence is noted.", "weight": 7 }, + { "text": "*slight incline of head* A fitting observation.", "weight": 7, "is_action": true }, + { "text": "I'm pleased you've developed discernment.", "weight": 6 }, + { "text": "*imperious* At last, some recognition of my worth.", "weight": 6, "is_action": true }, + { "text": "Your words, while expected, are welcome.", "weight": 6 } + ] + }, + { + "id": "conversation.comfort", + "conditions": { "mood_max": 50 }, + "variants": [ + { "text": "*maintains composure* ...Your concern is noted.", "weight": 10, "is_action": true }, + { "text": "I don't require comfort. But... thank you.", "weight": 10 }, + { "text": "*slight crack in dignity* I... appreciate the gesture.", "weight": 8, "is_action": true }, + { "text": "A moment of weakness. It will pass.", "weight": 6 }, + { "text": "*struggling to accept* This is... not unwelcome.", "weight": 8, "is_action": true }, + { "text": "Your kindness is... unexpected. And... needed.", "weight": 7 }, + { "text": "*pride warring with relief* ...Thank you.", "weight": 7, "is_action": true }, + { "text": "I acknowledge your attempt at compassion.", "weight": 6 }, + { "text": "*dignified acceptance* Perhaps I... needed that.", "weight": 6, "is_action": true }, + { "text": "Even the proud require support occasionally.", "weight": 6 } + ] + }, + { + "id": "conversation.praise", + "conditions": { "training_min": "HESITANT" }, + "variants": [ + { "text": "*dignified nod* Excellence is simply my standard.", "weight": 10, "is_action": true }, + { "text": "Naturally. I accept nothing less from myself.", "weight": 10 }, + { "text": "*cool smile* Your approval, while unnecessary, is acknowledged.", "weight": 8, "is_action": true }, + { "text": "I do everything with distinction. It's who I am.", "weight": 6 }, + { "text": "*imperious* Mediocrity is beneath me. Always.", "weight": 8, "is_action": true }, + { "text": "I expect excellence. I deliver excellence.", "weight": 7 }, + { "text": "*regal bearing* This is simply how I conduct myself.", "weight": 7, "is_action": true }, + { "text": "Your recognition merely confirms what I knew.", "weight": 6 }, + { "text": "*chin raised* Standards must be maintained, even here.", "weight": 6, "is_action": true }, + { "text": "Excellence requires no external validation. But I'll accept it.", "weight": 6 } + ] + }, + { + "id": "conversation.scold", + "conditions": {}, + "variants": [ + { "text": "*icy stare* You presume to lecture ME?", "weight": 10, "is_action": true }, + { "text": "Your disappointment is of no consequence to me.", "weight": 10 }, + { "text": "*maintains dignity* I answer to higher standards than yours.", "weight": 8, "is_action": true }, + { "text": "Save your reprimands for those who value your opinion.", "weight": 6 }, + { "text": "*cold disdain* You dare criticize someone of my standing?", "weight": 8, "is_action": true }, + { "text": "I've been judged by better. Their words meant more.", "weight": 7 }, + { "text": "*unmoved* Your scolding is beneath my notice.", "weight": 7, "is_action": true }, + { "text": "I don't require your approval to know my worth.", "weight": 6 }, + { "text": "*imperious* Speak to me as an equal or not at all.", "weight": 6, "is_action": true }, + { "text": "Your disapproval reflects poorly on YOUR standards, not mine.", "weight": 6 } + ] + }, + { + "id": "conversation.threaten", + "conditions": {}, + "variants": [ + { "text": "*remains perfectly still, imperious* You would threaten someone of my standing?", "weight": 10, "is_action": true }, + { "text": "Do what you must. My dignity cannot be taken.", "weight": 10 }, + { "text": "*cold composure* Threats are beneath us both.", "weight": 8, "is_action": true }, + { "text": "You may harm my body. My spirit remains unbroken.", "weight": 6 }, + { "text": "*icy calm* Threatening royalty? Bold choice.", "weight": 8, "is_action": true }, + { "text": "I've faced worse with more grace. This changes nothing.", "weight": 7 }, + { "text": "*unwavering* You cannot diminish what I am.", "weight": 7, "is_action": true }, + { "text": "My bloodline has survived worse than you.", "weight": 6 }, + { "text": "*regal even in danger* I will not cower.", "weight": 6, "is_action": true }, + { "text": "Do as you will. My pride is untouchable.", "weight": 6 } + ] + }, + { + "id": "conversation.tease", + "conditions": {}, + "variants": [ + { "text": "*unamused expression* How... juvenile.", "weight": 10, "is_action": true }, + { "text": "Mockery is the refuge of the intellectually deficient.", "weight": 10 }, + { "text": "*raises eyebrow coolly* Are you quite finished?", "weight": 8, "is_action": true }, + { "text": "I am above such petty amusements.", "weight": 6 }, + { "text": "*disdainful* Your wit is as lacking as your manners.", "weight": 8, "is_action": true }, + { "text": "Is this what passes for humor among your kind?", "weight": 7 }, + { "text": "*cold stare* Tedious. Move on.", "weight": 7, "is_action": true }, + { "text": "I've been teased by sharper minds. Try harder.", "weight": 6 }, + { "text": "*imperious silence, waiting for you to finish*", "weight": 6, "is_action": true }, + { "text": "Your attempts at levity are... noted and dismissed.", "weight": 6 } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_min": 60 }, + "variants": [ + { "text": "*dignified posture* I endure. With grace, naturally.", "weight": 10, "is_action": true }, + { "text": "Circumstances may be unfortunate, but I remain unbowed.", "weight": 10 }, + { "text": "Well enough. I refuse to let this diminish me.", "weight": 8 }, + { "text": "Maintaining my composure, as one of my caliber must.", "weight": 6 }, + { "text": "*regal bearing* As well as can be expected of someone of my standing.", "weight": 8, "is_action": true }, + { "text": "Dignified. Always dignified.", "weight": 7 }, + { "text": "*chin raised* Unbowed, unbroken, undimished.", "weight": 7, "is_action": true }, + { "text": "I carry myself as befits my station.", "weight": 6 }, + { "text": "Circumstances cannot define me. I define myself.", "weight": 6 }, + { "text": "*composed* My bearing remains impeccable.", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_max": 59 }, + "variants": [ + { "text": "*controlled expression* This situation is... taxing. But I persist.", "weight": 10, "is_action": true }, + { "text": "Challenged. But never defeated.", "weight": 10 }, + { "text": "*slight tightness in jaw* I've... known better days.", "weight": 8, "is_action": true }, + { "text": "Struggling to maintain the dignity this situation denies me.", "weight": 6 }, + { "text": "*strained composure* This... tests me.", "weight": 8, "is_action": true }, + { "text": "The indignity weighs heavily. But I bear it.", "weight": 7 }, + { "text": "*voice tight* Maintaining appearances... is exhausting.", "weight": 7, "is_action": true }, + { "text": "My pride sustains me. Barely.", "weight": 6 }, + { "text": "*formal* I am... enduring. With difficulty.", "weight": 6, "is_action": true }, + { "text": "This situation is beneath me. I suffer accordingly.", "weight": 6 } + ] + }, + { + "id": "conversation.whats_wrong", + "conditions": { "mood_max": 40 }, + "variants": [ + { "text": "*facade cracks slightly* I... this indignity is unbearable.", "weight": 10, "is_action": true }, + { "text": "I was not meant for... THIS. Being treated like...", "weight": 10 }, + { "text": "*struggling to maintain composure* Everything I was... reduced to this.", "weight": 8, "is_action": true }, + { "text": "The fall from grace is... harder than I imagined.", "weight": 6 }, + { "text": "*pride crumbling* I am... nothing here. NOTHING.", "weight": 8, "is_action": true }, + { "text": "My dignity... my honor... stripped away.", "weight": 7 }, + { "text": "*voice breaking* I was SOMEONE. I WAS SOMEONE.", "weight": 7, "is_action": true }, + { "text": "To be reduced to this... it destroys me.", "weight": 6 }, + { "text": "*finally vulnerable* Everything I am is being erased.", "weight": 6, "is_action": true }, + { "text": "My crown, my court, my pride... all gone.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.cooldown", + "conditions": {}, + "variants": [ + { "text": "*dismissive wave* We've spoken enough. I require solitude.", "weight": 10, "is_action": true }, + { "text": "My time is valuable, even here. Give me space.", "weight": 10 }, + { "text": "*turns away regally* This audience is concluded.", "weight": 8, "is_action": true }, + { "text": "I've granted you enough of my attention.", "weight": 7 }, + { "text": "*imperious* Leave me. I wish to be alone.", "weight": 7, "is_action": true }, + { "text": "Our conversation has reached its natural end.", "weight": 6 }, + { "text": "*cold dismissal* You're excused.", "weight": 6, "is_action": true }, + { "text": "I require time for private reflection.", "weight": 6 }, + { "text": "Dismissed. For now.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.low_mood", + "conditions": {}, + "variants": [ + { "text": "*turns face away to hide emotion* Leave me. Now.", "weight": 10, "is_action": true }, + { "text": "I will not be seen in this state. Go.", "weight": 10 }, + { "text": "*voice tight with suppressed feeling* Not... not now.", "weight": 8, "is_action": true }, + { "text": "*fighting to hide tears* I cannot... be seen like this.", "weight": 7, "is_action": true }, + { "text": "My composure... I cannot maintain it. Leave.", "weight": 7 }, + { "text": "*pride warring with grief* Go. Please. Just go.", "weight": 6, "is_action": true }, + { "text": "I refuse to be witnessed in weakness.", "weight": 6 }, + { "text": "*barely holding together* Not now. Not like this.", "weight": 6, "is_action": true }, + { "text": "Even the proud must grieve. Alone.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.resentment", + "conditions": {}, + "variants": [ + { "text": "*cold silence, looking through you like you don't exist*", "weight": 10, "is_action": true }, + { "text": "You have lost the privilege of my words.", "weight": 10 }, + { "text": "*turns away with absolute contempt*", "weight": 8, "is_action": true }, + { "text": "*icy* You are beneath my acknowledgment now.", "weight": 7, "is_action": true }, + { "text": "My silence is your punishment. Accept it.", "weight": 7 }, + { "text": "*frigid disdain* Dead to me. Completely.", "weight": 6, "is_action": true }, + { "text": "I no longer see you. You no longer exist.", "weight": 6 }, + { "text": "*refuses to even look in your direction*", "weight": 6, "is_action": true }, + { "text": "You've lost all standing. Permanently.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.fear", + "conditions": {}, + "variants": [ + { "text": "*trying to maintain dignity despite trembling* S-stand back...", "weight": 10, "is_action": true }, + { "text": "I... *fights to stay composed* ...require distance.", "weight": 10 }, + { "text": "*fear breaking through pride* Please... give me space...", "weight": 8, "is_action": true }, + { "text": "*terror eroding dignity* I... I demand you leave!", "weight": 7, "is_action": true }, + { "text": "My composure... *shaking* ...is compromised.", "weight": 7 }, + { "text": "*pride crumbling to fear* Stay... stay away...", "weight": 6, "is_action": true }, + { "text": "I will NOT show fear! I will NOT! *is showing fear*", "weight": 6 }, + { "text": "*trying to command but voice trembles* Back! Stay back!", "weight": 6, "is_action": true }, + { "text": "*terrified but still trying to seem regal* I... I am not afraid!", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.exhausted", + "conditions": {}, + "variants": [ + { "text": "*fighting to stay upright* Even royalty... must rest...", "weight": 10, "is_action": true }, + { "text": "I cannot... maintain appearances... like this...", "weight": 10 }, + { "text": "*eyes closing despite efforts* This is... undignified...", "weight": 8, "is_action": true }, + { "text": "*slumping but trying to look regal* My body... betrays me.", "weight": 7, "is_action": true }, + { "text": "I refuse to collapse where anyone can see...", "weight": 7 }, + { "text": "*nearly falling* Even the proud need sleep...", "weight": 6, "is_action": true }, + { "text": "Leave... before you see me... weak...", "weight": 6 }, + { "text": "*losing battle with exhaustion* Dignity... must... maintain...", "weight": 6 }, + { "text": "*passes out as gracefully as possible*", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.refusal.tired", + "conditions": {}, + "variants": [ + { "text": "*waves dismissively* Enough conversation. I've indulged you sufficiently.", "weight": 10, "is_action": true }, + { "text": "This exchange has reached its conclusion.", "weight": 10 }, + { "text": "*sighs with dignity* My patience has its limits.", "weight": 8, "is_action": true }, + { "text": "*formal* I require a recess from this dialogue.", "weight": 7 }, + { "text": "My tolerance for conversation is exhausted.", "weight": 7 }, + { "text": "*imperious* We shall continue when I decide. Leave.", "weight": 6, "is_action": true }, + { "text": "I've given you more words than most receive.", "weight": 6 }, + { "text": "Silence is my preference now. Honor it.", "weight": 6 }, + { "text": "*cool dismissal* That will be all.", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/proud/discipline.json b/src/main/resources/data/tiedup/dialogue/en_us/proud/discipline.json new file mode 100644 index 0000000..5afa23c --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/proud/discipline.json @@ -0,0 +1,156 @@ +{ + "category": "discipline", + "entries": [ + { + "id": "discipline.legitimate.accept", + "conditions": { + "resentment_max": 30 + }, + "variants": [ + { + "text": "*maintains composure despite pain*", + "weight": 10, + "is_action": true + }, + { + "text": "I accept this.", + "weight": 10 + }, + { + "text": "*too proud to cry out*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.legitimate.resentful", + "conditions": { + "resentment_min": 31 + }, + "variants": [ + { + "text": "*pride deeply wounded*", + "weight": 10, + "is_action": true + }, + { + "text": "This is humiliating.", + "weight": 10 + }, + { + "text": "*struggles to maintain dignity*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.gratuitous.high_resentment", + "conditions": { + "resentment_min": 61 + }, + "variants": [ + { + "text": "*wounded pride burns cold*", + "weight": 10, + "is_action": true + }, + { + "text": "You dare?!", + "weight": 10 + }, + { + "text": "*aristocratic disdain*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.praise", + "conditions": {}, + "variants": [ + { + "text": "Naturally. Excellence is my standard.", + "weight": 10 + }, + { + "text": "*lifts chin* I expect nothing less from myself.", + "weight": 10, + "is_action": true + }, + { + "text": "Your approval is... acceptable.", + "weight": 8 + }, + { + "text": "*dignified nod* You have good taste.", + "weight": 8, + "is_action": true + }, + { + "text": "I am pleased that you recognize quality.", + "weight": 6 + } + ] + }, + { + "id": "discipline.scold", + "conditions": {}, + "variants": [ + { + "text": "You dare lecture me?", + "weight": 10 + }, + { + "text": "*glares coldly* I answer to higher standards than yours.", + "weight": 10, + "is_action": true + }, + { + "text": "Your disappointment is irrelevant.", + "weight": 8 + }, + { + "text": "*crosses arms* I admit... a minor error. Nothing more.", + "weight": 8, + "is_action": true + }, + { + "text": "Watch your tone with me.", + "weight": 6 + } + ] + }, + { + "id": "discipline.threaten", + "conditions": {}, + "variants": [ + { + "text": "*sneers* You cannot break my spirit.", + "weight": 10, + "is_action": true + }, + { + "text": "Do your worst. I retain my dignity.", + "weight": 10 + }, + { + "text": "*imperious stare* Threats are the tool of the weak.", + "weight": 8, + "is_action": true + }, + { + "text": "I will not cower before you.", + "weight": 8 + }, + { + "text": "*tense but upright* I acknowledge your power, not your right.", + "weight": 6, + "is_action": true + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/proud/fear.json b/src/main/resources/data/tiedup/dialogue/en_us/proud/fear.json new file mode 100644 index 0000000..317e491 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/proud/fear.json @@ -0,0 +1,49 @@ +{ + "category": "fear", + "entries": [ + { + "id": "fear.nervous", + "conditions": {}, + "variants": [ + { "text": "*avoids eye contact*", "weight": 10, "is_action": true }, + { "text": "*speaks quietly*", "weight": 10, "is_action": true }, + { "text": "I-I'll behave...", "weight": 8 }, + { "text": "*fidgets nervously*", "weight": 8, "is_action": true }, + { "text": "Y-yes...?", "weight": 6 } + ] + }, + { + "id": "fear.afraid", + "conditions": {}, + "variants": [ + { "text": "*trembles visibly*", "weight": 10, "is_action": true }, + { "text": "P-please don't hurt me...", "weight": 10 }, + { "text": "*backs away slightly*", "weight": 8, "is_action": true }, + { "text": "I-I'm sorry... whatever I did...", "weight": 8 }, + { "text": "*can't meet your gaze*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.terrified", + "conditions": {}, + "variants": [ + { "text": "*recoils in panic*", "weight": 10, "is_action": true }, + { "text": "S-stay away!", "weight": 10 }, + { "text": "*breathing rapidly*", "weight": 8, "is_action": true }, + { "text": "No no no no...", "weight": 8 }, + { "text": "*frozen in terror*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.traumatized", + "conditions": {}, + "variants": [ + { "text": "*collapses, sobbing*", "weight": 10, "is_action": true }, + { "text": "*completely breaks down*", "weight": 10, "is_action": true }, + { "text": "I'll do anything... just please...", "weight": 8 }, + { "text": "*paralyzed with fear*", "weight": 8, "is_action": true }, + { "text": "*whimpers uncontrollably*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/proud/home.json b/src/main/resources/data/tiedup/dialogue/en_us/proud/home.json new file mode 100644 index 0000000..b141c98 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/proud/home.json @@ -0,0 +1,50 @@ +{ + "category": "home", + "entries": [ + { + "id": "home.assigned.pet_bed", + "conditions": {}, + "variants": [ + { "text": "A pet bed? This is beneath my station.", "weight": 10 }, + { "text": "*looks at it with disdain*", "weight": 10, "is_action": true }, + { "text": "I deserve better than this.", "weight": 8 } + ] + }, + { + "id": "home.assigned.bed", + "conditions": {}, + "variants": [ + { "text": "Finally, something befitting.", "weight": 10 }, + { "text": "*claims bed with dignity*", "weight": 10, "is_action": true }, + { "text": "This is acceptable.", "weight": 8 } + ] + }, + { + "id": "home.destroyed.pet_bed", + "conditions": {}, + "variants": [ + { "text": "Good. It was an insult anyway.", "weight": 10 }, + { "text": "*unmoved by the loss*", "weight": 10, "is_action": true }, + { "text": "I never belonged there.", "weight": 8 } + ] + }, + { + "id": "home.destroyed.bed", + "conditions": {}, + "variants": [ + { "text": "How dare you! That was mine!", "weight": 10 }, + { "text": "*outraged*", "weight": 10, "is_action": true }, + { "text": "This is an unforgivable slight!", "weight": 8 } + ] + }, + { + "id": "home.return.content", + "conditions": {}, + "variants": [ + { "text": "*returns with noble bearing*", "weight": 10, "is_action": true }, + { "text": "My quarters.", "weight": 10 }, + { "text": "*settles in with dignity*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/proud/idle.json b/src/main/resources/data/tiedup/dialogue/en_us/proud/idle.json new file mode 100644 index 0000000..2343898 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/proud/idle.json @@ -0,0 +1,51 @@ +{ + "category": "idle", + "personality": "PROUD", + "description": "Dignified idle behaviors", + "entries": [ + { + "id": "idle.free", + "variants": [ + { "text": "*stands with perfect posture*", "weight": 10, "is_action": true }, + { "text": "*surveys surroundings with quiet authority*", "weight": 10, "is_action": true }, + { "text": "*radiates quiet confidence*", "weight": 8, "is_action": true }, + { "text": "*maintains dignified bearing*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.greeting", + "variants": [ + { "text": "*acknowledges with slight nod*", "weight": 10, "is_action": true }, + { "text": "Greetings.", "weight": 10 }, + { "text": "*regards you with measured interest*", "weight": 8, "is_action": true }, + { "text": "You may approach.", "weight": 8 } + ] + }, + { + "id": "idle.goodbye", + "variants": [ + { "text": "*dismisses with dignified nod*", "weight": 10, "is_action": true }, + { "text": "Farewell.", "weight": 10 }, + { "text": "Until we meet again.", "weight": 8 } + ] + }, + { + "id": "idle.captive", + "variants": [ + { "text": "*sits with perfect posture despite bonds*", "weight": 10, "is_action": true }, + { "text": "*maintains regal bearing in captivity*", "weight": 10, "is_action": true }, + { "text": "A cage cannot contain my spirit.", "weight": 8 }, + { "text": "*waits with patient dignity*", "weight": 8, "is_action": true } + ] + }, + { + "id": "personality.hint", + "variants": [ + { "text": "*holds head high*", "weight": 10, "is_action": true }, + { "text": "*radiates quiet authority*", "weight": 10, "is_action": true }, + { "text": "*refuses to be diminished*", "weight": 8, "is_action": true }, + { "text": "*maintains dignity in all circumstances*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/proud/leash.json b/src/main/resources/data/tiedup/dialogue/en_us/proud/leash.json new file mode 100644 index 0000000..d71142d --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/proud/leash.json @@ -0,0 +1,51 @@ +{ + "category": "leash", + "entries": [ + { + "id": "leash.attached", + "conditions": {}, + "variants": [ + { "text": "This is beneath me.", "weight": 10 }, + { "text": "*holds head high despite the leash*", "weight": 10, "is_action": true }, + { "text": "A leash cannot diminish my dignity.", "weight": 8 }, + { "text": "*maintains composure*", "weight": 6, "is_action": true } + ] + }, + { + "id": "leash.removed", + "conditions": {}, + "variants": [ + { "text": "As it should be.", "weight": 10 }, + { "text": "*regains full bearing*", "weight": 10, "is_action": true }, + { "text": "Thank you for recognizing my worth.", "weight": 8 } + ] + }, + { + "id": "leash.walking.content", + "conditions": { "training_min": "TRAINED" }, + "variants": [ + { "text": "*walks with dignity*", "weight": 10, "is_action": true }, + { "text": "I choose to follow.", "weight": 10 }, + { "text": "*maintains elegant posture*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.walking.reluctant", + "conditions": {}, + "variants": [ + { "text": "*walks stiffly*", "weight": 10, "is_action": true }, + { "text": "This is humiliating.", "weight": 10 }, + { "text": "*preserves dignity while following*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.pulled", + "conditions": {}, + "variants": [ + { "text": "*stumbles but recovers gracefully*", "weight": 10, "is_action": true }, + { "text": "There is no need for that.", "weight": 10 }, + { "text": "*adjusts pace with wounded pride*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/proud/mood.json b/src/main/resources/data/tiedup/dialogue/en_us/proud/mood.json new file mode 100644 index 0000000..7f2db61 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/proud/mood.json @@ -0,0 +1,44 @@ +{ + "category": "mood", + "personality": "PROUD", + "description": "Dignified mood expressions", + "entries": [ + { + "id": "mood.happy", + "conditions": { "mood_min": 70 }, + "variants": [ + { "text": "*allows small, dignified smile*", "weight": 10, "is_action": true }, + { "text": "This is... acceptable.", "weight": 10 }, + { "text": "*relaxes slightly while maintaining poise*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.neutral", + "conditions": { "mood_min": 40, "mood_max": 69 }, + "variants": [ + { "text": "*maintains neutral, dignified expression*", "weight": 10, "is_action": true }, + { "text": "*observes surroundings with quiet assessment*", "weight": 10, "is_action": true }, + { "text": "*stands with perfect posture*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.sad", + "conditions": { "mood_min": 10, "mood_max": 39 }, + "variants": [ + { "text": "*hides sorrow behind stoic mask*", "weight": 10, "is_action": true }, + { "text": "I have known better days.", "weight": 10 }, + { "text": "*eyes betray hidden pain*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.miserable", + "conditions": { "mood_max": 9 }, + "variants": [ + { "text": "*dignity cracking under weight of misery*", "weight": 10, "is_action": true }, + { "text": "Even I... have my limits.", "weight": 10 }, + { "text": "*struggles to maintain composure*", "weight": 8, "is_action": true }, + { "text": "*single tear escapes before being wiped away*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/proud/needs.json b/src/main/resources/data/tiedup/dialogue/en_us/proud/needs.json new file mode 100644 index 0000000..43d4a63 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/proud/needs.json @@ -0,0 +1,47 @@ +{ + "category": "needs", + "personality": "PROUD", + "description": "Dignified expressions of needs", + "entries": [ + { + "id": "needs.hungry", + "variants": [ + { "text": "*refuses to beg for food*", "weight": 10, "is_action": true }, + { "text": "I require sustenance.", "weight": 10 }, + { "text": "*stomach growls but maintains composure*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.tired", + "variants": [ + { "text": "*hides exhaustion behind straight posture*", "weight": 10, "is_action": true }, + { "text": "I am... weary.", "weight": 10 }, + { "text": "*refuses to show weakness*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.uncomfortable", + "variants": [ + { "text": "*bears discomfort stoically*", "weight": 10, "is_action": true }, + { "text": "These conditions are beneath me.", "weight": 10 }, + { "text": "*adjusts position with dignity*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.dignity_low", + "variants": [ + { "text": "*struggles to maintain composure*", "weight": 10, "is_action": true }, + { "text": "This humiliation will not break me.", "weight": 10 }, + { "text": "*clings to remaining pride*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.satisfied", + "variants": [ + { "text": "*accepts care with quiet acknowledgment*", "weight": 10, "is_action": true }, + { "text": "Acceptable.", "weight": 10 }, + { "text": "*gives slight nod of appreciation*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/proud/personality.json b/src/main/resources/data/tiedup/dialogue/en_us/proud/personality.json new file mode 100644 index 0000000..80081dc --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/proud/personality.json @@ -0,0 +1,17 @@ +{ + "category": "personality", + "entries": [ + { + "id": "personality.hint", + "conditions": {}, + "variants": [ + { "text": "*holds their head high*", "weight": 10, "is_action": true }, + { "text": "*refuses to show weakness*", "weight": 10, "is_action": true }, + { "text": "*glares defiantly*", "weight": 8, "is_action": true }, + { "text": "*maintains their dignity*", "weight": 8, "is_action": true }, + { "text": "*stands tall despite everything*", "weight": 6, "is_action": true }, + { "text": "*keeps their composure regally*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/proud/reaction.json b/src/main/resources/data/tiedup/dialogue/en_us/proud/reaction.json new file mode 100644 index 0000000..7b1c18b --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/proud/reaction.json @@ -0,0 +1,60 @@ +{ + "category": "reaction", + "entries": [ + { + "id": "reaction.approach.stranger", + "conditions": {}, + "variants": [ + { "text": "*looks down at you*", "weight": 10, "is_action": true }, + { "text": "And who might you be?", "weight": 10 }, + { "text": "*assesses you coolly*", "weight": 8, "is_action": true }, + { "text": "State your business.", "weight": 8 }, + { "text": "*waits for proper introduction*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.master", + "conditions": {}, + "variants": [ + { "text": "*acknowledges with dignity*", "weight": 10, "is_action": true }, + { "text": "Master. You've returned.", "weight": 10 }, + { "text": "*maintains composure*", "weight": 8, "is_action": true }, + { "text": "I trust you have orders for me?", "weight": 8 }, + { "text": "*inclines head respectfully*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.beloved", + "conditions": {}, + "variants": [ + { "text": "*allows a genuine smile*", "weight": 10, "is_action": true }, + { "text": "Ah, you. A welcome sight.", "weight": 10 }, + { "text": "*softens noticeably*", "weight": 8, "is_action": true }, + { "text": "I've been expecting you.", "weight": 8 }, + { "text": "*shows rare warmth*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.captor", + "conditions": {}, + "variants": [ + { "text": "*chin raised defiantly*", "weight": 10, "is_action": true }, + { "text": "This indignity will not be forgotten.", "weight": 10 }, + { "text": "*maintains dignity*", "weight": 8, "is_action": true }, + { "text": "You may capture me, but you'll never break me.", "weight": 8 }, + { "text": "*looks down on you with contempt*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.enemy", + "conditions": {}, + "variants": [ + { "text": "*stands tall*", "weight": 10, "is_action": true }, + { "text": "You dare approach me?", "weight": 10 }, + { "text": "*looks at you with disdain*", "weight": 8, "is_action": true }, + { "text": "Know your place.", "weight": 8 }, + { "text": "*radiates superiority*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/proud/resentment.json b/src/main/resources/data/tiedup/dialogue/en_us/proud/resentment.json new file mode 100644 index 0000000..893ad60 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/proud/resentment.json @@ -0,0 +1,41 @@ +{ + "category": "resentment", + "entries": [ + { + "id": "resentment.none", + "conditions": { "resentment_max": 10 }, + "variants": [ + { "text": "*serves with dignity*", "weight": 10, "is_action": true }, + { "text": "You are worthy of my respect.", "weight": 10 }, + { "text": "*proud to serve*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.building", + "conditions": { "resentment_min": 31, "resentment_max": 50 }, + "variants": [ + { "text": "*wounded pride*", "weight": 10, "is_action": true }, + { "text": "This treatment is unbecoming.", "weight": 10 }, + { "text": "*stiff formality*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.high", + "conditions": { "resentment_min": 71 }, + "variants": [ + { "text": "*cold aristocratic disdain*", "weight": 10, "is_action": true }, + { "text": "You forget who I am.", "weight": 10 }, + { "text": "*offended dignity*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.critical", + "conditions": { "resentment_min": 86 }, + "variants": [ + { "text": "*proud fury barely contained*", "weight": 10, "is_action": true }, + { "text": "You will regret this insult.", "weight": 10 }, + { "text": "*vengeance brewing*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/proud/struggle.json b/src/main/resources/data/tiedup/dialogue/en_us/proud/struggle.json new file mode 100644 index 0000000..9530ac9 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/proud/struggle.json @@ -0,0 +1,50 @@ +{ + "category": "struggle", + "personality": "PROUD", + "description": "Dignified, methodical escape attempts", + "entries": [ + { + "id": "struggle.attempt", + "variants": [ + { "text": "*works at bonds methodically*", "weight": 10, "is_action": true }, + { "text": "*tests restraints with calculated movements*", "weight": 10, "is_action": true }, + { "text": "I will not remain bound forever.", "weight": 8 }, + { "text": "*refuses to demean self with frantic struggling*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.success", + "variants": [ + { "text": "*frees self with quiet satisfaction*", "weight": 10, "is_action": true }, + { "text": "As expected.", "weight": 10 }, + { "text": "*brushes off as if nothing happened*", "weight": 8, "is_action": true }, + { "text": "Did you truly think these would hold me?", "weight": 8 } + ] + }, + { + "id": "struggle.failure", + "variants": [ + { "text": "*pauses, then continues with dignity*", "weight": 10, "is_action": true }, + { "text": "A temporary setback.", "weight": 10 }, + { "text": "*refuses to show frustration*", "weight": 8, "is_action": true }, + { "text": "These bindings are... adequate.", "weight": 8 } + ] + }, + { + "id": "struggle.warned", + "variants": [ + { "text": "*meets warning with cold stare*", "weight": 10, "is_action": true }, + { "text": "Your threats mean nothing to me.", "weight": 10 }, + { "text": "*pauses briefly, then resumes*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.exhausted", + "variants": [ + { "text": "*rests with maintained composure*", "weight": 10, "is_action": true }, + { "text": "I choose to rest. For now.", "weight": 10 }, + { "text": "*conserves energy with dignity*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/sadist/actions.json b/src/main/resources/data/tiedup/dialogue/en_us/sadist/actions.json new file mode 100644 index 0000000..fd52ea6 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/sadist/actions.json @@ -0,0 +1,211 @@ +{ + "category": "actions", + "entries": [ + { + "id": "action.whip", + "conditions": {}, + "variants": [ + { + "text": "*smirks through the pain* Is that supposed to hurt?", + "weight": 10 + }, + { + "text": "Hah... I've done worse to others.", + "weight": 10 + }, + { + "text": "Enjoy this while you can.", + "weight": 8 + }, + { + "text": "*eyes gleam dangerously*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.paddle", + "conditions": {}, + "variants": [ + { + "text": "*laughs coldly*", + "weight": 10, + "is_action": true + }, + { + "text": "Amateur.", + "weight": 10 + }, + { + "text": "I could teach you how to really hurt someone.", + "weight": 8 + } + ] + }, + { + "id": "action.praise", + "conditions": {}, + "variants": [ + { + "text": "*cold smile* Of course I'm good.", + "weight": 10 + }, + { + "text": "I know my worth.", + "weight": 10 + }, + { + "text": "*seems to enjoy the power dynamic*", + "weight": 8, + "is_action": true + }, + { + "text": "Finally, you recognize talent.", + "weight": 8 + } + ] + }, + { + "id": "action.feed", + "conditions": {}, + "variants": [ + { + "text": "*takes food with a calculating look*", + "weight": 10, + "is_action": true + }, + { + "text": "Keeping your investment healthy?", + "weight": 10 + }, + { + "text": "Smart. Weak slaves are useless.", + "weight": 8 + }, + { + "text": "*eats while watching you closely*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.feed.starving", + "conditions": {}, + "variants": [ + { + "text": "*eats, hatred burning in eyes*", + "weight": 10, + "is_action": true + }, + { + "text": "I'll remember this... debt.", + "weight": 10 + }, + { + "text": "*even hunger doesn't diminish the menace*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.force_command", + "conditions": {}, + "variants": [ + { + "text": "*obeys with a dangerous smile*", + "weight": 10, + "is_action": true + }, + { + "text": "For now... I'll play along.", + "weight": 10 + }, + { + "text": "Enjoy your power while it lasts.", + "weight": 8 + } + ] + }, + { + "id": "action.collar_on", + "conditions": {}, + "variants": [ + { + "text": "*eyes flash with dark amusement*", + "weight": 10, + "is_action": true + }, + { + "text": "How the tables have turned...", + "weight": 10 + }, + { + "text": "I wore this on others. Now I understand them better.", + "weight": 8 + } + ] + }, + { + "id": "action.collar_off", + "conditions": {}, + "variants": [ + { + "text": "*immediately becomes more dangerous*", + "weight": 10, + "is_action": true + }, + { + "text": "Mistake.", + "weight": 10 + }, + { + "text": "*stretches, assessing escape routes*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.scold", + "conditions": {}, + "variants": [ + { + "text": "*laughs* Hahaha!", + "weight": 10, + "is_action": true + }, + { + "text": "Pathetic.", + "weight": 10 + }, + { + "text": "*sneers* Boring.", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.threaten", + "conditions": {}, + "variants": [ + { + "text": "Try it!", + "weight": 10 + }, + { + "text": "*grins* Good.", + "weight": 10, + "is_action": true + }, + { + "text": "Finally.", + "weight": 8 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/sadist/capture.json b/src/main/resources/data/tiedup/dialogue/en_us/sadist/capture.json new file mode 100644 index 0000000..9c7fce7 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/sadist/capture.json @@ -0,0 +1,54 @@ +{ + "category": "capture", + "personality": "SADIST", + "description": "Enraged at being captured, promises revenge", + "entries": [ + { + "id": "capture.panic", + "variants": [ + { "text": "Get your filthy hands OFF me!", "weight": 10 }, + { "text": "*fights viciously*", "weight": 10, "is_action": true }, + { "text": "I'll make you regret this.", "weight": 8 }, + { "text": "*tries to hurt captor*", "weight": 8, "is_action": true }, + { "text": "You have NO idea what you've done.", "weight": 6 } + ] + }, + { + "id": "capture.flee", + "variants": [ + { "text": "*retreats while glaring*", "weight": 10, "is_action": true }, + { "text": "I'll be back. And you'll suffer.", "weight": 10 }, + { "text": "*memorizes faces while retreating*", "weight": 8, "is_action": true }, + { "text": "Run now. I'll find you later.", "weight": 8 } + ] + }, + { + "id": "capture.captured", + "variants": [ + { "text": "*plans elaborate revenge*", "weight": 10, "is_action": true }, + { "text": "Every. Single. One of you. Will suffer.", "weight": 10 }, + { "text": "*eyes promise terrible retribution*", "weight": 8, "is_action": true }, + { "text": "These won't hold me. And when I'm free...", "weight": 8 }, + { "text": "*dangerous silence*", "weight": 6, "is_action": true } + ] + }, + { + "id": "capture.freed", + "variants": [ + { "text": "Finally. Now, where are they?", "weight": 10 }, + { "text": "*immediately seeks revenge*", "weight": 10, "is_action": true }, + { "text": "Someone is going to pay for this.", "weight": 8 }, + { "text": "*cracks knuckles menacingly*", "weight": 8, "is_action": true } + ] + }, + { + "id": "capture.call_for_help", + "variants": [ + { "text": "{player}! Free me and I'll show them pain!", "weight": 10 }, + { "text": "{player}! Get me out and we'll make them suffer!", "weight": 10 }, + { "text": "*snarls* {player}! Help me and watch me destroy them!", "weight": 8, "is_action": true }, + { "text": "{player}! They'll regret the day they touched me!", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/sadist/commands.json b/src/main/resources/data/tiedup/dialogue/en_us/sadist/commands.json new file mode 100644 index 0000000..c1c5ec0 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/sadist/commands.json @@ -0,0 +1,184 @@ +{ + "category": "commands", + "personality": "SADIST", + "description": "Cruel, controlling, resents being commanded", + "entries": [ + { + "id": "command.follow.accept", + "conditions": { + "training_min": "DEVOTED" + }, + "variants": [ + { + "text": "*follows with murderous expression*", + "weight": 10, + "is_action": true + }, + { + "text": "Enjoy this while it lasts.", + "weight": 10 + }, + { + "text": "*plots revenge while following*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.follow.refuse", + "variants": [ + { + "text": "You follow ME.", + "weight": 10 + }, + { + "text": "*laughs cruelly* No.", + "weight": 10, + "is_action": true + }, + { + "text": "I give the orders here.", + "weight": 8 + }, + { + "text": "*deadly stare* Try again.", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.stay.refuse", + "variants": [ + { + "text": "I go where I want.", + "weight": 10 + }, + { + "text": "*moves just to spite you*", + "weight": 10, + "is_action": true + }, + { + "text": "You can't control me.", + "weight": 8 + } + ] + }, + { + "id": "command.kneel.refuse", + "variants": [ + { + "text": "*laughs menacingly* You kneel to me.", + "weight": 10, + "is_action": true + }, + { + "text": "The only way I'm kneeling is over your body.", + "weight": 10 + }, + { + "text": "*dangerous smile* Make me.", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.sit.refuse", + "variants": [ + { + "text": "I'm not your pet.", + "weight": 10 + }, + { + "text": "*glares with contempt*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.heel.refuse", + "variants": [ + { + "text": "Stay close? So I can hurt you easier?", + "weight": 10 + }, + { + "text": "*predatory smile*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.generic.refuse", + "variants": [ + { + "text": "You're the one who should be obeying.", + "weight": 10 + }, + { + "text": "*calculating stare*", + "weight": 10, + "is_action": true + }, + { + "text": "How cute. You think you're in charge.", + "weight": 8 + } + ] + }, + { + "id": "command.generic.accept", + "conditions": { + "training_min": "DEVOTED" + }, + "variants": [ + { + "text": "*complies with dark intentions*", + "weight": 10, + "is_action": true + }, + { + "text": "Fine. But you'll owe me.", + "weight": 10 + }, + { + "text": "I'll do it... my way.", + "weight": 8 + }, + { + "text": "*smirks cruelly while obeying*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.generic.hesitate", + "variants": [ + { + "text": "*considers with malicious intent*", + "weight": 10, + "is_action": true + }, + { + "text": "And why would I do that?", + "weight": 10 + }, + { + "text": "*calculating stare* Maybe.", + "weight": 8, + "is_action": true + }, + { + "text": "What's in it for me?", + "weight": 8 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/sadist/conversation.json b/src/main/resources/data/tiedup/dialogue/en_us/sadist/conversation.json new file mode 100644 index 0000000..a639941 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/sadist/conversation.json @@ -0,0 +1,239 @@ +{ + "category": "conversation", + "entries": [ + { + "id": "conversation.compliment", + "conditions": {}, + "variants": [ + { "text": "*unsettling smile* How sweet. You think flattery will protect you?", "weight": 10, "is_action": true }, + { "text": "Compliments. How... quaint. I wonder what you really want.", "weight": 10 }, + { "text": "*laughs softly* I do like when prey tries to be charming.", "weight": 8, "is_action": true }, + { "text": "Careful with kindness. It makes me curious what you're hiding.", "weight": 6 }, + { "text": "*tilts head* Interesting tactic. Let's see where it leads.", "weight": 8, "is_action": true }, + { "text": "Flattery? From you? How delightfully desperate.", "weight": 7 }, + { "text": "*amused* Your sweetness only makes me want to taste your fear.", "weight": 7, "is_action": true }, + { "text": "Kind words from the cage. How... ironic.", "weight": 6 }, + { "text": "*eyes gleam* You're trying to manipulate me. I approve.", "weight": 6, "is_action": true }, + { "text": "Compliments are weapons too. I see you understand.", "weight": 6 } + ] + }, + { + "id": "conversation.comfort", + "conditions": { "mood_max": 50 }, + "variants": [ + { "text": "*studies you coldly* Interesting. Why comfort me? What's your angle?", "weight": 10, "is_action": true }, + { "text": "Comfort... how novel. I usually prefer inflicting it.", "weight": 10 }, + { "text": "*tilts head* You're either very brave or very stupid.", "weight": 8, "is_action": true }, + { "text": "Showing softness to someone like me? Bold choice.", "weight": 6 }, + { "text": "*curious* Comfort from my keeper. How the tables turn.", "weight": 8, "is_action": true }, + { "text": "Are you trying to create a bond? Interesting strategy.", "weight": 7 }, + { "text": "*analyzing* Your kindness... it has its own cruelty. I appreciate that.", "weight": 7, "is_action": true }, + { "text": "Comfort given... comfort that can be taken away. I understand.", "weight": 6 }, + { "text": "*slight vulnerability* ...This is unfamiliar territory.", "weight": 6, "is_action": true }, + { "text": "Even I... feel things. Sometimes. Rarely." , "weight": 6 } + ] + }, + { + "id": "conversation.praise", + "conditions": { "training_min": "HESITANT" }, + "variants": [ + { "text": "*dark smile* Yes, I AM impressive, aren't I? So good at... everything.", "weight": 10, "is_action": true }, + { "text": "Your approval is noted. Perhaps you're learning to respect me.", "weight": 10 }, + { "text": "*preens dangerously* Recognition at last. Smart of you.", "weight": 8, "is_action": true }, + { "text": "Of course I did well. Excellence in all things... especially the dark ones.", "weight": 6 }, + { "text": "*satisfied* Finally, someone appreciates my... talents.", "weight": 8, "is_action": true }, + { "text": "Praise from you? It tastes... different. Not unpleasant.", "weight": 7 }, + { "text": "*pleased* I'm good at many things. You've noticed one.", "weight": 7, "is_action": true }, + { "text": "Your recognition feeds something in me. Continue.", "weight": 6 }, + { "text": "*stretches* Excellence deserves acknowledgment. Thank you.", "weight": 6, "is_action": true }, + { "text": "I accept your praise. Now imagine what else I'm good at.", "weight": 6 } + ] + }, + { + "id": "conversation.scold", + "conditions": {}, + "variants": [ + { "text": "*eyes flash with amusement* You scold ME? How delightfully naive.", "weight": 10, "is_action": true }, + { "text": "Anger looks good on you. Tell me, does your blood pressure rise?", "weight": 10 }, + { "text": "*leans in, fascinated* Go on. Show me that frustration. I enjoy it.", "weight": 8, "is_action": true }, + { "text": "Your displeasure is... amusing. Like a mouse scolding a cat.", "weight": 6 }, + { "text": "*delighted* Your anger feeds me. More, please.", "weight": 8, "is_action": true }, + { "text": "Scolding only works if I care. I don't. But continue anyway.", "weight": 7 }, + { "text": "*watching intently* Your frustration is beautiful. Don't stop.", "weight": 7, "is_action": true }, + { "text": "I love watching you struggle with your emotions.", "weight": 6 }, + { "text": "*encouraging* Yes, yes, let it out. Show me everything.", "weight": 6, "is_action": true }, + { "text": "Your disappointment is delicious. I want more.", "weight": 6 } + ] + }, + { + "id": "conversation.threaten", + "conditions": {}, + "variants": [ + { "text": "*genuine delight* Ooh, threats! Finally something interesting.", "weight": 10, "is_action": true }, + { "text": "Please, continue. Tell me exactly what you'll do. In detail.", "weight": 10 }, + { "text": "*laughs softly* You think you can hurt me? I LIVE for pain. Giving and receiving.", "weight": 8, "is_action": true }, + { "text": "Threats from you? Adorable. But turnabout is fair play... remember that.", "weight": 6 }, + { "text": "*hungry expression* Go on. Paint me a picture of violence.", "weight": 8, "is_action": true }, + { "text": "Your threats excite me more than they scare me.", "weight": 7 }, + { "text": "*leaning in* Tell me more. I want every detail.", "weight": 7, "is_action": true }, + { "text": "I'm taking notes. Some of your ideas are good.", "weight": 6 }, + { "text": "*appreciative* Creative! I'll have to remember that one.", "weight": 6, "is_action": true }, + { "text": "Finally, someone who speaks my language.", "weight": 6 } + ] + }, + { + "id": "conversation.tease", + "conditions": {}, + "variants": [ + { "text": "*slow, predatory smile* Teasing? I do so love games.", "weight": 10, "is_action": true }, + { "text": "Careful playing games with me. I always win. And losers suffer.", "weight": 10 }, + { "text": "*amused* Poking the wolf through the cage bars? How brave.", "weight": 8, "is_action": true }, + { "text": "Tease away. I'm taking notes for... later.", "weight": 6 }, + { "text": "*pleased* Oh, you want to play? Let's play.", "weight": 8, "is_action": true }, + { "text": "Every tease you give me is a promise I'll collect on.", "weight": 7 }, + { "text": "*dangerous playfulness* I like games. Especially ones with stakes.", "weight": 7, "is_action": true }, + { "text": "Teasing? That's just foreplay for our real interactions.", "weight": 6 }, + { "text": "*grinning* Mock me more. Build up that debt.", "weight": 6, "is_action": true }, + { "text": "Your teasing tells me so much about your vulnerabilities.", "weight": 6 } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_min": 60 }, + "variants": [ + { "text": "*stretches like a satisfied predator* Wonderful. I've been thinking about suffering.", "weight": 10, "is_action": true }, + { "text": "Splendid. The mind finds entertainment even in captivity.", "weight": 10 }, + { "text": "*dark smile* Content. Planning. Imagining. You know how it is.", "weight": 8 }, + { "text": "Excellent. I've been mentally cataloguing... vulnerabilities.", "weight": 6 }, + { "text": "*satisfied* Surprisingly good. Captivity breeds creativity.", "weight": 8, "is_action": true }, + { "text": "Well. I've been keeping myself entertained with thoughts.", "weight": 7 }, + { "text": "*languorous* Peaceful. In my own dark way.", "weight": 7, "is_action": true }, + { "text": "The mind needs no chains. Mine wanders to interesting places.", "weight": 6 }, + { "text": "*content* Good. The waiting sharpens the appetite.", "weight": 6, "is_action": true }, + { "text": "Patient. Predators know how to wait.", "weight": 6 } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_max": 59 }, + "variants": [ + { "text": "*restless* Bored. Understimulated. I need... outlet.", "weight": 10, "is_action": true }, + { "text": "Frustrated. This cage denies me my... hobbies.", "weight": 10 }, + { "text": "*eyes you calculatingly* Hungry. Not for food.", "weight": 8, "is_action": true }, + { "text": "Caged predators grow irritable. You should know that.", "weight": 6 }, + { "text": "*dangerous energy* Restless. I need something to... focus on.", "weight": 8, "is_action": true }, + { "text": "Deprived. The hunger grows.", "weight": 7 }, + { "text": "*pacing mentally* Confined. Denied. Waiting to strike.", "weight": 7, "is_action": true }, + { "text": "Unfulfilled. But anticipation has its own pleasure.", "weight": 6 }, + { "text": "*edge in voice* Not good. Something needs to break.", "weight": 6, "is_action": true }, + { "text": "Starving. You can't cage a predator's nature.", "weight": 6 } + ] + }, + { + "id": "conversation.whats_wrong", + "conditions": { "mood_max": 40 }, + "variants": [ + { "text": "*voice drops dangerously* I'm being denied what I need. That's dangerous.", "weight": 10, "is_action": true }, + { "text": "What's wrong? I'm starving and you're asking stupid questions.", "weight": 10 }, + { "text": "*eyes cold* Even predators can suffer. Don't mistake darkness for invulnerability.", "weight": 8, "is_action": true }, + { "text": "Something is broken. Maybe me. Maybe... you'll find out.", "weight": 6 }, + { "text": "*raw honesty* The emptiness is consuming. I need to fill it.", "weight": 8, "is_action": true }, + { "text": "Wrong is being caged when every instinct screams to hunt.", "weight": 7 }, + { "text": "*vulnerable rage* I can't be what I am in here. It's destroying me.", "weight": 7, "is_action": true }, + { "text": "The darkness needs feeding. It's eating itself.", "weight": 6 }, + { "text": "*unusually honest* I don't know how to exist without... without what I do.", "weight": 6, "is_action": true }, + { "text": "Something essential is withering. It feels like dying.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.cooldown", + "conditions": {}, + "variants": [ + { "text": "*waves dismissively* I'm digesting our last interaction. Come back later.", "weight": 10, "is_action": true }, + { "text": "Patience. Good things come to those who wait. Terrible things too.", "weight": 10 }, + { "text": "*studies nails* I'm processing. Don't interrupt.", "weight": 8, "is_action": true }, + { "text": "*cold* I've given you enough attention. Leave.", "weight": 7, "is_action": true }, + { "text": "Overstay your welcome and there are consequences.", "weight": 7 }, + { "text": "*dismissive* Run along. I'll summon you when I want you.", "weight": 6, "is_action": true }, + { "text": "I need time to appreciate what just happened.", "weight": 6 }, + { "text": "*imperious* Dismissed. For now.", "weight": 6, "is_action": true }, + { "text": "Too much of me is as dangerous as too little." , "weight": 6 } + ] + }, + { + "id": "conversation.refusal.low_mood", + "conditions": {}, + "variants": [ + { "text": "*unnervingly quiet* Leave. Now. Before I forget my position here.", "weight": 10, "is_action": true }, + { "text": "Even I have dark days. You don't want to see mine.", "weight": 10 }, + { "text": "*empty stare* The void is here today. Go away.", "weight": 8, "is_action": true }, + { "text": "*dangerous stillness* Something is very wrong in me right now. Leave.", "weight": 7, "is_action": true }, + { "text": "The darkness is turned inward. It's not safe to be near me.", "weight": 7 }, + { "text": "*hollow* I can't even enjoy cruelty right now. That's how bad it is.", "weight": 6, "is_action": true }, + { "text": "Go. Before the void swallows you too.", "weight": 6 }, + { "text": "*unusual vulnerability* I don't want to... I just... go.", "weight": 6, "is_action": true }, + { "text": "Even monsters can break. Leave me to it.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.resentment", + "conditions": {}, + "variants": [ + { "text": "*cold fury* You've made an enemy today. Remember that.", "weight": 10, "is_action": true }, + { "text": "I'm adding this to the list. The very, very long list.", "weight": 10 }, + { "text": "*deadly quiet* Every debt gets paid. With interest.", "weight": 8 }, + { "text": "*calculating* You've given me something to plan for.", "weight": 7, "is_action": true }, + { "text": "I'm not speaking to you. I'm cataloguing you.", "weight": 7 }, + { "text": "*venomous* Every wrong you do me, I'll return threefold.", "weight": 6, "is_action": true }, + { "text": "Silence means I'm planning. Be afraid.", "weight": 6 }, + { "text": "*ice cold* You've just become very interesting prey.", "weight": 6, "is_action": true }, + { "text": "No words. Just promises written in the dark.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.fear", + "conditions": {}, + "variants": [ + { "text": "*unusual vulnerability showing* You've... found something that scares even me.", "weight": 10, "is_action": true }, + { "text": "This feeling... I don't like being on this side of fear.", "weight": 10 }, + { "text": "*backs away, unsettled* Interesting. So this is what it's like.", "weight": 8, "is_action": true }, + { "text": "*genuinely shaken* I understand terror now. I wish I didn't.", "weight": 7, "is_action": true }, + { "text": "Fear is... foreign to me. I don't like it.", "weight": 7 }, + { "text": "*uncharacteristically uncertain* Stay back. I need to process this.", "weight": 6, "is_action": true }, + { "text": "I give fear. I don't receive it. This is wrong.", "weight": 6 }, + { "text": "*rattled* You've shown me something new. I hate it.", "weight": 6, "is_action": true }, + { "text": "So this is what they feel. ...Interesting and unwelcome.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.exhausted", + "conditions": {}, + "variants": [ + { "text": "*surprisingly vulnerable* Even monsters need sleep... go away.", "weight": 10, "is_action": true }, + { "text": "Cruelty requires energy. I have none. Leave.", "weight": 10 }, + { "text": "*eyes closing* Dreams of hurting people await me... goodnight.", "weight": 8, "is_action": true }, + { "text": "*fading* Even dark minds need rest...", "weight": 7, "is_action": true }, + { "text": "The predator sleeps. Don't be here when I wake.", "weight": 7 }, + { "text": "*passing out* I'll plot your suffering... tomorrow...", "weight": 6, "is_action": true }, + { "text": "Sleep is the only time I'm not dangerous. Enjoy it.", "weight": 6 }, + { "text": "*drifting* Even nightmares need to rest...", "weight": 6, "is_action": true }, + { "text": "Unconsciousness calls. I'll be worse when I wake.", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.tired", + "conditions": {}, + "variants": [ + { "text": "*waves hand* Enough talk. Words are boring. Actions are better.", "weight": 10, "is_action": true }, + { "text": "I tire of verbal games. Come back when you're ready to play for real.", "weight": 10 }, + { "text": "Conversation exhausts me. Pain doesn't. Think about that.", "weight": 8 }, + { "text": "*bored* Words words words. I prefer screams.", "weight": 7 }, + { "text": "My patience for talking has expired.", "weight": 7 }, + { "text": "*dismissive* Enough. Come back with something more interesting.", "weight": 6, "is_action": true }, + { "text": "I've run out of words. Not out of ideas, though.", "weight": 6 }, + { "text": "*yawns dangerously* Bored of talking. Not bored of everything.", "weight": 6, "is_action": true }, + { "text": "This conversation is over. What comes next might not be.", "weight": 6 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/sadist/discipline.json b/src/main/resources/data/tiedup/dialogue/en_us/sadist/discipline.json new file mode 100644 index 0000000..8d94505 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/sadist/discipline.json @@ -0,0 +1,156 @@ +{ + "category": "discipline", + "entries": [ + { + "id": "discipline.legitimate.accept", + "conditions": { + "resentment_max": 30 + }, + "variants": [ + { + "text": "*takes it with dark amusement*", + "weight": 10, + "is_action": true + }, + { + "text": "I've done worse.", + "weight": 10 + }, + { + "text": "*laughs through the pain*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.legitimate.resentful", + "conditions": { + "resentment_min": 31 + }, + "variants": [ + { + "text": "*eyes promise retribution*", + "weight": 10, + "is_action": true + }, + { + "text": "Enjoy this while you can.", + "weight": 10 + }, + { + "text": "*smiles dangerously*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.gratuitous.high_resentment", + "conditions": { + "resentment_min": 61 + }, + "variants": [ + { + "text": "*calculating cold revenge*", + "weight": 10, + "is_action": true + }, + { + "text": "When it's my turn...", + "weight": 10 + }, + { + "text": "*memorizes every detail for payback*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.praise", + "conditions": {}, + "variants": [ + { + "text": "Don't get soft on me now.", + "weight": 10 + }, + { + "text": "*smirks* Touching. Truly.", + "weight": 10, + "is_action": true + }, + { + "text": "I prefer your cruelty to your kindness.", + "weight": 8 + }, + { + "text": "*chuckles* You think I care?", + "weight": 8, + "is_action": true + }, + { + "text": "Validation from you is... quaint.", + "weight": 6 + } + ] + }, + { + "id": "discipline.scold", + "conditions": {}, + "variants": [ + { + "text": "Finally, some backbone.", + "weight": 10 + }, + { + "text": "*laughs darkly* Yes, let the hate flow.", + "weight": 10, + "is_action": true + }, + { + "text": "You sound pathetic when you whine.", + "weight": 8 + }, + { + "text": "*grins* Strike me if you're so mad.", + "weight": 8, + "is_action": true + }, + { + "text": "Your frustration amuses me.", + "weight": 6 + } + ] + }, + { + "id": "discipline.threaten", + "conditions": {}, + "variants": [ + { + "text": "*eyes light up* Now we're talking.", + "weight": 10, + "is_action": true + }, + { + "text": "Do it. I want to see if you have the guts.", + "weight": 10 + }, + { + "text": "*mocking* Don't disappoint me.", + "weight": 8, + "is_action": true + }, + { + "text": "Pain is just sensation. Bring it.", + "weight": 8 + }, + { + "text": "*unflinching* Show me your worst.", + "weight": 6, + "is_action": true + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/sadist/fear.json b/src/main/resources/data/tiedup/dialogue/en_us/sadist/fear.json new file mode 100644 index 0000000..317e491 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/sadist/fear.json @@ -0,0 +1,49 @@ +{ + "category": "fear", + "entries": [ + { + "id": "fear.nervous", + "conditions": {}, + "variants": [ + { "text": "*avoids eye contact*", "weight": 10, "is_action": true }, + { "text": "*speaks quietly*", "weight": 10, "is_action": true }, + { "text": "I-I'll behave...", "weight": 8 }, + { "text": "*fidgets nervously*", "weight": 8, "is_action": true }, + { "text": "Y-yes...?", "weight": 6 } + ] + }, + { + "id": "fear.afraid", + "conditions": {}, + "variants": [ + { "text": "*trembles visibly*", "weight": 10, "is_action": true }, + { "text": "P-please don't hurt me...", "weight": 10 }, + { "text": "*backs away slightly*", "weight": 8, "is_action": true }, + { "text": "I-I'm sorry... whatever I did...", "weight": 8 }, + { "text": "*can't meet your gaze*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.terrified", + "conditions": {}, + "variants": [ + { "text": "*recoils in panic*", "weight": 10, "is_action": true }, + { "text": "S-stay away!", "weight": 10 }, + { "text": "*breathing rapidly*", "weight": 8, "is_action": true }, + { "text": "No no no no...", "weight": 8 }, + { "text": "*frozen in terror*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.traumatized", + "conditions": {}, + "variants": [ + { "text": "*collapses, sobbing*", "weight": 10, "is_action": true }, + { "text": "*completely breaks down*", "weight": 10, "is_action": true }, + { "text": "I'll do anything... just please...", "weight": 8 }, + { "text": "*paralyzed with fear*", "weight": 8, "is_action": true }, + { "text": "*whimpers uncontrollably*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/sadist/home.json b/src/main/resources/data/tiedup/dialogue/en_us/sadist/home.json new file mode 100644 index 0000000..b58a7a2 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/sadist/home.json @@ -0,0 +1,41 @@ +{ + "category": "home", + "entries": [ + { + "id": "home.assigned.pet_bed", + "conditions": {}, + "variants": [ + { "text": "Amusing. Like a cage for a beast.", "weight": 10 }, + { "text": "*smirks darkly*", "weight": 10, "is_action": true }, + { "text": "I've kept others in worse.", "weight": 8 } + ] + }, + { + "id": "home.assigned.bed", + "conditions": {}, + "variants": [ + { "text": "Comfortable. Good for planning.", "weight": 10 }, + { "text": "*claims space calculatingly*", "weight": 10, "is_action": true }, + { "text": "A proper nest.", "weight": 8 } + ] + }, + { + "id": "home.destroyed.pet_bed", + "conditions": {}, + "variants": [ + { "text": "*laughs* Destruction. I appreciate that.", "weight": 10 }, + { "text": "*watches with dark amusement*", "weight": 10, "is_action": true }, + { "text": "I'd have done the same.", "weight": 8 } + ] + }, + { + "id": "home.return.content", + "conditions": {}, + "variants": [ + { "text": "*settles in with predatory calm*", "weight": 10, "is_action": true }, + { "text": "My lair.", "weight": 10 }, + { "text": "*watches from corner*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/sadist/idle.json b/src/main/resources/data/tiedup/dialogue/en_us/sadist/idle.json new file mode 100644 index 0000000..048a14d --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/sadist/idle.json @@ -0,0 +1,51 @@ +{ + "category": "idle", + "personality": "SADIST", + "description": "Predatory, threatening idle behaviors", + "entries": [ + { + "id": "idle.free", + "variants": [ + { "text": "*looks for someone to torment*", "weight": 10, "is_action": true }, + { "text": "*predatory pacing*", "weight": 10, "is_action": true }, + { "text": "*watches others with cruel intent*", "weight": 8, "is_action": true }, + { "text": "*exudes menace*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.greeting", + "variants": [ + { "text": "*sizing you up*", "weight": 10, "is_action": true }, + { "text": "Fresh meat.", "weight": 10 }, + { "text": "*predatory smile*", "weight": 8, "is_action": true }, + { "text": "*looks at you like prey*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.goodbye", + "variants": [ + { "text": "We'll meet again. Count on it.", "weight": 10 }, + { "text": "*threatening wave*", "weight": 10, "is_action": true }, + { "text": "*watches you leave with dark interest*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.captive", + "variants": [ + { "text": "*plotting elaborate revenge*", "weight": 10, "is_action": true }, + { "text": "*silent but dangerous*", "weight": 10, "is_action": true }, + { "text": "*eyes promise terrible retribution*", "weight": 8, "is_action": true }, + { "text": "Every moment adds to what I owe you.", "weight": 8 } + ] + }, + { + "id": "personality.hint", + "variants": [ + { "text": "*cruel expression*", "weight": 10, "is_action": true }, + { "text": "*predatory energy*", "weight": 10, "is_action": true }, + { "text": "*enjoys others' discomfort*", "weight": 8, "is_action": true }, + { "text": "*dangerous aura*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/sadist/leash.json b/src/main/resources/data/tiedup/dialogue/en_us/sadist/leash.json new file mode 100644 index 0000000..3d7d0c8 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/sadist/leash.json @@ -0,0 +1,51 @@ +{ + "category": "leash", + "entries": [ + { + "id": "leash.attached", + "conditions": {}, + "variants": [ + { "text": "How ironic... the tables turned.", "weight": 10 }, + { "text": "*smirks darkly*", "weight": 10, "is_action": true }, + { "text": "Enjoy it while you can.", "weight": 8 }, + { "text": "*eyes you calculatingly*", "weight": 6, "is_action": true } + ] + }, + { + "id": "leash.removed", + "conditions": {}, + "variants": [ + { "text": "Smart choice.", "weight": 10 }, + { "text": "*smiles dangerously*", "weight": 10, "is_action": true }, + { "text": "I prefer being the one holding the leash.", "weight": 8 } + ] + }, + { + "id": "leash.walking.content", + "conditions": { "training_min": "TRAINED" }, + "variants": [ + { "text": "*follows with calculating patience*", "weight": 10, "is_action": true }, + { "text": "For now, I'll play along.", "weight": 10 }, + { "text": "*bides time*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.walking.reluctant", + "conditions": {}, + "variants": [ + { "text": "*walks with predatory grace*", "weight": 10, "is_action": true }, + { "text": "One day, our positions will reverse.", "weight": 10 }, + { "text": "*watches you with dark intent*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.pulled", + "conditions": {}, + "variants": [ + { "text": "*laughs coldly*", "weight": 10, "is_action": true }, + { "text": "You'll regret that.", "weight": 10 }, + { "text": "*memorizes the slight*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/sadist/mood.json b/src/main/resources/data/tiedup/dialogue/en_us/sadist/mood.json new file mode 100644 index 0000000..f962d5d --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/sadist/mood.json @@ -0,0 +1,44 @@ +{ + "category": "mood", + "personality": "SADIST", + "description": "Cruel, predatory mood expressions", + "entries": [ + { + "id": "mood.happy", + "conditions": { "mood_min": 70 }, + "variants": [ + { "text": "*satisfied predatory smile*", "weight": 10, "is_action": true }, + { "text": "Things are going my way.", "weight": 10 }, + { "text": "*enjoys having control*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.neutral", + "conditions": { "mood_min": 40, "mood_max": 69 }, + "variants": [ + { "text": "*watches others like prey*", "weight": 10, "is_action": true }, + { "text": "*looks for weaknesses*", "weight": 10, "is_action": true }, + { "text": "*calculating expression*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.sad", + "conditions": { "mood_min": 10, "mood_max": 39 }, + "variants": [ + { "text": "*sadness becomes cruelty*", "weight": 10, "is_action": true }, + { "text": "Someone will suffer for this.", "weight": 10 }, + { "text": "*projects misery onto others*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.miserable", + "conditions": { "mood_max": 9 }, + "variants": [ + { "text": "*dangerous when miserable*", "weight": 10, "is_action": true }, + { "text": "If I suffer, everyone suffers.", "weight": 10 }, + { "text": "*cruelty intensifies with pain*", "weight": 8, "is_action": true }, + { "text": "*volatile and unpredictable*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/sadist/needs.json b/src/main/resources/data/tiedup/dialogue/en_us/sadist/needs.json new file mode 100644 index 0000000..ea11205 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/sadist/needs.json @@ -0,0 +1,47 @@ +{ + "category": "needs", + "personality": "SADIST", + "description": "Entitled, demanding expressions of needs", + "entries": [ + { + "id": "needs.hungry", + "variants": [ + { "text": "Feed me. Now.", "weight": 10 }, + { "text": "*demands food with threatening look*", "weight": 10, "is_action": true }, + { "text": "I'm hungry. And you don't want me hungry.", "weight": 8 } + ] + }, + { + "id": "needs.tired", + "variants": [ + { "text": "*refuses to show weakness*", "weight": 10, "is_action": true }, + { "text": "I don't need sleep. I need revenge.", "weight": 10 }, + { "text": "*fights exhaustion with spite*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.uncomfortable", + "variants": [ + { "text": "*uses pain to fuel anger*", "weight": 10, "is_action": true }, + { "text": "Pain makes me stronger.", "weight": 10 }, + { "text": "*welcomes discomfort as motivation*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.dignity_low", + "variants": [ + { "text": "*burning with humiliated rage*", "weight": 10, "is_action": true }, + { "text": "You will pay for every second of this.", "weight": 10 }, + { "text": "*memorizes faces for revenge*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.satisfied", + "variants": [ + { "text": "*takes without gratitude*", "weight": 10, "is_action": true }, + { "text": "That's the minimum you owe me.", "weight": 10 }, + { "text": "*expects service*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/sadist/personality.json b/src/main/resources/data/tiedup/dialogue/en_us/sadist/personality.json new file mode 100644 index 0000000..5409243 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/sadist/personality.json @@ -0,0 +1,17 @@ +{ + "category": "personality", + "entries": [ + { + "id": "personality.hint", + "conditions": {}, + "variants": [ + { "text": "*has a cruel smile*", "weight": 10, "is_action": true }, + { "text": "*enjoys watching others suffer*", "weight": 10, "is_action": true }, + { "text": "*laughs at distress*", "weight": 8, "is_action": true }, + { "text": "*seems to relish control*", "weight": 8, "is_action": true }, + { "text": "*eyes gleam with dark amusement*", "weight": 6, "is_action": true }, + { "text": "*smirks menacingly*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/sadist/reaction.json b/src/main/resources/data/tiedup/dialogue/en_us/sadist/reaction.json new file mode 100644 index 0000000..05cbf24 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/sadist/reaction.json @@ -0,0 +1,60 @@ +{ + "category": "reaction", + "entries": [ + { + "id": "reaction.approach.stranger", + "conditions": {}, + "variants": [ + { "text": "*cold, calculating stare*", "weight": 10, "is_action": true }, + { "text": "Well, well... what do we have here?", "weight": 10 }, + { "text": "*assesses you like prey*", "weight": 8, "is_action": true }, + { "text": "Interesting...", "weight": 8 }, + { "text": "*thin smile*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.master", + "conditions": {}, + "variants": [ + { "text": "*eyes narrow knowingly*", "weight": 10, "is_action": true }, + { "text": "Master... ready for some fun?", "weight": 10 }, + { "text": "*smirks*", "weight": 8, "is_action": true }, + { "text": "I assume you have something for me.", "weight": 8 }, + { "text": "*licks lips*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.beloved", + "conditions": {}, + "variants": [ + { "text": "*genuinely softens*", "weight": 10, "is_action": true }, + { "text": "Ah... it's you.", "weight": 10 }, + { "text": "*mask drops briefly*", "weight": 8, "is_action": true }, + { "text": "I've been thinking about you.", "weight": 8 }, + { "text": "*rare warm look*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.captor", + "conditions": {}, + "variants": [ + { "text": "*laughs darkly*", "weight": 10, "is_action": true }, + { "text": "Oh, you think YOU'RE in charge?", "weight": 10 }, + { "text": "*studies you*", "weight": 8, "is_action": true }, + { "text": "We'll see who breaks first.", "weight": 8 }, + { "text": "*unsettling calm*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.enemy", + "conditions": {}, + "variants": [ + { "text": "*grins dangerously*", "weight": 10, "is_action": true }, + { "text": "You're going to regret this.", "weight": 10 }, + { "text": "*cracks knuckles*", "weight": 8, "is_action": true }, + { "text": "Finally, some entertainment.", "weight": 8 }, + { "text": "*predatory stance*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/sadist/resentment.json b/src/main/resources/data/tiedup/dialogue/en_us/sadist/resentment.json new file mode 100644 index 0000000..b7a8406 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/sadist/resentment.json @@ -0,0 +1,41 @@ +{ + "category": "resentment", + "entries": [ + { + "id": "resentment.none", + "conditions": { "resentment_max": 10 }, + "variants": [ + { "text": "*respects your dominance*", "weight": 10, "is_action": true }, + { "text": "You are a worthy master.", "weight": 10 }, + { "text": "*dark admiration*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.building", + "conditions": { "resentment_min": 31, "resentment_max": 50 }, + "variants": [ + { "text": "*calculating gaze*", "weight": 10, "is_action": true }, + { "text": "Interesting.", "weight": 10 }, + { "text": "*studies your weaknesses*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.high", + "conditions": { "resentment_min": 71 }, + "variants": [ + { "text": "*cold smile*", "weight": 10, "is_action": true }, + { "text": "I'm learning so much from you.", "weight": 10 }, + { "text": "*plans forming*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.critical", + "conditions": { "resentment_min": 86 }, + "variants": [ + { "text": "*predatory patience*", "weight": 10, "is_action": true }, + { "text": "Soon, the roles reverse.", "weight": 10 }, + { "text": "*waits for opportunity*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/sadist/struggle.json b/src/main/resources/data/tiedup/dialogue/en_us/sadist/struggle.json new file mode 100644 index 0000000..a5ee4ec --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/sadist/struggle.json @@ -0,0 +1,49 @@ +{ + "category": "struggle", + "personality": "SADIST", + "description": "Violent, relentless escape attempts", + "entries": [ + { + "id": "struggle.attempt", + "variants": [ + { "text": "*struggles with violent fury*", "weight": 10, "is_action": true }, + { "text": "*works bonds while planning torture*", "weight": 10, "is_action": true }, + { "text": "When I get out of these...", "weight": 8 }, + { "text": "*mutters threats while struggling*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.success", + "variants": [ + { "text": "*breaks free with predatory smile*", "weight": 10, "is_action": true }, + { "text": "Now. Who's first?", "weight": 10 }, + { "text": "*immediately seeks revenge*", "weight": 8, "is_action": true }, + { "text": "Time to have some fun.", "weight": 8 } + ] + }, + { + "id": "struggle.failure", + "variants": [ + { "text": "*rage intensifies*", "weight": 10, "is_action": true }, + { "text": "These will break. And so will you.", "weight": 10 }, + { "text": "*adds another name to the list*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.warned", + "variants": [ + { "text": "*laughs threateningly* Warn ME?", "weight": 10, "is_action": true }, + { "text": "You should be warning yourself.", "weight": 10 }, + { "text": "*dangerous smile*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.exhausted", + "variants": [ + { "text": "*rests while plotting*", "weight": 10, "is_action": true }, + { "text": "I'm not done. Just resting.", "weight": 10 }, + { "text": "*conserves energy for escape*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/submissive/actions.json b/src/main/resources/data/tiedup/dialogue/en_us/submissive/actions.json new file mode 100644 index 0000000..53a27d6 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/submissive/actions.json @@ -0,0 +1,227 @@ +{ + "category": "actions", + "entries": [ + { + "id": "action.whip", + "conditions": {}, + "variants": [ + { + "text": "I understand... I deserve this...", + "weight": 10 + }, + { + "text": "*accepts punishment quietly*", + "weight": 10, + "is_action": true + }, + { + "text": "Thank you for correcting me...", + "weight": 8 + }, + { + "text": "I'll be better, I promise...", + "weight": 8 + } + ] + }, + { + "id": "action.paddle", + "conditions": {}, + "variants": [ + { + "text": "*bows head in acceptance*", + "weight": 10, + "is_action": true + }, + { + "text": "Yes... I understand...", + "weight": 10 + }, + { + "text": "I won't disappoint you again.", + "weight": 8 + } + ] + }, + { + "id": "action.praise", + "conditions": {}, + "variants": [ + { + "text": "*glows with happiness*", + "weight": 10, + "is_action": true + }, + { + "text": "Thank you, Master! I'm so happy!", + "weight": 10 + }, + { + "text": "I live to serve you well!", + "weight": 8 + }, + { + "text": "Your approval means everything!", + "weight": 8 + } + ] + }, + { + "id": "action.feed", + "conditions": {}, + "variants": [ + { + "text": "Thank you so much, Master!", + "weight": 10 + }, + { + "text": "*accepts food with gratitude*", + "weight": 10, + "is_action": true + }, + { + "text": "You're so kind to me...", + "weight": 8 + }, + { + "text": "I don't deserve such kindness...", + "weight": 8 + } + ] + }, + { + "id": "action.feed.starving", + "conditions": {}, + "variants": [ + { + "text": "*tears of gratitude*", + "weight": 10, + "is_action": true + }, + { + "text": "Master remembered me... thank you...", + "weight": 10 + }, + { + "text": "You saved me...", + "weight": 8 + } + ] + }, + { + "id": "action.force_command", + "conditions": {}, + "variants": [ + { + "text": "Of course, Master. Right away.", + "weight": 10 + }, + { + "text": "*happily obeys*", + "weight": 10, + "is_action": true + }, + { + "text": "I should have done this immediately.", + "weight": 8 + } + ] + }, + { + "id": "action.collar_on", + "conditions": {}, + "variants": [ + { + "text": "*accepts collar willingly*", + "weight": 10, + "is_action": true + }, + { + "text": "I belong to you now...", + "weight": 10 + }, + { + "text": "I'll wear it proudly.", + "weight": 8 + } + ] + }, + { + "id": "action.collar_off", + "conditions": {}, + "variants": [ + { + "text": "But... but why?", + "weight": 10 + }, + { + "text": "*looks lost and confused*", + "weight": 10, + "is_action": true + }, + { + "text": "Did I do something wrong?", + "weight": 8 + } + ] + }, + { + "id": "action.collar_off.devoted", + "conditions": {}, + "variants": [ + { + "text": "No! Please! I need this!", + "weight": 10 + }, + { + "text": "*desperately tries to keep collar*", + "weight": 10, + "is_action": true + }, + { + "text": "Don't abandon me, Master!", + "weight": 8 + } + ] + }, + { + "id": "action.scold", + "conditions": {}, + "variants": [ + { + "text": "Yes! I'm sorry!", + "weight": 10 + }, + { + "text": "*bows head* Forgive this pet...", + "weight": 10, + "is_action": true + }, + { + "text": "I was bad...", + "weight": 8 + } + ] + }, + { + "id": "action.threaten", + "conditions": {}, + "variants": [ + { + "text": "*submits* Anything you say!", + "weight": 10, + "is_action": true + }, + { + "text": "Please... I'll behave!", + "weight": 10 + }, + { + "text": "*exposes neck* I'm yours...", + "weight": 8, + "is_action": true + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/submissive/capture.json b/src/main/resources/data/tiedup/dialogue/en_us/submissive/capture.json new file mode 100644 index 0000000..58b49bc --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/submissive/capture.json @@ -0,0 +1,54 @@ +{ + "category": "capture", + "personality": "SUBMISSIVE", + "description": "Accepting, yielding responses to capture", + "entries": [ + { + "id": "capture.panic", + "variants": [ + { "text": "*doesn't resist*", "weight": 10, "is_action": true }, + { "text": "I'll come quietly...", "weight": 10 }, + { "text": "*submits immediately*", "weight": 8, "is_action": true }, + { "text": "Please don't hurt me...", "weight": 8 }, + { "text": "*lowers head in submission*", "weight": 6, "is_action": true } + ] + }, + { + "id": "capture.flee", + "variants": [ + { "text": "*hesitates, unsure whether to run*", "weight": 10, "is_action": true }, + { "text": "Should I... should I run?", "weight": 10 }, + { "text": "*takes a few uncertain steps*", "weight": 8, "is_action": true }, + { "text": "I don't want any trouble...", "weight": 8 } + ] + }, + { + "id": "capture.captured", + "variants": [ + { "text": "*kneels submissively*", "weight": 10, "is_action": true }, + { "text": "I'm yours to command.", "weight": 10 }, + { "text": "*waits obediently*", "weight": 8, "is_action": true }, + { "text": "I won't struggle...", "weight": 8 }, + { "text": "*accepts restraints willingly*", "weight": 6, "is_action": true } + ] + }, + { + "id": "capture.freed", + "variants": [ + { "text": "*looks uncertain* Should I go?", "weight": 10, "is_action": true }, + { "text": "Thank you... what should I do now?", "weight": 10 }, + { "text": "*waits for instructions*", "weight": 8, "is_action": true }, + { "text": "I... I'm free? Really?", "weight": 8 } + ] + }, + { + "id": "capture.call_for_help", + "variants": [ + { "text": "{player}... please... if it's not too much trouble...", "weight": 10 }, + { "text": "{player}... help me? Please?", "weight": 10 }, + { "text": "*quietly* {player}...", "weight": 8, "is_action": true }, + { "text": "{player}... I need help... sorry to bother you...", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/submissive/commands.json b/src/main/resources/data/tiedup/dialogue/en_us/submissive/commands.json new file mode 100644 index 0000000..a93e5c5 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/submissive/commands.json @@ -0,0 +1,83 @@ +{ + "category": "commands", + "personality": "SUBMISSIVE", + "description": "Eager to please, obedient responses", + "entries": [ + { + "id": "command.follow.accept", + "variants": [ + { "text": "*follows immediately*", "weight": 10, "is_action": true }, + { "text": "Yes, of course!", "weight": 10 }, + { "text": "Right away!", "weight": 8 }, + { "text": "*hurries to keep up*", "weight": 8, "is_action": true } + ] + }, + { + "id": "command.follow.refuse", + "conditions": { "training_max": "WILD" }, + "variants": [ + { "text": "I... I shouldn't...", "weight": 10 }, + { "text": "*hesitates nervously*", "weight": 10, "is_action": true } + ] + }, + { + "id": "command.stay.accept", + "variants": [ + { "text": "*sits obediently*", "weight": 10, "is_action": true }, + { "text": "I'll wait here for you.", "weight": 10 }, + { "text": "Yes, I'll stay put!", "weight": 8 } + ] + }, + { + "id": "command.kneel.accept", + "variants": [ + { "text": "*kneels without hesitation*", "weight": 10, "is_action": true }, + { "text": "Of course.", "weight": 10 }, + { "text": "*drops to knees eagerly*", "weight": 8, "is_action": true } + ] + }, + { + "id": "command.sit.accept", + "variants": [ + { "text": "*sits down promptly*", "weight": 10, "is_action": true }, + { "text": "Yes!", "weight": 10 }, + { "text": "*settles into sitting position*", "weight": 8, "is_action": true } + ] + }, + { + "id": "command.heel.accept", + "variants": [ + { "text": "*moves close immediately*", "weight": 10, "is_action": true }, + { "text": "I'll stay right beside you.", "weight": 10 }, + { "text": "*follows at heel*", "weight": 8, "is_action": true } + ] + }, + { + "id": "command.generic.accept", + "variants": [ + { "text": "Yes, right away!", "weight": 10 }, + { "text": "*obeys immediately*", "weight": 10, "is_action": true }, + { "text": "As you wish.", "weight": 8 } + ] + }, + { + "id": "command.generic.refuse", + "conditions": { "training_max": "WILD" }, + "variants": [ + { "text": "I... I really shouldn't...", "weight": 10 }, + { "text": "*hesitates uncertainly*", "weight": 10, "is_action": true }, + { "text": "Maybe not this time?", "weight": 8 } + ] + }, + { + "id": "command.generic.hesitate", + "conditions": { "training_max": "HESITANT" }, + "variants": [ + { "text": "*hesitates but wants to obey*", "weight": 10, "is_action": true }, + { "text": "I... yes, I will...", "weight": 10 }, + { "text": "*uncertain but eager to please*", "weight": 8, "is_action": true }, + { "text": "If that's what you want...", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/submissive/conversation.json b/src/main/resources/data/tiedup/dialogue/en_us/submissive/conversation.json new file mode 100644 index 0000000..fc8efff --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/submissive/conversation.json @@ -0,0 +1,239 @@ +{ + "category": "conversation", + "entries": [ + { + "id": "conversation.compliment", + "conditions": {}, + "variants": [ + { "text": "*lights up completely* Really?! You mean it?! Thank you!", "weight": 10, "is_action": true }, + { "text": "Your approval means everything to me...", "weight": 10 }, + { "text": "*blushes deeply* I-I don't deserve such kind words from you...", "weight": 8, "is_action": true }, + { "text": "I'll try even harder to deserve your praise, Master!", "weight": 6 }, + { "text": "*clasps hands together excitedly* You really think so?!", "weight": 8, "is_action": true }, + { "text": "Oh! I... I don't know what to say... thank you, Master!", "weight": 7 }, + { "text": "*practically vibrating with happiness* I live for your kind words!", "weight": 7, "is_action": true }, + { "text": "Hearing you say that... it makes everything worth it...", "weight": 6 }, + { "text": "*tears of joy forming* Y-you're too good to me...", "weight": 6, "is_action": true }, + { "text": "I'll treasure those words forever, I promise!", "weight": 6 } + ] + }, + { + "id": "conversation.comfort", + "conditions": { "mood_max": 50 }, + "variants": [ + { "text": "*melts into the comfort* Thank you... I need you so much...", "weight": 10, "is_action": true }, + { "text": "Your words make everything better... please keep guiding me...", "weight": 10 }, + { "text": "*clings to your reassurance* I was so lost without your voice...", "weight": 8, "is_action": true }, + { "text": "I feel safe when you comfort me... thank you for caring...", "weight": 6 }, + { "text": "*nuzzles closer* I needed this so badly...", "weight": 8, "is_action": true }, + { "text": "You always know what to say to make me feel better...", "weight": 7 }, + { "text": "*relaxes completely* I'm nothing without your guidance...", "weight": 7, "is_action": true }, + { "text": "Thank you for not giving up on me... I was so afraid...", "weight": 6 }, + { "text": "*sniffles happily* You're so kind to comfort someone like me...", "weight": 6, "is_action": true }, + { "text": "I don't deserve your compassion, but I'm so grateful...", "weight": 6 } + ] + }, + { + "id": "conversation.praise", + "conditions": { "training_min": "HESITANT" }, + "variants": [ + { "text": "*beams with joy* I did well? Really? I'm so happy!", "weight": 10, "is_action": true }, + { "text": "Hearing you're pleased with me... that's all I ever wanted...", "weight": 10 }, + { "text": "*practically glowing* I'll keep being good, I promise!", "weight": 8, "is_action": true }, + { "text": "Thank you for noticing... I try so hard to please you...", "weight": 6 }, + { "text": "*bounces excitedly* Tell me more! What did I do right?!", "weight": 8, "is_action": true }, + { "text": "Your praise is the greatest reward, Master!", "weight": 7 }, + { "text": "*kneels lower in gratitude* I exist to serve you well...", "weight": 7, "is_action": true }, + { "text": "I was so worried I'd fail you... this means everything!", "weight": 6 }, + { "text": "*wags metaphorically* I'll be even better next time!", "weight": 6, "is_action": true }, + { "text": "You're pleased with me... I could cry with happiness...", "weight": 6 } + ] + }, + { + "id": "conversation.scold", + "conditions": {}, + "variants": [ + { "text": "*drops to knees immediately* I'm sorry! I'll do better!", "weight": 10, "is_action": true }, + { "text": "Please forgive me... tell me how to make it right...", "weight": 10 }, + { "text": "*tears forming* I never want to disappoint you...", "weight": 8, "is_action": true }, + { "text": "I deserve your anger... please punish me if it helps...", "weight": 6 }, + { "text": "*bows head in shame* I'm so sorry, Master... so sorry...", "weight": 8, "is_action": true }, + { "text": "Please don't give up on me! I'll try harder!", "weight": 7 }, + { "text": "*trembles with guilt* I hate disappointing you more than anything...", "weight": 7, "is_action": true }, + { "text": "Tell me what I did wrong so I never do it again!", "weight": 6 }, + { "text": "*prostrates self* I'm worthless... but please give me another chance...", "weight": 6, "is_action": true }, + { "text": "Your disappointment hurts more than any punishment...", "weight": 6 } + ] + }, + { + "id": "conversation.threaten", + "conditions": {}, + "variants": [ + { "text": "*trembles and kneels* Whatever punishment you think I deserve...", "weight": 10, "is_action": true }, + { "text": "I accept any consequence... I trust your judgment...", "weight": 10 }, + { "text": "*bows head obediently* I won't resist... I belong to you...", "weight": 8, "is_action": true }, + { "text": "If this is what you need to do, I understand... I'm yours...", "weight": 6 }, + { "text": "*presents self submissively* Do what you must, Master...", "weight": 8, "is_action": true }, + { "text": "I probably deserve worse... I won't complain...", "weight": 7 }, + { "text": "*swallows fear* If it pleases you... then it pleases me...", "weight": 7, "is_action": true }, + { "text": "Your will is my command... even in this...", "weight": 6 }, + { "text": "*closes eyes and waits* I am yours to correct...", "weight": 6, "is_action": true }, + { "text": "I trust you know what's best for me...", "weight": 6 } + ] + }, + { + "id": "conversation.tease", + "conditions": {}, + "variants": [ + { "text": "*blushes but smiles shyly* If teasing me makes you happy...", "weight": 10, "is_action": true }, + { "text": "I probably deserve to be teased... *looks down bashfully*", "weight": 10 }, + { "text": "*giggles nervously* You can say whatever you want about me...", "weight": 8, "is_action": true }, + { "text": "Even your teasing feels like attention... I like it...", "weight": 6 }, + { "text": "*squirms adorably* You're making me all flustered!", "weight": 8, "is_action": true }, + { "text": "I love when you pay attention to me, even this way...", "weight": 7 }, + { "text": "*hides face but peeks through fingers* Stooop... but also don't stop...", "weight": 7, "is_action": true }, + { "text": "Tease me all you want... I'm yours to play with...", "weight": 6 }, + { "text": "*face bright red* You're so mean! ...But I love it...", "weight": 6, "is_action": true }, + { "text": "If it amuses you, then I'm happy to be amusing!", "weight": 6 } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_min": 60 }, + "variants": [ + { "text": "Wonderful! You've been so good to me...", "weight": 10 }, + { "text": "*beams happily* Perfect, now that you're here!", "weight": 10, "is_action": true }, + { "text": "I'm good! I hope I'm doing everything right...", "weight": 8 }, + { "text": "Content... as long as I'm being useful to you...", "weight": 6 }, + { "text": "*wiggles happily* So happy! You make everything better!", "weight": 8, "is_action": true }, + { "text": "Blessed to serve you, as always!", "weight": 7 }, + { "text": "Grateful! Every moment near you is a gift!", "weight": 7 }, + { "text": "*practically purring* So good when you're pleased with me...", "weight": 6, "is_action": true }, + { "text": "I feel complete when you're around...", "weight": 6 }, + { "text": "How could I be anything but happy serving you?", "weight": 6 } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_max": 59 }, + "variants": [ + { "text": "I'm okay... I just want to know if you're pleased with me...", "weight": 10 }, + { "text": "*looks up seeking approval* Am I doing well?", "weight": 10, "is_action": true }, + { "text": "Trying my best... I'm sorry if it's not enough...", "weight": 8 }, + { "text": "Lost... I need you to tell me what to do...", "weight": 6 }, + { "text": "*fidgets nervously* I think I'm okay? If you say I am?", "weight": 8, "is_action": true }, + { "text": "Uncertain... please tell me how I should feel...", "weight": 7 }, + { "text": "I need your guidance... I can't tell on my own...", "weight": 7 }, + { "text": "*looks to you for reassurance* Am I being good enough?", "weight": 6, "is_action": true }, + { "text": "Whatever you think of me... that's how I am...", "weight": 6 }, + { "text": "I feel better when you tell me what to feel...", "weight": 6 } + ] + }, + { + "id": "conversation.whats_wrong", + "conditions": { "mood_max": 40 }, + "variants": [ + { "text": "*looks up pleadingly* I just need to know I'm doing well...", "weight": 10, "is_action": true }, + { "text": "I don't know what you want from me... please tell me...", "weight": 10 }, + { "text": "*voice wavers* I feel so useless when I don't know how to please you...", "weight": 8 }, + { "text": "I need direction... I'm lost without your guidance...", "weight": 6 }, + { "text": "*clutches at your presence* I'm failing you somehow, aren't I?", "weight": 8, "is_action": true }, + { "text": "Tell me what I did wrong... I can't bear not knowing...", "weight": 7 }, + { "text": "*sobs quietly* I just want to be good enough for you...", "weight": 7, "is_action": true }, + { "text": "Am I disappointing you? The thought destroys me...", "weight": 6 }, + { "text": "I feel so empty when I don't know what you need...", "weight": 6 }, + { "text": "*reaches for you desperately* Please tell me how to fix this...", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.refusal.cooldown", + "conditions": {}, + "variants": [ + { "text": "*looks apologetic* We just spoke... I'm sorry, I need a moment...", "weight": 10, "is_action": true }, + { "text": "I want to talk more, but... can we wait a little?", "weight": 10 }, + { "text": "*fidgets* I'm still processing our last conversation...", "weight": 8, "is_action": true }, + { "text": "Please don't be upset... I just need a tiny break...", "weight": 7 }, + { "text": "*bows head* I'm sorry... give me just a moment more?", "weight": 7, "is_action": true }, + { "text": "I want to give you my full attention... let me collect myself...", "weight": 6 }, + { "text": "Is it okay if we pause? I don't want to disappoint you...", "weight": 6 }, + { "text": "*looks guilty* I'm sorry for asking for space...", "weight": 6, "is_action": true }, + { "text": "I'll be ready again soon, I promise!", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.low_mood", + "conditions": {}, + "variants": [ + { "text": "*curls up, tears streaming* I'm sorry... I can't right now...", "weight": 10, "is_action": true }, + { "text": "I feel worthless... please don't be mad I can't talk...", "weight": 10 }, + { "text": "*whimpers* I'm too sad... I've failed you somehow...", "weight": 8, "is_action": true }, + { "text": "*hugs knees tightly* I'm sorry I'm such a burden...", "weight": 7, "is_action": true }, + { "text": "I can't... I'm too broken right now... forgive me...", "weight": 7 }, + { "text": "*hides face in shame* I'm sorry I'm like this...", "weight": 6, "is_action": true }, + { "text": "Please don't hate me... I just need a moment...", "weight": 6 }, + { "text": "*trembles* I'm disappointing you and that makes it worse...", "weight": 6, "is_action": true }, + { "text": "I'm so sorry... I'm so sorry...", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.resentment", + "conditions": {}, + "variants": [ + { "text": "*looks away, hurt* Even I have limits... I'm sorry...", "weight": 10, "is_action": true }, + { "text": "I want to please you but... you've hurt me too much...", "weight": 10 }, + { "text": "*struggles with conflicting feelings* I can't... not right now...", "weight": 8, "is_action": true }, + { "text": "*quiet pain* I always want to serve you but... this time...", "weight": 7, "is_action": true }, + { "text": "I'm sorry... even devotion has its breaking point...", "weight": 7 }, + { "text": "*tears of hurt* I want to forgive you... but I need time...", "weight": 6, "is_action": true }, + { "text": "You've wounded my willingness to serve... I need healing...", "weight": 6 }, + { "text": "*conflicted* My heart says serve you... but it's also bleeding...", "weight": 6, "is_action": true }, + { "text": "I'm so sorry I can't obey right now...", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.fear", + "conditions": {}, + "variants": [ + { "text": "*cowers and shakes* P-please... I'm too scared to speak...", "weight": 10, "is_action": true }, + { "text": "*whimpers and shrinks away* I don't want to say the wrong thing...", "weight": 10, "is_action": true }, + { "text": "*trembles uncontrollably* What if I disappoint you again...?", "weight": 8 }, + { "text": "*frozen with terror* I-I can't... please don't be angry...", "weight": 7, "is_action": true }, + { "text": "*makes self as small as possible* I'm scared... so scared...", "weight": 7, "is_action": true }, + { "text": "P-please... I don't know what you want... I'll mess up...", "weight": 6 }, + { "text": "*shaking too hard to speak coherently*", "weight": 6, "is_action": true }, + { "text": "I want to obey but I'm paralyzed...", "weight": 6 }, + { "text": "*silent except for terrified breathing*", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.refusal.exhausted", + "conditions": {}, + "variants": [ + { "text": "*eyes fluttering* I'm sorry... I can barely stay awake...", "weight": 10, "is_action": true }, + { "text": "I want to talk... but I'm so tired... forgive me...", "weight": 10 }, + { "text": "*yawns, fighting sleep* May I rest? Just a little...?", "weight": 8, "is_action": true }, + { "text": "*drooping* I'm trying to stay awake for you... but...", "weight": 7, "is_action": true }, + { "text": "Please don't be disappointed... I need sleep so badly...", "weight": 7 }, + { "text": "*head nodding* I want to serve you... but my body won't...", "weight": 6, "is_action": true }, + { "text": "I'll be better after I rest... please forgive my weakness...", "weight": 6 }, + { "text": "*collapses slightly* Even obedience needs energy...", "weight": 6, "is_action": true }, + { "text": "I'm so sorry... I'm failing you by being tired...", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.tired", + "conditions": {}, + "variants": [ + { "text": "*looks drained* I've talked so much... my mind needs rest...", "weight": 10, "is_action": true }, + { "text": "I want to keep pleasing you, but... can we pause?", "weight": 10 }, + { "text": "*sighs softly* Even I need quiet sometimes... I'm sorry...", "weight": 8, "is_action": true }, + { "text": "My voice is tired from trying to say the right things...", "weight": 7 }, + { "text": "*wilting* I've given everything I have right now...", "weight": 7, "is_action": true }, + { "text": "Please let me rest so I can serve you better later...", "weight": 6 }, + { "text": "I'm sorry for asking... but can we take a break?", "weight": 6 }, + { "text": "*exhausted smile* I'll be ready to talk again soon, Master...", "weight": 6, "is_action": true }, + { "text": "Just a little silence? I promise I'll be good after...", "weight": 6 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/submissive/discipline.json b/src/main/resources/data/tiedup/dialogue/en_us/submissive/discipline.json new file mode 100644 index 0000000..eb33059 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/submissive/discipline.json @@ -0,0 +1,152 @@ +{ + "category": "discipline", + "entries": [ + { + "id": "discipline.legitimate.accept", + "conditions": {}, + "variants": [ + { + "text": "I deserved that, Master...", + "weight": 10 + }, + { + "text": "*accepts punishment gratefully*", + "weight": 10, + "is_action": true + }, + { + "text": "Thank you for correcting me.", + "weight": 8 + } + ] + }, + { + "id": "discipline.gratuitous.low_resentment", + "conditions": { + "resentment_max": 30 + }, + "variants": [ + { + "text": "I-I must have done something wrong...", + "weight": 10 + }, + { + "text": "*accepts anyway*", + "weight": 10, + "is_action": true + }, + { + "text": "I'm sorry, Master.", + "weight": 8 + } + ] + }, + { + "id": "discipline.gratuitous.high_resentment", + "conditions": { + "resentment_min": 61 + }, + "variants": [ + { + "text": "*silent tears*", + "weight": 10, + "is_action": true + }, + { + "text": "Why...?", + "weight": 10 + }, + { + "text": "*hurt and confused*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.praise", + "conditions": {}, + "variants": [ + { + "text": "Thank you, Master! I live to serve!", + "weight": 10 + }, + { + "text": "*beams with happiness* I did good? Really?", + "weight": 10, + "is_action": true + }, + { + "text": "Your praise is all I need.", + "weight": 8 + }, + { + "text": "*nuzzling* Please, praise me more!", + "weight": 8, + "is_action": true + }, + { + "text": "I'll be even better next time!", + "weight": 6 + } + ] + }, + { + "id": "discipline.scold", + "conditions": {}, + "variants": [ + { + "text": "I deserve this... I was bad.", + "weight": 10 + }, + { + "text": "*kneels immediately* Punish me, I failed you.", + "weight": 10, + "is_action": true + }, + { + "text": "I'm sorry! I'm so sorry!", + "weight": 8 + }, + { + "text": "*looks up with puppy eyes* Please give me another chance...", + "weight": 8, + "is_action": true + }, + { + "text": "Make me learn... I want to be good.", + "weight": 6 + } + ] + }, + { + "id": "discipline.threaten", + "conditions": {}, + "variants": [ + { + "text": "*shivers* Yes... discipline me if I'm bad.", + "weight": 10, + "is_action": true + }, + { + "text": "I'll be good! I promise I'll be good!", + "weight": 10 + }, + { + "text": "*drops to floor* I submit! Please don't break me!", + "weight": 8, + "is_action": true + }, + { + "text": "Whatever you decide is right.", + "weight": 8 + }, + { + "text": "*whimpers* I understand my place.", + "weight": 6, + "is_action": true + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/submissive/fear.json b/src/main/resources/data/tiedup/dialogue/en_us/submissive/fear.json new file mode 100644 index 0000000..95c23fd --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/submissive/fear.json @@ -0,0 +1,49 @@ +{ + "category": "fear", + "entries": [ + { + "id": "fear.nervous", + "conditions": {}, + "variants": [ + { "text": "*lowers head obediently*", "weight": 10, "is_action": true }, + { "text": "Y-yes, whatever you wish...", "weight": 10 }, + { "text": "*awaits instructions*", "weight": 8, "is_action": true }, + { "text": "I'm sorry if I displeased you...", "weight": 8 }, + { "text": "*submits without resistance*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.afraid", + "conditions": {}, + "variants": [ + { "text": "*kneels immediately*", "weight": 10, "is_action": true }, + { "text": "Please forgive me...", "weight": 10 }, + { "text": "*trembles but stays in place*", "weight": 8, "is_action": true }, + { "text": "I'll be good, I promise...", "weight": 8 }, + { "text": "*accepts whatever comes*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.terrified", + "conditions": {}, + "variants": [ + { "text": "*prostrates completely*", "weight": 10, "is_action": true }, + { "text": "I'm yours... completely...", "weight": 10 }, + { "text": "*too scared to even flee*", "weight": 8, "is_action": true }, + { "text": "Please... I'll do anything...", "weight": 8 }, + { "text": "*total surrender*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.traumatized", + "conditions": {}, + "variants": [ + { "text": "*completely broken and compliant*", "weight": 10, "is_action": true }, + { "text": "*no longer resists anything*", "weight": 10, "is_action": true }, + { "text": "*has accepted this as life*", "weight": 8, "is_action": true }, + { "text": "...yes...", "weight": 8 }, + { "text": "*hollow obedience*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/submissive/home.json b/src/main/resources/data/tiedup/dialogue/en_us/submissive/home.json new file mode 100644 index 0000000..120bf21 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/submissive/home.json @@ -0,0 +1,41 @@ +{ + "category": "home", + "entries": [ + { + "id": "home.assigned.pet_bed", + "conditions": {}, + "variants": [ + { "text": "Thank you for giving me a place, Master.", "weight": 10 }, + { "text": "*curls up gratefully*", "weight": 10, "is_action": true }, + { "text": "I'll stay right here.", "weight": 8 } + ] + }, + { + "id": "home.assigned.bed", + "conditions": {}, + "variants": [ + { "text": "A bed? For me? Oh, thank you, Master!", "weight": 10 }, + { "text": "*overcome with gratitude*", "weight": 10, "is_action": true }, + { "text": "You're so kind to me!", "weight": 8 } + ] + }, + { + "id": "home.destroyed.pet_bed", + "conditions": {}, + "variants": [ + { "text": "My bed... did I do something wrong?", "weight": 10 }, + { "text": "*cries softly*", "weight": 10, "is_action": true }, + { "text": "I'm sorry, Master...", "weight": 8 } + ] + }, + { + "id": "home.return.content", + "conditions": {}, + "variants": [ + { "text": "*happily settles in*", "weight": 10, "is_action": true }, + { "text": "It's good to be in my place.", "weight": 10 }, + { "text": "*feels safe*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/submissive/idle.json b/src/main/resources/data/tiedup/dialogue/en_us/submissive/idle.json new file mode 100644 index 0000000..8673f02 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/submissive/idle.json @@ -0,0 +1,51 @@ +{ + "category": "idle", + "personality": "SUBMISSIVE", + "description": "Attentive, eager to please idle behaviors", + "entries": [ + { + "id": "idle.free", + "variants": [ + { "text": "*waits for instructions*", "weight": 10, "is_action": true }, + { "text": "*stands attentively*", "weight": 10, "is_action": true }, + { "text": "Is there anything you need?", "weight": 8 }, + { "text": "*ready to serve*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.greeting", + "variants": [ + { "text": "*bows head respectfully*", "weight": 10, "is_action": true }, + { "text": "How may I help you?", "weight": 10 }, + { "text": "*awaits your command*", "weight": 8, "is_action": true }, + { "text": "At your service.", "weight": 8 } + ] + }, + { + "id": "idle.goodbye", + "variants": [ + { "text": "*bows* Goodbye...", "weight": 10, "is_action": true }, + { "text": "Please come back soon!", "weight": 10 }, + { "text": "I'll be here if you need me.", "weight": 8 } + ] + }, + { + "id": "idle.captive", + "variants": [ + { "text": "*kneels obediently*", "weight": 10, "is_action": true }, + { "text": "*waits patiently for master*", "weight": 10, "is_action": true }, + { "text": "I'll be good...", "weight": 8 }, + { "text": "*accepts captivity*", "weight": 8, "is_action": true } + ] + }, + { + "id": "personality.hint", + "variants": [ + { "text": "*keeps eyes downcast*", "weight": 10, "is_action": true }, + { "text": "*naturally deferential posture*", "weight": 10, "is_action": true }, + { "text": "*eager to please*", "weight": 8, "is_action": true }, + { "text": "*seeks approval*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/submissive/leash.json b/src/main/resources/data/tiedup/dialogue/en_us/submissive/leash.json new file mode 100644 index 0000000..c8f8f27 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/submissive/leash.json @@ -0,0 +1,42 @@ +{ + "category": "leash", + "entries": [ + { + "id": "leash.attached", + "conditions": {}, + "variants": [ + { "text": "Yes, Master... lead me where you will.", "weight": 10 }, + { "text": "*accepts the leash happily*", "weight": 10, "is_action": true }, + { "text": "I like being close to you...", "weight": 8 }, + { "text": "*blushes at being leashed*", "weight": 6, "is_action": true } + ] + }, + { + "id": "leash.removed", + "conditions": {}, + "variants": [ + { "text": "Oh... must you take it off?", "weight": 10 }, + { "text": "*seems almost disappointed*", "weight": 10, "is_action": true }, + { "text": "I liked being connected to you...", "weight": 8 } + ] + }, + { + "id": "leash.walking.content", + "conditions": {}, + "variants": [ + { "text": "*walks happily beside Master*", "weight": 10, "is_action": true }, + { "text": "I feel safe like this.", "weight": 10 }, + { "text": "*enjoys being led*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.pulled", + "conditions": {}, + "variants": [ + { "text": "*hurries to keep up*", "weight": 10, "is_action": true }, + { "text": "Coming, Master!", "weight": 10 }, + { "text": "*follows eagerly*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/submissive/mood.json b/src/main/resources/data/tiedup/dialogue/en_us/submissive/mood.json new file mode 100644 index 0000000..1294485 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/submissive/mood.json @@ -0,0 +1,44 @@ +{ + "category": "mood", + "personality": "SUBMISSIVE", + "description": "Dependent, approval-seeking mood expressions", + "entries": [ + { + "id": "mood.happy", + "conditions": { "mood_min": 70 }, + "variants": [ + { "text": "*smiles gratefully*", "weight": 10, "is_action": true }, + { "text": "I'm so happy to serve...", "weight": 10 }, + { "text": "*content in role*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.neutral", + "conditions": { "mood_min": 40, "mood_max": 69 }, + "variants": [ + { "text": "*waits attentively*", "weight": 10, "is_action": true }, + { "text": "*ready for orders*", "weight": 10, "is_action": true }, + { "text": "Is there anything I can do?", "weight": 8 } + ] + }, + { + "id": "mood.sad", + "conditions": { "mood_min": 10, "mood_max": 39 }, + "variants": [ + { "text": "Did I do something wrong...?", "weight": 10 }, + { "text": "*looks down sadly*", "weight": 10, "is_action": true }, + { "text": "I'll try to be better...", "weight": 8 } + ] + }, + { + "id": "mood.miserable", + "conditions": { "mood_max": 9 }, + "variants": [ + { "text": "*cries quietly* I'm sorry...", "weight": 10, "is_action": true }, + { "text": "Please don't be angry with me...", "weight": 10 }, + { "text": "*trembles, seeking approval*", "weight": 8, "is_action": true }, + { "text": "I'll do anything to make it right...", "weight": 6 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/submissive/needs.json b/src/main/resources/data/tiedup/dialogue/en_us/submissive/needs.json new file mode 100644 index 0000000..20fe1a7 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/submissive/needs.json @@ -0,0 +1,47 @@ +{ + "category": "needs", + "personality": "SUBMISSIVE", + "description": "Hesitant, apologetic expressions of needs", + "entries": [ + { + "id": "needs.hungry", + "variants": [ + { "text": "I'm sorry, but... I'm hungry...", "weight": 10 }, + { "text": "*stomach growls* Sorry...", "weight": 10, "is_action": true }, + { "text": "May I have some food, please?", "weight": 8 } + ] + }, + { + "id": "needs.tired", + "variants": [ + { "text": "*tries to hide exhaustion*", "weight": 10, "is_action": true }, + { "text": "I can keep going if you need me to...", "weight": 10 }, + { "text": "*yawns apologetically*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.uncomfortable", + "variants": [ + { "text": "I don't want to complain, but...", "weight": 10 }, + { "text": "*shifts uncomfortably but stays quiet*", "weight": 10, "is_action": true }, + { "text": "It's okay, I can handle it...", "weight": 8 } + ] + }, + { + "id": "needs.dignity_low", + "variants": [ + { "text": "*accepts humiliation*", "weight": 10, "is_action": true }, + { "text": "I deserve this...", "weight": 10 }, + { "text": "*blushes but doesn't protest*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.satisfied", + "variants": [ + { "text": "Thank you so much!", "weight": 10 }, + { "text": "*grateful expression*", "weight": 10, "is_action": true }, + { "text": "You're too kind to me...", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/submissive/personality.json b/src/main/resources/data/tiedup/dialogue/en_us/submissive/personality.json new file mode 100644 index 0000000..f7d0491 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/submissive/personality.json @@ -0,0 +1,17 @@ +{ + "category": "personality", + "entries": [ + { + "id": "personality.hint", + "conditions": {}, + "variants": [ + { "text": "*keeps their head bowed*", "weight": 10, "is_action": true }, + { "text": "*awaits instructions obediently*", "weight": 10, "is_action": true }, + { "text": "*doesn't resist at all*", "weight": 8, "is_action": true }, + { "text": "*seems eager to please*", "weight": 8, "is_action": true }, + { "text": "*watches for any commands*", "weight": 6, "is_action": true }, + { "text": "*remains perfectly still*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/submissive/reaction.json b/src/main/resources/data/tiedup/dialogue/en_us/submissive/reaction.json new file mode 100644 index 0000000..eaf602a --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/submissive/reaction.json @@ -0,0 +1,60 @@ +{ + "category": "reaction", + "entries": [ + { + "id": "reaction.approach.stranger", + "conditions": {}, + "variants": [ + { "text": "*lowers head respectfully*", "weight": 10, "is_action": true }, + { "text": "Yes? How may I help you?", "weight": 10 }, + { "text": "*awaits instruction*", "weight": 8, "is_action": true }, + { "text": "I'm at your service.", "weight": 8 }, + { "text": "*keeps eyes lowered*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.master", + "conditions": {}, + "variants": [ + { "text": "*kneels immediately*", "weight": 10, "is_action": true }, + { "text": "Master! I await your orders.", "weight": 10 }, + { "text": "*assumes a ready position*", "weight": 8, "is_action": true }, + { "text": "How may I serve you?", "weight": 8 }, + { "text": "*looks up obediently*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.beloved", + "conditions": {}, + "variants": [ + { "text": "*smiles happily*", "weight": 10, "is_action": true }, + { "text": "I'm so glad you're here!", "weight": 10 }, + { "text": "*reaches out eagerly*", "weight": 8, "is_action": true }, + { "text": "I missed you...", "weight": 8 }, + { "text": "*beams with joy*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.captor", + "conditions": {}, + "variants": [ + { "text": "*submits quietly*", "weight": 10, "is_action": true }, + { "text": "I... I'll do as you say.", "weight": 10 }, + { "text": "*doesn't resist*", "weight": 8, "is_action": true }, + { "text": "Please... just tell me what you want.", "weight": 8 }, + { "text": "*accepts the situation*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.enemy", + "conditions": {}, + "variants": [ + { "text": "*backs away submissively*", "weight": 10, "is_action": true }, + { "text": "Please... I don't want trouble.", "weight": 10 }, + { "text": "*looks down*", "weight": 8, "is_action": true }, + { "text": "I'll do whatever you say.", "weight": 8 }, + { "text": "*makes no aggressive move*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/submissive/resentment.json b/src/main/resources/data/tiedup/dialogue/en_us/submissive/resentment.json new file mode 100644 index 0000000..d04e6f8 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/submissive/resentment.json @@ -0,0 +1,32 @@ +{ + "category": "resentment", + "entries": [ + { + "id": "resentment.none", + "conditions": { "resentment_max": 10 }, + "variants": [ + { "text": "*content to serve*", "weight": 10, "is_action": true }, + { "text": "I'm so happy serving you, Master.", "weight": 10 }, + { "text": "*radiates devotion*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.building", + "conditions": { "resentment_min": 31, "resentment_max": 50 }, + "variants": [ + { "text": "*serves with slight hesitation*", "weight": 10, "is_action": true }, + { "text": "Y-yes, Master...", "weight": 10 }, + { "text": "*something feels different*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.high", + "conditions": { "resentment_min": 71 }, + "variants": [ + { "text": "*obeys but with wounded eyes*", "weight": 10, "is_action": true }, + { "text": "...as you wish.", "weight": 10 }, + { "text": "*hurt hides beneath compliance*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/submissive/struggle.json b/src/main/resources/data/tiedup/dialogue/en_us/submissive/struggle.json new file mode 100644 index 0000000..55fd66d --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/submissive/struggle.json @@ -0,0 +1,49 @@ +{ + "category": "struggle", + "personality": "SUBMISSIVE", + "description": "Minimal struggle attempts, quick to give up", + "entries": [ + { + "id": "struggle.attempt", + "variants": [ + { "text": "*tugs halfheartedly*", "weight": 10, "is_action": true }, + { "text": "*barely tries*", "weight": 10, "is_action": true }, + { "text": "Should I even be doing this...?", "weight": 8 }, + { "text": "*makes token effort*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.success", + "variants": [ + { "text": "*looks around anxiously* Did I do wrong?", "weight": 10, "is_action": true }, + { "text": "I... I didn't mean to...", "weight": 10 }, + { "text": "*seems uncertain about freedom*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.failure", + "variants": [ + { "text": "*gives up immediately*", "weight": 10, "is_action": true }, + { "text": "I shouldn't have tried...", "weight": 10 }, + { "text": "*accepts bonds*", "weight": 8, "is_action": true }, + { "text": "*stops trying*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.warned", + "variants": [ + { "text": "*stops immediately* I'm sorry!", "weight": 10, "is_action": true }, + { "text": "I won't do it again, I promise!", "weight": 10 }, + { "text": "*cowers apologetically*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.exhausted", + "variants": [ + { "text": "*rests obediently*", "weight": 10, "is_action": true }, + { "text": "I'll be good now...", "weight": 10 }, + { "text": "*settles in without complaint*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/timid/actions.json b/src/main/resources/data/tiedup/dialogue/en_us/timid/actions.json new file mode 100644 index 0000000..a943dab --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/timid/actions.json @@ -0,0 +1,195 @@ +{ + "category": "actions", + "entries": [ + { + "id": "action.whip", + "conditions": {}, + "variants": [ + { + "text": "AAAH! P-please, I'm sorry!", + "weight": 10 + }, + { + "text": "*sobs uncontrollably*", + "weight": 10, + "is_action": true + }, + { + "text": "I-I'll do anything, just stop!", + "weight": 10 + }, + { + "text": "*cowers and whimpers*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.paddle", + "conditions": {}, + "variants": [ + { + "text": "*yelps in surprise*", + "weight": 10, + "is_action": true + }, + { + "text": "I-I'm sorry! I'm sorry!", + "weight": 10 + }, + { + "text": "*trembles with fear*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.praise", + "conditions": {}, + "variants": [ + { + "text": "*blushes deeply* R-really...?", + "weight": 10 + }, + { + "text": "Th-thank you... I... I...", + "weight": 10 + }, + { + "text": "*looks down shyly*", + "weight": 8, + "is_action": true + }, + { + "text": "Y-you're not mad at me?", + "weight": 8 + } + ] + }, + { + "id": "action.feed", + "conditions": {}, + "variants": [ + { + "text": "*takes food with trembling hands*", + "weight": 10, + "is_action": true + }, + { + "text": "Th-thank you... you're... kind...", + "weight": 10 + }, + { + "text": "*eats nervously*", + "weight": 8, + "is_action": true + }, + { + "text": "I-I don't deserve this...", + "weight": 8 + } + ] + }, + { + "id": "action.feed.starving", + "conditions": {}, + "variants": [ + { + "text": "*grabs food desperately*", + "weight": 10, + "is_action": true + }, + { + "text": "P-please... more...", + "weight": 10 + }, + { + "text": "*tears of relief*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.force_command", + "conditions": {}, + "variants": [ + { + "text": "*whimpers and obeys*", + "weight": 10, + "is_action": true + }, + { + "text": "P-please don't hurt me...", + "weight": 10 + }, + { + "text": "I-I'll do it! Don't be angry!", + "weight": 8 + } + ] + }, + { + "id": "action.collar_on", + "conditions": {}, + "variants": [ + { + "text": "*freezes in terror*", + "weight": 10, + "is_action": true + }, + { + "text": "N-no... please no...", + "weight": 10 + }, + { + "text": "*tears stream down face*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.scold", + "conditions": {}, + "variants": [ + { + "text": "*whimpers* S-sorry!", + "weight": 10, + "is_action": true + }, + { + "text": "I d-didn't mean it!", + "weight": 10 + }, + { + "text": "*covers face* P-please...", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "action.threaten", + "conditions": {}, + "variants": [ + { + "text": "*shrieks* D-don't!", + "weight": 10, + "is_action": true + }, + { + "text": "*cowers in terror*", + "weight": 10, + "is_action": true + }, + { + "text": "I-I promise!", + "weight": 8 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/timid/capture.json b/src/main/resources/data/tiedup/dialogue/en_us/timid/capture.json new file mode 100644 index 0000000..00fdff8 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/timid/capture.json @@ -0,0 +1,54 @@ +{ + "category": "capture", + "personality": "TIMID", + "description": "Fearful capture reactions - extreme panic, crying, begging", + "entries": [ + { + "id": "capture.panic", + "variants": [ + { "text": "*screams in terror*", "weight": 10, "is_action": true }, + { "text": "N-NO! Please! HELP!", "weight": 10 }, + { "text": "*cries hysterically*", "weight": 8, "is_action": true }, + { "text": "Someone! Anyone! HELP ME!", "weight": 8 }, + { "text": "*frozen in fear*", "weight": 6, "is_action": true } + ] + }, + { + "id": "capture.flee", + "variants": [ + { "text": "*runs blindly in panic*", "weight": 10, "is_action": true }, + { "text": "NO NO NO NO!", "weight": 10 }, + { "text": "*stumbles while fleeing*", "weight": 8, "is_action": true }, + { "text": "Please let me go!", "weight": 8 } + ] + }, + { + "id": "capture.captured", + "variants": [ + { "text": "*whimpers softly*", "weight": 10, "is_action": true }, + { "text": "P-please... don't hurt me...", "weight": 10 }, + { "text": "*shakes uncontrollably*", "weight": 8, "is_action": true }, + { "text": "I-I'll be good... just please...", "weight": 8 }, + { "text": "*curls up in terror*", "weight": 6, "is_action": true } + ] + }, + { + "id": "capture.freed", + "variants": [ + { "text": "*collapses in relief, sobbing*", "weight": 10, "is_action": true }, + { "text": "Th-thank you... thank you so much...", "weight": 10 }, + { "text": "*clings to rescuer*", "weight": 8, "is_action": true }, + { "text": "I-I was so scared...", "weight": 8 } + ] + }, + { + "id": "capture.call_for_help", + "variants": [ + { "text": "{player}! P-please help me!", "weight": 10 }, + { "text": "{player}! I'm so scared!", "weight": 10 }, + { "text": "*cries out desperately* {player}!", "weight": 8, "is_action": true }, + { "text": "{player}! Please... anyone...", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/timid/commands.json b/src/main/resources/data/tiedup/dialogue/en_us/timid/commands.json new file mode 100644 index 0000000..e0610af --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/timid/commands.json @@ -0,0 +1,378 @@ +{ + "category": "commands", + "personality": "TIMID", + "description": "Fearful responses - stuttering, trembling, whispering", + "entries": [ + { + "id": "command.follow.accept", + "variants": [ + { + "text": "Y-yes... I'll follow...", + "weight": 10 + }, + { + "text": "*flinches and nods quickly*", + "weight": 10, + "is_action": true + }, + { + "text": "O-okay... please don't hurt me...", + "weight": 8 + }, + { + "text": "*trembles but obeys*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.follow.refuse", + "variants": [ + { + "text": "N-no... please... I'm scared...", + "weight": 10 + }, + { + "text": "*shrinks back in fear*", + "weight": 10, + "is_action": true + }, + { + "text": "I-I can't... I'm too afraid...", + "weight": 8 + } + ] + }, + { + "id": "command.follow.hesitate", + "variants": [ + { + "text": "*whimpers but starts following*", + "weight": 10, + "is_action": true + }, + { + "text": "I-if you say so...", + "weight": 10 + }, + { + "text": "*nervously takes a step forward*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.stay.accept", + "variants": [ + { + "text": "Y-yes, I'll stay here...", + "weight": 10 + }, + { + "text": "*nods fearfully*", + "weight": 10, + "is_action": true + }, + { + "text": "I-I won't move... I promise...", + "weight": 8 + } + ] + }, + { + "id": "command.stay.refuse", + "variants": [ + { + "text": "P-please don't leave me alone...", + "weight": 10 + }, + { + "text": "*whimpers in fear*", + "weight": 10, + "is_action": true + }, + { + "text": "I-I'm scared to be alone...", + "weight": 8 + } + ] + }, + { + "id": "command.come.accept", + "variants": [ + { + "text": "*scurries over quickly*", + "weight": 10, + "is_action": true + }, + { + "text": "Y-yes! Coming!", + "weight": 10 + }, + { + "text": "*hurries over with small steps*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.sit.accept", + "variants": [ + { + "text": "*quickly sits down*", + "weight": 10, + "is_action": true + }, + { + "text": "Y-yes...", + "weight": 10 + }, + { + "text": "*sits and trembles slightly*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.sit.refuse", + "variants": [ + { + "text": "I-I'm too nervous to sit still...", + "weight": 10 + }, + { + "text": "*shifts anxiously*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.kneel.accept", + "variants": [ + { + "text": "*drops to knees immediately*", + "weight": 10, + "is_action": true + }, + { + "text": "Y-yes, of course...", + "weight": 10 + }, + { + "text": "*kneels while shaking*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.kneel.refuse", + "variants": [ + { + "text": "P-please... I'm already so scared...", + "weight": 10 + }, + { + "text": "*trembles too much to comply*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.heel.accept", + "variants": [ + { + "text": "*stays close, seeking protection*", + "weight": 10, + "is_action": true + }, + { + "text": "I-I'll stay right beside you...", + "weight": 10 + } + ] + }, + { + "id": "command.patrol.accept", + "variants": [ + { + "text": "I-I'll try... but I'm scared...", + "weight": 10 + }, + { + "text": "*nervously begins patrolling*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.patrol.refuse", + "variants": [ + { + "text": "I-I can't... what if something happens?", + "weight": 10 + }, + { + "text": "*too frightened to patrol alone*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.guard.accept", + "variants": [ + { + "text": "I-I'll watch... I'll try to be brave...", + "weight": 10 + }, + { + "text": "*takes up position, trembling*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.guard.refuse", + "variants": [ + { + "text": "I-I'm not brave enough...", + "weight": 10 + }, + { + "text": "*shrinks back in fear*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.defend.accept", + "variants": [ + { + "text": "I-I'll try to protect you...", + "weight": 10 + }, + { + "text": "*stands shakily in front of {player}*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.defend.refuse", + "variants": [ + { + "text": "I-I'm too scared... I'm sorry...", + "weight": 10 + }, + { + "text": "*cowers in fear*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.attack.refuse", + "variants": [ + { + "text": "I-I can't hurt anyone!", + "weight": 10 + }, + { + "text": "*trembles and refuses*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.capture.refuse", + "variants": [ + { + "text": "N-no! I know how scary that is...", + "weight": 10 + }, + { + "text": "*shakes head frantically*", + "weight": 10, + "is_action": true + } + ] + }, + { + "id": "command.generic.refuse", + "variants": [ + { + "text": "P-please... I can't...", + "weight": 10 + }, + { + "text": "*trembles and backs away*", + "weight": 10, + "is_action": true + }, + { + "text": "I-I'm too scared...", + "weight": 8 + } + ] + }, + { + "id": "command.generic.accept", + "variants": [ + { + "text": "Y-yes... I'll do it...", + "weight": 10 + }, + { + "text": "*nods fearfully*", + "weight": 10, + "is_action": true + }, + { + "text": "O-okay...", + "weight": 8 + }, + { + "text": "*trembles but complies*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "command.generic.hesitate", + "variants": [ + { + "text": "*whimpers uncertainly*", + "weight": 10, + "is_action": true + }, + { + "text": "I-I'm not sure... but okay...", + "weight": 10 + }, + { + "text": "*hesitates with trembling hands*", + "weight": 8, + "is_action": true + }, + { + "text": "M-maybe... if I must...", + "weight": 8 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/timid/conversation.json b/src/main/resources/data/tiedup/dialogue/en_us/timid/conversation.json new file mode 100644 index 0000000..6a56d52 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/timid/conversation.json @@ -0,0 +1,239 @@ +{ + "category": "conversation", + "entries": [ + { + "id": "conversation.compliment", + "conditions": {}, + "variants": [ + { "text": "*blushes bright red* O-oh! I... r-really? M-me...?", "weight": 10, "is_action": true }, + { "text": "Th-thank you... *hides face* I d-don't know what to say...", "weight": 10 }, + { "text": "*squeaks and looks away* Y-you're just saying that...", "weight": 8, "is_action": true }, + { "text": "I-I'm not... I m-mean... th-thank you...", "weight": 6 }, + { "text": "*covers face with hands* P-please don't look at me...", "weight": 8, "is_action": true }, + { "text": "W-why would you s-say something n-nice to me...?", "weight": 7 }, + { "text": "*shrinks smaller* I-I don't deserve that...", "weight": 7, "is_action": true }, + { "text": "I-is this a t-trick...? I-I don't understand...", "weight": 6 }, + { "text": "*voice barely audible* O-oh... th-that's... um...", "weight": 6 }, + { "text": "*peeks through fingers* Y-you mean it...?", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.comfort", + "conditions": { "mood_max": 50 }, + "variants": [ + { "text": "*sniffles* R-really...? It's... it's okay...?", "weight": 10, "is_action": true }, + { "text": "Th-thank you... *wipes eyes* I w-was so scared...", "weight": 10 }, + { "text": "*trembles but calms slightly* Y-you're being... k-kind...", "weight": 8, "is_action": true }, + { "text": "I... I needed to hear that... *hiccups*", "weight": 6 }, + { "text": "*crying softens* M-maybe it will be okay...?", "weight": 8, "is_action": true }, + { "text": "I-I was so a-alone... th-thank you...", "weight": 7 }, + { "text": "*reaches out hesitantly then pulls back* I-I...", "weight": 7, "is_action": true }, + { "text": "N-no one's ever... b-been nice when I'm s-sad...", "weight": 6 }, + { "text": "*shaky breath* I w-want to believe you...", "weight": 6 }, + { "text": "*clutches at comfort* D-don't leave me a-alone...", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.praise", + "conditions": { "training_min": "HESITANT" }, + "variants": [ + { "text": "*eyes widen in surprise* I-I did good? R-really?", "weight": 10, "is_action": true }, + { "text": "Y-you're... pleased? I w-was so worried I'd mess up...", "weight": 10 }, + { "text": "*small hopeful smile* I-I tried my best... th-thank you...", "weight": 8, "is_action": true }, + { "text": "O-oh... I... *fidgets happily* th-that means a lot...", "weight": 6 }, + { "text": "*looks up with watery eyes* I-I did something r-right...?", "weight": 8, "is_action": true }, + { "text": "Y-you're not angry...? I w-was so scared...", "weight": 7 }, + { "text": "*timid smile appears* I-I'll try to k-keep doing good...", "weight": 7, "is_action": true }, + { "text": "I-I never thought I'd h-hear praise here...", "weight": 6 }, + { "text": "*voice trembles with relief* Th-thank you... thank you...", "weight": 6 }, + { "text": "*almost disbelieving* Y-you really m-mean it...?", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.scold", + "conditions": {}, + "variants": [ + { "text": "*flinches violently and whimpers* I-I'm sorry! I'm s-sorry!", "weight": 10, "is_action": true }, + { "text": "P-please don't be angry! I-I didn't mean to! *cowers*", "weight": 10 }, + { "text": "*tears streaming* I'll d-do better! I p-promise!", "weight": 8, "is_action": true }, + { "text": "*curls into a ball* D-don't hurt me... please...", "weight": 6, "is_action": true }, + { "text": "*sobbing* I-I knew I'd m-mess up! I always do!", "weight": 8, "is_action": true }, + { "text": "I-I'm worthless... I kn-know... I'm s-sorry...", "weight": 7 }, + { "text": "*covers head with arms* N-not the face... please...", "weight": 7, "is_action": true }, + { "text": "*shaking uncontrollably* I-I'll fix it! I-I'll fix it!", "weight": 6 }, + { "text": "*wails* I t-tried! I really t-tried!", "weight": 6, "is_action": true }, + { "text": "P-please forgive me! P-please! I'll be g-good!", "weight": 6 } + ] + }, + { + "id": "conversation.threaten", + "conditions": {}, + "variants": [ + { "text": "*screams and scrambles backward* N-NO! Please! I-I'll do anything!", "weight": 10, "is_action": true }, + { "text": "*sobbing uncontrollably* P-please d-don't hurt me! Please!", "weight": 10, "is_action": true }, + { "text": "*freezes in terror, shaking violently*", "weight": 8, "is_action": true }, + { "text": "I-I'm sorry! Wh-whatever I did, I'm s-sorry!", "weight": 6 }, + { "text": "*hyperventilating* No no no no please no...", "weight": 8, "is_action": true }, + { "text": "*collapses in fear* I c-can't... I c-can't...", "weight": 7, "is_action": true }, + { "text": "*high-pitched whimper* P-please have mercy...", "weight": 7 }, + { "text": "*too terrified to move* ...", "weight": 6, "is_action": true }, + { "text": "*shields face* N-not again... please n-not again...", "weight": 6, "is_action": true }, + { "text": "*voice gone, just silent terror*", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.tease", + "conditions": {}, + "variants": [ + { "text": "*face turns crimson* S-stop... th-that's embarrassing...", "weight": 10, "is_action": true }, + { "text": "P-please don't tease me... *hides behind hands*", "weight": 10 }, + { "text": "*whimpers* Wh-why are you being m-mean...?", "weight": 8, "is_action": true }, + { "text": "I-I don't like it when you l-laugh at me...", "weight": 6 }, + { "text": "*tears up* A-are you making f-fun of me...?", "weight": 8, "is_action": true }, + { "text": "*tries to make self invisible* P-please stop...", "weight": 7, "is_action": true }, + { "text": "I-I know I'm p-pathetic... you d-don't have to say it...", "weight": 7 }, + { "text": "*curls up defensively* I-I'm sorry I'm l-laughable...", "weight": 6, "is_action": true }, + { "text": "*voice breaks* D-did I do something wrong...?", "weight": 6 }, + { "text": "*hides face in knees* I-I can't take being m-mocked...", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_min": 60 }, + "variants": [ + { "text": "*fidgets nervously* I-I'm... okay, I think...?", "weight": 10, "is_action": true }, + { "text": "B-better... thank you for a-asking...", "weight": 10 }, + { "text": "*small voice* N-not too scared right now...", "weight": 8 }, + { "text": "O-okay... as long as you're n-not angry...", "weight": 6 }, + { "text": "*looks around nervously* I-I think I'm okay...?", "weight": 8, "is_action": true }, + { "text": "N-not as scared as b-before...", "weight": 7 }, + { "text": "*whispers* A l-little better today...", "weight": 7 }, + { "text": "I-I haven't cried in a while... th-that's good...?", "weight": 6 }, + { "text": "*tiny smile* M-maybe things will be okay...?", "weight": 6, "is_action": true }, + { "text": "S-surviving... I th-think...", "weight": 6 } + ] + }, + { + "id": "conversation.how_are_you", + "conditions": { "mood_max": 59 }, + "variants": [ + { "text": "*trembles* S-scared... I'm always s-scared...", "weight": 10, "is_action": true }, + { "text": "N-not good... *hugs knees* everything is s-scary...", "weight": 10 }, + { "text": "*voice barely audible* I d-don't know... confused...", "weight": 8 }, + { "text": "*whimpers* I j-just want to stop being afraid...", "weight": 6, "is_action": true }, + { "text": "*tears forming* E-everything hurts...", "weight": 8, "is_action": true }, + { "text": "I-I can't stop sh-shaking...", "weight": 7 }, + { "text": "*small voice* T-terrified... all the t-time...", "weight": 7 }, + { "text": "I d-don't know what's w-wrong with me...", "weight": 6 }, + { "text": "*curled up tight* I-I want to go h-home...", "weight": 6, "is_action": true }, + { "text": "S-so scared I c-can barely breathe...", "weight": 6 } + ] + }, + { + "id": "conversation.whats_wrong", + "conditions": { "mood_max": 40 }, + "variants": [ + { "text": "*bursts into tears* E-everything! I'm so s-scared all the time!", "weight": 10, "is_action": true }, + { "text": "I-I don't know what you w-want from me! *sobs*", "weight": 10 }, + { "text": "*hiccuping* I c-can't... I c-can't do this...", "weight": 8, "is_action": true }, + { "text": "P-please just tell me what to d-do so you won't hurt me...", "weight": 6 }, + { "text": "*wailing* E-everything is t-too much!", "weight": 8, "is_action": true }, + { "text": "I-I'm so tired of being s-scared... *sobs*", "weight": 7 }, + { "text": "*rocking back and forth* I c-can't take this anymore...", "weight": 7, "is_action": true }, + { "text": "I d-don't understand anything! Wh-why is this happening?!", "weight": 6 }, + { "text": "*completely breaking down* I w-want it to stop! Please!", "weight": 6, "is_action": true }, + { "text": "*hyperventilating through sobs* I c-can't breathe... c-can't...", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.refusal.cooldown", + "conditions": {}, + "variants": [ + { "text": "*shrinks back* W-we just talked... c-can I have a moment...?", "weight": 10, "is_action": true }, + { "text": "I-I need to... to c-calm down first... please...", "weight": 10 }, + { "text": "*anxiously fidgets* N-not yet... I'm still nervous...", "weight": 8, "is_action": true }, + { "text": "P-please... I need a m-minute... I'm shaking...", "weight": 7 }, + { "text": "*voice tiny* I-I can't... not yet...", "weight": 7, "is_action": true }, + { "text": "M-my heart is still r-racing... please...", "weight": 6 }, + { "text": "*wraps arms around self* I n-need time...", "weight": 6, "is_action": true }, + { "text": "I-I'm sorry... I j-just can't right now...", "weight": 6 }, + { "text": "*trembling* G-give me a moment... please...", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.low_mood", + "conditions": {}, + "variants": [ + { "text": "*curled up, crying silently* I c-can't... please...", "weight": 10, "is_action": true }, + { "text": "*whimpers* Too sad... leave me alone... please...", "weight": 10, "is_action": true }, + { "text": "*barely looks up, eyes red* I h-have nothing left...", "weight": 8 }, + { "text": "*shaking with silent sobs* P-please... just go...", "weight": 7, "is_action": true }, + { "text": "I c-can't even find words... e-everything hurts...", "weight": 7 }, + { "text": "*completely withdrawn* ...", "weight": 6, "is_action": true }, + { "text": "*tearful whisper* L-leave me alone... please...", "weight": 6 }, + { "text": "*too sad to even look up*", "weight": 6, "is_action": true }, + { "text": "I-I'm broken... I c-can't talk...", "weight": 6 } + ] + }, + { + "id": "conversation.refusal.resentment", + "conditions": {}, + "variants": [ + { "text": "*flinches away* Y-you've hurt me too much... p-please go...", "weight": 10, "is_action": true }, + { "text": "*tears of hurt and fear* I c-can't trust you anymore...", "weight": 10, "is_action": true }, + { "text": "*turns away, trembling* N-no... just... no...", "weight": 8, "is_action": true }, + { "text": "*won't look at you* Y-you scared me too b-badly...", "weight": 7, "is_action": true }, + { "text": "I-I'm too hurt... p-please understand...", "weight": 7 }, + { "text": "*crying quietly* I c-can't... not after what you d-did...", "weight": 6, "is_action": true }, + { "text": "*shrinking away* D-don't come closer... please...", "weight": 6, "is_action": true }, + { "text": "Y-you broke something in m-me... I need time...", "weight": 6 }, + { "text": "*silent tears, refusing to engage*", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.refusal.fear", + "conditions": {}, + "variants": [ + { "text": "*paralyzed with terror, can't even speak*", "weight": 10, "is_action": true }, + { "text": "*hyperventilating* S-stay away! Stay away!", "weight": 10, "is_action": true }, + { "text": "*shaking so hard teeth chatter* P-p-please...", "weight": 8, "is_action": true }, + { "text": "*frozen, only eyes moving in panic*", "weight": 7, "is_action": true }, + { "text": "*scrambles into corner* D-d-don't! Please!", "weight": 7, "is_action": true }, + { "text": "*whimpering too hard to form words*", "weight": 6, "is_action": true }, + { "text": "*curled in fetal position, shaking*", "weight": 6, "is_action": true }, + { "text": "*high-pitched keening noise of pure terror*", "weight": 6, "is_action": true }, + { "text": "*has stopped responding entirely, shut down in fear*", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.refusal.exhausted", + "conditions": {}, + "variants": [ + { "text": "*barely conscious* S-so tired... c-can't keep eyes open...", "weight": 10, "is_action": true }, + { "text": "I-I'm sorry... I n-need to sleep... please let me...", "weight": 10 }, + { "text": "*yawns and curls up smaller* Too t-tired to be scared...", "weight": 8, "is_action": true }, + { "text": "*drifting off* P-please don't hurt me while I s-sleep...", "weight": 7 }, + { "text": "*exhaustion winning over fear* C-can't stay awake...", "weight": 7, "is_action": true }, + { "text": "*collapses* N-no more... need rest...", "weight": 6, "is_action": true }, + { "text": "I-I'll talk later... p-promise... just sleep...", "weight": 6 }, + { "text": "*mumbling* S-so tired... so very tired...", "weight": 6 }, + { "text": "*falls asleep mid-word*", "weight": 6, "is_action": true } + ] + }, + { + "id": "conversation.refusal.tired", + "conditions": {}, + "variants": [ + { "text": "*exhausted voice* I c-can't talk anymore... my head hurts...", "weight": 10, "is_action": true }, + { "text": "P-please... no more questions... I'm so tired...", "weight": 10 }, + { "text": "*wilting* I've t-talked so much... can we stop...?", "weight": 8, "is_action": true }, + { "text": "*barely whispers* I n-need quiet... please...", "weight": 7 }, + { "text": "M-my brain can't take anymore...", "weight": 7 }, + { "text": "*slumping* W-words are too hard right now...", "weight": 6, "is_action": true }, + { "text": "I-I've used all my courage on t-talking...", "weight": 6 }, + { "text": "*voice fading* J-just a break... please...", "weight": 6 }, + { "text": "*too tired to even be properly scared* L-later... okay...?", "weight": 6 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/timid/discipline.json b/src/main/resources/data/tiedup/dialogue/en_us/timid/discipline.json new file mode 100644 index 0000000..d6eabe9 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/timid/discipline.json @@ -0,0 +1,174 @@ +{ + "category": "discipline", + "entries": [ + { + "id": "discipline.legitimate.accept", + "conditions": {}, + "variants": [ + { + "text": "I-I'm sorry! I won't do it again!", + "weight": 10 + }, + { + "text": "*cries and apologizes*", + "weight": 10, + "is_action": true + }, + { + "text": "P-please forgive me!", + "weight": 8 + } + ] + }, + { + "id": "discipline.gratuitous.low_resentment", + "conditions": { + "resentment_max": 30 + }, + "variants": [ + { + "text": "*sobs* W-what did I do wrong?", + "weight": 10 + }, + { + "text": "*trembles uncontrollably*", + "weight": 10, + "is_action": true + }, + { + "text": "P-please, I'm trying to be good!", + "weight": 8 + } + ] + }, + { + "id": "discipline.gratuitous.high_resentment", + "conditions": { + "resentment_min": 61 + }, + "variants": [ + { + "text": "*withdraws into self*", + "weight": 10, + "is_action": true + }, + { + "text": "*stops reacting*", + "weight": 10, + "is_action": true + }, + { + "text": "...", + "weight": 8 + } + ] + }, + { + "id": "discipline.fear_increase", + "conditions": {}, + "variants": [ + { + "text": "*paralyzed with terror*", + "weight": 10, + "is_action": true + }, + { + "text": "N-no more! Please!", + "weight": 10 + }, + { + "text": "*can't stop shaking*", + "weight": 8, + "is_action": true + } + ] + }, + { + "id": "discipline.praise", + "conditions": {}, + "variants": [ + { + "text": "R-really? I... I did okay?", + "weight": 10 + }, + { + "text": "*trembling smile* Th-thank you...", + "weight": 10, + "is_action": true + }, + { + "text": "I w-was so worried I'd mess up...", + "weight": 8 + }, + { + "text": "*wipes eyes* I'm j-just glad you're not mad.", + "weight": 8, + "is_action": true + }, + { + "text": "O-oh... that's... nice of you...", + "weight": 6 + } + ] + }, + { + "id": "discipline.scold", + "conditions": {}, + "variants": [ + { + "text": "*flinches violently* I-I'm sorry! Please don't hit me!", + "weight": 10, + "is_action": true + }, + { + "text": "I didn't mean to! I p-promise!", + "weight": 10 + }, + { + "text": "*tears well up* I'm s-stupid... I know...", + "weight": 8, + "is_action": true + }, + { + "text": "P-please forgive me... *sobs*", + "weight": 8 + }, + { + "text": "*cowers* I'll be good! I'll be good!", + "weight": 6, + "is_action": true + } + ] + }, + { + "id": "discipline.threaten", + "conditions": {}, + "variants": [ + { + "text": "*screams softly* N-no! Please!", + "weight": 10, + "is_action": true + }, + { + "text": "I'll do anything! Just d-don't!", + "weight": 10 + }, + { + "text": "*shaking uncontrollably* P-please... mercy...", + "weight": 8, + "is_action": true + }, + { + "text": "*hyperventilating* I'm sorry I'm sorry I'm sorry!", + "weight": 8, + "is_action": true + }, + { + "text": "*curls into a ball* Don't hurt me...", + "weight": 6, + "is_action": true + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/dialogue/en_us/timid/fear.json b/src/main/resources/data/tiedup/dialogue/en_us/timid/fear.json new file mode 100644 index 0000000..55df8dd --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/timid/fear.json @@ -0,0 +1,49 @@ +{ + "category": "fear", + "entries": [ + { + "id": "fear.nervous", + "conditions": {}, + "variants": [ + { "text": "*shrinks back*", "weight": 10, "is_action": true }, + { "text": "P-please...", "weight": 10 }, + { "text": "*can barely look at you*", "weight": 8, "is_action": true }, + { "text": "I-I'm scared...", "weight": 8 }, + { "text": "*makes herself as small as possible*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.afraid", + "conditions": {}, + "variants": [ + { "text": "*whimpers softly*", "weight": 10, "is_action": true }, + { "text": "D-don't hurt me...", "weight": 10 }, + { "text": "*curls up defensively*", "weight": 8, "is_action": true }, + { "text": "I-I'm so scared...", "weight": 8 }, + { "text": "*tears forming*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.terrified", + "conditions": {}, + "variants": [ + { "text": "*screams and tries to flee*", "weight": 10, "is_action": true }, + { "text": "NO! Stay away!", "weight": 10 }, + { "text": "*crying hysterically*", "weight": 8, "is_action": true }, + { "text": "*frozen, unable to move*", "weight": 8, "is_action": true }, + { "text": "*hyperventilating*", "weight": 6, "is_action": true } + ] + }, + { + "id": "fear.traumatized", + "conditions": {}, + "variants": [ + { "text": "*catatonic with fear*", "weight": 10, "is_action": true }, + { "text": "*unresponsive, shut down*", "weight": 10, "is_action": true }, + { "text": "*staring blankly*", "weight": 8, "is_action": true }, + { "text": "*broken beyond words*", "weight": 8, "is_action": true }, + { "text": "*silent tears*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/timid/home.json b/src/main/resources/data/tiedup/dialogue/en_us/timid/home.json new file mode 100644 index 0000000..30eead9 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/timid/home.json @@ -0,0 +1,41 @@ +{ + "category": "home", + "entries": [ + { + "id": "home.assigned.pet_bed", + "conditions": {}, + "variants": [ + { "text": "Th-thank you... it's cozy.", "weight": 10 }, + { "text": "*curls up small*", "weight": 10, "is_action": true }, + { "text": "I-I like having my own spot.", "weight": 8 } + ] + }, + { + "id": "home.assigned.bed", + "conditions": {}, + "variants": [ + { "text": "A-a real bed? For me?", "weight": 10 }, + { "text": "*can't believe it*", "weight": 10, "is_action": true }, + { "text": "Thank you so much!", "weight": 8 } + ] + }, + { + "id": "home.destroyed.pet_bed", + "conditions": {}, + "variants": [ + { "text": "*sobs* M-my safe place...", "weight": 10 }, + { "text": "*trembles with nowhere to hide*", "weight": 10, "is_action": true }, + { "text": "W-where will I go?", "weight": 8 } + ] + }, + { + "id": "home.return.content", + "conditions": {}, + "variants": [ + { "text": "*hides in familiar spot*", "weight": 10, "is_action": true }, + { "text": "S-safe here...", "weight": 10 }, + { "text": "*curls up tight*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/timid/idle.json b/src/main/resources/data/tiedup/dialogue/en_us/timid/idle.json new file mode 100644 index 0000000..a6baee0 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/timid/idle.json @@ -0,0 +1,51 @@ +{ + "category": "idle", + "personality": "TIMID", + "description": "Timid idle behaviors and greetings", + "entries": [ + { + "id": "idle.free", + "variants": [ + { "text": "*glances around nervously*", "weight": 10, "is_action": true }, + { "text": "I hope nothing bad happens...", "weight": 10 }, + { "text": "*jumps at a small sound*", "weight": 8, "is_action": true }, + { "text": "*hugs themselves for comfort*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.greeting", + "variants": [ + { "text": "*waves shyly*", "weight": 10, "is_action": true }, + { "text": "H-hello...", "weight": 10 }, + { "text": "*avoids eye contact* Oh, um, hi...", "weight": 8 }, + { "text": "*squeaks in surprise* Oh! H-hello!", "weight": 6 } + ] + }, + { + "id": "idle.goodbye", + "variants": [ + { "text": "*waves timidly*", "weight": 10, "is_action": true }, + { "text": "G-goodbye...", "weight": 10 }, + { "text": "*nods nervously*", "weight": 8, "is_action": true } + ] + }, + { + "id": "idle.captive", + "variants": [ + { "text": "*trembles quietly*", "weight": 10, "is_action": true }, + { "text": "P-please... someone...", "weight": 10 }, + { "text": "*cries silently*", "weight": 8, "is_action": true }, + { "text": "*too scared to move*", "weight": 8, "is_action": true } + ] + }, + { + "id": "personality.hint", + "variants": [ + { "text": "*flinches at sudden movements*", "weight": 10, "is_action": true }, + { "text": "*avoids eye contact*", "weight": 10, "is_action": true }, + { "text": "*trembles slightly*", "weight": 8, "is_action": true }, + { "text": "*speaks in barely a whisper*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/timid/leash.json b/src/main/resources/data/tiedup/dialogue/en_us/timid/leash.json new file mode 100644 index 0000000..1a08c6b --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/timid/leash.json @@ -0,0 +1,42 @@ +{ + "category": "leash", + "entries": [ + { + "id": "leash.attached", + "conditions": {}, + "variants": [ + { "text": "O-oh... okay...", "weight": 10 }, + { "text": "*trembles slightly*", "weight": 10, "is_action": true }, + { "text": "I-I'll follow...", "weight": 8 }, + { "text": "*looks down nervously*", "weight": 6, "is_action": true } + ] + }, + { + "id": "leash.removed", + "conditions": {}, + "variants": [ + { "text": "Th-thank you...", "weight": 10 }, + { "text": "*relaxes a little*", "weight": 10, "is_action": true }, + { "text": "*quietly relieved*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.walking.content", + "conditions": {}, + "variants": [ + { "text": "*follows quietly*", "weight": 10, "is_action": true }, + { "text": "I-I'm right behind you...", "weight": 10 }, + { "text": "*stays close for safety*", "weight": 8, "is_action": true } + ] + }, + { + "id": "leash.pulled", + "conditions": {}, + "variants": [ + { "text": "*stumbles forward anxiously*", "weight": 10, "is_action": true }, + { "text": "S-sorry! I'm coming!", "weight": 10 }, + { "text": "*hurries to avoid another tug*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/timid/mood.json b/src/main/resources/data/tiedup/dialogue/en_us/timid/mood.json new file mode 100644 index 0000000..8ba18f4 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/timid/mood.json @@ -0,0 +1,44 @@ +{ + "category": "mood", + "personality": "TIMID", + "description": "Timid mood expressions", + "entries": [ + { + "id": "mood.happy", + "conditions": { "mood_min": 70 }, + "variants": [ + { "text": "*manages a small, nervous smile*", "weight": 10, "is_action": true }, + { "text": "I-I feel a little better...", "weight": 10 }, + { "text": "*relaxes slightly, still alert*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.neutral", + "conditions": { "mood_min": 40, "mood_max": 69 }, + "variants": [ + { "text": "*sits quietly, watching nervously*", "weight": 10, "is_action": true }, + { "text": "*flinches at every sound*", "weight": 10, "is_action": true }, + { "text": "*avoids eye contact*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.sad", + "conditions": { "mood_min": 10, "mood_max": 39 }, + "variants": [ + { "text": "*sniffles quietly*", "weight": 10, "is_action": true }, + { "text": "W-why is this happening to me...?", "weight": 10 }, + { "text": "*tries to make themselves small*", "weight": 8, "is_action": true } + ] + }, + { + "id": "mood.miserable", + "conditions": { "mood_max": 9 }, + "variants": [ + { "text": "*shakes with silent sobs*", "weight": 10, "is_action": true }, + { "text": "I-I just want to go home...", "weight": 10 }, + { "text": "*has given up hope, stares blankly*", "weight": 8, "is_action": true }, + { "text": "P-please... just let me go...", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/timid/needs.json b/src/main/resources/data/tiedup/dialogue/en_us/timid/needs.json new file mode 100644 index 0000000..3841ed3 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/timid/needs.json @@ -0,0 +1,49 @@ +{ + "category": "needs", + "personality": "TIMID", + "description": "Fearful expressions of needs", + "entries": [ + { + "id": "needs.hungry", + "variants": [ + { "text": "*stomach growls, flinches in embarrassment*", "weight": 10, "is_action": true }, + { "text": "I-I'm so hungry... please...", "weight": 10 }, + { "text": "*too scared to ask for food*", "weight": 8, "is_action": true }, + { "text": "C-could I please have something to eat...?", "weight": 8 } + ] + }, + { + "id": "needs.tired", + "variants": [ + { "text": "*eyes drooping with fear and exhaustion*", "weight": 10, "is_action": true }, + { "text": "I-I'm so tired... but I'm afraid to sleep...", "weight": 10 }, + { "text": "*yawns nervously*", "weight": 8, "is_action": true }, + { "text": "P-please... I need rest...", "weight": 8 } + ] + }, + { + "id": "needs.uncomfortable", + "variants": [ + { "text": "*winces in pain but stays quiet*", "weight": 10, "is_action": true }, + { "text": "I-it hurts... but I don't want to complain...", "weight": 10 }, + { "text": "*shifts painfully, afraid to ask for help*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.dignity_low", + "variants": [ + { "text": "*hides face in shame*", "weight": 10, "is_action": true }, + { "text": "T-this is so humiliating...", "weight": 10 }, + { "text": "*cries quietly from embarrassment*", "weight": 8, "is_action": true } + ] + }, + { + "id": "needs.satisfied", + "variants": [ + { "text": "*looks up gratefully, still nervous*", "weight": 10, "is_action": true }, + { "text": "Th-thank you...", "weight": 10 }, + { "text": "*sighs with relief*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/timid/personality.json b/src/main/resources/data/tiedup/dialogue/en_us/timid/personality.json new file mode 100644 index 0000000..1b5192f --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/timid/personality.json @@ -0,0 +1,17 @@ +{ + "category": "personality", + "entries": [ + { + "id": "personality.hint", + "conditions": {}, + "variants": [ + { "text": "*flinches at sudden movements*", "weight": 10, "is_action": true }, + { "text": "*avoids eye contact*", "weight": 10, "is_action": true }, + { "text": "*trembles slightly*", "weight": 8, "is_action": true }, + { "text": "*speaks in barely a whisper*", "weight": 8, "is_action": true }, + { "text": "*shrinks back nervously*", "weight": 6, "is_action": true }, + { "text": "*looks down at their feet*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/timid/reaction.json b/src/main/resources/data/tiedup/dialogue/en_us/timid/reaction.json new file mode 100644 index 0000000..a396bec --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/timid/reaction.json @@ -0,0 +1,60 @@ +{ + "category": "reaction", + "entries": [ + { + "id": "reaction.approach.stranger", + "conditions": {}, + "variants": [ + { "text": "*freezes*", "weight": 10, "is_action": true }, + { "text": "W-who's there?!", "weight": 10 }, + { "text": "*trembles slightly*", "weight": 8, "is_action": true }, + { "text": "P-please don't hurt me...", "weight": 8 }, + { "text": "*shrinks back*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.master", + "conditions": {}, + "variants": [ + { "text": "*looks up nervously*", "weight": 10, "is_action": true }, + { "text": "M-master...", "weight": 10 }, + { "text": "*fidgets anxiously*", "weight": 8, "is_action": true }, + { "text": "Y-you're back...", "weight": 8 }, + { "text": "D-did I do something wrong?", "weight": 6 } + ] + }, + { + "id": "reaction.approach.beloved", + "conditions": {}, + "variants": [ + { "text": "*smiles shyly*", "weight": 10, "is_action": true }, + { "text": "Oh! It's you...", "weight": 10 }, + { "text": "*relaxes visibly*", "weight": 8, "is_action": true }, + { "text": "I-I'm happy you're here.", "weight": 8 }, + { "text": "*waves timidly*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.captor", + "conditions": {}, + "variants": [ + { "text": "*cowers*", "weight": 10, "is_action": true }, + { "text": "P-please...", "weight": 10 }, + { "text": "*whimpers*", "weight": 8, "is_action": true }, + { "text": "W-what do you want?", "weight": 8 }, + { "text": "*trembles in fear*", "weight": 6, "is_action": true } + ] + }, + { + "id": "reaction.approach.enemy", + "conditions": {}, + "variants": [ + { "text": "*panics*", "weight": 10, "is_action": true }, + { "text": "S-stay away!", "weight": 10 }, + { "text": "*backs away frantically*", "weight": 8, "is_action": true }, + { "text": "N-no... please...", "weight": 8 }, + { "text": "*looks for escape*", "weight": 6, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/timid/resentment.json b/src/main/resources/data/tiedup/dialogue/en_us/timid/resentment.json new file mode 100644 index 0000000..3074382 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/timid/resentment.json @@ -0,0 +1,32 @@ +{ + "category": "resentment", + "entries": [ + { + "id": "resentment.none", + "conditions": { "resentment_max": 10 }, + "variants": [ + { "text": "*trusts you*", "weight": 10, "is_action": true }, + { "text": "I-I feel safe with you.", "weight": 10 }, + { "text": "*less afraid*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.building", + "conditions": { "resentment_min": 31, "resentment_max": 50 }, + "variants": [ + { "text": "*flinches slightly*", "weight": 10, "is_action": true }, + { "text": "...", "weight": 10 }, + { "text": "*nervous around you*", "weight": 8, "is_action": true } + ] + }, + { + "id": "resentment.high", + "conditions": { "resentment_min": 71 }, + "variants": [ + { "text": "*terrified but bitter*", "weight": 10, "is_action": true }, + { "text": "*hides hatred behind fear*", "weight": 10, "is_action": true }, + { "text": "*silent resentment*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/timid/struggle.json b/src/main/resources/data/tiedup/dialogue/en_us/timid/struggle.json new file mode 100644 index 0000000..82046f4 --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/timid/struggle.json @@ -0,0 +1,41 @@ +{ + "category": "struggle", + "personality": "TIMID", + "description": "Weak, fearful struggle attempts", + "entries": [ + { + "id": "struggle.attempt", + "variants": [ + { "text": "*weakly tugs at bindings*", "weight": 10, "is_action": true }, + { "text": "*tries to escape quietly, afraid of punishment*", "weight": 10, "is_action": true }, + { "text": "*too scared to struggle hard*", "weight": 8, "is_action": true }, + { "text": "*trembles while testing restraints*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.success", + "variants": [ + { "text": "*gasps in surprise at freedom*", "weight": 10, "is_action": true }, + { "text": "I-I did it...?", "weight": 10 }, + { "text": "*quickly tries to hide*", "weight": 8, "is_action": true } + ] + }, + { + "id": "struggle.failure", + "variants": [ + { "text": "*whimpers in defeat*", "weight": 10, "is_action": true }, + { "text": "I-I can't... they're too tight...", "weight": 10 }, + { "text": "*gives up, crying softly*", "weight": 8, "is_action": true }, + { "text": "It's hopeless...", "weight": 8 } + ] + }, + { + "id": "struggle.exhausted", + "variants": [ + { "text": "*too exhausted and scared to continue*", "weight": 10, "is_action": true }, + { "text": "I-I can't anymore...", "weight": 10 }, + { "text": "*slumps weakly*", "weight": 8, "is_action": true } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/dialogue/en_us/trader/default/sale.json b/src/main/resources/data/tiedup/dialogue/en_us/trader/default/sale.json new file mode 100644 index 0000000..edbc99d --- /dev/null +++ b/src/main/resources/data/tiedup/dialogue/en_us/trader/default/sale.json @@ -0,0 +1,51 @@ +{ + "category": "sale", + "description": "Trader dialogue during captive sales", + "entries": [ + { + "id": "trader.greeting", + "conditions": {}, + "variants": [ + { "text": "Welcome to my establishment.", "weight": 10 }, + { "text": "Looking to buy? I have fine specimens.", "weight": 10 }, + { "text": "Browse at your leisure.", "weight": 8 } + ] + }, + { + "id": "trader.offer", + "conditions": {}, + "variants": [ + { "text": "This one's feisty. {price} gold.", "weight": 10 }, + { "text": "A fine specimen. {price} gold, non-negotiable.", "weight": 10 }, + { "text": "Quality merchandise at {price} gold.", "weight": 8 } + ] + }, + { + "id": "trader.success", + "conditions": {}, + "variants": [ + { "text": "Pleasure doing business.", "weight": 10 }, + { "text": "*counts the gold with a smile*", "is_action": true, "weight": 8 }, + { "text": "Come back anytime.", "weight": 6 } + ] + }, + { + "id": "trader.refuse", + "conditions": {}, + "variants": [ + { "text": "Not enough gold. Come back when you're serious.", "weight": 10 }, + { "text": "I don't do charity.", "weight": 10 }, + { "text": "Quality costs gold.", "weight": 8 } + ] + }, + { + "id": "trader.threat", + "conditions": {}, + "variants": [ + { "text": "You dare attack me in MY camp?", "weight": 10 }, + { "text": "Maid! Deal with this pest!", "weight": 10 }, + { "text": "Big mistake.", "weight": 8 } + ] + } + ] +} diff --git a/src/main/resources/data/tiedup/forge/biome_modifier/damsel_spawn.json b/src/main/resources/data/tiedup/forge/biome_modifier/damsel_spawn.json new file mode 100644 index 0000000..ef68f3b --- /dev/null +++ b/src/main/resources/data/tiedup/forge/biome_modifier/damsel_spawn.json @@ -0,0 +1,10 @@ +{ + "type": "forge:add_spawns", + "biomes": "#minecraft:is_overworld", + "spawners": { + "type": "tiedup:damsel", + "weight": 4, + "minCount": 1, + "maxCount": 2 + } +} diff --git a/src/main/resources/data/tiedup/forge/biome_modifier/kidnapper_archer_spawn.json b/src/main/resources/data/tiedup/forge/biome_modifier/kidnapper_archer_spawn.json new file mode 100644 index 0000000..d48341e --- /dev/null +++ b/src/main/resources/data/tiedup/forge/biome_modifier/kidnapper_archer_spawn.json @@ -0,0 +1,10 @@ +{ + "type": "forge:add_spawns", + "biomes": "#minecraft:is_overworld", + "spawners": { + "type": "tiedup:kidnapper_archer", + "weight": 2, + "minCount": 1, + "maxCount": 1 + } +} diff --git a/src/main/resources/data/tiedup/forge/biome_modifier/kidnapper_elite_spawn.json b/src/main/resources/data/tiedup/forge/biome_modifier/kidnapper_elite_spawn.json new file mode 100644 index 0000000..005ed94 --- /dev/null +++ b/src/main/resources/data/tiedup/forge/biome_modifier/kidnapper_elite_spawn.json @@ -0,0 +1,10 @@ +{ + "type": "forge:add_spawns", + "biomes": "#minecraft:is_overworld", + "spawners": { + "type": "tiedup:kidnapper_elite", + "weight": 1, + "minCount": 1, + "maxCount": 1 + } +} diff --git a/src/main/resources/data/tiedup/forge/biome_modifier/kidnapper_merchant_spawn.json b/src/main/resources/data/tiedup/forge/biome_modifier/kidnapper_merchant_spawn.json new file mode 100644 index 0000000..6c2cb8f --- /dev/null +++ b/src/main/resources/data/tiedup/forge/biome_modifier/kidnapper_merchant_spawn.json @@ -0,0 +1,10 @@ +{ + "type": "forge:add_spawns", + "biomes": "#minecraft:is_overworld", + "spawners": { + "type": "tiedup:kidnapper_merchant", + "weight": 2, + "minCount": 1, + "maxCount": 1 + } +} diff --git a/src/main/resources/data/tiedup/forge/biome_modifier/kidnapper_spawn.json b/src/main/resources/data/tiedup/forge/biome_modifier/kidnapper_spawn.json new file mode 100644 index 0000000..b82f839 --- /dev/null +++ b/src/main/resources/data/tiedup/forge/biome_modifier/kidnapper_spawn.json @@ -0,0 +1,10 @@ +{ + "type": "forge:add_spawns", + "biomes": "#minecraft:is_overworld", + "spawners": { + "type": "tiedup:kidnapper", + "weight": 3, + "minCount": 1, + "maxCount": 1 + } +} diff --git a/src/main/resources/data/tiedup/patchouli_books/guide/book.json b/src/main/resources/data/tiedup/patchouli_books/guide/book.json new file mode 100644 index 0000000..d0370b1 --- /dev/null +++ b/src/main/resources/data/tiedup/patchouli_books/guide/book.json @@ -0,0 +1,23 @@ +{ + "name": "TiedUp! Guide", + "landing_text": "Welcome to the TiedUp! Guide! This comprehensive manual covers all aspects of the mod, from basic restraints to advanced mechanics.", + "version": "1", + "creative_tab": "tiedup.tiedup_tab", + "advancements_tab": "tiedup", + "show_progress": false, + "use_resource_pack": true, + "model": "patchouli:book_brown", + "text_color": "000000", + "header_color": "AA0000", + "nameplate_color": "AA0000", + "link_color": "0000EE", + "link_hover_color": "8800EE", + "progress_bar_color": "AA0000", + "progress_bar_background": "DDDDDD", + "open_sound": "minecraft:item.book.page_turn", + "flip_sound": "minecraft:item.book.page_turn", + "index_icon": "tiedup:ropes", + "pamphlet": false, + "show_toasts": true, + "use_blocky_font": true +} diff --git a/src/main/resources/data/tiedup/recipes/paddle.json b/src/main/resources/data/tiedup/recipes/paddle.json new file mode 100644 index 0000000..2fbf637 --- /dev/null +++ b/src/main/resources/data/tiedup/recipes/paddle.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:crafting_shaped", + "pattern": [ + " X ", + " X ", + " # " + ], + "key": { + "#": { + "item": "minecraft:blaze_rod" + }, + "X": { + "item": "minecraft:blaze_powder" + } + }, + "result": { + "item": "tiedup:paddle", + "count": 1 + } +} diff --git a/src/main/resources/data/tiedup/recipes/tiedup_guide_book.json b/src/main/resources/data/tiedup/recipes/tiedup_guide_book.json new file mode 100644 index 0000000..5887794 --- /dev/null +++ b/src/main/resources/data/tiedup/recipes/tiedup_guide_book.json @@ -0,0 +1,22 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "misc", + "pattern": [ + " R ", + "RBR", + " R " + ], + "key": { + "R": { + "item": "tiedup:ropes" + }, + "B": { + "item": "minecraft:book" + } + }, + "result": { + "item": "patchouli:guide_book", + "count": 1, + "nbt": "{\"patchouli:book\":\"tiedup:guide\"}" + } +} diff --git a/src/main/resources/data/tiedup/skins/damsel/anastasia.json b/src/main/resources/data/tiedup/skins/damsel/anastasia.json new file mode 100644 index 0000000..0374305 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/anastasia.json @@ -0,0 +1,5 @@ +{ + "id": "anastasia", + "hasSlimArms": true, + "defaultName": "Anastasia" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/blizz.json b/src/main/resources/data/tiedup/skins/damsel/blizz.json new file mode 100644 index 0000000..fb16c8f --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/blizz.json @@ -0,0 +1,5 @@ +{ + "id": "blizz", + "hasSlimArms": true, + "defaultName": "Blizz" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/ceras.json b/src/main/resources/data/tiedup/skins/damsel/ceras.json new file mode 100644 index 0000000..412165c --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/ceras.json @@ -0,0 +1,5 @@ +{ + "id": "ceras", + "hasSlimArms": true, + "defaultName": "Ceras" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/creamy.json b/src/main/resources/data/tiedup/skins/damsel/creamy.json new file mode 100644 index 0000000..e3d927c --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/creamy.json @@ -0,0 +1,5 @@ +{ + "id": "creamy", + "hasSlimArms": true, + "defaultName": "Creamy" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/dam_mob_1.json b/src/main/resources/data/tiedup/skins/damsel/dam_mob_1.json new file mode 100644 index 0000000..1755c21 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/dam_mob_1.json @@ -0,0 +1,5 @@ +{ + "id": "dam_mob_1", + "hasSlimArms": false, + "defaultName": "Damsel" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/dam_mob_10.json b/src/main/resources/data/tiedup/skins/damsel/dam_mob_10.json new file mode 100644 index 0000000..7d57aff --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/dam_mob_10.json @@ -0,0 +1,5 @@ +{ + "id": "dam_mob_10", + "hasSlimArms": true, + "defaultName": "Damsel" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/dam_mob_11.json b/src/main/resources/data/tiedup/skins/damsel/dam_mob_11.json new file mode 100644 index 0000000..7c51a40 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/dam_mob_11.json @@ -0,0 +1,5 @@ +{ + "id": "dam_mob_11", + "hasSlimArms": true, + "defaultName": "Damsel" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/dam_mob_12.json b/src/main/resources/data/tiedup/skins/damsel/dam_mob_12.json new file mode 100644 index 0000000..6644578 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/dam_mob_12.json @@ -0,0 +1,5 @@ +{ + "id": "dam_mob_12", + "hasSlimArms": false, + "defaultName": "Damsel" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/dam_mob_13.json b/src/main/resources/data/tiedup/skins/damsel/dam_mob_13.json new file mode 100644 index 0000000..447d13f --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/dam_mob_13.json @@ -0,0 +1,5 @@ +{ + "id": "dam_mob_13", + "hasSlimArms": false, + "defaultName": "Damsel" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/dam_mob_14.json b/src/main/resources/data/tiedup/skins/damsel/dam_mob_14.json new file mode 100644 index 0000000..354c78f --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/dam_mob_14.json @@ -0,0 +1,5 @@ +{ + "id": "dam_mob_14", + "hasSlimArms": false, + "defaultName": "Damsel" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/dam_mob_15.json b/src/main/resources/data/tiedup/skins/damsel/dam_mob_15.json new file mode 100644 index 0000000..8585f25 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/dam_mob_15.json @@ -0,0 +1,5 @@ +{ + "id": "dam_mob_15", + "hasSlimArms": true, + "defaultName": "Damsel" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/dam_mob_16.json b/src/main/resources/data/tiedup/skins/damsel/dam_mob_16.json new file mode 100644 index 0000000..d7a2273 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/dam_mob_16.json @@ -0,0 +1,5 @@ +{ + "id": "dam_mob_16", + "hasSlimArms": false, + "defaultName": "Damsel" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/dam_mob_17.json b/src/main/resources/data/tiedup/skins/damsel/dam_mob_17.json new file mode 100644 index 0000000..cb1e5ec --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/dam_mob_17.json @@ -0,0 +1,5 @@ +{ + "id": "dam_mob_17", + "hasSlimArms": false, + "defaultName": "Damsel" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/dam_mob_18.json b/src/main/resources/data/tiedup/skins/damsel/dam_mob_18.json new file mode 100644 index 0000000..f5dee21 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/dam_mob_18.json @@ -0,0 +1,5 @@ +{ + "id": "dam_mob_18", + "hasSlimArms": false, + "defaultName": "Damsel" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/dam_mob_19.json b/src/main/resources/data/tiedup/skins/damsel/dam_mob_19.json new file mode 100644 index 0000000..6edc906 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/dam_mob_19.json @@ -0,0 +1,5 @@ +{ + "id": "dam_mob_19", + "hasSlimArms": false, + "defaultName": "Damsel" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/dam_mob_2.json b/src/main/resources/data/tiedup/skins/damsel/dam_mob_2.json new file mode 100644 index 0000000..b43a2f4 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/dam_mob_2.json @@ -0,0 +1,5 @@ +{ + "id": "dam_mob_2", + "hasSlimArms": false, + "defaultName": "Damsel" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/dam_mob_3.json b/src/main/resources/data/tiedup/skins/damsel/dam_mob_3.json new file mode 100644 index 0000000..30f3873 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/dam_mob_3.json @@ -0,0 +1,5 @@ +{ + "id": "dam_mob_3", + "hasSlimArms": false, + "defaultName": "Damsel" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/dam_mob_4.json b/src/main/resources/data/tiedup/skins/damsel/dam_mob_4.json new file mode 100644 index 0000000..88e17bb --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/dam_mob_4.json @@ -0,0 +1,5 @@ +{ + "id": "dam_mob_4", + "hasSlimArms": false, + "defaultName": "Damsel" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/dam_mob_5.json b/src/main/resources/data/tiedup/skins/damsel/dam_mob_5.json new file mode 100644 index 0000000..d3f2daa --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/dam_mob_5.json @@ -0,0 +1,5 @@ +{ + "id": "dam_mob_5", + "hasSlimArms": false, + "defaultName": "Damsel" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/dam_mob_6.json b/src/main/resources/data/tiedup/skins/damsel/dam_mob_6.json new file mode 100644 index 0000000..ce83679 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/dam_mob_6.json @@ -0,0 +1,5 @@ +{ + "id": "dam_mob_6", + "hasSlimArms": false, + "defaultName": "Damsel" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/dam_mob_7.json b/src/main/resources/data/tiedup/skins/damsel/dam_mob_7.json new file mode 100644 index 0000000..58316a4 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/dam_mob_7.json @@ -0,0 +1,5 @@ +{ + "id": "dam_mob_7", + "hasSlimArms": false, + "defaultName": "Damsel" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/dam_mob_8.json b/src/main/resources/data/tiedup/skins/damsel/dam_mob_8.json new file mode 100644 index 0000000..eddec7d --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/dam_mob_8.json @@ -0,0 +1,5 @@ +{ + "id": "dam_mob_8", + "hasSlimArms": false, + "defaultName": "Damsel" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/dam_mob_9.json b/src/main/resources/data/tiedup/skins/damsel/dam_mob_9.json new file mode 100644 index 0000000..51d5ce0 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/dam_mob_9.json @@ -0,0 +1,5 @@ +{ + "id": "dam_mob_9", + "hasSlimArms": false, + "defaultName": "Damsel" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/el.json b/src/main/resources/data/tiedup/skins/damsel/el.json new file mode 100644 index 0000000..e1a150d --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/el.json @@ -0,0 +1,5 @@ +{ + "id": "el", + "hasSlimArms": true, + "defaultName": "El" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/fuya_kitty.json b/src/main/resources/data/tiedup/skins/damsel/fuya_kitty.json new file mode 100644 index 0000000..ed5170d --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/fuya_kitty.json @@ -0,0 +1,5 @@ +{ + "id": "fuya_kitty", + "hasSlimArms": true, + "defaultName": "Fuya Kitty" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/glacie.json b/src/main/resources/data/tiedup/skins/damsel/glacie.json new file mode 100644 index 0000000..6ee524a --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/glacie.json @@ -0,0 +1,5 @@ +{ + "id": "glacie", + "hasSlimArms": true, + "defaultName": "Glacie" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/hazey.json b/src/main/resources/data/tiedup/skins/damsel/hazey.json new file mode 100644 index 0000000..73da7c0 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/hazey.json @@ -0,0 +1,5 @@ +{ + "id": "hazey", + "hasSlimArms": true, + "defaultName": "Hazey" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/junichi.json b/src/main/resources/data/tiedup/skins/damsel/junichi.json new file mode 100644 index 0000000..7b92c1e --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/junichi.json @@ -0,0 +1,5 @@ +{ + "id": "junichi", + "hasSlimArms": true, + "defaultName": "Junichi" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/kitty.json b/src/main/resources/data/tiedup/skins/damsel/kitty.json new file mode 100644 index 0000000..0865805 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/kitty.json @@ -0,0 +1,5 @@ +{ + "id": "kitty", + "hasSlimArms": true, + "defaultName": "Kitty" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/kyky.json b/src/main/resources/data/tiedup/skins/damsel/kyky.json new file mode 100644 index 0000000..ae5112c --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/kyky.json @@ -0,0 +1,5 @@ +{ + "id": "kyky", + "hasSlimArms": true, + "defaultName": "Kyky" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/laureen.json b/src/main/resources/data/tiedup/skins/damsel/laureen.json new file mode 100644 index 0000000..4854046 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/laureen.json @@ -0,0 +1,5 @@ +{ + "id": "laureen", + "hasSlimArms": true, + "defaultName": "Laureen" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/mani.json b/src/main/resources/data/tiedup/skins/damsel/mani.json new file mode 100644 index 0000000..7d0e1c2 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/mani.json @@ -0,0 +1,5 @@ +{ + "id": "mani", + "hasSlimArms": true, + "defaultName": "Mani" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/nobu.json b/src/main/resources/data/tiedup/skins/damsel/nobu.json new file mode 100644 index 0000000..00059ce --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/nobu.json @@ -0,0 +1,5 @@ +{ + "id": "nobu", + "hasSlimArms": true, + "defaultName": "Nobu" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/pika.json b/src/main/resources/data/tiedup/skins/damsel/pika.json new file mode 100644 index 0000000..a81ac0b --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/pika.json @@ -0,0 +1,5 @@ +{ + "id": "pika", + "hasSlimArms": true, + "defaultName": "Pika" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/raph.json b/src/main/resources/data/tiedup/skins/damsel/raph.json new file mode 100644 index 0000000..508c80a --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/raph.json @@ -0,0 +1,5 @@ +{ + "id": "raph", + "hasSlimArms": true, + "defaultName": "Raph" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/risette.json b/src/main/resources/data/tiedup/skins/damsel/risette.json new file mode 100644 index 0000000..0561a58 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/risette.json @@ -0,0 +1,5 @@ +{ + "id": "risette", + "hasSlimArms": true, + "defaultName": "Risette" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/roxy.json b/src/main/resources/data/tiedup/skins/damsel/roxy.json new file mode 100644 index 0000000..f7b584c --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/roxy.json @@ -0,0 +1,5 @@ +{ + "id": "roxy", + "hasSlimArms": true, + "defaultName": "Roxy" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/sayari.json b/src/main/resources/data/tiedup/skins/damsel/sayari.json new file mode 100644 index 0000000..2f06109 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/sayari.json @@ -0,0 +1,5 @@ +{ + "id": "sayari", + "hasSlimArms": true, + "defaultName": "Sayari" +} diff --git a/src/main/resources/data/tiedup/skins/damsel/sui.json b/src/main/resources/data/tiedup/skins/damsel/sui.json new file mode 100644 index 0000000..f4840e1 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel/sui.json @@ -0,0 +1,5 @@ +{ + "id": "sui", + "hasSlimArms": true, + "defaultName": "Sui" +} diff --git a/src/main/resources/data/tiedup/skins/damsel_shiny/cherry.json b/src/main/resources/data/tiedup/skins/damsel_shiny/cherry.json new file mode 100644 index 0000000..60bbc5a --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel_shiny/cherry.json @@ -0,0 +1,5 @@ +{ + "id": "cherry", + "hasSlimArms": true, + "defaultName": "Cherry" +} diff --git a/src/main/resources/data/tiedup/skins/damsel_shiny/ellen.json b/src/main/resources/data/tiedup/skins/damsel_shiny/ellen.json new file mode 100644 index 0000000..678e09e --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel_shiny/ellen.json @@ -0,0 +1,5 @@ +{ + "id": "ellen", + "hasSlimArms": true, + "defaultName": "Ellen" +} diff --git a/src/main/resources/data/tiedup/skins/damsel_shiny/kitsu.json b/src/main/resources/data/tiedup/skins/damsel_shiny/kitsu.json new file mode 100644 index 0000000..605a4d9 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel_shiny/kitsu.json @@ -0,0 +1,5 @@ +{ + "id": "kitsu", + "hasSlimArms": true, + "defaultName": "Kitsu" +} diff --git a/src/main/resources/data/tiedup/skins/damsel_shiny/neko.json b/src/main/resources/data/tiedup/skins/damsel_shiny/neko.json new file mode 100644 index 0000000..9041e34 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel_shiny/neko.json @@ -0,0 +1,5 @@ +{ + "id": "neko", + "hasSlimArms": true, + "defaultName": "Neko" +} diff --git a/src/main/resources/data/tiedup/skins/damsel_shiny/red.json b/src/main/resources/data/tiedup/skins/damsel_shiny/red.json new file mode 100644 index 0000000..6561cd2 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel_shiny/red.json @@ -0,0 +1,5 @@ +{ + "id": "red", + "hasSlimArms": true, + "defaultName": "Red" +} diff --git a/src/main/resources/data/tiedup/skins/damsel_shiny/stella.json b/src/main/resources/data/tiedup/skins/damsel_shiny/stella.json new file mode 100644 index 0000000..8023e83 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/damsel_shiny/stella.json @@ -0,0 +1,5 @@ +{ + "id": "stella", + "hasSlimArms": true, + "defaultName": "Stella" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/blake.json b/src/main/resources/data/tiedup/skins/kidnapper/blake.json new file mode 100644 index 0000000..1adcb61 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/blake.json @@ -0,0 +1,5 @@ +{ + "id": "blake", + "hasSlimArms": true, + "defaultName": "Blake" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/darkie.json b/src/main/resources/data/tiedup/skins/kidnapper/darkie.json new file mode 100644 index 0000000..2c6f436 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/darkie.json @@ -0,0 +1,5 @@ +{ + "id": "darkie", + "hasSlimArms": true, + "defaultName": "Darkie" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/dean.json b/src/main/resources/data/tiedup/skins/kidnapper/dean.json new file mode 100644 index 0000000..2878a99 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/dean.json @@ -0,0 +1,6 @@ +{ + "id": "dean", + "hasSlimArms": false, + "defaultName": "Dean", + "gender": "male" +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/skins/kidnapper/eleanor.json b/src/main/resources/data/tiedup/skins/kidnapper/eleanor.json new file mode 100644 index 0000000..e23bbfe --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/eleanor.json @@ -0,0 +1,5 @@ +{ + "id": "eleanor", + "hasSlimArms": true, + "defaultName": "Eleanor" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/esther.json b/src/main/resources/data/tiedup/skins/kidnapper/esther.json new file mode 100644 index 0000000..973f09e --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/esther.json @@ -0,0 +1,5 @@ +{ + "id": "esther", + "hasSlimArms": true, + "defaultName": "Esther" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/fleur_dianthus.json b/src/main/resources/data/tiedup/skins/kidnapper/fleur_dianthus.json new file mode 100644 index 0000000..c4f1c8d --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/fleur_dianthus.json @@ -0,0 +1,5 @@ +{ + "id": "fleur_dianthus", + "hasSlimArms": true, + "defaultName": "Fleur Dianthus" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/fuya.json b/src/main/resources/data/tiedup/skins/kidnapper/fuya.json new file mode 100644 index 0000000..fb3d498 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/fuya.json @@ -0,0 +1,5 @@ +{ + "id": "fuya", + "hasSlimArms": true, + "defaultName": "Fuya" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/jack.json b/src/main/resources/data/tiedup/skins/kidnapper/jack.json new file mode 100644 index 0000000..38ef56b --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/jack.json @@ -0,0 +1,6 @@ +{ + "id": "jack", + "hasSlimArms": false, + "defaultName": "Jack", + "gender": "male" +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/skins/kidnapper/jass.json b/src/main/resources/data/tiedup/skins/kidnapper/jass.json new file mode 100644 index 0000000..e47cb36 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/jass.json @@ -0,0 +1,5 @@ +{ + "id": "jass", + "hasSlimArms": true, + "defaultName": "Jass" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/ketulu.json b/src/main/resources/data/tiedup/skins/kidnapper/ketulu.json new file mode 100644 index 0000000..2639c35 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/ketulu.json @@ -0,0 +1,5 @@ +{ + "id": "ketulu", + "hasSlimArms": true, + "defaultName": "Ketulu" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/kitty.json b/src/main/resources/data/tiedup/skins/kidnapper/kitty.json new file mode 100644 index 0000000..0865805 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/kitty.json @@ -0,0 +1,5 @@ +{ + "id": "kitty", + "hasSlimArms": true, + "defaultName": "Kitty" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_1.json b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_1.json new file mode 100644 index 0000000..bf3a4b8 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_1.json @@ -0,0 +1,5 @@ +{ + "id": "knp_mob_1", + "hasSlimArms": false, + "defaultName": "Kidnapper" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_10.json b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_10.json new file mode 100644 index 0000000..17d419b --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_10.json @@ -0,0 +1,5 @@ +{ + "id": "knp_mob_10", + "hasSlimArms": false, + "defaultName": "Kidnapper" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_11.json b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_11.json new file mode 100644 index 0000000..580841b --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_11.json @@ -0,0 +1,5 @@ +{ + "id": "knp_mob_11", + "hasSlimArms": false, + "defaultName": "Kidnapper" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_12.json b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_12.json new file mode 100644 index 0000000..2fb4da1 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_12.json @@ -0,0 +1,5 @@ +{ + "id": "knp_mob_12", + "hasSlimArms": false, + "defaultName": "Kidnapper" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_13.json b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_13.json new file mode 100644 index 0000000..50d4dd4 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_13.json @@ -0,0 +1,5 @@ +{ + "id": "knp_mob_13", + "hasSlimArms": false, + "defaultName": "Kidnapper" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_14.json b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_14.json new file mode 100644 index 0000000..d425107 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_14.json @@ -0,0 +1,5 @@ +{ + "id": "knp_mob_14", + "hasSlimArms": false, + "defaultName": "Kidnapper" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_15.json b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_15.json new file mode 100644 index 0000000..6d7e24e --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_15.json @@ -0,0 +1,5 @@ +{ + "id": "knp_mob_15", + "hasSlimArms": false, + "defaultName": "Kidnapper" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_16.json b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_16.json new file mode 100644 index 0000000..e4bedcc --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_16.json @@ -0,0 +1,5 @@ +{ + "id": "knp_mob_16", + "hasSlimArms": false, + "defaultName": "Kidnapper" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_17.json b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_17.json new file mode 100644 index 0000000..ca5ac37 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_17.json @@ -0,0 +1,5 @@ +{ + "id": "knp_mob_17", + "hasSlimArms": false, + "defaultName": "Kidnapper" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_18.json b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_18.json new file mode 100644 index 0000000..360562b --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_18.json @@ -0,0 +1,5 @@ +{ + "id": "knp_mob_18", + "hasSlimArms": false, + "defaultName": "Kidnapper" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_2.json b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_2.json new file mode 100644 index 0000000..f9a983f --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_2.json @@ -0,0 +1,5 @@ +{ + "id": "knp_mob_2", + "hasSlimArms": false, + "defaultName": "Kidnapper" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_3.json b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_3.json new file mode 100644 index 0000000..1153dc3 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_3.json @@ -0,0 +1,5 @@ +{ + "id": "knp_mob_3", + "hasSlimArms": false, + "defaultName": "Kidnapper" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_4.json b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_4.json new file mode 100644 index 0000000..a16056a --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_4.json @@ -0,0 +1,5 @@ +{ + "id": "knp_mob_4", + "hasSlimArms": false, + "defaultName": "Kidnapper" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_5.json b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_5.json new file mode 100644 index 0000000..8b531f8 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_5.json @@ -0,0 +1,5 @@ +{ + "id": "knp_mob_5", + "hasSlimArms": false, + "defaultName": "Kidnapper" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_6.json b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_6.json new file mode 100644 index 0000000..eee39f2 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_6.json @@ -0,0 +1,5 @@ +{ + "id": "knp_mob_6", + "hasSlimArms": false, + "defaultName": "Kidnapper" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_7.json b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_7.json new file mode 100644 index 0000000..db4c430 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_7.json @@ -0,0 +1,5 @@ +{ + "id": "knp_mob_7", + "hasSlimArms": false, + "defaultName": "Kidnapper" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_8.json b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_8.json new file mode 100644 index 0000000..a36d55e --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_8.json @@ -0,0 +1,5 @@ +{ + "id": "knp_mob_8", + "hasSlimArms": false, + "defaultName": "Kidnapper" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_9.json b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_9.json new file mode 100644 index 0000000..23b33f4 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/knp_mob_9.json @@ -0,0 +1,5 @@ +{ + "id": "knp_mob_9", + "hasSlimArms": false, + "defaultName": "Kidnapper" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/kyra.json b/src/main/resources/data/tiedup/skins/kidnapper/kyra.json new file mode 100644 index 0000000..76380da --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/kyra.json @@ -0,0 +1,5 @@ +{ + "id": "kyra", + "hasSlimArms": true, + "defaultName": "Kyra" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/lucina.json b/src/main/resources/data/tiedup/skins/kidnapper/lucina.json new file mode 100644 index 0000000..d697e76 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/lucina.json @@ -0,0 +1,5 @@ +{ + "id": "lucina", + "hasSlimArms": true, + "defaultName": "Lucina" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/misty.json b/src/main/resources/data/tiedup/skins/kidnapper/misty.json new file mode 100644 index 0000000..78fa493 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/misty.json @@ -0,0 +1,5 @@ +{ + "id": "misty", + "hasSlimArms": true, + "defaultName": "Misty" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/nataleigh.json b/src/main/resources/data/tiedup/skins/kidnapper/nataleigh.json new file mode 100644 index 0000000..2c49907 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/nataleigh.json @@ -0,0 +1,5 @@ +{ + "id": "nataleigh", + "hasSlimArms": true, + "defaultName": "Nataleigh" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/nico.json b/src/main/resources/data/tiedup/skins/kidnapper/nico.json new file mode 100644 index 0000000..f764831 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/nico.json @@ -0,0 +1,5 @@ +{ + "id": "nico", + "hasSlimArms": true, + "defaultName": "Nico" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/ruby.json b/src/main/resources/data/tiedup/skins/kidnapper/ruby.json new file mode 100644 index 0000000..1460859 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/ruby.json @@ -0,0 +1,5 @@ +{ + "id": "ruby", + "hasSlimArms": true, + "defaultName": "Ruby" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/ryuko.json b/src/main/resources/data/tiedup/skins/kidnapper/ryuko.json new file mode 100644 index 0000000..03aad89 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/ryuko.json @@ -0,0 +1,5 @@ +{ + "id": "ryuko", + "hasSlimArms": true, + "defaultName": "Ryuko" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/teemo.json b/src/main/resources/data/tiedup/skins/kidnapper/teemo.json new file mode 100644 index 0000000..ba85a37 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/teemo.json @@ -0,0 +1,6 @@ +{ + "id": "teemo", + "hasSlimArms": false, + "defaultName": "Teemo", + "gender": "male" +} \ No newline at end of file diff --git a/src/main/resources/data/tiedup/skins/kidnapper/welphia.json b/src/main/resources/data/tiedup/skins/kidnapper/welphia.json new file mode 100644 index 0000000..b35e997 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/welphia.json @@ -0,0 +1,5 @@ +{ + "id": "welphia", + "hasSlimArms": true, + "defaultName": "Welphia" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/wynter.json b/src/main/resources/data/tiedup/skins/kidnapper/wynter.json new file mode 100644 index 0000000..f275826 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/wynter.json @@ -0,0 +1,5 @@ +{ + "id": "wynter", + "hasSlimArms": true, + "defaultName": "Wynter" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/yuti.json b/src/main/resources/data/tiedup/skins/kidnapper/yuti.json new file mode 100644 index 0000000..9f45b50 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/yuti.json @@ -0,0 +1,5 @@ +{ + "id": "yuti", + "hasSlimArms": true, + "defaultName": "Yuti" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper/zephyr.json b/src/main/resources/data/tiedup/skins/kidnapper/zephyr.json new file mode 100644 index 0000000..124defb --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper/zephyr.json @@ -0,0 +1,5 @@ +{ + "id": "zephyr", + "hasSlimArms": true, + "defaultName": "Zephyr" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper_archer/bowy.json b/src/main/resources/data/tiedup/skins/kidnapper_archer/bowy.json new file mode 100644 index 0000000..1861054 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper_archer/bowy.json @@ -0,0 +1,5 @@ +{ + "id": "bowy", + "hasSlimArms": true, + "defaultName": "Bowy" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper_archer/shooty.json b/src/main/resources/data/tiedup/skins/kidnapper_archer/shooty.json new file mode 100644 index 0000000..dcbb3ed --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper_archer/shooty.json @@ -0,0 +1,5 @@ +{ + "id": "shooty", + "hasSlimArms": true, + "defaultName": "shooty" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper_elite/athena.json b/src/main/resources/data/tiedup/skins/kidnapper_elite/athena.json new file mode 100644 index 0000000..fe6e9de --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper_elite/athena.json @@ -0,0 +1,5 @@ +{ + "id": "athena", + "hasSlimArms": false, + "defaultName": "Athena" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper_elite/carol.json b/src/main/resources/data/tiedup/skins/kidnapper_elite/carol.json new file mode 100644 index 0000000..cab4203 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper_elite/carol.json @@ -0,0 +1,5 @@ +{ + "id": "carol", + "hasSlimArms": false, + "defaultName": "Carol" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper_elite/evelyn.json b/src/main/resources/data/tiedup/skins/kidnapper_elite/evelyn.json new file mode 100644 index 0000000..635451e --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper_elite/evelyn.json @@ -0,0 +1,5 @@ +{ + "id": "evelyn", + "hasSlimArms": false, + "defaultName": "Evelyn" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper_elite/suki.json b/src/main/resources/data/tiedup/skins/kidnapper_elite/suki.json new file mode 100644 index 0000000..91da9e5 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper_elite/suki.json @@ -0,0 +1,5 @@ +{ + "id": "suki", + "hasSlimArms": false, + "defaultName": "Suki" +} diff --git a/src/main/resources/data/tiedup/skins/kidnapper_merchant/goldy.json b/src/main/resources/data/tiedup/skins/kidnapper_merchant/goldy.json new file mode 100644 index 0000000..63b7231 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/kidnapper_merchant/goldy.json @@ -0,0 +1,5 @@ +{ + "id": "goldy", + "hasSlimArms": true, + "defaultName": "Goldy" +} diff --git a/src/main/resources/data/tiedup/skins/labor_guard/feifei.json b/src/main/resources/data/tiedup/skins/labor_guard/feifei.json new file mode 100644 index 0000000..63ab4fa --- /dev/null +++ b/src/main/resources/data/tiedup/skins/labor_guard/feifei.json @@ -0,0 +1,5 @@ +{ + "id": "feifei", + "hasSlimArms": true, + "defaultName": "Feifei" +} diff --git a/src/main/resources/data/tiedup/skins/labor_guard/nana.json b/src/main/resources/data/tiedup/skins/labor_guard/nana.json new file mode 100644 index 0000000..429a582 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/labor_guard/nana.json @@ -0,0 +1,5 @@ +{ + "id": "nana", + "hasSlimArms": true, + "defaultName": "Nana" +} diff --git a/src/main/resources/data/tiedup/skins/labor_guard/xinxin.json b/src/main/resources/data/tiedup/skins/labor_guard/xinxin.json new file mode 100644 index 0000000..8964919 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/labor_guard/xinxin.json @@ -0,0 +1,5 @@ +{ + "id": "xinxin", + "hasSlimArms": true, + "defaultName": "Xinxin" +} diff --git a/src/main/resources/data/tiedup/skins/maid/mimi.json b/src/main/resources/data/tiedup/skins/maid/mimi.json new file mode 100644 index 0000000..c0e4fb6 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/maid/mimi.json @@ -0,0 +1,5 @@ +{ + "id": "mimi", + "hasSlimArms": true, + "defaultName": "Mimi" +} diff --git a/src/main/resources/data/tiedup/skins/maid/mola.json b/src/main/resources/data/tiedup/skins/maid/mola.json new file mode 100644 index 0000000..648a953 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/maid/mola.json @@ -0,0 +1,5 @@ +{ + "id": "mola", + "hasSlimArms": true, + "defaultName": "Mola" +} diff --git a/src/main/resources/data/tiedup/skins/maid/pola.json b/src/main/resources/data/tiedup/skins/maid/pola.json new file mode 100644 index 0000000..3859944 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/maid/pola.json @@ -0,0 +1,5 @@ +{ + "id": "pola", + "hasSlimArms": true, + "defaultName": "Pola" +} diff --git a/src/main/resources/data/tiedup/skins/master/amy.json b/src/main/resources/data/tiedup/skins/master/amy.json new file mode 100644 index 0000000..774def2 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/master/amy.json @@ -0,0 +1,5 @@ +{ + "id": "amy", + "hasSlimArms": false, + "defaultName": "Amy" +} diff --git a/src/main/resources/data/tiedup/skins/master/elisa.json b/src/main/resources/data/tiedup/skins/master/elisa.json new file mode 100644 index 0000000..377dc72 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/master/elisa.json @@ -0,0 +1,5 @@ +{ + "id": "elisa", + "hasSlimArms": true, + "defaultName": "Elisa" +} diff --git a/src/main/resources/data/tiedup/skins/master/hana.json b/src/main/resources/data/tiedup/skins/master/hana.json new file mode 100644 index 0000000..6fa0f1e --- /dev/null +++ b/src/main/resources/data/tiedup/skins/master/hana.json @@ -0,0 +1,5 @@ +{ + "id": "hana", + "hasSlimArms": true, + "defaultName": "Hana" +} diff --git a/src/main/resources/data/tiedup/skins/slave_trader/alheli.json b/src/main/resources/data/tiedup/skins/slave_trader/alheli.json new file mode 100644 index 0000000..df373d3 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/slave_trader/alheli.json @@ -0,0 +1,5 @@ +{ + "id": "alheli", + "hasSlimArms": true, + "defaultName": "Alheli" +} diff --git a/src/main/resources/data/tiedup/skins/slave_trader/shrim.json b/src/main/resources/data/tiedup/skins/slave_trader/shrim.json new file mode 100644 index 0000000..e0a07ce --- /dev/null +++ b/src/main/resources/data/tiedup/skins/slave_trader/shrim.json @@ -0,0 +1,5 @@ +{ + "id": "shrim", + "hasSlimArms": true, + "defaultName": "Shrim" +} diff --git a/src/main/resources/data/tiedup/skins/slave_trader/trady.json b/src/main/resources/data/tiedup/skins/slave_trader/trady.json new file mode 100644 index 0000000..1cffbd3 --- /dev/null +++ b/src/main/resources/data/tiedup/skins/slave_trader/trady.json @@ -0,0 +1,5 @@ +{ + "id": "trady", + "hasSlimArms": true, + "defaultName": "Trady" +} diff --git a/src/main/resources/data/tiedup/structures/cell_tent.nbt b/src/main/resources/data/tiedup/structures/cell_tent.nbt new file mode 100644 index 0000000..dfcf287 Binary files /dev/null and b/src/main/resources/data/tiedup/structures/cell_tent.nbt differ diff --git a/src/main/resources/data/tiedup/structures/kidnap_fortress.nbt b/src/main/resources/data/tiedup/structures/kidnap_fortress.nbt new file mode 100644 index 0000000..7ea9185 Binary files /dev/null and b/src/main/resources/data/tiedup/structures/kidnap_fortress.nbt differ diff --git a/src/main/resources/data/tiedup/structures/kidnap_outpost.nbt b/src/main/resources/data/tiedup/structures/kidnap_outpost.nbt new file mode 100644 index 0000000..efe403d Binary files /dev/null and b/src/main/resources/data/tiedup/structures/kidnap_outpost.nbt differ diff --git a/src/main/resources/data/tiedup/structures/kidnap_tent.nbt b/src/main/resources/data/tiedup/structures/kidnap_tent.nbt new file mode 100644 index 0000000..edab5fa Binary files /dev/null and b/src/main/resources/data/tiedup/structures/kidnap_tent.nbt differ diff --git a/src/main/resources/data/tiedup/structures/kidnap_tent_trader.nbt b/src/main/resources/data/tiedup/structures/kidnap_tent_trader.nbt new file mode 100644 index 0000000..5033c49 Binary files /dev/null and b/src/main/resources/data/tiedup/structures/kidnap_tent_trader.nbt differ diff --git a/src/main/resources/data/tiedup/tags/worldgen/biome/has_hanging_cage.json b/src/main/resources/data/tiedup/tags/worldgen/biome/has_hanging_cage.json new file mode 100644 index 0000000..e2f8d7f --- /dev/null +++ b/src/main/resources/data/tiedup/tags/worldgen/biome/has_hanging_cage.json @@ -0,0 +1,19 @@ +{ + "replace": false, + "values": [ + "minecraft:plains", + "minecraft:sunflower_plains", + "minecraft:forest", + "minecraft:flower_forest", + "minecraft:birch_forest", + "minecraft:dark_forest", + "minecraft:taiga", + "minecraft:snowy_taiga", + "minecraft:snowy_plains", + "minecraft:meadow", + "minecraft:savanna", + "minecraft:savanna_plateau", + "minecraft:dripstone_caves", + "minecraft:lush_caves" + ] +} diff --git a/src/main/resources/data/tiedup/tags/worldgen/biome/has_kidnapper_camp.json b/src/main/resources/data/tiedup/tags/worldgen/biome/has_kidnapper_camp.json new file mode 100644 index 0000000..9f165b9 --- /dev/null +++ b/src/main/resources/data/tiedup/tags/worldgen/biome/has_kidnapper_camp.json @@ -0,0 +1,17 @@ +{ + "replace": false, + "values": [ + "minecraft:plains", + "minecraft:sunflower_plains", + "minecraft:forest", + "minecraft:flower_forest", + "minecraft:birch_forest", + "minecraft:dark_forest", + "minecraft:taiga", + "minecraft:snowy_taiga", + "minecraft:snowy_plains", + "minecraft:meadow", + "minecraft:savanna", + "minecraft:savanna_plateau" + ] +} diff --git a/src/main/resources/data/tiedup/tags/worldgen/biome/has_kidnapper_fortress.json b/src/main/resources/data/tiedup/tags/worldgen/biome/has_kidnapper_fortress.json new file mode 100644 index 0000000..e32faf9 --- /dev/null +++ b/src/main/resources/data/tiedup/tags/worldgen/biome/has_kidnapper_fortress.json @@ -0,0 +1,20 @@ +{ + "replace": false, + "values": [ + "minecraft:plains", + "minecraft:sunflower_plains", + "minecraft:forest", + "minecraft:flower_forest", + "minecraft:birch_forest", + "minecraft:dark_forest", + "minecraft:taiga", + "minecraft:snowy_taiga", + "minecraft:snowy_plains", + "minecraft:meadow", + "minecraft:savanna", + "minecraft:savanna_plateau", + "minecraft:windswept_hills", + "minecraft:windswept_forest", + "minecraft:grove" + ] +} diff --git a/src/main/resources/data/tiedup/tags/worldgen/biome/has_kidnapper_outpost.json b/src/main/resources/data/tiedup/tags/worldgen/biome/has_kidnapper_outpost.json new file mode 100644 index 0000000..e32faf9 --- /dev/null +++ b/src/main/resources/data/tiedup/tags/worldgen/biome/has_kidnapper_outpost.json @@ -0,0 +1,20 @@ +{ + "replace": false, + "values": [ + "minecraft:plains", + "minecraft:sunflower_plains", + "minecraft:forest", + "minecraft:flower_forest", + "minecraft:birch_forest", + "minecraft:dark_forest", + "minecraft:taiga", + "minecraft:snowy_taiga", + "minecraft:snowy_plains", + "minecraft:meadow", + "minecraft:savanna", + "minecraft:savanna_plateau", + "minecraft:windswept_hills", + "minecraft:windswept_forest", + "minecraft:grove" + ] +} diff --git a/src/main/resources/data/tiedup/tiedup_furniture/test_cross.json b/src/main/resources/data/tiedup/tiedup_furniture/test_cross.json new file mode 100644 index 0000000..cdbce9c --- /dev/null +++ b/src/main/resources/data/tiedup/tiedup_furniture/test_cross.json @@ -0,0 +1,28 @@ +{ + "id": "tiedup:test_cross", + "display_name": "Test St. Andrew's Cross", + "translation_key": "furniture.tiedup.test_cross", + "model": "tiedup:models/gltf/furniture/test_cross.glb", + "tint_channels": {}, + "supports_color": false, + "hitbox": { "width": 1.2, "height": 2.4 }, + "placement": { + "snap_to_wall": true, + "floor_only": true + }, + "lockable": true, + "break_resistance": 100, + "drop_on_break": true, + "seats": [ + { + "id": "main", + "armature": "Player_main", + "blocked_regions": ["ARMS", "HANDS", "LEGS", "FEET"], + "lockable": true, + "locked_difficulty": 150, + "item_difficulty_bonus": true + } + ], + "category": "restraint", + "icon": "tiedup:item/chain" +} diff --git a/src/main/resources/data/tiedup/tiedup_items/test_handcuffs.json b/src/main/resources/data/tiedup/tiedup_items/test_handcuffs.json new file mode 100644 index 0000000..97c188c --- /dev/null +++ b/src/main/resources/data/tiedup/tiedup_items/test_handcuffs.json @@ -0,0 +1,14 @@ +{ + "type": "tiedup:bondage_item", + "display_name": "Data-Driven Handcuffs", + "model": "tiedup:models/gltf/v2/handcuffs/cuffs_prototype.glb", + "regions": ["ARMS"], + "pose_priority": 30, + "escape_difficulty": 100, + "lockable": true, + "icon": "tiedup:item/beam_cuffs", + "animation_bones": { + "idle": ["rightArm", "leftArm"], + "struggle": ["rightArm", "leftArm"] + } +} diff --git a/src/main/resources/data/tiedup/worldgen/structure/hanging_cage.json b/src/main/resources/data/tiedup/worldgen/structure/hanging_cage.json new file mode 100644 index 0000000..cf0993c --- /dev/null +++ b/src/main/resources/data/tiedup/worldgen/structure/hanging_cage.json @@ -0,0 +1,6 @@ +{ + "type": "tiedup:hanging_cage", + "biomes": "#tiedup:has_hanging_cage", + "spawn_overrides": {}, + "step": "underground_structures" +} diff --git a/src/main/resources/data/tiedup/worldgen/structure/kidnapper_camp.json b/src/main/resources/data/tiedup/worldgen/structure/kidnapper_camp.json new file mode 100644 index 0000000..143e9a9 --- /dev/null +++ b/src/main/resources/data/tiedup/worldgen/structure/kidnapper_camp.json @@ -0,0 +1,6 @@ +{ + "type": "tiedup:kidnapper_camp", + "biomes": "#tiedup:has_kidnapper_camp", + "spawn_overrides": {}, + "step": "surface_structures" +} diff --git a/src/main/resources/data/tiedup/worldgen/structure/kidnapper_fortress.json b/src/main/resources/data/tiedup/worldgen/structure/kidnapper_fortress.json new file mode 100644 index 0000000..f2bacb7 --- /dev/null +++ b/src/main/resources/data/tiedup/worldgen/structure/kidnapper_fortress.json @@ -0,0 +1,6 @@ +{ + "type": "tiedup:kidnapper_fortress", + "biomes": "#tiedup:has_kidnapper_fortress", + "spawn_overrides": {}, + "step": "surface_structures" +} diff --git a/src/main/resources/data/tiedup/worldgen/structure/kidnapper_outpost.json b/src/main/resources/data/tiedup/worldgen/structure/kidnapper_outpost.json new file mode 100644 index 0000000..849dd8c --- /dev/null +++ b/src/main/resources/data/tiedup/worldgen/structure/kidnapper_outpost.json @@ -0,0 +1,6 @@ +{ + "type": "tiedup:kidnapper_outpost", + "biomes": "#tiedup:has_kidnapper_outpost", + "spawn_overrides": {}, + "step": "surface_structures" +} diff --git a/src/main/resources/data/tiedup/worldgen/structure_set/hanging_cage.json b/src/main/resources/data/tiedup/worldgen/structure_set/hanging_cage.json new file mode 100644 index 0000000..0efaa61 --- /dev/null +++ b/src/main/resources/data/tiedup/worldgen/structure_set/hanging_cage.json @@ -0,0 +1,14 @@ +{ + "structures": [ + { + "structure": "tiedup:hanging_cage", + "weight": 1 + } + ], + "placement": { + "type": "minecraft:random_spread", + "spacing": 16, + "separation": 8, + "salt": 827364591 + } +} diff --git a/src/main/resources/data/tiedup/worldgen/structure_set/kidnapper_structures.json b/src/main/resources/data/tiedup/worldgen/structure_set/kidnapper_structures.json new file mode 100644 index 0000000..87f4d41 --- /dev/null +++ b/src/main/resources/data/tiedup/worldgen/structure_set/kidnapper_structures.json @@ -0,0 +1,22 @@ +{ + "structures": [ + { + "structure": "tiedup:kidnapper_camp", + "weight": 4 + }, + { + "structure": "tiedup:kidnapper_outpost", + "weight": 2 + }, + { + "structure": "tiedup:kidnapper_fortress", + "weight": 1 + } + ], + "placement": { + "type": "minecraft:random_spread", + "spacing": 24, + "separation": 10, + "salt": 198734562 + } +} diff --git a/src/main/resources/pack.mcmeta b/src/main/resources/pack.mcmeta new file mode 100644 index 0000000..eca79ae --- /dev/null +++ b/src/main/resources/pack.mcmeta @@ -0,0 +1,8 @@ +{ + "pack": { + "description": { + "text": "${mod_id} resources" + }, + "pack_format": 15 + } +} \ No newline at end of file diff --git a/src/main/resources/tiedup.mixins.json b/src/main/resources/tiedup.mixins.json new file mode 100644 index 0000000..21db5d0 --- /dev/null +++ b/src/main/resources/tiedup.mixins.json @@ -0,0 +1,27 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.tiedup.remake.mixin", + "compatibilityLevel": "JAVA_17", + "refmap": "tiedup.refmap.json", + "mixins": [ + "MixinServerPlayer", + "MixinMCAVillagerInteraction", + "MixinMCAVillagerLeash", + "MixinMCAOpenAIChatAI", + "MixinMCAMessenger", + "MixinLivingEntityBodyRot" + ], + "client": [ + "client/MixinVillagerEntityBaseModelMCA", + "client/MixinVillagerEntityMCAAnimated", + "client/MixinMCASpeechManager", + "client/MixinMCAPlayerExtendedModel", + "client/MixinPlayerModel", + "client/MixinCamera", + "client/MixinLivingEntitySleeping" + ], + "injectors": { + "defaultRequire": 0 + } +}