diff --git a/src/main/java/net/tfminecraft/rpcharacters/grave/GraveDeathListener.java b/src/main/java/net/tfminecraft/rpcharacters/grave/GraveDeathListener.java index dac420c..fe6a51c 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/grave/GraveDeathListener.java +++ b/src/main/java/net/tfminecraft/rpcharacters/grave/GraveDeathListener.java @@ -63,6 +63,12 @@ public void onPlayerDeath(PlayerDeathEvent event) { ItemStack offhand = cloneItem(inventory.getItemInOffHand()); Map stash = new HashMap<>(); offhand = stripKept(storage, armor, offhand, stash); + ItemStack[] offhandSlot = new ItemStack[] { offhand }; + ItemStack bundledTicket = null; + if (GraveLoader.isInsuranceEnabled() && GraveInsuranceTickets.bindableSlot(stash) == null) { + bundledTicket = GraveInsuranceExtract.pull(storage, armor, offhandSlot, stash.values()); + offhand = offhandSlot[0]; + } if (!hasStoreableItems(storage, armor, offhand) && event.getDroppedExp() <= 0) { return; } @@ -89,6 +95,12 @@ public void onPlayerDeath(PlayerDeathEvent event) { } GraveInsuranceTickets.Binding binding = GraveInsuranceTickets.bind(stash, grave); + if (!binding.bound() && bundledTicket != null) { + ItemStack bound = GraveInsuranceTickets.bindTicket(bundledTicket, grave); + if (bound != null) { + binding = new GraveInsuranceTickets.Binding(true, bound); + } + } if (binding.split() != null) { splitTicket.put(victim.getUniqueId(), binding.split()); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/grave/GraveInsuranceExtract.java b/src/main/java/net/tfminecraft/rpcharacters/grave/GraveInsuranceExtract.java new file mode 100644 index 0000000..b3e0f0d --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/grave/GraveInsuranceExtract.java @@ -0,0 +1,202 @@ +package net.tfminecraft.rpcharacters.grave; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BundleMeta; +import org.bukkit.inventory.meta.ItemMeta; + +/** + * Pulls one insurance ticket out of a death snapshot, including tickets stored in a bundle. + * The bundle stays where it was, with that one ticket removed. + */ +final class GraveInsuranceExtract { + + private static final int MAX_DEPTH = 16; + + private GraveInsuranceExtract() {} + + /** + * Searches storage, armor, offhand, then any extra stacks (kept items). The first + * ticket that is not bound to a grave still in the world is removed and returned + * with amount 1. {@code offhand} is a one-slot array so the caller sees a cleared slot. + */ + static ItemStack pull(ItemStack[] storage, ItemStack[] armor, ItemStack[] offhand, + Iterable extra) { + if (!GraveLoader.isInsuranceEnabled()) { + return null; + } + Items items = new Items(); + ItemStack taken = pullSlots(storage, items); + if (taken != null) { + return taken; + } + taken = pullSlots(armor, items); + if (taken != null) { + return taken; + } + taken = pullSlots(offhand, items); + if (taken != null) { + return taken; + } + if (extra == null) { + return null; + } + for (ItemStack stack : extra) { + taken = pullOne(stack, items, ignored -> { }); + if (taken != null) { + return taken; + } + } + return null; + } + + static T pullSlots(T[] slots, Stacks stacks) { + if (slots == null) { + return null; + } + for (int i = 0; i < slots.length; i++) { + int slot = i; + T taken = pullOne(slots[i], stacks, remainder -> slots[slot] = remainder); + if (taken != null) { + return taken; + } + } + return null; + } + + private static T pullOne(T item, Stacks stacks, Consumer replace) { + Take taken = take(item, stacks, 0); + if (taken == null) { + return null; + } + replace.accept(taken.remainder); + return taken.ticket; + } + + private static Take take(T item, Stacks stacks, int depth) { + if (item == null || stacks.blank(item) || depth > MAX_DEPTH) { + return null; + } + if (stacks.insurance(item)) { + if (stacks.keepBinding(item)) { + return null; + } + T ticket = stacks.one(item); + if (stacks.amount(item) <= 1) { + return new Take<>(ticket, null); + } + stacks.setAmount(item, stacks.amount(item) - 1); + return new Take<>(ticket, item); + } + if (!stacks.bundle(item)) { + return null; + } + List contents = new ArrayList<>(stacks.contents(item)); + for (int i = 0; i < contents.size(); i++) { + Take inner = take(contents.get(i), stacks, depth + 1); + if (inner == null) { + continue; + } + if (inner.remainder == null) { + contents.remove(i); + } else { + contents.set(i, inner.remainder); + } + stacks.setContents(item, contents); + return new Take<>(inner.ticket, item); + } + return null; + } + + private record Take(T ticket, T remainder) {} + + interface Stacks { + boolean blank(T item); + + int amount(T item); + + void setAmount(T item, int amount); + + boolean insurance(T item); + + /** True when this ticket is already bound to a grave that still exists. */ + boolean keepBinding(T item); + + boolean bundle(T item); + + List contents(T bundle); + + void setContents(T bundle, List contents); + + T one(T item); + } + + private static final class Items implements Stacks { + @Override + public boolean blank(ItemStack item) { + return Grave.isBlank(item); + } + + @Override + public int amount(ItemStack item) { + return item.getAmount(); + } + + @Override + public void setAmount(ItemStack item, int amount) { + item.setAmount(amount); + } + + @Override + public boolean insurance(ItemStack item) { + return GraveLoader.isInsuranceItem(item); + } + + @Override + public boolean keepBinding(ItemStack item) { + return GraveInsuranceTickets.boundToLiveGrave(item); + } + + @Override + public boolean bundle(ItemStack item) { + return item.getItemMeta() instanceof BundleMeta; + } + + @Override + public List contents(ItemStack bundle) { + ItemMeta meta = bundle.getItemMeta(); + if (!(meta instanceof BundleMeta bundleMeta) || !bundleMeta.hasItems()) { + return List.of(); + } + return bundleMeta.getItems(); + } + + @Override + public void setContents(ItemStack bundle, List contents) { + ItemMeta meta = bundle.getItemMeta(); + if (!(meta instanceof BundleMeta bundleMeta)) { + return; + } + List kept = new ArrayList<>(); + if (contents != null) { + for (ItemStack inner : contents) { + if (!Grave.isBlank(inner)) { + kept.add(inner); + } + } + } + bundleMeta.setItems(kept.isEmpty() ? null : kept); + bundle.setItemMeta(bundleMeta); + } + + @Override + public ItemStack one(ItemStack item) { + ItemStack single = item.clone(); + single.setAmount(1); + return single; + } + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/grave/GraveInsuranceTickets.java b/src/main/java/net/tfminecraft/rpcharacters/grave/GraveInsuranceTickets.java index c004bf3..b604e3d 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/grave/GraveInsuranceTickets.java +++ b/src/main/java/net/tfminecraft/rpcharacters/grave/GraveInsuranceTickets.java @@ -29,6 +29,27 @@ record Binding(boolean bound, ItemStack split) { private GraveInsuranceTickets() {} + /** The stash slot that {@link #bind} would use, or null when no loose ticket can be bound. */ + static Integer bindableSlot(Map stash) { + if (stash == null || stash.isEmpty()) { + return null; + } + List slots = new ArrayList<>(stash.keySet()); + slots.sort(null); + return pickSlot(slots, slot -> GraveLoader.isInsuranceItem(stash.get(slot)), + slot -> boundToLiveGrave(stash.get(slot))); + } + + /** Tags one already-separated ticket and returns the bound copy. */ + static ItemStack bindTicket(ItemStack ticket, Grave grave) { + if (Grave.isBlank(ticket) || grave == null) { + return null; + } + ItemStack single = ticket.clone(); + single.setAmount(1); + return tag(single, grave); + } + /** * Binds one ticket from the death stash to {@code grave}. A stacked ticket is split: * the rest of the stack stays in its slot and the bound ticket is returned in @@ -38,10 +59,7 @@ static Binding bind(Map stash, Grave grave) { if (stash == null || stash.isEmpty() || grave == null) { return Binding.NONE; } - List slots = new ArrayList<>(stash.keySet()); - slots.sort(null); - Integer slot = pickSlot(slots, s -> GraveLoader.isInsuranceItem(stash.get(s)), - s -> isBoundToLiveGrave(stash.get(s))); + Integer slot = bindableSlot(stash); if (slot == null) { return Binding.NONE; } @@ -105,7 +123,7 @@ static void unbind(ItemStack item) { item.setItemMeta(meta); } - private static boolean isBoundToLiveGrave(ItemStack item) { + static boolean boundToLiveGrave(ItemStack item) { UUID id = boundGrave(item); return id != null && GraveManager.get().getById(id) != null; } diff --git a/src/main/resources/graves.yml b/src/main/resources/graves.yml index b2a5fef..2e63f80 100644 --- a/src/main/resources/graves.yml +++ b/src/main/resources/graves.yml @@ -32,7 +32,8 @@ excluded-items: # Carry a ticket when you die and one ticket is bound to that grave. Right-click the # bound ticket to recover that grave from anywhere, even in an unloaded chunk # (consumes the ticket on success). Unbound tickets, or tickets bound to another grave, -# do nothing. Tickets never go into a grave. +# do nothing. Loose tickets never go into a grave. A ticket inside a bundle still +# counts: one is taken out, bound, and returned on respawn. The bundle goes in the grave. insurance: item: m.miscellanea.grave_insurance consume: true diff --git a/src/test/java/net/tfminecraft/rpcharacters/grave/GraveInsuranceExtractTest.java b/src/test/java/net/tfminecraft/rpcharacters/grave/GraveInsuranceExtractTest.java new file mode 100644 index 0000000..c91246a --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/grave/GraveInsuranceExtractTest.java @@ -0,0 +1,170 @@ +package net.tfminecraft.rpcharacters.grave; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class GraveInsuranceExtractTest { + + @Test + void pullsOneTicketOutOfABundle() { + Node ticket = ticket("ticket", 1); + Node bread = item("bread"); + Node bundle = bundle("bundle", ticket, bread); + Node[] slots = { bundle }; + + Node pulled = GraveInsuranceExtract.pullSlots(slots, new Nodes()); + + assertEquals("ticket", pulled.id); + assertEquals(1, pulled.amount); + assertEquals(1, bundle.contents.size()); + assertEquals("bread", bundle.contents.get(0).id); + assertTrue(slots[0] == bundle); + } + + @Test + void leavesTheRestOfAStackInTheBundle() { + Node stack = ticket("ticket", 3); + Node bundle = bundle("bundle", stack); + Node[] slots = { bundle }; + + Node pulled = GraveInsuranceExtract.pullSlots(slots, new Nodes()); + + assertEquals(1, pulled.amount); + assertEquals(1, bundle.contents.size()); + assertEquals(2, bundle.contents.get(0).amount); + } + + @Test + void pullsFromANestedBundle() { + Node ticket = ticket("ticket", 1); + Node inner = bundle("inner", ticket); + Node outer = bundle("outer", item("bread"), inner); + Node[] slots = { outer }; + + Node pulled = GraveInsuranceExtract.pullSlots(slots, new Nodes()); + + assertEquals("ticket", pulled.id); + assertTrue(inner.contents.isEmpty()); + assertEquals(2, outer.contents.size()); + assertEquals("bread", outer.contents.get(0).id); + } + + @Test + void skipsATicketBoundToALiveGrave() { + Node bound = ticket("bound", 1); + bound.liveBound = true; + Node free = ticket("free", 1); + Node bundle = bundle("bundle", bound, free); + Node[] slots = { bundle }; + + Node pulled = GraveInsuranceExtract.pullSlots(slots, new Nodes()); + + assertEquals("free", pulled.id); + assertEquals(1, bundle.contents.size()); + assertEquals("bound", bundle.contents.get(0).id); + } + + @Test + void returnsNullWhenTheBundleHasNoTicket() { + Node[] slots = { bundle("bundle", item("bread")) }; + + assertNull(GraveInsuranceExtract.pullSlots(slots, new Nodes())); + assertEquals(1, slots[0].contents.size()); + } + + @Test + void searchesLaterSlotsAfterAnEmptyBundle() { + Node ticket = ticket("ticket", 1); + Node[] slots = { bundle("empty"), null, bundle("later", ticket) }; + + Node pulled = GraveInsuranceExtract.pullSlots(slots, new Nodes()); + + assertEquals("ticket", pulled.id); + assertTrue(slots[2].contents.isEmpty()); + } + + private static Node item(String id) { + return new Node(id, 1, false, null); + } + + private static Node ticket(String id, int amount) { + return new Node(id, amount, true, null); + } + + private static Node bundle(String id, Node... contents) { + List inner = new ArrayList<>(); + for (Node content : contents) { + inner.add(content); + } + return new Node(id, 1, false, inner); + } + + private static final class Node { + private final String id; + private int amount; + private final boolean insurance; + private boolean liveBound; + private final List contents; + + private Node(String id, int amount, boolean insurance, List contents) { + this.id = id; + this.amount = amount; + this.insurance = insurance; + this.contents = contents; + } + } + + private static final class Nodes implements GraveInsuranceExtract.Stacks { + @Override + public boolean blank(Node item) { + return item == null || item.amount <= 0; + } + + @Override + public int amount(Node item) { + return item.amount; + } + + @Override + public void setAmount(Node item, int amount) { + item.amount = amount; + } + + @Override + public boolean insurance(Node item) { + return item.insurance; + } + + @Override + public boolean keepBinding(Node item) { + return item.liveBound; + } + + @Override + public boolean bundle(Node item) { + return item.contents != null; + } + + @Override + public List contents(Node bundle) { + return bundle.contents; + } + + @Override + public void setContents(Node bundle, List contents) { + bundle.contents.clear(); + bundle.contents.addAll(contents); + } + + @Override + public Node one(Node item) { + return new Node(item.id, 1, item.insurance, null); + } + } +}