Compare commits

..
1 Commits
Author SHA1 Message Date
dmg 272acfde11 feat(characters): add native sheets and safe recipe previews
Release / release (push) Successful in 4m48s
CI / build (push) Successful in 3m13s
2026-09-17 20:57:55 -04:00
3 changed files with 183 additions and 0 deletions

No files matched your search

+6
View File
@@ -31,6 +31,12 @@ The file adapter uses safe YAML construction, duplicate-key rejection, bounded r
File-level atomic replacement is **not** atomicity with Minecraft inventory/player saves. This checkpoint does not implement cross-save reconciliation, an administrator lock, automatic overflow drops or player/session persistence.
## Paper sheet and recipe definition
`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.
## Native equipment adapter
`EquipmentStorage` preflights and moves nine hotbar/four armor slots into an actual named shulker in one free main-inventory slot. Item counts, metadata/damage/enchantments and legal nested contents remain native item data, accessible without this plugin. Eligibility is tested against the real vanilla shulker slot for every nonlegacy native item material; a main-inventory shulker does not prevent entry. Oversized or ineligible equipment and missing space produce unchanged refusals.
@@ -0,0 +1,60 @@
package games.dmg.fantasystick.items;
import games.dmg.fantasystick.persistence.CharacterRecord;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import net.kyori.adventure.text.Component;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapelessRecipe;
import org.bukkit.persistence.PersistentDataType;
/** Sheet presentation/identity only; saved records and session eligibility remain authoritative. */
public final class CharacterSheets {
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.";
public ItemStack create(CharacterRecord character) {
if (character.dead()) throw new IllegalArgumentException("Dead characters require authoritative revival before sheet creation");
var item = paper(character.name());
var meta = item.getItemMeta();
var data = meta.getPersistentDataContainer();
data.set(VERSION, PersistentDataType.INTEGER, 1);
data.set(CHARACTER, PersistentDataType.STRING, character.id().toString());
item.setItemMeta(meta);
return item;
}
/** 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"))
.addIngredient(Material.STICK).addIngredient(Material.PAPER)
.addIngredient(Material.FEATHER).addIngredient(Material.INK_SAC);
}
private static ItemStack paper(String name) {
var item = new ItemStack(Material.PAPER);
var meta = item.getItemMeta();
meta.displayName(Component.text(name));
meta.lore(List.of(Component.text(INSTRUCTION)));
item.setItemMeta(meta);
return item;
}
/** A reference, not authorization to activate: the caller must load saved state and claim the session. */
public Optional<UUID> identify(ItemStack item) {
if (item == null || item.getType() != Material.PAPER || !item.hasItemMeta()) return Optional.empty();
var data = item.getItemMeta().getPersistentDataContainer();
if (!data.has(VERSION, PersistentDataType.INTEGER)
|| !Integer.valueOf(1).equals(data.get(VERSION, PersistentDataType.INTEGER))
|| !data.has(CHARACTER, PersistentDataType.STRING)) return Optional.empty();
try {
return Optional.of(UUID.fromString(data.get(CHARACTER, PersistentDataType.STRING)));
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
}
@@ -0,0 +1,117 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.Collections;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import net.kyori.adventure.text.Component;
import net.minecraft.nbt.NbtOps;
import net.minecraft.resources.RegistryOps;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.craftbukkit.CraftRegistry;
import org.bukkit.craftbukkit.inventory.CraftItemStack;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.RecipeChoice;
import org.bukkit.persistence.PersistentDataType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class CharacterSheetsNativeTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
private static CharacterRecord character() {
var stats = new EnumMap<Stat, BigInteger>(Stat.class);
for (Stat stat : Stat.values()) stats.put(stat, BigInteger.ZERO);
return new CharacterRecord(UUID.randomUUID(), "Ari of Ash", BigInteger.ZERO, stats, 1,
BigInteger.ZERO, BigInteger.valueOf(40), BigDecimal.TEN, Set.of(), Collections.nCopies(7, ""),
Map.of(), false, null, null);
}
@Test
void presentationEditsPreserveIdentityButLookalikesAndMalformedReferencesAreRejected() {
var sheets = new CharacterSheets();
var record = character();
var sheet = sheets.create(record);
var meta = sheet.getItemMeta();
meta.displayName(Component.text("Different presentation"));
sheet.setItemMeta(meta);
assertEquals(record.id(), sheets.identify(sheet).orElseThrow());
var fake = new ItemStack(Material.PAPER);
var fakeMeta = fake.getItemMeta();
fakeMeta.displayName(meta.displayName());
fakeMeta.lore(meta.lore());
fake.setItemMeta(fakeMeta);
assertTrue(sheets.identify(fake).isEmpty());
assertTrue(sheets.identify(new ItemStack(Material.STICK)).isEmpty());
assertTrue(sheets.identify(null).isEmpty());
var version = new NamespacedKey("fantasystick", "sheet_version");
for (int variant = 0; variant < 3; variant++) {
var invalid = sheet.clone();
var invalidMeta = invalid.getItemMeta();
var data = invalidMeta.getPersistentDataContainer();
if (variant == 0) data.set(version, PersistentDataType.INTEGER, 2);
else if (variant == 1) data.set(version, PersistentDataType.STRING, "1");
else data.set(new NamespacedKey("fantasystick", "character_id"), PersistentDataType.STRING, "not-a-uuid");
invalid.setItemMeta(invalidMeta);
assertTrue(sheets.identify(invalid).isEmpty());
}
}
@Test
void fourIngredientShapelessRecipeOnlyExposesAnUnidentifiedPaperPreview() {
var sheets = new CharacterSheets();
var recipe = sheets.recipe();
assertEquals(new NamespacedKey("fantasystick", "character_sheet"), recipe.getKey());
assertEquals(4, recipe.getChoiceList().size());
assertTrue(recipe.getChoiceList().stream().allMatch(choice ->
choice instanceof RecipeChoice.MaterialChoice materials && materials.getChoices().size() == 1));
assertEquals(Set.of(Material.STICK, Material.PAPER, Material.FEATHER, Material.INK_SAC),
recipe.getChoiceList().stream().map(choice -> ((RecipeChoice.MaterialChoice) choice).getChoices().getFirst())
.collect(java.util.stream.Collectors.toSet()));
for (int i = 0; i < 10; i++) {
var preview = sheets.recipe().getResult();
assertEquals(Material.PAPER, preview.getType());
assertEquals(1, preview.getAmount());
assertTrue(sheets.identify(preview).isEmpty());
assertTrue(preview.getItemMeta().getPersistentDataContainer().isEmpty());
}
}
@Test
void aDeadRecordCannotBeTurnedIntoASheetByTheItemFactory() {
var original = character();
var dead = new CharacterRecord(original.id(), original.name(), original.revision(), original.stats(),
original.level(), original.experience(), original.unspentPoints(), BigDecimal.ZERO,
original.learned(), original.loadout(), original.cooldowns(), true, UUID.randomUUID(), java.time.Instant.EPOCH);
assertThrows(IllegalArgumentException.class, () -> new CharacterSheets().create(dead));
}
@Test
void sheetShowsSavedNameAndInstructionAndSerializesIdentityWithoutAnOwnerLock() {
var record = character();
var sheets = new CharacterSheets();
var sheet = sheets.create(record);
assertEquals(Material.PAPER, sheet.getType());
assertEquals(1, sheet.getAmount());
assertEquals(Component.text(record.name()), sheet.getItemMeta().displayName());
assertEquals(List.of(Component.text("Character sheet: place in off hand to enter fantasy world.")), sheet.getItemMeta().lore());
assertEquals(record.id(), sheets.identify(sheet).orElseThrow());
assertEquals(Set.of(new NamespacedKey("fantasystick", "sheet_version"), new NamespacedKey("fantasystick", "character_id")),
sheet.getItemMeta().getPersistentDataContainer().getKeys(), "No player owner, equipment or progress snapshot belongs on the sheet");
var ops = RegistryOps.create(NbtOps.INSTANCE, CraftRegistry.getMinecraftRegistry());
var encoded = net.minecraft.world.item.ItemStack.CODEC.encodeStart(ops, CraftItemStack.asNMSCopy(sheet)).getOrThrow();
var reloaded = CraftItemStack.asBukkitCopy(net.minecraft.world.item.ItemStack.CODEC.parse(ops, encoded).getOrThrow());
assertEquals(record.id(), new CharacterSheets().identify(reloaded).orElseThrow());
assertEquals(sheet, reloaded);
}
}