commit 910c58c30be181e437eecdaaec5337269ed9eb85 Author: arkonsadter Date: Sun Jun 21 14:31:38 2026 +0600 Add Fabric client IRC chat mod and Drone pipeline diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..d652bcd --- /dev/null +++ b/.drone.yml @@ -0,0 +1,18 @@ +kind: pipeline +type: docker +name: build + +trigger: + event: + - push + - pull_request + +steps: + - name: gradle-build + image: gradle:8.10.2-jdk21 + environment: + GRADLE_USER_HOME: /drone/src/.gradle + commands: + - gradle --no-daemon clean build + - ls -la build/libs + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3f5857 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.gradle/ +build/ +out/ +run/ +*.iml +.idea/ +*.class + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a58a46d --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +# Client IRC Chat + +Клиентский Fabric-мод, который перехватывает сообщения вида `.irc <текст>` и отправляет их на отдельный TCP IRC-подобный сервер. + +Формат сообщения в чате клиента: + +`НАЗВАНИЕ-КЛИЕНТА >> Ник-игрока: сообщение` + +Пример: + +`MY-CLIENT >> Arkon: Привет` + +## Что входит + +- клиентский Fabric-мод; +- команда `.irc <сообщение>` через обычный чат Minecraft; +- служебные команды `.irc status`, `.irc reconnect`, `.irc reload`; +- пример простого IRC-сервера на Node.js без внешних зависимостей; +- `/.drone.yml` для сборки через Drone CI. + +## Важное допущение по версии + +Я заложил сборку под связку: + +- Minecraft `1.21.6` +- Fabric Loader `0.16.14` +- Fabric API `0.126.2+1.21.6` + +Если под "Fabric 26.2" ты имел в виду другую точную версию, поправь [gradle.properties](/D:/Desktop/gdfg/gradle.properties) и [src/main/resources/fabric.mod.json](/D:/Desktop/gdfg/src/main/resources/fabric.mod.json). + +## Команды в игре + +- `.irc Привет` - отправить сообщение на IRC-сервер +- `.irc status` - показать состояние подключения +- `.irc reconnect` - переподключиться к IRC-серверу +- `.irc reload` - перечитать конфиг из файла + +## Конфиг клиента + +После первого запуска мод создаст: + +`config/clientirc.json` + +Пример: + +```json +{ + "host": "127.0.0.1", + "port": 24469, + "clientName": "MY-CLIENT", + "token": "", + "autoConnect": true, + "connectTimeoutMs": 5000 +} +``` + +Поля: + +- `host` - адрес IRC-сервера +- `port` - порт IRC-сервера +- `clientName` - то самое `НАЗВАНИЕ-КЛИЕНТА` +- `token` - общий токен, если хочешь ограничить подключение +- `autoConnect` - подключаться автоматически при старте клиента + +## Как поднять сервер для IRC-чата + +В репозитории уже есть пример сервера: [server/irc-server.js](/D:/Desktop/gdfg/server/irc-server.js) + +### 1. Установи Node.js + +Нужен Node.js 20+. + +### 2. Настрой сервер + +Файл конфига: [server/config.json](/D:/Desktop/gdfg/server/config.json) + +Пример: + +```json +{ + "host": "0.0.0.0", + "port": 24469, + "token": "secret-token" +} +``` + +Если укажешь `token`, такой же токен нужно прописать в `config/clientirc.json` на клиентах. + +### 3. Запусти сервер + +```bash +cd server +node irc-server.js +``` + +### 4. Открой порт + +Если сервер не локальный, открой TCP-порт `24469` в фаерволе и пробрось его на роутере/VPS. + +### 5. Подключи клиентов + +На каждом клиенте укажи: + +- `host` - IP или домен сервера +- `port` - тот же порт +- `clientName` - имя клиента +- `token` - если используется + +После этого в игре можно писать: + +```text +.irc Привет +``` + +И все подключенные клиенты увидят: + +```text +[IRC] MY-CLIENT >> Arkon: Привет +``` + +## Сборка мода + +Для локальной сборки нужен Gradle 8.10+ и Java 21. + +```bash +gradle build +``` + +Артефакт будет в: + +`build/libs/` + +## Drone CI + +Файл пайплайна: [/.drone.yml](/D:/Desktop/gdfg/.drone.yml) + +Он делает: + +- запуск Docker pipeline; +- `gradle clean build`; +- вывод содержимого `build/libs`. + +Для работы Drone runner должен иметь доступ в интернет, чтобы скачать зависимости Gradle/Fabric. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..6ead99f --- /dev/null +++ b/build.gradle @@ -0,0 +1,55 @@ +plugins { + id 'fabric-loom' version '1.10-SNAPSHOT' + id 'maven-publish' +} + +version = project.mod_version +group = project.maven_group + +base { + archivesName = project.archives_base_name +} + +repositories { + mavenCentral() + maven { + url = 'https://maven.fabricmc.net/' + } +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2" + modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" +} + +processResources { + inputs.property 'version', project.version + + filesMatching('fabric.mod.json') { + expand 'version': project.version + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.release = 21 +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + withSourcesJar() +} + +publishing { + publications { + mavenJava(MavenPublication) { + from components.java + } + } + + repositories { + } +} + diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..6e8c7c5 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,14 @@ +org.gradle.jvmargs=-Xmx2G +org.gradle.parallel=true + +# Assumption: "Fabric 26.2" refers to Fabric API 0.126.2 for Minecraft 1.21.6. +# If your target differs, update the four values below together. +minecraft_version=1.21.6 +yarn_mappings=1.21.6+build.1 +loader_version=0.16.14 +fabric_version=0.126.2+1.21.6 + +mod_version=1.0.0 +maven_group=ru.nevetime +archives_base_name=client-irc-chat + diff --git a/server/config.json b/server/config.json new file mode 100644 index 0000000..6c326ce --- /dev/null +++ b/server/config.json @@ -0,0 +1,6 @@ +{ + "host": "0.0.0.0", + "port": 24469, + "token": "" +} + diff --git a/server/irc-server.js b/server/irc-server.js new file mode 100644 index 0000000..b4c2146 --- /dev/null +++ b/server/irc-server.js @@ -0,0 +1,132 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const net = require('node:net'); + +const configPath = path.join(__dirname, 'config.json'); +const defaultConfig = { + host: '0.0.0.0', + port: 24469, + token: '' +}; + +if (!fs.existsSync(configPath)) { + fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2)); +} + +const config = { + ...defaultConfig, + ...JSON.parse(fs.readFileSync(configPath, 'utf8')) +}; + +const clients = new Set(); + +function sendJson(socket, payload) { + socket.write(JSON.stringify(payload) + '\n'); +} + +function broadcast(payload) { + const line = JSON.stringify(payload) + '\n'; + for (const socket of clients) { + if (!socket.destroyed) { + socket.write(line); + } + } +} + +function systemMessage(message) { + return { + type: 'system', + message, + timestamp: Date.now() + }; +} + +const server = net.createServer((socket) => { + socket.setEncoding('utf8'); + clients.add(socket); + let buffer = ''; + + sendJson(socket, systemMessage('Connected to IRC server.')); + console.log(`[connect] ${socket.remoteAddress}:${socket.remotePort}`); + + socket.on('data', (chunk) => { + buffer += chunk; + + let separatorIndex = buffer.indexOf('\n'); + while (separatorIndex !== -1) { + const rawLine = buffer.slice(0, separatorIndex).trim(); + buffer = buffer.slice(separatorIndex + 1); + separatorIndex = buffer.indexOf('\n'); + + if (!rawLine) { + continue; + } + + let payload; + try { + payload = JSON.parse(rawLine); + } catch (error) { + sendJson(socket, systemMessage(`Bad JSON: ${error.message}`)); + continue; + } + + if (config.token && payload.token !== config.token) { + sendJson(socket, systemMessage('Invalid token.')); + continue; + } + + if (payload.type === 'hello') { + const name = payload.player || 'unknown'; + sendJson(socket, systemMessage(`Hello, ${name}.`)); + continue; + } + + if (payload.type === 'chat') { + const clientName = String(payload.client || 'UNKNOWN-CLIENT').trim(); + const playerName = String(payload.player || 'unknown').trim(); + const message = String(payload.message || '').trim(); + + if (!message) { + sendJson(socket, systemMessage('Empty chat message ignored.')); + continue; + } + + const outgoing = { + type: 'chat', + client: clientName, + player: playerName, + message, + timestamp: Date.now() + }; + + console.log(`[chat] ${clientName} >> ${playerName}: ${message}`); + broadcast(outgoing); + continue; + } + + sendJson(socket, systemMessage(`Unknown packet type: ${payload.type}`)); + } + }); + + socket.on('close', () => { + clients.delete(socket); + console.log(`[disconnect] ${socket.remoteAddress}:${socket.remotePort}`); + }); + + socket.on('error', (error) => { + clients.delete(socket); + console.error(`[socket-error] ${socket.remoteAddress}:${socket.remotePort} ${error.message}`); + }); +}); + +server.on('error', (error) => { + console.error(`[server-error] ${error.message}`); + process.exitCode = 1; +}); + +server.listen(config.port, config.host, () => { + console.log(`IRC server listening on ${config.host}:${config.port}`); +}); + diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..2166e33 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + maven { + url = 'https://maven.fabricmc.net/' + } + gradlePluginPortal() + mavenCentral() + } +} + +rootProject.name = 'client-irc-chat' + diff --git a/src/main/java/ru/nevetime/clientirc/ClientIrcMod.java b/src/main/java/ru/nevetime/clientirc/ClientIrcMod.java new file mode 100644 index 0000000..e8ba116 --- /dev/null +++ b/src/main/java/ru/nevetime/clientirc/ClientIrcMod.java @@ -0,0 +1,108 @@ +package ru.nevetime.clientirc; + +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.message.v1.ClientSendMessageEvents; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.MinecraftClient; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class ClientIrcMod implements ClientModInitializer { + public static final String MOD_ID = "clientirc"; + public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); + + private static final String COMMAND_PREFIX = ".irc"; + + private static IrcConfig config; + private static IrcService service; + + @Override + public void onInitializeClient() { + config = IrcConfig.load(FabricLoader.getInstance().getConfigDir().resolve("clientirc.json")); + service = new IrcService(config, LOGGER); + + ClientLifecycleEvents.CLIENT_STARTED.register(client -> { + if (config.autoConnect) { + service.connect(resolvePlayerName(client)); + } + }); + + ClientTickEvents.END_CLIENT_TICK.register(client -> { + IrcService.DisplayLine line; + while ((line = service.pollLine()) != null) { + Formatting color = line.system() ? Formatting.GRAY : Formatting.AQUA; + pushClientChat(client, Text.literal(line.text()).formatted(color)); + } + }); + + ClientSendMessageEvents.ALLOW_CHAT.register(message -> !interceptOutgoingChat(message)); + } + + private static boolean interceptOutgoingChat(String rawMessage) { + String trimmed = rawMessage.trim(); + if (!trimmed.startsWith(COMMAND_PREFIX)) { + return false; + } + + MinecraftClient client = MinecraftClient.getInstance(); + String argument = trimmed.length() == COMMAND_PREFIX.length() ? "" : trimmed.substring(COMMAND_PREFIX.length()).trim(); + + if (argument.isBlank()) { + pushClientChat(client, Text.literal("[IRC] Usage: .irc | .irc status | .irc reconnect | .irc reload").formatted(Formatting.YELLOW)); + return true; + } + + if ("status".equalsIgnoreCase(argument)) { + String status = service.isConnected() ? "connected" : "disconnected"; + pushClientChat(client, Text.literal("[IRC] " + status + " | " + config.host + ":" + config.port + " | clientName=" + config.clientName).formatted(Formatting.YELLOW)); + return true; + } + + if ("reconnect".equalsIgnoreCase(argument)) { + boolean connected = service.reconnect(resolvePlayerName(client)); + Formatting color = connected ? Formatting.GREEN : Formatting.RED; + pushClientChat(client, Text.literal("[IRC] reconnect " + (connected ? "ok" : "failed")).formatted(color)); + return true; + } + + if ("reload".equalsIgnoreCase(argument)) { + config = IrcConfig.load(FabricLoader.getInstance().getConfigDir().resolve("clientirc.json")); + service.updateConfig(config); + pushClientChat(client, Text.literal("[IRC] config reloaded").formatted(Formatting.YELLOW)); + return true; + } + + if (!service.ensureConnected(resolvePlayerName(client))) { + pushClientChat(client, Text.literal("[IRC] server is offline or config is wrong").formatted(Formatting.RED)); + return true; + } + + boolean sent = service.sendChat(resolvePlayerName(client), argument); + if (!sent) { + pushClientChat(client, Text.literal("[IRC] send failed").formatted(Formatting.RED)); + } + return true; + } + + private static void pushClientChat(MinecraftClient client, Text text) { + if (client == null) { + return; + } + client.execute(() -> client.inGameHud.getChatHud().addMessage(text)); + } + + private static String resolvePlayerName(MinecraftClient client) { + if (client != null && client.player != null) { + return client.player.getGameProfile().getName(); + } + if (client != null && client.getSession() != null) { + return client.getSession().getUsername(); + } + return "unknown"; + } +} + diff --git a/src/main/java/ru/nevetime/clientirc/IrcConfig.java b/src/main/java/ru/nevetime/clientirc/IrcConfig.java new file mode 100644 index 0000000..2a55027 --- /dev/null +++ b/src/main/java/ru/nevetime/clientirc/IrcConfig.java @@ -0,0 +1,78 @@ +package ru.nevetime.clientirc; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +public final class IrcConfig { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + public String host = "127.0.0.1"; + public int port = 24469; + public String clientName = "MY-CLIENT"; + public String token = ""; + public boolean autoConnect = true; + public int connectTimeoutMs = 5000; + + public static IrcConfig load(Path path) { + try { + Files.createDirectories(path.getParent()); + if (Files.notExists(path)) { + IrcConfig config = new IrcConfig(); + config.save(path); + return config; + } + + try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + IrcConfig config = GSON.fromJson(reader, IrcConfig.class); + if (config == null) { + config = new IrcConfig(); + } + config.normalize(); + config.save(path); + return config; + } + } catch (IOException | JsonParseException exception) { + ClientIrcMod.LOGGER.error("Failed to load IRC config from {}", path, exception); + IrcConfig fallback = new IrcConfig(); + try { + fallback.save(path); + } catch (IOException ioException) { + ClientIrcMod.LOGGER.error("Failed to save fallback IRC config to {}", path, ioException); + } + return fallback; + } + } + + public void save(Path path) throws IOException { + Files.createDirectories(path.getParent()); + try (Writer writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) { + GSON.toJson(this, writer); + } + } + + public void normalize() { + if (host == null || host.isBlank()) { + host = "127.0.0.1"; + } + if (port <= 0 || port > 65535) { + port = 24469; + } + if (clientName == null || clientName.isBlank()) { + clientName = "MY-CLIENT"; + } + if (token == null) { + token = ""; + } + if (connectTimeoutMs < 1000) { + connectTimeoutMs = 5000; + } + } +} + diff --git a/src/main/java/ru/nevetime/clientirc/IrcMessage.java b/src/main/java/ru/nevetime/clientirc/IrcMessage.java new file mode 100644 index 0000000..14f39a6 --- /dev/null +++ b/src/main/java/ru/nevetime/clientirc/IrcMessage.java @@ -0,0 +1,32 @@ +package ru.nevetime.clientirc; + +public final class IrcMessage { + public String type; + public String client; + public String player; + public String message; + public String token; + public long timestamp; + + public static IrcMessage hello(String clientName, String playerName, String token) { + IrcMessage message = new IrcMessage(); + message.type = "hello"; + message.client = clientName; + message.player = playerName; + message.token = token; + message.timestamp = System.currentTimeMillis(); + return message; + } + + public static IrcMessage chat(String clientName, String playerName, String text, String token) { + IrcMessage message = new IrcMessage(); + message.type = "chat"; + message.client = clientName; + message.player = playerName; + message.message = text; + message.token = token; + message.timestamp = System.currentTimeMillis(); + return message; + } +} + diff --git a/src/main/java/ru/nevetime/clientirc/IrcService.java b/src/main/java/ru/nevetime/clientirc/IrcService.java new file mode 100644 index 0000000..9bff48c --- /dev/null +++ b/src/main/java/ru/nevetime/clientirc/IrcService.java @@ -0,0 +1,206 @@ +package ru.nevetime.clientirc; + +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import java.util.concurrent.ConcurrentLinkedQueue; +import org.slf4j.Logger; + +public final class IrcService { + private static final Gson GSON = new Gson(); + + private final Logger logger; + private final ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue<>(); + + private volatile IrcConfig config; + private volatile Socket socket; + private volatile BufferedWriter writer; + private volatile BufferedReader reader; + private volatile boolean running; + private volatile Thread readerThread; + + public IrcService(IrcConfig config, Logger logger) { + this.config = Objects.requireNonNull(config, "config"); + this.logger = Objects.requireNonNull(logger, "logger"); + } + + public synchronized void updateConfig(IrcConfig config) { + this.config = Objects.requireNonNull(config, "config"); + } + + public synchronized boolean ensureConnected(String playerName) { + return isConnected() || connect(playerName); + } + + public synchronized boolean connect(String playerName) { + if (isConnected()) { + return true; + } + + Socket newSocket = new Socket(); + BufferedWriter newWriter = null; + BufferedReader newReader = null; + + try { + newSocket.connect(new InetSocketAddress(config.host, config.port), config.connectTimeoutMs); + newSocket.setTcpNoDelay(true); + + newWriter = new BufferedWriter(new OutputStreamWriter(newSocket.getOutputStream(), StandardCharsets.UTF_8)); + newReader = new BufferedReader(new InputStreamReader(newSocket.getInputStream(), StandardCharsets.UTF_8)); + + socket = newSocket; + writer = newWriter; + reader = newReader; + running = true; + + startReaderThread(newReader); + sendInternal(IrcMessage.hello(config.clientName, playerName, config.token)); + enqueueSystem("Connected to " + config.host + ":" + config.port); + return true; + } catch (IOException exception) { + closeQuietly(newReader); + closeQuietly(newWriter); + closeQuietly(newSocket); + enqueueSystem("Connection failed: " + exception.getMessage()); + logger.warn("IRC connection failed", exception); + return false; + } + } + + public synchronized boolean reconnect(String playerName) { + disconnect(); + return connect(playerName); + } + + public synchronized void disconnect() { + running = false; + closeSocketState(); + } + + public synchronized boolean sendChat(String playerName, String text) { + if (!isConnected()) { + return false; + } + + try { + sendInternal(IrcMessage.chat(config.clientName, playerName, text, config.token)); + return true; + } catch (IOException exception) { + enqueueSystem("Send failed: " + exception.getMessage()); + logger.warn("Failed to send IRC message", exception); + running = false; + closeSocketState(); + return false; + } + } + + public synchronized boolean isConnected() { + return socket != null && socket.isConnected() && !socket.isClosed() && writer != null; + } + + public DisplayLine pollLine() { + return queue.poll(); + } + + private void startReaderThread(BufferedReader activeReader) { + Thread thread = new Thread(() -> readLoop(activeReader), "clientirc-reader"); + thread.setDaemon(true); + readerThread = thread; + thread.start(); + } + + private void readLoop(BufferedReader activeReader) { + try { + String line; + while (running && (line = activeReader.readLine()) != null) { + handleIncomingLine(line); + } + + if (running) { + enqueueSystem("Server closed the connection."); + } + } catch (IOException exception) { + if (running) { + enqueueSystem("Disconnected: " + exception.getMessage()); + logger.warn("IRC reader stopped", exception); + } + } finally { + running = false; + synchronized (this) { + closeSocketState(); + } + } + } + + private void handleIncomingLine(String rawLine) { + try { + IrcMessage message = GSON.fromJson(rawLine, IrcMessage.class); + if (message == null || message.type == null) { + return; + } + + if ("chat".equals(message.type)) { + String clientName = safeValue(message.client, "UNKNOWN-CLIENT"); + String playerName = safeValue(message.player, "unknown"); + String text = safeValue(message.message, ""); + queue.add(new DisplayLine("[IRC] " + clientName + " >> " + playerName + ": " + text, false)); + return; + } + + if ("system".equals(message.type) && message.message != null && !message.message.isBlank()) { + enqueueSystem(message.message); + } + } catch (JsonParseException exception) { + enqueueSystem("Malformed packet: " + rawLine); + logger.warn("Failed to parse IRC packet: {}", rawLine, exception); + } + } + + private void enqueueSystem(String text) { + queue.add(new DisplayLine("[IRC] " + text, true)); + } + + private void sendInternal(IrcMessage message) throws IOException { + writer.write(GSON.toJson(message)); + writer.newLine(); + writer.flush(); + } + + private synchronized void closeSocketState() { + closeQuietly(reader); + closeQuietly(writer); + closeQuietly(socket); + reader = null; + writer = null; + socket = null; + readerThread = null; + } + + private static void closeQuietly(Closeable closeable) { + if (closeable == null) { + return; + } + + try { + closeable.close(); + } catch (IOException ignored) { + } + } + + private static String safeValue(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + + public record DisplayLine(String text, boolean system) { + } +} + diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..2562f8b --- /dev/null +++ b/src/main/resources/fabric.mod.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "id": "clientirc", + "version": "${version}", + "name": "Client IRC Chat", + "description": "Client-side Fabric mod that redirects .irc messages to a standalone IRC-like TCP chat server.", + "authors": [ + "Codex" + ], + "contact": {}, + "license": "MIT", + "environment": "client", + "entrypoints": { + "client": [ + "ru.nevetime.clientirc.ClientIrcMod" + ] + }, + "depends": { + "fabricloader": ">=0.16.14", + "minecraft": "~1.21.6", + "java": ">=21", + "fabric-api": "*" + } +} +