package ar.com.companeros.horror; import ar.com.companeros.Companeros; import ar.com.companeros.WorkArea; import ar.com.companeros.identity.*; import java.util.UUID; import net.minecraft.core.BlockPos; import net.minecraft.gametest.framework.*; import net.minecraft.nbt.*; import net.minecraft.world.entity.EntityType; import net.minecraft.world.item.*; import net.minecraft.world.level.block.Blocks; import net.minecraftforge.gametest.*; /** Persistencia, exclusión de huéspedes y seguridad se prueban sin llamadas a un modelo. */ @GameTestHolder(Companeros.ID) @PrefixGameTestTemplate(false) public final class HorrorTests { @GameTest(template = "empty", timeoutTicks = 20) public void savedWorldClockAndSingleHostSurviveRestart(GameTestHelper helper) { HorrorConfig config = new HorrorConfig(true, 100, 50, 20, 4, 32); HorrorState initial = new HorrorState(500); helper.assertTrue(!initial.meteorDue(599, config) && initial.meteorDue(600, config), "El meteorito espera tiempo guardado del mundo"); initial.landed(new BlockPos(120, 64, 120), 600); HorrorState state = HorrorState.load(initial.save(new CompoundTag())); helper.assertTrue(!state.meteorDue(10_000, config) && !state.infectionDue(649, config) && state.infectionDue(650, config), "El reinicio no repite el meteorito ni adelanta la infección"); UUID host = UUID.randomUUID(); state.selectHost(host, "TestHost", 650); state.selectHost(host, "TestHost", 900); helper.assertTrue(state.infectionTime() == 650 && !state.infectionDue(10_000, config), "La elección es idempotente y no contagia otro bot"); try { state.selectHost(UUID.randomUUID(), "OtherHost", 900); helper.fail("Debe rechazarse otro huésped"); } catch (IllegalStateException expected) { } state.forceStage(2); HorrorState restarted = HorrorState.load(state.save(new CompoundTag())); helper.assertTrue(restarted.host().orElseThrow().equals(host) && restarted.stage(650, config) == 2 && restarted.stage(710, config) == 3 && restarted.stage(10_000, config) == 4, "El huésped y las fases permanecen después del reinicio y alcanzan un máximo"); helper.succeed(); } @GameTest(template = "empty", timeoutTicks = 20) public void quarantineIsSealedAndTransferIdempotent(GameTestHelper helper) { HorrorState state = new HorrorState(0); UUID id = UUID.randomUUID(); state.selectHost(id, "Iron", 10); CompoundTag snapshot = new CompoundTag(); ListTag inventory = new ListTag(); CompoundTag stack = new ItemStack(Items.IRON_INGOT, 7).save(new CompoundTag()); stack.putByte("Slot", (byte) 0); inventory.add(stack); snapshot.put("Inventory", inventory); snapshot.put("EnderItems", new ListTag()); state.prepareQuarantine(snapshot, "minecraft:overworld", new BlockPos(1, 64, 1)); inventory.clear(); helper.assertTrue(state.quarantine().getList("Inventory", Tag.TAG_COMPOUND).getCompound(0).getByte("Count") == 7, "El depósito copia el inventario y no depende de una referencia mutable"); CompoundTag escaped = state.quarantine(); escaped.getList("Inventory", Tag.TAG_COMPOUND).clear(); state.commitTransfer(); state.commitTransfer(); HorrorState restored = HorrorState.load(state.save(new CompoundTag())); helper.assertTrue(restored.transferred() && restored.quarantine().getList("Inventory", Tag.TAG_COMPOUND).size() == 1, "No se duplican ni borran pertenencias al repetir el commit o reiniciar"); try { restored.prepareQuarantine(snapshot, "minecraft:overworld", BlockPos.ZERO); helper.fail("No se reemplaza un depósito sellado"); } catch (IllegalStateException expected) { } helper.succeed(); } @GameTest(template = "empty", timeoutTicks = 20) public void protectedMeteorSiteNeverDestroysBlocks(GameTestHelper helper) { BlockPos center = helper.absolutePos(new BlockPos(1, 2, 1)); helper.getLevel().setBlock(center, Blocks.DIAMOND_BLOCK.defaultBlockState(), 3); WorkArea.get(helper.getLevel()).protect(center); helper.assertTrue(!Meteorite.land(helper.getLevel(), center, 32) && helper.getLevel().getBlockState(center).is(Blocks.DIAMOND_BLOCK), "El meteorito rechaza el lugar antes de tocar una construcción"); var materials = Meteorite.plan(center).values(); helper.assertTrue(materials.stream().anyMatch(block -> block.is(Blocks.RAW_IRON_BLOCK)) && materials.stream().anyMatch(block -> block.is(Blocks.GRANITE)) && materials.stream().anyMatch(block -> block.is(Blocks.DIORITE)), "El depósito planificado usa minerales físicos"); helper.succeed(); } @GameTest(template = "empty", timeoutTicks = 20) public void sensitivityPersistsAndCreatureNeverTargetsNonHumans(GameTestHelper helper) { UUID player = UUID.randomUUID(); HorrorState state = new HorrorState(0); state.sensitivity(player, true); HorrorState restored = HorrorState.load(state.save(new CompoundTag())); helper.assertTrue(restored.sensitive(player), "El modo sensible queda ligado al UUID tras reiniciar"); restored.sensitivity(player, false); helper.assertTrue(!restored.sensitive(player), "El jugador puede retractarse sin cambiar de identidad"); var pig = EntityType.PIG.create(helper.getLevel()); var entity = HorrorModule.STALKER.get().create(helper.getLevel()); helper.assertTrue(pig != null && entity != null, "Se crean cuerpos de prueba"); entity.setTarget(pig); helper.assertTrue(entity.getTarget() == null && !entity.doHurtTarget(pig) && !HorrorModule.mayTarget(helper.getLevel().getServer(), pig), "Un animal o entidad bot jamás es un objetivo de terror"); helper.succeed(); } @GameTest(template = "empty", timeoutTicks = 20) public void safeHumanAndRegisteredBotRejectTargetsAndDamage(GameTestHelper helper) { var server = helper.getLevel().getServer(); var human = warmedPlayer(helper, UUID.randomUUID(), "SafeHuman"); var entity = HorrorModule.STALKER.get().create(helper.getLevel()); helper.assertTrue(entity != null && HorrorModule.mayTarget(server, human), "Un humano normal es un objetivo posible"); float normalHealth = human.getHealth(); helper.assertTrue(human.hurt(entity.damageSources().mobAttack(entity), 3) && human.getHealth() < normalHealth, "El ataque causa consecuencias físicas sobre un humano sin modo seguro"); entity.setTarget(human); helper.assertTrue(entity.getTarget() == human, "La criatura puede elegir al humano sin modo seguro"); HorrorState.get(server).sensitivity(human.getUUID(), true); entity.setTarget(human); human.invulnerableTime = 0; float before = human.getHealth(); helper.assertTrue(entity.getTarget() == null && !entity.doHurtTarget(human) && !human.hurt(entity.damageSources().mobAttack(entity), 6) && human.getHealth() == before, "El modo seguro bloquea objetivo, golpe directo y daño despachado por eventos"); UUID botId = UUID.randomUUID(); var bot = warmedPlayer(helper, botId, "BotNoFear"); float initialBotHealth = bot.getHealth(); helper.assertTrue(bot.hurt(entity.damageSources().mobAttack(entity), 3) && bot.getHealth() < initialBotHealth, "Antes de registrarse, ese mismo cuerpo recibe daño real como humano"); bot.invulnerableTime = 0; AgentRegistry.get(server).register(botId, "BotNoFear", null, null, "quieto"); AgentRegistry.get(server).markState(botId, AgentState.PAUSED); try { entity.setTarget(bot); helper.assertTrue(entity.getTarget() == null && !entity.doHurtTarget(bot) && !bot.hurt(entity.damageSources().mobAttack(entity), 6), "Los bots no reciben ataques ni daño del parásito"); helper.succeed(); } finally { AgentRegistry.get(server).markState(botId, AgentState.RETIRED); HorrorState.get(server).sensitivity(human.getUUID(), false); } } @GameTest(template = "empty", timeoutTicks = 190) public void realGazeStartsEncounterOnceAndOnlyARealHitAnimates(GameTestHelper helper) { var server = helper.getLevel().getServer(); var human = warmedPlayer(helper, UUID.randomUUID(), "GazeHuman"); var entity = HorrorModule.STALKER.get().create(helper.getLevel()); helper.assertTrue(entity != null, "Existe un cuerpo para la escena"); BlockPos pos = helper.absolutePos(new BlockPos(3, 2, 3)); entity.moveTo(pos.getX() + .5, pos.getY(), pos.getZ() + .5, 0, 0); human.moveTo(entity.getX(), entity.getY(), entity.getZ() + 6, 180, 0); helper.assertTrue(!entity.tryBeginGazeEncounter(human), "La fase humana inicial no dispara terror"); entity.setMutationStage(2); human.setYRot(0); helper.assertTrue(!entity.tryBeginGazeEncounter(human), "Mirar en sentido contrario no activa la escena"); human.setYRot(180); helper.assertTrue(entity.tryBeginGazeEncounter(human) && entity.getEncounterTicks() == 0 && entity.getTarget() == human && human.getYRot() == 180, "Una mirada visible inicia la escena sin alterar la cámara del jugador"); float health = human.getHealth(); helper.assertTrue(!entity.doHurtTarget(human) && human.getHealth() == health && entity.getAttackAnimationTicks() == 0, "La contorsión inicial no daña al jugador ni simula golpes"); CompoundTag saved = new CompoundTag(); entity.addAdditionalSaveData(saved); helper.assertTrue(saved.getLong("NextEncounterAt") >= helper.getLevel().getGameTime() + ParasiteEntity.ENCOUNTER_COOLDOWN, "El encuentro conserva al menos cinco minutos de espera del mundo"); saved.putLong("EncounterStart", -1); saved.remove("EncounterAudience"); var restored = HorrorModule.STALKER.get().create(helper.getLevel()); restored.readAdditionalSaveData(saved); restored.moveTo(entity.getX(), entity.getY(), entity.getZ(), 0, 0); helper.assertTrue(!restored.tryBeginGazeEncounter(human) && restored.getEncounterTicks() == -1, "El tiempo de espera permanece guardado y no repite la voz tras una recarga"); UUID botId = UUID.randomUUID(); var bot = warmedPlayer(helper, botId, "GazeBot"); bot.moveTo(human.getX(), human.getY(), human.getZ(), 180, 0); AgentRegistry.get(server).register(botId, "GazeBot", null, null, "quieto"); AgentRegistry.get(server).markState(botId, AgentState.PAUSED); try { var excluded = HorrorModule.STALKER.get().create(helper.getLevel()); excluded.setMutationStage(2); excluded.moveTo(entity.getX(), entity.getY(), entity.getZ(), 0, 0); helper.assertTrue(!excluded.tryBeginGazeEncounter(bot), "Un bot que mira a la criatura nunca dispara el encuentro"); HorrorState.get(server).sensitivity(human.getUUID(), true); helper.assertTrue(!excluded.tryBeginGazeEncounter(human), "Un humano sensible tampoco dispara el encuentro"); } finally { AgentRegistry.get(server).markState(botId, AgentState.RETIRED); HorrorState.get(server).sensitivity(human.getUUID(), false); } helper.runAfterDelay(141, () -> { human.moveTo(entity.getX(), entity.getY(), entity.getZ() + 1.6, 180, 0); helper.assertTrue(!entity.doHurtTarget(human), "Terminar la introducción no fuerza un ataque sin tensión suficiente"); HorrorPacing pacing = HorrorPacing.get(server); for (int provocation = 0; provocation < 3; provocation++) pacing.provoked(human.getUUID(), helper.getLevel().getGameTime()); entity.setIntent(ParasiteEntity.Intent.AMBUSH, 20); helper.assertTrue(entity.getEncounterTicks() >= ParasiteEntity.ENCOUNTER_INTRO && entity.doHurtTarget(human) && human.getHealth() < health && entity.getAttackAnimationTicks() > 0, "Después de la introducción, un golpe físico exitoso activa la animación breve"); helper.succeed(); }); } @GameTest(batch = "horror_02_encounter", template = "empty", timeoutTicks = 30) public void sensitivityImmediatelyCancelsActiveEncounterAndStopsAudio(GameTestHelper helper) { var server = helper.getLevel().getServer(); HorrorState previous = HorrorState.get(server); var entity = HorrorModule.STALKER.get().create(helper.getLevel()); try { HorrorState state = new HorrorState(helper.getLevel().getGameTime()); server.overworld().getDataStorage().set("raps_parasite", state); UUID host = UUID.randomUUID(); state.selectHost(host, "LoreHost", helper.getLevel().getGameTime()); CompoundTag inventory = new CompoundTag(); inventory.put("Inventory", new ListTag()); inventory.put("EnderItems", new ListTag()); BlockPos pos = helper.absolutePos(new BlockPos(3, 2, 3)); state.prepareQuarantine(inventory, helper.getLevel().dimension().location().toString(), pos); state.commitTransfer(); state.created(); entity.setUUID(state.entityId().orElseThrow()); entity.setHost(host, "LoreHost"); entity.setMutationStage(2); entity.moveTo(pos.getX() + .5, pos.getY(), pos.getZ() + .5, 0, 0); helper.assertTrue(helper.getLevel().addFreshEntity(entity), "El cuerpo canónico se añade al mundo"); var human = warmedPlayer(helper, UUID.randomUUID(), "LoreSafe"); human.moveTo(entity.getX(), entity.getY(), entity.getZ() + 6, 180, 0); helper.assertTrue(entity.beginEncounter(human), "El humano sin sensibilidad inicia una escena real"); float before = human.getHealth(); HorrorModule.setSensitivity(human, true); helper.assertTrue(entity.getTarget() == null && entity.getEncounterTicks() == -1 && !entity.isPursuing() && !entity.doHurtTarget(human) && human.getHealth() == before, "Activar sensibilidad cancela inmediatamente la presa, la escena y cualquier golpe"); helper.assertTrue(((FixtureConnection) human.connection.connection).stoppedSounds == 6, "La preferencia detiene voz, pasos, lore, ataque, trepada y respiración que ya estuvieran sonando"); helper.succeed(); } finally { if (entity != null) entity.discard(); server.overworld().getDataStorage().set("raps_parasite", previous); HorrorModule.durableState(server); } } // Tick oficial: expira la protección de entrada vanilla sin modificar campos privados. private static net.minecraft.server.level.ServerPlayer warmedPlayer(GameTestHelper helper, UUID id, String name) { var player = new net.minecraft.server.level.ServerPlayer(helper.getLevel().getServer(), helper.getLevel(), new com.mojang.authlib.GameProfile(id, name)); player.connection = new net.minecraft.server.network.ServerGamePacketListenerImpl(helper.getLevel().getServer(), new FixtureConnection(), player); for (int tick = 0; tick < 61; tick++) player.tick(); return player; } private static final class FixtureConnection extends net.minecraft.network.Connection { private int stoppedSounds; private FixtureConnection() { super(net.minecraft.network.protocol.PacketFlow.SERVERBOUND); } @Override public void send(net.minecraft.network.protocol.Packet packet) { if (packet instanceof net.minecraft.network.protocol.game.ClientboundStopSoundPacket) stoppedSounds++; } @Override public void send(net.minecraft.network.protocol.Packet packet, net.minecraft.network.PacketSendListener listener) { send(packet); } } @GameTest(batch = "horror_01_transfer", template = "empty", timeoutTicks = 40) public void realHostTransferSurvivesSavedStateAndNeverDuplicatesInventory(GameTestHelper helper) { var server = helper.getLevel().getServer(); HorrorState previous = HorrorState.get(server); UUID id = UUID.randomUUID(); String name = "Hp_" + id.toString().substring(0, 8); ParasiteEntity parasite = null; try { server.overworld().getDataStorage().set("raps_parasite", new HorrorState(server.overworld().getGameTime())); for (int x = 0; x <= 13; x++) for (int z = 0; z <= 13; z++) helper.setBlock(new BlockPos(x, 1, z), Blocks.DIRT); BlockPos position = helper.absolutePos(new BlockPos(3, 2, 3)); AgentRegistry registry = AgentRegistry.get(server); registry.register(id, name, null, net.minecraft.core.GlobalPos.of(helper.getLevel().dimension(), position), "quieto"); registry.markState(id, AgentState.ACTIVE); var player = ar.com.companeros.body.PlayerSessions.create(server, id, name, helper.getLevel(), position); player.getInventory().setItem(0, new ItemStack(Items.IRON_INGOT, 7)); player.getEnderChestInventory().setItem(0, new ItemStack(Items.DIAMOND, 2)); ar.com.companeros.memory.MemoryStore memory = ar.com.companeros.memory.MemoryStore.get(server); UUID memoryId = UUID.randomUUID(); long now = System.currentTimeMillis(); memory.record(new ar.com.companeros.memory.MemoryRecord(memoryId, id, registry.worldId(), net.minecraft.core.GlobalPos.of(helper.getLevel().dimension(), position), now, helper.getLevel().getGameTime(), ar.com.companeros.memory.MemoryType.EXPERIENCE, 1, 1, now, helper.getLevel().getGameTime(), ar.com.companeros.memory.MemoryOrigin.OBSERVED, "horror-fixture", java.util.List.of(), "Recuerdo anterior a la infección", java.util.Set.of("identity"), java.util.Set.of(), null, true)); // Simula reinicio con depósito preparado pero inventario todavía intacto. HorrorState pending = HorrorState.get(server); pending.selectHost(id, name, helper.getLevel().getGameTime()); pending.prepareQuarantine(player.saveWithoutId(new CompoundTag()), helper.getLevel().dimension().location().toString(), position); server.overworld().getDataStorage().set("raps_parasite", HorrorState.load(pending.save(new CompoundTag()))); HorrorModule.infect(server, registry.find(id).orElseThrow()); HorrorState committed = HorrorState.get(server); parasite = HorrorModule.findParasite(server).orElseThrow(); UUID bodyId = parasite.getUUID(); helper.assertTrue(committed.transferred() && committed.entityCreated() && ar.com.companeros.body.PlayerSessions.find(server, id) == null && parasite.hostId().orElseThrow().equals(id), "El cuerpo único conserva el UUID de su huésped y cierra su sesión normal"); CompoundTag savedPlayer = NbtIo.readCompressed(server.getWorldPath(net.minecraft.world.level.storage.LevelResource.PLAYER_DATA_DIR).resolve(id + ".dat").toFile()); helper.assertTrue(savedPlayer.getList("Inventory", Tag.TAG_COMPOUND).isEmpty() && savedPlayer.getList("EnderItems", Tag.TAG_COMPOUND).isEmpty() && committed.quarantine().getList("Inventory", Tag.TAG_COMPOUND).getCompound(0).getByte("Count") == 7 && committed.quarantine().getList("EnderItems", Tag.TAG_COMPOUND).getCompound(0).getByte("Count") == 2, "El playerdata queda vacío y las pertenencias reales existen únicamente en cuarentena"); server.overworld().getDataStorage().set("raps_parasite", HorrorState.load(committed.save(new CompoundTag()))); HorrorModule.infect(server, registry.find(id).orElseThrow()); helper.assertTrue(HorrorModule.spawn(server).getUUID().equals(bodyId) && HorrorModule.findParasite(server).orElseThrow() == parasite && memory.find(memoryId).isPresent() && registry.find(id).orElseThrow().id().equals(id), "Repetir infección/entrada tras recarga no duplica cuerpo, objetos ni borra memoria"); helper.succeed(); } catch (java.io.IOException failure) { throw new IllegalStateException(failure); } finally { if (parasite != null) parasite.discard(); ar.com.companeros.body.PlayerSessions.remove(server, id); if (AgentRegistry.get(server).find(id).isPresent()) AgentRegistry.get(server).markState(id, AgentState.RETIRED); server.overworld().getDataStorage().set("raps_parasite", previous); HorrorModule.durableState(server); } } }