package ar.com.companeros.identity; import ar.com.companeros.body.PlayerSessions; import ar.com.companeros.horror.HorrorModule; import com.google.gson.*; import com.mojang.authlib.properties.Property; import java.io.IOException; import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.util.*; import java.util.concurrent.*; import net.minecraft.commands.CommandSourceStack; import net.minecraft.network.chat.Component; import net.minecraft.network.protocol.game.*; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerEntity; import net.minecraft.server.level.ServerPlayer; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.event.TickEvent; import net.minecraftforge.event.server.ServerStoppingEvent; import net.minecraftforge.fml.loading.FMLPaths; /** Apariencia persistente fuera de los mundos; el nombre, UUID y playerdata no se reemplazan. */ public final class SkinProfiles { private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create(); private static final Map SERVERS = new WeakHashMap<>(); private static boolean installed; private final MinecraftServer server; private final Path file; private final Map selections = new LinkedHashMap<>(); private final Map> pending = new HashMap<>(); private final Map lastRequested = new HashMap<>(); private final Map cache = new LinkedHashMap<>(); private final ThreadPoolExecutor worker = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(16), task -> { Thread thread = new Thread(task, "raps-skins"); thread.setDaemon(true); return thread; }); private boolean closed; // Registro mínimo de origen, propiedad firmada y fecha. No contiene credenciales. public record Selection(String source, UUID profileId, String texture, String value, String signature, long updatedAtUtc) { public Selection { source = SkinSources.profile(source); Objects.requireNonNull(profileId); texture = SkinSources.texture(texture); if (source.equals("default") || !texture.equals(SkinSources.textureFromProperty(value)) || signature == null || signature.isBlank() || signature.length() > 2_048 || updatedAtUtc < 0) throw new IllegalArgumentException("Registro de skin inválido"); } public Property property() { return new Property("textures", value, signature); } } private SkinProfiles(MinecraftServer server) throws IOException { this.server = server; Path directory = FMLPaths.CONFIGDIR.get().resolve("raps").toAbsolutePath().normalize(); if (Files.isSymbolicLink(directory)) throw new IOException("La carpeta de skins no puede ser un enlace"); Files.createDirectories(directory); file = directory.resolve("skins.json"); if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) { if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.size(file) > 1_048_576) throw new IOException("Archivo de skins inválido"); try { selections.putAll(decode(Files.readString(file, StandardCharsets.UTF_8))); } catch (RuntimeException invalid) { throw new IOException("No se pudo validar skins.json", invalid); } } } public static SkinProfiles get(MinecraftServer server) { if (!server.isSameThread()) throw new IllegalStateException("Las skins se registran en el hilo del servidor"); SkinProfiles current = SERVERS.get(server); if (current == null) try { current = new SkinProfiles(server); SERVERS.put(server, current); } catch (IOException invalid) { throw new IllegalStateException("No se pudieron cargar las skins", invalid); } return current; } public static synchronized void install() { if (installed) return; installed = true; MinecraftForge.EVENT_BUS.addListener(SkinProfiles::tick); MinecraftForge.EVENT_BUS.addListener(SkinProfiles::stopping); } private static void tick(TickEvent.ServerTickEvent event) { if (event.phase != TickEvent.Phase.END || event.getServer().getTickCount() % 20 != 0) return; MinecraftServer server = event.getServer(); if (server instanceof net.minecraft.gametest.framework.GameTestServer) return; HorrorModule.findParasite(server).ifPresent(body -> syncParasiteSkin(server, body)); } public static void syncParasiteSkin(MinecraftServer server, ar.com.companeros.horror.ParasiteEntity body) { String override = ar.com.companeros.horror.HorrorState.get(server).appearanceOverride(); UUID host = body.hostId().orElse(null); String hostTexture = "steve".equals(override) || host == null ? "" : get(server).texture(host); String source = SkinSources.parasiteSource(override, host, hostTexture); if (!source.equals(body.getHostSkinSource())) body.setHostSkinSource(source); } private static void stopping(ServerStoppingEvent event) { SkinProfiles current = SERVERS.remove(event.getServer()); if (current != null) { current.closed = true; current.pending.values().forEach(future -> future.cancel(true)); current.worker.shutdownNow(); } } public String source(UUID id) { Selection selected = selections.get(id); return selected == null ? "default" : selected.source(); } public String texture(UUID id) { Selection selected = selections.get(id); return selected == null ? "" : selected.texture(); } public void applySaved(ServerPlayer player) { player.getGameProfile().getProperties().removeAll("textures"); Selection selected = selections.get(player.getUUID()); if (selected != null) player.getGameProfile().getProperties().put("textures", selected.property()); } // Red lenta fuera del tick. Una segunda solicitud no puede hacer llegar una respuesta vieja después. public CompletableFuture select(UUID actor, String requested) { if (closed || !server.isSameThread() || !AgentRegistry.get(server).isAgent(actor)) throw new IllegalArgumentException("Agente no registrado"); String source = SkinSources.profile(requested); long tick = server.getTickCount(); if (pending.containsKey(actor) || tick - lastRequested.getOrDefault(actor, tick - 1_200) < 1_200) throw new IllegalArgumentException("Esperá un minuto entre cambios de skin"); if (!selections.containsKey(actor) && selections.size() >= 128) throw new IllegalArgumentException("Registro de skins lleno"); lastRequested.put(actor, tick); if (source.equals("default")) { Selection previous = selections.remove(actor); try { save(); refresh(actor); return CompletableFuture.completedFuture(true); } catch (IOException failure) { if (previous != null) selections.put(actor, previous); return CompletableFuture.completedFuture(false); } } CompletableFuture result = new CompletableFuture<>(); pending.put(actor, result); try { worker.execute(() -> { Selection resolved = null; try { resolved = resolve(source); } catch (IOException | InterruptedException | RuntimeException failure) { } Selection completed = resolved; server.execute(() -> { if (closed || pending.get(actor) != result) return; pending.remove(actor); if (result.isCancelled()) return; if (completed == null || !AgentRegistry.get(server).isAgent(actor)) { result.complete(false); return; } Selection previous = selections.put(actor, completed); try { save(); refresh(actor); result.complete(true); } catch (IOException failure) { if (previous == null) selections.remove(actor); else selections.put(actor, previous); result.complete(false); } }); }); } catch (RejectedExecutionException full) { pending.remove(actor); result.complete(false); } return result; } private Selection resolve(String source) throws IOException, InterruptedException { Selection remembered = cache.get(source.toLowerCase(Locale.ROOT)); if (remembered != null && System.currentTimeMillis() - remembered.updatedAtUtc() < 3_600_000) return remembered; UUID profileId; if (source.matches("[A-Za-z0-9_]{1,16}")) { JsonObject account = JsonParser.parseString(new String(SkinHttp.get(URI.create("https://api.mojang.com/users/profiles/minecraft/" + source), 4_096), StandardCharsets.UTF_8)).getAsJsonObject(); profileId = SkinSources.uuid(account.get("id").getAsString()); } else profileId = UUID.fromString(source); URI endpoint = URI.create("https://sessionserver.mojang.com/session/minecraft/profile/" + profileId.toString().replace("-", "") + "?unsigned=false"); JsonObject profile = JsonParser.parseString(new String(SkinHttp.get(endpoint, 32_768), StandardCharsets.UTF_8)).getAsJsonObject(); if (!SkinSources.uuid(profile.get("id").getAsString()).equals(profileId)) throw new IOException("Minecraft devolvió otra cuenta"); JsonArray properties = profile.getAsJsonArray("properties"); if (properties == null || properties.size() > 8) throw new IOException("Propiedades de skin inválidas"); for (JsonElement element : properties) { JsonObject property = element.getAsJsonObject(); if (!"textures".equals(property.get("name").getAsString()) || !property.has("signature")) continue; String value = property.get("value").getAsString(); Selection selected = new Selection(source, profileId, SkinSources.textureFromProperty(value), value, property.get("signature").getAsString(), System.currentTimeMillis()); byte[] png = SkinHttp.get(URI.create(selected.texture()), 131_072); if (!SkinSources.playerPng(png)) throw new IOException("Minecraft no devolvió una imagen de skin válida"); if (cache.size() >= 128) cache.remove(cache.keySet().iterator().next()); cache.put(source.toLowerCase(Locale.ROOT), selected); return selected; } throw new IOException("Esa cuenta no tiene una skin firmada disponible"); } // Reenvío vanilla: los clientes ven la nueva piel; el cuerpo real mantiene inventario, salud y UUID. private void refresh(UUID actor) { ServerPlayer player = PlayerSessions.find(server, actor); if (player != null) { applySaved(player); var remove = new ClientboundPlayerInfoRemovePacket(List.of(actor)); var add = ClientboundPlayerInfoUpdatePacket.createPlayerInitializing(List.of(player)); var tracker = new ServerEntity(player.serverLevel(), player, 1, false, ignored -> { }); for (ServerPlayer observer : server.getPlayerList().getPlayers()) { if (observer == player || PlayerSessions.isAgent(server, observer.getUUID())) continue; observer.connection.send(remove); observer.connection.send(add); if (observer.serverLevel() == player.serverLevel() && observer.distanceToSqr(player) <= 64 * 64) { observer.connection.send(new ClientboundRemoveEntitiesPacket(player.getId())); tracker.sendPairingData(observer, observer.connection::send); } } } HorrorModule.findParasite(server).filter(body -> body.hostId().filter(actor::equals).isPresent()) .ifPresent(body -> syncParasiteSkin(server, body)); } // Archivo compartido entre mundos, escritura atómica y esquema cerrado. public static String encode(Map profiles) { if (profiles.size() > 128) throw new IllegalArgumentException("Registro de skins lleno"); JsonObject root = new JsonObject(); root.addProperty("schema", 1); JsonObject rows = new JsonObject(); profiles.forEach((id, selected) -> rows.add(id.toString(), JSON.toJsonTree(selected))); root.add("agents", rows); return JSON.toJson(root); } public static Map decode(String text) { if (text.getBytes(StandardCharsets.UTF_8).length > 1_048_576) throw new IllegalArgumentException("Skins fuera de límite"); JsonObject root = JsonParser.parseString(text).getAsJsonObject(); if (!root.keySet().equals(Set.of("schema", "agents")) || root.get("schema").getAsInt() != 1) throw new IllegalArgumentException("Formato de skins desconocido"); JsonObject rows = root.getAsJsonObject("agents"); if (rows.size() > 128) throw new IllegalArgumentException("Demasiadas skins"); Map result = new LinkedHashMap<>(); for (var row : rows.entrySet()) { UUID actor = UUID.fromString(row.getKey()); JsonObject value = row.getValue().getAsJsonObject(); if (!value.keySet().equals(Set.of("source", "profileId", "texture", "value", "signature", "updatedAtUtc"))) throw new IllegalArgumentException("Registro de skin desconocido"); result.put(actor, new Selection(value.get("source").getAsString(), UUID.fromString(value.get("profileId").getAsString()), value.get("texture").getAsString(), value.get("value").getAsString(), value.get("signature").getAsString(), value.get("updatedAtUtc").getAsLong())); } return result; } private void save() throws IOException { Path temporary = file.resolveSibling("skins.tmp"); if (Files.isSymbolicLink(file) || Files.isSymbolicLink(temporary)) throw new IOException("Archivo de skins enlazado"); Files.writeString(temporary, encode(selections), StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); try { Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } catch (AtomicMoveNotSupportedException unsupported) { Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); } } // Comando del operador; una búsqueda de skin nunca otorga permisos de operador al agente. public static int command(CommandSourceStack source, String name, String requested) { AgentIdentity identity = AgentRegistry.get(source.getServer()).entries().stream() .filter(agent -> agent.name().equalsIgnoreCase(name) && agent.state() != AgentState.RETIRED).findFirst().orElse(null); if (identity == null) { source.sendFailure(Component.literal("No existe ese bot.")); return 0; } if (requested == null) { source.sendSuccess(() -> Component.literal(identity.name() + " usa " + get(source.getServer()).source(identity.id()) + "."), false); return 1; } try { CompletableFuture future = get(source.getServer()).select(identity.id(), requested); source.sendSuccess(() -> Component.literal("Buscando skin para " + identity.name() + "."), false); future.whenComplete((success, failure) -> source.getServer().execute(() -> { if (Boolean.TRUE.equals(success)) source.sendSuccess(() -> Component.literal("Skin de " + identity.name() + " actualizada."), false); else source.sendFailure(Component.literal("No se pudo obtener esa skin. Se conserva la anterior.")); })); return 1; } catch (IllegalArgumentException rejected) { source.sendFailure(Component.literal(rejected.getMessage())); return 0; } } }