diff --git a/.gitignore b/.gitignore index 8dad928..e30de7c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ build/ .gradle/ gradle/ -.gradle_cache/ \ No newline at end of file +.gradle_cache/ +COBBLEVERSE-1.7.30-CF.zip +.env +modpacks/ +minecraft-data/ +out.log \ No newline at end of file diff --git a/cobble-api.http b/cobble-api.http new file mode 100644 index 0000000..23f3452 --- /dev/null +++ b/cobble-api.http @@ -0,0 +1,3 @@ +@host = http://localhost:8085 + +GET {{host}}/battles diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..46866ee --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +services: + minecraft: + image: itzg/minecraft-server:java21 + stdin_open: true + tty: true + ports: + - "2222:2222" # Minecraft + - "25576:25576" # RCON + - "8085:8085" # Cobblemon Battle API + environment: + EULA: "true" + TYPE: "AUTO_CURSEFORGE" + CF_SLUG: "cobbleverse-cobblemon" + CF_MODPACK_ZIP: "/modpacks/COBBLEVERSE-1.7.30-CF.zip" + MEMORY: "4G" + SERVER_PORT: "2222" + RCON_PORT: "25576" + CF_OVERRIDES_EXCLUSIONS: | + shaderpacks/** + resourcepacks/** + env_file: + - .env + volumes: + - ./minecraft-data:/data + - ./COBBLEVERSE-1.7.30-CF.zip:/modpacks/COBBLEVERSE-1.7.30-CF.zip:ro + restart: unless-stopped diff --git a/src/main/java/com/example/cobbleapi/CobbleBattleApiMod.java b/src/main/java/com/example/cobbleapi/CobbleBattleApiMod.java index 25cddbb..f182412 100644 --- a/src/main/java/com/example/cobbleapi/CobbleBattleApiMod.java +++ b/src/main/java/com/example/cobbleapi/CobbleBattleApiMod.java @@ -15,15 +15,13 @@ 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.LinkedHashMap; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -31,15 +29,12 @@ 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. + * Fabric mod exposing a single HTTP endpoint: * - * 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 + * GET /battles — all active battles; each player actor includes their + * playerId, active Pokémon (no moves), and full party + * with moves. Opponent move sets are only present under + * that opponent's own actor block. */ public class CobbleBattleApiMod implements ModInitializer { @@ -51,12 +46,10 @@ public class CobbleBattleApiMod implements ModInitializer { @Override public void onInitialize() { - ServerLifecycleEvents.SERVER_STARTED.register(mc -> { this.server = mc; startHttpServer(); }); - ServerLifecycleEvents.SERVER_STOPPING.register(mc -> { stopHttpServer(); this.server = null; @@ -68,7 +61,6 @@ public class CobbleBattleApiMod implements ModInitializer { 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); @@ -84,64 +76,13 @@ public class CobbleBattleApiMod implements ModInitializer { } } - // ─── /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, player); - case "pc" -> buildPc(uuid, player); - default -> buildBasicStatus(uuid, player); - }; - } - - // ─── /battles ─────────────────────────────────────────────────────── + // ─── GET /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")); @@ -150,120 +91,15 @@ public class CobbleBattleApiMod implements ModInitializer { 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, ServerPlayerEntity player) { - PlayerPartyStore party = Cobblemon.INSTANCE.getStorage().getParty(player); - StringBuilder sb = new StringBuilder(); - sb.append("{\"uuid\":\"").append(uuid).append("\",\"party\":["); - boolean first = true; - for (Pokemon p : party.toGappyList()) { - if (p == null) continue; - 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, ServerPlayerEntity player) { - PCStore pc = Cobblemon.INSTANCE.getStorage().getPC(player); - StringBuilder sb = new StringBuilder(); - sb.append("{\"uuid\":\"").append(uuid).append("\",\"pc\":["); - boolean first = true; - for (com.cobblemon.mod.common.api.storage.pc.PCBox box : pc.getBoxes()) { - for (Pokemon p : box.getNonEmptySlots().values()) { - if (p == null) continue; - if (!first) sb.append(','); - first = false; - appendPokemonJson(sb, p); - } - } - sb.append("]}"); - return sb.toString(); - } - - /** - * GET /battles → all active battles + * Collects all active battles, deduplicating by battleId. + * Each PlayerBattleActor includes: + * - playerId, name, type + * - activePokemon: species/level/hp only (no moves — hides move info from opponent reads) + * - party: full Pokémon data including moves (keyed to this actor only) */ private String buildAllBattles() { - // No getBattles() in this Cobblemon version — collect battles from online players - java.util.LinkedHashMap seen = new java.util.LinkedHashMap<>(); + LinkedHashMap seen = new LinkedHashMap<>(); for (ServerPlayerEntity p : server.getPlayerManager().getPlayerList()) { PokemonBattle b = BattleRegistry.INSTANCE.getBattleByParticipatingPlayer(p); if (b != null) { @@ -273,38 +109,110 @@ public class CobbleBattleApiMod implements ModInitializer { StringBuilder sb = new StringBuilder(); sb.append("{\"battles\":["); - boolean first = true; + boolean firstBattle = true; + for (PokemonBattle battle : seen.values()) { - if (!first) sb.append(','); - first = false; + if (!firstBattle) sb.append(','); + firstBattle = false; + sb.append('{'); jsonField(sb, "battleId", battle.getBattleId().toString()); - sb.append(','); - sb.append("\"actors\":["); - boolean afirst = true; + sb.append(",\"actors\":["); + + boolean firstActor = true; for (BattleActor actor : battle.getActors()) { - if (!afirst) sb.append(','); - afirst = false; + if (!firstActor) sb.append(','); + firstActor = false; + sb.append('{'); jsonField(sb, "name", actor.getName().getString()); sb.append(','); - String type = (actor instanceof PlayerBattleActor) ? "player" : "npc"; - jsonField(sb, "type", type); - sb.append('}'); + + if (actor instanceof PlayerBattleActor playerActor) { + jsonField(sb, "type", "player"); + sb.append(','); + + UUID playerId = playerActor.getUuid(); + jsonField(sb, "playerId", playerId.toString()); + + // Active Pokémon — species/level/hp, intentionally no moves + sb.append(",\"activePokemon\":["); + boolean firstActive = true; + for (ActiveBattlePokemon abp : actor.getActivePokemon()) { + if (abp.getBattlePokemon() == null) continue; + if (!firstActive) sb.append(','); + firstActive = false; + var bp = abp.getBattlePokemon(); + var orig = bp.getOriginalPokemon(); + sb.append('{'); + jsonField(sb, "uuid", orig.getUuid().toString()); + sb.append(','); + jsonField(sb, "species", orig.getSpecies().getName()); + sb.append(','); + jsonFieldRaw(sb, "level", String.valueOf(orig.getLevel())); + sb.append(','); + jsonFieldRaw(sb, "hp", String.valueOf(bp.getHealth())); + sb.append(','); + jsonFieldRaw(sb, "maxHp", String.valueOf(bp.getMaxHealth())); + sb.append('}'); + } + sb.append(']'); + + // Party — full data including moves, scoped to this player only + ServerPlayerEntity playerEntity = server.getPlayerManager().getPlayer(playerId); + sb.append(",\"party\":["); + if (playerEntity != null) { + PlayerPartyStore party = Cobblemon.INSTANCE.getStorage().getParty(playerEntity); + boolean firstParty = true; + for (Pokemon p : party.toGappyList()) { + if (p == null) continue; + if (!firstParty) sb.append(','); + firstParty = false; + appendPokemonJson(sb, p); + } + } + sb.append(']'); + + } else { + // NPC actor — name and type only, no party or move data + jsonField(sb, "type", "npc"); + + sb.append(",\"activePokemon\":["); + boolean firstActive = true; + for (ActiveBattlePokemon abp : actor.getActivePokemon()) { + if (abp.getBattlePokemon() == null) continue; + if (!firstActive) sb.append(','); + firstActive = false; + var bp = abp.getBattlePokemon(); + var orig = bp.getOriginalPokemon(); + sb.append('{'); + jsonField(sb, "uuid", orig.getUuid().toString()); + sb.append(','); + jsonField(sb, "species", orig.getSpecies().getName()); + sb.append(','); + jsonFieldRaw(sb, "level", String.valueOf(orig.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('}'); // end actor } - sb.append(']'); - sb.append('}'); + + sb.append("]}"); // end actors + battle } - sb.append("]}"); + + sb.append("]}"); // end battles return sb.toString(); } // ─── Helpers ──────────────────────────────────────────────────────── - private PokemonBattle findBattle(UUID playerUuid) { - return BattleRegistry.INSTANCE.getBattleByParticipatingPlayerId(playerUuid); - } - + /** Full Pokémon snapshot including moves — only used for the owning player's party. */ private void appendPokemonJson(StringBuilder sb, Pokemon p) { sb.append('{'); jsonField(sb, "uuid", p.getUuid().toString()); @@ -322,8 +230,6 @@ public class CobbleBattleApiMod implements ModInitializer { 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()) { @@ -337,9 +243,7 @@ public class CobbleBattleApiMod implements ModInitializer { jsonFieldRaw(sb, "maxPp", String.valueOf(move.getMaxPp())); sb.append('}'); } - sb.append(']'); - - sb.append('}'); + sb.append("]}"); } private static String json(String key, String value) { @@ -363,10 +267,6 @@ public class CobbleBattleApiMod implements ModInitializer { .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; @@ -398,3 +298,4 @@ public class CobbleBattleApiMod implements ModInitializer { } } } +