From 2bd90bdb50b07e50e83c568361c97b0df3ef65ea Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Fri, 24 Jul 2026 11:36:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 11 + .github/workflows/build.yml | 30 + .gitignore | 40 + LICENSE | 121 + README.md | 9 + TODO | 1 + build.gradle | 88 + gradle.properties | 20 + gradle/init.gradle | 40 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 ++ gradlew.bat | 93 + settings.gradle | 58 + ...nalCooperativeCapitalismMarketEconomy.java | 124 + .../com/gvsds/tcme/command/CMECommand.java | 2620 +++++++++++++++++ .../java/com/gvsds/tcme/config/Config.java | 69 + .../com/gvsds/tcme/config/ConfigManager.java | 55 + .../gvsds/tcme/database/DatabaseManager.java | 187 ++ .../gvsds/tcme/gui/AlertScreenHandler.java | 106 + .../gvsds/tcme/gui/ConfirmScreenHandler.java | 117 + .../java/com/gvsds/tcme/gui/GuiInventory.java | 87 + .../gvsds/tcme/gui/InputScreenHandler.java | 151 + .../tcme/gui/LargeChestScreenHandler.java | 66 + .../java/com/gvsds/tcme/gui/ServerGuiAPI.java | 134 + .../tcme/gui/SmallChestScreenHandler.java | 60 + .../com/gvsds/tcme/i18n/LanguageManager.java | 225 ++ .../gvsds/tcme/manager/BalanceManager.java | 157 + .../gvsds/tcme/manager/CurrencyManager.java | 286 ++ .../tcme/manager/ExchangeRateManager.java | 134 + .../tcme/manager/PaymentRequestManager.java | 254 ++ .../tcme/manager/PlayerSettingsManager.java | 125 + .../tcme/manager/TransactionManager.java | 141 + .../java/com/gvsds/tcme/model/Currency.java | 30 + .../com/gvsds/tcme/model/PaymentRequest.java | 45 + .../com/gvsds/tcme/model/PlayerBalance.java | 30 + .../com/gvsds/tcme/model/Transaction.java | 50 + .../java/com/gvsds/tcme/util/TextUtil.java | 80 + .../default_config.json | 12 + .../icon.png | Bin 0 -> 8082 bytes ...tive-capitalism-market-economy.mixins.json | 12 + src/main/resources/fabric.mod.json | 31 + 42 files changed, 6154 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 TODO create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradle/init.gradle create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/com/gvsds/tcme/CardinalCooperativeCapitalismMarketEconomy.java create mode 100644 src/main/java/com/gvsds/tcme/command/CMECommand.java create mode 100644 src/main/java/com/gvsds/tcme/config/Config.java create mode 100644 src/main/java/com/gvsds/tcme/config/ConfigManager.java create mode 100644 src/main/java/com/gvsds/tcme/database/DatabaseManager.java create mode 100644 src/main/java/com/gvsds/tcme/gui/AlertScreenHandler.java create mode 100644 src/main/java/com/gvsds/tcme/gui/ConfirmScreenHandler.java create mode 100644 src/main/java/com/gvsds/tcme/gui/GuiInventory.java create mode 100644 src/main/java/com/gvsds/tcme/gui/InputScreenHandler.java create mode 100644 src/main/java/com/gvsds/tcme/gui/LargeChestScreenHandler.java create mode 100644 src/main/java/com/gvsds/tcme/gui/ServerGuiAPI.java create mode 100644 src/main/java/com/gvsds/tcme/gui/SmallChestScreenHandler.java create mode 100644 src/main/java/com/gvsds/tcme/i18n/LanguageManager.java create mode 100644 src/main/java/com/gvsds/tcme/manager/BalanceManager.java create mode 100644 src/main/java/com/gvsds/tcme/manager/CurrencyManager.java create mode 100644 src/main/java/com/gvsds/tcme/manager/ExchangeRateManager.java create mode 100644 src/main/java/com/gvsds/tcme/manager/PaymentRequestManager.java create mode 100644 src/main/java/com/gvsds/tcme/manager/PlayerSettingsManager.java create mode 100644 src/main/java/com/gvsds/tcme/manager/TransactionManager.java create mode 100644 src/main/java/com/gvsds/tcme/model/Currency.java create mode 100644 src/main/java/com/gvsds/tcme/model/PaymentRequest.java create mode 100644 src/main/java/com/gvsds/tcme/model/PlayerBalance.java create mode 100644 src/main/java/com/gvsds/tcme/model/Transaction.java create mode 100644 src/main/java/com/gvsds/tcme/util/TextUtil.java create mode 100644 src/main/resources/assets/cardinal-cooperative-capitalism-market-economy/default_config.json create mode 100644 src/main/resources/assets/cardinal-cooperative-capitalism-market-economy/icon.png create mode 100644 src/main/resources/cardinal-cooperative-capitalism-market-economy.mixins.json create mode 100644 src/main/resources/fabric.mod.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..20dd8f0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + +/build/ +/.gradle/ \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..524fb1b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,30 @@ +# Automatically build the project and run any configured tests for every push +# and submitted pull request. This can help catch issues that only occur on +# certain platforms or Java versions, and provides a first line of defence +# against bad commits. + +name: build +on: [pull_request, push] + +jobs: + build: + runs-on: ubuntu-24.04 + steps: + - name: checkout repository + uses: actions/checkout@v6 + - name: validate gradle wrapper + uses: gradle/actions/wrapper-validation@v6 + - name: setup jdk + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'microsoft' + - name: make gradle wrapper executable + run: chmod +x ./gradlew + - name: build + run: ./gradlew build + - name: capture build artifacts + uses: actions/upload-artifact@v7 + with: + name: Artifacts + path: build/libs/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c476faf --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# gradle + +.gradle/ +build/ +out/ +classes/ + +# eclipse + +*.launch + +# idea + +.idea/ +*.iml +*.ipr +*.iws + +# vscode + +.settings/ +.vscode/ +bin/ +.classpath +.project + +# macos + +*.DS_Store + +# fabric + +run/ + +# java + +hs_err_*.log +replay_*.log +*.hprof +*.jfr diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1625c17 --- /dev/null +++ b/LICENSE @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc07760 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# Cardinal Cooperative Capitalism: Market Economy + +## Setup + +For setup instructions, please see the [Fabric Documentation page](https://docs.fabricmc.net/develop/getting-started/creating-a-project#setting-up) related to the IDE that you are using. + +## License + +This template is available under the CC0 license. Feel free to learn from it and incorporate it in your own projects. diff --git a/TODO b/TODO new file mode 100644 index 0000000..2cca3aa --- /dev/null +++ b/TODO @@ -0,0 +1 @@ +礼物,信,神器,点歌 \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..d10e8e5 --- /dev/null +++ b/build.gradle @@ -0,0 +1,88 @@ +plugins { + id 'net.fabricmc.fabric-loom-remap' version "${loom_version}" + id 'maven-publish' +} + +version = project.mod_version +group = project.maven_group + +repositories { + // Add repositories to retrieve artifacts from in here. + // You should only use this when depending on other mods because + // Loom adds the essential maven repositories to download Minecraft and libraries from automatically. + // See https://docs.gradle.org/current/userguide/declaring_repositories.html + // for more information about repositories. +} + +loom { + mods { + "cardinal-cooperative-capitalism-market-economy" { + sourceSet sourceSets.main + } + } +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2" + modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + + implementation("org.xerial:sqlite-jdbc:3.45.1.0") + implementation("com.mysql:mysql-connector-j:8.3.0") + implementation("com.zaxxer:HikariCP:5.1.0") + + include("org.xerial:sqlite-jdbc:3.45.1.0") + include("com.mysql:mysql-connector-j:8.3.0") + include("com.zaxxer:HikariCP:5.1.0") +} + +processResources { + def version = project.version + inputs.property "version", version + + filesMatching("fabric.mod.json") { + expand "version": version + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.release = 21 +} + +java { + // Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task + // if it is present. + // If you remove this line, sources will not be generated. + withSourcesJar() + + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +jar { + def projectName = project.name + inputs.property "projectName", projectName + + from("LICENSE") { + rename { "${it}_$projectName"} + } +} + +// configure the maven publication +publishing { + publications { + create("mavenJava", MavenPublication) { + from components.java + } + } + + // See https://docs.gradle.org/current/userguide/publishing_maven.html for information on how to set up publishing. + repositories { + // Add repositories to publish to here. + // Notice: This block does NOT have the same function as the block in the top level. + // The repositories here will be used for publishing your artifact, not for + // retrieving dependencies. + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..c5a8f52 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,20 @@ +# Done to increase the memory available to gradle. +org.gradle.jvmargs=-Xmx1G +org.gradle.parallel=true + +# IntelliJ IDEA is not yet fully compatible with configuration cache, see: https://github.com/FabricMC/fabric-loom/issues/1349 +org.gradle.configuration-cache=false + +# Fabric Properties +# check these on https://fabricmc.net/develop +minecraft_version=1.21.10 +yarn_mappings=1.21.10+build.3 +loader_version=0.19.2 +loom_version=1.16-SNAPSHOT + +# Mod Properties +mod_version=1.0.0 +maven_group=com.gvsds.tcme + +# Dependencies +fabric_api_version=0.138.4+1.21.10 \ No newline at end of file diff --git a/gradle/init.gradle b/gradle/init.gradle new file mode 100644 index 0000000..1aa0fc7 --- /dev/null +++ b/gradle/init.gradle @@ -0,0 +1,40 @@ +allprojects { + buildscript { + repositories { + maven { + name 'Aliyun' + url 'https://maven.aliyun.com/repository/central' + } + maven { + name 'AliyunPublic' + url 'https://maven.aliyun.com/repository/public' + } + maven { + name 'Tencent' + url 'https://mirrors.cloud.tencent.com/nexus/repository/maven-public/' + } + mavenCentral() + gradlePluginPortal() + } + } + + repositories { + maven { + name 'Aliyun' + url 'https://maven.aliyun.com/repository/central' + } + maven { + name 'AliyunPublic' + url 'https://maven.aliyun.com/repository/public' + } + maven { + name 'Tencent' + url 'https://mirrors.cloud.tencent.com/nexus/repository/maven-public/' + } + maven { + name 'Modrinth' + url 'https://api.modrinth.com/maven' + } + mavenCentral() + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d997cfc60f4cff0e7451d19d49a82fa986695d07 GIT binary patch literal 48966 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

`Lem|V$A>eR0uN8Y&sdjtu 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

1bvi{DS7E@q1{$_8->K{_OWv2 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..8f23c7a --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=file:///D:/%E8%BF%85%E9%9B%B7%E4%B8%8B%E8%BD%BD/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..739907d --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables 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. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +: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/settings.gradle b/settings.gradle new file mode 100644 index 0000000..10b349d --- /dev/null +++ b/settings.gradle @@ -0,0 +1,58 @@ +pluginManagement { + repositories { + // 阿里云镜像 + maven { + name = 'Aliyun' + url = 'https://maven.aliyun.com/repository/central' + } + maven { + name = 'AliyunPublic' + url = 'https://maven.aliyun.com/repository/public' + } + // 腾讯云镜像 + maven { + name = 'Tencent' + url = 'https://mirrors.cloud.tencent.com/nexus/repository/maven-public/' + } + // Fabric 官方源 + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + // 阿里云镜像 - 用于通用依赖 + maven { + name = 'Aliyun' + url = 'https://maven.aliyun.com/repository/central' + } + maven { + name = 'AliyunPublic' + url = 'https://maven.aliyun.com/repository/public' + } + // 腾讯云镜像 + maven { + name = 'Tencent' + url = 'https://mirrors.cloud.tencent.com/nexus/repository/maven-public/' + } + // Modrinth 镜像 - 用于 Minecraft 模组相关依赖 + maven { + name = 'Modrinth' + url = 'https://api.modrinth.com/maven' + } + // Fabric 官方源 - 用于 Fabric 相关依赖 + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + } +} + +// Should match your modid +rootProject.name = 'cardinal-cooperative-capitalism-market-economy' diff --git a/src/main/java/com/gvsds/tcme/CardinalCooperativeCapitalismMarketEconomy.java b/src/main/java/com/gvsds/tcme/CardinalCooperativeCapitalismMarketEconomy.java new file mode 100644 index 0000000..7b9b60d --- /dev/null +++ b/src/main/java/com/gvsds/tcme/CardinalCooperativeCapitalismMarketEconomy.java @@ -0,0 +1,124 @@ +package com.gvsds.tcme; + +import com.gvsds.tcme.command.CMECommand; +import com.gvsds.tcme.config.Config; +import com.gvsds.tcme.config.ConfigManager; +import com.gvsds.tcme.database.DatabaseManager; +import com.gvsds.tcme.gui.InputScreenHandler; +import com.gvsds.tcme.manager.BalanceManager; +import com.gvsds.tcme.manager.CurrencyManager; +import com.gvsds.tcme.manager.ExchangeRateManager; +import com.gvsds.tcme.manager.PaymentRequestManager; +import com.gvsds.tcme.manager.PlayerSettingsManager; +import com.gvsds.tcme.manager.TransactionManager; +import com.gvsds.tcme.i18n.LanguageManager; +import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.message.v1.ServerMessageEvents; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class CardinalCooperativeCapitalismMarketEconomy implements ModInitializer { + public static final String MOD_ID = "cardinal-cooperative-capitalism-market-economy"; + + public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); + + private static ConfigManager configManager; + private static DatabaseManager databaseManager; + private static CurrencyManager currencyManager; + private static BalanceManager balanceManager; + private static TransactionManager transactionManager; + private static PaymentRequestManager paymentRequestManager; + private static PlayerSettingsManager playerSettingsManager; + private static LanguageManager languageManager; + private static ExchangeRateManager exchangeRateManager; + + @Override + public void onInitialize() { + LOGGER.info("Initializing Cardinal Cooperative Capitalism: Market Economy"); + + configManager = new ConfigManager(); + LOGGER.info("Configuration loaded successfully"); + + CMECommand command = new CMECommand(); + command.register(); + LOGGER.info("CME command tree registered via CommandRegistrationCallback"); + + ServerLifecycleEvents.SERVER_STARTING.register(server -> { + LOGGER.info("Server starting, initializing database and managers..."); + Config.Database dbConfig = configManager.getConfig().getDatabase(); + databaseManager = new DatabaseManager(dbConfig); + databaseManager.connect(); + + if (databaseManager.isConnected()) { + boolean isMySQL = "mysql".equalsIgnoreCase(dbConfig.getType()); + Config.Tables tables = dbConfig.getTables(); + + currencyManager = new CurrencyManager(databaseManager, tables); + balanceManager = new BalanceManager(databaseManager, tables, isMySQL); + transactionManager = new TransactionManager(databaseManager, tables, balanceManager, isMySQL); + paymentRequestManager = new PaymentRequestManager(databaseManager, tables, balanceManager, transactionManager, isMySQL); + playerSettingsManager = new PlayerSettingsManager(databaseManager, tables, isMySQL); + exchangeRateManager = new ExchangeRateManager(databaseManager, tables, isMySQL); + languageManager = new LanguageManager(databaseManager, isMySQL); + + CMECommand.setManagers(currencyManager, balanceManager, transactionManager, paymentRequestManager, playerSettingsManager, languageManager, exchangeRateManager); + LOGGER.info("Managers initialized and set to CMECommand"); + } + }); + + ServerMessageEvents.CHAT_MESSAGE.register((message, sender, params) -> { + String text = message.getContent().getString(); + if (InputScreenHandler.hasSession(sender.getUuid())) { + InputScreenHandler.submitInput(sender, text); + } + }); + + ServerLifecycleEvents.SERVER_STOPPING.register(server -> { + LOGGER.info("Server stopping, closing database connection..."); + if (databaseManager != null) { + databaseManager.disconnect(); + } + }); + + LOGGER.info("Initialization complete!"); + } + + public static ConfigManager getConfigManager() { + return configManager; + } + + public static DatabaseManager getDatabaseManager() { + return databaseManager; + } + + public static CurrencyManager getCurrencyManager() { + return currencyManager; + } + + public static BalanceManager getBalanceManager() { + return balanceManager; + } + + public static TransactionManager getTransactionManager() { + return transactionManager; + } + + public static PaymentRequestManager getPaymentRequestManager() { + return paymentRequestManager; + } + + public static PlayerSettingsManager getPlayerSettingsManager() { + return playerSettingsManager; + } + + public static LanguageManager getLanguageManager() { + return languageManager; + } + + public static ExchangeRateManager getExchangeRateManager() { + return exchangeRateManager; + } +} diff --git a/src/main/java/com/gvsds/tcme/command/CMECommand.java b/src/main/java/com/gvsds/tcme/command/CMECommand.java new file mode 100644 index 0000000..5a22403 --- /dev/null +++ b/src/main/java/com/gvsds/tcme/command/CMECommand.java @@ -0,0 +1,2620 @@ +package com.gvsds.tcme.command; + +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.BoolArgumentType; +import com.mojang.brigadier.arguments.DoubleArgumentType; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.minecraft.command.argument.EntityArgumentType; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.LightningEntity; +import net.minecraft.entity.SpawnReason; +import net.minecraft.entity.boss.BossBar; +import net.minecraft.entity.boss.ServerBossBar; +import net.minecraft.particle.ParticleTypes; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.sound.SoundCategory; +import net.minecraft.sound.SoundEvents; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.util.hit.BlockHitResult; +import net.minecraft.util.hit.HitResult; +import net.minecraft.block.BlockState; +import net.minecraft.block.Blocks; +import net.minecraft.block.Block; +import net.minecraft.block.entity.BlockEntity; +import net.minecraft.inventory.Inventory; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.Heightmap; +import net.minecraft.world.RaycastContext; +import net.minecraft.world.World; + +import com.gvsds.tcme.gui.ServerGuiAPI; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static net.minecraft.server.command.CommandManager.argument; +import static net.minecraft.server.command.CommandManager.literal; + +public class CMECommand { + + private static com.gvsds.tcme.manager.CurrencyManager currencyManager; + private static com.gvsds.tcme.manager.BalanceManager balanceManager; + private static com.gvsds.tcme.manager.TransactionManager transactionManager; + private static com.gvsds.tcme.manager.PaymentRequestManager paymentRequestManager; + private static com.gvsds.tcme.manager.PlayerSettingsManager playerSettingsManager; + private static com.gvsds.tcme.i18n.LanguageManager languageManager; + private static com.gvsds.tcme.manager.ExchangeRateManager exchangeRateManager; + + private static final List BoomSessions = new ArrayList<>(); + private static final List MeteoSessions = new ArrayList<>(); + private static final List LineSessions = new ArrayList<>(); + private static final java.util.concurrent.atomic.AtomicInteger NextTestId = new java.util.concurrent.atomic.AtomicInteger(1); + + private interface TestSession { + int getId(); + void cancel(); + String getType(); + String getDescription(); + } + + private static final class GlobalBlockRegistry { + private static final Map> OriginalStates = new HashMap<>(); + private static final Map> OriginalNbt = new HashMap<>(); + private static final Map> RefCounts = new HashMap<>(); + + private static Map getStates(ServerWorld world) { + return OriginalStates.computeIfAbsent(world, k -> new HashMap<>()); + } + private static Map getNbt(ServerWorld world) { + return OriginalNbt.computeIfAbsent(world, k -> new HashMap<>()); + } + private static Map getCounts(ServerWorld world) { + return RefCounts.computeIfAbsent(world, k -> new HashMap<>()); + } + + static boolean acquire(ServerWorld world, BlockPos pos, BlockState currentState, NbtCompound currentNbt) { + Map states = getStates(world); + Map nbt = getNbt(world); + Map counts = getCounts(world); + + int count = counts.getOrDefault(pos, 0); + if (count == 0) { + states.put(pos, currentState); + if (currentNbt != null) nbt.put(pos, currentNbt); + } + counts.put(pos, count + 1); + return count == 0; + } + + static boolean release(ServerWorld world, BlockPos pos) { + Map counts = getCounts(world); + Integer count = counts.get(pos); + if (count == null) return false; + if (count <= 1) { + counts.remove(pos); + return true; + } else { + counts.put(pos, count - 1); + return false; + } + } + + static BlockState getOriginalState(ServerWorld world, BlockPos pos) { + Map states = OriginalStates.get(world); + return states != null ? states.get(pos) : null; + } + + static NbtCompound getOriginalNbt(ServerWorld world, BlockPos pos) { + Map nbt = OriginalNbt.get(world); + return nbt != null ? nbt.get(pos) : null; + } + + static void clearEntry(ServerWorld world, BlockPos pos) { + Map states = OriginalStates.get(world); + Map nbt = OriginalNbt.get(world); + if (states != null) states.remove(pos); + if (nbt != null) nbt.remove(pos); + } + } + + public CMECommand() { + } + + public static void setManagers(com.gvsds.tcme.manager.CurrencyManager currencyManager, + com.gvsds.tcme.manager.BalanceManager balanceManager, + com.gvsds.tcme.manager.TransactionManager transactionManager, + com.gvsds.tcme.manager.PaymentRequestManager paymentRequestManager, + com.gvsds.tcme.manager.PlayerSettingsManager playerSettingsManager, + com.gvsds.tcme.i18n.LanguageManager languageManager, + com.gvsds.tcme.manager.ExchangeRateManager exchangeRateManager) { + CMECommand.currencyManager = currencyManager; + CMECommand.balanceManager = balanceManager; + CMECommand.transactionManager = transactionManager; + CMECommand.paymentRequestManager = paymentRequestManager; + CMECommand.playerSettingsManager = playerSettingsManager; + CMECommand.languageManager = languageManager; + CMECommand.exchangeRateManager = exchangeRateManager; + } + + public void register() { + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> { + buildCommandTree(dispatcher); + }); + registerTickHandler(); + } + + public void registerDirect(CommandDispatcher dispatcher) { + buildCommandTree(dispatcher); + registerTickHandler(); + } + + private static boolean tickHandlerRegistered = false; + + private void registerTickHandler() { + if (tickHandlerRegistered) return; + tickHandlerRegistered = true; + ServerTickEvents.END_SERVER_TICK.register(server -> { + if (!BoomSessions.isEmpty()) { + Iterator iterator = BoomSessions.iterator(); + while (iterator.hasNext()) { + BoomSession session = iterator.next(); + session.tick(); + if (session.isFinished()) { + iterator.remove(); + } + } + } + if (!MeteoSessions.isEmpty()) { + Iterator iterator = MeteoSessions.iterator(); + while (iterator.hasNext()) { + MeteoSession session = iterator.next(); + session.tick(); + if (session.isFinished()) { + iterator.remove(); + } + } + } + if (!LineSessions.isEmpty()) { + Iterator iterator = LineSessions.iterator(); + while (iterator.hasNext()) { + LineSession session = iterator.next(); + session.tick(); + if (session.isFinished()) { + iterator.remove(); + } + } + } + }); + } + + private void buildCommandTree(CommandDispatcher dispatcher) { + var root = literal("cme").requires(source -> source.hasPermissionLevel(0)); + + var admin = literal("admin").requires(source -> source.hasPermissionLevel(4)); + + var coin = literal("coin"); + coin.then(literal("add") + .then(argument("name", StringArgumentType.string()) + .then(argument("symbol", StringArgumentType.string()) + .then(argument("tradable", BoolArgumentType.bool()) + .executes(ctx -> addCoin(ctx, StringArgumentType.getString(ctx, "name"), + StringArgumentType.getString(ctx, "symbol"), + BoolArgumentType.getBool(ctx, "tradable"))))))); + coin.then(literal("delete") + .then(argument("name", StringArgumentType.string()) + .executes(ctx -> deleteCoin(ctx, StringArgumentType.getString(ctx, "name"))))); + coin.then(literal("tradable") + .then(argument("name", StringArgumentType.string()) + .then(argument("tradable", BoolArgumentType.bool()) + .executes(ctx -> setTradable(ctx, StringArgumentType.getString(ctx, "name"), + BoolArgumentType.getBool(ctx, "tradable")))))); + coin.then(literal("list") + .executes(this::listCoins)); + coin.then(literal("rename") + .then(argument("oldName", StringArgumentType.string()) + .then(argument("newName", StringArgumentType.string()) + .executes(ctx -> renameCoin(ctx, StringArgumentType.getString(ctx, "oldName"), + StringArgumentType.getString(ctx, "newName")))))); + coin.then(literal("edit") + .then(argument("name", StringArgumentType.string()) + .then(argument("symbol", StringArgumentType.string()) + .then(argument("tradable", BoolArgumentType.bool()) + .executes(ctx -> editCoin(ctx, StringArgumentType.getString(ctx, "name"), + StringArgumentType.getString(ctx, "symbol"), + BoolArgumentType.getBool(ctx, "tradable"))))))); + coin.then(literal("rate") + .then(argument("currencyName", StringArgumentType.string()) + .then(argument("rate", DoubleArgumentType.doubleArg(0)) + .executes(ctx -> setExchangeRate(ctx, StringArgumentType.getString(ctx, "currencyName"), + DoubleArgumentType.getDouble(ctx, "rate"))))) + .executes(this::listExchangeRates)); + + admin.then(coin); + + var balance = literal("balance"); + balance.then(literal("check") + .then(argument("player", EntityArgumentType.player()) + .then(argument("currencyName", StringArgumentType.string()) + .executes(ctx -> checkBalance(ctx, EntityArgumentType.getPlayer(ctx, "player"), + StringArgumentType.getString(ctx, "currencyName")))))); + balance.then(literal("add") + .then(argument("player", EntityArgumentType.player()) + .then(argument("currencyName", StringArgumentType.string()) + .then(argument("amount", DoubleArgumentType.doubleArg(0)) + .executes(ctx -> adminModifyBalance(ctx, EntityArgumentType.getPlayer(ctx, "player"), + StringArgumentType.getString(ctx, "currencyName"), + DoubleArgumentType.getDouble(ctx, "amount"), + true)))))); + balance.then(literal("remove") + .then(argument("player", EntityArgumentType.player()) + .then(argument("currencyName", StringArgumentType.string()) + .then(argument("amount", DoubleArgumentType.doubleArg(0)) + .executes(ctx -> adminModifyBalance(ctx, EntityArgumentType.getPlayer(ctx, "player"), + StringArgumentType.getString(ctx, "currencyName"), + DoubleArgumentType.getDouble(ctx, "amount"), + false)))))); + + admin.then(balance); + + root.then(admin); + + root.then(literal("wallet") + .executes(this::showWallet)); + + root.then(literal("lang") + .executes(this::showLanguageList) + .then(argument("language", StringArgumentType.string()) + .executes(ctx -> setLanguage(ctx, StringArgumentType.getString(ctx, "language"))))); + + root.then(literal("convert") + .then(argument("amount", DoubleArgumentType.doubleArg(0)) + .then(argument("fromCurrency", StringArgumentType.string()) + .then(argument("toCurrency", StringArgumentType.string()) + .executes(ctx -> previewConvert(ctx, DoubleArgumentType.getDouble(ctx, "amount"), + StringArgumentType.getString(ctx, "fromCurrency"), + StringArgumentType.getString(ctx, "toCurrency"))) + .then(literal("confirm") + .executes(ctx -> executeConvert(ctx, DoubleArgumentType.getDouble(ctx, "amount"), + StringArgumentType.getString(ctx, "fromCurrency"), + StringArgumentType.getString(ctx, "toCurrency")))))))); + + root.then(literal("pay") + .then(argument("player", EntityArgumentType.player()) + .then(argument("amount", DoubleArgumentType.doubleArg(0)) + .executes(ctx -> pay(ctx, EntityArgumentType.getPlayer(ctx, "player"), + DoubleArgumentType.getDouble(ctx, "amount"), + null)) + .then(argument("currency", StringArgumentType.string()) + .executes(ctx -> pay(ctx, EntityArgumentType.getPlayer(ctx, "player"), + DoubleArgumentType.getDouble(ctx, "amount"), + StringArgumentType.getString(ctx, "currency"))))))); + + root.then(literal("apay") + .then(argument("player", EntityArgumentType.player()) + .then(argument("amount", DoubleArgumentType.doubleArg(0)) + .executes(ctx -> apay(ctx, EntityArgumentType.getPlayer(ctx, "player"), + DoubleArgumentType.getDouble(ctx, "amount"), + null)) + .then(argument("currency", StringArgumentType.string()) + .executes(ctx -> apay(ctx, EntityArgumentType.getPlayer(ctx, "player"), + DoubleArgumentType.getDouble(ctx, "amount"), + StringArgumentType.getString(ctx, "currency"))))))); + + root.then(literal("requests") + .executes(this::listRequests) + .then(literal("accept") + .then(argument("requestId", IntegerArgumentType.integer()) + .executes(ctx -> acceptRequest(ctx, IntegerArgumentType.getInteger(ctx, "requestId"))))) + .then(literal("reject") + .then(argument("requestId", IntegerArgumentType.integer()) + .executes(ctx -> rejectRequest(ctx, IntegerArgumentType.getInteger(ctx, "requestId"))))) + .then(literal("cancel") + .then(argument("requestId", IntegerArgumentType.integer()) + .executes(ctx -> cancelRequest(ctx, IntegerArgumentType.getInteger(ctx, "requestId")))))); + + var uidemo = literal("uidemo"); + uidemo.then(literal("alert") + .executes(this::demoAlert)); + uidemo.then(literal("confirm") + .executes(this::demoConfirm)); + uidemo.then(literal("input") + .executes(this::demoInput)); + uidemo.then(literal("smallchest") + .executes(this::demoSmallChest)); + uidemo.then(literal("mediumchest") + .executes(this::demoMediumChest)); + uidemo.then(literal("largechest") + .executes(this::demoLargeChest)); + uidemo.then(literal("anvil") + .executes(this::demoAnvil)); + + root.then(uidemo); + + var test = literal("test").requires(source -> source.hasPermissionLevel(2)); + test.then(literal("boom") + .executes(ctx -> boomRaycast(ctx, 10, 5)) + .then(argument("x", IntegerArgumentType.integer()) + .suggests((ctx, builder) -> { + BlockPos pos = getTargetedBlockPos(ctx.getSource()); + if (pos != null) builder.suggest(pos.getX()); + return builder.buildFuture(); + }) + .then(argument("y", IntegerArgumentType.integer()) + .suggests((ctx, builder) -> { + BlockPos pos = getTargetedBlockPos(ctx.getSource()); + if (pos != null) builder.suggest(pos.getY()); + return builder.buildFuture(); + }) + .then(argument("z", IntegerArgumentType.integer()) + .suggests((ctx, builder) -> { + BlockPos pos = getTargetedBlockPos(ctx.getSource()); + if (pos != null) builder.suggest(pos.getZ()); + return builder.buildFuture(); + }) + .executes(ctx -> boom(ctx, IntegerArgumentType.getInteger(ctx, "x"), IntegerArgumentType.getInteger(ctx, "y"), IntegerArgumentType.getInteger(ctx, "z"), 10, 5)) + .then(argument("range", IntegerArgumentType.integer(1)) + .executes(ctx -> boom(ctx, IntegerArgumentType.getInteger(ctx, "x"), IntegerArgumentType.getInteger(ctx, "y"), IntegerArgumentType.getInteger(ctx, "z"), IntegerArgumentType.getInteger(ctx, "range"), 5)) + .then(argument("depth", IntegerArgumentType.integer(-1)) + .executes(ctx -> boom(ctx, IntegerArgumentType.getInteger(ctx, "x"), IntegerArgumentType.getInteger(ctx, "y"), IntegerArgumentType.getInteger(ctx, "z"), IntegerArgumentType.getInteger(ctx, "range"), IntegerArgumentType.getInteger(ctx, "depth"))))))))); + test.then(literal("meteo") + .executes(ctx -> meteoRaycast(ctx)) + .then(argument("x", IntegerArgumentType.integer()) + .suggests((ctx, builder) -> { + BlockPos pos = getTargetedBlockPos(ctx.getSource()); + if (pos != null) builder.suggest(pos.getX()); + return builder.buildFuture(); + }) + .then(argument("y", IntegerArgumentType.integer()) + .suggests((ctx, builder) -> { + BlockPos pos = getTargetedBlockPos(ctx.getSource()); + if (pos != null) builder.suggest(pos.getY()); + return builder.buildFuture(); + }) + .then(argument("z", IntegerArgumentType.integer()) + .suggests((ctx, builder) -> { + BlockPos pos = getTargetedBlockPos(ctx.getSource()); + if (pos != null) builder.suggest(pos.getZ()); + return builder.buildFuture(); + }) + .executes(ctx -> meteo(ctx, IntegerArgumentType.getInteger(ctx, "x"), IntegerArgumentType.getInteger(ctx, "y"), IntegerArgumentType.getInteger(ctx, "z"))))))); + test.then(literal("line") + .executes(ctx -> lineRaycast(ctx, 10)) + .then(argument("x", IntegerArgumentType.integer()) + .suggests((ctx, builder) -> { + BlockPos pos = getTargetedBlockPos(ctx.getSource()); + if (pos != null) builder.suggest(pos.getX()); + return builder.buildFuture(); + }) + .then(argument("z", IntegerArgumentType.integer()) + .suggests((ctx, builder) -> { + BlockPos pos = getTargetedBlockPos(ctx.getSource()); + if (pos != null) builder.suggest(pos.getZ()); + return builder.buildFuture(); + }) + .executes(ctx -> line(ctx, IntegerArgumentType.getInteger(ctx, "x"), IntegerArgumentType.getInteger(ctx, "z"), 10)) + .then(argument("range", IntegerArgumentType.integer(1)) + .executes(ctx -> line(ctx, IntegerArgumentType.getInteger(ctx, "x"), IntegerArgumentType.getInteger(ctx, "z"), IntegerArgumentType.getInteger(ctx, "range"))))))); + test.then(literal("sq") + .executes(ctx -> sqDemoRaycast(ctx)) + .then(argument("x", IntegerArgumentType.integer()) + .suggests((ctx, builder) -> { + BlockPos pos = getTargetedBlockPos(ctx.getSource()); + if (pos != null) builder.suggest(pos.getX()); + return builder.buildFuture(); + }) + .then(argument("y", IntegerArgumentType.integer()) + .suggests((ctx, builder) -> { + BlockPos pos = getTargetedBlockPos(ctx.getSource()); + if (pos != null) builder.suggest(pos.getY()); + return builder.buildFuture(); + }) + .then(argument("z", IntegerArgumentType.integer()) + .suggests((ctx, builder) -> { + BlockPos pos = getTargetedBlockPos(ctx.getSource()); + if (pos != null) builder.suggest(pos.getZ()); + return builder.buildFuture(); + }) + .executes(ctx -> sqDemo(ctx, IntegerArgumentType.getInteger(ctx, "x"), IntegerArgumentType.getInteger(ctx, "y"), IntegerArgumentType.getInteger(ctx, "z"), 10)) + .then(argument("range", IntegerArgumentType.integer(1)) + .executes(ctx -> sqDemo(ctx, IntegerArgumentType.getInteger(ctx, "x"), IntegerArgumentType.getInteger(ctx, "y"), IntegerArgumentType.getInteger(ctx, "z"), IntegerArgumentType.getInteger(ctx, "range")))))))); + test.then(literal("list") + .executes(this::listTestSessions)); + test.then(literal("cancel") + .then(argument("id", IntegerArgumentType.integer(1)) + .executes(ctx -> cancelTestSession(ctx, IntegerArgumentType.getInteger(ctx, "id"))))); + root.then(test); + + dispatcher.register(root); + } + + private void sendMsg(ServerPlayerEntity player, String key, Object... args) { + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), key), args)).formatted(Formatting.WHITE)); + } + + private void sendFeedback(ServerCommandSource source, String key, Object... args) { + ServerPlayerEntity player = source.getPlayer(); + if (player != null) { + source.sendFeedback(() -> Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), key), args)).formatted(Formatting.WHITE), false); + } else { + source.sendFeedback(() -> Text.literal(String.format(key, args)).formatted(Formatting.WHITE), false); + } + } + + private int addCoin(CommandContext ctx, String name, String symbol, boolean tradable) { + ServerPlayerEntity player = ctx.getSource().getPlayer(); + Optional result = currencyManager.createCurrency(name, symbol, tradable); + if (result.isPresent()) { + sendFeedback(ctx.getSource(), "cme.currency.created", name, symbol, tradable); + return 1; + } + sendFeedback(ctx.getSource(), "cme.currency.create_failed", name); + return 0; + } + + private int deleteCoin(CommandContext ctx, String name) { + if (currencyManager.deleteCurrency(name)) { + sendFeedback(ctx.getSource(), "cme.currency.deleted", name); + return 1; + } + sendFeedback(ctx.getSource(), "cme.currency.delete_failed", name); + return 0; + } + + private int renameCoin(CommandContext ctx, String oldName, String newName) { + if (currencyManager.renameCurrency(oldName, newName)) { + sendFeedback(ctx.getSource(), "cme.currency.renamed", oldName, newName); + return 1; + } + sendFeedback(ctx.getSource(), "cme.currency.rename_failed", oldName); + return 0; + } + + private int editCoin(CommandContext ctx, String name, String symbol, boolean tradable) { + if (currencyManager.updateCurrency(name, symbol, tradable)) { + sendFeedback(ctx.getSource(), "cme.currency.updated", name, symbol, tradable); + return 1; + } + sendFeedback(ctx.getSource(), "cme.currency.update_failed", name); + return 0; + } + + private int setTradable(CommandContext ctx, String name, boolean tradable) { + Optional currency = currencyManager.getCurrencyByName(name); + if (currency.isEmpty()) { + sendFeedback(ctx.getSource(), "cme.currency.not_found", name); + return 0; + } + if (currencyManager.setTradable(currency.get().getId(), tradable)) { + sendFeedback(ctx.getSource(), "cme.currency.tradable", name, tradable); + return 1; + } + sendFeedback(ctx.getSource(), "cme.currency.tradable_failed", name); + return 0; + } + + private int listCoins(CommandContext ctx) { + List currencies = currencyManager.getAllCurrencies(); + if (currencies.isEmpty()) { + sendFeedback(ctx.getSource(), "cme.currency.no_currencies"); + return 0; + } + sendFeedback(ctx.getSource(), "cme.currency.list"); + for (com.gvsds.tcme.model.Currency c : currencies) { + double rate = exchangeRateManager.getRate(c.getId()); + sendFeedback(ctx.getSource(), "cme.currency.list_item", c.getName(), c.getSymbol(), c.isTradable(), rate); + } + return 1; + } + + private int checkBalance(CommandContext ctx, ServerPlayerEntity player, String currencyName) { + Optional currency = currencyManager.getCurrencyByName(currencyName); + if (currency.isEmpty()) { + sendFeedback(ctx.getSource(), "cme.currency.not_found", currencyName); + return 0; + } + double balance = balanceManager.getBalance(player.getUuid(), currency.get().getId()); + sendFeedback(ctx.getSource(), "cme.balance.check", player.getName().getString(), balance, currency.get().getSymbol()); + return 1; + } + + private int adminModifyBalance(CommandContext ctx, ServerPlayerEntity player, String currencyName, double amount, boolean add) { + Optional currency = currencyManager.getCurrencyByName(currencyName); + if (currency.isEmpty()) { + sendFeedback(ctx.getSource(), "cme.currency.not_found", currencyName); + return 0; + } + + boolean success; + if (add) { + success = balanceManager.addBalance(player.getUuid(), currency.get().getId(), amount); + } else { + success = balanceManager.subtractBalance(player.getUuid(), currency.get().getId(), amount); + } + + if (success) { + String action = add ? "Added" : "Removed"; + sendFeedback(ctx.getSource(), "cme.balance." + (add ? "added" : "removed"), amount, currency.get().getSymbol(), player.getName().getString()); + return 1; + } + sendFeedback(ctx.getSource(), "cme.balance.failed", add ? "add" : "remove"); + return 0; + } + + private int pay(CommandContext ctx, ServerPlayerEntity target, double amount, String currencyName) { + ServerPlayerEntity source = ctx.getSource().getPlayer(); + if (source == null) return 0; + + int currencyId; + com.gvsds.tcme.model.Currency currency; + + if (currencyName == null) { + List currencies = currencyManager.getAllCurrencies(); + if (currencies.isEmpty()) { + source.sendMessage(Text.literal(languageManager.getMessageRaw(source.getUuid(), "cme.currency.no_available")).formatted(Formatting.RED)); + return 0; + } + currency = currencies.get(0); + currencyId = currency.getId(); + } else { + Optional opt = currencyManager.getCurrencyByName(currencyName); + if (opt.isEmpty()) { + source.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(source.getUuid(), "cme.currency.not_found"), currencyName)).formatted(Formatting.RED)); + return 0; + } + currency = opt.get(); + currencyId = currency.getId(); + } + + if (!currency.isTradable()) { + source.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(source.getUuid(), "cme.currency.not_tradable"), currency.getName())).formatted(Formatting.RED)); + return 0; + } + + if (source.getUuid().equals(target.getUuid())) { + source.sendMessage(Text.literal(languageManager.getMessageRaw(source.getUuid(), "cme.pay.self")).formatted(Formatting.RED)); + return 0; + } + + if (transactionManager.transfer(source.getUuid(), target.getUuid(), currencyId, amount, "pay", "Player transfer")) { + source.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(source.getUuid(), "cme.pay.success"), amount, currency.getSymbol(), target.getName().getString())).formatted(Formatting.GREEN)); + target.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(target.getUuid(), "cme.pay.received"), source.getName().getString(), amount, currency.getSymbol())).formatted(Formatting.GREEN)); + return 1; + } + source.sendMessage(Text.literal(languageManager.getMessageRaw(source.getUuid(), "cme.pay.failed")).formatted(Formatting.RED)); + return 0; + } + + private int apay(CommandContext ctx, ServerPlayerEntity target, double amount, String currencyName) { + ServerPlayerEntity source = ctx.getSource().getPlayer(); + if (source == null) return 0; + + int currencyId; + com.gvsds.tcme.model.Currency currency; + + if (currencyName == null) { + List currencies = currencyManager.getAllCurrencies(); + if (currencies.isEmpty()) { + source.sendMessage(Text.literal(languageManager.getMessageRaw(source.getUuid(), "cme.currency.no_available")).formatted(Formatting.RED)); + return 0; + } + currency = currencies.get(0); + currencyId = currency.getId(); + } else { + Optional opt = currencyManager.getCurrencyByName(currencyName); + if (opt.isEmpty()) { + source.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(source.getUuid(), "cme.currency.not_found"), currencyName)).formatted(Formatting.RED)); + return 0; + } + currency = opt.get(); + currencyId = currency.getId(); + } + + if (source.getUuid().equals(target.getUuid())) { + source.sendMessage(Text.literal(languageManager.getMessageRaw(source.getUuid(), "cme.apay.self")).formatted(Formatting.RED)); + return 0; + } + + Optional request = paymentRequestManager.createRequest(source.getUuid(), target.getUuid(), currencyId, amount); + if (request.isPresent()) { + source.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(source.getUuid(), "cme.apay.sent"), target.getName().getString(), amount, currency.getSymbol(), request.get().getId())).formatted(Formatting.GREEN)); + target.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(target.getUuid(), "cme.apay.received"), source.getName().getString(), amount, currency.getSymbol(), request.get().getId())).formatted(Formatting.YELLOW)); + + target.sendMessage(Text.literal(String.format("§e§l%s §r§7向你发起了付款请求: §e%.2f %s", source.getName().getString(), amount, currency.getSymbol())).formatted(Formatting.YELLOW), true); + target.playSound(SoundEvents.BLOCK_BELL_USE, 1.0f, 1.2f); + target.playSound(SoundEvents.ENTITY_EXPERIENCE_ORB_PICKUP, 0.8f, 1.0f); + + return 1; + } + source.sendMessage(Text.literal(languageManager.getMessageRaw(source.getUuid(), "cme.apay.failed")).formatted(Formatting.RED)); + return 0; + } + + private int listRequests(CommandContext ctx) { + ServerPlayerEntity player = ctx.getSource().getPlayer(); + if (player == null) return 0; + + List requests = paymentRequestManager.getPendingRequests(player.getUuid()); + if (requests.isEmpty()) { + player.sendMessage(Text.literal(languageManager.getMessageRaw(player.getUuid(), "cme.requests.empty")).formatted(Formatting.YELLOW)); + return 0; + } + + player.sendMessage(Text.literal(languageManager.getMessageRaw(player.getUuid(), "cme.requests.list")).formatted(Formatting.AQUA)); + for (com.gvsds.tcme.model.PaymentRequest req : requests) { + Optional currency = currencyManager.getCurrency(req.getCurrencyId()); + String currencyName = currency.map(c -> c.getName()).orElse(String.valueOf(req.getCurrencyId())); + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.requests.item"), req.getId(), req.getAmount(), currencyName)).formatted(Formatting.WHITE)); + } + return 1; + } + + private int acceptRequest(CommandContext ctx, int requestId) { + ServerPlayerEntity player = ctx.getSource().getPlayer(); + if (player == null) return 0; + + UUID fromUUID = null; + double amount = 0; + int currencyId = 0; + for (com.gvsds.tcme.model.PaymentRequest req : paymentRequestManager.getPendingRequests(player.getUuid())) { + if (req.getId() == requestId) { + fromUUID = req.getFromUUID(); + amount = req.getAmount(); + currencyId = req.getCurrencyId(); + break; + } + } + + if (paymentRequestManager.acceptRequest(requestId)) { + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.requests.accepted"), requestId)).formatted(Formatting.GREEN)); + + if (fromUUID != null) { + ServerPlayerEntity fromPlayer = ctx.getSource().getServer().getPlayerManager().getPlayer(fromUUID); + if (fromPlayer != null) { + Optional currency = currencyManager.getCurrency(currencyId); + String currencySymbol = currency.map(com.gvsds.tcme.model.Currency::getSymbol).orElse("?"); + String fromName = player.getName().getString(); + + fromPlayer.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(fromPlayer.getUuid(), "cme.requests.accepted_by"), fromName, requestId, amount, currencySymbol)).formatted(Formatting.GREEN)); + fromPlayer.sendMessage(Text.literal(String.format("§a§l%s §r§7已接受你的付款请求: §a%.2f %s", fromName, amount, currencySymbol)).formatted(Formatting.GREEN), true); + fromPlayer.playSound(SoundEvents.ENTITY_PLAYER_LEVELUP, 1.0f, 1.0f); + fromPlayer.playSound(SoundEvents.UI_TOAST_CHALLENGE_COMPLETE, 0.8f, 1.0f); + } + } + + return 1; + } + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.requests.accept_failed"), requestId)).formatted(Formatting.RED)); + return 0; + } + + private int cancelRequest(CommandContext ctx, int requestId) { + ServerPlayerEntity player = ctx.getSource().getPlayer(); + if (player == null) return 0; + + Optional opt = paymentRequestManager.getRequestById(requestId); + if (opt.isEmpty() || !opt.get().getFromUUID().equals(player.getUuid())) { + player.sendMessage(Text.literal(languageManager.getMessageRaw(player.getUuid(), "cme.requests.not_yours")).formatted(Formatting.RED)); + return 0; + } + + com.gvsds.tcme.model.PaymentRequest req = opt.get(); + if (!"pending".equals(req.getStatus())) { + player.sendMessage(Text.literal(languageManager.getMessageRaw(player.getUuid(), "cme.requests.not_pending")).formatted(Formatting.RED)); + return 0; + } + + if (paymentRequestManager.cancelRequest(requestId)) { + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.requests.revoked"), requestId)).formatted(Formatting.GREEN)); + + ServerPlayerEntity toPlayer = ctx.getSource().getServer().getPlayerManager().getPlayer(req.getToUUID()); + if (toPlayer != null) { + Optional currency = currencyManager.getCurrency(req.getCurrencyId()); + String currencySymbol = currency.map(com.gvsds.tcme.model.Currency::getSymbol).orElse("?"); + String fromName = player.getName().getString(); + + toPlayer.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(toPlayer.getUuid(), "cme.requests.revoked_by"), fromName, requestId, req.getAmount(), currencySymbol)).formatted(Formatting.YELLOW)); + toPlayer.playSound(SoundEvents.BLOCK_BELL_USE, 1.0f, 0.8f); + } + + return 1; + } + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.requests.revoke_failed"), requestId)).formatted(Formatting.RED)); + return 0; + } + + private int rejectRequest(CommandContext ctx, int requestId) { + ServerPlayerEntity player = ctx.getSource().getPlayer(); + if (player == null) return 0; + + Optional opt = paymentRequestManager.getRequestById(requestId); + if (opt.isEmpty() || !opt.get().getToUUID().equals(player.getUuid())) { + player.sendMessage(Text.literal(languageManager.getMessageRaw(player.getUuid(), "cme.requests.not_yours")).formatted(Formatting.RED)); + return 0; + } + + com.gvsds.tcme.model.PaymentRequest req = opt.get(); + if (!"pending".equals(req.getStatus())) { + player.sendMessage(Text.literal(languageManager.getMessageRaw(player.getUuid(), "cme.requests.not_pending")).formatted(Formatting.RED)); + return 0; + } + + if (paymentRequestManager.rejectRequest(requestId)) { + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.requests.rejected"), requestId)).formatted(Formatting.GREEN)); + + ServerPlayerEntity fromPlayer = ctx.getSource().getServer().getPlayerManager().getPlayer(req.getFromUUID()); + if (fromPlayer != null) { + Optional currency = currencyManager.getCurrency(req.getCurrencyId()); + String currencySymbol = currency.map(com.gvsds.tcme.model.Currency::getSymbol).orElse("?"); + String toName = player.getName().getString(); + + fromPlayer.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(fromPlayer.getUuid(), "cme.requests.rejected_by"), toName, requestId, req.getAmount(), currencySymbol)).formatted(Formatting.RED)); + fromPlayer.sendMessage(Text.literal(String.format("§c§l%s §r§7拒绝了你的付款请求: §c%.2f %s", toName, req.getAmount(), currencySymbol)).formatted(Formatting.RED), true); + fromPlayer.playSound(SoundEvents.ENTITY_VILLAGER_NO, 1.0f, 1.0f); + fromPlayer.playSound(SoundEvents.BLOCK_BELL_USE, 1.0f, 0.6f); + } + + return 1; + } + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.requests.reject_failed"), requestId)).formatted(Formatting.RED)); + return 0; + } + + private int showWallet(CommandContext ctx) { + ServerPlayerEntity player = ctx.getSource().getPlayer(); + if (player == null) return 0; + + List currencies = currencyManager.getAllCurrencies(); + if (currencies.isEmpty()) { + player.sendMessage(Text.literal(languageManager.getMessageRaw(player.getUuid(), "cme.currency.no_currencies")).formatted(Formatting.YELLOW)); + return 0; + } + + player.sendMessage(Text.literal(languageManager.getMessageRaw(player.getUuid(), "cme.wallet.title")).formatted(Formatting.AQUA)); + boolean hasBalance = false; + for (com.gvsds.tcme.model.Currency c : currencies) { + double balance = balanceManager.getBalance(player.getUuid(), c.getId()); + if (balance > 0) { + hasBalance = true; + String msg = String.format(languageManager.getMessageRaw(player.getUuid(), "cme.wallet.balance"), c.getName(), balance, c.getSymbol()); + player.sendMessage(Text.literal(msg).formatted(Formatting.WHITE)); + } + } + if (!hasBalance) { + player.sendMessage(Text.literal(languageManager.getMessageRaw(player.getUuid(), "cme.wallet.empty")).formatted(Formatting.YELLOW)); + } + return 1; + } + + private int showLanguageList(CommandContext ctx) { + ServerPlayerEntity player = ctx.getSource().getPlayer(); + if (player == null) return 0; + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.lang.available"), languageManager.getAvailableLanguages())).formatted(Formatting.AQUA)); + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.lang.usage"))).formatted(Formatting.GRAY)); + return 1; + } + + private int setLanguage(CommandContext ctx, String language) { + ServerPlayerEntity player = ctx.getSource().getPlayer(); + if (player == null) return 0; + + language = language.toLowerCase(); + if (!languageManager.hasLanguage(language)) { + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.lang.notfound"), language)).formatted(Formatting.RED)); + return 0; + } + + languageManager.setPlayerLanguage(player.getUuid(), language); + playerSettingsManager.setLanguage(player.getUuid(), language); + + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.lang.changed"), language)).formatted(Formatting.GREEN)); + return 1; + } + + private int setExchangeRate(CommandContext ctx, String currencyName, double rate) { + Optional currency = currencyManager.getCurrencyByName(currencyName); + if (currency.isEmpty()) { + sendFeedback(ctx.getSource(), "cme.currency.not_found", currencyName); + return 0; + } + exchangeRateManager.setRate(currency.get().getId(), rate); + sendFeedback(ctx.getSource(), "cme.exchange_rate.set", currencyName, rate); + return 1; + } + + private int listExchangeRates(CommandContext ctx) { + List currencies = currencyManager.getAllCurrencies(); + if (currencies.isEmpty()) { + sendFeedback(ctx.getSource(), "cme.currency.no_currencies"); + return 0; + } + sendFeedback(ctx.getSource(), "cme.exchange_rate.list"); + for (com.gvsds.tcme.model.Currency c : currencies) { + double rate = exchangeRateManager.getRate(c.getId()); + sendFeedback(ctx.getSource(), "cme.exchange_rate.item", c.getName(), rate); + } + return 1; + } + + private int previewConvert(CommandContext ctx, double amount, String fromCurrency, String toCurrency) { + ServerPlayerEntity player = ctx.getSource().getPlayer(); + if (player == null) return 0; + + if (amount <= 0) { + player.sendMessage(Text.literal(languageManager.getMessageRaw(player.getUuid(), "cme.convert.invalid_amount")).formatted(Formatting.RED)); + return 0; + } + + Optional fromOpt = currencyManager.getCurrencyByName(fromCurrency); + Optional toOpt = currencyManager.getCurrencyByName(toCurrency); + + if (fromOpt.isEmpty()) { + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.currency.not_found"), fromCurrency)).formatted(Formatting.RED)); + return 0; + } + if (toOpt.isEmpty()) { + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.currency.not_found"), toCurrency)).formatted(Formatting.RED)); + return 0; + } + + com.gvsds.tcme.model.Currency from = fromOpt.get(); + com.gvsds.tcme.model.Currency to = toOpt.get(); + + if (from.getId() == to.getId()) { + player.sendMessage(Text.literal(languageManager.getMessageRaw(player.getUuid(), "cme.convert.same_currency")).formatted(Formatting.RED)); + return 0; + } + + double fromRate = exchangeRateManager.getRate(from.getId()); + double toRate = exchangeRateManager.getRate(to.getId()); + double convertedAmount = exchangeRateManager.convert(amount, from.getId(), to.getId()); + double currentBalance = balanceManager.getBalance(player.getUuid(), from.getId()); + + if (currentBalance < amount) { + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.convert.insufficient"), currentBalance, from.getSymbol(), amount)).formatted(Formatting.RED)); + return 0; + } + + player.sendMessage(Text.literal(languageManager.getMessageRaw(player.getUuid(), "cme.convert.title")).formatted(Formatting.AQUA)); + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.convert.from"), amount, from.getName(), from.getSymbol())).formatted(Formatting.WHITE)); + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.convert.to"), String.format("%.2f", convertedAmount), to.getName(), to.getSymbol())).formatted(Formatting.GREEN)); + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.convert.rate"), from.getName(), to.getName(), fromRate, toRate)).formatted(Formatting.GRAY)); + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.convert.balance_from"), currentBalance, from.getSymbol())).formatted(Formatting.GRAY)); + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.convert.balance_to"), String.format("%.2f", balanceManager.getBalance(player.getUuid(), to.getId()) + convertedAmount), to.getSymbol())).formatted(Formatting.GRAY)); + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.convert.prompt"), amount, fromCurrency, toCurrency)).formatted(Formatting.YELLOW)); + return 1; + } + + private int executeConvert(CommandContext ctx, double amount, String fromCurrency, String toCurrency) { + ServerPlayerEntity player = ctx.getSource().getPlayer(); + if (player == null) return 0; + + if (amount <= 0) { + player.sendMessage(Text.literal(languageManager.getMessageRaw(player.getUuid(), "cme.convert.invalid_amount")).formatted(Formatting.RED)); + return 0; + } + + Optional fromOpt = currencyManager.getCurrencyByName(fromCurrency); + Optional toOpt = currencyManager.getCurrencyByName(toCurrency); + + if (fromOpt.isEmpty()) { + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.currency.not_found"), fromCurrency)).formatted(Formatting.RED)); + return 0; + } + if (toOpt.isEmpty()) { + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.currency.not_found"), toCurrency)).formatted(Formatting.RED)); + return 0; + } + + com.gvsds.tcme.model.Currency from = fromOpt.get(); + com.gvsds.tcme.model.Currency to = toOpt.get(); + + if (from.getId() == to.getId()) { + player.sendMessage(Text.literal(languageManager.getMessageRaw(player.getUuid(), "cme.convert.same_currency")).formatted(Formatting.RED)); + return 0; + } + + double convertedAmount = exchangeRateManager.convert(amount, from.getId(), to.getId()); + double currentBalance = balanceManager.getBalance(player.getUuid(), from.getId()); + + if (currentBalance < amount) { + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.convert.insufficient"), currentBalance, from.getSymbol(), amount)).formatted(Formatting.RED)); + return 0; + } + + boolean withdrawn = balanceManager.subtractBalance(player.getUuid(), from.getId(), amount); + if (!withdrawn) { + player.sendMessage(Text.literal(languageManager.getMessageRaw(player.getUuid(), "cme.convert.failed")).formatted(Formatting.RED)); + return 0; + } + + boolean deposited = balanceManager.addBalance(player.getUuid(), to.getId(), convertedAmount); + if (!deposited) { + balanceManager.addBalance(player.getUuid(), from.getId(), amount); + player.sendMessage(Text.literal(languageManager.getMessageRaw(player.getUuid(), "cme.convert.refunded")).formatted(Formatting.RED)); + return 0; + } + + player.sendMessage(Text.literal(String.format(languageManager.getMessageRaw(player.getUuid(), "cme.convert.success"), amount, from.getSymbol(), String.format("%.2f", convertedAmount), to.getSymbol())).formatted(Formatting.GREEN)); + return 1; + } + + private int demoAlert(CommandContext ctx) { + ServerPlayerEntity player = ctx.getSource().getPlayer(); + if (player == null) return 0; + + ServerGuiAPI.showAlert(player, "§6§l[Alert Demo]", + "§e这是一个警告提示框示例!\n§7This is an alert demo!"); + sendFeedback(ctx.getSource(), "cme.uidemo.alert", player.getName().getString()); + return 1; + } + + private int demoConfirm(CommandContext ctx) { + ServerPlayerEntity player = ctx.getSource().getPlayer(); + if (player == null) return 0; + + ServerGuiAPI.showConfirm(player, "§6§l[Confirm Demo]", + "§e你确定要执行此操作吗?\n§7Are you sure?", + (confirmed) -> { + if (confirmed) { + player.sendMessage(Text.literal("§a[GUI] 你点击了确认!You confirmed!").formatted(Formatting.GREEN)); + } else { + player.sendMessage(Text.literal("§c[GUI] 你点击了取消!You cancelled!").formatted(Formatting.RED)); + } + }); + sendFeedback(ctx.getSource(), "cme.uidemo.confirm", player.getName().getString()); + return 1; + } + + private int demoInput(CommandContext ctx) { + ServerPlayerEntity player = ctx.getSource().getPlayer(); + if (player == null) return 0; + + ServerGuiAPI.showInputBox(player, "§6§l[Input Demo]", + "§e请输入内容... / Enter text:", + (input) -> { + if (input != null) { + player.sendMessage(Text.literal("§a[GUI] 你输入了: " + input + " / You entered: " + input).formatted(Formatting.GREEN)); + } else { + player.sendMessage(Text.literal("§c[GUI] 你取消了输入!You cancelled!").formatted(Formatting.RED)); + } + }); + sendFeedback(ctx.getSource(), "cme.uidemo.input", player.getName().getString()); + return 1; + } + + private int demoSmallChest(CommandContext ctx) { + ServerPlayerEntity player = ctx.getSource().getPlayer(); + if (player == null) return 0; + + ServerGuiAPI.showSmallChest(player, "§6§l[Small Chest Demo]", + (slot) -> { + player.sendMessage(Text.literal("§a[GUI] 你点击了槽位 / You clicked slot: " + slot).formatted(Formatting.GREEN)); + }); + sendFeedback(ctx.getSource(), "cme.uidemo.smallchest", player.getName().getString()); + return 1; + } + + private int demoMediumChest(CommandContext ctx) { + ServerPlayerEntity player = ctx.getSource().getPlayer(); + if (player == null) return 0; + + ServerGuiAPI.showMediumChest(player, "§6§l[Medium Chest Demo]", + (slot) -> { + player.sendMessage(Text.literal("§a[GUI] 你点击了槽位 / You clicked slot: " + slot).formatted(Formatting.GREEN)); + }); + sendFeedback(ctx.getSource(), "cme.uidemo.mediumchest", player.getName().getString()); + return 1; + } + + private int demoLargeChest(CommandContext ctx) { + ServerPlayerEntity player = ctx.getSource().getPlayer(); + if (player == null) return 0; + + ServerGuiAPI.showLargeChest(player, "§6§l[Large Chest Demo]", + (slot) -> { + player.sendMessage(Text.literal("§a[GUI] 你点击了槽位 / You clicked slot: " + slot).formatted(Formatting.GREEN)); + }); + sendFeedback(ctx.getSource(), "cme.uidemo.largechest", player.getName().getString()); + return 1; + } + + private int demoAnvil(CommandContext ctx) { + ServerPlayerEntity player = ctx.getSource().getPlayer(); + if (player == null) return 0; + + ServerGuiAPI.showAnvil(player, "§6§l[Anvil Demo]"); + sendFeedback(ctx.getSource(), "cme.uidemo.anvil", player.getName().getString()); + return 1; + } + + private BlockPos getTargetedBlockPos(ServerCommandSource source) { + ServerPlayerEntity player = source.getPlayer(); + if (player == null) return null; + ServerWorld world = source.getWorld(); + RaycastContext context = new RaycastContext( + player.getCameraPosVec(1.0f), + player.getCameraPosVec(1.0f).add(player.getRotationVec(1.0f).multiply(5.0)), + RaycastContext.ShapeType.OUTLINE, + RaycastContext.FluidHandling.NONE, + player + ); + BlockHitResult hitResult = world.raycast(context); + if (hitResult.getType() == HitResult.Type.BLOCK) { + return hitResult.getBlockPos(); + } + return null; + } + + private int meteoRaycast(CommandContext ctx) { + BlockPos pos = getTargetedBlockPos(ctx.getSource()); + if (pos == null) { + ctx.getSource().sendFeedback(() -> Text.literal("§c请对准一个方块!").formatted(Formatting.RED), false); + return 0; + } + return meteo(ctx, pos.getX(), pos.getY(), pos.getZ()); + } + + private int meteo(CommandContext ctx, int x, int y, int z) { + ServerCommandSource source = ctx.getSource(); + ServerWorld world = source.getWorld(); + + com.gvsds.tcme.CardinalCooperativeCapitalismMarketEconomy.LOGGER.info("[METEO] Creating MeteoSession at x={}, y={}, z={}", x, y, z); + MeteoSession session = new MeteoSession(world, x, y, z); + MeteoSessions.add(session); + + source.sendFeedback(() -> Text.literal(String.format("§6§l[METEO#%d] §e陨石袭击已启动!目标: %d, %d, %d", session.getId(), x, y, z)).formatted(Formatting.GOLD), false); + + return 1; + } + + private int lineRaycast(CommandContext ctx, int range) { + BlockPos pos = getTargetedBlockPos(ctx.getSource()); + if (pos == null) { + ctx.getSource().sendFeedback(() -> Text.literal("§c请对准一个方块!").formatted(Formatting.RED), false); + return 0; + } + return line(ctx, pos.getX(), pos.getZ(), range); + } + + private int line(CommandContext ctx, int x, int z, int range) { + ServerCommandSource source = ctx.getSource(); + ServerWorld world = source.getWorld(); + + int topY = world.getTopY(Heightmap.Type.WORLD_SURFACE, x, z); + com.gvsds.tcme.CardinalCooperativeCapitalismMarketEconomy.LOGGER.info("[LINE] Creating LineSession at x={}, z={}, topY={}, range={}", x, z, topY, range); + LineSession session = new LineSession(world, x, z, topY, range); + LineSessions.add(session); + + source.sendFeedback(() -> Text.literal(String.format("§b§l[LINE#%d] §e波纹扩散已启动!中心: %d, %d, %d | 范围: %d", session.getId(), x, topY, z, range)).formatted(Formatting.AQUA), false); + + return 1; + } + + private int boomRaycast(CommandContext ctx, int range, int depth) { + BlockPos pos = getTargetedBlockPos(ctx.getSource()); + if (pos == null) { + ctx.getSource().sendFeedback(() -> Text.literal("§c请对准一个方块!").formatted(Formatting.RED), false); + return 0; + } + return boom(ctx, pos.getX(), pos.getY(), pos.getZ(), range, depth); + } + + private int boom(CommandContext ctx, int x, int y, int z, int range, int depth) { + ServerCommandSource source = ctx.getSource(); + ServerWorld world = source.getWorld(); + + com.gvsds.tcme.CardinalCooperativeCapitalismMarketEconomy.LOGGER.info("[BOOM] Creating BoomSession at x={}, y={}, z={}, range={}, depth={}", x, y, z, range, depth); + BoomSession session = new BoomSession(world, x, y, z, range, depth, false); + BoomSessions.add(session); + + source.sendFeedback(() -> Text.literal(String.format("§c§l[BOOM#%d] §e引爆序列已启动!坐标: %d, %d, %d | 范围: %d | 深度: %s", session.getId(), x, y, z, range, depth == -1 ? "基岩层" : String.valueOf(depth))).formatted(Formatting.RED), false); + + return 1; + } + + private int sqDemoRaycast(CommandContext ctx) { + BlockPos pos = getTargetedBlockPos(ctx.getSource()); + if (pos == null) { + ctx.getSource().sendFeedback(() -> Text.literal("§c请对准一个方块!").formatted(Formatting.RED), false); + return 0; + } + return sqDemo(ctx, pos.getX(), pos.getY(), pos.getZ(), 10); + } + + private int sqDemo(CommandContext ctx, int x, int y, int z, int range) { + ServerCommandSource source = ctx.getSource(); + ServerWorld world = source.getWorld(); + + BoomSession session = new BoomSession(world, x, y, z, range, 5, true); + BoomSessions.add(session); + + source.sendFeedback(() -> Text.literal(String.format("§8§l[SQ#%d] §e球体演示启动!坐标: %d, %d, %d | 范围: %d", session.getId(), x, y, z, range)).formatted(Formatting.DARK_GRAY), false); + + return 1; + } + + private int listTestSessions(CommandContext ctx) { + ServerCommandSource source = ctx.getSource(); + List all = new ArrayList<>(); + all.addAll(BoomSessions); + all.addAll(MeteoSessions); + all.addAll(LineSessions); + + if (all.isEmpty()) { + source.sendFeedback(() -> Text.literal("§7当前没有活动的 test 事件").formatted(Formatting.GRAY), false); + return 0; + } + + source.sendFeedback(() -> Text.literal(String.format("§6§l=== 活动事件列表 (%d) ===", all.size())).formatted(Formatting.GOLD), false); + for (TestSession s : all) { + source.sendFeedback(() -> Text.literal(s.getDescription()).formatted(Formatting.WHITE), false); + } + return all.size(); + } + + private int cancelTestSession(CommandContext ctx, int id) { + ServerCommandSource source = ctx.getSource(); + + TestSession target = null; + for (BoomSession s : BoomSessions) { + if (s.getId() == id) { target = s; break; } + } + if (target == null) { + for (MeteoSession s : MeteoSessions) { + if (s.getId() == id) { target = s; break; } + } + } + if (target == null) { + for (LineSession s : LineSessions) { + if (s.getId() == id) { target = s; break; } + } + } + + if (target == null) { + source.sendFeedback(() -> Text.literal(String.format("§c未找到 ID 为 %d 的活动事件", id)).formatted(Formatting.RED), false); + return 0; + } + + final TestSession finalTarget = target; + source.sendFeedback(() -> Text.literal(String.format("§e正在取消事件 #%d [%s]...", id, finalTarget.getType())).formatted(Formatting.YELLOW), false); + target.cancel(); + source.sendFeedback(() -> Text.literal(String.format("§a已取消事件 #%d [%s]", id, finalTarget.getType())).formatted(Formatting.GREEN), false); + return 1; + } + + private static class BoomSession implements TestSession { + private static final int TOTAL_TICKS = 1200; + private static final int BLACK_SPHERE_GROW_END = 400; + private static final int BLACK_SPHERE_MERGE_END = 500; + private static final int BLACK_SPHERE_SHRINK_END = 600; + private static final int POST_EXPLOSION_DURATION = 400; + private static final int DEMO_CLEANUP_DURATION = 60; + + private final int id = NextTestId.getAndIncrement(); + private final ServerWorld world; + private final int x; + private final int z; + private final double cx; + private final double cz; + private final int range; + private final int depth; + private final double topY; + private int tickCounter = 0; + private boolean finished = false; + private boolean cancelled = false; + private final ServerBossBar bossBar; + private boolean explosionExecuted = false; + private int explosionExecutedTick = -1; + private final boolean demoOnly; + + private final double maxSphereRadius; + private final double sphereCenterY; + private final Set allBuiltPositions = new HashSet<>(); + private final Set lastCenterSphereBlocks = new HashSet<>(); + private final List> lastSmallSphereBlocks = new ArrayList<>(); + private int lastCenterRadius = -1; + private boolean blocksCleared = false; + + BoomSession(ServerWorld world, int x, int y, int z, int range, int depth, boolean demoOnly) { + this.world = world; + this.x = x; + this.z = z; + this.cx = x + 0.5; + this.cz = z + 0.5; + this.range = range; + this.depth = depth; + this.topY = y; + this.demoOnly = demoOnly; + this.maxSphereRadius = Math.min(range * 0.5, 20.0); + this.sphereCenterY = topY; + String prefix = demoOnly ? "§8§l[SQ] §e" : "§c§l[BOOM] §e"; + this.bossBar = new ServerBossBar( + Text.literal(prefix + "初始化中..."), + BossBar.Color.YELLOW, + BossBar.Style.NOTCHED_20 + ); + this.bossBar.setVisible(true); + this.bossBar.setPercent(1.0f); + var players = world.getServer().getPlayerManager().getPlayerList(); + com.gvsds.tcme.CardinalCooperativeCapitalismMarketEconomy.LOGGER.info("[BOOM] BossBar created, adding {} players", players.size()); + for (ServerPlayerEntity player : players) { + this.bossBar.addPlayer(player); + } + for (int i = 0; i < 6; i++) { + lastSmallSphereBlocks.add(new HashSet<>()); + } + updateBossBar(); + } + + boolean isFinished() { + return finished; + } + + @Override + public int getId() { + return id; + } + + @Override + public String getType() { + return demoOnly ? "SQ" : "BOOM"; + } + + @Override + public String getDescription() { + String worldName = world.getRegistryKey().getValue().toString(); + if (demoOnly) { + return String.format("§8[SQ#%d] §7世界: %s §7| 坐标: %d, %d, %d §7| 范围: %d §7| tick: %d/%d", + id, worldName, x, (int) topY, z, range, tickCounter, BLACK_SPHERE_SHRINK_END + DEMO_CLEANUP_DURATION); + } + String phase; + if (explosionExecuted) { + phase = "爆炸后"; + } else if (tickCounter < BLACK_SPHERE_GROW_END) { + phase = "核心凝聚"; + } else if (tickCounter < BLACK_SPHERE_MERGE_END) { + phase = "核心合并"; + } else if (tickCounter < BLACK_SPHERE_SHRINK_END) { + phase = "核心坍缩"; + } else { + phase = "爆发准备"; + } + return String.format("§c[BOOM#%d] §7世界: %s §7| 坐标: %d, %d, %d §7| 范围: %d §7| 深度: %s §7| 阶段: %s §7| tick: %d/%d", + id, worldName, x, (int) topY, z, range, depth == -1 ? "基岩层" : String.valueOf(depth), phase, tickCounter, TOTAL_TICKS); + } + + @Override + public void cancel() { + if (finished) return; + cancelled = true; + if (!blocksCleared) { + clearAllSphereBlocks(); + } + restoreRemainingBlocks(false); + bossBar.clearPlayers(); + bossBar.setVisible(false); + finished = true; + } + + private void restoreRemainingBlocks(boolean checkExplosionRange) { + int centerTopY = (int) topY; + int upwardHeight = Math.max(5, range / 2); + int bottomY = (depth == -1) ? world.getBottomY() + 1 : (centerTopY - depth); + double rangeSq = (double) range * (double) range; + + for (BlockPos pos : allBuiltPositions) { + boolean shouldRestore = GlobalBlockRegistry.release(world, pos); + if (!shouldRestore) continue; + + boolean skip = false; + if (checkExplosionRange) { + int dx = pos.getX() - x; + int dz = pos.getZ() - z; + boolean inRange = (dx * dx + dz * dz) <= rangeSq; + boolean inY = pos.getY() >= bottomY && pos.getY() <= (centerTopY + upwardHeight); + skip = inRange && inY; + } + + if (!skip) { + BlockState original = GlobalBlockRegistry.getOriginalState(world, pos); + if (original != null) { + world.setBlockState(pos, original, RESTORE_FLAGS); + NbtCompound nbt = GlobalBlockRegistry.getOriginalNbt(world, pos); + if (nbt != null) { + BlockEntity newBe = BlockEntity.createFromNbt(pos, original, nbt, world.getRegistryManager()); + if (newBe != null) world.addBlockEntity(newBe); + } + } else { + world.setBlockState(pos, Blocks.AIR.getDefaultState(), RESTORE_FLAGS); + } + } + GlobalBlockRegistry.clearEntry(world, pos); + } + allBuiltPositions.clear(); + } + + void tick() { + if (finished) return; + int t = tickCounter; + + if (demoOnly) { + if (t >= BLACK_SPHERE_SHRINK_END + DEMO_CLEANUP_DURATION) { + bossBar.clearPlayers(); + bossBar.setVisible(false); + finished = true; + } else if (t >= BLACK_SPHERE_SHRINK_END) { + if (t == BLACK_SPHERE_SHRINK_END) { + clearAllSphereBlocks(); + restoreAllRecordedBlocks(); + } + updateBossBarDemo(t); + } else { + updateBossBar(); + if (t < BLACK_SPHERE_GROW_END) { + tickBlackSphereGrow(t); + } else if (t < BLACK_SPHERE_MERGE_END) { + tickBlackSphereMerge(t); + } else { + tickBlackSphereShrink(t); + } + } + tickCounter++; + return; + } + + if (explosionExecuted) { + updateBossBarPostExplosion(); + if (t >= explosionExecutedTick + POST_EXPLOSION_DURATION) { + bossBar.clearPlayers(); + bossBar.setVisible(false); + finished = true; + } + tickCounter++; + return; + } + + updateBossBar(); + + if (t >= TOTAL_TICKS) { + executeFinalExplosion(); + explosionExecuted = true; + explosionExecutedTick = t; + tickCounter++; + return; + } + + if (t < BLACK_SPHERE_GROW_END) { + tickBlackSphereGrow(t); + } else if (t < BLACK_SPHERE_MERGE_END) { + tickBlackSphereMerge(t); + } else if (t < BLACK_SPHERE_SHRINK_END) { + tickBlackSphereShrink(t); + } else { + tickIntensePhase(t); + } + + tickCounter++; + } + + private void updateBossBar() { + int remainingTicks = Math.max(0, TOTAL_TICKS - tickCounter); + int remainingSeconds = remainingTicks / 20; + int minutes = remainingSeconds / 60; + int seconds = remainingSeconds % 60; + String worldName = world.getRegistryKey().getValue().toString(); + + String phaseName; + BossBar.Color color; + if (tickCounter < BLACK_SPHERE_GROW_END) { + phaseName = "§8● 核心凝聚"; + color = BossBar.Color.PURPLE; + } else if (tickCounter < BLACK_SPHERE_MERGE_END) { + phaseName = "§8● 核心合并"; + color = BossBar.Color.PURPLE; + } else if (tickCounter < BLACK_SPHERE_SHRINK_END) { + phaseName = "§8● 核心坍缩"; + color = BossBar.Color.PINK; + } else { + phaseName = "§c☠ 爆发准备"; + color = BossBar.Color.RED; + } + + Text title = Text.literal(String.format("§c§l[BOOM] §e世界: %s §7| §e坐标: %d, %d §7| %s §7| §c倒计时: %02d:%02d", + worldName, x, z, phaseName, minutes, seconds)); + this.bossBar.setName(title); + + float progress = 1.0f - ((float) tickCounter / TOTAL_TICKS); + this.bossBar.setPercent(Math.max(0.0f, Math.min(1.0f, progress))); + this.bossBar.setColor(color); + + refreshBossBarPlayers(); + } + + private void updateBossBarPostExplosion() { + int elapsed = tickCounter - explosionExecutedTick; + int remainingTicks = Math.max(0, POST_EXPLOSION_DURATION - elapsed); + int remainingSeconds = remainingTicks / 20; + String worldName = world.getRegistryKey().getValue().toString(); + + Text title = Text.literal(String.format("§a§l[BOOM] §e世界: %s §7| §e坐标: %d, %d §7| §a爆炸完成 §7| §eHUD消失: %ds", + worldName, x, z, remainingSeconds)); + this.bossBar.setName(title); + + float progress = (float) elapsed / POST_EXPLOSION_DURATION; + this.bossBar.setPercent(Math.max(0.0f, 1.0f - progress)); + this.bossBar.setColor(BossBar.Color.GREEN); + + refreshBossBarPlayers(); + } + + private void updateBossBarDemo(int t) { + int elapsed = t - BLACK_SPHERE_SHRINK_END; + int remainingTicks = Math.max(0, DEMO_CLEANUP_DURATION - elapsed); + int remainingSeconds = remainingTicks / 20; + String worldName = world.getRegistryKey().getValue().toString(); + + Text title = Text.literal(String.format("§8§l[SQ] §e世界: %s §7| §e坐标: %d, %d §7| §a演示完成 §7| §eHUD消失: %ds", + worldName, x, z, remainingSeconds)); + this.bossBar.setName(title); + + float progress = (float) elapsed / DEMO_CLEANUP_DURATION; + this.bossBar.setPercent(Math.max(0.0f, 1.0f - progress)); + this.bossBar.setColor(BossBar.Color.GREEN); + + refreshBossBarPlayers(); + } + + private void refreshBossBarPlayers() { + if (tickCounter % 10 == 0) { + var playerList = world.getServer().getPlayerManager().getPlayerList(); + var existingPlayers = bossBar.getPlayers(); + java.util.Set existingUUIDs = new java.util.HashSet<>(); + for (ServerPlayerEntity p : existingPlayers) { + existingUUIDs.add(p.getUuid()); + } + for (ServerPlayerEntity player : playerList) { + if (!existingUUIDs.contains(player.getUuid())) { + bossBar.addPlayer(player); + } + } + } + } + + private void playSoundToAll(double x, double y, double z, net.minecraft.sound.SoundEvent sound, net.minecraft.sound.SoundCategory category, float volume, float pitch) { + world.playSound(null, x, y, z, sound, category, 10000.0f, pitch); + } + + private void tickBlackSphereGrow(int t) { + double progress = (double) t / BLACK_SPHERE_GROW_END; + int currentRadius = (int) Math.round(progress * maxSphereRadius); + + if (currentRadius != lastCenterRadius) { + if (!lastCenterSphereBlocks.isEmpty()) { + clearSphereBlocks(lastCenterSphereBlocks); + lastCenterSphereBlocks.clear(); + } + if (currentRadius > 0) { + buildSphere((int) cx, (int) sphereCenterY, (int) cz, currentRadius, lastCenterSphereBlocks); + } + lastCenterRadius = currentRadius; + } + + if (t % 5 == 0) { + double initialDistance = maxSphereRadius * 2.0; + double currentDistance = initialDistance - progress * (initialDistance - maxSphereRadius); + int smallRadius = Math.max(1, (int) Math.round(maxSphereRadius * 0.2)); + double rotationAngle = progress * Math.PI * 4; + + for (int i = 0; i < 6; i++) { + if (!lastSmallSphereBlocks.get(i).isEmpty()) { + clearSphereBlocks(lastSmallSphereBlocks.get(i)); + lastSmallSphereBlocks.get(i).clear(); + } + + double angle = (Math.PI * 2 * i) / 6.0 + rotationAngle; + double sx = cx + Math.cos(angle) * currentDistance; + double sz = cz + Math.sin(angle) * currentDistance; + double sy = sphereCenterY; + + buildSphere((int) Math.round(sx), (int) Math.round(sy), (int) Math.round(sz), smallRadius, lastSmallSphereBlocks.get(i)); + } + } + + if (t % 2 == 0) { + spawnBlackSphereParticles(); + } + + if (t % 40 == 0) { + playSoundToAll(cx, sphereCenterY, cz, SoundEvents.BLOCK_END_PORTAL_SPAWN, SoundCategory.BLOCKS, 1.0f, 0.5f); + } + } + + private void tickBlackSphereMerge(int t) { + int relativeT = t - BLACK_SPHERE_GROW_END; + int duration = BLACK_SPHERE_MERGE_END - BLACK_SPHERE_GROW_END; + double progress = (double) relativeT / duration; + + if (relativeT % 5 != 0) { + if (t % 2 == 0) { + spawnBlackSphereParticles(); + double mergeDistance = maxSphereRadius * (1.0 - progress); + double mergeRotation = Math.PI * 4 + progress * Math.PI * 3; + for (int i = 0; i < 6; i++) { + double angle = (Math.PI * 2 * i) / 6.0 + mergeRotation; + double sx = cx + Math.cos(angle) * mergeDistance; + double sz = cz + Math.sin(angle) * mergeDistance; + spawnBlackDripParticles(sx, sphereCenterY, sz, cx, sphereCenterY, cz); + } + } + return; + } + + for (int i = 0; i < 6; i++) { + if (!lastSmallSphereBlocks.get(i).isEmpty()) { + clearSphereBlocks(lastSmallSphereBlocks.get(i)); + lastSmallSphereBlocks.get(i).clear(); + } + } + + int centerRadius = (int) Math.round(maxSphereRadius + progress * 2); + if (centerRadius != lastCenterRadius) { + if (!lastCenterSphereBlocks.isEmpty()) { + clearSphereBlocks(lastCenterSphereBlocks); + lastCenterSphereBlocks.clear(); + } + if (centerRadius > 0) { + buildSphere((int) cx, (int) sphereCenterY, (int) cz, centerRadius, lastCenterSphereBlocks); + } + lastCenterRadius = centerRadius; + } + + double mergeDistance = maxSphereRadius * (1.0 - progress); + int baseSmallRadius = Math.max(1, (int) Math.round(maxSphereRadius * 0.2)); + int smallRadius = (int) Math.max(1, Math.round(baseSmallRadius * (1.0 - progress))); + double mergeRotation = Math.PI * 4 + progress * Math.PI * 3; + + for (int i = 0; i < 6; i++) { + double angle = (Math.PI * 2 * i) / 6.0 + mergeRotation; + double sx = cx + Math.cos(angle) * mergeDistance; + double sz = cz + Math.sin(angle) * mergeDistance; + double sy = sphereCenterY; + + if (smallRadius > 0 && mergeDistance > 0.5) { + buildSphere((int) Math.round(sx), (int) Math.round(sy), (int) Math.round(sz), smallRadius, lastSmallSphereBlocks.get(i)); + } + + spawnBlackDripParticles(sx, sy, sz, cx, sphereCenterY, cz); + } + + spawnBlackSphereParticles(); + + if (relativeT % 20 == 0) { + playSoundToAll(cx, sphereCenterY, cz, SoundEvents.ENTITY_ENDERMAN_TELEPORT, SoundCategory.HOSTILE, 1.0f, 0.5f); + } + } + + private void tickBlackSphereShrink(int t) { + int relativeT = t - BLACK_SPHERE_MERGE_END; + int duration = BLACK_SPHERE_SHRINK_END - BLACK_SPHERE_MERGE_END; + double progress = (double) relativeT / duration; + + if (relativeT % 5 != 0) { + if (t % 2 == 0) { + spawnBlackSphereParticles(); + } + return; + } + + int currentRadius = (int) Math.round((maxSphereRadius + 2) * (1.0 - progress)); + if (currentRadius != lastCenterRadius) { + if (!lastCenterSphereBlocks.isEmpty()) { + clearSphereBlocks(lastCenterSphereBlocks); + lastCenterSphereBlocks.clear(); + } + if (currentRadius > 0) { + buildSphere((int) cx, (int) sphereCenterY, (int) cz, currentRadius, lastCenterSphereBlocks); + } + lastCenterRadius = currentRadius; + } + + spawnBlackSphereParticles(); + + if (relativeT % 20 == 0) { + playSoundToAll(cx, sphereCenterY, cz, SoundEvents.BLOCK_END_PORTAL_FRAME_FILL, SoundCategory.BLOCKS, 1.0f, 0.8f); + } + } + + private void tickIntensePhase(int t) { + int relativeT = t - BLACK_SPHERE_SHRINK_END; + + if (relativeT == 0) { + clearAllSphereBlocks(); + } + + if (t % 2 == 0) { + spawnBurstParticles(); + } + if (t % 10 == 0) { + spawnRangeBorder(); + summonRandomLightningInArea(); + } + if (range >= 8 && t % 30 == 0) { + spawnRandomSmallExplosion(); + } + } + + private static final int SKIP_DROPS_FLAGS = Block.SKIP_DROPS | Block.MOVED; + private static final int BUILD_FLAGS = SKIP_DROPS_FLAGS | Block.NOTIFY_LISTENERS; + private static final int RESTORE_FLAGS = Block.SKIP_DROPS | Block.NOTIFY_LISTENERS; + + private void buildSphere(int centerX, int centerY, int centerZ, int radius, Set blockSet) { + for (int dx = -radius; dx <= radius; dx++) { + for (int dy = -radius; dy <= radius; dy++) { + for (int dz = -radius; dz <= radius; dz++) { + if (dx * dx + dy * dy + dz * dz <= radius * radius) { + BlockPos pos = new BlockPos(centerX + dx, centerY + dy, centerZ + dz); + BlockState state = world.getBlockState(pos); + if (!state.isOf(Blocks.BLACK_CONCRETE)) { + NbtCompound nbt = null; + BlockEntity be = world.getBlockEntity(pos); + if (be != null) { + nbt = be.createNbtWithIdentifyingData(world.getRegistryManager()); + } + boolean isFirst = GlobalBlockRegistry.acquire(world, pos, state, nbt); + if (isFirst && be != null) { + if (be instanceof Inventory) { + ((Inventory) be).clear(); + } + world.removeBlockEntity(pos); + } + world.setBlockState(pos, Blocks.BLACK_CONCRETE.getDefaultState(), BUILD_FLAGS); + } + blockSet.add(pos); + allBuiltPositions.add(pos); + } + } + } + } + } + + private void clearSphereBlocks(Set blocks) { + for (BlockPos pos : blocks) { + boolean shouldRestore = GlobalBlockRegistry.release(world, pos); + if (shouldRestore) { + BlockState original = GlobalBlockRegistry.getOriginalState(world, pos); + if (original != null) { + world.setBlockState(pos, original, RESTORE_FLAGS); + NbtCompound nbt = GlobalBlockRegistry.getOriginalNbt(world, pos); + if (nbt != null) { + BlockEntity newBe = BlockEntity.createFromNbt(pos, original, nbt, world.getRegistryManager()); + if (newBe != null) world.addBlockEntity(newBe); + } + } else { + world.setBlockState(pos, Blocks.AIR.getDefaultState(), RESTORE_FLAGS); + } + GlobalBlockRegistry.clearEntry(world, pos); + } + } + } + + private void clearAllSphereBlocks() { + if (!lastCenterSphereBlocks.isEmpty()) { + clearSphereBlocks(lastCenterSphereBlocks); + lastCenterSphereBlocks.clear(); + } + for (int i = 0; i < 6; i++) { + if (!lastSmallSphereBlocks.get(i).isEmpty()) { + clearSphereBlocks(lastSmallSphereBlocks.get(i)); + lastSmallSphereBlocks.get(i).clear(); + } + } + blocksCleared = true; + } + + private void spawnBlackSphereParticles() { + world.spawnParticles(ParticleTypes.SQUID_INK, false, false, cx, sphereCenterY, cz, 20, maxSphereRadius, maxSphereRadius, maxSphereRadius, 0.1); + world.spawnParticles(ParticleTypes.ASH, false, false, cx, sphereCenterY, cz, 15, maxSphereRadius, maxSphereRadius, maxSphereRadius, 0.05); + } + + private void spawnBlackDripParticles(double fromX, double fromY, double fromZ, double toX, double toY, double toZ) { + int steps = 10; + for (int i = 0; i < steps; i++) { + double t = (double) i / steps; + double px = fromX + (toX - fromX) * t; + double py = fromY + (toY - fromY) * t; + double pz = fromZ + (toZ - fromZ) * t; + world.spawnParticles(ParticleTypes.SQUID_INK, false, false, px, py, pz, 3, 0.1, 0.1, 0.1, 0.0); + world.spawnParticles(ParticleTypes.ASH, false, false, px, py, pz, 2, 0.1, 0.1, 0.1, 0.0); + } + } + + private void spawnRangeBorder() { + double borderRadius = (double) range + 0.5; + int segments = Math.max(32, range * 8); + for (int i = 0; i < segments; i++) { + double angle = (Math.PI * 2 * i) / segments; + double rx = cx + Math.cos(angle) * borderRadius; + double rz = cz + Math.sin(angle) * borderRadius; + world.spawnParticles(ParticleTypes.FLAME, false, false, rx, topY + 1.0, rz, 3, 0.02, 0.0, 0.02, 0.0); + world.spawnParticles(ParticleTypes.CRIT, false, false, rx, topY + 0.2, rz, 2, 0.02, 0.0, 0.02, 0.0); + } + playSoundToAll(cx, topY, cz, SoundEvents.BLOCK_AMETHYST_BLOCK_CHIME, SoundCategory.BLOCKS, 1.5f, 0.8f); + } + + private void summonRandomLightningInArea() { + double angle = Math.random() * Math.PI * 2; + double r = Math.random() * range; + double lx = cx + Math.cos(angle) * r; + double lz = cz + Math.sin(angle) * r; + int ly = world.getTopY(Heightmap.Type.WORLD_SURFACE, (int) lx, (int) lz); + LightningEntity lightning = EntityType.LIGHTNING_BOLT.create(world, SpawnReason.COMMAND); + if (lightning != null) { + lightning.refreshPositionAfterTeleport(lx, ly, lz); + world.spawnEntity(lightning); + } + } + + private void spawnRandomSmallExplosion() { + double angle = Math.random() * Math.PI * 2; + double r = Math.random() * range; + double ex = cx + Math.cos(angle) * r; + double ez = cz + Math.sin(angle) * r; + int ey = world.getTopY(Heightmap.Type.WORLD_SURFACE, (int) ex, (int) ez); + world.createExplosion(null, ex, ey, ez, 2.0f, false, World.ExplosionSourceType.TNT); + world.spawnParticles(ParticleTypes.FLAME, false, false, ex, ey + 1.0, ez, 20, 0.5, 0.5, 0.5, 0.2); + playSoundToAll(ex, ey, ez, SoundEvents.ENTITY_FIREWORK_ROCKET_BLAST, SoundCategory.AMBIENT, 1.5f, 0.6f); + } + + private void spawnBurstParticles() { + for (int i = 0; i < 24; i++) { + double angle = Math.random() * Math.PI * 2; + double radius = Math.random() * 6.0; + double rx = cx + Math.cos(angle) * radius; + double rz = cz + Math.sin(angle) * radius; + world.spawnParticles(ParticleTypes.FLAME, false, false, rx, topY + Math.random() * 4, rz, 6, 0.1, 0.1, 0.1, 0.05); + world.spawnParticles(ParticleTypes.CRIT, false, false, rx, topY + Math.random() * 4, rz, 6, 0.1, 0.1, 0.1, 0.0); + } + world.spawnParticles(ParticleTypes.TOTEM_OF_UNDYING, false, false, cx, topY + 1.0, cz, 60, 2.5, 2.5, 2.5, 0.3); + world.spawnParticles(ParticleTypes.END_ROD, false, false, cx, topY + 1.0, cz, 30, 1.5, 1.5, 1.5, 0.2); + world.spawnParticles(ParticleTypes.CAMPFIRE_COSY_SMOKE, false, false, cx, topY + 1.0, cz, 20, 1.0, 1.0, 1.0, 0.05); + world.spawnParticles(ParticleTypes.ENCHANT, false, false, cx, topY + 1.0, cz, 30, 2.0, 2.0, 2.0, 0.3); + } + + private void executeFinalExplosion() { + int centerTopY = (int) topY; + int upwardHeight = Math.max(5, range / 2); + int bottomY = (depth == -1) ? world.getBottomY() + 1 : (centerTopY - depth); + double rangeSq = (double) range * (double) range; + + for (int bx = x - range; bx <= x + range; bx++) { + for (int bz = z - range; bz <= z + range; bz++) { + int dx = bx - x; + int dz = bz - z; + if ((double) (dx * dx + dz * dz) <= rangeSq) { + int columnTopY = world.getTopY(Heightmap.Type.WORLD_SURFACE, bx, bz) + upwardHeight; + for (int by = columnTopY; by >= bottomY; by--) { + world.removeBlock(new BlockPos(bx, by, bz), false); + } + } + } + } + + // 清理受灾区域的液体(液体方块和流动液体) + for (int bx = x - range; bx <= x + range; bx++) { + for (int bz = z - range; bz <= z + range; bz++) { + int dx = bx - x; + int dz = bz - z; + if ((double) (dx * dx + dz * dz) <= rangeSq) { + int columnTopY = world.getTopY(Heightmap.Type.WORLD_SURFACE, bx, bz) + upwardHeight; + for (int by = columnTopY; by >= bottomY; by--) { + BlockPos pos = new BlockPos(bx, by, bz); + BlockState state = world.getBlockState(pos); + if (!state.getFluidState().isEmpty()) { + world.setBlockState(pos, Blocks.AIR.getDefaultState()); + } + } + } + } + } + + world.createExplosion(null, cx, topY, cz, 8.0f, true, World.ExplosionSourceType.TNT); + + restoreRecordedBlocks(); + + playSoundToAll(cx, topY, cz, SoundEvents.ENTITY_ENDER_DRAGON_GROWL, SoundCategory.HOSTILE, 2.0f, 0.5f); + playSoundToAll(cx, topY, cz, SoundEvents.ENTITY_WITHER_SPAWN, SoundCategory.HOSTILE, 2.0f, 0.6f); + playSoundToAll(cx, topY, cz, SoundEvents.ITEM_TOTEM_USE, SoundCategory.PLAYERS, 2.0f, 0.5f); + playSoundToAll(cx, topY, cz, SoundEvents.BLOCK_BELL_USE, SoundCategory.BLOCKS, 2.0f, 0.5f); + playSoundToAll(cx, topY, cz, SoundEvents.ENTITY_FIREWORK_ROCKET_BLAST, SoundCategory.AMBIENT, 2.0f, 0.5f); + playSoundToAll(cx, topY, cz, SoundEvents.UI_TOAST_CHALLENGE_COMPLETE, SoundCategory.MASTER, 2.0f, 0.5f); + playSoundToAll(cx, topY, cz, SoundEvents.ENTITY_LIGHTNING_BOLT_THUNDER, SoundCategory.WEATHER, 2.0f, 0.5f); + playSoundToAll(cx, topY, cz, SoundEvents.BLOCK_PORTAL_TRIGGER, SoundCategory.AMBIENT, 2.0f, 0.5f); + } + + private void restoreRecordedBlocks() { + restoreRemainingBlocks(true); + } + + private void restoreAllRecordedBlocks() { + restoreRemainingBlocks(false); + } + } + + private static class MeteoSession implements TestSession { + private static final int EXPLOSION_RADIUS = 20; + private static final int METEO_FALL_TICKS = 200; + private static final int POST_EXPLOSION_DURATION = 400; + + private final int id = NextTestId.getAndIncrement(); + private final ServerWorld world; + private final int targetX, targetY, targetZ; + private int tickCounter = 0; + private boolean finished = false; + private final ServerBossBar bossBar; + private boolean explosionExecuted = false; + private int explosionExecutedTick = -1; + + private final List meteos = new ArrayList<>(); + private final Set allBuiltPositions = new HashSet<>(); + private static final int SKIP_DROPS_FLAGS = Block.SKIP_DROPS | Block.MOVED; + private static final int RESTORE_FLAGS = Block.SKIP_DROPS | Block.NOTIFY_LISTENERS; + + private static class Meteo { + double startX, startY, startZ; + double currentX, currentY, currentZ; + double targetX, targetY, targetZ; + int radius; + Set currentBlocks = new HashSet<>(); + boolean arrived = false; + boolean isMain; + + Meteo(double sx, double sy, double sz, double tx, double ty, double tz, int r, boolean main) { + startX = sx; startY = sy; startZ = sz; + currentX = sx; currentY = sy; currentZ = sz; + targetX = tx; targetY = ty; targetZ = tz; + radius = r; + isMain = main; + } + } + + MeteoSession(ServerWorld world, int x, int y, int z) { + this.world = world; + this.targetX = x; + this.targetY = y; + this.targetZ = z; + + int worldHeight = world.getHeight(); + int startY = Math.min(worldHeight, targetY + 80 + (int)(Math.random() * 40)); + + double mainAngle = Math.random() * Math.PI * 2; + double mainDistance = 70 + Math.random() * 30; + double mainSx = x + 0.5 + Math.cos(mainAngle) * mainDistance; + double mainSz = z + 0.5 + Math.sin(mainAngle) * mainDistance; + meteos.add(new Meteo(mainSx, startY, mainSz, x + 0.5, y, z + 0.5, 4, true)); + + this.bossBar = new ServerBossBar( + Text.literal("§6§l[METEO] §e陨石来袭!"), + BossBar.Color.RED, + BossBar.Style.NOTCHED_20 + ); + this.bossBar.setVisible(true); + this.bossBar.setPercent(1.0f); + var players = world.getServer().getPlayerManager().getPlayerList(); + for (ServerPlayerEntity player : players) { + this.bossBar.addPlayer(player); + } + } + + boolean isFinished() { + return finished; + } + + @Override + public int getId() { + return id; + } + + @Override + public String getType() { + return "METEO"; + } + + @Override + public String getDescription() { + String worldName = world.getRegistryKey().getValue().toString(); + String phase = explosionExecuted ? "爆炸后" : (tickCounter < METEO_FALL_TICKS ? "陨石坠落" : "即将爆炸"); + return String.format("§6[METEO#%d] §7世界: %s §7| 目标: %d, %d, %d §7| 阶段: %s §7| tick: %d/%d", + id, worldName, targetX, targetY, targetZ, phase, tickCounter, explosionExecuted ? explosionExecutedTick + POST_EXPLOSION_DURATION : METEO_FALL_TICKS); + } + + @Override + public void cancel() { + if (finished) return; + for (Meteo meteo : meteos) { + if (!meteo.currentBlocks.isEmpty()) { + clearMeteoBlocks(meteo.currentBlocks); + meteo.currentBlocks.clear(); + } + } + for (BlockPos pos : allBuiltPositions) { + boolean shouldRestore = GlobalBlockRegistry.release(world, pos); + if (shouldRestore) { + BlockState original = GlobalBlockRegistry.getOriginalState(world, pos); + if (original != null) { + world.setBlockState(pos, original, RESTORE_FLAGS); + NbtCompound nbt = GlobalBlockRegistry.getOriginalNbt(world, pos); + if (nbt != null) { + BlockEntity newBe = BlockEntity.createFromNbt(pos, original, nbt, world.getRegistryManager()); + if (newBe != null) world.addBlockEntity(newBe); + } + } + GlobalBlockRegistry.clearEntry(world, pos); + } + } + allBuiltPositions.clear(); + bossBar.clearPlayers(); + bossBar.setVisible(false); + finished = true; + } + + void tick() { + if (finished) return; + + if (explosionExecuted) { + if (tickCounter >= explosionExecutedTick + POST_EXPLOSION_DURATION) { + bossBar.clearPlayers(); + bossBar.setVisible(false); + finished = true; + } else { + updateBossBarPostExplosion(); + } + tickCounter++; + return; + } + + updateBossBar(); + + double progress = Math.min(1.0, (double) tickCounter / METEO_FALL_TICKS); + + for (Meteo meteo : meteos) { + if (meteo.arrived) continue; + + if (!meteo.currentBlocks.isEmpty()) { + clearMeteoBlocks(meteo.currentBlocks); + meteo.currentBlocks.clear(); + } + + meteo.currentX = meteo.startX + (meteo.targetX - meteo.startX) * progress; + meteo.currentY = meteo.startY + (meteo.targetY - meteo.startY) * progress; + meteo.currentZ = meteo.startZ + (meteo.targetZ - meteo.startZ) * progress; + + buildMeteo(meteo); + spawnMeteoParticles(meteo); + + if (progress >= 1.0) { + meteo.arrived = true; + } + } + + if (tickCounter % 40 == 0) { + playSoundToAll(targetX + 0.5, targetY, targetZ + 0.5, SoundEvents.ENTITY_FIREWORK_ROCKET_LAUNCH, SoundCategory.AMBIENT, 1.0f, 0.5f); + } + + if (meteos.get(0).arrived) { + executeExplosion(); + explosionExecuted = true; + explosionExecutedTick = tickCounter; + } + + tickCounter++; + } + + private void buildMeteo(Meteo meteo) { + int cx = (int) Math.round(meteo.currentX); + int cy = (int) Math.round(meteo.currentY); + int cz = (int) Math.round(meteo.currentZ); + int r = meteo.radius; + + for (int dx = -r; dx <= r; dx++) { + for (int dy = -r; dy <= r; dy++) { + for (int dz = -r; dz <= r; dz++) { + int distSq = dx * dx + dy * dy + dz * dz; + if (distSq <= r * r) { + BlockPos pos = new BlockPos(cx + dx, cy + dy, cz + dz); + BlockState state = world.getBlockState(pos); + if (!isMeteoBlock(state) && !state.isOf(Blocks.BLACK_CONCRETE)) { + NbtCompound nbt = null; + BlockEntity be = world.getBlockEntity(pos); + if (be != null) { + nbt = be.createNbtWithIdentifyingData(world.getRegistryManager()); + } + boolean isFirst = GlobalBlockRegistry.acquire(world, pos, state, nbt); + if (isFirst && be != null) { + if (be instanceof Inventory) { + ((Inventory) be).clear(); + } + world.removeBlockEntity(pos); + } + world.setBlockState(pos, chooseMeteoBlock(distSq, r), SKIP_DROPS_FLAGS); + } + meteo.currentBlocks.add(pos); + allBuiltPositions.add(pos); + } + } + } + } + } + + private boolean isMeteoBlock(BlockState state) { + return state.isOf(Blocks.MAGMA_BLOCK) || state.isOf(Blocks.COBBLESTONE) || state.isOf(Blocks.STONE); + } + + private BlockState chooseMeteoBlock(int distSq, int r) { + double rand = Math.random(); + if (distSq >= (r - 1) * (r - 1)) { + // 外壳:更多岩浆块 + if (rand < 0.5) return Blocks.MAGMA_BLOCK.getDefaultState(); + if (rand < 0.75) return Blocks.COBBLESTONE.getDefaultState(); + return Blocks.STONE.getDefaultState(); + } else { + // 内核:更多石头和圆石 + if (rand < 0.4) return Blocks.STONE.getDefaultState(); + if (rand < 0.75) return Blocks.COBBLESTONE.getDefaultState(); + return Blocks.MAGMA_BLOCK.getDefaultState(); + } + } + + private void clearMeteoBlocks(Set blocks) { + for (BlockPos pos : blocks) { + BlockState current = world.getBlockState(pos); + if (isMeteoBlock(current)) { + boolean shouldRestore = GlobalBlockRegistry.release(world, pos); + if (shouldRestore) { + BlockState original = GlobalBlockRegistry.getOriginalState(world, pos); + if (original != null) { + world.setBlockState(pos, original, RESTORE_FLAGS); + NbtCompound nbt = GlobalBlockRegistry.getOriginalNbt(world, pos); + if (nbt != null) { + BlockEntity newBe = BlockEntity.createFromNbt(pos, original, nbt, world.getRegistryManager()); + if (newBe != null) world.addBlockEntity(newBe); + } + } else { + world.removeBlock(pos, false); + } + GlobalBlockRegistry.clearEntry(world, pos); + } + } + } + } + + private void spawnMeteoParticles(Meteo meteo) { + world.spawnParticles(ParticleTypes.FLAME, false, false, meteo.currentX, meteo.currentY, meteo.currentZ, 15, 0.5, 0.5, 0.5, 0.1); + world.spawnParticles(ParticleTypes.LAVA, false, false, meteo.currentX, meteo.currentY, meteo.currentZ, 5, 0.3, 0.3, 0.3, 0.1); + world.spawnParticles(ParticleTypes.CAMPFIRE_COSY_SMOKE, false, false, meteo.currentX, meteo.currentY + 1, meteo.currentZ, 10, 0.5, 0.5, 0.5, 0.05); + } + + private void executeExplosion() { + for (Meteo meteo : meteos) { + if (!meteo.currentBlocks.isEmpty()) { + clearMeteoBlocks(meteo.currentBlocks); + meteo.currentBlocks.clear(); + } + } + + int r = EXPLOSION_RADIUS; + for (int dx = -r; dx <= r; dx++) { + for (int dy = -r; dy <= r; dy++) { + for (int dz = -r; dz <= r; dz++) { + if (dx * dx + dy * dy + dz * dz <= r * r) { + BlockPos pos = new BlockPos(targetX + dx, targetY + dy, targetZ + dz); + world.removeBlock(pos, false); + } + } + } + } + + // 清理液体(液体方块和流动液体) + for (int dx = -r; dx <= r; dx++) { + for (int dy = -r; dy <= r; dy++) { + for (int dz = -r; dz <= r; dz++) { + if (dx * dx + dy * dy + dz * dz <= r * r) { + BlockPos pos = new BlockPos(targetX + dx, targetY + dy, targetZ + dz); + BlockState state = world.getBlockState(pos); + if (!state.getFluidState().isEmpty()) { + world.setBlockState(pos, Blocks.AIR.getDefaultState()); + } + } + } + } + } + + generateOres(); + placeMeteoRemains(); + placeChest(); + + world.createExplosion(null, targetX + 0.5, targetY, targetZ + 0.5, 8.0f, true, World.ExplosionSourceType.TNT); + + playSoundToAll(targetX + 0.5, targetY, targetZ + 0.5, SoundEvents.ENTITY_GENERIC_EXPLODE.value(), SoundCategory.BLOCKS, 3.0f, 0.6f); + playSoundToAll(targetX + 0.5, targetY, targetZ + 0.5, SoundEvents.ENTITY_ENDER_DRAGON_GROWL, SoundCategory.HOSTILE, 2.0f, 0.5f); + playSoundToAll(targetX + 0.5, targetY, targetZ + 0.5, SoundEvents.ENTITY_WITHER_SPAWN, SoundCategory.HOSTILE, 2.0f, 0.6f); + playSoundToAll(targetX + 0.5, targetY, targetZ + 0.5, SoundEvents.ENTITY_FIREWORK_ROCKET_BLAST, SoundCategory.AMBIENT, 2.0f, 0.5f); + playSoundToAll(targetX + 0.5, targetY, targetZ + 0.5, SoundEvents.ENTITY_LIGHTNING_BOLT_THUNDER, SoundCategory.WEATHER, 2.0f, 0.5f); + } + + private void generateOres() { + int r = EXPLOSION_RADIUS; + BlockState[] ores = { + Blocks.DIAMOND_ORE.getDefaultState(), + Blocks.GOLD_ORE.getDefaultState(), + Blocks.IRON_ORE.getDefaultState(), + Blocks.EMERALD_ORE.getDefaultState(), + Blocks.REDSTONE_ORE.getDefaultState(), + Blocks.COAL_ORE.getDefaultState(), + Blocks.LAPIS_ORE.getDefaultState(), + Blocks.NETHER_QUARTZ_ORE.getDefaultState(), + }; + + for (int dx = -r; dx <= r; dx++) { + for (int dy = -r; dy <= r; dy++) { + for (int dz = -r; dz <= r; dz++) { + int distSq = dx * dx + dy * dy + dz * dz; + if (distSq <= r * r && distSq >= (r - 1) * (r - 1)) { + BlockPos pos = new BlockPos(targetX + dx, targetY + dy, targetZ + dz); + BlockState current = world.getBlockState(pos); + if (current.isAir() && hasSolidNeighbor(pos) && Math.random() < 0.08) { + BlockState ore = ores[(int) (Math.random() * ores.length)]; + world.setBlockState(pos, ore); + } + } + } + } + } + } + + private boolean hasSolidNeighbor(BlockPos pos) { + BlockPos[] neighbors = { + pos.up(), pos.down(), pos.north(), pos.south(), pos.east(), pos.west() + }; + for (BlockPos neighbor : neighbors) { + BlockState state = world.getBlockState(neighbor); + if (!state.isAir() && state.getFluidState().isEmpty()) { + return true; + } + } + return false; + } + + private void placeMeteoRemains() { + int bottomY = targetY - EXPLOSION_RADIUS + 2; + int r = 3; + for (int dx = -r; dx <= r; dx++) { + for (int dy = -r; dy <= r; dy++) { + for (int dz = -r; dz <= r; dz++) { + int distSq = dx * dx + dy * dy + dz * dz; + if (distSq <= r * r) { + BlockPos pos = new BlockPos(targetX + dx, bottomY + dy, targetZ + dz); + world.setBlockState(pos, chooseMeteoBlock(distSq, r)); + } + } + } + } + } + + private void placeChest() { + int bottomY = targetY - EXPLOSION_RADIUS + 2; + BlockPos chestPos = new BlockPos(targetX, bottomY + 4, targetZ); + world.setBlockState(chestPos, Blocks.CHEST.getDefaultState()); + + var blockEntity = world.getBlockEntity(chestPos); + if (blockEntity instanceof net.minecraft.block.entity.ChestBlockEntity) { + var chest = (net.minecraft.block.entity.ChestBlockEntity) blockEntity; + chest.setStack(0, new net.minecraft.item.ItemStack(net.minecraft.item.Items.DIAMOND, 3 + (int)(Math.random() * 5))); + chest.setStack(1, new net.minecraft.item.ItemStack(net.minecraft.item.Items.GOLD_INGOT, 5 + (int)(Math.random() * 10))); + chest.setStack(2, new net.minecraft.item.ItemStack(net.minecraft.item.Items.IRON_INGOT, 10 + (int)(Math.random() * 20))); + chest.setStack(3, new net.minecraft.item.ItemStack(net.minecraft.item.Items.EMERALD, 2 + (int)(Math.random() * 4))); + chest.setStack(4, new net.minecraft.item.ItemStack(net.minecraft.item.Items.EXPERIENCE_BOTTLE, 5 + (int)(Math.random() * 10))); + chest.setStack(5, new net.minecraft.item.ItemStack(net.minecraft.item.Items.GOLDEN_APPLE, 2 + (int)(Math.random() * 3))); + chest.setStack(6, new net.minecraft.item.ItemStack(net.minecraft.item.Items.NETHERITE_SCRAP, 1 + (int)(Math.random() * 2))); + chest.setStack(7, new net.minecraft.item.ItemStack(net.minecraft.item.Items.ENDER_PEARL, 3 + (int)(Math.random() * 5))); + } + } + + private void playSoundToAll(double x, double y, double z, net.minecraft.sound.SoundEvent sound, net.minecraft.sound.SoundCategory category, float volume, float pitch) { + world.playSound(null, x, y, z, sound, category, 10000.0f, pitch); + } + + private void updateBossBar() { + int remainingTicks = Math.max(0, METEO_FALL_TICKS - tickCounter); + int remainingSeconds = remainingTicks / 20; + + Text title = Text.literal(String.format("§6§l[METEO] §e目标: %d, %d, %d §7| §c陨石来袭 §7| §e倒计时: %ds", targetX, targetY, targetZ, remainingSeconds)); + this.bossBar.setName(title); + float progress = (float) tickCounter / METEO_FALL_TICKS; + this.bossBar.setPercent(Math.max(0.0f, Math.min(1.0f, progress))); + this.bossBar.setColor(BossBar.Color.RED); + + refreshBossBarPlayers(); + } + + private void updateBossBarPostExplosion() { + int elapsed = tickCounter - explosionExecutedTick; + int remainingTicks = Math.max(0, POST_EXPLOSION_DURATION - elapsed); + int remainingSeconds = remainingTicks / 20; + + Text title = Text.literal(String.format("§a§l[METEO] §e陨石已坠落!§7| §eHUD消失: %ds", remainingSeconds)); + this.bossBar.setName(title); + float progress = (float) elapsed / POST_EXPLOSION_DURATION; + this.bossBar.setPercent(Math.max(0.0f, 1.0f - progress)); + this.bossBar.setColor(BossBar.Color.GREEN); + + refreshBossBarPlayers(); + } + + private void refreshBossBarPlayers() { + if (tickCounter % 10 == 0) { + var playerList = world.getServer().getPlayerManager().getPlayerList(); + var existingPlayers = bossBar.getPlayers(); + java.util.Set existingUUIDs = new java.util.HashSet<>(); + for (ServerPlayerEntity p : existingPlayers) { + existingUUIDs.add(p.getUuid()); + } + for (ServerPlayerEntity player : playerList) { + if (!existingUUIDs.contains(player.getUuid())) { + bossBar.addPlayer(player); + } + } + } + } + } + + private static class LineSession implements TestSession { + private static final int TOTAL_TICKS = 1200; + private static final int WAVE_INTERVAL_TICKS = 87; + private static final double WAVE_EXPAND_SPEED = 0.15; + private static final int BLOCK_RESTORE_DELAY_TICKS = 13; + private static final int BUILD_FLAGS = Block.SKIP_DROPS | Block.MOVED | Block.NOTIFY_LISTENERS; + private static final int RESTORE_FLAGS = Block.SKIP_DROPS | Block.NOTIFY_LISTENERS; + + private final int id = NextTestId.getAndIncrement(); + private final ServerWorld world; + private final int x; + private final int z; + private final double cx; + private final double cz; + private final int topY; + private final int range; + private int tickCounter = 0; + private boolean finished = false; + private final ServerBossBar bossBar; + + private static class Wave { + double currentRadius; + boolean active; + Wave(double startRadius) { + this.currentRadius = startRadius; + this.active = true; + } + } + + private static class PendingRestore { + BlockPos pos; + int restoreAtTick; + + PendingRestore(BlockPos pos, int restoreAtTick) { + this.pos = pos; + this.restoreAtTick = restoreAtTick; + } + } + + private final List waves = new ArrayList<>(); + private final List pendingRestores = new ArrayList<>(); + private final Set allBuiltPositions = new HashSet<>(); + + LineSession(ServerWorld world, int x, int z, int topY, int range) { + this.world = world; + this.x = x; + this.z = z; + this.cx = x + 0.5; + this.cz = z + 0.5; + this.topY = topY; + this.range = range; + this.bossBar = new ServerBossBar( + Text.literal("§b§l[LINE] §e初始化中..."), + BossBar.Color.BLUE, + BossBar.Style.NOTCHED_20 + ); + this.bossBar.setVisible(true); + this.bossBar.setPercent(1.0f); + var players = world.getServer().getPlayerManager().getPlayerList(); + for (ServerPlayerEntity player : players) { + this.bossBar.addPlayer(player); + } + spawnInitialBurst(); + } + + boolean isFinished() { + return finished; + } + + @Override + public int getId() { + return id; + } + + @Override + public String getType() { + return "LINE"; + } + + @Override + public String getDescription() { + String worldName = world.getRegistryKey().getValue().toString(); + return String.format("§b[LINE#%d] §7世界: %s §7| 中心: %d, %d, %d §7| 范围: %d §7| 波纹: %d §7| 黑块: %d §7| tick: %d/%d", + id, worldName, x, topY, z, range, waves.size(), allBuiltPositions.size(), tickCounter, TOTAL_TICKS); + } + + @Override + public void cancel() { + if (finished) return; + pendingRestores.clear(); + waves.clear(); + for (BlockPos pos : allBuiltPositions) { + boolean shouldRestore = GlobalBlockRegistry.release(world, pos); + if (shouldRestore) { + BlockState original = GlobalBlockRegistry.getOriginalState(world, pos); + if (original != null && world.getBlockState(pos).isOf(Blocks.BLACK_CONCRETE)) { + world.setBlockState(pos, original, RESTORE_FLAGS); + NbtCompound nbt = GlobalBlockRegistry.getOriginalNbt(world, pos); + if (nbt != null) { + BlockEntity newBe = BlockEntity.createFromNbt(pos, original, nbt, world.getRegistryManager()); + if (newBe != null) world.addBlockEntity(newBe); + } + } + GlobalBlockRegistry.clearEntry(world, pos); + } + } + allBuiltPositions.clear(); + bossBar.clearPlayers(); + bossBar.setVisible(false); + finished = true; + } + + private void playSoundToAll(double sx, double sy, double sz, net.minecraft.sound.SoundEvent sound, net.minecraft.sound.SoundCategory category, float volume, float pitch) { + world.playSound(null, sx, sy, sz, sound, category, 10000.0f, pitch); + } + + private void spawnInitialBurst() { + world.spawnParticles(ParticleTypes.END_ROD, false, false, cx, topY + 1.0, cz, 40, 0.5, 1.0, 0.5, 0.1); + world.spawnParticles(ParticleTypes.NAUTILUS, false, false, cx, topY + 1.0, cz, 30, 0.5, 1.0, 0.5, 0.1); + playSoundToAll(cx, topY + 1.0, cz, SoundEvents.BLOCK_CONDUIT_ACTIVATE, SoundCategory.BLOCKS, 1.5f, 1.2f); + playSoundToAll(cx, topY + 1.0, cz, SoundEvents.BLOCK_BEACON_ACTIVATE, SoundCategory.BLOCKS, 1.5f, 1.5f); + } + + private void spawnNewWave() { + waves.add(new Wave(0.5)); + playSoundToAll(cx, topY + 1.0, cz, SoundEvents.BLOCK_CONDUIT_AMBIENT_SHORT, SoundCategory.BLOCKS, 1.2f, 1.0f + 0.1f * waves.size()); + playSoundToAll(cx, topY + 1.0, cz, SoundEvents.BLOCK_BELL_USE, SoundCategory.BLOCKS, 1.0f, 1.5f); + } + + void tick() { + if (finished) return; + tickCounter++; + + if (tickCounter <= TOTAL_TICKS && tickCounter % WAVE_INTERVAL_TICKS == 0) { + spawnNewWave(); + } + + Iterator iterator = waves.iterator(); + while (iterator.hasNext()) { + Wave wave = iterator.next(); + if (!wave.active) { + iterator.remove(); + continue; + } + wave.currentRadius += WAVE_EXPAND_SPEED; + if (wave.currentRadius > range) { + wave.active = false; + iterator.remove(); + continue; + } + placeRingBlocks(wave.currentRadius); + } + + processPendingRestores(); + + if (tickCounter >= TOTAL_TICKS && waves.isEmpty() && pendingRestores.isEmpty()) { + forceRestoreAll(); + finish(); + } + + updateBossBar(); + } + + private void placeRingBlocks(double ringRadius) { + double ringWidth = 1.0; + double innerRadius = ringRadius; + double outerRadius = ringRadius + ringWidth; + double innerSq = innerRadius * innerRadius; + double outerSq = outerRadius * outerRadius; + + int minX = x - (int) Math.ceil(outerRadius); + int maxX = x + (int) Math.ceil(outerRadius); + int minZ = z - (int) Math.ceil(outerRadius); + int maxZ = z + (int) Math.ceil(outerRadius); + + for (int bx = minX; bx <= maxX; bx++) { + for (int bz = minZ; bz <= maxZ; bz++) { + int dx = bx - x; + int dz = bz - z; + double distSq = dx * dx + dz * dz; + if (distSq >= innerSq && distSq <= outerSq) { + int topYPlusOne = world.getTopY(Heightmap.Type.WORLD_SURFACE, bx, bz); + int blockTopY = topYPlusOne - 1; + if (blockTopY < world.getBottomY()) continue; + BlockPos pos = new BlockPos(bx, blockTopY, bz); + + if (allBuiltPositions.contains(pos)) continue; + + BlockState currentState = world.getBlockState(pos); + if (currentState.isOf(Blocks.BLACK_CONCRETE)) continue; + if (currentState.isAir()) continue; + if (!currentState.getFluidState().isEmpty()) continue; + + NbtCompound nbt = null; + BlockEntity be = world.getBlockEntity(pos); + if (be != null) { + nbt = be.createNbtWithIdentifyingData(world.getRegistryManager()); + } + boolean isFirst = GlobalBlockRegistry.acquire(world, pos, currentState, nbt); + if (isFirst && be != null) { + if (be instanceof Inventory) { + ((Inventory) be).clear(); + } + world.removeBlockEntity(pos); + } + + world.setBlockState(pos, Blocks.BLACK_CONCRETE.getDefaultState(), BUILD_FLAGS); + allBuiltPositions.add(pos); + + pendingRestores.add(new PendingRestore(pos, tickCounter + BLOCK_RESTORE_DELAY_TICKS)); + + double px = bx + 0.5; + double pz = bz + 0.5; + double py = blockTopY + 1.0; + world.spawnParticles(ParticleTypes.END_ROD, false, false, px, py, pz, 2, 0.1, 0.3, 0.1, 0.03); + } + } + } + } + + private void processPendingRestores() { + Iterator it = pendingRestores.iterator(); + while (it.hasNext()) { + PendingRestore pr = it.next(); + if (tickCounter >= pr.restoreAtTick) { + restoreBlock(pr); + it.remove(); + } + } + } + + private void restoreBlock(PendingRestore pr) { + BlockPos pos = pr.pos; + if (!world.getBlockState(pos).isOf(Blocks.BLACK_CONCRETE)) { + boolean shouldClear = GlobalBlockRegistry.release(world, pos); + if (shouldClear) GlobalBlockRegistry.clearEntry(world, pos); + allBuiltPositions.remove(pos); + return; + } + boolean shouldRestore = GlobalBlockRegistry.release(world, pos); + if (shouldRestore) { + BlockState original = GlobalBlockRegistry.getOriginalState(world, pos); + if (original != null) { + world.setBlockState(pos, original, RESTORE_FLAGS); + NbtCompound nbt = GlobalBlockRegistry.getOriginalNbt(world, pos); + if (nbt != null) { + BlockEntity newBe = BlockEntity.createFromNbt(pos, original, nbt, world.getRegistryManager()); + if (newBe != null) world.addBlockEntity(newBe); + } + } + GlobalBlockRegistry.clearEntry(world, pos); + } + allBuiltPositions.remove(pos); + + double px = pos.getX() + 0.5; + double py = pos.getY() + 1.0; + double pz = pos.getZ() + 0.5; + world.spawnParticles(ParticleTypes.END_ROD, false, false, px, py, pz, 2, 0.05, 0.1, 0.05, 0.02); + } + + private void forceRestoreAll() { + for (PendingRestore pr : pendingRestores) { + restoreBlock(pr); + } + pendingRestores.clear(); + + for (BlockPos pos : new HashSet<>(allBuiltPositions)) { + if (world.getBlockState(pos).isOf(Blocks.BLACK_CONCRETE)) { + boolean shouldRestore = GlobalBlockRegistry.release(world, pos); + if (shouldRestore) { + BlockState original = GlobalBlockRegistry.getOriginalState(world, pos); + if (original != null) { + world.setBlockState(pos, original, RESTORE_FLAGS); + NbtCompound nbt = GlobalBlockRegistry.getOriginalNbt(world, pos); + if (nbt != null) { + BlockEntity newBe = BlockEntity.createFromNbt(pos, original, nbt, world.getRegistryManager()); + if (newBe != null) world.addBlockEntity(newBe); + } + } + GlobalBlockRegistry.clearEntry(world, pos); + } + } + } + allBuiltPositions.clear(); + } + + private void updateBossBar() { + int elapsed = tickCounter; + int remainingTicks = Math.max(0, TOTAL_TICKS - elapsed); + int activeWaves = waves.size(); + int activeBlocks = allBuiltPositions.size(); + + int totalSeconds = remainingTicks / 20; + int minutes = totalSeconds / 60; + int seconds = totalSeconds % 60; + String timeStr = minutes > 0 ? String.format("%dm%02ds", minutes, seconds) : String.format("%ds", seconds); + + Text title = Text.literal(String.format("§b§l[LINE] §e中心: %d, %d, %d §7| §e范围: %d §7| §b波纹: %d §7| §8黑块: %d §7| §e剩余: %s", + x, topY, z, range, activeWaves, activeBlocks, timeStr)); + bossBar.setName(title); + + float progress; + if (tickCounter >= TOTAL_TICKS) { + progress = (waves.isEmpty() && pendingRestores.isEmpty()) ? 1.0f : 0.95f; + } else { + progress = (float) elapsed / TOTAL_TICKS; + } + bossBar.setPercent(Math.max(0.0f, Math.min(1.0f, progress))); + + if (tickCounter % 10 == 0) { + var playerList = world.getServer().getPlayerManager().getPlayerList(); + var existingPlayers = bossBar.getPlayers(); + java.util.Set existingUUIDs = new java.util.HashSet<>(); + for (ServerPlayerEntity p : existingPlayers) { + existingUUIDs.add(p.getUuid()); + } + for (ServerPlayerEntity player : playerList) { + if (!existingUUIDs.contains(player.getUuid())) { + bossBar.addPlayer(player); + } + } + } + } + + private void finish() { + if (finished) return; + finished = true; + bossBar.setVisible(false); + bossBar.clearPlayers(); + + playSoundToAll(cx, topY + 1.0, cz, SoundEvents.BLOCK_BEACON_DEACTIVATE, SoundCategory.BLOCKS, 2.0f, 0.8f); + playSoundToAll(cx, topY + 1.0, cz, SoundEvents.BLOCK_CONDUIT_DEACTIVATE, SoundCategory.BLOCKS, 2.0f, 1.0f); + playSoundToAll(cx, topY + 1.0, cz, SoundEvents.UI_TOAST_CHALLENGE_COMPLETE, SoundCategory.MASTER, 1.5f, 1.0f); + } + } +} diff --git a/src/main/java/com/gvsds/tcme/config/Config.java b/src/main/java/com/gvsds/tcme/config/Config.java new file mode 100644 index 0000000..e9f2fdb --- /dev/null +++ b/src/main/java/com/gvsds/tcme/config/Config.java @@ -0,0 +1,69 @@ +package com.gvsds.tcme.config; + +import java.util.List; + +public class Config { + + public static class Tables { + private String currencies = "cme_currencies"; + private String balances = "cme_balances"; + private String transactions = "cme_transactions"; + private String paymentRequests = "cme_payment_requests"; + + public String getCurrencies() { return currencies; } + public void setCurrencies(String currencies) { this.currencies = currencies; } + + public String getBalances() { return balances; } + public void setBalances(String balances) { this.balances = balances; } + + public String getTransactions() { return transactions; } + public void setTransactions(String transactions) { this.transactions = transactions; } + + public String getPaymentRequests() { return paymentRequests; } + public void setPaymentRequests(String paymentRequests) { this.paymentRequests = paymentRequests; } + } + + public static class Database { + private String type = "sqlite"; + private String sqlitePath = "./config/cardinal-economy/database"; + private String mysqlHost = "localhost"; + private int mysqlPort = 3306; + private String mysqlDatabase = "cardinal_economy"; + private String mysqlUsername = "root"; + private String mysqlPassword = ""; + private boolean useSSL = false; + private Tables tables = new Tables(); + + public String getType() { return type; } + public void setType(String type) { this.type = type; } + + public String getSqlitePath() { return sqlitePath; } + public void setSqlitePath(String sqlitePath) { this.sqlitePath = sqlitePath; } + + public String getMysqlHost() { return mysqlHost; } + public void setMysqlHost(String mysqlHost) { this.mysqlHost = mysqlHost; } + + public int getMysqlPort() { return mysqlPort; } + public void setMysqlPort(int mysqlPort) { this.mysqlPort = mysqlPort; } + + public String getMysqlDatabase() { return mysqlDatabase; } + public void setMysqlDatabase(String mysqlDatabase) { this.mysqlDatabase = mysqlDatabase; } + + public String getMysqlUsername() { return mysqlUsername; } + public void setMysqlUsername(String mysqlUsername) { this.mysqlUsername = mysqlUsername; } + + public String getMysqlPassword() { return mysqlPassword; } + public void setMysqlPassword(String mysqlPassword) { this.mysqlPassword = mysqlPassword; } + + public boolean isUseSSL() { return useSSL; } + public void setUseSSL(boolean useSSL) { this.useSSL = useSSL; } + + public Tables getTables() { return tables; } + public void setTables(Tables tables) { this.tables = tables; } + } + + private Database database = new Database(); + + public Database getDatabase() { return database; } + public void setDatabase(Database database) { this.database = database; } +} diff --git a/src/main/java/com/gvsds/tcme/config/ConfigManager.java b/src/main/java/com/gvsds/tcme/config/ConfigManager.java new file mode 100644 index 0000000..0ac9b79 --- /dev/null +++ b/src/main/java/com/gvsds/tcme/config/ConfigManager.java @@ -0,0 +1,55 @@ +package com.gvsds.tcme.config; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +public class ConfigManager { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Path CONFIG_DIR = Path.of("config", "cardinal-economy"); + private static final Path CONFIG_FILE = CONFIG_DIR.resolve("config.json"); + + private Config config; + + public ConfigManager() { + loadConfig(); + } + + private void loadConfig() { + try { + if (!Files.exists(CONFIG_DIR)) { + Files.createDirectories(CONFIG_DIR); + } + + if (Files.exists(CONFIG_FILE)) { + String json = Files.readString(CONFIG_FILE); + config = GSON.fromJson(json, Config.class); + } else { + config = new Config(); + saveConfig(); + } + } catch (IOException e) { + throw new RuntimeException("Failed to load config", e); + } + } + + public void saveConfig() { + try { + if (!Files.exists(CONFIG_DIR)) { + Files.createDirectories(CONFIG_DIR); + } + String json = GSON.toJson(config); + Files.writeString(CONFIG_FILE, json); + } catch (IOException e) { + throw new RuntimeException("Failed to save config", e); + } + } + + public Config getConfig() { + return config; + } +} diff --git a/src/main/java/com/gvsds/tcme/database/DatabaseManager.java b/src/main/java/com/gvsds/tcme/database/DatabaseManager.java new file mode 100644 index 0000000..f59e7a5 --- /dev/null +++ b/src/main/java/com/gvsds/tcme/database/DatabaseManager.java @@ -0,0 +1,187 @@ +package com.gvsds.tcme.database; + +import com.gvsds.tcme.CardinalCooperativeCapitalismMarketEconomy; +import com.gvsds.tcme.config.Config; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +import java.sql.Connection; +import java.sql.SQLException; + +public class DatabaseManager { + + private Config.Database dbConfig; + private boolean initialized = false; + private HikariDataSource dataSource; + + public DatabaseManager(Config.Database dbConfig) { + this.dbConfig = dbConfig; + } + + public void connect() { + try { + HikariConfig hikariConfig = new HikariConfig(); + + if ("sqlite".equalsIgnoreCase(dbConfig.getType())) { + String dbPath = dbConfig.getSqlitePath(); + java.nio.file.Path path = java.nio.file.Path.of(dbPath); + try { + java.nio.file.Files.createDirectories(path); + } catch (java.io.IOException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to create SQLite directory", e); + } + String jdbcUrl = "jdbc:sqlite:" + dbPath + "/cardinal_economy.db"; + hikariConfig.setJdbcUrl(jdbcUrl); + hikariConfig.setDriverClassName("org.sqlite.JDBC"); + hikariConfig.setMaximumPoolSize(1); + hikariConfig.setConnectionTimeout(10000); + hikariConfig.setAutoCommit(false); + } else if ("mysql".equalsIgnoreCase(dbConfig.getType())) { + String jdbcUrl = String.format("jdbc:mysql://%s:%d/%s?useSSL=%s&allowPublicKeyRetrieval=true&serverTimezone=UTC", + dbConfig.getMysqlHost(), + dbConfig.getMysqlPort(), + dbConfig.getMysqlDatabase(), + dbConfig.isUseSSL()); + hikariConfig.setJdbcUrl(jdbcUrl); + hikariConfig.setDriverClassName("com.mysql.cj.jdbc.Driver"); + hikariConfig.setUsername(dbConfig.getMysqlUsername()); + hikariConfig.setPassword(dbConfig.getMysqlPassword()); + hikariConfig.setMaximumPoolSize(10); + hikariConfig.setMinimumIdle(2); + hikariConfig.setConnectionTimeout(30000); + hikariConfig.setIdleTimeout(600000); + hikariConfig.setMaxLifetime(1800000); + hikariConfig.setAutoCommit(false); + hikariConfig.addDataSourceProperty("cachePrepStmts", "true"); + hikariConfig.addDataSourceProperty("prepStmtCacheSize", "250"); + hikariConfig.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + } else { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Unknown database type: {}", dbConfig.getType()); + return; + } + + hikariConfig.setPoolName("3CME-Pool"); + this.dataSource = new HikariDataSource(hikariConfig); + + try (Connection testConn = dataSource.getConnection()) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.info("Successfully connected to {} database (HikariCP pool initialized)", dbConfig.getType()); + } + + initialized = true; + initializeTables(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to connect to database", e); + } + } + + private String getJdbcUrl(Config.Database dbConfig) { + if ("sqlite".equalsIgnoreCase(dbConfig.getType())) { + String dbPath = dbConfig.getSqlitePath(); + return "jdbc:sqlite:" + dbPath + "/cardinal_economy.db"; + } else if ("mysql".equalsIgnoreCase(dbConfig.getType())) { + return String.format("jdbc:mysql://%s:%d/%s?useSSL=%s&allowPublicKeyRetrieval=true&serverTimezone=UTC", + dbConfig.getMysqlHost(), + dbConfig.getMysqlPort(), + dbConfig.getMysqlDatabase(), + dbConfig.isUseSSL()); + } + return ""; + } + + private void initializeTables() { + try (Connection conn = getConnection()) { + try (var statement = conn.createStatement()) { + String dbType = dbConfig.getType(); + boolean isMySQL = "mysql".equalsIgnoreCase(dbType); + + String uuidType = isMySQL ? "BINARY(16)" : "BLOB"; + String textType = isMySQL ? "VARCHAR(255)" : "TEXT"; + String autoIncrement = isMySQL ? "AUTO_INCREMENT" : "AUTOINCREMENT"; + String currentTimestamp = isMySQL ? "CURRENT_TIMESTAMP" : "CURRENT_TIMESTAMP"; + String engineClause = isMySQL ? "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" : ""; + + String currenciesTable = String.format(""" + CREATE TABLE IF NOT EXISTS %s ( + id INTEGER PRIMARY KEY %s, + name %s NOT NULL UNIQUE, + symbol %s NOT NULL, + tradable INTEGER DEFAULT 1, + created_at TIMESTAMP DEFAULT %s + ) %s + """, dbConfig.getTables().getCurrencies(), autoIncrement, textType, textType, currentTimestamp, engineClause); + + String balancesTable = String.format(""" + CREATE TABLE IF NOT EXISTS %s ( + id INTEGER PRIMARY KEY %s, + user_uuid %s NOT NULL, + currency_id INTEGER NOT NULL, + balance REAL DEFAULT 0.0, + created_at TIMESTAMP DEFAULT %s, + updated_at TIMESTAMP DEFAULT %s, + FOREIGN KEY (currency_id) REFERENCES %s(id), + UNIQUE(user_uuid, currency_id) + ) %s + """, dbConfig.getTables().getBalances(), autoIncrement, uuidType, currentTimestamp, currentTimestamp, + dbConfig.getTables().getCurrencies(), engineClause); + + String transactionsTable = String.format(""" + CREATE TABLE IF NOT EXISTS %s ( + id INTEGER PRIMARY KEY %s, + from_uuid %s, + to_uuid %s, + currency_id INTEGER NOT NULL, + amount REAL NOT NULL, + type %s NOT NULL, + description %s, + created_at TIMESTAMP DEFAULT %s, + FOREIGN KEY (currency_id) REFERENCES %s(id) + ) %s + """, dbConfig.getTables().getTransactions(), autoIncrement, uuidType, uuidType, + textType, textType, currentTimestamp, dbConfig.getTables().getCurrencies(), engineClause); + + String paymentRequestsTable = String.format(""" + CREATE TABLE IF NOT EXISTS %s ( + id INTEGER PRIMARY KEY %s, + from_uuid %s NOT NULL, + to_uuid %s NOT NULL, + currency_id INTEGER NOT NULL, + amount REAL NOT NULL, + status %s DEFAULT 'pending', + created_at TIMESTAMP DEFAULT %s, + FOREIGN KEY (currency_id) REFERENCES %s(id) + ) %s + """, dbConfig.getTables().getPaymentRequests(), autoIncrement, uuidType, uuidType, + textType, currentTimestamp, dbConfig.getTables().getCurrencies(), engineClause); + + statement.execute(currenciesTable); + statement.execute(balancesTable); + statement.execute(transactionsTable); + statement.execute(paymentRequestsTable); + + conn.commit(); + CardinalCooperativeCapitalismMarketEconomy.LOGGER.info("Database tables initialized"); + } + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to initialize database tables", e); + } + } + + public Connection getConnection() throws SQLException { + Connection conn = dataSource.getConnection(); + conn.setAutoCommit(false); + return conn; + } + + public void disconnect() { + initialized = false; + if (dataSource != null && !dataSource.isClosed()) { + dataSource.close(); + CardinalCooperativeCapitalismMarketEconomy.LOGGER.info("HikariCP datasource closed"); + } + CardinalCooperativeCapitalismMarketEconomy.LOGGER.info("Database manager disconnected"); + } + + public boolean isConnected() { + return initialized && dataSource != null && !dataSource.isClosed(); + } +} diff --git a/src/main/java/com/gvsds/tcme/gui/AlertScreenHandler.java b/src/main/java/com/gvsds/tcme/gui/AlertScreenHandler.java new file mode 100644 index 0000000..ab400cf --- /dev/null +++ b/src/main/java/com/gvsds/tcme/gui/AlertScreenHandler.java @@ -0,0 +1,106 @@ +package com.gvsds.tcme.gui; + +import com.gvsds.tcme.util.TextUtil; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.entity.player.PlayerInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.network.packet.s2c.play.CloseScreenS2CPacket; +import net.minecraft.network.packet.s2c.play.ScreenHandlerSlotUpdateS2CPacket; +import net.minecraft.screen.ScreenHandler; +import net.minecraft.screen.ScreenHandlerType; +import net.minecraft.screen.slot.Slot; +import net.minecraft.screen.slot.SlotActionType; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; + +import java.util.function.Consumer; + +public class AlertScreenHandler extends ScreenHandler { + private final GuiInventory inventory; + private final Consumer callback; + private boolean closed = false; + + public AlertScreenHandler(int syncId, PlayerInventory playerInventory, String message, Consumer onClose) { + super(ScreenHandlerType.GENERIC_9X3, syncId); + this.callback = onClose; + this.inventory = new GuiInventory(27); + + for (int row = 0; row < 3; ++row) { + for (int col = 0; col < 9; ++col) { + this.addSlot(new Slot(this.inventory, col + row * 9, 8 + col * 18, 18 + row * 18) { + @Override + public boolean canInsert(ItemStack stack) { + return false; + } + + @Override + public boolean canTakeItems(PlayerEntity playerEntity) { + return false; + } + }); + } + } + + ItemStack infoItem = new ItemStack(Items.PAPER); + infoItem.set(DataComponentTypes.CUSTOM_NAME, TextUtil.parseFormatting(message)); + inventory.setSlot(13, infoItem); + + ItemStack closeItem = new ItemStack(Items.RED_WOOL); + closeItem.set(DataComponentTypes.CUSTOM_NAME, Text.literal("[ Close ]").formatted(net.minecraft.util.Formatting.RED)); + inventory.setSlot(22, closeItem); + + for (int i = 0; i < 9; i++) { + this.addSlot(new Slot(playerInventory, i, 8 + i * 18, 142)); + } + } + + @Override + public ItemStack quickMove(PlayerEntity player, int index) { + return ItemStack.EMPTY; + } + + @Override + public boolean canUse(PlayerEntity player) { + return true; + } + + private void closeGui(PlayerEntity player) { + if (closed) return; + closed = true; + if (player instanceof ServerPlayerEntity serverPlayer) { + serverPlayer.networkHandler.sendPacket(new CloseScreenS2CPacket(this.syncId)); + } + } + + @Override + public void onSlotClick(int slotIndex, int button, SlotActionType actionType, PlayerEntity player) { + if (closed) return; + + if (slotIndex == 22 && actionType == SlotActionType.PICKUP) { + closeGui(player); + if (callback != null) callback.accept(true); + return; + } + + if (slotIndex >= 0 && slotIndex < 27 && actionType == SlotActionType.PICKUP) { + closeGui(player); + if (callback != null) callback.accept(true); + return; + } + + if (player instanceof ServerPlayerEntity sp) { + sp.networkHandler.sendPacket(new ScreenHandlerSlotUpdateS2CPacket(this.syncId, 0, slotIndex, this.getSlot(slotIndex).getStack())); + } + } + + @Override + public void onClosed(PlayerEntity player) { + if (!closed) { + closed = true; + if (callback != null) callback.accept(true); + } + super.onClosed(player); + } +} diff --git a/src/main/java/com/gvsds/tcme/gui/ConfirmScreenHandler.java b/src/main/java/com/gvsds/tcme/gui/ConfirmScreenHandler.java new file mode 100644 index 0000000..9d8711b --- /dev/null +++ b/src/main/java/com/gvsds/tcme/gui/ConfirmScreenHandler.java @@ -0,0 +1,117 @@ +package com.gvsds.tcme.gui; + +import com.gvsds.tcme.util.TextUtil; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.entity.player.PlayerInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.network.packet.s2c.play.CloseScreenS2CPacket; +import net.minecraft.network.packet.s2c.play.ScreenHandlerSlotUpdateS2CPacket; +import net.minecraft.screen.ScreenHandler; +import net.minecraft.screen.ScreenHandlerType; +import net.minecraft.screen.slot.Slot; +import net.minecraft.screen.slot.SlotActionType; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; + +import java.util.function.Consumer; + +public class ConfirmScreenHandler extends ScreenHandler { + private final GuiInventory inventory; + private final Consumer callback; + private boolean responded = false; + + public ConfirmScreenHandler(int syncId, PlayerInventory playerInventory, String message, Consumer onConfirm) { + super(ScreenHandlerType.GENERIC_9X3, syncId); + this.callback = onConfirm; + this.inventory = new GuiInventory(27); + + for (int row = 0; row < 3; ++row) { + for (int col = 0; col < 9; ++col) { + this.addSlot(new Slot(this.inventory, col + row * 9, 8 + col * 18, 18 + row * 18) { + @Override + public boolean canInsert(ItemStack stack) { + return false; + } + + @Override + public boolean canTakeItems(PlayerEntity playerEntity) { + return false; + } + }); + } + } + + ItemStack infoItem = new ItemStack(Items.BOOK); + infoItem.set(DataComponentTypes.CUSTOM_NAME, TextUtil.parseFormatting(message)); + inventory.setSlot(13, infoItem); + + ItemStack confirmItem = new ItemStack(Items.LIME_WOOL); + confirmItem.set(DataComponentTypes.CUSTOM_NAME, Text.literal("[ Confirm ]").formatted(Formatting.GREEN)); + inventory.setSlot(20, confirmItem); + + ItemStack cancelItem = new ItemStack(Items.RED_WOOL); + cancelItem.set(DataComponentTypes.CUSTOM_NAME, Text.literal("[ Cancel ]").formatted(Formatting.RED)); + inventory.setSlot(24, cancelItem); + + for (int i = 0; i < 9; i++) { + this.addSlot(new Slot(playerInventory, i, 8 + i * 18, 142)); + } + } + + @Override + public ItemStack quickMove(PlayerEntity player, int index) { + return ItemStack.EMPTY; + } + + @Override + public boolean canUse(PlayerEntity player) { + return true; + } + + private void closeGui(PlayerEntity player) { + if (player instanceof ServerPlayerEntity serverPlayer) { + serverPlayer.networkHandler.sendPacket(new CloseScreenS2CPacket(this.syncId)); + } + } + + @Override + public void onSlotClick(int slotIndex, int button, SlotActionType actionType, PlayerEntity player) { + if (responded) { + closeGui(player); + return; + } + + if (actionType != SlotActionType.PICKUP) { + if (player instanceof ServerPlayerEntity sp) { + sp.networkHandler.sendPacket(new ScreenHandlerSlotUpdateS2CPacket(this.syncId, 0, slotIndex, this.getSlot(slotIndex).getStack())); + } + return; + } + + if (slotIndex == 20) { + responded = true; + closeGui(player); + if (callback != null) callback.accept(true); + } else if (slotIndex == 24) { + responded = true; + closeGui(player); + if (callback != null) callback.accept(false); + } else { + if (player instanceof ServerPlayerEntity sp) { + sp.networkHandler.sendPacket(new ScreenHandlerSlotUpdateS2CPacket(this.syncId, 0, slotIndex, this.getSlot(slotIndex).getStack())); + } + } + } + + @Override + public void onClosed(PlayerEntity player) { + if (!responded) { + responded = true; + if (callback != null) callback.accept(false); + } + super.onClosed(player); + } +} diff --git a/src/main/java/com/gvsds/tcme/gui/GuiInventory.java b/src/main/java/com/gvsds/tcme/gui/GuiInventory.java new file mode 100644 index 0000000..fc2d5d4 --- /dev/null +++ b/src/main/java/com/gvsds/tcme/gui/GuiInventory.java @@ -0,0 +1,87 @@ +package com.gvsds.tcme.gui; + +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.entity.player.PlayerInventory; +import net.minecraft.inventory.Inventory; +import net.minecraft.item.ItemStack; +import net.minecraft.screen.ScreenHandler; +import net.minecraft.screen.slot.Slot; + +public class GuiInventory implements Inventory { + + private final ItemStack[] items; + + public GuiInventory(int size) { + this.items = new ItemStack[size]; + for (int i = 0; i < size; i++) { + items[i] = ItemStack.EMPTY; + } + } + + public void setSlot(int index, ItemStack stack) { + if (index >= 0 && index < items.length) { + items[index] = stack; + } + } + + public void setSlot(int index, net.minecraft.item.Item item) { + setSlot(index, new ItemStack(item)); + } + + public void setSlot(int index, net.minecraft.item.Item item, int count) { + setSlot(index, new ItemStack(item, count)); + } + + @Override + public int size() { + return items.length; + } + + @Override + public boolean isEmpty() { + for (ItemStack stack : items) { + if (!stack.isEmpty()) return false; + } + return true; + } + + @Override + public ItemStack getStack(int slot) { + if (slot >= 0 && slot < items.length) { + return items[slot]; + } + return ItemStack.EMPTY; + } + + @Override + public ItemStack removeStack(int slot, int amount) { + return ItemStack.EMPTY; + } + + @Override + public ItemStack removeStack(int slot) { + return ItemStack.EMPTY; + } + + @Override + public void setStack(int slot, ItemStack stack) { + if (slot >= 0 && slot < items.length) { + items[slot] = stack.copy(); + } + } + + @Override + public void markDirty() {} + + @Override + public boolean canPlayerUse(PlayerEntity player) { + return true; + } + + @Override + public void clear() { + for (int i = 0; i < items.length; i++) { + items[i] = ItemStack.EMPTY; + } + } +} diff --git a/src/main/java/com/gvsds/tcme/gui/InputScreenHandler.java b/src/main/java/com/gvsds/tcme/gui/InputScreenHandler.java new file mode 100644 index 0000000..008505c --- /dev/null +++ b/src/main/java/com/gvsds/tcme/gui/InputScreenHandler.java @@ -0,0 +1,151 @@ +package com.gvsds.tcme.gui; + +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.entity.player.PlayerInventory; +import net.minecraft.inventory.Inventory; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.network.packet.s2c.play.CloseScreenS2CPacket; +import net.minecraft.screen.AnvilScreenHandler; +import net.minecraft.screen.ScreenHandlerContext; +import net.minecraft.screen.slot.SlotActionType; +import net.minecraft.server.network.ServerPlayerEntity; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; + +public class InputScreenHandler extends AnvilScreenHandler { + private static final Map sessions = new HashMap<>(); + private final UUID playerId; + private boolean closed = false; + + public InputScreenHandler(int syncId, PlayerInventory playerInventory, String hint, Consumer callback, ServerPlayerEntity sp) { + super(syncId, playerInventory, ScreenHandlerContext.create(sp.getEntityWorld(), sp.getBlockPos())); + this.playerId = sp.getUuid(); + + InputSession session = new InputSession(syncId, callback); + sessions.put(this.playerId, session); + + ItemStack hintItem = new ItemStack(Items.PAPER); + hintItem.setCount(1); + this.getSlot(0).setStack(hintItem); + } + + public static boolean hasSession(UUID playerId) { + InputSession session = sessions.get(playerId); + return session != null && !session.responded; + } + + public static void submitInput(ServerPlayerEntity player, String text) { + InputSession session = sessions.get(player.getUuid()); + if (session != null && !session.responded) { + session.responded = true; + player.networkHandler.sendPacket(new CloseScreenS2CPacket(session.syncId)); + if (session.callback != null) { + session.callback.accept(text); + } + sessions.remove(player.getUuid()); + } + } + + @Override + public void onSlotClick(int slotIndex, int button, SlotActionType actionType, PlayerEntity player) { + if (slotIndex == 2) { + if (player instanceof ServerPlayerEntity sp && !closed) { + ItemStack result = this.getSlot(2).getStack(); + if (!result.isEmpty()) { + closed = true; + InputSession session = sessions.get(sp.getUuid()); + if (session != null && !session.responded) { + session.responded = true; + String text = result.getName().getString(); + if (session.callback != null) { + session.callback.accept(text); + } + sp.networkHandler.sendPacket(new CloseScreenS2CPacket(session.syncId)); + sessions.remove(sp.getUuid()); + } + } + } + return; + } + if (slotIndex == 0 || slotIndex == 1) { + return; + } + super.onSlotClick(slotIndex, button, actionType, player); + } + + @Override + public void onClosed(PlayerEntity player) { + if (player instanceof ServerPlayerEntity sp) { + InputSession session = sessions.get(sp.getUuid()); + if (session != null && !session.responded) { + session.responded = true; + if (session.callback != null) { + session.callback.accept(null); + } + } + this.getSlot(0).setStack(ItemStack.EMPTY); + this.getSlot(1).setStack(ItemStack.EMPTY); + this.getSlot(2).setStack(ItemStack.EMPTY); + sessions.remove(sp.getUuid()); + } + super.onClosed(player); + } + + @Override + public ItemStack quickMove(PlayerEntity player, int index) { + return ItemStack.EMPTY; + } + + @Override + public boolean canUse(PlayerEntity player) { + return true; + } + + @Override + public boolean canTakeOutput(PlayerEntity player, boolean present) { + return false; + } + + @Override + public boolean onButtonClick(PlayerEntity player, int id) { + if (id == 0 && !closed) { + ItemStack result = this.getSlot(2).getStack(); + if (!result.isEmpty()) { + if (player instanceof ServerPlayerEntity sp) { + closed = true; + InputSession session = sessions.get(sp.getUuid()); + if (session != null && !session.responded) { + session.responded = true; + String text = result.getName().getString(); + if (session.callback != null) { + session.callback.accept(text); + } + sp.networkHandler.sendPacket(new CloseScreenS2CPacket(session.syncId)); + sessions.remove(sp.getUuid()); + return true; + } + } + } + } + return false; + } + + @Override + public void onTakeOutput(PlayerEntity player, ItemStack stack) { + } + + public static class InputSession { + public final int syncId; + public boolean responded; + public final Consumer callback; + + public InputSession(int syncId, Consumer callback) { + this.syncId = syncId; + this.callback = callback; + } + } +} diff --git a/src/main/java/com/gvsds/tcme/gui/LargeChestScreenHandler.java b/src/main/java/com/gvsds/tcme/gui/LargeChestScreenHandler.java new file mode 100644 index 0000000..01d32ea --- /dev/null +++ b/src/main/java/com/gvsds/tcme/gui/LargeChestScreenHandler.java @@ -0,0 +1,66 @@ +package com.gvsds.tcme.gui; + +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.entity.player.PlayerInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.network.packet.s2c.play.ScreenHandlerSlotUpdateS2CPacket; +import net.minecraft.screen.ScreenHandler; +import net.minecraft.screen.ScreenHandlerType; +import net.minecraft.screen.slot.Slot; +import net.minecraft.screen.slot.SlotActionType; +import net.minecraft.server.network.ServerPlayerEntity; + +public class LargeChestScreenHandler extends ScreenHandler { + private final GuiInventory inventory; + + public LargeChestScreenHandler(int syncId, PlayerInventory playerInventory, GuiInventory guiInventory) { + super(ScreenHandlerType.GENERIC_9X6, syncId); + this.inventory = guiInventory; + + for (int row = 0; row < 6; ++row) { + for (int col = 0; col < 9; ++col) { + this.addSlot(new Slot(this.inventory, col + row * 9, 8 + col * 18, 18 + row * 18) { + @Override + public boolean canInsert(ItemStack stack) { + return false; + } + + @Override + public boolean canTakeItems(PlayerEntity playerEntity) { + return false; + } + }); + } + } + + for (int row = 0; row < 3; ++row) { + for (int col = 0; col < 9; ++col) { + this.addSlot(new Slot(playerInventory, col + row * 9 + 9, 8 + col * 18, 140 + row * 18)); + } + } + + for (int i = 0; i < 9; i++) { + this.addSlot(new Slot(playerInventory, i, 8 + i * 18, 198)); + } + } + + @Override + public ItemStack quickMove(PlayerEntity player, int index) { + return ItemStack.EMPTY; + } + + @Override + public boolean canUse(PlayerEntity player) { + return true; + } + + @Override + public void onSlotClick(int slotIndex, int button, SlotActionType actionType, PlayerEntity player) { + if (player instanceof ServerPlayerEntity sp) { + if (slotIndex >= 0 && slotIndex < 54) { + sp.networkHandler.sendPacket(new ScreenHandlerSlotUpdateS2CPacket(this.syncId, 0, slotIndex, this.getSlot(slotIndex).getStack())); + } + sp.networkHandler.sendPacket(new ScreenHandlerSlotUpdateS2CPacket(this.syncId, -1, -1, ItemStack.EMPTY)); + } + } +} diff --git a/src/main/java/com/gvsds/tcme/gui/ServerGuiAPI.java b/src/main/java/com/gvsds/tcme/gui/ServerGuiAPI.java new file mode 100644 index 0000000..dc9a959 --- /dev/null +++ b/src/main/java/com/gvsds/tcme/gui/ServerGuiAPI.java @@ -0,0 +1,134 @@ +package com.gvsds.tcme.gui; + +import com.gvsds.tcme.util.TextUtil; +import net.minecraft.entity.player.PlayerInventory; +import net.minecraft.screen.GenericContainerScreenHandler; +import net.minecraft.screen.NamedScreenHandlerFactory; +import net.minecraft.screen.ScreenHandler; +import net.minecraft.screen.AnvilScreenHandler; +import net.minecraft.screen.ScreenHandlerContext; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import org.jetbrains.annotations.Nullable; + +import java.util.function.Consumer; + +public class ServerGuiAPI { + + public static void showAlert(ServerPlayerEntity player, String title, String message) { + showAlert(player, title, message, null); + } + + public static void showAlert(ServerPlayerEntity player, String title, String message, Consumer onClose) { + NamedScreenHandlerFactory factory = new NamedScreenHandlerFactory() { + @Override + public Text getDisplayName() { + return TextUtil.parseFormatting(title); + } + + @Nullable + @Override + public ScreenHandler createMenu(int syncId, PlayerInventory playerInv, net.minecraft.entity.player.PlayerEntity player) { + return new AlertScreenHandler(syncId, playerInv, message, onClose); + } + }; + player.openHandledScreen(factory); + } + + public static void showConfirm(ServerPlayerEntity player, String title, String message, Consumer onConfirm) { + NamedScreenHandlerFactory factory = new NamedScreenHandlerFactory() { + @Override + public Text getDisplayName() { + return TextUtil.parseFormatting(title); + } + + @Nullable + @Override + public ScreenHandler createMenu(int syncId, PlayerInventory playerInv, net.minecraft.entity.player.PlayerEntity player) { + return new ConfirmScreenHandler(syncId, playerInv, message, onConfirm); + } + }; + player.openHandledScreen(factory); + } + + public static void showInputBox(ServerPlayerEntity player, String title, String hint, Consumer onSubmit) { + NamedScreenHandlerFactory factory = new NamedScreenHandlerFactory() { + @Override + public Text getDisplayName() { + return TextUtil.parseFormatting(title); + } + + @Nullable + @Override + public ScreenHandler createMenu(int syncId, PlayerInventory playerInv, net.minecraft.entity.player.PlayerEntity player) { + return new InputScreenHandler(syncId, playerInv, hint, onSubmit, player instanceof ServerPlayerEntity ? (ServerPlayerEntity) player : null); + } + }; + player.openHandledScreen(factory); + } + + public static void showSmallChest(ServerPlayerEntity player, String title, Consumer onSlotClick) { + GuiInventory inventory = new GuiInventory(9); + player.openHandledScreen(new NamedScreenHandlerFactory() { + @Override + public Text getDisplayName() { + return TextUtil.parseFormatting(title); + } + + @Nullable + @Override + public ScreenHandler createMenu(int syncId, PlayerInventory playerInv, net.minecraft.entity.player.PlayerEntity player) { + return new SmallChestScreenHandler(syncId, playerInv, inventory); + } + }); + } + + public static void showMediumChest(ServerPlayerEntity player, String title, Consumer onSlotClick) { + GuiInventory inventory = new GuiInventory(27); + player.openHandledScreen(new NamedScreenHandlerFactory() { + @Override + public Text getDisplayName() { + return TextUtil.parseFormatting(title); + } + + @Nullable + @Override + public ScreenHandler createMenu(int syncId, PlayerInventory playerInv, net.minecraft.entity.player.PlayerEntity player) { + return GenericContainerScreenHandler.createGeneric9x3(syncId, playerInv, inventory); + } + }); + } + + public static void showLargeChest(ServerPlayerEntity player, String title, Consumer onSlotClick) { + GuiInventory inventory = new GuiInventory(54); + player.openHandledScreen(new NamedScreenHandlerFactory() { + @Override + public Text getDisplayName() { + return TextUtil.parseFormatting(title); + } + + @Nullable + @Override + public ScreenHandler createMenu(int syncId, PlayerInventory playerInv, net.minecraft.entity.player.PlayerEntity player) { + return new LargeChestScreenHandler(syncId, playerInv, inventory); + } + }); + } + + public static void showAnvil(ServerPlayerEntity player, String title) { + player.openHandledScreen(new NamedScreenHandlerFactory() { + @Override + public Text getDisplayName() { + return TextUtil.parseFormatting(title); + } + + @Nullable + @Override + public ScreenHandler createMenu(int syncId, PlayerInventory playerInv, net.minecraft.entity.player.PlayerEntity player) { + ServerPlayerEntity sp = (ServerPlayerEntity) player; + return new AnvilScreenHandler(syncId, playerInv, + ScreenHandlerContext.create(sp.getEntityWorld(), sp.getBlockPos())); + } + }); + } +} diff --git a/src/main/java/com/gvsds/tcme/gui/SmallChestScreenHandler.java b/src/main/java/com/gvsds/tcme/gui/SmallChestScreenHandler.java new file mode 100644 index 0000000..6de761c --- /dev/null +++ b/src/main/java/com/gvsds/tcme/gui/SmallChestScreenHandler.java @@ -0,0 +1,60 @@ +package com.gvsds.tcme.gui; + +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.entity.player.PlayerInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.network.packet.s2c.play.ScreenHandlerSlotUpdateS2CPacket; +import net.minecraft.screen.ScreenHandler; +import net.minecraft.screen.ScreenHandlerType; +import net.minecraft.screen.slot.Slot; +import net.minecraft.screen.slot.SlotActionType; +import net.minecraft.server.network.ServerPlayerEntity; + +public class SmallChestScreenHandler extends ScreenHandler { + private final GuiInventory inventory; + + public SmallChestScreenHandler(int syncId, PlayerInventory playerInventory, GuiInventory guiInventory) { + super(ScreenHandlerType.GENERIC_9X1, syncId); + this.inventory = guiInventory; + + for (int col = 0; col < 9; ++col) { + this.addSlot(new Slot(this.inventory, col, 8 + col * 18, 18) { + @Override + public boolean canInsert(ItemStack stack) { + return false; + } + + @Override + public boolean canTakeItems(PlayerEntity playerEntity) { + return false; + } + }); + } + + for (int row = 0; row < 3; ++row) { + for (int col = 0; col < 9; ++col) { + this.addSlot(new Slot(playerInventory, col + row * 9 + 9, 8 + col * 18, 84 + row * 18)); + } + } + } + + @Override + public ItemStack quickMove(PlayerEntity player, int index) { + return ItemStack.EMPTY; + } + + @Override + public boolean canUse(PlayerEntity player) { + return true; + } + + @Override + public void onSlotClick(int slotIndex, int button, SlotActionType actionType, PlayerEntity player) { + if (player instanceof ServerPlayerEntity sp) { + if (slotIndex >= 0 && slotIndex < 9) { + sp.networkHandler.sendPacket(new ScreenHandlerSlotUpdateS2CPacket(this.syncId, 0, slotIndex, this.getSlot(slotIndex).getStack())); + } + sp.networkHandler.sendPacket(new ScreenHandlerSlotUpdateS2CPacket(this.syncId, -1, -1, ItemStack.EMPTY)); + } + } +} diff --git a/src/main/java/com/gvsds/tcme/i18n/LanguageManager.java b/src/main/java/com/gvsds/tcme/i18n/LanguageManager.java new file mode 100644 index 0000000..0be3105 --- /dev/null +++ b/src/main/java/com/gvsds/tcme/i18n/LanguageManager.java @@ -0,0 +1,225 @@ +package com.gvsds.tcme.i18n; + +import com.gvsds.tcme.CardinalCooperativeCapitalismMarketEconomy; +import com.gvsds.tcme.database.DatabaseManager; + +import java.nio.ByteBuffer; +import java.sql.*; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public class LanguageManager { + + private static final Map> LANGUAGES = new HashMap<>(); + + private final Map playerLanguages = new HashMap<>(); + private DatabaseManager dbManager; + private boolean isMySQL; + + public LanguageManager(DatabaseManager dbManager, boolean isMySQL) { + this.dbManager = dbManager; + this.isMySQL = isMySQL; + loadLanguages(); + loadAllPlayerLanguages(); + } + + private void loadLanguages() { + Map enUS = new HashMap<>(); + enUS.put("cme.wallet.title", "=== Your Wallet ==="); + enUS.put("cme.wallet.balance", " %s: %.2f %s"); + enUS.put("cme.wallet.empty", "Your wallet is empty."); + enUS.put("cme.lang.changed", "Language changed to %s"); + enUS.put("cme.lang.available", "Available languages: %s"); + enUS.put("cme.lang.notfound", "Language not found: %s"); + enUS.put("cme.currency.created", "Created currency: %s (%s) - Tradable: %s"); + enUS.put("cme.currency.create_failed", "Failed to create currency: %s"); + enUS.put("cme.currency.deleted", "Deleted currency: %s"); + enUS.put("cme.currency.delete_failed", "Failed to delete currency: %s"); + enUS.put("cme.currency.tradable", "Currency %s tradable set to: %s"); + enUS.put("cme.currency.tradable_failed", "Failed to set tradable for currency: %s"); + enUS.put("cme.currency.list", "Currencies:"); + enUS.put("cme.currency.not_found", "Currency not found: %s"); + enUS.put("cme.currency.no_currencies", "No currencies found"); + enUS.put("cme.currency.no_available", "No currencies available"); + enUS.put("cme.currency.not_tradable", "Currency %s does not allow free trading"); + enUS.put("cme.balance.check", "%s's balance: %.2f %s"); + enUS.put("cme.balance.added", "Added %.2f %s to %s"); + enUS.put("cme.balance.removed", "Removed %.2f %s from %s"); + enUS.put("cme.balance.failed", "Failed to %s balance"); + enUS.put("cme.pay.success", "Successfully paid %.2f %s to %s"); + enUS.put("cme.pay.received", "%s paid you %.2f %s"); + enUS.put("cme.pay.failed", "Payment failed. Insufficient balance or other error"); + enUS.put("cme.pay.self", "Cannot pay yourself"); + enUS.put("cme.apay.sent", "Payment request sent to %s: %.2f %s [ID: %s]"); + enUS.put("cme.apay.received", "%s requested %.2f %s from you [ID: %s]"); + enUS.put("cme.apay.self", "Cannot request payment from yourself"); + enUS.put("cme.apay.failed", "Failed to create payment request"); + enUS.put("cme.requests.list", "Pending payment requests:"); + enUS.put("cme.requests.empty", "No pending payment requests"); + enUS.put("cme.requests.item", " [ID: %s] Requested: %.2f (Currency: %s)"); + enUS.put("cme.requests.accepted", "Payment request #%s accepted"); + enUS.put("cme.requests.accept_failed", "Failed to accept payment request #%s"); + enUS.put("cme.requests.cancelled", "Payment request #%s cancelled"); + enUS.put("cme.requests.cancel_failed", "Failed to cancel payment request #%s"); + enUS.put("cme.currency.renamed", "Renamed currency: %s -> %s"); + enUS.put("cme.currency.rename_failed", "Failed to rename currency: %s"); + enUS.put("cme.currency.updated", "Updated currency %s: symbol=%s, tradable=%s"); + enUS.put("cme.currency.update_failed", "Failed to update currency: %s"); + enUS.put("cme.currency.list_item", " %s (%s) - Tradable: %s - Rate: %.2f"); + enUS.put("cme.lang.usage", "Use /cme lang to change"); + enUS.put("cme.exchange_rate.set", "Exchange rate for %s set to: %.2f"); + enUS.put("cme.exchange_rate.list", "Exchange Rates (relative to base):"); + enUS.put("cme.exchange_rate.item", " %s: %.2f"); + enUS.put("cme.convert.invalid_amount", "Amount must be greater than 0"); + enUS.put("cme.convert.same_currency", "Cannot convert to the same currency"); + enUS.put("cme.convert.insufficient", "Insufficient balance. You have %s %s, but need %s"); + enUS.put("cme.convert.title", "=== Currency Conversion Preview ==="); + enUS.put("cme.convert.from", " From: %s %s (%s)"); + enUS.put("cme.convert.to", " To: %s %s (%s)"); + enUS.put("cme.convert.rate", " Rate: %s : %s = %.2f : %.2f"); + enUS.put("cme.convert.balance_from", " Current %s balance: %s"); + enUS.put("cme.convert.balance_to", " Total %s balance after conversion: %s"); + enUS.put("cme.convert.prompt", "Use /cme convert %s %s %s confirm to proceed"); + enUS.put("cme.convert.success", "Successfully converted %s %s to %s %s"); + enUS.put("cme.convert.failed", "Conversion failed. Please try again"); + enUS.put("cme.convert.refunded", "Conversion failed. Your balance has been refunded"); + enUS.put("cme.requests.rejected", "Rejected payment request #%s"); + enUS.put("cme.requests.reject_failed", "Failed to reject payment request #%s"); + enUS.put("cme.requests.rejected_by", "%s rejected your payment request #%s: %.2f %s"); + enUS.put("cme.requests.revoked", "Revoked payment request #%s"); + enUS.put("cme.requests.revoke_failed", "Failed to revoke payment request #%s"); + enUS.put("cme.requests.revoked_by", "%s revoked payment request #%s: %.2f %s"); + enUS.put("cme.requests.accepted_by", "%s accepted your payment request #%s: %.2f %s"); + enUS.put("cme.requests.not_yours", "This is not your request"); + enUS.put("cme.requests.not_pending", "This request is no longer pending"); + LANGUAGES.put("en_us", enUS); + + Map zhCN = new HashMap<>(); + zhCN.put("cme.wallet.title", "=== 你的钱包 ==="); + zhCN.put("cme.wallet.balance", " %s: %.2f %s"); + zhCN.put("cme.wallet.empty", "你的钱包是空的。"); + zhCN.put("cme.lang.changed", "语言已更改为 %s"); + zhCN.put("cme.lang.available", "可用语言: %s"); + zhCN.put("cme.lang.notfound", "未找到语言: %s"); + zhCN.put("cme.currency.created", "创建币种: %s (%s) - 可自由交易: %s"); + zhCN.put("cme.currency.create_failed", "创建币种失败: %s"); + zhCN.put("cme.currency.deleted", "已删除ID为 %s 的币种"); + zhCN.put("cme.currency.delete_failed", "删除币种失败: %s"); + zhCN.put("cme.currency.tradable", "币种 %s 的自由交易设置为: %s"); + zhCN.put("cme.currency.tradable_failed", "设置币种自由交易失败: %s"); + zhCN.put("cme.currency.list", "币种列表:"); + zhCN.put("cme.currency.not_found", "未找到币种: %s"); + zhCN.put("cme.currency.no_currencies", "未找到任何币种"); + zhCN.put("cme.currency.no_available", "没有可用的币种"); + zhCN.put("cme.currency.not_tradable", "币种 %s 不允许自由交易"); + zhCN.put("cme.balance.check", "%s 的余额: %.2f %s"); + zhCN.put("cme.balance.added", "已添加 %.2f %s 到 %s"); + zhCN.put("cme.balance.removed", "已移除 %.2f %s (来自 %s)"); + zhCN.put("cme.balance.failed", "余额操作失败"); + zhCN.put("cme.pay.success", "成功支付 %.2f %s 给 %s"); + zhCN.put("cme.pay.received", "%s 向你支付了 %.2f %s"); + zhCN.put("cme.pay.failed", "支付失败,余额不足或其他错误"); + zhCN.put("cme.pay.self", "不能支付给自己"); + zhCN.put("cme.apay.sent", "已向 %s 发送支付请求: %.2f %s [ID: %s]"); + zhCN.put("cme.apay.received", "%s 请求你支付 %.2f %s [ID: %s]"); + zhCN.put("cme.apay.self", "不能向自己请求支付"); + zhCN.put("cme.apay.failed", "创建支付请求失败"); + zhCN.put("cme.requests.list", "待处理的支付请求:"); + zhCN.put("cme.requests.empty", "没有待处理的支付请求"); + zhCN.put("cme.requests.item", " [ID: %s] 请求金额: %.2f (币种: %s)"); + zhCN.put("cme.requests.accepted", "已接受支付请求 #%s"); + zhCN.put("cme.requests.accept_failed", "接受支付请求 #%s 失败"); + zhCN.put("cme.requests.cancelled", "已取消支付请求 #%s"); + zhCN.put("cme.requests.cancel_failed", "取消支付请求 #%s 失败"); + zhCN.put("cme.currency.renamed", "已将货币从 %s 重命名为 %s"); + zhCN.put("cme.currency.rename_failed", "重命名货币 %s 失败"); + zhCN.put("cme.currency.updated", "已更新货币 %s: 符号=%s, 可交易=%s"); + zhCN.put("cme.currency.update_failed", "更新货币 %s 失败"); + zhCN.put("cme.currency.list_item", " %s (%s) - 可交易: %s - 汇率: %.2f"); + zhCN.put("cme.lang.usage", "使用 /cme lang <语言> 来切换语言"); + zhCN.put("cme.exchange_rate.set", "%s 的汇率已设置为: %.2f"); + zhCN.put("cme.exchange_rate.list", "汇率列表(相对基准汇率):"); + zhCN.put("cme.exchange_rate.item", " %s: %.2f"); + zhCN.put("cme.convert.invalid_amount", "数量必须大于 0"); + zhCN.put("cme.convert.same_currency", "不能转换为相同的货币"); + zhCN.put("cme.convert.insufficient", "余额不足。你有 %s %s,但需要 %s"); + zhCN.put("cme.convert.title", "=== 货币转换预览 ==="); + zhCN.put("cme.convert.from", " 来源: %s %s (%s)"); + zhCN.put("cme.convert.to", " 目标: %s %s (%s)"); + zhCN.put("cme.convert.rate", " 汇率: %s : %s = %.2f : %.2f"); + zhCN.put("cme.convert.balance_from", " 当前 %s 余额: %s"); + zhCN.put("cme.convert.balance_to", " 转换后 %s 总余额: %s"); + zhCN.put("cme.convert.prompt", "使用 /cme convert %s %s %s confirm 确认执行"); + zhCN.put("cme.convert.success", "成功将 %s %s 转换为 %s %s"); + zhCN.put("cme.convert.failed", "转换失败,请重试"); + zhCN.put("cme.convert.refunded", "转换失败,已退还扣除的货币"); + zhCN.put("cme.requests.rejected", "已拒绝付款请求 #%s"); + zhCN.put("cme.requests.reject_failed", "拒绝付款请求 #%s 失败"); + zhCN.put("cme.requests.rejected_by", "%s 拒绝了你的付款请求 #%s: %.2f %s"); + zhCN.put("cme.requests.revoked", "已撤回付款请求 #%s"); + zhCN.put("cme.requests.revoke_failed", "撤回付款请求 #%s 失败"); + zhCN.put("cme.requests.revoked_by", "%s 撤回了付款请求 #%s: %.2f %s"); + zhCN.put("cme.requests.accepted_by", "%s 已接受你的付款请求 #%s: %.2f %s"); + zhCN.put("cme.requests.not_yours", "这不是你的请求"); + zhCN.put("cme.requests.not_pending", "该请求已不在待处理状态"); + LANGUAGES.put("zh_cn", zhCN); + } + + private void loadAllPlayerLanguages() { + if (dbManager == null || !dbManager.isConnected()) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.warn("Database not connected, cannot load player languages"); + return; + } + + String sql = "SELECT user_uuid, language FROM cme_player_settings"; + try (Connection conn = dbManager.getConnection(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + byte[] uuidBytes = rs.getBytes("user_uuid"); + if (uuidBytes != null && uuidBytes.length == 16) { + UUID uuid = bytesToUUID(uuidBytes); + String lang = rs.getString("language"); + playerLanguages.put(uuid, lang.toLowerCase()); + } + } + CardinalCooperativeCapitalismMarketEconomy.LOGGER.info("Loaded {} player language settings", playerLanguages.size()); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to load player languages", e); + } + } + + public void setPlayerLanguage(UUID playerUUID, String language) { + playerLanguages.put(playerUUID, language.toLowerCase()); + } + + public String getPlayerLanguage(UUID playerUUID) { + if (playerUUID == null) { + return "en_us"; + } + return playerLanguages.getOrDefault(playerUUID, "en_us"); + } + + public String getMessageRaw(UUID playerUUID, String key) { + String lang = getPlayerLanguage(playerUUID); + Map langMap = LANGUAGES.getOrDefault(lang, LANGUAGES.get("en_us")); + return langMap.getOrDefault(key, key); + } + + public boolean hasLanguage(String lang) { + return LANGUAGES.containsKey(lang.toLowerCase()); + } + + public String getAvailableLanguages() { + return String.join(", ", LANGUAGES.keySet()); + } + + private UUID bytesToUUID(byte[] bytes) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + long mostSigBits = buffer.getLong(); + long leastSigBits = buffer.getLong(); + return new UUID(mostSigBits, leastSigBits); + } +} diff --git a/src/main/java/com/gvsds/tcme/manager/BalanceManager.java b/src/main/java/com/gvsds/tcme/manager/BalanceManager.java new file mode 100644 index 0000000..3799cc0 --- /dev/null +++ b/src/main/java/com/gvsds/tcme/manager/BalanceManager.java @@ -0,0 +1,157 @@ +package com.gvsds.tcme.manager; + +import com.gvsds.tcme.CardinalCooperativeCapitalismMarketEconomy; +import com.gvsds.tcme.config.Config; +import com.gvsds.tcme.database.DatabaseManager; +import com.gvsds.tcme.model.PlayerBalance; + +import java.nio.ByteBuffer; +import java.sql.*; +import java.util.Optional; +import java.util.UUID; + +public class BalanceManager { + + private final DatabaseManager dbManager; + private final Config.Tables tables; + private final boolean isMySQL; + + public BalanceManager(DatabaseManager dbManager, Config.Tables tables, boolean isMySQL) { + this.dbManager = dbManager; + this.tables = tables; + this.isMySQL = isMySQL; + } + + public double getBalance(UUID playerUUID, int currencyId) { + String sql = String.format("SELECT balance FROM %s WHERE user_uuid = ? AND currency_id = ?", tables.getBalances()); + Connection conn = null; + try { + conn = dbManager.getConnection(); + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + setUUIDParameter(stmt, 1, playerUUID); + stmt.setInt(2, currencyId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return rs.getDouble("balance"); + } + } + } + conn.commit(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to get balance for player: {}", playerUUID, e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + return 0.0; + } + + public boolean setBalance(UUID playerUUID, int currencyId, double amount) { + Connection conn = null; + try { + conn = dbManager.getConnection(); + boolean result = setBalance(conn, playerUUID, currencyId, amount); + if (result) { + conn.commit(); + } else { + conn.rollback(); + } + return result; + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to set balance for player: {}", playerUUID, e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + return false; + } + + public boolean addBalance(UUID playerUUID, int currencyId, double amount) { + double currentBalance = getBalance(playerUUID, currencyId); + return setBalance(playerUUID, currencyId, currentBalance + amount); + } + + public double getBalance(Connection conn, UUID playerUUID, int currencyId) throws SQLException { + String sql = String.format("SELECT balance FROM %s WHERE user_uuid = ? AND currency_id = ?", tables.getBalances()); + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + setUUIDParameter(stmt, 1, playerUUID); + stmt.setInt(2, currencyId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return rs.getDouble("balance"); + } + } + } + return 0.0; + } + + public boolean setBalance(Connection conn, UUID playerUUID, int currencyId, double amount) throws SQLException { + String sql; + if (isMySQL) { + sql = String.format( + "INSERT INTO %s (user_uuid, currency_id, balance, created_at, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) " + + "ON DUPLICATE KEY UPDATE balance = VALUES(balance), updated_at = CURRENT_TIMESTAMP", + tables.getBalances() + ); + } else { + sql = String.format( + "INSERT INTO %s (user_uuid, currency_id, balance, created_at, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) " + + "ON CONFLICT(user_uuid, currency_id) DO UPDATE SET balance = excluded.balance, updated_at = CURRENT_TIMESTAMP", + tables.getBalances() + ); + } + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + setUUIDParameter(stmt, 1, playerUUID); + stmt.setInt(2, currencyId); + stmt.setDouble(3, amount); + stmt.executeUpdate(); + return true; + } + } + + public boolean addBalance(Connection conn, UUID playerUUID, int currencyId, double amount) throws SQLException { + double currentBalance = getBalance(conn, playerUUID, currencyId); + return setBalance(conn, playerUUID, currencyId, currentBalance + amount); + } + + public boolean subtractBalance(UUID playerUUID, int currencyId, double amount) { + double currentBalance = getBalance(playerUUID, currencyId); + if (currentBalance < amount) { + return false; + } + return setBalance(playerUUID, currencyId, currentBalance - amount); + } + + public boolean hasSufficientBalance(UUID playerUUID, int currencyId, double amount) { + return getBalance(playerUUID, currencyId) >= amount; + } + + private void setUUIDParameter(PreparedStatement stmt, int parameterIndex, UUID uuid) throws SQLException { + ByteBuffer buffer = ByteBuffer.wrap(new byte[16]); + buffer.putLong(uuid.getMostSignificantBits()); + buffer.putLong(uuid.getLeastSignificantBits()); + stmt.setBytes(parameterIndex, buffer.array()); + } + + public UUID getUUIDFromResultSet(ResultSet rs, String columnName) throws SQLException { + byte[] bytes = rs.getBytes(columnName); + if (bytes == null) { + return null; + } + return bytesToUUID(bytes); + } + + private byte[] uuidToBytes(UUID uuid) { + ByteBuffer buffer = ByteBuffer.wrap(new byte[16]); + buffer.putLong(uuid.getMostSignificantBits()); + buffer.putLong(uuid.getLeastSignificantBits()); + return buffer.array(); + } + + private UUID bytesToUUID(byte[] bytes) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + long mostSigBits = buffer.getLong(); + long leastSigBits = buffer.getLong(); + return new UUID(mostSigBits, leastSigBits); + } +} diff --git a/src/main/java/com/gvsds/tcme/manager/CurrencyManager.java b/src/main/java/com/gvsds/tcme/manager/CurrencyManager.java new file mode 100644 index 0000000..45b88f9 --- /dev/null +++ b/src/main/java/com/gvsds/tcme/manager/CurrencyManager.java @@ -0,0 +1,286 @@ +package com.gvsds.tcme.manager; + +import com.gvsds.tcme.CardinalCooperativeCapitalismMarketEconomy; +import com.gvsds.tcme.config.Config; +import com.gvsds.tcme.database.DatabaseManager; +import com.gvsds.tcme.model.Currency; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class CurrencyManager { + + private final DatabaseManager dbManager; + private final Config.Tables tables; + + public CurrencyManager(DatabaseManager dbManager, Config.Tables tables) { + this.dbManager = dbManager; + this.tables = tables; + } + + public Optional createCurrency(String name, String symbol, boolean tradable) { + String sql = String.format("INSERT INTO %s (name, symbol, tradable) VALUES (?, ?, ?)", tables.getCurrencies()); + Connection conn = null; + try { + conn = dbManager.getConnection(); + try (PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + stmt.setString(1, name); + stmt.setString(2, symbol); + stmt.setInt(3, tradable ? 1 : 0); + stmt.executeUpdate(); + + try (ResultSet rs = stmt.getGeneratedKeys()) { + if (rs.next()) { + Currency currency = new Currency(name, symbol, tradable); + currency.setId(rs.getInt(1)); + CardinalCooperativeCapitalismMarketEconomy.LOGGER.info("Created currency: {} ({})", name, symbol); + conn.commit(); + return Optional.of(currency); + } + } + } + conn.rollback(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to create currency: {}", name, e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + return Optional.empty(); + } + + public boolean deleteCurrency(String name) { + String sql = String.format("DELETE FROM %s WHERE name = ?", tables.getCurrencies()); + Connection conn = null; + try { + conn = dbManager.getConnection(); + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, name); + int rows = stmt.executeUpdate(); + if (rows > 0) { + conn.commit(); + CardinalCooperativeCapitalismMarketEconomy.LOGGER.info("Deleted currency: {}", name); + return true; + } + } + conn.rollback(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to delete currency: {}", name, e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + return false; + } + + public boolean deleteCurrency(int currencyId) { + String sql = String.format("DELETE FROM %s WHERE id = ?", tables.getCurrencies()); + Connection conn = null; + try { + conn = dbManager.getConnection(); + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, currencyId); + int rows = stmt.executeUpdate(); + if (rows > 0) { + conn.commit(); + CardinalCooperativeCapitalismMarketEconomy.LOGGER.info("Deleted currency with ID: {}", currencyId); + return true; + } + } + conn.rollback(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to delete currency: {}", currencyId, e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + return false; + } + + public boolean renameCurrency(String oldName, String newName) { + String sql = String.format("UPDATE %s SET name = ? WHERE name = ?", tables.getCurrencies()); + + Connection conn = null; + try { + conn = dbManager.getConnection(); + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, newName); + stmt.setString(2, oldName); + int rows = stmt.executeUpdate(); + if (rows == 0) { + conn.rollback(); + return false; + } + } + + try (PreparedStatement stmt = conn.prepareStatement("UPDATE cme_exchange_rates SET currency_name = ? WHERE currency_name = ?")) { + stmt.setString(1, newName); + stmt.setString(2, oldName); + stmt.executeUpdate(); + } + + conn.commit(); + CardinalCooperativeCapitalismMarketEconomy.LOGGER.info("Renamed currency: {} -> {}", oldName, newName); + return true; + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to rename currency: {}", oldName, e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + return false; + } + + public boolean updateCurrency(String name, String newSymbol, Boolean newTradable) { + StringBuilder sqlBuilder = new StringBuilder(String.format("UPDATE %s SET ", tables.getCurrencies())); + boolean first = true; + + if (newSymbol != null) { + sqlBuilder.append("symbol = ?"); + first = false; + } + if (newTradable != null) { + if (!first) sqlBuilder.append(", "); + sqlBuilder.append("tradable = ?"); + } + + sqlBuilder.append(" WHERE name = ?"); + String sql = sqlBuilder.toString(); + + Connection conn = null; + try { + conn = dbManager.getConnection(); + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + int paramIndex = 1; + if (newSymbol != null) { + stmt.setString(paramIndex++, newSymbol); + } + if (newTradable != null) { + stmt.setInt(paramIndex++, newTradable ? 1 : 0); + } + stmt.setString(paramIndex, name); + + int rows = stmt.executeUpdate(); + if (rows > 0) { + conn.commit(); + CardinalCooperativeCapitalismMarketEconomy.LOGGER.info("Updated currency: {}", name); + return true; + } + } + conn.rollback(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to update currency: {}", name, e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + return false; + } + + public boolean setTradable(int currencyId, boolean tradable) { + String sql = String.format("UPDATE %s SET tradable = ? WHERE id = ?", tables.getCurrencies()); + Connection conn = null; + try { + conn = dbManager.getConnection(); + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, tradable ? 1 : 0); + stmt.setInt(2, currencyId); + int rows = stmt.executeUpdate(); + if (rows > 0) { + conn.commit(); + CardinalCooperativeCapitalismMarketEconomy.LOGGER.info("Currency {} tradable set to {}", currencyId, tradable); + return true; + } + } + conn.rollback(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to set tradable for currency: {}", currencyId, e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + return false; + } + + public Optional getCurrency(int currencyId) { + String sql = String.format("SELECT * FROM %s WHERE id = ?", tables.getCurrencies()); + Connection conn = null; + try { + conn = dbManager.getConnection(); + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, currencyId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + conn.commit(); + return Optional.of(mapCurrency(rs)); + } + } + } + conn.commit(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to get currency: {}", currencyId, e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + return Optional.empty(); + } + + public Optional getCurrencyByName(String name) { + String sql = String.format("SELECT * FROM %s WHERE name = ?", tables.getCurrencies()); + Connection conn = null; + try { + conn = dbManager.getConnection(); + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, name); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + conn.commit(); + return Optional.of(mapCurrency(rs)); + } + } + } + conn.commit(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to get currency by name: {}", name, e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + return Optional.empty(); + } + + public List getAllCurrencies() { + String sql = String.format("SELECT * FROM %s", tables.getCurrencies()); + List currencies = new ArrayList<>(); + Connection conn = null; + try { + conn = dbManager.getConnection(); + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + currencies.add(mapCurrency(rs)); + } + } + conn.commit(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to get all currencies", e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + return currencies; + } + + private Currency mapCurrency(ResultSet rs) throws SQLException { + Currency currency = new Currency(); + currency.setId(rs.getInt("id")); + currency.setName(rs.getString("name")); + currency.setSymbol(rs.getString("symbol")); + currency.setTradable(rs.getInt("tradable") == 1); + return currency; + } +} diff --git a/src/main/java/com/gvsds/tcme/manager/ExchangeRateManager.java b/src/main/java/com/gvsds/tcme/manager/ExchangeRateManager.java new file mode 100644 index 0000000..c58693d --- /dev/null +++ b/src/main/java/com/gvsds/tcme/manager/ExchangeRateManager.java @@ -0,0 +1,134 @@ +package com.gvsds.tcme.manager; + +import com.gvsds.tcme.CardinalCooperativeCapitalismMarketEconomy; +import com.gvsds.tcme.config.Config; +import com.gvsds.tcme.database.DatabaseManager; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +public class ExchangeRateManager { + + private final DatabaseManager dbManager; + private final Config.Tables tables; + private final String exchangeRatesTable = "cme_exchange_rates"; + private final boolean isMySQL; + + public ExchangeRateManager(DatabaseManager dbManager, Config.Tables tables, boolean isMySQL) { + this.dbManager = dbManager; + this.tables = tables; + this.isMySQL = isMySQL; + initializeTable(); + } + + private void initializeTable() { + String textType = isMySQL ? "VARCHAR(255)" : "TEXT"; + String autoIncrement = isMySQL ? "AUTO_INCREMENT" : "AUTOINCREMENT"; + String engineClause = isMySQL ? "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" : ""; + + String sql = String.format(""" + CREATE TABLE IF NOT EXISTS %s ( + id INTEGER PRIMARY KEY %s, + currency_name %s NOT NULL UNIQUE, + rate REAL NOT NULL DEFAULT 1.0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) %s + """, exchangeRatesTable, autoIncrement, textType, engineClause); + + Connection conn = null; + try { + conn = dbManager.getConnection(); + try (Statement stmt = conn.createStatement()) { + stmt.execute(sql); + } + conn.commit(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to create exchange rates table", e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + } + + public void setRate(int currencyId, double rate) { + String sql; + if (isMySQL) { + sql = String.format( + "INSERT INTO %s (currency_name, rate, created_at, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) " + + "ON DUPLICATE KEY UPDATE rate = VALUES(rate), updated_at = CURRENT_TIMESTAMP", + exchangeRatesTable + ); + } else { + sql = String.format( + "INSERT INTO %s (currency_name, rate, created_at, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) " + + "ON CONFLICT(currency_name) DO UPDATE SET rate = ?, updated_at = CURRENT_TIMESTAMP", + exchangeRatesTable + ); + } + + Connection conn = null; + try { + conn = dbManager.getConnection(); + String nameSql = String.format("SELECT name FROM %s WHERE id = ?", tables.getCurrencies()); + try (java.sql.PreparedStatement nameStmt = conn.prepareStatement(nameSql)) { + nameStmt.setInt(1, currencyId); + try (ResultSet rs = nameStmt.executeQuery()) { + if (!rs.next()) { + conn.rollback(); + return; + } + String currencyName = rs.getString("name"); + + try (java.sql.PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, currencyName); + stmt.setDouble(2, rate); + if (!isMySQL) { + stmt.setDouble(3, rate); + } + stmt.executeUpdate(); + } + } + } + conn.commit(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to set exchange rate for currency: {}", currencyId, e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + } + + public double getRate(int currencyId) { + String sql = String.format("SELECT rate FROM %s WHERE currency_name = (SELECT name FROM %s WHERE id = ?)", exchangeRatesTable, tables.getCurrencies()); + Connection conn = null; + try { + conn = dbManager.getConnection(); + try (java.sql.PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, currencyId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + double rate = rs.getDouble("rate"); + conn.commit(); + return rate; + } + } + } + conn.commit(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to get exchange rate for currency: {}", currencyId, e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + return 1.0; + } + + public double convert(double amount, int fromCurrencyId, int toCurrencyId) { + double fromRate = getRate(fromCurrencyId); + double toRate = getRate(toCurrencyId); + return amount * (fromRate / toRate); + } +} diff --git a/src/main/java/com/gvsds/tcme/manager/PaymentRequestManager.java b/src/main/java/com/gvsds/tcme/manager/PaymentRequestManager.java new file mode 100644 index 0000000..6756f95 --- /dev/null +++ b/src/main/java/com/gvsds/tcme/manager/PaymentRequestManager.java @@ -0,0 +1,254 @@ +package com.gvsds.tcme.manager; + +import com.gvsds.tcme.CardinalCooperativeCapitalismMarketEconomy; +import com.gvsds.tcme.config.Config; +import com.gvsds.tcme.database.DatabaseManager; +import com.gvsds.tcme.model.PaymentRequest; + +import java.nio.ByteBuffer; +import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public class PaymentRequestManager { + + private final DatabaseManager dbManager; + private final Config.Tables tables; + private final boolean isMySQL; + + public PaymentRequestManager(DatabaseManager dbManager, Config.Tables tables, BalanceManager balanceManager, TransactionManager transactionManager, boolean isMySQL) { + this.dbManager = dbManager; + this.tables = tables; + this.isMySQL = isMySQL; + } + + public Optional createRequest(UUID fromUUID, UUID toUUID, int currencyId, double amount) { + if (amount <= 0) { + return Optional.empty(); + } + + String sql = String.format( + "INSERT INTO %s (from_uuid, to_uuid, currency_id, amount, status, created_at) VALUES (?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)", + tables.getPaymentRequests() + ); + + Connection conn = null; + try { + conn = dbManager.getConnection(); + try (PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + setUUID(stmt, 1, fromUUID); + setUUID(stmt, 2, toUUID); + stmt.setInt(3, currencyId); + stmt.setDouble(4, amount); + stmt.executeUpdate(); + + try (ResultSet rs = stmt.getGeneratedKeys()) { + if (rs.next()) { + PaymentRequest request = new PaymentRequest(fromUUID, toUUID, currencyId, amount); + request.setId(rs.getInt(1)); + conn.commit(); + return Optional.of(request); + } + } + } + conn.rollback(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to create payment request", e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + return Optional.empty(); + } + + public boolean acceptRequest(int requestId) { + String selectSql = String.format("SELECT * FROM %s WHERE id = ? AND status = 'pending'", tables.getPaymentRequests()); + String updateSql = String.format("UPDATE %s SET status = 'completed' WHERE id = ?", tables.getPaymentRequests()); + String insertTransactionSql = String.format( + "INSERT INTO %s (from_uuid, to_uuid, currency_id, amount, type, description, created_at) VALUES (?, ?, ?, ?, 'apay_accept', ?, CURRENT_TIMESTAMP)", + tables.getTransactions() + ); + + Connection conn = null; + try { + conn = dbManager.getConnection(); + + UUID fromUUID, toUUID; + int currencyId; + double amount; + + try (PreparedStatement selectStmt = conn.prepareStatement(selectSql)) { + selectStmt.setInt(1, requestId); + try (ResultSet rs = selectStmt.executeQuery()) { + if (!rs.next()) { + conn.rollback(); + return false; + } + + fromUUID = getUUIDFromBytes(rs.getBytes("from_uuid")); + toUUID = getUUIDFromBytes(rs.getBytes("to_uuid")); + currencyId = rs.getInt("currency_id"); + amount = rs.getDouble("amount"); + } + } + + BalanceManager bm = CardinalCooperativeCapitalismMarketEconomy.getBalanceManager(); + double toBalance = bm.getBalance(conn, toUUID, currencyId); + if (toBalance < amount) { + conn.rollback(); + return false; + } + + bm.addBalance(conn, toUUID, currencyId, -amount); + bm.addBalance(conn, fromUUID, currencyId, amount); + + try (PreparedStatement stmt = conn.prepareStatement(insertTransactionSql)) { + setUUID(stmt, 1, toUUID); + setUUID(stmt, 2, fromUUID); + stmt.setInt(3, currencyId); + stmt.setDouble(4, amount); + stmt.setString(5, "Accepted payment request #" + requestId); + stmt.executeUpdate(); + } + + try (PreparedStatement updateStmt = conn.prepareStatement(updateSql)) { + updateStmt.setInt(1, requestId); + updateStmt.executeUpdate(); + } + + conn.commit(); + CardinalCooperativeCapitalismMarketEconomy.LOGGER.info("Payment request #{} accepted", requestId); + return true; + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to accept payment request", e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + return false; + } + + public boolean cancelRequest(int requestId) { + String sql = String.format("UPDATE %s SET status = 'cancelled' WHERE id = ? AND status = 'pending'", tables.getPaymentRequests()); + Connection conn = null; + try { + conn = dbManager.getConnection(); + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, requestId); + int rows = stmt.executeUpdate(); + if (rows > 0) { + conn.commit(); + return true; + } + } + conn.rollback(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to cancel payment request", e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + return false; + } + + public boolean rejectRequest(int requestId) { + String sql = String.format("UPDATE %s SET status = 'rejected' WHERE id = ? AND status = 'pending'", tables.getPaymentRequests()); + Connection conn = null; + try { + conn = dbManager.getConnection(); + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, requestId); + int rows = stmt.executeUpdate(); + if (rows > 0) { + conn.commit(); + return true; + } + } + conn.rollback(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to reject payment request", e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + return false; + } + + public Optional getRequestById(int requestId) { + String sql = String.format("SELECT * FROM %s WHERE id = ?", tables.getPaymentRequests()); + Connection conn = null; + try { + conn = dbManager.getConnection(); + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, requestId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(mapPaymentRequest(rs)); + } + } + } + conn.commit(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to get payment request by id", e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + return Optional.empty(); + } + + public List getPendingRequests(UUID playerUUID) { + String sql = String.format("SELECT * FROM %s WHERE to_uuid = ? AND status = 'pending'", tables.getPaymentRequests()); + List requests = new ArrayList<>(); + Connection conn = null; + try { + conn = dbManager.getConnection(); + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + setUUID(stmt, 1, playerUUID); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + requests.add(mapPaymentRequest(rs)); + } + } + } + conn.commit(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to get pending requests", e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + return requests; + } + + private PaymentRequest mapPaymentRequest(ResultSet rs) throws SQLException { + PaymentRequest request = new PaymentRequest(); + request.setId(rs.getInt("id")); + request.setFromUUID(getUUIDFromBytes(rs.getBytes("from_uuid"))); + request.setToUUID(getUUIDFromBytes(rs.getBytes("to_uuid"))); + request.setCurrencyId(rs.getInt("currency_id")); + request.setAmount(rs.getDouble("amount")); + request.setStatus(rs.getString("status")); + request.setCreatedAt(rs.getTimestamp("created_at")); + return request; + } + + private void setUUID(PreparedStatement stmt, int index, UUID uuid) throws SQLException { + ByteBuffer buffer = ByteBuffer.wrap(new byte[16]); + buffer.putLong(uuid.getMostSignificantBits()); + buffer.putLong(uuid.getLeastSignificantBits()); + stmt.setBytes(index, buffer.array()); + } + + private UUID getUUIDFromBytes(byte[] bytes) { + if (bytes == null || bytes.length != 16) { + return null; + } + ByteBuffer buffer = ByteBuffer.wrap(bytes); + long mostSigBits = buffer.getLong(); + long leastSigBits = buffer.getLong(); + return new UUID(mostSigBits, leastSigBits); + } +} diff --git a/src/main/java/com/gvsds/tcme/manager/PlayerSettingsManager.java b/src/main/java/com/gvsds/tcme/manager/PlayerSettingsManager.java new file mode 100644 index 0000000..d7a411d --- /dev/null +++ b/src/main/java/com/gvsds/tcme/manager/PlayerSettingsManager.java @@ -0,0 +1,125 @@ +package com.gvsds.tcme.manager; + +import com.gvsds.tcme.CardinalCooperativeCapitalismMarketEconomy; +import com.gvsds.tcme.config.Config; +import com.gvsds.tcme.database.DatabaseManager; + +import java.nio.ByteBuffer; +import java.sql.*; +import java.util.Optional; +import java.util.UUID; + +public class PlayerSettingsManager { + + private final DatabaseManager dbManager; + private final Config.Tables tables; + private final boolean isMySQL; + private final String settingsTable; + + public PlayerSettingsManager(DatabaseManager dbManager, Config.Tables tables, boolean isMySQL) { + this.dbManager = dbManager; + this.tables = tables; + this.isMySQL = isMySQL; + this.settingsTable = "cme_player_settings"; + initializeTable(); + } + + private void initializeTable() { + String uuidType = isMySQL ? "BINARY(16)" : "BLOB"; + String textType = isMySQL ? "VARCHAR(50)" : "TEXT"; + String autoIncrement = isMySQL ? "AUTO_INCREMENT" : "AUTOINCREMENT"; + String engineClause = isMySQL ? "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" : ""; + + String sql = String.format(""" + CREATE TABLE IF NOT EXISTS %s ( + id INTEGER PRIMARY KEY %s, + user_uuid %s NOT NULL UNIQUE, + language %s DEFAULT 'en_us', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) %s + """, settingsTable, autoIncrement, uuidType, textType, engineClause); + + Connection conn = null; + try { + conn = dbManager.getConnection(); + try (Statement stmt = conn.createStatement()) { + stmt.execute(sql); + } + conn.commit(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to create player settings table", e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + } + + public void setLanguage(UUID playerUUID, String language) { + String sql; + if (isMySQL) { + sql = String.format( + "INSERT INTO %s (user_uuid, language, created_at, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) " + + "ON DUPLICATE KEY UPDATE language = VALUES(language), updated_at = CURRENT_TIMESTAMP", + settingsTable + ); + } else { + sql = String.format( + "INSERT INTO %s (user_uuid, language, created_at, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) " + + "ON CONFLICT(user_uuid) DO UPDATE SET language = ?, updated_at = CURRENT_TIMESTAMP", + settingsTable + ); + } + + Connection conn = null; + try { + conn = dbManager.getConnection(); + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + setUUID(stmt, 1, playerUUID); + stmt.setString(2, language); + if (!isMySQL) { + stmt.setString(3, language); + } + stmt.executeUpdate(); + } + conn.commit(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to set language for player: {}", playerUUID, e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + } + + public Optional getLanguage(UUID playerUUID) { + String sql = String.format("SELECT language FROM %s WHERE user_uuid = ?", settingsTable); + Connection conn = null; + try { + conn = dbManager.getConnection(); + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + setUUID(stmt, 1, playerUUID); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + String lang = rs.getString("language"); + conn.commit(); + return Optional.of(lang); + } + } + } + conn.commit(); + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to get language for player: {}", playerUUID, e); + if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } + } finally { + if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } + } + return Optional.empty(); + } + + private void setUUID(PreparedStatement stmt, int index, UUID uuid) throws SQLException { + ByteBuffer buffer = ByteBuffer.wrap(new byte[16]); + buffer.putLong(uuid.getMostSignificantBits()); + buffer.putLong(uuid.getLeastSignificantBits()); + stmt.setBytes(index, buffer.array()); + } +} diff --git a/src/main/java/com/gvsds/tcme/manager/TransactionManager.java b/src/main/java/com/gvsds/tcme/manager/TransactionManager.java new file mode 100644 index 0000000..9aaf56e --- /dev/null +++ b/src/main/java/com/gvsds/tcme/manager/TransactionManager.java @@ -0,0 +1,141 @@ +package com.gvsds.tcme.manager; + +import com.gvsds.tcme.CardinalCooperativeCapitalismMarketEconomy; +import com.gvsds.tcme.config.Config; +import com.gvsds.tcme.database.DatabaseManager; + +import java.nio.ByteBuffer; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.UUID; + +public class TransactionManager { + + private final DatabaseManager dbManager; + private final Config.Tables tables; + private final boolean isMySQL; + + public TransactionManager(DatabaseManager dbManager, Config.Tables tables, BalanceManager balanceManager, boolean isMySQL) { + this.dbManager = dbManager; + this.tables = tables; + this.isMySQL = isMySQL; + } + + public boolean transfer(UUID fromUUID, UUID toUUID, int currencyId, double amount, String type, String description) { + if (amount <= 0) { + return false; + } + + String sql = String.format( + "INSERT INTO %s (from_uuid, to_uuid, currency_id, amount, type, description, created_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)", + tables.getTransactions() + ); + + String checkBalanceSql = String.format("SELECT balance FROM %s WHERE user_uuid = ? AND currency_id = ?", tables.getBalances()); + String updateBalanceSql; + if (isMySQL) { + updateBalanceSql = String.format( + "INSERT INTO %s (user_uuid, currency_id, balance, created_at, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) " + + "ON DUPLICATE KEY UPDATE balance = VALUES(balance), updated_at = CURRENT_TIMESTAMP", + tables.getBalances() + ); + } else { + updateBalanceSql = String.format( + "INSERT INTO %s (user_uuid, currency_id, balance, created_at, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) " + + "ON CONFLICT(user_uuid, currency_id) DO UPDATE SET balance = excluded.balance, updated_at = CURRENT_TIMESTAMP", + tables.getBalances() + ); + } + + Connection conn = null; + try { + conn = dbManager.getConnection(); + + double fromBalance; + try (PreparedStatement stmt = conn.prepareStatement(checkBalanceSql)) { + setUUID(stmt, 1, fromUUID); + stmt.setInt(2, currencyId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + fromBalance = rs.getDouble("balance"); + } else { + fromBalance = 0.0; + } + } + } + + if (fromBalance < amount) { + conn.rollback(); + return false; + } + + try (PreparedStatement stmt = conn.prepareStatement(updateBalanceSql)) { + setUUID(stmt, 1, fromUUID); + stmt.setInt(2, currencyId); + stmt.setDouble(3, fromBalance - amount); + stmt.executeUpdate(); + } + + double toBalance; + try (PreparedStatement stmt = conn.prepareStatement(checkBalanceSql)) { + setUUID(stmt, 1, toUUID); + stmt.setInt(2, currencyId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + toBalance = rs.getDouble("balance"); + } else { + toBalance = 0.0; + } + } + } + + try (PreparedStatement stmt = conn.prepareStatement(updateBalanceSql)) { + setUUID(stmt, 1, toUUID); + stmt.setInt(2, currencyId); + stmt.setDouble(3, toBalance + amount); + stmt.executeUpdate(); + } + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + setUUID(stmt, 1, fromUUID); + setUUID(stmt, 2, toUUID); + stmt.setInt(3, currencyId); + stmt.setDouble(4, amount); + stmt.setString(5, type); + stmt.setString(6, description); + stmt.executeUpdate(); + } + + conn.commit(); + CardinalCooperativeCapitalismMarketEconomy.LOGGER.info("Transfer: {} -> {} amount: {} currency: {}", fromUUID, toUUID, amount, currencyId); + return true; + } catch (SQLException e) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to process transfer", e); + if (conn != null) { + try { + conn.rollback(); + } catch (SQLException re) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to rollback transfer", re); + } + } + } finally { + if (conn != null) { + try { + conn.close(); + } catch (SQLException ce) { + CardinalCooperativeCapitalismMarketEconomy.LOGGER.error("Failed to close connection", ce); + } + } + } + return false; + } + + private void setUUID(PreparedStatement stmt, int index, UUID uuid) throws SQLException { + ByteBuffer buffer = ByteBuffer.wrap(new byte[16]); + buffer.putLong(uuid.getMostSignificantBits()); + buffer.putLong(uuid.getLeastSignificantBits()); + stmt.setBytes(index, buffer.array()); + } +} diff --git a/src/main/java/com/gvsds/tcme/model/Currency.java b/src/main/java/com/gvsds/tcme/model/Currency.java new file mode 100644 index 0000000..abdedb2 --- /dev/null +++ b/src/main/java/com/gvsds/tcme/model/Currency.java @@ -0,0 +1,30 @@ +package com.gvsds.tcme.model; + +import java.util.UUID; + +public class Currency { + private int id; + private String name; + private String symbol; + private boolean tradable; + + public Currency() {} + + public Currency(String name, String symbol, boolean tradable) { + this.name = name; + this.symbol = symbol; + this.tradable = tradable; + } + + public int getId() { return id; } + public void setId(int id) { this.id = id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getSymbol() { return symbol; } + public void setSymbol(String symbol) { this.symbol = symbol; } + + public boolean isTradable() { return tradable; } + public void setTradable(boolean tradable) { this.tradable = tradable; } +} diff --git a/src/main/java/com/gvsds/tcme/model/PaymentRequest.java b/src/main/java/com/gvsds/tcme/model/PaymentRequest.java new file mode 100644 index 0000000..c655d93 --- /dev/null +++ b/src/main/java/com/gvsds/tcme/model/PaymentRequest.java @@ -0,0 +1,45 @@ +package com.gvsds.tcme.model; + +import java.sql.Timestamp; +import java.util.UUID; + +public class PaymentRequest { + private int id; + private UUID fromUUID; + private UUID toUUID; + private int currencyId; + private double amount; + private String status; + private Timestamp createdAt; + + public PaymentRequest() {} + + public PaymentRequest(UUID fromUUID, UUID toUUID, int currencyId, double amount) { + this.fromUUID = fromUUID; + this.toUUID = toUUID; + this.currencyId = currencyId; + this.amount = amount; + this.status = "pending"; + } + + public int getId() { return id; } + public void setId(int id) { this.id = id; } + + public UUID getFromUUID() { return fromUUID; } + public void setFromUUID(UUID fromUUID) { this.fromUUID = fromUUID; } + + public UUID getToUUID() { return toUUID; } + public void setToUUID(UUID toUUID) { this.toUUID = toUUID; } + + public int getCurrencyId() { return currencyId; } + public void setCurrencyId(int currencyId) { this.currencyId = currencyId; } + + public double getAmount() { return amount; } + public void setAmount(double amount) { this.amount = amount; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public Timestamp getCreatedAt() { return createdAt; } + public void setCreatedAt(Timestamp createdAt) { this.createdAt = createdAt; } +} diff --git a/src/main/java/com/gvsds/tcme/model/PlayerBalance.java b/src/main/java/com/gvsds/tcme/model/PlayerBalance.java new file mode 100644 index 0000000..95e2ddb --- /dev/null +++ b/src/main/java/com/gvsds/tcme/model/PlayerBalance.java @@ -0,0 +1,30 @@ +package com.gvsds.tcme.model; + +import java.util.UUID; + +public class PlayerBalance { + private int id; + private UUID userUUID; + private int currencyId; + private double balance; + + public PlayerBalance() {} + + public PlayerBalance(UUID userUUID, int currencyId, double balance) { + this.userUUID = userUUID; + this.currencyId = currencyId; + this.balance = balance; + } + + public int getId() { return id; } + public void setId(int id) { this.id = id; } + + public UUID getUserUUID() { return userUUID; } + public void setUserUUID(UUID userUUID) { this.userUUID = userUUID; } + + public int getCurrencyId() { return currencyId; } + public void setCurrencyId(int currencyId) { this.currencyId = currencyId; } + + public double getBalance() { return balance; } + public void setBalance(double balance) { this.balance = balance; } +} diff --git a/src/main/java/com/gvsds/tcme/model/Transaction.java b/src/main/java/com/gvsds/tcme/model/Transaction.java new file mode 100644 index 0000000..8da8050 --- /dev/null +++ b/src/main/java/com/gvsds/tcme/model/Transaction.java @@ -0,0 +1,50 @@ +package com.gvsds.tcme.model; + +import java.sql.Timestamp; +import java.util.UUID; + +public class Transaction { + private int id; + private UUID fromUUID; + private UUID toUUID; + private int currencyId; + private double amount; + private String type; + private String description; + private Timestamp createdAt; + + public Transaction() {} + + public Transaction(UUID fromUUID, UUID toUUID, int currencyId, double amount, String type, String description) { + this.fromUUID = fromUUID; + this.toUUID = toUUID; + this.currencyId = currencyId; + this.amount = amount; + this.type = type; + this.description = description; + } + + public int getId() { return id; } + public void setId(int id) { this.id = id; } + + public UUID getFromUUID() { return fromUUID; } + public void setFromUUID(UUID fromUUID) { this.fromUUID = fromUUID; } + + public UUID getToUUID() { return toUUID; } + public void setToUUID(UUID toUUID) { this.toUUID = toUUID; } + + public int getCurrencyId() { return currencyId; } + public void setCurrencyId(int currencyId) { this.currencyId = currencyId; } + + public double getAmount() { return amount; } + public void setAmount(double amount) { this.amount = amount; } + + public String getType() { return type; } + public void setType(String type) { this.type = type; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public Timestamp getCreatedAt() { return createdAt; } + public void setCreatedAt(Timestamp createdAt) { this.createdAt = createdAt; } +} diff --git a/src/main/java/com/gvsds/tcme/util/TextUtil.java b/src/main/java/com/gvsds/tcme/util/TextUtil.java new file mode 100644 index 0000000..e36558b --- /dev/null +++ b/src/main/java/com/gvsds/tcme/util/TextUtil.java @@ -0,0 +1,80 @@ +package com.gvsds.tcme.util; + +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; + +import java.util.*; + +public class TextUtil { + + private static final Map CODE_MAP = new HashMap<>(); + + static { + CODE_MAP.put('0', Formatting.BLACK); + CODE_MAP.put('1', Formatting.DARK_BLUE); + CODE_MAP.put('2', Formatting.DARK_GREEN); + CODE_MAP.put('3', Formatting.DARK_AQUA); + CODE_MAP.put('4', Formatting.DARK_RED); + CODE_MAP.put('5', Formatting.DARK_PURPLE); + CODE_MAP.put('6', Formatting.GOLD); + CODE_MAP.put('7', Formatting.GRAY); + CODE_MAP.put('8', Formatting.DARK_GRAY); + CODE_MAP.put('9', Formatting.BLUE); + CODE_MAP.put('a', Formatting.GREEN); + CODE_MAP.put('b', Formatting.AQUA); + CODE_MAP.put('c', Formatting.RED); + CODE_MAP.put('d', Formatting.LIGHT_PURPLE); + CODE_MAP.put('e', Formatting.YELLOW); + CODE_MAP.put('f', Formatting.WHITE); + CODE_MAP.put('k', Formatting.OBFUSCATED); + CODE_MAP.put('l', Formatting.BOLD); + CODE_MAP.put('m', Formatting.STRIKETHROUGH); + CODE_MAP.put('n', Formatting.UNDERLINE); + CODE_MAP.put('o', Formatting.ITALIC); + } + + public static Text parseFormatting(String text) { + if (text == null || text.isEmpty()) return Text.empty(); + + MutableText result = null; + StringBuilder currentText = new StringBuilder(); + Set currentFormats = new LinkedHashSet<>(); + + for (int i = 0; i < text.length(); i++) { + if (text.charAt(i) == '\u00A7' && i + 1 < text.length()) { + if (currentText.length() > 0) { + MutableText part = Text.literal(currentText.toString()); + if (!currentFormats.isEmpty()) { + part.formatted(currentFormats.toArray(new Formatting[0])); + } + result = result == null ? part : result.append(part); + currentText = new StringBuilder(); + } + + char code = Character.toLowerCase(text.charAt(i + 1)); + if (code == 'r') { + currentFormats.clear(); + } else { + Formatting fmt = CODE_MAP.get(code); + if (fmt != null) { + currentFormats.add(fmt); + } + } + i++; + } else { + currentText.append(text.charAt(i)); + } + } + + if (currentText.length() > 0) { + MutableText part = Text.literal(currentText.toString()); + if (!currentFormats.isEmpty()) { + part.formatted(currentFormats.toArray(new Formatting[0])); + } + result = result == null ? part : result.append(part); + } + + return result != null ? result : Text.empty(); + } +} diff --git a/src/main/resources/assets/cardinal-cooperative-capitalism-market-economy/default_config.json b/src/main/resources/assets/cardinal-cooperative-capitalism-market-economy/default_config.json new file mode 100644 index 0000000..9a7eea6 --- /dev/null +++ b/src/main/resources/assets/cardinal-cooperative-capitalism-market-economy/default_config.json @@ -0,0 +1,12 @@ +{ + "database": { + "type": "sqlite", + "sqlitePath": "./config/cardinal-economy/database", + "mysqlHost": "localhost", + "mysqlPort": 3306, + "mysqlDatabase": "cardinal_economy", + "mysqlUsername": "root", + "mysqlPassword": "", + "useSSL": false + } +} diff --git a/src/main/resources/assets/cardinal-cooperative-capitalism-market-economy/icon.png b/src/main/resources/assets/cardinal-cooperative-capitalism-market-economy/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..699f52d93680de57412e43a50a7e314a26604457 GIT binary patch literal 8082 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.19.2", + "minecraft": "~1.21.10", + "java": ">=21", + "fabric-api": "*" + } +} \ No newline at end of file