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
@@ -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
}
}