From 790ca442b26f390fef35cad66f0a0908ea178386 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Mon, 16 Mar 2026 16:37:51 -0600 Subject: [PATCH] initial --- .gitignore | 4 + build.gradle | 33 ++ build.sh | 24 ++ gradle.properties | 1 + jar.sh | 14 + javap.sh | 12 + settings.gradle | 8 + .../example/cobbleapi/CobbleBattleApiMod.java | 406 ++++++++++++++++++ src/main/resources/fabric.mod.json | 18 + 9 files changed, 520 insertions(+) create mode 100644 .gitignore create mode 100644 build.gradle create mode 100755 build.sh create mode 100644 gradle.properties create mode 100755 jar.sh create mode 100755 javap.sh create mode 100644 settings.gradle create mode 100644 src/main/java/com/example/cobbleapi/CobbleBattleApiMod.java create mode 100644 src/main/resources/fabric.mod.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8dad928 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +build/ +.gradle/ +gradle/ +.gradle_cache/ \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..ec1c5a2 --- /dev/null +++ b/build.gradle @@ -0,0 +1,33 @@ +plugins { + id 'fabric-loom' version '1.7-SNAPSHOT' + id 'maven-publish' +} + +group = 'com.example' +version = '1.0.0' + +repositories { + mavenCentral() + maven { url = "https://maven.fabricmc.net/" } + maven { url = "https://maven.architectury.dev/" } + maven { url = "https://maven.minecraftforge.net/" } + maven { url = "https://dl.cloudsmith.io/public/geckolib3/geckolib/maven/" } + maven { url = "https://maven.impactdev.net/repository/development/" } +} + +dependencies { + minecraft "com.mojang:minecraft:1.21.1" + mappings "net.fabricmc:yarn:1.21.1+build.3:v2" + modImplementation "net.fabricmc:fabric-loader:0.16.5" + + modImplementation "net.fabricmc.fabric-api:fabric-api:0.106.1+1.21.1" + + // Cobblemon + modImplementation "com.cobblemon:fabric:1.7.3+1.21.1" +} + +java { + withSourcesJar() + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..9be4375 --- /dev/null +++ b/build.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" + +IMAGE="gradle:8.7-jdk21" + +echo "=== Building with Docker ($IMAGE) ===" +echo "Project dir: $(pwd)" +echo "" + +docker run --rm \ + -v "$PWD":/project \ + -v "$PWD/.gradle_cache:/home/gradle/.gradle" \ + -w /project \ + "$IMAGE" \ + gradle build "$@" + +echo "" +echo "=== Build complete ===" +ls -lh build/libs/*.jar 2>/dev/null || echo "No jars found in build/libs/" + + +echo "" +echo "put the build/libs/cobblemon-battle-api-1.0.0.jar in the mods folder of your minecraft instance to use it" diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..4687f10 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +org.gradle.jvmargs=-Xmx2G diff --git a/jar.sh b/jar.sh new file mode 100755 index 0000000..bd6213e --- /dev/null +++ b/jar.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env nix-shell +#!nix-shell -i bash -p jdk17 + +set -euo pipefail + +if [ $# -eq 0 ]; then + echo "Usage: ./jar.sh " + echo "Examples:" + echo " ./jar.sh tf some.jar # list contents" + echo " ./jar.sh tf some.jar | grep ClassName # find a class" + exit 1 +fi + +jar "$@" diff --git a/javap.sh b/javap.sh new file mode 100755 index 0000000..7d06a7d --- /dev/null +++ b/javap.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env nix-shell +#!nix-shell -i bash -p jdk17 + +set -euo pipefail + +if [ $# -eq 0 ]; then + echo "Usage: ./javap.sh " + echo "Example: ./javap.sh com.cobblemon.mod.common.battles.BattleRegistry some.jar" + exit 1 +fi + +javap -classpath "$2" "$1" diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..e65ddd6 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,8 @@ +pluginManagement { + repositories { + maven { url = "https://maven.fabricmc.net/" } + gradlePluginPortal() + } +} + +rootProject.name = "cobblemon-battle-api" diff --git a/src/main/java/com/example/cobbleapi/CobbleBattleApiMod.java b/src/main/java/com/example/cobbleapi/CobbleBattleApiMod.java new file mode 100644 index 0000000..a053e99 --- /dev/null +++ b/src/main/java/com/example/cobbleapi/CobbleBattleApiMod.java @@ -0,0 +1,406 @@ +package com.example.cobbleapi; + +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpExchange; + +import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; + +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; + +import com.cobblemon.mod.common.api.battles.model.PokemonBattle; +import com.cobblemon.mod.common.api.battles.model.actor.BattleActor; +import com.cobblemon.mod.common.battles.BattleRegistry; +import com.cobblemon.mod.common.battles.ActiveBattlePokemon; +import com.cobblemon.mod.common.battles.actor.PlayerBattleActor; +import com.cobblemon.mod.common.api.storage.party.PlayerPartyStore; +import com.cobblemon.mod.common.api.storage.pc.PCStore; +import com.cobblemon.mod.common.Cobblemon; +import com.cobblemon.mod.common.pokemon.Pokemon; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Fabric server-side mod that exposes Cobblemon battle and Pokémon data + * over a lightweight HTTP API. + * + * Endpoints: + * GET /player/ — is the player in a battle? + * GET /player//battle — full battle state for that player + * GET /player//party — player's party Pokémon + * GET /player//pc — player's PC Pokémon + * GET /battles — list all active battles + */ +public class CobbleBattleApiMod implements ModInitializer { + + private static final Logger LOGGER = LoggerFactory.getLogger("CobbleBattleAPI"); + private static final int PORT = 8085; + + private volatile MinecraftServer server; + private HttpServer httpServer; + + @Override + public void onInitialize() { + + ServerLifecycleEvents.SERVER_STARTED.register(mc -> { + this.server = mc; + startHttpServer(); + }); + + ServerLifecycleEvents.SERVER_STOPPING.register(mc -> { + stopHttpServer(); + this.server = null; + }); + } + + // ─── HTTP server lifecycle ────────────────────────────────────────── + + private void startHttpServer() { + try { + httpServer = HttpServer.create(new InetSocketAddress(PORT), 0); + httpServer.createContext("/player", this::handlePlayer); + httpServer.createContext("/battles", this::handleBattles); + httpServer.start(); + LOGGER.info("CobbleBattleAPI listening on port {}", PORT); + } catch (IOException e) { + LOGGER.error("Failed to start CobbleBattleAPI HTTP server", e); + } + } + + private void stopHttpServer() { + if (httpServer != null) { + httpServer.stop(0); + LOGGER.info("CobbleBattleAPI HTTP server stopped"); + } + } + + // ─── /player/[/battle|/party|/pc] ───────────────────────────── + + private void handlePlayer(HttpExchange exchange) throws IOException { + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + send(exchange, 405, json("error", "method not allowed")); + return; + } + + String path = exchange.getRequestURI().getPath(); // e.g. /player//battle + String[] parts = path.split("/"); // ["", "player", "", ...] + + if (parts.length < 3) { + send(exchange, 400, json("error", "missing uuid")); + return; + } + + UUID uuid; + try { + uuid = UUID.fromString(parts[2]); + } catch (IllegalArgumentException e) { + send(exchange, 400, json("error", "invalid uuid")); + return; + } + + // We need to touch the MC server on the server thread. + String subRoute = parts.length >= 4 ? parts[3] : ""; + String response = runOnServerThread(() -> dispatch(uuid, subRoute)); + + if (response == null) { + send(exchange, 500, json("error", "server unavailable")); + return; + } + + send(exchange, 200, response); + } + + private String dispatch(UUID uuid, String subRoute) { + ServerPlayerEntity player = server.getPlayerManager().getPlayer(uuid); + if (player == null) { + return json("error", "player not online"); + } + + return switch (subRoute) { + case "battle" -> buildBattleState(uuid, player); + case "party" -> buildParty(uuid); + case "pc" -> buildPc(uuid); + default -> buildBasicStatus(uuid, player); + }; + } + + // ─── /battles ─────────────────────────────────────────────────────── + + private void handleBattles(HttpExchange exchange) throws IOException { + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + send(exchange, 405, json("error", "method not allowed")); + return; + } + + String response = runOnServerThread(this::buildAllBattles); + if (response == null) { + send(exchange, 500, json("error", "server unavailable")); + return; + } + send(exchange, 200, response); + } + + // ─── Response builders ────────────────────────────────────────────── + + /** + * GET /player/ → { uuid, inBattle } + */ + private String buildBasicStatus(UUID uuid, ServerPlayerEntity player) { + boolean inBattle = findBattle(uuid) != null; + StringBuilder sb = new StringBuilder(); + sb.append('{'); + jsonField(sb, "uuid", uuid.toString()); + sb.append(','); + jsonFieldRaw(sb, "inBattle", String.valueOf(inBattle)); + sb.append('}'); + return sb.toString(); + } + + /** + * GET /player//battle → full battle snapshot + */ + private String buildBattleState(UUID uuid, ServerPlayerEntity player) { + PokemonBattle battle = findBattle(uuid); + if (battle == null) { + return json("error", "not in battle"); + } + + StringBuilder sb = new StringBuilder(); + sb.append('{'); + jsonField(sb, "battleId", battle.getBattleId().toString()); + sb.append(','); + + // actors + sb.append("\"actors\":["); + boolean first = true; + for (BattleActor actor : battle.getActors()) { + if (!first) sb.append(','); + first = false; + sb.append('{'); + jsonField(sb, "name", actor.getName().getString()); + sb.append(','); + + String type = (actor instanceof PlayerBattleActor) ? "player" : "npc"; + jsonField(sb, "type", type); + sb.append(','); + + // active pokémon + sb.append("\"activePokemon\":["); + boolean afirst = true; + for (ActiveBattlePokemon abp : actor.getActivePokemon()) { + if (abp.getBattlePokemon() == null) continue; + if (!afirst) sb.append(','); + afirst = false; + var bp = abp.getBattlePokemon(); + sb.append('{'); + jsonField(sb, "species", bp.getOriginalPokemon().getSpecies().getName()); + sb.append(','); + jsonFieldRaw(sb, "level", String.valueOf(bp.getOriginalPokemon().getLevel())); + sb.append(','); + jsonFieldRaw(sb, "hp", String.valueOf(bp.getHealth())); + sb.append(','); + jsonFieldRaw(sb, "maxHp", String.valueOf(bp.getMaxHealth())); + sb.append('}'); + } + sb.append(']'); + sb.append('}'); + } + sb.append(']'); + sb.append('}'); + return sb.toString(); + } + + /** + * GET /player//party → party pokémon list + */ + private String buildParty(UUID uuid) { + PlayerPartyStore party; + try { + party = Cobblemon.INSTANCE.getStorage().getParty(uuid); + } catch (Exception e) { + return json("error", "could not load party"); + } + StringBuilder sb = new StringBuilder(); + sb.append("{\"uuid\":\"").append(uuid).append("\",\"party\":["); + boolean first = true; + for (Pokemon p : party) { + if (!first) sb.append(','); + first = false; + appendPokemonJson(sb, p); + } + sb.append("]}"); + return sb.toString(); + } + + /** + * GET /player//pc → PC pokémon list + */ + private String buildPc(UUID uuid) { + PCStore pc; + try { + pc = Cobblemon.INSTANCE.getStorage().getPC(uuid); + } catch (Exception e) { + return json("error", "could not load pc"); + } + StringBuilder sb = new StringBuilder(); + sb.append("{\"uuid\":\"").append(uuid).append("\",\"pc\":["); + boolean first = true; + for (Pokemon p : pc) { + if (!first) sb.append(','); + first = false; + appendPokemonJson(sb, p); + } + sb.append("]}"); + return sb.toString(); + } + + /** + * GET /battles → all active battles + */ + private String buildAllBattles() { + // No getBattles() in this Cobblemon version — collect battles from online players + java.util.LinkedHashMap seen = new java.util.LinkedHashMap<>(); + for (ServerPlayerEntity p : server.getPlayerManager().getPlayerList()) { + PokemonBattle b = BattleRegistry.INSTANCE.getBattleByParticipatingPlayer(p); + if (b != null) { + seen.putIfAbsent(b.getBattleId(), b); + } + } + + StringBuilder sb = new StringBuilder(); + sb.append("{\"battles\":["); + boolean first = true; + for (PokemonBattle battle : seen.values()) { + if (!first) sb.append(','); + first = false; + sb.append('{'); + jsonField(sb, "battleId", battle.getBattleId().toString()); + sb.append(','); + sb.append("\"actors\":["); + boolean afirst = true; + for (BattleActor actor : battle.getActors()) { + if (!afirst) sb.append(','); + afirst = false; + sb.append('{'); + jsonField(sb, "name", actor.getName().getString()); + sb.append(','); + String type = (actor instanceof PlayerBattleActor) ? "player" : "npc"; + jsonField(sb, "type", type); + sb.append('}'); + } + sb.append(']'); + sb.append('}'); + } + sb.append("]}"); + return sb.toString(); + } + + // ─── Helpers ──────────────────────────────────────────────────────── + + private PokemonBattle findBattle(UUID playerUuid) { + return BattleRegistry.INSTANCE.getBattleByParticipatingPlayerId(playerUuid); + } + + private void appendPokemonJson(StringBuilder sb, Pokemon p) { + sb.append('{'); + jsonField(sb, "uuid", p.getUuid().toString()); + sb.append(','); + jsonField(sb, "species", p.getSpecies().getName()); + sb.append(','); + jsonFieldRaw(sb, "level", String.valueOf(p.getLevel())); + sb.append(','); + jsonFieldRaw(sb, "hp", String.valueOf(p.getCurrentHealth())); + sb.append(','); + jsonFieldRaw(sb, "maxHp", String.valueOf(p.getHp())); + sb.append(','); + jsonField(sb, "ability", p.getAbility().getName()); + sb.append(','); + jsonField(sb, "nature", p.getNature().getName().toString()); + sb.append(','); + jsonFieldRaw(sb, "shiny", String.valueOf(p.getShiny())); + + // moves + sb.append(",\"moves\":["); + boolean mfirst = true; + for (var move : p.getMoveSet()) { + if (!mfirst) sb.append(','); + mfirst = false; + sb.append('{'); + jsonField(sb, "name", move.getName()); + sb.append(','); + jsonFieldRaw(sb, "pp", String.valueOf(move.getCurrentPp())); + sb.append(','); + jsonFieldRaw(sb, "maxPp", String.valueOf(move.getMaxPp())); + sb.append('}'); + } + sb.append(']'); + + sb.append('}'); + } + + private static String json(String key, String value) { + return "{\"" + escapeJson(key) + "\":\"" + escapeJson(value) + "\"}"; + } + + private static void jsonField(StringBuilder sb, String key, String value) { + sb.append('"').append(escapeJson(key)).append("\":\"").append(escapeJson(value)).append('"'); + } + + private static void jsonFieldRaw(StringBuilder sb, String key, String rawValue) { + sb.append('"').append(escapeJson(key)).append("\":").append(rawValue); + } + + private static String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + /** + * Run a task on the MC server thread and wait for the result. + * Returns null if the server is unavailable. + */ + private String runOnServerThread(java.util.function.Supplier task) { + MinecraftServer mc = this.server; + if (mc == null) return null; + + CompletableFuture future = new CompletableFuture<>(); + mc.execute(() -> { + try { + future.complete(task.get()); + } catch (Exception e) { + LOGGER.error("Error processing API request", e); + future.complete(json("error", "internal error")); + } + }); + + try { + return future.get(5, java.util.concurrent.TimeUnit.SECONDS); + } catch (Exception e) { + LOGGER.error("Timeout waiting for server thread", e); + return null; + } + } + + private void send(HttpExchange exchange, int code, String body) throws IOException { + byte[] bytes = body.getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(code, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..6a9e664 --- /dev/null +++ b/src/main/resources/fabric.mod.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "id": "cobblebattleapi", + "version": "1.0.0", + "name": "Cobblemon Battle API", + "description": "HTTP API exposing Cobblemon battle state", + "authors": ["you"], + "environment": "server", + "entrypoints": { + "main": ["com.example.cobbleapi.CobbleBattleApiMod"] + }, + "depends": { + "fabricloader": ">=0.16", + "minecraft": "1.21.1", + "fabric-api": "*", + "cobblemon": "*" + } +}