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