commit 6c8c4065df998bf9e54a471217835e695f05dd80 Author: Dmitrii `yawaflua` Shimanskii Date: Thu Apr 2 03:14:26 2026 +0300 Add initial implementation of SPMega mod with banking UI and API integration Signed-off-by: Dmitrii Took 2 hours 53 minutes diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..86597f4 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,102 @@ +name: Release CI + +on: + release: + types: + - published + push: + tags: + - v* + +permissions: + contents: write + +jobs: + build-and-upload-mod: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve tag + id: tag + shell: bash + run: | + if [ -n "${{ github.event.release.tag_name }}" ]; then + echo "value=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT" + else + echo "value=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + fi + + - name: Check release version (>= 1.21.11) + id: version_check + shell: bash + run: | + TAG="${{ steps.tag.outputs.value }}" + VERSION="${TAG#v}" + MIN_VERSION="1.21.11" + + if [ "$(printf '%s\n%s\n' "$MIN_VERSION" "$VERSION" | sort -V | head -n1)" = "$MIN_VERSION" ]; then + echo "should_build=true" >> "$GITHUB_OUTPUT" + else + echo "should_build=false" >> "$GITHUB_OUTPUT" + fi + + - name: Set up JDK 21 + if: steps.version_check.outputs.should_build == 'true' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: gradle + + - name: Grant execute permission for Gradle + if: steps.version_check.outputs.should_build == 'true' + run: chmod +x ./gradlew + + - name: Build mod + if: steps.version_check.outputs.should_build == 'true' + run: ./gradlew clean build + + - name: Upload jars to GitHub release assets + if: steps.version_check.outputs.should_build == 'true' && github.server_url == 'https://github.com' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.value }} + files: | + build/libs/*.jar + + - name: Upload jars to Gitea release assets + if: steps.version_check.outputs.should_build == 'true' && github.server_url != 'https://github.com' + shell: bash + env: + API: ${{ github.api_url }} + REPO: ${{ github.repository }} + TAG: ${{ steps.tag.outputs.value }} + TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -euo pipefail + + release_json="$(curl -fsS -H "Authorization: token $TOKEN" "$API/repos/$REPO/releases/tags/$TAG" || true)" + + if [ -z "$release_json" ]; then + release_json="$(curl -fsS -X POST \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}" \ + "$API/repos/$REPO/releases")" + fi + + release_id="$(python -c 'import sys,json; print(json.load(sys.stdin)["id"])' <<< "$release_json")" + + for file in build/libs/*.jar; do + [ -f "$file" ] || continue + name="$(basename "$file")" + curl -fsS -X POST \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"$file" \ + "$API/repos/$REPO/releases/$release_id/assets?name=$name" + done + diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..651a2f2 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,28 @@ +name: Java CI + +on: + push: + branches: + - "**" + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: gradle + + - name: Grant execute permission for Gradle wrapper + run: chmod +x ./gradlew + + - name: Build with Gradle + run: ./gradlew clean build --no-daemon \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e4b940 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# MacOS DS_Store files +.DS_Store + +# Gradle cache folder +.gradle + +# Gradle build folder +build + +# IntelliJ +out/ +.idea +*.iml +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +# Common working directory +run diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..979fc2b --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,23 @@ +LICENSE Agreement + +Copyright (c) 2024-2026 Dmitrii 'yawaflua' Shimanskii. All rights reserved. + +This project is licensed under the Creative Commons Attribution 4.0 International License (CC BY 4.0) +Subject to the additional restrictive conditions below: + +1. PROHIBITION OF USE: + Any use, reproduction, distribution, or modification of the materials in this repository + without the express written permission of the author (Dmitrii 'yawaflua' Shimanskii) is PROHIBITED. + +2. MANDATORY ATTRIBUTION: + In all allowed cases of use, the authorship of Dmitrii 'yawaflua' Shimanskii must be + clearly and explicitly credited. + +3. SPECIAL PROVISIONS FOR EXTERNAL CODE: + Parts of the code affiliated with or derived from the repository + https://github.com/Zadudoder/SPmHelper/commit/8b8139efdef4d2466a65a6ae1218521564116db1 + are distributed under the terms of the MIT License (MIT) as applied to forks. + The original MIT license conditions for those specific parts must be preserved. + +For permissions and inquiries, contact the author. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..69edc85 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# SPMega + +Клиентский мод Fabric с банковым UI, интеграцией с API `spworlds.ru` и локальным SQLite-кэшем. + +## Что реализовано + +- Открытие меню по `P` и через кнопку `SPMega` в меню `Esc` +- Экран `Карты`: + - список карт из локальной БД + - удаление карты + - обновление данных карты через API + - добавление карты из конфига (`token.cardId` + `token.cardToken`) +- Экран `Оплата`: + - перевод по номеру карты + - при вводе ника: загрузка карт игрока из API и выбор карты получателя + - выполнение транзакции через API +- Локальная БД `config/spmega.db`: + - `cards` (id, token, number, name, balance, owner_uuid) + - `transfer_history` (локальная история переводов) +- Автообновление балансов при входе на сервер + +## Ключевые файлы + +- `src/main/java/git/yawaflua/tech/spmega/api/SPWorldsApiClient.java` +- `src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankUiService.java` +- `src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankDatabase.java` +- `src/client/java/git/yawaflua/tech/spmega/client/ui/PaymentScreen.java` +- `src/client/java/git/yawaflua/tech/spmega/client/ui/CardScreen.java` + +## Конфиг + +Файл: `config/spmega.properties` + +- `api.domain=https://spworlds.ru` +- `token.cardId=` +- `token.cardToken=<токен карты>` + +При добавлении новой карты через UI выполняется проверка владельца через `GET /api/public/accounts/me`. +Если UUID не совпадает с UUID игрока, показывается сообщение: +`Вы не владелец карты. Часть функций может быть ограничена.` + +## Проверка сборки (PowerShell) + +```powershell +$javaHome = 'C:/Users/yawaflua/AppData/Roaming/PrismLauncher/java/java-runtime-delta/' +$env:JAVA_HOME = $javaHome +$env:Path = "$($env:JAVA_HOME)bin;$env:Path" +Set-Location 'C:\Users\yawaflua\IdeaProjects\untitled' +.\gradlew.bat classes +``` diff --git a/assets/spmega/icon.png b/assets/spmega/icon.png new file mode 100644 index 0000000..38e69f4 Binary files /dev/null and b/assets/spmega/icon.png differ diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..826f675 --- /dev/null +++ b/build.gradle @@ -0,0 +1,115 @@ +plugins { + id 'fabric-loom' version '1.14.10' + id 'maven-publish' +} + +version = project.mod_version +group = project.maven_group + +base { + archivesName = project.archives_base_name +} + +loom { + splitEnvironmentSourceSets() + + mods { + "spmega" { + sourceSet sourceSets.main + sourceSet sourceSets.client + } + } +} + +fabricApi { + configureDataGeneration { + client = true + } +} + +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. + + maven { url "https://maven.shedaniel.me/" } +} + +dependencies { + // To change the versions see the gradle.properties file + 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_version}" + + modImplementation("me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}") { + exclude(group: "net.fabricmc.fabric-api") + } + + include(implementation("org.xerial:sqlite-jdbc:3.46.1.3")) +} + +processResources { + inputs.property "version", project.version + inputs.property "minecraft_version", project.minecraft_version + inputs.property "loader_version", project.loader_version + inputs.property "cloth_config_version", project.cloth_config_version + filteringCharset "UTF-8" + + filesMatching("fabric.mod.json") { + expand "version": project.version, + "minecraft_version": project.minecraft_version, + "loader_version": project.loader_version, + "cloth_config_version": project.cloth_config_version + } +} + +def targetJavaVersion = 21 +tasks.withType(JavaCompile).configureEach { + // ensure that the encoding is set to UTF-8, no matter what the system default is + // this fixes some edge cases with special characters not displaying correctly + // see http://yodaconditions.net/blog/fix-for-java-file-encoding-problems-with-gradle.html + // If Javadoc is generated, this must be specified in that task too. + it.options.encoding = "UTF-8" + if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible()) { + it.options.release.set(targetJavaVersion) + } +} + +java { + def javaVersion = JavaVersion.toVersion(targetJavaVersion) + if (JavaVersion.current() < javaVersion) { + toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion) + } + // 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() +} + +jar { + from("LICENSE") { + rename { "${it}_${project.archives_base_name}" } + } +} + +// configure the maven publication +publishing { + publications { + create("mavenJava", MavenPublication) { + artifactId = project.archives_base_name + 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..7813374 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,15 @@ +# Done to increase the memory available to gradle. +org.gradle.jvmargs=-Xmx1G +# Fabric Properties +# check these on https://modmuss50.me/fabric.html +minecraft_version=1.21.11 +yarn_mappings=1.21.11+build.4 +loader_version=0.18.1 +# Mod Properties +mod_version=0.1-pre-alpha +maven_group=git.yawaflua.tech +archives_base_name=SPMega +# Dependencies +# check this on https://modmuss50.me/fabric.html +fabric_version=0.141.1+1.21.11 +cloth_config_version=21.11.153 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f8e1ee3 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..23449a2 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.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..adff685 --- /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/HEAD/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..f91a4fe --- /dev/null +++ b/settings.gradle @@ -0,0 +1,9 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + gradlePluginPortal() + } +} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/SPMegaClient.java b/src/client/java/git/yawaflua/tech/spmega/client/SPMegaClient.java new file mode 100644 index 0000000..515ceaa --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/SPMegaClient.java @@ -0,0 +1,89 @@ +package git.yawaflua.tech.spmega.client; + +import git.yawaflua.tech.spmega.SPMega; +import git.yawaflua.tech.spmega.client.ui.UiOpeners; +import git.yawaflua.tech.spmega.client.ui.service.BankUiService; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; +import net.fabricmc.fabric.api.event.player.UseBlockCallback; +import net.minecraft.block.entity.SignBlockEntity; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.option.KeyBinding; +import net.minecraft.client.util.InputUtil; +import net.minecraft.text.Text; +import net.minecraft.util.ActionResult; +import org.lwjgl.glfw.GLFW; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class SPMegaClient implements ClientModInitializer { + private static final Pattern ISOLATED_FIVE_DIGITS = Pattern.compile("(? { + while (openBankMenuKeyBinding.wasPressed()) { + UiOpeners.openMainMenu(client); + } + }); + + ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> { + if (client.player != null) { + BankUiService.instance().refreshOnServerJoin(client.player.getUuidAsString()); + } + }); + + UseBlockCallback.EVENT.register((player, world, hand, hitResult) -> { + if (!world.isClient()) { + return ActionResult.PASS; + } + if (!player.isSneaking()) { + return ActionResult.PASS; + } + if (SPMega.getConfig() == null || !SPMega.getConfig().signQuickPayEnabled()) { + return ActionResult.PASS; + } + + if (!(world.getBlockEntity(hitResult.getBlockPos()) instanceof SignBlockEntity signBlockEntity)) { + return ActionResult.PASS; + } + + String cardNumber = extractCardNumber(signBlockEntity); + if (cardNumber == null) { + return ActionResult.PASS; + } + + UiOpeners.openPaymentMenu(MinecraftClient.getInstance(), cardNumber); + return ActionResult.SUCCESS; + }); + } +} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/SPMegaDataGenerator.java b/src/client/java/git/yawaflua/tech/spmega/client/SPMegaDataGenerator.java new file mode 100644 index 0000000..fe649a3 --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/SPMegaDataGenerator.java @@ -0,0 +1,12 @@ +package git.yawaflua.tech.spmega.client; + +import net.fabricmc.fabric.api.datagen.v1.DataGeneratorEntrypoint; +import net.fabricmc.fabric.api.datagen.v1.FabricDataGenerator; + +public class SPMegaDataGenerator implements DataGeneratorEntrypoint { + + @Override + public void onInitializeDataGenerator(FabricDataGenerator fabricDataGenerator) { + FabricDataGenerator.Pack pack = fabricDataGenerator.createPack(); + } +} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/AddCardScreen.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/AddCardScreen.java new file mode 100644 index 0000000..012f1f6 --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/AddCardScreen.java @@ -0,0 +1,121 @@ +package git.yawaflua.tech.spmega.client.ui; + +import git.yawaflua.tech.spmega.client.ui.service.BankUiService; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.TextFieldWidget; +import net.minecraft.text.Text; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +public class AddCardScreen extends Screen { + private final Screen parent; + private final Consumer onSubmitMessage; + private final BankUiService bankUiService = BankUiService.instance(); + private final UiNotifications notifications = UiNotifications.instance(); + private TextFieldWidget cardIdField; + private TextFieldWidget cardTokenField; + private ButtonWidget addButton; + private ButtonWidget cancelButton; + + public AddCardScreen(Screen parent, Consumer onSubmitMessage) { + super(Text.literal("Добавление карты")); + this.parent = parent; + this.onSubmitMessage = onSubmitMessage; + } + + @Override + protected void init() { + int centerX = this.width / 2; + int startY = this.height / 2 - 50; + + cardIdField = new TextFieldWidget(this.textRenderer, centerX - 140, startY, 280, 20, Text.literal("Card ID")); + cardIdField.setMaxLength(64); + cardIdField.setPlaceholder(Text.literal("ID карты (UUID)")); + this.addDrawableChild(cardIdField); + + cardTokenField = new TextFieldWidget(this.textRenderer, centerX - 140, startY + 28, 280, 20, Text.literal("Card Token")); + cardTokenField.setMaxLength(128); + cardTokenField.setPlaceholder(Text.literal("Токен карты")); + this.addDrawableChild(cardTokenField); + + addButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("Добавить"), button -> submit()) + .dimensions(centerX - 140, startY + 60, 136, 20) + .build()); + + cancelButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("Отмена"), button -> this.close()) + .dimensions(centerX + 4, startY + 60, 136, 20) + .build()); + } + + @Override + public void close() { + if (this.client != null) { + this.client.setScreen(parent); + } + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + super.render(context, mouseX, mouseY, delta); + + int centerX = this.width / 2; + int startY = this.height / 2 - 50; + + context.drawCenteredTextWithShadow(this.textRenderer, this.title, centerX, 24, 0xFFFFFF); + context.drawTextWithShadow(this.textRenderer, Text.literal("Card ID (UUID):"), centerX - 140, startY - 10, 0xCCCCCC); + context.drawTextWithShadow(this.textRenderer, Text.literal("Card Token:"), centerX - 140, startY + 18, 0xCCCCCC); + notifications.render(context, this.textRenderer, this.width, this.height); + } + + private void submit() { + String cardId = cardIdField.getText().trim(); + String cardToken = cardTokenField.getText().trim(); + if (cardId.isEmpty() || cardToken.isEmpty()) { + notifications.show(Text.literal("Укажи cardId и cardToken")); + return; + } + + String playerUuid = this.client != null && this.client.player != null + ? this.client.player.getUuidAsString() + : ""; + + notifications.show(Text.literal("Проверка карты...")); + addButton.active = false; + cancelButton.active = false; + + CompletableFuture + .supplyAsync(() -> bankUiService.addCard(cardId, cardToken, playerUuid)) + .thenAccept(message -> { + if (this.client == null) { + return; + } + this.client.execute(() -> { + notifications.showMessage(message); + addButton.active = true; + cancelButton.active = true; + + if (onSubmitMessage != null) { + onSubmitMessage.accept(message); + } + + if (message.startsWith("Карта добавлена") || message.startsWith("Вы не владелец карты")) { + this.close(); + } + }); + }) + .exceptionally(exception -> { + if (this.client != null) { + this.client.execute(() -> { + notifications.show(Text.literal("Ошибка добавления карты: " + exception.getMessage())); + addButton.active = true; + cancelButton.active = true; + }); + } + return null; + }); + } +} + diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/CardScreen.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/CardScreen.java new file mode 100644 index 0000000..7d1171c --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/CardScreen.java @@ -0,0 +1,124 @@ +package git.yawaflua.tech.spmega.client.ui; + +import git.yawaflua.tech.spmega.client.ui.service.BankUiService; +import git.yawaflua.tech.spmega.client.ui.service.CardViewModel; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.text.Text; + +import java.util.ArrayList; +import java.util.List; + +public class CardScreen extends Screen { + private final Screen parent; + private final BankUiService bankUiService = BankUiService.instance(); + private final UiNotifications notifications = UiNotifications.instance(); + private final List cardButtons = new ArrayList<>(); + + public CardScreen(Screen parent) { + super(Text.literal("Управление картами")); + this.parent = parent; + } + + @Override + protected void init() { + cardButtons.clear(); + + int centerX = this.width / 2; + int startY = this.height / 2 - 82; + int leftX = centerX - 210; + int rightX = centerX + 14; + int columnWidth = 196; + + List cards = bankUiService.getCards(); + for (int i = 0; i < cards.size(); i++) { + final int index = i; + CardViewModel card = cards.get(i); + ButtonWidget cardButton = this.addDrawableChild(ButtonWidget.builder(Text.literal(card.title()), button -> { + bankUiService.setSelectedCardIndex(index); + notifications.show(Text.literal("Выбрана карта " + card.title())); + updateCardButtonStates(); + }).dimensions(leftX, startY + i * 24, columnWidth, 20).build()); + cardButtons.add(cardButton); + } + + this.addDrawableChild(ButtonWidget.builder(Text.literal("Удалить"), button -> { + bankUiService.removeSelectedCard(); + notifications.showMessage(bankUiService.getLastMessage()); + this.clearAndInit(); + }).dimensions(rightX, startY, columnWidth, 20).build()); + + this.addDrawableChild(ButtonWidget.builder(Text.literal("Изменить"), button -> { + String playerUuid = this.client != null && this.client.player != null + ? this.client.player.getUuidAsString() + : ""; + bankUiService.refreshSelectedCard(playerUuid); + String message = bankUiService.getLastMessage().isBlank() ? "Карта обновлена" : bankUiService.getLastMessage(); + notifications.showMessage(message); + this.clearAndInit(); + }).dimensions(rightX, startY + 24, columnWidth, 20).build()); + + this.addDrawableChild(ButtonWidget.builder(Text.literal("Добавить новую"), button -> { + if (this.client == null) { + return; + } + this.client.setScreen(new AddCardScreen(this, message -> { + notifications.showMessage(message); + this.clearAndInit(); + })); + }).dimensions(rightX, startY + 48, columnWidth, 20).build()); + + this.addDrawableChild(ButtonWidget.builder(Text.literal("Назад"), button -> this.close()) + .dimensions(rightX, startY + 120, columnWidth, 20) + .build()); + + if (cards.isEmpty()) { + notifications.show(Text.literal("Нет карт. Добавь карту через кнопку 'Добавить новую'.")); + } + + updateCardButtonStates(); + } + + @Override + public void close() { + if (this.client != null) { + this.client.setScreen(parent); + } + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + super.render(context, mouseX, mouseY, delta); + + int centerX = this.width / 2; + int startY = this.height / 2 - 82; + int leftX = centerX - 210; + int rightX = centerX + 14; + + context.drawCenteredTextWithShadow(this.textRenderer, this.title, centerX, 24, 0xFFFFFF); + context.drawTextWithShadow(this.textRenderer, Text.literal("Список карт"), leftX, startY - 18, 0xBFBFBF); + context.drawTextWithShadow(this.textRenderer, Text.literal("Действия"), rightX, startY - 18, 0xBFBFBF); + notifications.render(context, this.textRenderer, this.width, this.height); + } + + private void updateCardButtonStates() { + int selectedIndex = bankUiService.getSelectedCardIndex(); + List cards = bankUiService.getCards(); + + for (int i = 0; i < cardButtons.size(); i++) { + ButtonWidget button = cardButtons.get(i); + if (i >= cards.size()) { + button.visible = false; + button.active = false; + continue; + } + + boolean selected = i == selectedIndex; + String prefix = selected ? ">> " : ""; + String suffix = selected ? " <<" : ""; + button.setMessage(Text.literal(prefix + cards.get(i).title() + suffix)); + button.active = !selected; + } + } +} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/MainBankScreen.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/MainBankScreen.java new file mode 100644 index 0000000..7edd40d --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/MainBankScreen.java @@ -0,0 +1,78 @@ +package git.yawaflua.tech.spmega.client.ui; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.text.Text; + +public class MainBankScreen extends Screen { + private final Screen parent; + + public MainBankScreen(Screen parent) { + super(Text.literal("SPMega")); + this.parent = parent; + } + + @Override + protected void init() { + int centerX = this.width / 2; + int y = this.height / 2 + 4; + int buttonWidth = 92; + int gap = 8; + int totalWidth = buttonWidth * 3 + gap * 2; + int startX = centerX - totalWidth / 2; + + this.addDrawableChild(ButtonWidget.builder(Text.literal("Карта"), button -> { + this.client.setScreen(new CardScreen(this)); + }).dimensions(startX, y, buttonWidth, 20).build()); + + this.addDrawableChild(ButtonWidget.builder(Text.literal("Оплата"), button -> { + this.client.setScreen(new PaymentScreen(this)); + }).dimensions(startX + buttonWidth + gap, y, buttonWidth, 20).build()); + + this.addDrawableChild(ButtonWidget.builder(Text.literal("Закрыть"), button -> this.close()) + .dimensions(startX + (buttonWidth + gap) * 2, y, buttonWidth, 20) + .build()); + } + + @Override + public void close() { + if (this.client != null) { + this.client.setScreen(parent); + } + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + super.render(context, mouseX, mouseY, delta); + + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 24, 0xFFFFFF); + context.drawCenteredTextWithShadow(this.textRenderer, Text.literal(greetingLine()), this.width / 2, 48, 0xFFD27F); + } + + private String greetingLine() { + MinecraftClient minecraftClient = MinecraftClient.getInstance(); + String username = minecraftClient.getSession().getUsername(); + return "Доброе " + getTimeOfDay() + ", " + username; + } + + private String getTimeOfDay() { + MinecraftClient minecraftClient = MinecraftClient.getInstance(); + if (minecraftClient.world == null) { + return "время суток"; + } + + long dayTime = minecraftClient.world.getTimeOfDay() % 24000L; + if (dayTime < 6000) { + return "утро"; + } + if (dayTime < 12000) { + return "день"; + } + if (dayTime < 18000) { + return "вечер"; + } + return "ночь"; + } +} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/PaymentScreen.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/PaymentScreen.java new file mode 100644 index 0000000..9175033 --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/PaymentScreen.java @@ -0,0 +1,384 @@ +package git.yawaflua.tech.spmega.client.ui; + +import git.yawaflua.tech.spmega.client.ui.service.BankUiService; +import git.yawaflua.tech.spmega.client.ui.service.CardViewModel; +import git.yawaflua.tech.spmega.client.ui.service.PaymentDraft; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.TextFieldWidget; +import net.minecraft.text.Text; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class PaymentScreen extends Screen { + private static final int MAX_RECIPIENT_OPTION_BUTTONS = 10; + private static final long RECIPIENT_LOOKUP_DEBOUNCE_MS = 500L; + private static final Pattern CARD_ID_PATTERN = Pattern.compile("\\d{5}"); + private final Screen parent; + private final BankUiService bankUiService = BankUiService.instance(); + private final List recipientCardOptionButtons = new ArrayList<>(); + private final List recipientCardOptions = new ArrayList<>(); + private final String initialRecipient; + private final UiNotifications notifications = UiNotifications.instance(); + private TextFieldWidget amountField; + private TextFieldWidget recipientField; + private TextFieldWidget commentField; + private ButtonWidget senderLeftButton; + private ButtonWidget senderCardLabelButton; + private ButtonWidget senderRightButton; + private ButtonWidget recipientCardDropdownButton; + private boolean recipientDropdownExpanded; + private String selectedRecipientCard = ""; + private String lastRecipientLookup = ""; + private String pendingRecipientLookup = ""; + private long pendingRecipientLookupAt; + private Text senderCardText = Text.literal("Карта отправителя: не выбрана"); + private ButtonWidget transferButton; + private ButtonWidget backButton; + + public PaymentScreen(Screen parent) { + this(parent, ""); + } + + public PaymentScreen(Screen parent, String initialRecipient) { + super(Text.literal("Оплата")); + this.parent = parent; + this.initialRecipient = initialRecipient == null ? "" : initialRecipient.trim(); + } + + @Override + protected void init() { + int centerX = this.width / 2; + int startY = this.height / 2 - 82; + int leftX = centerX - 210; + int rightX = centerX + 14; + int leftWidth = 196; + int rightWidth = 196; + TextRenderer tr = this.textRenderer; + + amountField = new TextFieldWidget(tr, leftX, startY, leftWidth, 20, Text.literal("Сумма")); + amountField.setMaxLength(16); + amountField.setPlaceholder(Text.literal("Сумма перевода")); + this.addDrawableChild(amountField); + + recipientField = new TextFieldWidget(tr, leftX, startY + 28, leftWidth, 20, Text.literal("Получатель")); + recipientField.setMaxLength(32); + recipientField.setPlaceholder(Text.literal("Ник или 5 цифр карты")); + recipientField.setChangedListener(value -> { + String input = value.trim(); + if (isNickname(input)) { + scheduleRecipientLookup(input); + } else { + lastRecipientLookup = ""; + pendingRecipientLookup = ""; + recipientCardOptions.clear(); + selectedRecipientCard = ""; + } + updateRecipientDropdownVisibility(); + updateSenderCardText(); + }); + this.addDrawableChild(recipientField); + + commentField = new TextFieldWidget(tr, leftX, startY + 56, leftWidth, 20, Text.literal("Комментарий")); + commentField.setMaxLength(64); + commentField.setPlaceholder(Text.literal("Комментарий (необязательно)")); + this.addDrawableChild(commentField); + + recipientCardDropdownButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("Карта игрока: -"), button -> { + recipientDropdownExpanded = !recipientDropdownExpanded; + updateRecipientDropdownVisibility(); + }).dimensions(leftX, startY + 84, leftWidth, 20).build()); + + recipientCardOptionButtons.clear(); + for (int i = 0; i < MAX_RECIPIENT_OPTION_BUTTONS; i++) { + final int index = i; + ButtonWidget optionButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("-"), button -> { + if (index < recipientCardOptions.size()) { + selectedRecipientCard = recipientCardOptions.get(index); + recipientDropdownExpanded = false; + updateRecipientDropdownVisibility(); + updateSenderCardText(); + } + }).dimensions(leftX, startY + 108 + i * 22, leftWidth, 20).build()); + recipientCardOptionButtons.add(optionButton); + } + + if (!initialRecipient.isEmpty()) { + recipientField.setText(initialRecipient); + } + + int arrowWidth = 36; + int middleWidth = rightWidth - arrowWidth * 2 - 8; + senderLeftButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("<"), button -> { + bankUiService.cycleSelectedCard(-1); + updateSenderCardSelector(); + updateSenderCardText(); + }).dimensions(rightX, startY, arrowWidth, 20).build()); + + senderCardLabelButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("00000: 0 АР"), button -> { + }).dimensions(rightX + arrowWidth + 4, startY, middleWidth, 20).build()); + senderCardLabelButton.active = false; + + senderRightButton = this.addDrawableChild(ButtonWidget.builder(Text.literal(">"), button -> { + bankUiService.cycleSelectedCard(1); + updateSenderCardSelector(); + updateSenderCardText(); + }).dimensions(rightX + arrowWidth + 4 + middleWidth + 4, startY, arrowWidth, 20).build()); + + transferButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("Перевести"), button -> submit()) + .dimensions(rightX, startY + 28, rightWidth, 20) + .build()); + + backButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("Назад"), button -> this.close()) + .dimensions(rightX, startY + 56, rightWidth, 20) + .build()); + + updateSenderCardSelector(); + updateRecipientDropdownVisibility(); + updateSenderCardText(); + } + + @Override + public void close() { + if (this.client != null) { + this.client.setScreen(parent); + } + } + + @Override + public void tick() { + super.tick(); + if (pendingRecipientLookup.isEmpty()) { + return; + } + + long elapsed = System.currentTimeMillis() - pendingRecipientLookupAt; + if (elapsed < RECIPIENT_LOOKUP_DEBOUNCE_MS) { + return; + } + + String lookupUsername = pendingRecipientLookup; + pendingRecipientLookup = ""; + requestRecipientCards(lookupUsername); + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + super.render(context, mouseX, mouseY, delta); + + int centerX = this.width / 2; + int startY = this.height / 2 - 82; + int leftX = centerX - 210; + int rightX = centerX + 14; + + context.drawCenteredTextWithShadow(this.textRenderer, this.title, centerX, 20, 0xFFFFFF); + context.drawTextWithShadow(this.textRenderer, Text.literal("Левая колонка: ввод"), leftX, startY - 18, 0xBFBFBF); + context.drawTextWithShadow(this.textRenderer, Text.literal("Правая колонка: действия"), rightX, startY - 18, 0xBFBFBF); + context.drawTextWithShadow(this.textRenderer, Text.literal("Сумма:"), leftX, startY - 10, 0xCCCCCC); + context.drawTextWithShadow(this.textRenderer, Text.literal("Получатель:"), leftX, startY + 18, 0xCCCCCC); + context.drawTextWithShadow(this.textRenderer, Text.literal("Комментарий:"), leftX, startY + 46, 0xCCCCCC); + + if (recipientCardDropdownButton.visible) { + context.drawTextWithShadow(this.textRenderer, Text.literal("Карта игрока"), leftX, startY + 74, 0xD7B2FF); + } + + context.drawCenteredTextWithShadow(this.textRenderer, senderCardText, centerX, this.height - 20, 0xA9E5A9); + notifications.render(context, this.textRenderer, this.width, this.height); + } + + private void submit() { + CardViewModel selectedCard = bankUiService.getSelectedCard(); + if (selectedCard == null) { + notifications.show(Text.literal("Нет выбранной карты отправителя")); + return; + } + + long amount; + try { + amount = Long.parseLong(amountField.getText().trim()); + if (amount <= 0) { + notifications.show(Text.literal("Сумма должна быть больше 0")); + return; + } + } catch (NumberFormatException exception) { + notifications.show(Text.literal("Некорректная сумма")); + return; + } + + String recipientInput = recipientField.getText().trim(); + if (!isValidRecipient(recipientInput)) { + notifications.show(Text.literal("Укажи ник или 5 цифр карты")); + return; + } + + String receiver = recipientInput; + if (isNickname(recipientInput)) { + if (selectedRecipientCard.isEmpty()) { + notifications.show(Text.literal("Выбери карту получателя из списка")); + return; + } + receiver = selectedRecipientCard; + } + + PaymentDraft draft = new PaymentDraft( + selectedCard.id(), + receiver, + amount, + commentField.getText().trim() + ); + + boolean accepted = bankUiService.submitPayment(draft); + String serviceMessage = bankUiService.getLastMessage(); + if (!serviceMessage.isBlank()) { + + notifications.showMessage(serviceMessage); + } else { + notifications.show(accepted + ? Text.literal("Перевод выполнен") + : Text.literal("Перевод отклонен")); + } + updateSenderCardSelector(); + updateSenderCardText(); + } + + private boolean isValidRecipient(String recipient) { + if (recipient.matches("\\d{5}")) { + return true; + } + return recipient.matches("[A-Za-z0-9_]{3,16}"); + } + + private void updateSenderCardText() { + CardViewModel selectedCard = bankUiService.getSelectedCard(); + if (selectedCard == null) { + senderCardText = Text.literal("Карта отправителя: не выбрана"); + return; + } + + String recipient = recipientField == null ? "" : recipientField.getText().trim(); + String suffix; + if (isNickname(recipient)) { + String recipientCard = selectedRecipientCard.isEmpty() ? "карта не выбрана" : selectedRecipientCard; + suffix = " (ник: выбранная карта -> " + recipientCard + ")"; + } else { + suffix = " (режим: перевод по номеру карты)"; + } + + senderCardText = Text.literal( + "Карта отправителя: " + selectedCard.title() + " | Баланс: " + selectedCard.balance() + suffix + ); + } + + private void updateSenderCardSelector() { + CardViewModel selectedCard = bankUiService.getSelectedCard(); + if (selectedCard == null) { + senderCardLabelButton.setMessage(Text.literal("\"00000\": \"0\" АР")); + senderLeftButton.active = false; + senderRightButton.active = false; + return; + } + + senderLeftButton.active = true; + senderRightButton.active = true; + String cardId = extractCardId(selectedCard.title()); + String balance = Long.toString(selectedCard.balance()); + senderCardLabelButton.setMessage(Text.literal("\"" + cardId + "\": \"" + balance + "\" АР")); + senderCardLabelButton.active = false; + } + + private void updateRecipientDropdownVisibility() { + if (recipientCardDropdownButton == null) { + return; + } + + boolean nicknameMode = isNickname(recipientField == null ? "" : recipientField.getText().trim()); + + boolean hasRecipientCards = !recipientCardOptions.isEmpty(); + recipientCardDropdownButton.visible = nicknameMode; + recipientCardDropdownButton.active = nicknameMode && hasRecipientCards; + + if (!nicknameMode) { + recipientDropdownExpanded = false; + selectedRecipientCard = ""; + } else if (hasRecipientCards && selectedRecipientCard.isEmpty()) { + selectedRecipientCard = recipientCardOptions.get(0); + } + + String dropdownText; + if (!nicknameMode) { + dropdownText = "Карта игрока: -"; + } else if (!hasRecipientCards) { + dropdownText = "Карта игрока: нет карт"; + } else { + dropdownText = "Карта игрока: " + selectedRecipientCard; + } + recipientCardDropdownButton.setMessage(Text.literal(dropdownText)); + + for (int i = 0; i < recipientCardOptionButtons.size(); i++) { + ButtonWidget optionButton = recipientCardOptionButtons.get(i); + boolean showOption = nicknameMode && recipientDropdownExpanded && i < recipientCardOptions.size(); + optionButton.visible = showOption; + optionButton.active = showOption; + if (i < recipientCardOptions.size()) { + optionButton.setMessage(Text.literal(recipientCardOptions.get(i))); + } + } + } + + private boolean isNickname(String value) { + return value.matches("[A-Za-z0-9_]{3,16}") && !value.matches("\\d+"); + } + + private String extractCardId(String source) { + Matcher matcher = CARD_ID_PATTERN.matcher(source); + if (matcher.find()) { + return matcher.group(); + } + return "00000"; + } + + private void requestRecipientCards(String username) { + if (username.equals(lastRecipientLookup)) { + return; + } + lastRecipientLookup = username; + + CompletableFuture + .supplyAsync(() -> bankUiService.loadRecipientCards(username)) + .thenAccept(cards -> { + if (this.client == null) { + return; + } + this.client.execute(() -> { + if (recipientField == null || !username.equals(recipientField.getText().trim())) { + return; + } + + recipientCardOptions.clear(); + recipientCardOptions.addAll(cards); + selectedRecipientCard = recipientCardOptions.isEmpty() ? "" : recipientCardOptions.get(0); + recipientDropdownExpanded = false; + + String serviceMessage = bankUiService.getLastMessage(); + if (!serviceMessage.isBlank()) { + notifications.showMessage(serviceMessage); + } + + updateRecipientDropdownVisibility(); + updateSenderCardText(); + }); + }); + } + + private void scheduleRecipientLookup(String username) { + pendingRecipientLookup = username; + pendingRecipientLookupAt = System.currentTimeMillis(); + } + +} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/UiNotifications.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/UiNotifications.java new file mode 100644 index 0000000..b882681 --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/UiNotifications.java @@ -0,0 +1,87 @@ +package git.yawaflua.tech.spmega.client.ui; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.Strictness; +import com.google.gson.stream.JsonReader; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.text.Text; + +import java.awt.*; + +public final class UiNotifications { + private static final long DEFAULT_DURATION_MS = 3500L; + private static final UiNotifications INSTANCE = new UiNotifications(); + + private Text currentText = Text.empty(); + private long visibleUntilMs; + + private UiNotifications() { + } + + public static UiNotifications instance() { + return INSTANCE; + } + + public static String extractMessage(String raw) { + if (raw == null || raw.isBlank()) { + return ""; + } + + try { + System.out.println(raw); + var reader = new JsonReader(new java.io.StringReader(raw)); + reader.setStrictness(Strictness.LENIENT); + + JsonElement parsed = JsonParser.parseReader(reader); + JsonObject object = parsed.isJsonArray() ? parsed.getAsJsonArray().get(0).getAsJsonObject() : parsed.getAsJsonObject(); + if (object.has("message") && !object.get("message").isJsonNull()) { + return object.get("message").getAsString(); + } + + } catch (Exception ignored) { + System.out.println(ignored.getMessage()); + // fallback to raw text + } + + return raw; + } + + public synchronized void show(Text text) { + if (text == null || text.getString().isBlank()) { + return; + } + currentText = text; + visibleUntilMs = System.currentTimeMillis() + DEFAULT_DURATION_MS; + } + + public synchronized void showMessage(String message) { + if (message == null || message.isBlank()) { + return; + } + show(Text.literal(extractMessage(message))); + } + + public synchronized void render(DrawContext context, TextRenderer textRenderer, int width, int height) { + if (currentText == null || currentText.getString().isBlank()) { + return; + } + if (System.currentTimeMillis() > visibleUntilMs) { + return; + } + + String message = currentText.getString(); + int padding = 6; + int textWidth = textRenderer.getWidth(message); + int boxWidth = textWidth + padding * 2; + int boxHeight = textRenderer.fontHeight + padding * 2; + int x = width - boxWidth - 10; + int y = height - boxHeight - 10; + + context.fill(x, y, x + boxWidth, y + boxHeight, Color.GRAY.getRGB()); + context.drawTextWithShadow(textRenderer, currentText, x + padding, y + padding, Color.WHITE.getRGB()); + } +} + diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/UiOpeners.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/UiOpeners.java new file mode 100644 index 0000000..c5577ce --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/UiOpeners.java @@ -0,0 +1,28 @@ +package git.yawaflua.tech.spmega.client.ui; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.screen.Screen; + +public final class UiOpeners { + private UiOpeners() { + } + + public static void openMainMenu(MinecraftClient client) { + if (client == null) { + return; + } + + Screen current = client.currentScreen; + client.setScreen(new MainBankScreen(current)); + } + + public static void openPaymentMenu(MinecraftClient client, String recipient) { + if (client == null) { + return; + } + + Screen current = client.currentScreen; + client.setScreen(new PaymentScreen(current, recipient)); + } +} + diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankDatabase.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankDatabase.java new file mode 100644 index 0000000..c26cd34 --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankDatabase.java @@ -0,0 +1,173 @@ +package git.yawaflua.tech.spmega.client.ui.service; + +import java.nio.file.Path; +import java.sql.*; +import java.util.ArrayList; +import java.util.List; + +public final class BankDatabase { + private final String jdbcUrl; + + public BankDatabase(Path databasePath) { + this.jdbcUrl = "jdbc:sqlite:" + databasePath.toAbsolutePath(); + } + + public void initialize() { + try (Connection connection = open()) { + try (Statement statement = connection.createStatement()) { + statement.executeUpdate(""" + CREATE TABLE IF NOT EXISTS cards ( + card_id TEXT PRIMARY KEY, + card_token TEXT NOT NULL, + card_name TEXT, + card_number TEXT, + balance INTEGER NOT NULL DEFAULT 0, + owner_uuid TEXT, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + """); + statement.executeUpdate(""" + CREATE TABLE IF NOT EXISTS transfer_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_card_id TEXT NOT NULL, + receiver TEXT NOT NULL, + amount INTEGER NOT NULL, + comment TEXT, + balance_after INTEGER, + status TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + """); + } + } catch (SQLException exception) { + throw new RuntimeException("Failed to initialize sqlite database", exception); + } + } + + public synchronized void upsertCardCredentials(String cardId, String cardToken) { + String sql = """ + INSERT INTO cards(card_id, card_token, updated_at) + VALUES(?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(card_id) DO UPDATE SET + card_token = excluded.card_token, + updated_at = CURRENT_TIMESTAMP + """; + try (Connection connection = open(); PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, cardId); + statement.setString(2, cardToken); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new RuntimeException("Failed to upsert card credentials", exception); + } + } + + public synchronized void updateCardMeta(String cardId, String cardName, String cardNumber, long balance, String ownerUuid) { + String sql = """ + UPDATE cards + SET card_name = ?, card_number = ?, balance = ?, owner_uuid = ?, updated_at = CURRENT_TIMESTAMP + WHERE card_id = ? + """; + try (Connection connection = open(); PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, cardName); + statement.setString(2, cardNumber); + statement.setLong(3, balance); + statement.setString(4, ownerUuid); + statement.setString(5, cardId); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new RuntimeException("Failed to update card meta", exception); + } + } + + public synchronized void updateCardBalance(String cardId, long balance) { + String sql = "UPDATE cards SET balance = ?, updated_at = CURRENT_TIMESTAMP WHERE card_id = ?"; + try (Connection connection = open(); PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, balance); + statement.setString(2, cardId); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new RuntimeException("Failed to update card balance", exception); + } + } + + public synchronized void deleteCard(String cardId) { + String sql = "DELETE FROM cards WHERE card_id = ?"; + try (Connection connection = open(); PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, cardId); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new RuntimeException("Failed to delete card", exception); + } + } + + public synchronized List loadCards() { + String sql = "SELECT card_id, card_token, card_name, card_number, balance, owner_uuid FROM cards ORDER BY updated_at DESC"; + List result = new ArrayList<>(); + try (Connection connection = open(); + PreparedStatement statement = connection.prepareStatement(sql); + ResultSet rs = statement.executeQuery()) { + while (rs.next()) { + result.add(new StoredCard( + rs.getString("card_id"), + rs.getString("card_token"), + rs.getString("card_name"), + rs.getString("card_number"), + rs.getLong("balance"), + rs.getString("owner_uuid") + )); + } + return result; + } catch (SQLException exception) { + throw new RuntimeException("Failed to load cards", exception); + } + } + + public synchronized CardCredentials getCredentials(String cardId) { + String sql = "SELECT card_id, card_token FROM cards WHERE card_id = ?"; + try (Connection connection = open(); PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, cardId); + try (ResultSet rs = statement.executeQuery()) { + if (rs.next()) { + return new CardCredentials(rs.getString("card_id"), rs.getString("card_token")); + } + } + return null; + } catch (SQLException exception) { + throw new RuntimeException("Failed to get card credentials", exception); + } + } + + public synchronized void insertTransferHistory( + String senderCardId, + String receiver, + long amount, + String comment, + Long balanceAfter, + String status + ) { + String sql = """ + INSERT INTO transfer_history(sender_card_id, receiver, amount, comment, balance_after, status) + VALUES(?, ?, ?, ?, ?, ?) + """; + try (Connection connection = open(); PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, senderCardId); + statement.setString(2, receiver); + statement.setLong(3, amount); + statement.setString(4, comment); + if (balanceAfter == null) { + statement.setNull(5, java.sql.Types.INTEGER); + } else { + statement.setLong(5, balanceAfter); + } + statement.setString(6, status); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new RuntimeException("Failed to insert transfer history", exception); + } + } + + private Connection open() throws SQLException { + return DriverManager.getConnection(jdbcUrl); + } +} + diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankUiService.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankUiService.java new file mode 100644 index 0000000..a458a09 --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankUiService.java @@ -0,0 +1,298 @@ +package git.yawaflua.tech.spmega.client.ui.service; + +import git.yawaflua.tech.spmega.api.SPWorldsApiClient; +import net.fabricmc.loader.api.FabricLoader; + +import java.util.*; + +public final class BankUiService { + private static final BankUiService INSTANCE = new BankUiService(); + + private final List cards = new ArrayList<>(); + private final BankDatabase database; + private final SPWorldsApiClient apiClient; + private final String apiDomain = "https://spworlds.ru"; + + private int selectedCardIndex; + private String lastMessage = ""; + + private BankUiService() { + this.database = new BankDatabase(FabricLoader.getInstance().getConfigDir().resolve("spmega.db")); + this.database.initialize(); + this.apiClient = new SPWorldsApiClient(apiDomain); + + reloadCardsFromDb(); + } + + public static BankUiService instance() { + return INSTANCE; + } + + private static SPWorldsApiClient.CardAuth toApiAuth(CardCredentials credentials) { + return new SPWorldsApiClient.CardAuth(credentials.cardId(), credentials.cardToken()); + } + + private static String trimMessage(String message) { + if (message == null) { + return "unknown"; + } + return message.length() > 120 ? message.substring(0, 120) : message; + } + + private static String normalizeUuid(String rawUuid) { + return rawUuid == null ? "" : rawUuid.toLowerCase(Locale.ROOT).replace("-", ""); + } + + private static String extractLastDigits(String value) { + String normalized = value == null ? "" : value.replace("-", ""); + if (normalized.length() < 5) { + return "00000"; + } + return normalized.substring(normalized.length() - 5); + } + + public synchronized List getCards() { + return Collections.unmodifiableList(cards); + } + + public synchronized CardViewModel getSelectedCard() { + if (cards.isEmpty()) { + return null; + } + selectedCardIndex = Math.floorMod(selectedCardIndex, cards.size()); + return cards.get(selectedCardIndex); + } + + public synchronized int getSelectedCardIndex() { + if (cards.isEmpty()) { + return 0; + } + selectedCardIndex = Math.floorMod(selectedCardIndex, cards.size()); + return selectedCardIndex; + } + + public synchronized void setSelectedCardIndex(int index) { + if (cards.isEmpty()) { + selectedCardIndex = 0; + return; + } + selectedCardIndex = Math.floorMod(index, cards.size()); + } + + public synchronized CardViewModel cycleSelectedCard(int direction) { + if (cards.isEmpty()) { + return null; + } + selectedCardIndex = Math.floorMod(selectedCardIndex + direction, cards.size()); + return cards.get(selectedCardIndex); + } + + public synchronized String getLastMessage() { + return lastMessage; + } + + public synchronized void refreshOnServerJoin(String playerUuid) { + List storedCards = database.loadCards(); + for (StoredCard card : storedCards) { + refreshCard(card.cardId(), card.cardToken(), playerUuid, false, false); + } + reloadCardsFromDb(); + } + + public synchronized List loadRecipientCards(String username) { + CardCredentials credentials = getSelectedCredentials(); + if (credentials == null || username == null || username.isBlank()) { + return List.of(); + } + + try { + List apiCards = apiClient.getPlayerCards(username, toApiAuth(credentials)); + List numbers = new ArrayList<>(); + for (SPWorldsApiClient.PlayerCard apiCard : apiCards) { + if (apiCard.number() != null && !apiCard.number().isBlank()) { + numbers.add(apiCard.number()); + } + } + lastMessage = ""; + return numbers; + } catch (Exception exception) { + lastMessage = "Не удалось получить карты игрока: " + exception.getMessage(); + return List.of(); + } + } + + public synchronized String addCard(String cardIdRaw, String cardTokenRaw, String playerUuid) { + String cardId = cardIdRaw == null ? "" : cardIdRaw.trim(); + String cardToken = cardTokenRaw == null ? "" : cardTokenRaw.trim(); + + if (cardId.isEmpty() || cardToken.isEmpty()) { + lastMessage = "Укажи cardId и cardToken"; + return lastMessage; + } + + try { + UUID.fromString(cardId); + } catch (IllegalArgumentException exception) { + lastMessage = "cardId должен быть UUID"; + return lastMessage; + } + + database.upsertCardCredentials(cardId, cardToken); + boolean refreshed = refreshCard(cardId, cardToken, playerUuid, true, true); + if (!refreshed) { + database.deleteCard(cardId); + } + reloadCardsFromDb(); + + if (refreshed && lastMessage.isBlank()) { + lastMessage = "Карта добавлена"; + } + return lastMessage; + } + + public synchronized void removeSelectedCard() { + CardViewModel selected = getSelectedCard(); + if (selected == null) { + lastMessage = "Нет карты для удаления"; + return; + } + + database.deleteCard(selected.id()); + reloadCardsFromDb(); + if (selectedCardIndex >= cards.size()) { + selectedCardIndex = Math.max(0, cards.size() - 1); + } + lastMessage = "Карта удалена"; + } + + public synchronized void refreshSelectedCard(String playerUuid) { + CardViewModel selected = getSelectedCard(); + if (selected == null) { + lastMessage = "Нет карты для обновления"; + return; + } + + CardCredentials credentials = database.getCredentials(selected.id()); + if (credentials == null) { + lastMessage = "Не найдены креды карты"; + return; + } + + refreshCard(credentials.cardId(), credentials.cardToken(), playerUuid, false, false); + reloadCardsFromDb(); + } + + public synchronized boolean submitPayment(PaymentDraft draft) { + CardCredentials credentials = database.getCredentials(draft.senderCardId()); + if (credentials == null) { + lastMessage = "Не найдены креды карты отправителя"; + return false; + } + + try { + SPWorldsApiClient.TransactionResult result = apiClient.createTransaction( + toApiAuth(credentials), + draft.recipient(), + draft.amount(), + draft.comment() + ); + + database.updateCardBalance(credentials.cardId(), result.balance()); + database.insertTransferHistory( + credentials.cardId(), + draft.recipient(), + draft.amount(), + draft.comment(), + result.balance(), + "SUCCESS" + ); + + reloadCardsFromDb(); + lastMessage = "Перевод выполнен"; + return true; + } catch (Exception exception) { + database.insertTransferHistory( + credentials.cardId(), + draft.recipient(), + draft.amount(), + draft.comment(), + null, + "FAILED: " + trimMessage(exception.getMessage()) + ); + lastMessage = "Ошибка перевода: " + exception.getMessage(); + return false; + } + } + + private boolean refreshCard( + String cardId, + String cardToken, + String playerUuid, + boolean reportOwnerWarning, + boolean requireCardInAccount + ) { + try { + SPWorldsApiClient.CardAuth auth = new SPWorldsApiClient.CardAuth(cardId, cardToken); + SPWorldsApiClient.AccountMe me = apiClient.getAccountMe(auth); + SPWorldsApiClient.CardInfo cardInfo = apiClient.getCardInfo(auth); + + SPWorldsApiClient.AccountCard currentCard = me.cards().stream() + .filter(card -> Objects.equals(card.id(), cardId)) + .findFirst() + .orElse(null); + + if (requireCardInAccount && currentCard == null) { + lastMessage = "Карта не найдена в аккаунте SPWorlds"; + return false; + } + + String cardName = currentCard == null || currentCard.name().isBlank() ? "Карта" : currentCard.name(); + String cardNumber = currentCard == null || currentCard.number().isBlank() ? extractLastDigits(cardId) : currentCard.number(); + String ownerUuid = normalizeUuid(me.minecraftUuid()); + + database.updateCardMeta(cardId, cardName, cardNumber, cardInfo.balance(), ownerUuid); + + if (reportOwnerWarning && playerUuid != null && !playerUuid.isBlank()) { + String normalizedPlayerUuid = normalizeUuid(playerUuid); + if (!normalizedPlayerUuid.equals(ownerUuid)) { + lastMessage = "Вы не владелец карты. Часть функций может быть ограничена."; + return true; + } + } + + lastMessage = ""; + return true; + } catch (Exception exception) { + lastMessage = "Не удалось обновить карту: " + exception.getMessage(); + return false; + } + } + + private void reloadCardsFromDb() { + List storedCards = database.loadCards(); + cards.clear(); + + for (StoredCard stored : storedCards) { + String cardNumber = stored.cardNumber() == null || stored.cardNumber().isBlank() + ? extractLastDigits(stored.cardId()) + : stored.cardNumber(); + String cardName = stored.cardName() == null || stored.cardName().isBlank() ? "Карта" : stored.cardName(); + String title = cardNumber + ": " + cardName; + cards.add(new CardViewModel(stored.cardId(), title, stored.balance())); + } + + if (cards.isEmpty()) { + selectedCardIndex = 0; + } else { + selectedCardIndex = Math.floorMod(selectedCardIndex, cards.size()); + } + } + + private CardCredentials getSelectedCredentials() { + CardViewModel selected = getSelectedCard(); + if (selected == null) { + return null; + } + return database.getCredentials(selected.id()); + } +} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/CardCredentials.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/CardCredentials.java new file mode 100644 index 0000000..8950849 --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/CardCredentials.java @@ -0,0 +1,5 @@ +package git.yawaflua.tech.spmega.client.ui.service; + +public record CardCredentials(String cardId, String cardToken) { +} + diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/CardViewModel.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/CardViewModel.java new file mode 100644 index 0000000..f6a35f6 --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/CardViewModel.java @@ -0,0 +1,5 @@ +package git.yawaflua.tech.spmega.client.ui.service; + +public record CardViewModel(String id, String title, long balance) { +} + diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/PaymentDraft.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/PaymentDraft.java new file mode 100644 index 0000000..955a5bc --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/PaymentDraft.java @@ -0,0 +1,5 @@ +package git.yawaflua.tech.spmega.client.ui.service; + +public record PaymentDraft(String senderCardId, String recipient, long amount, String comment) { +} + diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/StoredCard.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/StoredCard.java new file mode 100644 index 0000000..aece313 --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/StoredCard.java @@ -0,0 +1,6 @@ +package git.yawaflua.tech.spmega.client.ui.service; + +public record StoredCard(String cardId, String cardToken, String cardName, String cardNumber, long balance, + String ownerUuid) { +} + diff --git a/src/client/java/git/yawaflua/tech/spmega/mixin/client/GameMenuScreenMixin.java b/src/client/java/git/yawaflua/tech/spmega/mixin/client/GameMenuScreenMixin.java new file mode 100644 index 0000000..97520d3 --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/mixin/client/GameMenuScreenMixin.java @@ -0,0 +1,29 @@ +package git.yawaflua.tech.spmega.mixin.client; + +import git.yawaflua.tech.spmega.client.ui.UiOpeners; +import net.minecraft.client.gui.screen.GameMenuScreen; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.text.Text; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(GameMenuScreen.class) +public abstract class GameMenuScreenMixin extends Screen { + protected GameMenuScreenMixin(Text title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void spmega$addOpenMenuButton(CallbackInfo ci) { + int buttonWidth = 96; + int x = this.width / 2 + 106; + int y = this.height / 4 + 24 - 16; + this.addDrawableChild(ButtonWidget.builder(Text.literal("SPMega"), button -> { + UiOpeners.openMainMenu(this.client); + }).dimensions(x, y, buttonWidth, 20).build()); + } +} + diff --git a/src/client/resources/assets/spmega/lang/en_us.json b/src/client/resources/assets/spmega/lang/en_us.json new file mode 100644 index 0000000..3d2e77f --- /dev/null +++ b/src/client/resources/assets/spmega/lang/en_us.json @@ -0,0 +1,5 @@ +{ + "category.spmega": "SPMega", + "key.spmega.open_menu": "Open SPMega Menu" +} + diff --git a/src/client/resources/spmega.client.mixins.json b/src/client/resources/spmega.client.mixins.json new file mode 100644 index 0000000..eb2d742 --- /dev/null +++ b/src/client/resources/spmega.client.mixins.json @@ -0,0 +1,15 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "git.yawaflua.tech.spmega.mixin.client", + "compatibilityLevel": "JAVA_21", + "client": [ + "GameMenuScreenMixin" + ], + "injectors": { + "defaultRequire": 1 + }, + "overwrites": { + "requireAnnotations": true + } +} diff --git a/src/main/java/git/yawaflua/tech/spmega/ConfigManager.java b/src/main/java/git/yawaflua/tech/spmega/ConfigManager.java new file mode 100644 index 0000000..2cc5605 --- /dev/null +++ b/src/main/java/git/yawaflua/tech/spmega/ConfigManager.java @@ -0,0 +1,116 @@ +package git.yawaflua.tech.spmega; + +import net.fabricmc.loader.api.FabricLoader; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; +import java.util.UUID; + +public final class ConfigManager { + private static final String FILE_NAME = "spmega.properties"; + + private ConfigManager() { + } + + public static ModConfig loadOrCreate() { + Path configPath = FabricLoader.getInstance().getConfigDir().resolve(FILE_NAME); + Properties properties = new Properties(); + ModConfig defaults = ModConfig.createDefault(); + boolean shouldSave = false; + + if (Files.exists(configPath)) { + try (InputStream inputStream = Files.newInputStream(configPath)) { + properties.load(inputStream); + } catch (IOException exception) { + shouldSave = true; + } + } else { + shouldSave = true; + } + + String apiDomain = readString(properties, "api.domain", defaults.apiDomain()); + if (!apiDomain.equals(properties.getProperty("api.domain"))) { + shouldSave = true; + } + + String apiToken = readString(properties, "api.token", defaults.apiToken()); + if (!apiToken.equals(properties.getProperty("api.token"))) { + shouldSave = true; + } + + boolean signQuickPayEnabled = readBoolean(properties, "sign.quickPay.enabled", defaults.signQuickPayEnabled()); + String rawQuickPay = properties.getProperty("sign.quickPay.enabled"); + if (rawQuickPay == null || !Boolean.toString(signQuickPayEnabled).equalsIgnoreCase(rawQuickPay.trim())) { + shouldSave = true; + } + + ModConfig config = new ModConfig(apiDomain, apiToken, signQuickPayEnabled); + + if (shouldSave) { + save(configPath, config); + } + + return config; + } + + private static String readString(Properties properties, String key, String fallback) { + String value = properties.getProperty(key); + if (value == null) { + return fallback; + } + + String trimmed = value.trim(); + return trimmed.isEmpty() ? fallback : trimmed; + } + + private static UUID readUuid(Properties properties, String key, UUID fallback) { + String value = properties.getProperty(key); + if (value == null || value.trim().isEmpty()) { + return fallback; + } + + try { + return UUID.fromString(value.trim()); + } catch (IllegalArgumentException exception) { + // Self-heal broken UUID values by falling back to a valid default. + return fallback; + } + } + + private static boolean readBoolean(Properties properties, String key, boolean fallback) { + String value = properties.getProperty(key); + if (value == null) { + return fallback; + } + + String normalized = value.trim().toLowerCase(); + if ("true".equals(normalized)) { + return true; + } + if ("false".equals(normalized)) { + return false; + } + return fallback; + } + + private static void save(Path configPath, ModConfig config) { + Properties properties = new Properties(); + properties.setProperty("api.domain", config.apiDomain()); + properties.setProperty("api.token", config.apiToken()); + properties.setProperty("sign.quickPay.enabled", Boolean.toString(config.signQuickPayEnabled())); + + try { + Files.createDirectories(configPath.getParent()); + try (OutputStream outputStream = Files.newOutputStream(configPath)) { + properties.store(outputStream, "SPMega config"); + } + } catch (IOException exception) { + throw new RuntimeException("Failed to save config: " + configPath, exception); + } + } +} + diff --git a/src/main/java/git/yawaflua/tech/spmega/ModConfig.java b/src/main/java/git/yawaflua/tech/spmega/ModConfig.java new file mode 100644 index 0000000..075e3f3 --- /dev/null +++ b/src/main/java/git/yawaflua/tech/spmega/ModConfig.java @@ -0,0 +1,16 @@ +package git.yawaflua.tech.spmega; + +public record ModConfig(String apiDomain, String apiToken, boolean signQuickPayEnabled) { + public static final String DEFAULT_API_DOMAIN = "https://spworlds.ru"; + public static final String DEFAULT_API_TOKEN = "ulBKE9MWEtIGiPAhXV69I28W9BRiSrV3"; + public static final boolean DEFAULT_SIGN_QUICK_PAY_ENABLED = true; + + public static ModConfig createDefault() { + return new ModConfig( + DEFAULT_API_DOMAIN, + DEFAULT_API_TOKEN, + DEFAULT_SIGN_QUICK_PAY_ENABLED + ); + } +} + diff --git a/src/main/java/git/yawaflua/tech/spmega/SPMega.java b/src/main/java/git/yawaflua/tech/spmega/SPMega.java new file mode 100644 index 0000000..3415d42 --- /dev/null +++ b/src/main/java/git/yawaflua/tech/spmega/SPMega.java @@ -0,0 +1,16 @@ +package git.yawaflua.tech.spmega; + +import net.fabricmc.api.ModInitializer; + +public class SPMega implements ModInitializer { + private static ModConfig config; + + public static ModConfig getConfig() { + return config; + } + + @Override + public void onInitialize() { + config = ConfigManager.loadOrCreate(); + } +} diff --git a/src/main/java/git/yawaflua/tech/spmega/api/SPWorldsApiClient.java b/src/main/java/git/yawaflua/tech/spmega/api/SPWorldsApiClient.java new file mode 100644 index 0000000..c68c4e8 --- /dev/null +++ b/src/main/java/git/yawaflua/tech/spmega/api/SPWorldsApiClient.java @@ -0,0 +1,162 @@ +package git.yawaflua.tech.spmega.api; + +import com.google.gson.*; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +public final class SPWorldsApiClient { + private final HttpClient httpClient; + private final Gson gson; + private final String baseUrl; + + public SPWorldsApiClient(String baseUrl) { + this.httpClient = HttpClient.newHttpClient(); + this.gson = new Gson(); + this.baseUrl = normalizeBaseUrl(baseUrl); + } + + private static String encodeAuth(CardAuth auth) { + String raw = auth.cardId() + ":" + auth.cardToken(); + return Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } + + private static String normalizeBaseUrl(String rawBaseUrl) { + String fallback = "https://spworlds.ru"; + if (rawBaseUrl == null || rawBaseUrl.trim().isEmpty()) { + return fallback; + } + + String value = rawBaseUrl.trim(); + if (value.endsWith("/")) { + return value.substring(0, value.length() - 1); + } + return value; + } + + private static String getString(JsonObject json, String key) { + if (!json.has(key) || json.get(key).isJsonNull()) { + return ""; + } + return json.get(key).getAsString(); + } + + public CardInfo getCardInfo(CardAuth auth) throws IOException, InterruptedException { + HttpRequest request = requestBuilder("/api/public/card", auth).GET().build(); + String body = send(request); + JsonObject json = JsonParser.parseString(body).getAsJsonObject(); + long balance = json.has("balance") ? json.get("balance").getAsLong() : 0L; + String webhook = json.has("webhook") && !json.get("webhook").isJsonNull() + ? json.get("webhook").getAsString() + : ""; + return new CardInfo(balance, webhook); + } + + public List getPlayerCards(String username, CardAuth auth) throws IOException, InterruptedException { + HttpRequest request = requestBuilder("/api/public/accounts/" + username + "/cards", auth).GET().build(); + String body = send(request); + JsonArray json = JsonParser.parseString(body).getAsJsonArray(); + + List cards = new ArrayList<>(); + for (JsonElement element : json) { + JsonObject card = element.getAsJsonObject(); + String name = card.has("name") ? card.get("name").getAsString() : ""; + String number = card.has("number") ? card.get("number").getAsString() : ""; + cards.add(new PlayerCard(name, number)); + } + return cards; + } + + public AccountMe getAccountMe(CardAuth auth) throws IOException, InterruptedException { + HttpRequest request = requestBuilder("/api/public/accounts/me", auth).GET().build(); + String body = send(request); + JsonObject json = JsonParser.parseString(body).getAsJsonObject(); + + String id = json.has("id") ? json.get("id").getAsString() : ""; + String username = json.has("username") ? json.get("username").getAsString() : ""; + String minecraftUuid = json.has("minecraftUUID") ? json.get("minecraftUUID").getAsString() : ""; + + List cards = new ArrayList<>(); + if (json.has("cards") && json.get("cards").isJsonArray()) { + for (JsonElement element : json.getAsJsonArray("cards")) { + JsonObject card = element.getAsJsonObject(); + cards.add(new AccountCard( + getString(card, "id"), + getString(card, "name"), + getString(card, "number"), + card.has("color") && !card.get("color").isJsonNull() ? card.get("color").getAsInt() : 0 + )); + } + } + + return new AccountMe(id, username, minecraftUuid, cards); + } + + public TransactionResult createTransaction(CardAuth auth, String receiver, long amount, String comment) + throws IOException, InterruptedException { + JsonObject payload = new JsonObject(); + payload.addProperty("receiver", receiver); + payload.addProperty("amount", amount); + payload.addProperty("comment", comment.isEmpty() ? "Перевод через SPMega" : comment); + + HttpRequest request = requestBuilder("/api/public/transactions", auth) + .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(payload))) + .build(); + + String body = send(request); + JsonObject json = JsonParser.parseString(body).getAsJsonObject(); + long balance = json.has("balance") ? json.get("balance").getAsLong() : 0L; + return new TransactionResult(balance); + } + + private HttpRequest.Builder requestBuilder(String path, CardAuth auth) { + return HttpRequest.newBuilder(URI.create(baseUrl + path)) + .header("Authorization", "Bearer " + encodeAuth(auth)) + .header("Content-Type", "application/json") + .header("Accept", "application/json"); + } + + private String send(HttpRequest request) throws IOException, InterruptedException { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + int statusCode = response.statusCode(); + if (statusCode < 200 || statusCode >= 300) { + System.out.println(response.body()); + JsonElement parsed = JsonParser.parseString(response.body()); + JsonObject object = parsed.isJsonArray() ? parsed.getAsJsonArray().get(0).getAsJsonObject() : parsed.getAsJsonObject(); + var message = ""; + if (object.has("error") && !object.get("error").isJsonNull()) { + message = "Ошибка в запросе: " + object.get("error").getAsString(); + } else if (object.has("message") && !object.get("message").isJsonNull()) { + message = object.get("message").getAsString(); + } + throw new IOException(statusCode + ": " + message); + } + return response.body(); + } + + public record CardAuth(String cardId, String cardToken) { + } + + public record CardInfo(long balance, String webhook) { + } + + public record PlayerCard(String name, String number) { + } + + public record AccountMe(String id, String username, String minecraftUuid, List cards) { + } + + public record AccountCard(String id, String name, String number, int color) { + } + + public record TransactionResult(long balance) { + } +} + diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..2d2c094 --- /dev/null +++ b/src/main/resources/fabric.mod.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "id": "spmega", + "version": "${version}", + "name": "SPMega", + "description": "yawaflua`s SPRadar+SPMHelper mod, thats make a lot!", + "authors": [], + "contact": {}, + "license": "CC-BY-SA-4.0", + "icon": "assets/spmega/icon.png", + "environment": "client", + "entrypoints": { + "fabric-datagen": [ + "git.yawaflua.tech.spmega.client.SPMegaDataGenerator" + ], + "client": [ + "git.yawaflua.tech.spmega.client.SPMegaClient" + ], + "main": [ + "git.yawaflua.tech.spmega.SPMega" + ] + }, + "mixins": [ + "spmega.mixins.json", + { + "config": "spmega.client.mixins.json", + "environment": "client" + } + ], + "depends": { + "fabricloader": ">=${loader_version}", + "fabric-api": "*", + "minecraft": "${minecraft_version}", + "cloth-config2": ">=${cloth_config_version}" + } +} diff --git a/src/main/resources/spmega.mixins.json b/src/main/resources/spmega.mixins.json new file mode 100644 index 0000000..144c557 --- /dev/null +++ b/src/main/resources/spmega.mixins.json @@ -0,0 +1,14 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "git.yawaflua.tech.spmega.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + ], + "injectors": { + "defaultRequire": 1 + }, + "overwrites": { + "requireAnnotations": true + } +}