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