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