Volver al índice

src/main/java/ar/com/companeros/identity/SkinProfiles.java

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<MinecraftServer, SkinProfiles> SERVERS = new WeakHashMap<>();
    private static boolean installed;
    private final MinecraftServer server;
    private final Path file;
    private final Map<UUID, Selection> selections = new LinkedHashMap<>();
    private final Map<UUID, CompletableFuture<Boolean>> pending = new HashMap<>();
    private final Map<UUID, Long> lastRequested = new HashMap<>();
    private final Map<String, Selection> 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<Boolean> 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<Boolean> 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<UUID, Selection> 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<UUID, Selection> 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<UUID, Selection> 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<Boolean> 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; }
    }
}