Volver al índice

src/main/java/ar/com/companeros/horror/HorrorModule.java

package ar.com.companeros.horror;

import ar.com.companeros.Companeros;
import ar.com.companeros.body.PlayerSessions;
import ar.com.companeros.identity.*;
import ar.com.companeros.horror.tools.*;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.logging.LogUtils;
import java.io.IOException;
import java.nio.file.*;
import java.util.*;
import net.minecraft.ChatFormatting;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.core.*;
import net.minecraft.core.registries.Registries;
import net.minecraft.nbt.*;
import net.minecraft.network.chat.Component;
import net.minecraft.network.protocol.game.ClientboundSoundPacket;
import net.minecraft.network.protocol.game.ClientboundStopSoundPacket;
import net.minecraft.resources.*;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.*;
import net.minecraft.sounds.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.level.storage.LevelResource;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.event.entity.EntityAttributeCreationEvent;
import net.minecraftforge.event.entity.living.LivingAttackEvent;
import net.minecraftforge.event.entity.player.PlayerEvent;
import net.minecraftforge.event.server.ServerStoppingEvent;
import net.minecraftforge.eventbus.api.*;
import net.minecraftforge.fml.loading.FMLPaths;
import net.minecraftforge.registries.*;
import static net.minecraft.commands.Commands.argument;
import static net.minecraft.commands.Commands.literal;

/** Evento del mundo, transferencia del huésped y controles de administración. */
public final class HorrorModule {
    private static final DeferredRegister<EntityType<?>> ENTITIES = DeferredRegister.create(ForgeRegistries.ENTITY_TYPES, Companeros.ID);
    private static final DeferredRegister<SoundEvent> SOUNDS = DeferredRegister.create(ForgeRegistries.SOUND_EVENTS, Companeros.ID);
    public static final RegistryObject<EntityType<ParasiteEntity>> STALKER = ENTITIES.register("parasite",
        () -> EntityType.Builder.of(ParasiteEntity::new, MobCategory.MONSTER).sized(.6F, 1.8F)
            .clientTrackingRange(12).build(Companeros.ID + ":parasite"));
    public static final RegistryObject<SoundEvent> PARASITE_VOICE = SOUNDS.register("parasite_voice",
        () -> SoundEvent.createVariableRangeEvent(new ResourceLocation(Companeros.ID, "parasite_voice")));
    public static final RegistryObject<SoundEvent> PARASITE_STEP = SOUNDS.register("parasite_step",
        () -> SoundEvent.createVariableRangeEvent(new ResourceLocation(Companeros.ID, "parasite_step")));
    public static final RegistryObject<SoundEvent> PARASITE_LORE = SOUNDS.register("parasite_lore",
        () -> SoundEvent.createVariableRangeEvent(new ResourceLocation(Companeros.ID, "parasite_lore")));
    public static final RegistryObject<SoundEvent> PARASITE_ATTACK = SOUNDS.register("parasite_attack",
        () -> SoundEvent.createVariableRangeEvent(new ResourceLocation(Companeros.ID, "parasite_attack")));
    public static final RegistryObject<SoundEvent> PARASITE_CLIMB = SOUNDS.register("parasite_climb",
        () -> SoundEvent.createVariableRangeEvent(new ResourceLocation(Companeros.ID, "parasite_climb")));
    public static final RegistryObject<SoundEvent> PARASITE_CHASE = SOUNDS.register("parasite_chase",
        () -> SoundEvent.createVariableRangeEvent(new ResourceLocation(Companeros.ID, "parasite_chase")));
    private static final Map<MinecraftServer, HorrorConfig> CONFIGS = new WeakHashMap<>();
    private static boolean installed;
    private HorrorModule() { }
    public static void register(IEventBus bus) {
        ENTITIES.register(bus); SOUNDS.register(bus);
        bus.addListener((EntityAttributeCreationEvent event) -> event.put(STALKER.get(), ParasiteEntity.attributes().build()));
        HorrorNetwork.register();
    }
    public static synchronized void install() {
        if (installed) return; installed = true;
        MinecraftForge.EVENT_BUS.addListener(HorrorModule::tick);
        MinecraftForge.EVENT_BUS.addListener(HorrorModule::login);
        MinecraftForge.EVENT_BUS.addListener(EventPriority.HIGHEST, HorrorModule::attack);
        MinecraftForge.EVENT_BUS.addListener((ServerStoppingEvent event) -> { InfectedTools.close(event.getServer()); CONFIGS.remove(event.getServer()); });
    }
    public static HorrorConfig config(MinecraftServer server) {
        return CONFIGS.computeIfAbsent(server, key -> {
            try {
                AppearanceAssets.install(FMLPaths.CONFIGDIR.get().resolve("raps"));
                return HorrorConfig.load(FMLPaths.CONFIGDIR.get().resolve("raps"));
            }
            catch (IOException failure) { throw new IllegalStateException("No se pudo cargar parasite.json", failure); }
        });
    }
    public static boolean isInfected(MinecraftServer server, UUID id) { return HorrorState.get(server).host().filter(id::equals).isPresent(); }
    public static boolean sensitivityProtected(MinecraftServer server, UUID id) { return HorrorState.get(server).sensitive(id); }
    public static boolean mayTarget(MinecraftServer server, Entity entity) {
        return config(server).enabled() && entity instanceof ServerPlayer player && player.isAlive() && !player.isCreative() && !player.isSpectator()
            && AgentRegistry.get(server).isHuman(player) && !sensitivityProtected(server, player.getUUID());
    }
    public static Optional<ParasiteEntity> findParasite(MinecraftServer server) {
        UUID id = HorrorState.get(server).entityId().orElse(null);
        if (id == null) return Optional.empty();
        for (ServerLevel level : server.getAllLevels()) if (level.getEntity(id) instanceof ParasiteEntity entity) return Optional.of(entity);
        return Optional.empty();
    }

    // No se generan chunks para buscar el meteorito. Si no hay sitio seguro, se espera.
    private static void tick(TickEvent.ServerTickEvent event) {
        if (event.phase != TickEvent.Phase.END) return;
        try { InfectedTools.tick(event.getServer()); }
        catch (RuntimeException failure) { LogUtils.getLogger().error("No se pudo avanzar Tools/infected", failure); }
        if (event.getServer() instanceof net.minecraft.gametest.framework.GameTestServer
                || event.getServer().getTickCount() % 20 != 0) return;
        MinecraftServer server = event.getServer();
        try {
            HorrorConfig config = config(server); if (!config.enabled()) return;
            HorrorState state = HorrorState.get(server); long time = server.overworld().getGameTime();
            if (state.meteorDue(time, config) && server.getTickCount() % 1200 == 0) landMeteor(server);
            if (state.infectionDue(time, config)) {
                List<AgentIdentity> candidates = AgentRegistry.get(server).entries().stream()
                    .filter(agent -> agent.state() != AgentState.LEGACY && agent.state() != AgentState.RETIRED).toList();
                if (!candidates.isEmpty()) infect(server, candidates.get(server.overworld().random.nextInt(candidates.size())));
            }
            if (state.active() && !state.transferred()) transferHost(server);
            if (state.transferred() && !state.entityCreated()) spawn(server);
            findParasite(server).ifPresent(entity -> entity.setMutationStage(state.stage(time, config)));
        } catch (RuntimeException failure) {
            LogUtils.getLogger().error("No se pudo avanzar el acontecimiento del parásito; se conservan sus datos", failure);
        }
    }
    public static boolean landMeteor(MinecraftServer server) {
        HorrorState state = HorrorState.get(server);
        if (state.meteorLanded()) return false;
        ServerLevel level = server.overworld();
        Optional<BlockPos> site = Meteorite.findSite(level, config(server).safeRadius());
        if (site.isEmpty() || !Meteorite.land(level, site.get(), config(server).safeRadius())) return false;
        state.landed(site.get(), level.getGameTime()); durableState(server);
        BlockPos p = site.get();
        server.getPlayerList().broadcastSystemMessage(Component.literal("Algo extraño cayó en las coordenadas "
            + p.getX() + " " + p.getY() + " " + p.getZ()).withStyle(ChatFormatting.AQUA), false);
        return true;
    }
    public static void infect(MinecraftServer server, AgentIdentity identity) {
        if (identity.state() == AgentState.LEGACY || identity.state() == AgentState.RETIRED)
            throw new IllegalArgumentException("Ese bot no puede ser huésped");
        HorrorState state = HorrorState.get(server); boolean newHost = !state.active();
        state.selectHost(identity.id(), identity.name(), server.overworld().getGameTime());
        AgentRegistry.get(server).markState(identity.id(), AgentState.PAUSED);
        durableState(server);
        if (newHost) server.getPlayerList().broadcastSystemMessage(warning(), false);
        transferHost(server);
        if (state.transferred() && !state.entityCreated()) spawn(server);
    }

    // El depósito se escribe antes de vaciar el playerdata. Un reinicio retoma la misma transferencia.
    public static void transferHost(MinecraftServer server) {
        HorrorState state = HorrorState.get(server); if (!state.active() || state.transferred()) return;
        UUID id = state.host().orElseThrow();
        AgentIdentity identity = AgentRegistry.get(server).find(id).orElseThrow();
        ServerPlayer player = PlayerSessions.find(server, id);
        if (player == null) {
            ServerLevel level = identity.camp() == null ? server.overworld() : server.getLevel(identity.camp().dimension());
            if (level == null) level = server.overworld();
            BlockPos pos = identity.camp() == null ? level.getSharedSpawnPos().above() : identity.camp().pos();
            player = PlayerSessions.create(server, id, identity.name(), level, pos);
        }
        CompoundTag snapshot = player.saveWithoutId(new CompoundTag());
        if (state.quarantine().isEmpty()) {
            state.prepareQuarantine(snapshot, player.level().dimension().location().toString(), player.blockPosition());
            durableState(server);
        }
        boolean marked = player.getPersistentData().hasUUID("RapsParasiteTransfer")
            && player.getPersistentData().getUUID("RapsParasiteTransfer").equals(state.entityId().orElseThrow());
        if (!marked && !sameInventory(snapshot, state.quarantine()))
            throw new IllegalStateException("El inventario cambió durante la transferencia; requiere revisión del admin");
        player.getInventory().clearContent(); player.getEnderChestInventory().clearContent();
        player.getPersistentData().putUUID("RapsParasiteTransfer", state.entityId().orElseThrow());
        durablePlayer(server, player);
        state.commitTransfer(); durableState(server);
        PlayerSessions.remove(server, id);
    }
    private static boolean sameInventory(CompoundTag left, CompoundTag right) {
        return left.getList("Inventory", Tag.TAG_COMPOUND).equals(right.getList("Inventory", Tag.TAG_COMPOUND))
            && left.getList("EnderItems", Tag.TAG_COMPOUND).equals(right.getList("EnderItems", Tag.TAG_COMPOUND));
    }
    public static ParasiteEntity spawn(MinecraftServer server) {
        HorrorState state = HorrorState.get(server);
        Optional<ParasiteEntity> existing = findParasite(server);
        if (existing.isPresent()) {
            SkinProfiles.syncParasiteSkin(server, existing.get());
            if (!state.entityCreated()) { state.created(); durableState(server); }
            return existing.get();
        }
        if (!state.transferred()) throw new IllegalStateException("Primero debe infectarse y transferirse un bot");
        ServerLevel level = server.getLevel(ResourceKey.create(Registries.DIMENSION, new ResourceLocation(state.entityDimension())));
        if (level == null) throw new IllegalStateException("La dimensión del huésped no está disponible");
        BlockPos pos = state.entityPosition(); level.getChunkAt(pos);
        if (!level.areEntitiesLoaded(new net.minecraft.world.level.ChunkPos(pos).toLong()))
            throw new IllegalStateException("El chunk del huésped está cargando; espera y vuelve a consultar");
        existing = findParasite(server);
        if (existing.isPresent()) {
            if (!state.entityCreated()) { state.created(); durableState(server); }
            return existing.get();
        }
        if (state.entityCreated()) throw new IllegalStateException(state.dead()
            ? "El parásito murió; no se crea un segundo ejemplar" : "El parásito está guardado fuera de los chunks activos");
        ParasiteEntity entity = STALKER.get().create(level);
        if (entity == null) throw new IllegalStateException("No se pudo crear el cuerpo");
        entity.setUUID(state.entityId().orElseThrow()); entity.setHost(state.host().orElseThrow(), state.hostName());
        SkinProfiles.syncParasiteSkin(server, entity);
        entity.moveTo(pos.getX() + .5, pos.getY(), pos.getZ() + .5, 0, 0);
        entity.setMutationStage(state.stage(server.overworld().getGameTime(), config(server)));
        if (!level.addFreshEntity(entity)) throw new IllegalStateException("El mundo rechazó la aparición");
        state.created(); durableState(server); return entity;
    }

    // Sensibilidad: persistencia por UUID, preferencia cliente y exclusión inmediata de objetivos.
    public static Component warning() {
        return Component.literal("Algo inusual está pasando en el mundo... Escribe /sensitivity mode enabled para habilitar el modo seguro; /sensitivity mode disabled para desactivarlo.")
            .withStyle(ChatFormatting.RED);
    }
    private static void login(PlayerEvent.PlayerLoggedInEvent event) {
        if (!(event.getEntity() instanceof ServerPlayer player) || !AgentRegistry.get(player.server).isHuman(player)) return;
        HorrorState state = HorrorState.get(player.server); HorrorNetwork.send(player, state.sensitive(player.getUUID()));
        if (state.active()) player.sendSystemMessage(warning());
    }
    private static void attack(LivingAttackEvent event) {
        if (!(event.getEntity().level() instanceof ServerLevel level)) return;
        Entity actor = event.getSource().getEntity(), direct = event.getSource().getDirectEntity();
        boolean parasite = actor instanceof ParasiteEntity || direct instanceof ParasiteEntity
            || (direct instanceof net.minecraft.world.entity.projectile.Projectile projectile && projectile.getOwner() instanceof ParasiteEntity)
            || (direct instanceof net.minecraft.world.entity.AreaEffectCloud cloud && cloud.getOwner() instanceof ParasiteEntity);
        if (parasite && !mayTarget(level.getServer(), event.getEntity())) event.setCanceled(true);
    }
    public static void setSensitivity(ServerPlayer player, boolean enabled) {
        HorrorState state = HorrorState.get(player.server); state.sensitivity(player.getUUID(), enabled); durableState(player.server);
        if (enabled) {
            findParasite(player.server).ifPresent(entity -> entity.excludeSensitivePlayer(player));
            for (SoundEvent sound : new SoundEvent[]{PARASITE_VOICE.get(), PARASITE_STEP.get(), PARASITE_LORE.get(),
                    PARASITE_ATTACK.get(), PARASITE_CLIMB.get(), PARASITE_CHASE.get()})
                player.connection.send(new ClientboundStopSoundPacket(sound.getLocation(), SoundSource.HOSTILE));
        }
        HorrorNetwork.send(player, enabled);
        player.sendSystemMessage(Component.literal(enabled ? "Modo seguro habilitado." : "Modo seguro deshabilitado."));
    }
    public static int playForEligibleHumans(ParasiteEntity entity, SoundEvent sound, float volume, float pitch) {
        if (unavailableSound(sound) || !(entity.level() instanceof ServerLevel level)) return 0;
        int delivered = 0;
        for (ServerPlayer player : level.players()) if (mayTarget(level.getServer(), player) && player.distanceToSqr(entity) < 32 * 32) {
            HorrorPacing pacing = HorrorPacing.get(level.getServer());
            if (!pacing.available(player.getUUID(), level.getGameTime())) continue;
            if (sound != PARASITE_STEP.get() && !pacing.reserveSound(player.getUUID(), level.getGameTime())) continue;
            player.connection.send(new ClientboundSoundPacket(Holder.direct(sound), SoundSource.HOSTILE,
                entity.getX(), entity.getY(), entity.getZ(), volume, pitch, level.random.nextLong()));
            delivered++;
        }
        return delivered;
    }
    // CONTACTO FÍSICO: pasos y golpes tienen cadencia propia, separada de voces y sustos.
    // Se envían sólo a oyentes humanos elegibles; nunca se usa un sonido global de área.
    public static int playPhysicalCue(ParasiteEntity entity, SoundEvent sound, float volume, float pitch) {
        if (unavailableSound(sound) || !(entity.level() instanceof ServerLevel level)) return 0;
        HorrorPacing pacing = HorrorPacing.get(level.getServer());
        if (pacing.intensity() == 0) return 0;
        int delivered = 0;
        for (ServerPlayer player : level.players()) {
            if (!mayTarget(level.getServer(), player) || player.distanceToSqr(entity) > 16 * 16
                    || !pacing.available(player.getUUID(), level.getGameTime())) continue;
            player.connection.send(new ClientboundSoundPacket(Holder.direct(sound), SoundSource.HOSTILE,
                entity.getX(), entity.getY(), entity.getZ(), volume, pitch, level.random.nextLong()));
            delivered++;
        }
        return delivered;
    }
    // AUDIO PENDIENTE: conservar IDs permite conectar clientes existentes; estos clips no se emiten.
    // La narración aprobada y la voz dinámica siguen usando PARASITE_LORE y su propio canal.
    private static boolean unavailableSound(SoundEvent sound) {
        return sound == PARASITE_VOICE.get() || sound == PARASITE_STEP.get()
            || sound == PARASITE_ATTACK.get() || sound == PARASITE_CLIMB.get() || sound == PARASITE_CHASE.get();
    }
    public static void registerSensitivity(com.mojang.brigadier.CommandDispatcher<CommandSourceStack> dispatcher) {
        for (String name : new String[]{"sensitivity", "sensivitiy"}) dispatcher.register(literal(name)
            .requires(source -> source.getEntity() instanceof ServerPlayer player && AgentRegistry.get(source.getServer()).isHuman(player))
            .then(literal("mode").then(literal("enabled").executes(context -> { setSensitivity(context.getSource().getPlayerOrException(), true); return 1; }))
                .then(literal("disabled").executes(context -> { setSensitivity(context.getSource().getPlayerOrException(), false); return 1; }))));
    }
    public static void registerCommands(LiteralArgumentBuilder<CommandSourceStack> root) {
        for (String alias : new String[]{"intensity", "intensibility", "intensidad"}) root.then(literal(alias)
            .requires(source -> source.hasPermission(2)
                && !(source.getEntity() instanceof net.minecraftforge.common.util.FakePlayer)
                && !(source.getEntity() instanceof ServerPlayer player && AgentRegistry.get(source.getServer()).isAgent(player.getUUID())))
            .executes(context -> intensity(context.getSource(), null))
            .then(argument("porcentaje", IntegerArgumentType.integer(0, 100))
                .executes(context -> intensity(context.getSource(), IntegerArgumentType.getInteger(context, "porcentaje")))));
        var command = literal("mimicer").requires(source -> source.hasPermission(2)
            && !(source.getEntity() instanceof net.minecraftforge.common.util.FakePlayer)
            && !(source.getEntity() instanceof ServerPlayer player && AgentRegistry.get(source.getServer()).isAgent(player.getUUID())));
        command.then(literal("estado").executes(context -> status(context.getSource())));
        command.then(literal("mejoras").executes(context -> {
            context.getSource().sendSuccess(() -> Component.literal(ParasiteUpgrades.get(context.getSource().getServer()).describe()), false);
            return 1;
        }));
        command.then(literal("meteorito").executes(context -> run(context.getSource(), () -> {
            if (!landMeteor(context.getSource().getServer())) throw new IllegalStateException("El meteorito ya cayó o no hay terreno cargado y seguro disponible");
        })));
        command.then(literal("infectar").executes(context -> infectRandom(context.getSource()))
            .then(argument("bot", StringArgumentType.word()).suggests((context, builder) -> net.minecraft.commands.SharedSuggestionProvider.suggest(
                AgentRegistry.get(context.getSource().getServer()).entries().stream().filter(identity -> identity.state() != AgentState.LEGACY && identity.state() != AgentState.RETIRED)
                    .map(AgentIdentity::name), builder)).executes(context -> run(context.getSource(), () -> {
                String name = StringArgumentType.getString(context, "bot");
                AgentIdentity identity = AgentRegistry.get(context.getSource().getServer()).entries().stream()
                    .filter(agent -> agent.name().equalsIgnoreCase(name)).findFirst().orElseThrow(() -> new IllegalArgumentException("Bot desconocido"));
                infect(context.getSource().getServer(), identity);
            }))));
        command.then(literal("etapa").then(argument("numero", IntegerArgumentType.integer(0, 4)).executes(context -> run(context.getSource(), () ->
            advance(context.getSource().getServer(), IntegerArgumentType.getInteger(context, "numero"))))));
        command.then(literal("avanzar").executes(context -> run(context.getSource(), () -> {
            MinecraftServer server = context.getSource().getServer(); HorrorState state = HorrorState.get(server);
            advance(server, Math.min(4, state.stage(server.overworld().getGameTime(), config(server)) + 1));
        })));
        command.then(literal("spawn").executes(context -> run(context.getSource(), () -> spawn(context.getSource().getServer()))));
        command.then(literal("skin").then(argument("aspecto", StringArgumentType.word())
            .suggests((context, builder) -> net.minecraft.commands.SharedSuggestionProvider.suggest(List.of("Steve", "default", "host"), builder))
            .executes(context -> run(context.getSource(), () -> appearance(context.getSource().getServer(), StringArgumentType.getString(context, "aspecto"))))));
        command.then(literal("lore").executes(context -> previewEncounter(context.getSource(), true)));
        command.then(literal("persecucion").executes(context -> previewEncounter(context.getSource(), false)));
        command.then(literal("postura")
            .then(literal("encorvado").executes(context -> run(context.getSource(), () -> requiredBody(context.getSource()).setBodyPosture(ParasiteEntity.BodyPosture.HUNCHED))))
            .then(literal("arrastre").executes(context -> run(context.getSource(), () -> requiredBody(context.getSource()).setBodyPosture(ParasiteEntity.BodyPosture.CRAWL)))));
        command.then(literal("decir").then(argument("mensaje", StringArgumentType.greedyString()).executes(context -> run(context.getSource(), () -> {
            CommandSourceStack source = context.getSource();
            HorrorSpeech.speak(requiredBody(source), StringArgumentType.getString(context, "mensaje")).whenComplete((delivered, failure) -> source.getServer().execute(() -> {
                if (failure != null || !Boolean.TRUE.equals(delivered)) source.sendFailure(Component.literal("La voz no pudo entregarse a un jugador cercano."));
            }));
        }))));
        command.then(literal("tools")
            .then(literal("estado").executes(context -> infectedToolStatus(context.getSource())))
            .then(literal("proponer").then(argument("manifiesto", StringArgumentType.greedyString()).executes(context -> run(context.getSource(), () -> {
                var source = context.getSource(); var manifest = InfectedTools.get(source.getServer()).propose(requiredBody(source), StringArgumentType.getString(context, "manifiesto"));
                source.sendSuccess(() -> Component.literal("Propuesta corporal: " + manifest.id() + " v" + manifest.version() + ". Debe completar pruebas útiles."), false);
            }))))
            .then(literal("usar").then(argument("id", StringArgumentType.word()).suggests((context, builder) -> {
                var body = findParasite(context.getSource().getServer());
                return net.minecraft.commands.SharedSuggestionProvider.suggest(body.isEmpty() ? List.<String>of() : InfectedTools.get(context.getSource().getServer())
                    .available(body.get().hostId().orElseThrow()).stream().map(InfectedToolManifest::id).distinct().toList(), builder);
            }).executes(context -> run(context.getSource(), () -> {
                var source = context.getSource(); String id = StringArgumentType.getString(context, "id");
                InfectedTools.get(source.getServer()).startLatest(requiredBody(source), id).whenComplete((outcome, failure) -> source.getServer().execute(() -> {
                    if (failure != null || outcome != InfectedTools.Outcome.SUCCEEDED) source.sendFailure(Component.literal("La herramienta corporal terminó sin éxito útil."));
                    else source.sendSuccess(() -> Component.literal("Herramienta corporal completada: " + id), false);
                }));
            })))));
        root.then(command);
    }
    private static int intensity(CommandSourceStack source, Integer percentage) {
        HorrorPacing pacing = HorrorPacing.get(source.getServer());
        if (percentage != null) {
            pacing.intensity(percentage);
            source.getServer().overworld().getDataStorage().save();
            if (percentage == 0) findParasite(source.getServer()).ifPresent(body -> body.releaseAudience());
        }
        source.sendSuccess(() -> Component.literal("Intensidad: " + pacing.intensity()
            + "% · /Raps intensity 0..100 · 0 desactiva el acoso."), false);
        return 1;
    }
    private static ParasiteEntity requiredBody(CommandSourceStack source) {
        return findParasite(source.getServer()).orElseThrow(() -> new IllegalStateException("No hay un parásito activo en los chunks cargados"));
    }
    // La elección puede guardarse antes del meteorito o la infección y no necesita red ni un humano cerca.
    public static void appearance(MinecraftServer server, String choice) {
        HorrorState.get(server).appearanceOverride(SkinSources.parasiteOverride(choice));
        durableState(server);
        findParasite(server).ifPresent(body -> SkinProfiles.syncParasiteSkin(server, body));
    }
    private static int infectedToolStatus(CommandSourceStack source) {
        try {
            ParasiteEntity body = requiredBody(source); InfectedTools tools = InfectedTools.get(source.getServer());
            List<InfectedToolManifest> available = tools.available(body.hostId().orElseThrow());
            if (available.isEmpty()) source.sendSuccess(() -> Component.literal("El huésped todavía no tiene herramientas corporales."), false);
            for (InfectedToolManifest manifest : available) {
                var stats = tools.statistics(manifest.id(), manifest.version());
                source.sendSuccess(() -> Component.literal(manifest.id() + " v" + manifest.version() + " · " + (stats.certified() ? "certificada" : "prueba del autor")
                    + " · éxitos=" + stats.successes() + " fallos=" + stats.failures() + " · reutilizaciones=" + stats.reuseSuccesses()), false);
            }
            return 1;
        } catch (RuntimeException failure) { source.sendFailure(Component.literal(failure.getMessage())); return 0; }
    }
    // Las vistas previas conservan sensibilidad y elegibilidad; la consola debe tener un humano cerca.
    private static int previewEncounter(CommandSourceStack source, boolean lore) {
        return run(source, () -> {
            ParasiteEntity entity = findParasite(source.getServer()).orElseThrow(() -> new IllegalStateException("No hay un parásito activo en los chunks cargados"));
            ServerPlayer viewer;
            if (source.getEntity() instanceof ServerPlayer player) {
                if (!mayTarget(source.getServer(), player)) throw new IllegalStateException("Desactiva el modo seguro y usa supervivencia para esta vista previa");
                viewer = player;
            } else {
                viewer = ((ServerLevel) entity.level()).players().stream().filter(player -> mayTarget(source.getServer(), player))
                    .filter(player -> entity.distanceToSqr(player) <= 32 * 32).min(Comparator.comparingDouble(entity::distanceToSqr))
                    .orElseThrow(() -> new IllegalStateException("Debe haber un jugador en supervivencia sin modo seguro cerca del parásito"));
            }
            if (lore) {
                if (!entity.beginEncounter(viewer)) throw new IllegalStateException("La escena requiere etapa 1, distancia visible de hasta 32 bloques y que termine su tiempo de espera");
            } else {
                if (entity.getMutationStage() < 2) throw new IllegalStateException("La persecución requiere etapa 2 o superior");
                if (viewer.level() != entity.level() || entity.distanceToSqr(viewer) > 32 * 32)
                    throw new IllegalStateException("Acércate al parásito para iniciar la persecución");
                entity.setTarget(viewer); entity.setIntent(ParasiteEntity.Intent.AMBUSH, 1200);
            }
        });
    }
    private static int infectRandom(CommandSourceStack source) {
        return run(source, () -> {
            List<AgentIdentity> eligible = AgentRegistry.get(source.getServer()).entries().stream()
                .filter(agent -> agent.state() != AgentState.LEGACY && agent.state() != AgentState.RETIRED).toList();
            if (eligible.isEmpty()) throw new IllegalStateException("No hay bots disponibles");
            infect(source.getServer(), eligible.get(source.getServer().overworld().random.nextInt(eligible.size())));
        });
    }
    private static void advance(MinecraftServer server, int stage) {
        HorrorState state = HorrorState.get(server); int current = state.stage(server.overworld().getGameTime(), config(server));
        if (stage < current) throw new IllegalArgumentException("La mutación no retrocede");
        state.forceStage(stage); durableState(server); findParasite(server).ifPresent(entity -> entity.setMutationStage(stage));
    }
    private static int status(CommandSourceStack source) {
        HorrorState state = HorrorState.get(source.getServer());
        source.sendSuccess(() -> Component.literal("Meteorito: " + state.meteor().map(pos -> pos.getX() + " " + pos.getY() + " " + pos.getZ()).orElse("pendiente")
            + " · Huésped: " + (state.active() ? state.hostName() : "ninguno") + " · Etapa: "
            + state.stage(source.getServer().overworld().getGameTime(), config(source.getServer()))
            + " · Cuerpo: " + (state.dead() ? "muerto" : findParasite(source.getServer()).isPresent() ? "activo" : "guardado/pendiente")), false);
        return 1;
    }
    private static int run(CommandSourceStack source, Runnable action) {
        try { action.run(); source.sendSuccess(() -> Component.literal("Acontecimiento actualizado."), false); return 1; }
        catch (RuntimeException failure) { source.sendFailure(Component.literal(failure.getMessage())); return 0; }
    }

    // Archivos fijos y atómicos; ninguna ruta puede provenir de Luna, del chat o de una herramienta.
    static void durableState(MinecraftServer server) {
        HorrorState state = HorrorState.get(server);
        CompoundTag root = new CompoundTag(); root.put("data", state.save(new CompoundTag()));
        NbtUtils.addCurrentDataVersion(root);
        writeAtomic(server.getWorldPath(LevelResource.ROOT).resolve("data").resolve("raps_parasite.dat"), root);
        state.setDirty(false);
    }
    private static void durablePlayer(MinecraftServer server, ServerPlayer player) {
        CompoundTag snapshot = player.saveWithoutId(new CompoundTag());
        writeAtomic(server.getWorldPath(LevelResource.PLAYER_DATA_DIR).resolve(player.getUUID() + ".dat"), snapshot);
    }
    private static void writeAtomic(Path file, CompoundTag tag) {
        Path temp = file.resolveSibling(file.getFileName() + ".tmp");
        try {
            Files.createDirectories(file.getParent());
            if (Files.isSymbolicLink(file) || Files.isSymbolicLink(temp)) throw new IOException("Archivo enlazado no permitido");
            NbtIo.writeCompressed(tag, temp.toFile());
            try { Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); }
            catch (AtomicMoveNotSupportedException unsupported) { Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING); }
        } catch (IOException failure) { throw new IllegalStateException("No se pudo guardar la transferencia del parásito", failure); }
    }
}