Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
272acfde11 | ||
|
|
c215dc1990 |
No files matched your search
@@ -29,7 +29,21 @@ The file adapter uses safe YAML construction, duplicate-key rejection, bounded r
|
||||
|
||||
`TailBooks` creates vanilla `WRITTEN_BOOK` items with static pages. The full character name remains in display/pages; the native title is bounded to vanilla's 32 UTF-16 units without splitting a surrogate pair. `fantasystick:tail_version`, `character_id` and `death_id` identify the saved record. Pages/title are never read as authoritative stats or revival eligibility. Plugin absence does not remove the vanilla written-book contents. The factory does not yet deliver books from a death event.
|
||||
|
||||
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 equipment/session persistence. Ordinary equipment must still be implemented as actual native shulker contents, never an external-only reconstruction receipt.
|
||||
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.
|
||||
|
||||
Version-1 box metadata uses `fantasystick:storage_version`, `storage_owner_id`, `storage_session_id` and `storage_slots`. Box positions 0–8 contain original hotbar slots 0–8; positions 9–12 contain boots, leggings, chest and head from player slots 36–39. These metadata fields are for identity/restoration, not the only copy of the items. Restoration searches main slots 9–35, so sorting relocation does not lose the box. Repeated requests, wrong identity, duplicate/malformed boxes, unexpected extra contents or occupied restoration slots do not overwrite/delete items. These are adapter error results, not a persistent administrator lock or completed recovery policy.
|
||||
|
||||
The caller must remove authenticated fantasy controls before restoration. One new head stack uses the slot freed by removing the box, even with a full main inventory; there is no drop fallback. The adapter does not yet route sheet/cursor events, emit entry warnings, manage character/player snapshots or register a playable session. Native tests use real player-slot storage with only the absent network recipient stubbed; client packets and live gameplay remain unverified.
|
||||
|
||||
## CI and publication
|
||||
|
||||
|
||||
@@ -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,111 @@
|
||||
package games.dmg.fantasystick.items;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.block.ShulkerBox;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.bukkit.inventory.meta.BlockStateMeta;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Server-thread inventory operations; session eligibility and event routing belong to the caller. */
|
||||
public final class EquipmentStorage {
|
||||
public enum PackResult { PACKED, NO_SPACE, INELIGIBLE_EQUIPMENT, ALREADY_PACKED }
|
||||
|
||||
private static final int[] EQUIPMENT_SLOTS = {0, 1, 2, 3, 4, 5, 6, 7, 8, 36, 37, 38, 39};
|
||||
private static final NamespacedKey VERSION = key("storage_version");
|
||||
private static final NamespacedKey OWNER = key("storage_owner_id");
|
||||
private static final NamespacedKey SESSION = key("storage_session_id");
|
||||
private static final NamespacedKey SLOTS = key("storage_slots");
|
||||
|
||||
public PackResult pack(PlayerInventory inventory, UUID owner, UUID session) {
|
||||
Objects.requireNonNull(owner);
|
||||
Objects.requireNonNull(session);
|
||||
for (int slot = 9; slot < 36; slot++) {
|
||||
if (matches(inventory.getItem(slot), owner, session)) return PackResult.ALREADY_PACKED;
|
||||
}
|
||||
for (int slot : EQUIPMENT_SLOTS) {
|
||||
var item = inventory.getItem(slot);
|
||||
if (!empty(item) && item.getType().name().endsWith("SHULKER_BOX")) return PackResult.INELIGIBLE_EQUIPMENT;
|
||||
}
|
||||
int available = -1;
|
||||
for (int slot = 9; slot < 36; slot++) {
|
||||
if (empty(inventory.getItem(slot))) { available = slot; break; }
|
||||
}
|
||||
if (available == -1) return PackResult.NO_SPACE;
|
||||
var item = new ItemStack(Material.SHULKER_BOX);
|
||||
var meta = (BlockStateMeta) item.getItemMeta();
|
||||
var box = (ShulkerBox) meta.getBlockState();
|
||||
var contents = box.getInventory();
|
||||
for (int i = 0; i < EQUIPMENT_SLOTS.length; i++) {
|
||||
var equipment = inventory.getItem(EQUIPMENT_SLOTS[i]);
|
||||
if (!empty(equipment) && equipment.getAmount() > Math.min(equipment.getMaxStackSize(), contents.getMaxStackSize())) {
|
||||
return PackResult.INELIGIBLE_EQUIPMENT;
|
||||
}
|
||||
contents.setItem(i, equipment);
|
||||
}
|
||||
meta.setBlockState(box);
|
||||
meta.displayName(Component.text("Fantasy Storage Box"));
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(VERSION, PersistentDataType.INTEGER, 1);
|
||||
data.set(OWNER, PersistentDataType.STRING, owner.toString());
|
||||
data.set(SESSION, PersistentDataType.STRING, session.toString());
|
||||
data.set(SLOTS, PersistentDataType.INTEGER_ARRAY, EQUIPMENT_SLOTS.clone());
|
||||
item.setItemMeta(meta);
|
||||
for (int slot : EQUIPMENT_SLOTS) inventory.setItem(slot, null);
|
||||
inventory.setItem(available, item);
|
||||
return PackResult.PACKED;
|
||||
}
|
||||
|
||||
public enum RestoreResult { RESTORED, NOT_FOUND, AMBIGUOUS, INVALID_BOX, OCCUPIED_SLOT }
|
||||
|
||||
/** Caller removes validated fantasy controls first. Unexpected slot occupants are never overwritten. */
|
||||
public RestoreResult restore(PlayerInventory inventory, UUID owner, UUID session) {
|
||||
Objects.requireNonNull(owner);
|
||||
Objects.requireNonNull(session);
|
||||
int found = -1;
|
||||
for (int slot = 9; slot < 36; slot++) {
|
||||
if (matches(inventory.getItem(slot), owner, session)) {
|
||||
if (found != -1) return RestoreResult.AMBIGUOUS;
|
||||
found = slot;
|
||||
}
|
||||
}
|
||||
if (found == -1) return RestoreResult.NOT_FOUND;
|
||||
var item = inventory.getItem(found);
|
||||
if (item.getAmount() != 1 || !(item.getItemMeta() instanceof BlockStateMeta meta)
|
||||
|| !(meta.getBlockState() instanceof ShulkerBox box)) return RestoreResult.INVALID_BOX;
|
||||
var data = meta.getPersistentDataContainer();
|
||||
if (!data.has(VERSION, PersistentDataType.INTEGER) || data.get(VERSION, PersistentDataType.INTEGER) != 1
|
||||
|| !data.has(SLOTS, PersistentDataType.INTEGER_ARRAY)
|
||||
|| !Arrays.equals(EQUIPMENT_SLOTS, data.get(SLOTS, PersistentDataType.INTEGER_ARRAY))) {
|
||||
return RestoreResult.INVALID_BOX;
|
||||
}
|
||||
var contents = box.getInventory();
|
||||
for (int slot = EQUIPMENT_SLOTS.length; slot < contents.getSize(); slot++) {
|
||||
if (!empty(contents.getItem(slot))) return RestoreResult.INVALID_BOX;
|
||||
}
|
||||
for (int slot : EQUIPMENT_SLOTS) {
|
||||
if (slot != 39 && !empty(inventory.getItem(slot))) return RestoreResult.OCCUPIED_SLOT;
|
||||
}
|
||||
var currentHead = inventory.getItem(39);
|
||||
var displacedHead = empty(currentHead) ? null : currentHead.clone();
|
||||
for (int i = 0; i < EQUIPMENT_SLOTS.length; i++) inventory.setItem(EQUIPMENT_SLOTS[i], contents.getItem(i));
|
||||
inventory.setItem(found, displacedHead);
|
||||
return RestoreResult.RESTORED;
|
||||
}
|
||||
|
||||
private static boolean matches(ItemStack item, UUID owner, UUID session) {
|
||||
if (empty(item)) return false;
|
||||
var data = item.getItemMeta().getPersistentDataContainer();
|
||||
return data.has(OWNER, PersistentDataType.STRING) && data.has(SESSION, PersistentDataType.STRING)
|
||||
&& owner.toString().equals(data.get(OWNER, PersistentDataType.STRING))
|
||||
&& session.toString().equals(data.get(SESSION, PersistentDataType.STRING));
|
||||
}
|
||||
|
||||
private static NamespacedKey key(String name) { return new NamespacedKey("fantasystick", name); }
|
||||
private static boolean empty(ItemStack item) { return item == null || item.isEmpty(); }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.items.EquipmentStorage;
|
||||
import java.util.Arrays;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.minecraft.core.NonNullList;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.nbt.NbtOps;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.world.entity.EntityEquipment;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.block.ShulkerBox;
|
||||
import org.bukkit.craftbukkit.CraftRegistry;
|
||||
import org.bukkit.craftbukkit.inventory.CraftInventoryPlayer;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.enchantments.Enchantment;
|
||||
import org.bukkit.inventory.InventoryHolder;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.BlockStateMeta;
|
||||
import org.bukkit.inventory.meta.Damageable;
|
||||
import org.bukkit.inventory.meta.BundleMeta;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class EquipmentStorageNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
private static CraftInventoryPlayer inventory() {
|
||||
// Real native player slot/equipment storage. Only the absent network recipient is stubbed.
|
||||
return new CraftInventoryPlayer(new Inventory(null, new EntityEquipment()) {
|
||||
@Override public InventoryHolder getOwner() { return null; }
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedAmbiguousAndOccupiedRestorationPathsLeaveAllItemsUntouched() {
|
||||
for (int variant = 0; variant < 6; variant++) {
|
||||
var inventory = inventory();
|
||||
var owner = UUID.randomUUID();
|
||||
var session = UUID.randomUUID();
|
||||
var storage = new EquipmentStorage();
|
||||
inventory.setItem(0, new ItemStack(Material.DIAMOND_SWORD));
|
||||
assertEquals(EquipmentStorage.PackResult.PACKED, storage.pack(inventory, owner, session));
|
||||
var item = inventory.getItem(9);
|
||||
var meta = item.getItemMeta();
|
||||
var expected = EquipmentStorage.RestoreResult.INVALID_BOX;
|
||||
switch (variant) {
|
||||
case 0 -> {
|
||||
meta.getPersistentDataContainer().set(new NamespacedKey("fantasystick", "storage_version"), PersistentDataType.INTEGER, 2);
|
||||
item.setItemMeta(meta);
|
||||
}
|
||||
case 1 -> {
|
||||
meta.getPersistentDataContainer().set(new NamespacedKey("fantasystick", "storage_slots"), PersistentDataType.INTEGER_ARRAY, new int[]{35, 35});
|
||||
item.setItemMeta(meta);
|
||||
}
|
||||
case 2 -> {
|
||||
inventory.setItem(0, new ItemStack(Material.EMERALD));
|
||||
expected = EquipmentStorage.RestoreResult.OCCUPIED_SLOT;
|
||||
}
|
||||
case 3 -> {
|
||||
inventory.setItem(10, item.clone());
|
||||
expected = EquipmentStorage.RestoreResult.AMBIGUOUS;
|
||||
}
|
||||
case 4 -> item.setAmount(2);
|
||||
case 5 -> {
|
||||
var impostor = new ItemStack(Material.SHULKER_BOX);
|
||||
var impostorMeta = impostor.getItemMeta();
|
||||
impostorMeta.displayName(Component.text("Fantasy Storage Box"));
|
||||
impostor.setItemMeta(impostorMeta);
|
||||
inventory.setItem(9, impostor);
|
||||
expected = EquipmentStorage.RestoreResult.NOT_FOUND;
|
||||
}
|
||||
default -> throw new AssertionError();
|
||||
}
|
||||
var before = snapshot(inventory);
|
||||
assertEquals(expected, storage.restore(inventory, owner, session), "variant " + variant);
|
||||
assertArrayEquals(before, inventory.getContents(), "variant " + variant);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void eligibilityMatchesTheActualVanillaShulkerSlotForEveryNativeItemMaterial() {
|
||||
var nativeSlot = new net.minecraft.world.inventory.ShulkerBoxSlot(new net.minecraft.world.SimpleContainer(27), 0, 0, 0);
|
||||
var storage = new EquipmentStorage();
|
||||
for (Material material : Material.values()) {
|
||||
if (material.isLegacy() || !material.isItem() || material.isAir()) continue;
|
||||
var item = new ItemStack(material);
|
||||
boolean allowed = nativeSlot.mayPlace(CraftItemStack.asNMSCopy(item));
|
||||
var inventory = inventory();
|
||||
inventory.setItem(0, item);
|
||||
assertEquals(allowed ? EquipmentStorage.PackResult.PACKED : EquipmentStorage.PackResult.INELIGIBLE_EQUIPMENT,
|
||||
storage.pack(inventory, UUID.randomUUID(), UUID.randomUUID()), material.name());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOversizedStacksBeforeAnyEquipmentMoves() {
|
||||
var inventory = inventory();
|
||||
inventory.setItem(0, new ItemStack(Material.DIAMOND_SWORD));
|
||||
inventory.setItem(8, new ItemStack(Material.ARROW, 65));
|
||||
var before = snapshot(inventory);
|
||||
assertEquals(EquipmentStorage.PackResult.INELIGIBLE_EQUIPMENT,
|
||||
new EquipmentStorage().pack(inventory, UUID.randomUUID(), UUID.randomUUID()));
|
||||
assertArrayEquals(before, inventory.getContents());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullMainInventoryRefusesUnchangedAndOnlyAMainSlotCountsAsSpace() {
|
||||
var inventory = inventory();
|
||||
for (int slot = 9; slot < 36; slot++) inventory.setItem(slot, new ItemStack(Material.STONE, 64));
|
||||
inventory.setItem(0, new ItemStack(Material.DIAMOND, 12));
|
||||
inventory.setItemInOffHand(new ItemStack(Material.PAPER));
|
||||
var before = snapshot(inventory);
|
||||
var storage = new EquipmentStorage();
|
||||
assertEquals(EquipmentStorage.PackResult.NO_SPACE, storage.pack(inventory, UUID.randomUUID(), UUID.randomUUID()));
|
||||
assertArrayEquals(before, inventory.getContents());
|
||||
inventory.setItem(35, null);
|
||||
assertEquals(EquipmentStorage.PackResult.PACKED, storage.pack(inventory, UUID.randomUUID(), UUID.randomUUID()));
|
||||
assertEquals(Material.SHULKER_BOX, inventory.getItem(35).getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesUnexpectedBoxContentsInsteadOfDeletingThemOnRestoration() {
|
||||
var inventory = inventory();
|
||||
var owner = UUID.randomUUID();
|
||||
var session = UUID.randomUUID();
|
||||
var storage = new EquipmentStorage();
|
||||
inventory.setItem(0, new ItemStack(Material.DIAMOND_SWORD));
|
||||
assertEquals(EquipmentStorage.PackResult.PACKED, storage.pack(inventory, owner, session));
|
||||
var item = inventory.getItem(9);
|
||||
var meta = (BlockStateMeta) item.getItemMeta();
|
||||
var box = (ShulkerBox) meta.getBlockState();
|
||||
box.getInventory().setItem(26, new ItemStack(Material.EMERALD, 64));
|
||||
meta.setBlockState(box);
|
||||
item.setItemMeta(meta);
|
||||
var before = snapshot(inventory);
|
||||
assertEquals(EquipmentStorage.RestoreResult.INVALID_BOX, storage.restore(inventory, owner, session));
|
||||
assertArrayEquals(before, inventory.getContents());
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedPackingAndWrongOwnerOrSessionCannotDuplicateOrRestoreEquipment() {
|
||||
var inventory = inventory();
|
||||
inventory.setItem(0, new ItemStack(Material.DIAMOND_SWORD));
|
||||
var owner = UUID.randomUUID();
|
||||
var session = UUID.randomUUID();
|
||||
var storage = new EquipmentStorage();
|
||||
assertEquals(EquipmentStorage.PackResult.PACKED, storage.pack(inventory, owner, session));
|
||||
var packed = snapshot(inventory);
|
||||
assertEquals(EquipmentStorage.PackResult.ALREADY_PACKED, storage.pack(inventory, owner, session));
|
||||
assertArrayEquals(packed, inventory.getContents());
|
||||
assertEquals(EquipmentStorage.RestoreResult.NOT_FOUND, storage.restore(inventory, UUID.randomUUID(), session));
|
||||
assertEquals(EquipmentStorage.RestoreResult.NOT_FOUND, storage.restore(inventory, owner, UUID.randomUUID()));
|
||||
assertArrayEquals(packed, inventory.getContents());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsNewHeadInTheFreedBoxSlotEvenWhenMainInventoryIsFull() {
|
||||
var inventory = inventory();
|
||||
var owner = UUID.randomUUID();
|
||||
var session = UUID.randomUUID();
|
||||
var storage = new EquipmentStorage();
|
||||
var helmet = new ItemStack(Material.DIAMOND_HELMET);
|
||||
var meta = (Damageable) helmet.getItemMeta();
|
||||
meta.setDamage(93);
|
||||
meta.addEnchant(Enchantment.PROTECTION, 4, true);
|
||||
helmet.setItemMeta(meta);
|
||||
inventory.setHelmet(helmet);
|
||||
assertEquals(EquipmentStorage.PackResult.PACKED, storage.pack(inventory, owner, session));
|
||||
for (int slot = 10; slot < 36; slot++) inventory.setItem(slot, new ItemStack(Material.COBBLESTONE, 64));
|
||||
var pumpkin = new ItemStack(Material.CARVED_PUMPKIN);
|
||||
inventory.setHelmet(pumpkin);
|
||||
assertEquals(EquipmentStorage.RestoreResult.RESTORED, storage.restore(inventory, owner, session));
|
||||
assertEquals(helmet, inventory.getHelmet(), "Existing damage and protection are not repaired or discarded");
|
||||
assertEquals(pumpkin, inventory.getItem(9));
|
||||
for (int slot = 10; slot < 36; slot++) assertEquals(new ItemStack(Material.COBBLESTONE, 64), inventory.getItem(slot));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeSerializedContentsNeedNoPluginAndRestoreAfterTheBoxMoves() {
|
||||
var inventory = inventory();
|
||||
var owner = UUID.randomUUID();
|
||||
var session = UUID.randomUUID();
|
||||
var storage = new EquipmentStorage();
|
||||
for (int slot = 0; slot < 9; slot++) inventory.setItem(slot, new ItemStack(Material.ARROW, slot + 1));
|
||||
var nested = new ItemStack(Material.BUNDLE);
|
||||
var bundle = (BundleMeta) nested.getItemMeta();
|
||||
bundle.addItem(new ItemStack(Material.EMERALD, 12));
|
||||
nested.setItemMeta(bundle);
|
||||
inventory.setItem(3, nested);
|
||||
inventory.setHelmet(new ItemStack(Material.DIAMOND_HELMET));
|
||||
inventory.setLeggings(new ItemStack(Material.IRON_LEGGINGS));
|
||||
inventory.setChestplate(new ItemStack(Material.ELYTRA));
|
||||
inventory.setBoots(new ItemStack(Material.GOLDEN_BOOTS));
|
||||
var before = snapshot(inventory);
|
||||
assertEquals(EquipmentStorage.PackResult.PACKED, storage.pack(inventory, owner, session));
|
||||
var ops = RegistryOps.create(NbtOps.INSTANCE, CraftRegistry.getMinecraftRegistry());
|
||||
var encoded = net.minecraft.world.item.ItemStack.CODEC.encodeStart(ops,
|
||||
CraftItemStack.asNMSCopy(inventory.getItem(9))).getOrThrow();
|
||||
var decoded = net.minecraft.world.item.ItemStack.CODEC.parse(ops, encoded).getOrThrow();
|
||||
// Minecraft's native container component, not a Fantasy Stick reconstruction receipt.
|
||||
var contents = NonNullList.withSize(27, net.minecraft.world.item.ItemStack.EMPTY);
|
||||
assertNotNull(decoded.get(DataComponents.CONTAINER));
|
||||
decoded.get(DataComponents.CONTAINER).copyInto(contents);
|
||||
assertTrue(net.minecraft.world.item.ItemStack.matches(CraftItemStack.asNMSCopy(nested), contents.get(3)));
|
||||
assertTrue(net.minecraft.world.item.ItemStack.matches(CraftItemStack.asNMSCopy(before[36]), contents.get(9)));
|
||||
assertTrue(net.minecraft.world.item.ItemStack.matches(CraftItemStack.asNMSCopy(before[39]), contents.get(12)));
|
||||
inventory.setItem(9, null);
|
||||
inventory.setItem(35, CraftItemStack.asBukkitCopy(decoded));
|
||||
assertEquals(EquipmentStorage.RestoreResult.RESTORED, storage.restore(inventory, owner, session));
|
||||
assertArrayEquals(before, inventory.getContents());
|
||||
assertEquals(EquipmentStorage.RestoreResult.NOT_FOUND, storage.restore(inventory, owner, session));
|
||||
assertArrayEquals(before, inventory.getContents());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesEveryShulkerColorInPackedSlotsWithoutAnyPartialMutation() {
|
||||
for (Material material : Material.values()) {
|
||||
if (!material.name().endsWith("SHULKER_BOX") || material.isLegacy()) continue;
|
||||
for (int slot : new int[]{0, 8, 36, 39}) {
|
||||
var inventory = inventory();
|
||||
inventory.setItem(1, new ItemStack(Material.DIAMOND, 12));
|
||||
inventory.setItem(slot, new ItemStack(material));
|
||||
var before = snapshot(inventory);
|
||||
assertEquals(EquipmentStorage.PackResult.INELIGIBLE_EQUIPMENT,
|
||||
new EquipmentStorage().pack(inventory, UUID.randomUUID(), UUID.randomUUID()), material + " slot " + slot);
|
||||
assertArrayEquals(before, inventory.getContents());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ItemStack[] snapshot(CraftInventoryPlayer inventory) {
|
||||
return Arrays.stream(inventory.getContents()).map(item -> item == null ? null : item.clone()).toArray(ItemStack[]::new);
|
||||
}
|
||||
|
||||
@Test
|
||||
void movesActualHotbarAndArmorIntoOneNativeShulkerWithoutTouchingMainItemsOrSheet() {
|
||||
var inventory = inventory();
|
||||
var sword = new ItemStack(Material.DIAMOND_SWORD);
|
||||
var meta = (Damageable) sword.getItemMeta();
|
||||
meta.setDamage(137);
|
||||
meta.displayName(Component.text("Old sword"));
|
||||
sword.setItemMeta(meta);
|
||||
var arrows = new ItemStack(Material.ARROW, 47);
|
||||
var boots = new ItemStack(Material.DIAMOND_BOOTS);
|
||||
inventory.setItem(0, sword);
|
||||
inventory.setItem(8, arrows);
|
||||
inventory.setBoots(boots);
|
||||
var unrelated = new ItemStack(Material.RED_SHULKER_BOX);
|
||||
var sheet = new ItemStack(Material.PAPER);
|
||||
inventory.setItem(9, unrelated);
|
||||
inventory.setItemInOffHand(sheet);
|
||||
var storage = new EquipmentStorage();
|
||||
assertEquals(EquipmentStorage.PackResult.PACKED, storage.pack(inventory, UUID.randomUUID(), UUID.randomUUID()));
|
||||
for (int i = 0; i < 9; i++) assertNull(inventory.getItem(i));
|
||||
assertTrue(inventory.getBoots().isEmpty());
|
||||
assertEquals(unrelated, inventory.getItem(9));
|
||||
assertEquals(sheet, inventory.getItemInOffHand());
|
||||
var boxItem = inventory.getItem(10);
|
||||
assertNotNull(boxItem);
|
||||
assertEquals(Material.SHULKER_BOX, boxItem.getType());
|
||||
assertEquals(Component.text("Fantasy Storage Box"), boxItem.getItemMeta().displayName());
|
||||
var box = (ShulkerBox) ((BlockStateMeta) boxItem.getItemMeta()).getBlockState();
|
||||
assertEquals(sword, box.getInventory().getItem(0));
|
||||
assertEquals(arrows, box.getInventory().getItem(8));
|
||||
assertEquals(boots, box.getInventory().getItem(9));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user