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> ENTITIES = DeferredRegister.create(ForgeRegistries.ENTITY_TYPES, Companeros.ID); private static final DeferredRegister SOUNDS = DeferredRegister.create(ForgeRegistries.SOUND_EVENTS, Companeros.ID); public static final RegistryObject> 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 PARASITE_VOICE = SOUNDS.register("parasite_voice", () -> SoundEvent.createVariableRangeEvent(new ResourceLocation(Companeros.ID, "parasite_voice"))); public static final RegistryObject PARASITE_STEP = SOUNDS.register("parasite_step", () -> SoundEvent.createVariableRangeEvent(new ResourceLocation(Companeros.ID, "parasite_step"))); public static final RegistryObject PARASITE_LORE = SOUNDS.register("parasite_lore", () -> SoundEvent.createVariableRangeEvent(new ResourceLocation(Companeros.ID, "parasite_lore"))); public static final RegistryObject PARASITE_ATTACK = SOUNDS.register("parasite_attack", () -> SoundEvent.createVariableRangeEvent(new ResourceLocation(Companeros.ID, "parasite_attack"))); public static final RegistryObject PARASITE_CLIMB = SOUNDS.register("parasite_climb", () -> SoundEvent.createVariableRangeEvent(new ResourceLocation(Companeros.ID, "parasite_climb"))); public static final RegistryObject PARASITE_CHASE = SOUNDS.register("parasite_chase", () -> SoundEvent.createVariableRangeEvent(new ResourceLocation(Companeros.ID, "parasite_chase"))); private static final Map 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 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 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 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 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 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 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.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 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 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); } } }