Volver al índice

src/main/java/ar/com/companeros/horror/tools/InfectedToolTests.java

package ar.com.companeros.horror.tools;

import ar.com.companeros.Companeros;
import ar.com.companeros.horror.*;
import com.google.gson.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.*;
import net.minecraft.nbt.*;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.gametest.*;

/** La certificación se prueba con locomoción del cuerpo real, nunca un booleano dado por Luna. */
@GameTestHolder(Companeros.ID)
@PrefixGameTestTemplate(false)
public final class InfectedToolTests {
    @GameTest(template = "empty")
    public void bodyPoseCatalogIsBoundedAndAcceptsLegacyTools(GameTestHelper helper) {
        for (String mode : List.of("auto", "hunched", "crawl", "all_fours", "wall_brace")) {
            JsonObject args = new JsonObject(); args.addProperty("mode", mode);
            InfectedToolManifest.validatePrimitive("posture", args, 1, false);
            args.addProperty("durationTicks", 1200);
            InfectedToolManifest.validatePrimitive("posture", args, 1, false);
        }
        JsonObject args = new JsonObject(); args.addProperty("mode", "wall_brace"); args.addProperty("durationTicks", 1201);
        rejected(helper, () -> InfectedToolManifest.validatePrimitive("posture", args, 1, false), "Una herramienta no deja una postura activa indefinidamente");
        args.addProperty("durationTicks", 20); args.addProperty("block", "inventado");
        rejected(helper, () -> InfectedToolManifest.validatePrimitive("posture", args, 1, false), "No acepta soportes ni coordenadas elegidos por el modelo");
        args.remove("block"); args.addProperty("mode", "teleport");
        rejected(helper, () -> InfectedToolManifest.validatePrimitive("posture", args, 1, false), "La preferencia pertenece a un repertorio cerrado");
        helper.succeed();
    }

    private static final UUID AUTHOR = UUID.fromString("00000000-0000-0000-0000-000000000973");

    @GameTest(template = "empty")
    public void bodySandboxRejectsExternalActionsSafetyChangesAndUnverifiedDependencies(GameTestHelper helper) throws Exception {
        Path folder = folder(); InfectedTools tools = new InfectedTools(folder); JsonObject source = manifest("pending_body", false);
        InfectedToolManifest pending = tools.proposeForAuthor(AUTHOR, source.toString());
        JsonObject changed = source.deepCopy(); changed.addProperty("description", "Sustituido");
        rejected(helper, () -> tools.proposeForAuthor(AUTHOR, changed.toString()), "No sustituye una versión existente");
        for (String forbidden : List.of("shell", "write_file", "set_stage", "set_sensitivity", "set_attribute", "give_item")) {
            JsonObject invalid = source.deepCopy(); invalid.addProperty("id", "bad_" + forbidden);
            invalid.getAsJsonArray("steps").get(0).getAsJsonObject().addProperty("primitive", forbidden);
            rejected(helper, () -> tools.proposeForAuthor(AUTHOR, invalid.toString()), "Rechaza " + forbidden + " antes de ejecutar");
        }
        JsonObject uuid = source.deepCopy(); uuid.addProperty("id", "target_spoof");
        uuid.getAsJsonArray("steps").get(0).getAsJsonObject().getAsJsonObject("args").addProperty("targetUUID", UUID.randomUUID().toString());
        rejected(helper, () -> tools.proposeForAuthor(AUTHOR, uuid.toString()), "No admite elegir UUID ni pasar argumentos ocultos");
        JsonObject spoof = source.deepCopy(); spoof.addProperty("author", UUID.randomUUID().toString());
        rejected(helper, () -> tools.proposeForAuthor(AUTHOR, spoof.toString()), "El JSON no suplanta al autor del servidor");
        JsonObject dependency = new JsonObject(); dependency.addProperty("id", pending.id()); dependency.addProperty("version", pending.version()); dependency.addProperty("sha256", pending.sha256());
        JsonArray dependencies = new JsonArray(); dependencies.add(dependency);
        JsonObject composed = source.deepCopy(); composed.addProperty("id", "unverified_child"); composed.add("dependencies", dependencies);
        rejected(helper, () -> tools.proposeForAuthor(AUTHOR, composed.toString()), "Una propuesta no es una dependencia certificada");
        helper.assertTrue(tools.available(UUID.randomUUID()).isEmpty() && !tools.statistics(pending.id(), 1).certified(),
            "La propuesta pertenece sólo a su autor y no tiene éxitos inventados");
        InfectedTools restored = new InfectedTools(folder);
        helper.assertTrue(restored.available(AUTHOR).size() == 1 && restored.statistics(pending.id(), 1).successes() == 0,
            "La propuesta recuperada conserva autor y cero pruebas");
        helper.succeed();
    }

    @GameTest(template = "empty")
    public void signedBodyLedgerRejectsForgedSuccessAndMissingKey(GameTestHelper helper) throws Exception {
        Path folder = folder(); InfectedTools tools = new InfectedTools(folder); tools.proposeForAuthor(AUTHOR, manifest("signed_body", false).toString());
        Path ledger = folder.resolve("learning.json"); String original = Files.readString(ledger, StandardCharsets.UTF_8);
        JsonObject modified = JsonParser.parseString(original).getAsJsonObject();
        JsonObject row = modified.getAsJsonObject("content").getAsJsonArray("tools").get(0).getAsJsonObject();
        row.addProperty("successes", 3); row.addProperty("certified", true);
        Files.writeString(ledger, modified.toString(), StandardCharsets.UTF_8);
        boolean blocked = false;
        try { new InfectedTools(folder); } catch (IOException expected) { blocked = true; }
        helper.assertTrue(blocked, "Editar estadísticas no concede una certificación corporal");
        Files.writeString(ledger, original, StandardCharsets.UTF_8);
        Files.move(folder.resolve("certification.key"), folder.resolve("original.key"));
        blocked = false;
        try { new InfectedTools(folder); } catch (IOException expected) { blocked = true; }
        helper.assertTrue(blocked && !Files.exists(folder.resolve("certification.key")), "No reemplaza la clave para aprobar un registro existente");
        helper.succeed();
    }

    @GameTest(batch = "horror_03_body_tools", template = "empty", timeoutTicks = 280)
    public void physicalBodyTrialsCertifyPersistReuseAndRunByEventWithoutLuna(GameTestHelper helper) throws Exception {
        Fixture fixture = new Fixture(helper); Path folder = folder(); InfectedTools tools = new InfectedTools(folder);
        InfectedToolManifest source = tools.propose(fixture.body, manifest("physical_retreat", true).toString());
        InfectedToolManifest pending = tools.propose(fixture.body, manifest("private_wait", false).toString());
        helper.assertTrue(!tools.available(UUID.randomUUID()).contains(source), "Un huésped distinto no ve la herramienta pendiente");
        trial(helper, fixture, tools, source, 0, () -> {
            try {
                helper.assertTrue(tools.statistics(source.id(), 1).successes() == 3 && tools.statistics(source.id(), 1).certified(),
                    "Tres retiradas con desplazamiento físico certifican el hash observado");
                InfectedTools restored = new InfectedTools(folder);
                helper.assertTrue(restored.statistics(source.id(), 1).certified() && restored.statistics(source.id(), 1).successes() == 3,
                    "La firma y los recibos recuperan la capacidad tras reiniciar");
                fixture.resetBody(); fixture.body.setTarget(null); restored.advance(fixture.server());
                fixture.body.setTarget(fixture.human); restored.advance(fixture.server());
                helper.assertTrue(!restored.busy() && fixture.body.getTarget() == null,
                    "La política aprendida respeta el descanso del humano anterior");
                fixture.freshWitness(); fixture.body.setTarget(fixture.human); restored.advance(fixture.server());
                helper.assertTrue(restored.busy(), "Ver una nueva presa disponible dispara la política certificada sin llamar a un modelo");
                awaitIdle(helper, fixture, restored, () -> {
                    helper.assertTrue(restored.statistics(source.id(), 1).reuseSuccesses() == 1, "La ejecución automática también obtiene un resultado físico real");
                    fixture.replaceHost(); fixture.resetBody();
                    rejected(helper, () -> restored.start(fixture.body, pending.id(), pending.version(), Map.of()), "El nuevo huésped no prueba herramientas privadas del anterior");
                    CompletableFuture<InfectedTools.Outcome> reused = restored.start(fixture.body, source.id(), source.version(), Map.of());
                    await(helper, fixture, restored, reused, () -> {
                        helper.assertTrue(reused.join() == InfectedTools.Outcome.SUCCEEDED && restored.statistics(source.id(), 1).reuseSuccesses() == 2,
                            "Otro huésped canónico reutiliza la herramienta certificada con su propio cuerpo");
                        fixture.close(); helper.succeed();
                    });
                });
            } catch (Throwable failure) { tools.cancelCurrent(); fixture.close(); throw unchecked(failure); }
        });
        helper.runAfterDelay(250, () -> { if (!fixture.closed) { tools.cancelCurrent(); fixture.close(); helper.fail("La prueba corporal excedió su plazo"); } });
    }

    @GameTest(batch = "horror_04_body_noop", template = "empty", timeoutTicks = 50)
    public void waitingDoesNotCertifyAndConcurrentOrNonCanonicalBodiesAreRejected(GameTestHelper helper) throws Exception {
        Fixture fixture = new Fixture(helper); InfectedTools tools = new InfectedTools(folder());
        InfectedToolManifest source = tools.propose(fixture.body, manifest("noop_body", false).toString());
        CompletableFuture<InfectedTools.Outcome> running = tools.start(fixture.body, source.id(), 1, Map.of());
        rejected(helper, () -> tools.start(fixture.body, source.id(), 1, Map.of()), "No hay dos herramientas simultáneas en el mismo cuerpo");
        var impostor = HorrorModule.STALKER.get().create(helper.getLevel()); impostor.setHost(fixture.body.hostId().orElseThrow(), "FakeHost");
        rejected(helper, () -> tools.propose(impostor, manifest("impostor_body", false).toString()), "El UUID del huésped no basta para suplantar al cuerpo canónico");
        await(helper, fixture, tools, running, () -> {
            helper.assertTrue(running.join() == InfectedTools.Outcome.FAILED && tools.statistics(source.id(), 1).successes() == 0
                && tools.statistics(source.id(), 1).failures() == 1 && !tools.statistics(source.id(), 1).certified(),
                "Esperar realmente sin progreso no fabrica pruebas de utilidad");
            fixture.close(); helper.succeed();
        });
    }

    // Las ejecuciones avanzan por ticks; el movimiento lo realiza la IA y navegación reales del mob.
    private static void trial(GameTestHelper helper, Fixture fixture, InfectedTools tools, InfectedToolManifest source, int index, Runnable done) {
        fixture.resetBody(); CompletableFuture<InfectedTools.Outcome> running = tools.start(fixture.body, source.id(), 1, Map.of());
        await(helper, fixture, tools, running, () -> {
            helper.assertTrue(running.join() == InfectedTools.Outcome.SUCCEEDED && tools.statistics(source.id(), 1).successes() == index + 1,
                "La prueba " + (index + 1) + " tiene un recibo único y desplazamiento físico");
            if (index < 2) trial(helper, fixture, tools, source, index + 1, done); else done.run();
        });
    }
    private static void await(GameTestHelper helper, Fixture fixture, InfectedTools tools, CompletableFuture<?> running, Runnable done) {
        helper.runAfterDelay(1, () -> {
            try { tools.advance(fixture.server()); if (running.isDone()) done.run(); else await(helper, fixture, tools, running, done); }
            catch (Throwable failure) { tools.cancelCurrent(); fixture.close(); throw unchecked(failure); }
        });
    }
    private static void awaitIdle(GameTestHelper helper, Fixture fixture, InfectedTools tools, Runnable done) {
        helper.runAfterDelay(1, () -> {
            try { tools.advance(fixture.server()); if (!tools.busy()) done.run(); else awaitIdle(helper, fixture, tools, done); }
            catch (Throwable failure) { tools.cancelCurrent(); fixture.close(); throw unchecked(failure); }
        });
    }
    private static final class Fixture implements AutoCloseable {
        final GameTestHelper helper; final HorrorState previous; final BlockPos position;
        ServerPlayer human;
        ParasiteEntity body; boolean closed;
        Fixture(GameTestHelper helper) {
            this.helper = helper; previous = HorrorState.get(server());
            for (int x = 0; x <= 13; x++) for (int z = 0; z <= 13; z++) helper.setBlock(new BlockPos(x, 1, z), Blocks.DIRT);
            position = helper.absolutePos(new BlockPos(6, 2, 6));
            freshWitness();
            replaceHost();
        }
        void freshWitness() {
            human = new ServerPlayer(server(), helper.getLevel(), new com.mojang.authlib.GameProfile(UUID.randomUUID(), "BodyWitness"));
            human.connection = new net.minecraft.server.network.ServerGamePacketListenerImpl(server(), new FixtureConnection(), human);
            for (int tick = 0; tick < 61; tick++) human.tick();
            human.moveTo(position.getX() + .5, position.getY(), position.getZ() + 6.5, 0, 0);
        }
        net.minecraft.server.MinecraftServer server() { return helper.getLevel().getServer(); }
        void replaceHost() {
            if (body != null) body.discard();
            HorrorState state = new HorrorState(helper.getLevel().getGameTime()); server().overworld().getDataStorage().set("raps_parasite", state);
            UUID host = UUID.randomUUID(); String name = "It_" + host.toString().substring(0, 8); state.selectHost(host, name, helper.getLevel().getGameTime());
            CompoundTag inventory = new CompoundTag(); inventory.put("Inventory", new ListTag()); inventory.put("EnderItems", new ListTag());
            state.prepareQuarantine(inventory, helper.getLevel().dimension().location().toString(), position); state.commitTransfer(); state.created();
            body = HorrorModule.STALKER.get().create(helper.getLevel()); body.setUUID(state.entityId().orElseThrow()); body.setHost(host, name); body.setMutationStage(2);
            resetBody(); if (!helper.getLevel().addFreshEntity(body)) throw new IllegalStateException("No se añadió el cuerpo de prueba");
        }
        void resetBody() {
            if (!ar.com.companeros.horror.HorrorPacing.get(server()).available(human.getUUID(), helper.getLevel().getGameTime())) freshWitness();
            body.getNavigation().stop(); body.setDeltaMovement(Vec3.ZERO); body.moveTo(position.getX() + .5, position.getY(), position.getZ() + .5, 0, 0);
            body.setIntent(ParasiteEntity.Intent.STALK, 20); body.setTarget(human);
        }
        @Override public void close() {
            if (closed) return; closed = true; body.discard(); server().overworld().getDataStorage().set("raps_parasite", previous);
        }
    }
    private static final class FixtureConnection extends net.minecraft.network.Connection {
        FixtureConnection() { super(net.minecraft.network.protocol.PacketFlow.SERVERBOUND); }
        @Override public void send(net.minecraft.network.protocol.Packet<?> packet) { }
        @Override public void send(net.minecraft.network.protocol.Packet<?> packet, net.minecraft.network.PacketSendListener listener) { }
    }
    private static JsonObject manifest(String id, boolean movement) {
        JsonObject source = new JsonObject(); source.addProperty("schema", 1); source.addProperty("id", id); source.addProperty("version", 1);
        source.addProperty("description", "Composición corporal de prueba con resultados observables");
        JsonArray permissions = new JsonArray(); permissions.add("BODY_MOVE"); source.add("permissions", permissions); source.add("dependencies", new JsonArray());
        JsonObject limits = new JsonObject(); limits.addProperty("maxTicks", 50); limits.addProperty("maxSteps", 2); source.add("limits", limits);
        JsonArray steps = new JsonArray(); steps.add(step("wait", 3, 5));
        if (movement) {
            steps.add(step("retreat", 20, 30)); JsonArray triggers = new JsonArray(); triggers.add("ON_TARGET_VISIBLE"); source.add("triggers", triggers);
        }
        source.add("steps", steps); return source;
    }
    private static JsonObject step(String primitive, int duration, int timeout) {
        JsonObject step = new JsonObject(); step.addProperty("primitive", primitive); step.addProperty("timeoutTicks", timeout);
        JsonObject args = new JsonObject(); args.addProperty("durationTicks", duration); step.add("args", args); return step;
    }
    private static Path folder() { return Path.of("infected-tool-test-output", UUID.randomUUID().toString()).toAbsolutePath(); }
    private static void rejected(GameTestHelper helper, Throwing action, String why) {
        boolean rejected = false; try { action.run(); } catch (IllegalArgumentException | IOException expected) { rejected = true; }
        catch (Exception failure) { throw unchecked(failure); }
        helper.assertTrue(rejected, why);
    }
    private static RuntimeException unchecked(Throwable failure) { return failure instanceof RuntimeException runtime ? runtime : new IllegalStateException(failure); }
    @FunctionalInterface private interface Throwing { void run() throws Exception; }
}