Add initial implementation of SPMega mod with banking UI and API integration
Java CI / build (push) Successful in 5m38s

Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 2 hours 53 minutes
This commit is contained in:
Dmitrii `yawaflua` Shimanskii
2026-04-02 03:14:26 +03:00
committed by Dmitrii
commit 6c8c4065df
36 changed files with 2535 additions and 0 deletions
+102
View File
@@ -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
+28
View File
@@ -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
+21
View File
@@ -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
+23
View File
@@ -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.
+50
View File
@@ -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=<UUID карты>`
- `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
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

+115
View File
@@ -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.
}
}
+15
View File
@@ -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
Binary file not shown.
+7
View File
@@ -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
Vendored
+248
View File
@@ -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" "$@"
Vendored
+93
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
pluginManagement {
repositories {
maven {
name = 'Fabric'
url = 'https://maven.fabricmc.net/'
}
gradlePluginPortal()
}
}
@@ -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("(?<!\\d)(\\d{5})(?!\\d)");
private static KeyBinding openBankMenuKeyBinding;
private static String extractCardNumber(SignBlockEntity signBlockEntity) {
String candidate = findFiveDigits(signBlockEntity.getFrontText().getMessages(false));
if (candidate != null) {
return candidate;
}
return findFiveDigits(signBlockEntity.getBackText().getMessages(false));
}
private static String findFiveDigits(Text[] lines) {
for (Text line : lines) {
Matcher matcher = ISOLATED_FIVE_DIGITS.matcher(line.getString());
if (matcher.find()) {
return matcher.group(1);
}
}
return null;
}
@Override
public void onInitializeClient() {
openBankMenuKeyBinding = KeyBindingHelper.registerKeyBinding(new KeyBinding(
"key.spmega.open_menu",
InputUtil.Type.KEYSYM,
GLFW.GLFW_KEY_P,
KeyBinding.Category.GAMEPLAY
));
ClientTickEvents.END_CLIENT_TICK.register(client -> {
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;
});
}
}
@@ -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();
}
}
@@ -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<String> 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<String> 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;
});
}
}
@@ -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<ButtonWidget> 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<CardViewModel> 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<CardViewModel> 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;
}
}
}
@@ -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 "ночь";
}
}
@@ -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<ButtonWidget> recipientCardOptionButtons = new ArrayList<>();
private final List<String> 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();
}
}
@@ -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());
}
}
@@ -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));
}
}
@@ -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<StoredCard> loadCards() {
String sql = "SELECT card_id, card_token, card_name, card_number, balance, owner_uuid FROM cards ORDER BY updated_at DESC";
List<StoredCard> 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);
}
}
@@ -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<CardViewModel> 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<CardViewModel> 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<StoredCard> storedCards = database.loadCards();
for (StoredCard card : storedCards) {
refreshCard(card.cardId(), card.cardToken(), playerUuid, false, false);
}
reloadCardsFromDb();
}
public synchronized List<String> loadRecipientCards(String username) {
CardCredentials credentials = getSelectedCredentials();
if (credentials == null || username == null || username.isBlank()) {
return List.of();
}
try {
List<SPWorldsApiClient.PlayerCard> apiCards = apiClient.getPlayerCards(username, toApiAuth(credentials));
List<String> 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<StoredCard> 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());
}
}
@@ -0,0 +1,5 @@
package git.yawaflua.tech.spmega.client.ui.service;
public record CardCredentials(String cardId, String cardToken) {
}
@@ -0,0 +1,5 @@
package git.yawaflua.tech.spmega.client.ui.service;
public record CardViewModel(String id, String title, long balance) {
}
@@ -0,0 +1,5 @@
package git.yawaflua.tech.spmega.client.ui.service;
public record PaymentDraft(String senderCardId, String recipient, long amount, String comment) {
}
@@ -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) {
}
@@ -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());
}
}
@@ -0,0 +1,5 @@
{
"category.spmega": "SPMega",
"key.spmega.open_menu": "Open SPMega Menu"
}
@@ -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
}
}
@@ -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);
}
}
}
@@ -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
);
}
}
@@ -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();
}
}
@@ -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<PlayerCard> 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<PlayerCard> 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<AccountCard> 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<String> 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<AccountCard> cards) {
}
public record AccountCard(String id, String name, String number, int color) {
}
public record TransactionResult(long balance) {
}
}
+36
View File
@@ -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}"
}
}
+14
View File
@@ -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
}
}