commit 790ca442b26f390fef35cad66f0a0908ea178386 Author: Alex Mickelson Date: Mon Mar 16 16:37:51 2026 -0600 initial 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": "*" + } +}