Volver al índice

src/main/java/ar/com/companeros/body/PlayerSessions.java

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<MinecraftServer, Map<UUID, Session>> 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<ServerPlayer> 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<UUID, Session> 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;
        }
    }
}