Compare commits

...
1 Commits
Author SHA1 Message Date
dmg 6aa7ea469b feat(crafting): guard against automated character crafting
Release / release (push) Successful in 4m37s
CI / build (push) Successful in 2m29s
2026-09-17 21:14:52 -04:00
4 changed files with 85 additions and 2 deletions

No files matched your search

+1 -1
View File
@@ -35,7 +35,7 @@ File-level atomic replacement is **not** atomicity with Minecraft inventory/play
`CharacterSheets` renders one Paper sheet from a saved live record, with its current name and the exact offhand instruction. `fantasystick:sheet_version` (integer 1) and `character_id` (UUID string) identify the character, with no owner lock or duplicated progress snapshot. Identification is only a reference: it does not authorize entry or replace saved-state/session validation. Rendering from a dead record is refused.
The `fantasystick:character_sheet` shapeless recipe definition contains one stick, paper, feather and ink sac. Its preview has no character metadata and allocates no identity/name. It is deliberately **not registered** yet: actual completion, bulk crafting, cancellation/save handling and anvil rename persistence still need their event adapters. Presentation edits preserving metadata are tested, not a claim that complete anvil transactions work.
The `fantasystick:character_sheet` shapeless recipe definition contains one stick, paper, feather and ink sac. Character creation is **manual player crafting only** (inventory grid or crafting table). `ManualCraftingGuard` cancels this recipe's automated `CrafterCraftEvent` before native consumption/ejection, without touching other recipes or reversing cancellations; register the guard with the eventual completed-crafting adapter before exposing the recipe. Its preview has no character metadata and allocates no identity/name. It is deliberately **not registered** yet: actual completion, bulk crafting, cancellation/save handling and anvil rename persistence still need their event adapters. Presentation edits preserving metadata are tested, not a claim that complete anvil transactions work.
## Native equipment adapter
@@ -0,0 +1,15 @@
package games.dmg.fantasystick.crafting;
import games.dmg.fantasystick.items.CharacterSheets;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.CrafterCraftEvent;
/** Register alongside the completed manual-crafting adapter, before exposing the recipe. */
public final class ManualCraftingGuard implements Listener {
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onAutomatedCraft(CrafterCraftEvent event) {
if (CharacterSheets.RECIPE.equals(event.getRecipe().getKey())) event.setCancelled(true);
}
}
@@ -13,6 +13,7 @@ import org.bukkit.persistence.PersistentDataType;
/** Sheet presentation/identity only; saved records and session eligibility remain authoritative. */
public final class CharacterSheets {
public static final NamespacedKey RECIPE = new NamespacedKey("fantasystick", "character_sheet");
private static final NamespacedKey VERSION = new NamespacedKey("fantasystick", "sheet_version");
private static final NamespacedKey CHARACTER = new NamespacedKey("fantasystick", "character_id");
private static final String INSTRUCTION = "Character sheet: place in off hand to enter fantasy world.";
@@ -30,7 +31,7 @@ public final class CharacterSheets {
/** Definition only: a future craft-completion adapter must persist and mint each actual result. */
public ShapelessRecipe recipe() {
return new ShapelessRecipe(new NamespacedKey("fantasystick", "character_sheet"), paper("Character sheet"))
return new ShapelessRecipe(RECIPE, paper("Character sheet"))
.addIngredient(Material.STICK).addIngredient(Material.PAPER)
.addIngredient(Material.FEATHER).addIngredient(Material.INK_SAC);
}
@@ -0,0 +1,67 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.crafting.ManualCraftingGuard;
import games.dmg.fantasystick.items.CharacterSheets;
import java.lang.reflect.Proxy;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.block.Block;
import org.bukkit.event.block.CrafterCraftEvent;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapelessRecipe;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class ManualCraftingGuardNativeTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
private static Block untouchedBlock() {
// The event boundary supplies a block, but cancellation must not access/mutate world or inventory.
return (Block) Proxy.newProxyInstance(Block.class.getClassLoader(), new Class<?>[]{Block.class},
(proxy, method, arguments) -> { throw new AssertionError("Unexpected block access: " + method.getName()); });
}
@Test
void leavesOtherRecipesAndExistingCancellationsAlone() {
var guard = new ManualCraftingGuard();
for (var key : new NamespacedKey[]{new NamespacedKey("minecraft", "paper"), new NamespacedKey("other", "character_sheet")}) {
var recipe = new ShapelessRecipe(key, new ItemStack(Material.PAPER)).addIngredient(Material.SUGAR_CANE);
for (boolean cancelled : new boolean[]{false, true}) {
var event = new CrafterCraftEvent(untouchedBlock(), recipe, recipe.getResult());
event.setCancelled(cancelled);
guard.onAutomatedCraft(event);
assertEquals(cancelled, event.isCancelled());
assertEquals(recipe.getResult(), event.getResult());
}
}
var ours = new CharacterSheets().recipe();
var alreadyCancelled = new CrafterCraftEvent(untouchedBlock(), ours, ours.getResult());
alreadyCancelled.setCancelled(true);
guard.onAutomatedCraft(alreadyCancelled);
assertTrue(alreadyCancelled.isCancelled());
}
@Test
void declaresTheNativeEventHookWithoutOverridingPriorCancellations() throws Exception {
var annotation = ManualCraftingGuard.class.getMethod("onAutomatedCraft", CrafterCraftEvent.class).getAnnotation(EventHandler.class);
assertNotNull(annotation);
assertEquals(EventPriority.HIGHEST, annotation.priority());
assertTrue(annotation.ignoreCancelled());
}
@Test
void cancelsAutomatedSheetCraftWithoutTouchingTheBlockOrResult() {
var recipe = new CharacterSheets().recipe();
var result = recipe.getResult();
var event = new CrafterCraftEvent(untouchedBlock(), recipe, result);
new ManualCraftingGuard().onAutomatedCraft(event);
assertTrue(event.isCancelled(), "The native Crafter must stop before consumption/ejection");
assertEquals(result, event.getResult());
assertEquals(Material.PAPER, event.getResult().getType());
assertTrue(new CharacterSheets().identify(event.getResult()).isEmpty());
}
}