Volver al índice
src/main/java/ar/com/companeros/tools/ToolLifecycleTests.java
package ar.com.companeros.tools;
import ar.com.companeros.Companeros;
import ar.com.companeros.body.PlayerSessions;
import ar.com.companeros.identity.*;
import ar.com.companeros.runtime.BuiltinTools;
import ar.com.companeros.world.WorldPrimitiveExecutor;
import com.google.gson.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import net.minecraft.core.BlockPos;
import net.minecraft.core.GlobalPos;
import net.minecraft.gametest.framework.*;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraftforge.gametest.*;
/** Pruebas de aprendizaje: efectos físicos, certificación y archivos persistentes. */
@GameTestHolder(Companeros.ID)
@PrefixGameTestTemplate(false)
public final class ToolLifecycleTests {
private static final UUID AUTHOR = UUID.fromString("00000000-0000-0000-0000-000000000912");
private static final Set<ToolPermission> PERMISSIONS = Set.copyOf(EnumSet.allOf(ToolPermission.class));
@GameTest(template = "empty")
public void pendingSandboxRejectsExternalCapabilitiesAndVersionReplacement(GameTestHelper helper) throws Exception {
Path folder = folder(); ToolRegistry registry = registry(folder);
ToolLearningService learning = new ToolLearningService(registry);
ToolManifest source = manifest("research_bundle", "inspect", new JsonObject(), ToolPermission.WORLD_READ);
learning.propose(AUTHOR, source.toJson().toString());
JsonObject changed = source.toJson(); changed.addProperty("description", "Contenido sustituido");
rejected(helper, () -> learning.propose(AUTHOR, changed.toString()), "La misma versión pendiente no cambia de contenido");
JsonObject web = new JsonObject(); web.addProperty("query", "Minecraft");
rejected(helper, () -> learning.propose(AUTHOR, manifest("network_bundle", "research_web", web, ToolPermission.WORLD_READ).toJson().toString()),
"Una composición no puede activar búsquedas ni servicios externos");
JsonObject proposal = new JsonObject(); proposal.addProperty("manifest", source.toJson().toString());
rejected(helper, () -> learning.propose(AUTHOR, manifest("recursive_bundle", "propose_tool", proposal, ToolPermission.WORLD_READ).toJson().toString()),
"No permite propuestas que generen recursivamente otras herramientas");
rejected(helper, () -> learning.propose(AUTHOR, manifest("shell_bundle", "shell", new JsonObject(), ToolPermission.WORLD_READ).toJson().toString()),
"No existen primitivas de sistema operativo");
rejected(helper, () -> learning.propose(AUTHOR, manifest("inspect", "inspect", new JsonObject(), ToolPermission.WORLD_READ).toJson().toString()),
"No puede suplantar las herramientas instaladas por el mod");
helper.assertTrue(registry.certifiedTools().stream().noneMatch(tool -> tool.id().equals(source.id())),
"La validación estructural no equivale a ejecutar o certificar");
ToolLearningService restored = new ToolLearningService(registry(folder));
helper.assertTrue(restored.pending(AUTHOR, source.id(), 1).isPresent()
&& restored.statistics(source.sha256()).successes() == 0,
"La propuesta se recupera con autor propio y ninguna prueba inventada");
helper.succeed();
}
@GameTest(template = "empty")
public void alteredLedgerAndMissingSigningKeyGrantNoCertification(GameTestHelper helper) throws Exception {
Path folder = folder(); ToolRegistry registry = registry(folder);
ToolLearningService learning = new ToolLearningService(registry);
ToolManifest tool = manifest("tamper_bundle", "inspect", new JsonObject(), ToolPermission.WORLD_READ);
learning.propose(AUTHOR, tool.toJson().toString());
Path ledger = folder.resolve("learning.json");
String original = Files.readString(ledger, StandardCharsets.UTF_8);
JsonObject altered = JsonParser.parseString(original).getAsJsonObject();
JsonObject row = altered.getAsJsonObject("content").getAsJsonArray("tools").get(0).getAsJsonObject();
row.addProperty("successes", 3); row.addProperty("certified", true);
Files.writeString(ledger, altered.toString(), StandardCharsets.UTF_8);
ToolRegistry clean = registry(folder);
boolean blocked = false;
try { new ToolLearningService(clean); } catch (IOException expected) { blocked = true; }
helper.assertTrue(blocked && clean.certifiedTools().stream().noneMatch(value -> value.id().equals(tool.id())),
"Modificar contadores o aprobación rompe la firma y no concede una capacidad");
Files.writeString(ledger, original, StandardCharsets.UTF_8);
Files.move(folder.resolve("certification.key"), folder.resolve("original.key"));
blocked = false;
try { new ToolLearningService(registry(folder)); } catch (IOException expected) { blocked = true; }
helper.assertTrue(blocked && !Files.exists(folder.resolve("certification.key")),
"Un registro existente no se firma automáticamente con una clave reemplazada");
helper.succeed();
}
@GameTest(batch = "learning_01_real_trials", template = "empty", timeoutTicks = 60)
public void realCraftTrialsConsumeResourcesPromoteRestoreAndShare(GameTestHelper helper) throws Exception {
MinecraftServer server = helper.getLevel().getServer(); UUID author = UUID.randomUUID(), other = UUID.randomUUID();
Path folder = folder();
try {
ServerPlayer player = fixture(helper, author);
player.getInventory().add(new ItemStack(Items.OAK_LOG, 3));
ToolRegistry registry = registry(folder); ToolLearningService learning = new ToolLearningService(registry);
JsonObject args = new JsonObject(); args.addProperty("item", "minecraft:oak_planks"); args.addProperty("count", 4);
ToolManifest source = authored(manifest("plank_bundle", "craft", args, ToolPermission.CRAFT), author);
ToolRegistry.Proposal proposal = learning.propose(author, source.toJson().toString());
for (int trial = 0; trial < 3; trial++) {
ToolExecution execution = learning.startTrial(proposal.manifest().sha256(), context(server, author), server, result -> { });
helper.assertTrue(execution.tick(server.getTickCount()) == ToolExecution.Status.SUCCEEDED,
"La prueba fabrica usando la receta y el inventario del jugador registrado");
helper.assertTrue(learning.reportTrial(execution).successes() == trial + 1,
"El servidor conserva un recibo distinto por prueba y no acepta autoaprobación");
execution.tick(server.getTickCount()); execution.cancel();
}
helper.assertTrue(count(player, Items.OAK_LOG) == 0 && count(player, Items.OAK_PLANKS) == 12,
"Tres pruebas gastan tres troncos reales y producen doce tablas sin recursos mágicos");
helper.assertTrue(learning.statistics(source.sha256()).certified() && learning.trialTools(author).isEmpty(),
"Tres éxitos útiles promueven sólo la composición del hash observado");
JsonObject pipeline = source.toJson(); pipeline.addProperty("id", "plank_pipeline");
JsonObject pinned = new JsonObject(); pinned.addProperty("id", source.id()); pinned.addProperty("version", 1);
pinned.addProperty("sha256", source.sha256()); JsonArray dependencies = new JsonArray(); dependencies.add(pinned);
pipeline.add("dependencies", dependencies);
JsonObject call = new JsonObject(); call.addProperty("tool", source.id()); call.addProperty("version", 1);
call.addProperty("sha256", source.sha256()); call.addProperty("timeoutTicks", 80); call.add("args", new JsonObject());
JsonArray calls = new JsonArray(); calls.add(call); pipeline.add("steps", calls);
ToolManifest nested = learning.propose(author, pipeline.toString()).manifest();
player.getInventory().add(new ItemStack(Items.OAK_LOG));
ToolExecution nestedTrial = learning.startTrial(nested.sha256(), context(server, author), server, result -> { });
helper.assertTrue(nestedTrial.tick(server.getTickCount()) == ToolExecution.Status.SUCCEEDED
&& count(player, Items.OAK_LOG) == 0 && count(player, Items.OAK_PLANKS) == 16,
"Otra herramienta puede componer una dependencia certificada del hash exacto y consumir sus materiales reales");
ToolRegistry restoredRegistry = registry(folder); ToolLearningService restored = new ToolLearningService(restoredRegistry);
helper.assertTrue(restored.statistics(source.sha256()).successes() == 3 && restored.statistics(source.sha256()).certified()
&& restoredRegistry.certifiedTools().stream().anyMatch(tool -> tool.sha256().equals(source.sha256())),
"El reinicio verifica firma, hash y evidencia antes de recuperar la herramienta certificada");
helper.assertTrue(restored.pending(author, "plank_pipeline", 1).isPresent()
&& restored.statistics(nested.sha256()).successes() == 1,
"La dependencia aprobada se restaura antes de cargar la composición todavía pendiente");
cleanup(server, author);
ServerPlayer peer = fixture(helper, other); peer.getInventory().add(new ItemStack(Items.OAK_LOG));
ToolExecution shared = restoredRegistry.start(source.id(), 1, context(server, other), new WorldPrimitiveExecutor(server, result -> { }));
helper.assertTrue(shared.tick(server.getTickCount()) == ToolExecution.Status.SUCCEEDED
&& count(peer, Items.OAK_LOG) == 0 && count(peer, Items.OAK_PLANKS) == 4,
"Otro bot reutiliza la capacidad aprobada con sus propios materiales");
helper.assertTrue(restored.statistics(source.sha256()).successes() == 3,
"La reutilización normal no falsifica las pruebas de certificación originales");
helper.succeed();
} finally { cleanup(server, author); cleanup(server, other); }
}
@GameTest(batch = "learning_02_noop_trials", template = "empty", timeoutTicks = 60)
public void repeatedInspectDoesNotLearnAndOtherActorsCannotTrial(GameTestHelper helper) throws Exception {
MinecraftServer server = helper.getLevel().getServer(); UUID actor = UUID.randomUUID();
try {
fixture(helper, actor);
ToolRegistry registry = registry(folder()); ToolLearningService learning = new ToolLearningService(registry);
ToolManifest source = authored(manifest("noop_bundle", "inspect", new JsonObject(), ToolPermission.WORLD_READ), actor);
learning.propose(actor, source.toJson().toString());
for (int index = 0; index < 3; index++) {
ToolExecution execution = learning.startTrial(source.sha256(), context(server, actor), server, result -> { });
helper.assertTrue(execution.tick(server.getTickCount()) == ToolExecution.Status.SUCCEEDED,
"Una observación puede terminar correctamente sin progreso útil nuevo");
}
helper.assertTrue(learning.statistics(source.sha256()).successes() == 0
&& learning.statistics(source.sha256()).failures() == 3 && !learning.statistics(source.sha256()).certified(),
"Repetir inspect no concede una certificación sin utilidad comprobada");
rejected(helper, () -> learning.startTrial(source.sha256(), context(server, UUID.randomUUID()), server, result -> { }),
"Las propuestas pendientes sólo pueden probarse por su autor");
JsonObject noPermission = new JsonObject();
rejected(helper, () -> learning.startTrial(source.sha256(), new ToolRegistry.ExecutionContext(actor,
server.getTickCount(), Set.of(), noPermission), server, result -> { }), "No prueba sin permisos declarados");
ToolExecution ordinary = registry.start("inspect", 1, context(server, actor), new WorldPrimitiveExecutor(server, result -> { }));
ordinary.tick(server.getTickCount());
rejected(helper, () -> learning.reportTrial(ordinary), "Una ejecución normal no se hace pasar por evidencia de aprendizaje");
helper.succeed();
} finally { cleanup(server, actor); }
}
@GameTest(batch = "learning_03_failed_and_cancelled", template = "empty", timeoutTicks = 60)
public void resourceFailureCancellationAndRoleArePersistent(GameTestHelper helper) throws Exception {
MinecraftServer server = helper.getLevel().getServer(); UUID actor = UUID.randomUUID(); Path folder = folder();
try {
ServerPlayer player = fixture(helper, actor);
ToolRegistry registry = registry(folder); ToolLearningService learning = new ToolLearningService(registry);
JsonObject args = new JsonObject(); args.addProperty("item", "minecraft:oak_planks"); args.addProperty("count", 4);
ToolManifest source = authored(manifest("failure_bundle", "craft", args, ToolPermission.CRAFT), actor);
learning.propose(actor, source.toJson().toString());
ToolExecution missing = learning.startTrial(source.sha256(), context(server, actor), server, result -> { });
helper.assertTrue(missing.tick(server.getTickCount()) == ToolExecution.Status.FAILED && count(player, Items.OAK_PLANKS) == 0,
"Una prueba sin troncos falla sin inventar la materia prima");
ToolExecution cancelled = learning.startTrial(source.sha256(), context(server, actor), server, result -> { }); cancelled.cancel();
cancelled.cancel(); cancelled.tick(server.getTickCount());
helper.assertTrue(learning.statistics(source.sha256()).failures() == 1 && learning.statistics(source.sha256()).cancellations() == 1,
"Cada fallo y cancelación se registra una sola vez");
ToolLearningService restored = new ToolLearningService(registry(folder));
helper.assertTrue(restored.statistics(source.sha256()).failures() == 1 && restored.statistics(source.sha256()).cancellations() == 1,
"Fallos y cancelaciones sobreviven al reinicio sin convertirlos en éxitos");
AgentRoles.get(server).assign(actor, AgentRoles.Role.INVESTIGADOR);
rejected(helper, () -> restored.startTrial(source.sha256(), context(server, actor), server, result -> { }),
"El investigador no evade su especialización mediante una herramienta compuesta");
AgentRoles.get(server).assign(actor, AgentRoles.Role.GENERALISTA);
player.getInventory().add(new ItemStack(Items.OAK_LOG));
ToolExecution once = restored.startTrial(source.sha256(), context(server, actor), server, result -> { });
rejected(helper, () -> restored.startTrial(source.sha256(), context(server, actor), server, result -> { }),
"Cada bot dispone como máximo de una prueba simultánea");
once.cancel(); helper.succeed();
} finally { cleanup(server, actor); }
}
// Cada prueba física usa un batch separado porque el servidor GameTest admite un solo jugador.
private static ServerPlayer fixture(GameTestHelper helper, UUID actor) {
PlayerSessions.install();
for (int x = 0; x <= 13; x++) for (int z = 0; z <= 13; z++) helper.setBlock(new BlockPos(x, 1, z), Blocks.DIRT);
BlockPos pos = helper.absolutePos(new BlockPos(3, 2, 3)); MinecraftServer server = helper.getLevel().getServer();
AgentRegistry.get(server).register(actor, name(actor), null, GlobalPos.of(helper.getLevel().dimension(), pos), "quieto");
AgentRegistry.get(server).markState(actor, AgentState.ACTIVE);
return PlayerSessions.create(server, actor, name(actor), helper.getLevel(), pos);
}
private static void cleanup(MinecraftServer server, UUID actor) {
PlayerSessions.remove(server, actor);
AgentRegistry.get(server).find(actor).ifPresent(identity -> AgentRegistry.get(server).markState(actor, AgentState.RETIRED));
}
private static String name(UUID id) { return "Tl_" + id.toString().substring(0, 8); }
private static Path folder() { return Path.of("tool-learning-test-output", UUID.randomUUID().toString()).toAbsolutePath(); }
private static ToolRegistry registry(Path folder) throws IOException { ToolRegistry registry = new ToolRegistry(folder); BuiltinTools.install(registry); return registry; }
private static ToolRegistry.ExecutionContext context(MinecraftServer server, UUID actor) {
return new ToolRegistry.ExecutionContext(actor, server.getTickCount(), PERMISSIONS, new JsonObject());
}
private static ToolManifest authored(ToolManifest template, UUID author) { return ToolManifest.parse(template.toJson().toString(), author); }
private static ToolManifest manifest(String id, String primitive, JsonObject args, ToolPermission permission) {
JsonObject source = new JsonObject(); source.addProperty("schema", 1); source.addProperty("id", id);
source.addProperty("version", 1); source.addProperty("description", "Composición de prueba con efectos reales");
JsonArray permissions = new JsonArray(); permissions.add(permission.name()); source.add("permissions", permissions);
source.add("dependencies", new JsonArray()); JsonObject limits = new JsonObject(); limits.addProperty("maxTicks", 100);
limits.addProperty("maxSteps", 4); source.add("limits", limits);
JsonObject step = new JsonObject(); step.addProperty("primitive", primitive); step.addProperty("timeoutTicks", 80);
step.add("args", args); JsonArray steps = new JsonArray(); steps.add(step); source.add("steps", steps);
return ToolManifest.parse(source.toString(), AUTHOR);
}
private static int count(ServerPlayer player, Item item) {
int count = 0; for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++)
if (player.getInventory().getItem(slot).is(item)) count += player.getInventory().getItem(slot).getCount();
return count;
}
private static void rejected(GameTestHelper helper, Throwing action, String message) throws Exception {
boolean rejected = false; try { action.run(); } catch (IllegalArgumentException expected) { rejected = true; }
helper.assertTrue(rejected, message);
}
private interface Throwing { void run() throws Exception; }
}