Volver al índice

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

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