package ar.com.companeros.body; import com.mojang.authlib.GameProfile; import com.mojang.logging.LogUtils; import java.nio.file.Files; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.WeakHashMap; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.level.GameType; import net.minecraft.world.level.storage.LevelResource; import net.minecraft.world.phys.AABB; import net.minecraft.world.phys.Vec3; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.event.TickEvent; import net.minecraftforge.event.server.ServerStoppingEvent; import net.minecraftforge.eventbus.api.EventPriority; import org.slf4j.Logger; /** Ciclo de vida de jugadores locales. Todas las operaciones exigen el hilo del servidor. */ public final class PlayerSessions { private static final int MOVEMENT_LEASE_TICKS = 5; private static final Logger LOGGER = LogUtils.getLogger(); private static final Map> SERVERS = new WeakHashMap<>(); private static boolean installed; private PlayerSessions() { } // Instalación explícita: no hay conexiones ni jugadores creados al cargar el mod. public static synchronized void install() { if (installed) return; installed = true; MinecraftForge.EVENT_BUS.addListener(EventPriority.HIGHEST, PlayerSessions::onServerTick); MinecraftForge.EVENT_BUS.addListener(PlayerSessions::onStopping); } /** Crea una identidad nueva o carga su playerdata; rechaza nombres/UUID que estén conectados. */ public static AgentPlayer create(MinecraftServer server, UUID id, String name, ServerLevel level, Vec3 pos) { requireServerThread(server); if (id == null || name == null || !name.matches("[A-Za-z0-9_]{1,16}") || level == null || level.getServer() != server || pos == null || !Double.isFinite(pos.x) || !Double.isFinite(pos.y) || !Double.isFinite(pos.z)) throw new IllegalArgumentException("Identidad o dimensión inválida"); if (server.getPlayerList().getPlayer(id) != null || server.getPlayerList().getPlayerByName(name) != null) throw new IllegalArgumentException("Ya hay un participante conectado con esa identidad"); if (sessions(server).containsKey(id)) throw new IllegalArgumentException("La sesión del agente ya existe"); var profile = new GameProfile(id, name); var denied = server.getPlayerList().canPlayerLogin(new java.net.InetSocketAddress("127.0.0.1", 0), profile); if (denied != null) throw new IllegalStateException(denied.getString()); boolean saved = Files.isRegularFile(server.getWorldPath(LevelResource.PLAYER_DATA_DIR).resolve(id + ".dat")); AgentPlayer player = new AgentPlayer(server, level, id, name); ar.com.companeros.identity.SkinProfiles.get(server).applySaved(player); if (!saved) { player.absMoveTo(pos.x, pos.y, pos.z, 0, 0); validateNewSpawn(level, player); } LocalConnection transport = new LocalConnection(); Session session = new Session(player, transport); sessions(server).put(id, session); try { // PlayerList registra UUID, tab list, playerdata, tracking y eventos Forge normales. server.getPlayerList().placeNewPlayer(transport, player); if (sessions(server).get(id) != session || server.getPlayerList().getPlayer(id) != player) throw new IllegalStateException("El servidor canceló la entrada del agente"); installListener(server, session); player.setGameMode(GameType.SURVIVAL); if (!saved) player.teleportTo(level, pos.x, pos.y, pos.z, 0, 0); loadBodyChunks(player.serverLevel(), player.getBoundingBox()); player.connection.resetPosition(); player.serverLevel().getChunkSource().move(player); return player; } catch (RuntimeException failure) { remove(server, id); transport.close(); throw failure; } } public static AgentPlayer create(MinecraftServer server, UUID id, String name, ServerLevel level, net.minecraft.core.BlockPos pos) { return create(server, id, name, level, new Vec3(pos.getX() + .5, pos.getY(), pos.getZ() + .5)); } /** Vanilla puede sustituir ServerPlayer al reaparecer; la identidad del agente no cambia. */ public static ServerPlayer find(MinecraftServer server, UUID id) { requireServerThread(server); Session session = sessions(server).get(id); return session == null ? null : session.player; } public static boolean isAgent(MinecraftServer server, UUID id) { requireServerThread(server); return sessions(server).containsKey(id); } public static List players(MinecraftServer server) { requireServerThread(server); return sessions(server).values().stream().map(session -> session.player).toList(); } // El ejecutor renueva una intención breve: un controlador detenido no deja caminar al agente. public static void setMovementIntent(MinecraftServer server, UUID id, float strafe, float forward, boolean jump) { requireServerThread(server); Session session = sessions(server).get(id); if (session == null) throw new IllegalArgumentException("No hay una sesión de ese agente"); if (!Float.isFinite(strafe) || !Float.isFinite(forward)) throw new IllegalArgumentException("Movimiento inválido"); session.strafe = Math.max(-1, Math.min(1, strafe)); session.forward = Math.max(-1, Math.min(1, forward)); session.jump = jump; session.movementUpdatedTick = server.getTickCount(); session.hasMovementIntent = true; } /** Reaparición vanilla: respeta inventario, punto de aparición, capacidades y evento Clone. */ public static ServerPlayer respawn(MinecraftServer server, UUID id) { requireServerThread(server); Session session = sessions(server).get(id); if (session == null) throw new IllegalArgumentException("No hay una sesión de ese agente"); if (!session.player.isDeadOrDying() && !session.player.wonGame) throw new IllegalStateException("El agente sigue vivo"); boolean conqueredEnd = session.player.wonGame; session.player = server.getPlayerList().respawn(session.player, conqueredEnd); ar.com.companeros.identity.SkinProfiles.get(server).applySaved(session.player); session.player.setGameMode(GameType.SURVIVAL); session.player.hasChangedDimension(); session.strafe = 0; session.forward = 0; session.jump = false; session.deadTicks = 0; session.hasMovementIntent = false; installListener(server, session); loadBodyChunks(session.player.serverLevel(), session.player.getBoundingBox()); return session.player; } public static void remove(MinecraftServer server, UUID id) { requireServerThread(server); Session session = sessions(server).remove(id); if (session == null) return; if (server.getPlayerList().getPlayer(id) == session.player) server.getPlayerList().remove(session.player); session.transport.close(); } /** Guardado normal de playerdata; no usa un inventario paralelo. */ public static void save(MinecraftServer server) { requireServerThread(server); server.getPlayerList().saveAll(); } public static long completedPlayerTicks(MinecraftServer server, UUID id) { requireServerThread(server); Session session = sessions(server).get(id); return session == null ? 0 : session.completedTicks; } public static long deliveredPackets(MinecraftServer server, UUID id) { requireServerThread(server); Session session = sessions(server).get(id); return session == null ? 0 : session.transport.deliveredPackets(); } // Un único tick por sesión y por tick de servidor, incluso si se llama también desde una prueba. public static void tick(MinecraftServer server) { requireServerThread(server); for (Session session : new ArrayList<>(sessions(server).values())) { if (session.lastTick == server.getTickCount()) continue; session.lastTick = server.getTickCount(); ServerPlayer player = session.player; if (server.getPlayerList().getPlayer(player.getUUID()) != player || player.hasDisconnected()) { remove(server, player.getUUID()); continue; } try { if (player.wonGame) player = respawn(server, player.getUUID()); Vec3 before = player.position(); var dimension = player.level().dimension(); if (!session.hasMovementIntent || server.getTickCount() - session.movementUpdatedTick >= MOVEMENT_LEASE_TICKS) { session.strafe = 0; session.forward = 0; session.jump = false; session.hasMovementIntent = false; } player.xxa = session.strafe; player.zza = session.forward; player.setJumping(session.jump); player.doTick(); session.completedTicks++; if (player.level().dimension().equals(dimension)) { Vec3 moved = player.position().subtract(before); player.doCheckFallDamage(moved.x, moved.y, moved.z, player.onGround()); } player.connection.resetPosition(); player.serverLevel().getChunkSource().move(player); session.transport.pump(); if (player.isChangingDimension()) player.hasChangedDimension(); if (player.isDeadOrDying()) { if (++session.deadTicks >= 20) respawn(server, player.getUUID()); } else session.deadTicks = 0; } catch (RuntimeException failure) { LOGGER.error("Se cerró la sesión del agente {} después de un fallo de tick", player.getUUID(), failure); remove(server, player.getUUID()); } } } private static void installListener(MinecraftServer server, Session session) { var listener = new LocalPacketListener(server, session.transport, session.player); session.player.connection = listener; session.transport.setListener(listener); listener.resetPosition(); } // Una identidad nueva necesita piso real. Los datos guardados pueden contener una caída legítima. private static void validateNewSpawn(ServerLevel level, ServerPlayer player) { AABB body = player.getBoundingBox(); if (body.minY <= level.getMinBuildHeight() || body.maxY > level.getMaxBuildHeight() || !level.getWorldBorder().isWithinBounds(player.blockPosition())) throw new IllegalArgumentException("El punto inicial necesita espacio libre y terreno seguro"); loadBodyChunks(level, body); boolean supported = !level.noCollision(player, body.move(0, -.05, 0)); if (!level.noCollision(player) || !supported || !level.getFluidState(player.blockPosition()).isEmpty()) throw new IllegalArgumentException("El punto inicial necesita espacio libre y terreno seguro"); } // Sólo conexión/reaparición: prepara la caja final sin cambiar posiciones guardadas ni el piso. private static void loadBodyChunks(ServerLevel level, AABB body) { int minChunkX = net.minecraft.util.Mth.floor(body.minX) >> 4; int maxChunkX = net.minecraft.util.Mth.floor(body.maxX) >> 4; int minChunkZ = net.minecraft.util.Mth.floor(body.minZ) >> 4; int maxChunkZ = net.minecraft.util.Mth.floor(body.maxZ) >> 4; if (maxChunkX - minChunkX > 1 || maxChunkZ - minChunkZ > 1) throw new IllegalArgumentException("El cuerpo excede el tamaño de aparición permitido"); for (int chunkX = minChunkX; chunkX <= maxChunkX; chunkX++) for (int chunkZ = minChunkZ; chunkZ <= maxChunkZ; chunkZ++) level.getChunk(chunkX, chunkZ); } private static Map sessions(MinecraftServer server) { return SERVERS.computeIfAbsent(server, unused -> new HashMap<>()); } private static void requireServerThread(MinecraftServer server) { if (server == null || !server.isSameThread()) throw new IllegalStateException("La acción exige el hilo del servidor"); } private static void onServerTick(TickEvent.ServerTickEvent event) { if (event.phase == TickEvent.Phase.END) tick(event.getServer()); } private static void onStopping(ServerStoppingEvent event) { MinecraftServer server = event.getServer(); for (UUID id : new ArrayList<>(sessions(server).keySet())) remove(server, id); SERVERS.remove(server); } private static final class Session { private ServerPlayer player; private final LocalConnection transport; private int lastTick = Integer.MIN_VALUE; private long completedTicks; private int deadTicks; private float strafe; private float forward; private boolean jump; private int movementUpdatedTick; private boolean hasMovementIntent; private Session(ServerPlayer player, LocalConnection transport) { this.player = player; this.transport = transport; } } }