This commit is contained in:
2026-03-16 16:37:51 -06:00
commit 790ca442b2
9 changed files with 520 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
build/
.gradle/
gradle/
.gradle_cache/

33
build.gradle Normal file
View File

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

24
build.sh Executable file
View File

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

1
gradle.properties Normal file
View File

@@ -0,0 +1 @@
org.gradle.jvmargs=-Xmx2G

14
jar.sh Executable file
View File

@@ -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 <jar-args...>"
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 "$@"

12
javap.sh Executable file
View File

@@ -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 <class-name> <jar-file>"
echo "Example: ./javap.sh com.cobblemon.mod.common.battles.BattleRegistry some.jar"
exit 1
fi
javap -classpath "$2" "$1"

8
settings.gradle Normal file
View File

@@ -0,0 +1,8 @@
pluginManagement {
repositories {
maven { url = "https://maven.fabricmc.net/" }
gradlePluginPortal()
}
}
rootProject.name = "cobblemon-battle-api"

View File

@@ -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/<uuid> — is the player in a battle?
* GET /player/<uuid>/battle — full battle state for that player
* GET /player/<uuid>/party — player's party Pokémon
* GET /player/<uuid>/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/<uuid>[/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/<uuid>/battle
String[] parts = path.split("/"); // ["", "player", "<uuid>", ...]
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> → { 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/<uuid>/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/<uuid>/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/<uuid>/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<UUID, PokemonBattle> 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<String> task) {
MinecraftServer mc = this.server;
if (mc == null) return null;
CompletableFuture<String> 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);
}
}
}

View File

@@ -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": "*"
}
}