diff --git a/src/main/java/net/tfminecraft/magic/gear/GearCache.java b/src/main/java/net/tfminecraft/magic/gear/GearCache.java index 99452c2..2bc1ca9 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearCache.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearCache.java @@ -11,6 +11,10 @@ public final class GearCache { public static int outputSlot = 16; public static long confirmMillis = 5000L; + /** Share of the weapon's part costs a reset of its attunement takes. */ + public static final double DEFAULT_REATTUNE_COST_FRACTION = 0.5; + public static double reattuneCostFraction = DEFAULT_REATTUNE_COST_FRACTION; + public static boolean alignmentEnabled = false; /** When false, gem socket colour strings omit Common/Rare/Epic/Legendary prefix. */ diff --git a/src/main/java/net/tfminecraft/magic/gear/GearChargeService.java b/src/main/java/net/tfminecraft/magic/gear/GearChargeService.java index 1f78a69..e3b702f 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearChargeService.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearChargeService.java @@ -74,6 +74,12 @@ public static void tryApply(Player player, Location station, ItemStack hand) { player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); return; } + // Whoever charges a paid reset picks the weapon's new attunement, so only its owner may. + if (occupancy.isReattune() && !net.tfminecraft.magic.gear.ownership.GearEffects.authorize(player, station, occupancy)) { + player.sendMessage(Messages.get("gear.reattune.not_yours")); + player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); + return; + } String stationKey = GearStationStore.key(station); List over = overResonance(player, charge); if (!over.isEmpty() && !confirmed(player, stationKey, occupancy, token.orElseThrow())) { diff --git a/src/main/java/net/tfminecraft/magic/gear/GearCosts.java b/src/main/java/net/tfminecraft/magic/gear/GearCosts.java index aa6904f..1a5c73b 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearCosts.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearCosts.java @@ -36,11 +36,34 @@ public static boolean bypasses(Player player) { return player != null && player.hasPermission("magic.bypass_crafting_cost"); } + /** + * The share of these costs a re-attunement takes, rounded up per material so a + * nonzero fraction never makes a material free. + */ + public static Map fraction(Map costs, double fraction) { + Map share = new HashMap<>(); + if (costs == null || !(fraction > 0)) { + return share; + } + for (Map.Entry entry : costs.entrySet()) { + Integer amount = entry.getValue(); + if (amount == null || amount <= 0) { + continue; + } + // The epsilon keeps float error (10 * 0.3 = 3.0000000000000004) from adding an item. + share.put(entry.getKey(), (int) Math.min(Integer.MAX_VALUE, Math.max(1L, (long) Math.ceil(amount * fraction - 1e-9)))); + } + return share; + } + public static boolean has(Player player, Collection parts) { + return has(player, total(parts)); + } + + public static boolean has(Player player, Map costs) { if (bypasses(player)) { return true; } - Map costs = total(parts); for (Map.Entry entry : costs.entrySet()) { int remaining = entry.getValue(); for (ItemStack item : player.getInventory().getContents()) { @@ -92,9 +115,13 @@ public static void take(Player player, Collection parts) { /** Plans removal on clones; a deposit checks the captured frame again before commit. */ public static ItemStack[] plan(Player player, Collection parts) { + return plan(player, total(parts)); + } + + public static ItemStack[] plan(Player player, Map costs) { ItemStack[] after = net.tfminecraft.magic.gear.ownership.GearEffects.cloneContents(player.getInventory().getContents()); if (bypasses(player)) return after; - for (var cost : total(parts).entrySet()) { + for (var cost : costs.entrySet()) { int remaining = cost.getValue(); for (int i = 0; i < after.length && remaining > 0; i++) { ItemStack item = after[i]; if (item == null) continue; diff --git a/src/main/java/net/tfminecraft/magic/gear/GearReattuneService.java b/src/main/java/net/tfminecraft/magic/gear/GearReattuneService.java new file mode 100644 index 0000000..6967c90 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/GearReattuneService.java @@ -0,0 +1,85 @@ +package net.tfminecraft.magic.gear; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.Location; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import net.tfminecraft.magic.Messages; +import net.tfminecraft.magic.gear.ownership.GearEffects; +import net.tfminecraft.magic.gear.ownership.GearIdentity; +import net.tfminecraft.magic.gear.ownership.GearItems; +import net.tfminecraft.magic.gear.ownership.GearPropertyStore; +import net.tfminecraft.magic.util.CostFormatter; + +/** + * Wipes an attuned weapon's attunement so it can be attuned anew. The first sneak-click + * names the fee; a second one within the confirm window pays it and rests the weapon. + * The old attunement holds until a run catches something. + */ +public final class GearReattuneService { + + private static final Map CONFIRMS = new HashMap<>(); + + private GearReattuneService() {} + + private record Confirm(String stationKey, GearIdentity.Token token, String itemHash, long expiresAt) {} + + /** The weapon's current part costs, scaled by {@code gear.reattune.cost_fraction}. */ + public static Map fee(ItemStack weapon) { + return GearCosts.fraction(GearCosts.total(GearProvenance.resolveParts(weapon)), GearCache.reattuneCostFraction); + } + + public static void clear(Player player) { + CONFIRMS.remove(player.getUniqueId()); + } + + public static void request(Player player, Location station, ItemStack hand) { + var current = GearStationStore.property().identity().current(player); + if (current.isEmpty()) return; + GearIdentity.Token token = current.orElseThrow(); + boolean bypass = GearCosts.bypasses(player); + Map fee = bypass ? Map.of() : fee(hand); + String stationKey = GearStationStore.key(station); + String itemHash = GearPropertyStore.hash(GearItems.bytes(hand)); + Confirm confirm = CONFIRMS.remove(player.getUniqueId()); + if (confirm == null || System.currentTimeMillis() > confirm.expiresAt() || !confirm.stationKey().equals(stationKey) + || !confirm.token().equals(token) || !confirm.itemHash().equals(itemHash)) { + CONFIRMS.put(player.getUniqueId(), new Confirm(stationKey, token, itemHash, System.currentTimeMillis() + GearCache.confirmMillis)); + player.sendMessage(Messages.get("gear.reattune.warn")); + List lines = CostFormatter.getCostsFormatted(fee); + if (lines.isEmpty()) { + player.sendMessage(Messages.get("gear.reattune.free")); + } + for (String line : lines) { + player.sendMessage(line); + } + player.sendMessage(Messages.get("gear.reattune.confirm")); + player.playSound(player.getLocation(), Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 0.8f); + return; + } + List before = GearEffects.frame(player.getInventory().getContents()); + ItemStack[] after = GearCosts.plan(player, fee); + int slot = player.getInventory().getHeldItemSlot(); + if (after == null || after[slot] == null || !Arrays.equals(GearItems.bytes(hand), GearItems.bytes(after[slot]))) { + player.sendMessage(Messages.get("gear.reattune.lacking")); + player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); + return; + } + if (after[slot].getAmount() <= 1) after[slot] = null; else after[slot].setAmount(after[slot].getAmount() - 1); + ItemStack placed = hand.clone(); + placed.setAmount(1); + if (GearEffects.deposit(player, token, station, placed, fee, true, true, after, before) == null) return; + player.sendMessage(Messages.get("gear.reattune.placed")); + if (bypass) { + player.sendMessage(Messages.get("gear.craft.bypassed")); + } + player.playSound(station, Sound.BLOCK_ANVIL_USE, 1f, 1.2f); + } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java b/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java index e056bf3..20ef335 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java @@ -85,6 +85,7 @@ public void onFurnitureBroken(FurnitureBreakEvent event) { ItemStack weapon = GearStationStore.takeForAbort(station); if (weapon != null) { station.getWorld().dropItem(station.clone().add(0.5, 1.0, 0.5), weapon); + GearStationStore.dropReattuneFee(station, captured); GearStationStore.acknowledgeRelease(station, captured.generation()); Magic.plugin.getLogger().warning("[Magic] Gear station at " + GearStationStore.key(station) + " lost its furniture. Dropped the weapon and removed its display."); @@ -167,6 +168,9 @@ public void onInteract(PlayerInteractEvent event) { } if (player.isSneaking()) { + if (!GearBrokenMarker.isBroken(hand) && GearProvenance.stationCanCreate(hand) && GearStationStore.isAttuned(hand)) { + GearReattuneService.request(player, location, hand); + } return; } if (GearBrokenMarker.isBroken(hand)) { @@ -262,6 +266,11 @@ private static void eject(Player player, Location location) { player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); return; } + if (occupancy != null && occupancy.isReattune()) { + player.sendMessage(Messages.get("gear.reattune.locked")); + player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); + return; + } if (GearStationStore.property().identity().current(player).isEmpty()) return; ItemStack item = GearStationStore.eject(location); if (item == null) { @@ -288,6 +297,30 @@ private void returnRested(Player player, Location location, GearStationStore.Occ player.playSound(location, Sound.ENTITY_ITEM_PICKUP, 1f, 1f); } + /** + * Calls off a paid reset between runs: the unchanged weapon and the whole fee go back. + * A running charge cannot be called off, or the fee would only ever be paid for a + * result the owner liked. + */ + private void returnReattune(Player player, Location location, GearStationStore.Occupancy occupancy) { + if (occupancy.isOrbSessionActive() || GearOrbService.isActive(location)) { + player.sendMessage(Messages.get("gear.orbs.busy")); return; + } + if (!net.tfminecraft.magic.gear.ownership.GearEffects.authorize(player, location, occupancy)) { + player.sendMessage(Messages.get("gear.reattune.not_yours")); return; + } + if (!net.tfminecraft.magic.gear.ownership.GearEffects.abortReturn(player, location, occupancy, occupancy.getCharged())) { + player.sendMessage(Messages.get("gear.reattune.held")); + Magic.plugin.getLogger().warning("[Magic] Gear station at " + GearStationStore.key(location) + + " could not return a re-attunement and its fee. Check the gear station data for staff review."); + return; + } + recentAborts.values().removeIf(at -> System.currentTimeMillis() - at >= ABORT_BREAK_GUARD_MILLIS); + recentAborts.put(GearStationStore.key(location), System.currentTimeMillis()); + player.sendMessage(Messages.get("gear.reattune.returned")); + player.playSound(location, Sound.ENTITY_ITEM_PICKUP, 1f, 1f); + } + private void tryAbort(PlayerInteractEvent event) { if (event.getHand() != EquipmentSlot.HAND) { return; @@ -313,6 +346,10 @@ private void tryAbort(PlayerInteractEvent event) { if (occupancy == null) { return; } + if (occupancy.isReattune()) { + returnReattune(player, location, occupancy); + return; + } if (GearStationStore.isAttuned(occupancy.getItem())) { return; } diff --git a/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java b/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java index 0f64fe4..44912a1 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java @@ -48,17 +48,19 @@ public static final class Occupancy { private UUID displayId; private boolean orbSessionActive; private final UUID owner; - private final Map charged; + private Map charged; private final boolean rested; + private boolean reattune; private UUID generation; private net.tfminecraft.magic.gear.ownership.GearIdentity.Owner characterOwner; - Occupancy(ItemStack item, UUID displayId, UUID owner, Map charged, boolean rested) { + Occupancy(ItemStack item, UUID displayId, UUID owner, Map charged, boolean rested, boolean reattune) { this.item = item; this.displayId = displayId; this.owner = owner; this.charged = charged == null ? null : Map.copyOf(charged); this.rested = rested; + this.reattune = rested && reattune; } /** Player who prepared the craft. Null for stations saved before owners were recorded. */ @@ -82,6 +84,14 @@ public boolean isRested() { return rested; } + /** + * A rested weapon whose owner paid to wipe its attunement. The old attunement holds + * until a run catches something; until then {@link #getCharged()} is the paid fee. + */ + public boolean isReattune() { + return reattune; + } + public ItemStack getItem() { return item == null ? null : item.clone(); } @@ -165,7 +175,7 @@ public static Occupancy install(Location location, Map row) thro UUID owner = row.get("owner") instanceof String text ? uuidOf(text) : null; ItemStack item = decodeItem(row); UUID displayId = spawnDisplay(location, item); - Occupancy occupancy = new Occupancy(item, displayId, owner, readChargedRaw(row), Boolean.TRUE.equals(row.get("rested"))); + Occupancy occupancy = new Occupancy(item, displayId, owner, readChargedRaw(row), Boolean.TRUE.equals(row.get("rested")), Boolean.TRUE.equals(row.get("reattune"))); occupancy.generation = net.tfminecraft.magic.gear.ownership.GearPropertyStore.id(row); if (row.get("ownership") instanceof Map authority && "CHARACTER".equals(authority.get("kind"))) occupancy.characterOwner = net.tfminecraft.magic.gear.ownership.GearPropertyStore.owner(row); @@ -176,7 +186,7 @@ public static Occupancy install(Location location, Map row) thro public static ItemStack eject(Location location) { Occupancy occupancy = get(location); - if (occupancy == null || occupancy.isOrbSessionActive() || (!isAttuned(occupancy.getItem()) && !occupancy.isRested())) return null; + if (occupancy == null || occupancy.isOrbSessionActive() || occupancy.isReattune() || (!isAttuned(occupancy.getItem()) && !occupancy.isRested())) return null; return release(location); } /** A durable release intent precedes either inventory settlement or a public world drop. */ @@ -226,14 +236,23 @@ public static void update(Location location, ItemStack item) { update(location, occupancy, item); } public static boolean update(Location location, Occupancy captured, ItemStack item) { + return update(location, captured, item, false); + } + /** Writes the re-attuned weapon and spends the paid fee, so it is a plain rested weapon again. */ + public static boolean completeReattune(Location location, Occupancy captured, ItemStack item) { + return captured != null && captured.isReattune() && update(location, captured, item, true); + } + private static boolean update(Location location, Occupancy captured, ItemStack item, boolean reattuned) { if (get(location) != captured || item == null) return false; try { var station = expected(location, captured); var after = net.tfminecraft.magic.gear.ownership.GearYaml.copy(station.raw()); after.put("item-bytes", net.tfminecraft.magic.gear.ownership.GearPropertyStore.b64(net.tfminecraft.magic.gear.ownership.GearItems.bytes(item))); after.put("effect-state", "active"); + if (reattuned) { after.remove("reattune"); after.put("charged", new LinkedHashMap<>()); } ItemStack cached = decodeItem(after); // the re-read copy, as in install property().patch(station, after); + if (reattuned) { captured.reattune = false; captured.charged = Map.of(); } captured.setItem(cached); captured.setOrbSessionActive(false); return true; } catch (IOException ex) { warning(ex); return false; } } @@ -280,7 +299,7 @@ public static void loadViews() throws IOException { if (existing != null && id.equals(existing.generation()) && java.util.Objects.equals(existing.characterOwner(), ownerOrNull(row))) continue; if (existing != null) removeDisplays(location, null, existing.displayId); UUID display = ensureDisplay(location, item, uuidOf(row.get("display") instanceof String text ? text : null)); - Occupancy occupancy = new Occupancy(item, display, uuidOf(row.get("owner") instanceof String text ? text : null), readChargedRaw(row), Boolean.TRUE.equals(row.get("rested"))); + Occupancy occupancy = new Occupancy(item, display, uuidOf(row.get("owner") instanceof String text ? text : null), readChargedRaw(row), Boolean.TRUE.equals(row.get("rested")), Boolean.TRUE.equals(row.get("reattune"))); occupancy.generation = id; occupancy.characterOwner = ownerOrNull(row); OCCUPIED.put(key, occupancy); if (furniture(location) == Furniture.ABSENT) abandon(location); } catch (IOException | RuntimeException ex) { warning(ex); } @@ -415,7 +434,7 @@ private static void abandon(Location location) { net.tfminecraft.magic.gear.orb.GearOrbService.abort(location); ItemStack item = takeForAbort(location); if (item != null) { - drop(location, item); acknowledgeRelease(location, occupancy.generation()); + drop(location, item); dropReattuneFee(location, occupancy); acknowledgeRelease(location, occupancy.generation()); Magic.plugin.getLogger().warning("[Magic] Gear station at " + key(location) + " has no furniture. Dropped the weapon and removed its display."); } } @@ -524,6 +543,27 @@ private static void mark(ItemDisplay display, Location location) { GearKeys.stationDisplay(), PersistentDataType.STRING, key(location)); } + /** A station that loses its furniture drops a pending re-attunement's fee with the weapon. */ + public static void dropReattuneFee(Location location, Occupancy occupancy) { + if (location == null || occupancy == null || !occupancy.isReattune()) return; + if (occupancy.getCharged() == null) { + Magic.plugin.getLogger().warning("[Magic] Gear station at " + key(location) + " lost a re-attunement fee with no readable record."); + return; + } + for (Map.Entry cost : occupancy.getCharged().entrySet()) { + int remaining = cost.getValue(); + while (remaining > 0) { + ItemStack stack = net.tfminecraft.magic.util.ItemRef.build(cost.getKey()); + if (stack == null || stack.getType().isAir()) { + Magic.plugin.getLogger().warning("[Magic] Gear station at " + key(location) + " could not drop " + remaining + " " + cost.getKey() + " of a re-attunement fee."); + break; + } + int amount = Math.min(remaining, stack.getMaxStackSize()); stack.setAmount(amount); remaining -= amount; + drop(location, stack); + } + } + } + private static void drop(Location location, ItemStack item) { if (location.getWorld() == null || item == null) { return; diff --git a/src/main/java/net/tfminecraft/magic/gear/WeaponRequirement.java b/src/main/java/net/tfminecraft/magic/gear/WeaponRequirement.java index 47e16e7..a018fc9 100644 --- a/src/main/java/net/tfminecraft/magic/gear/WeaponRequirement.java +++ b/src/main/java/net/tfminecraft/magic/gear/WeaponRequirement.java @@ -24,6 +24,11 @@ public static WeaponRequirement fromItem(ItemStack stack) { stack, GearKeys.weaponReqCap(), GearKeys.weaponReqFill(), GearKeys.weaponReq())); } + /** No element at all, for a re-attunement that replaces what the weapon held. */ + public static WeaponRequirement empty() { + return new WeaponRequirement(new AuraData()); + } + public AuraData aura() { return aura; } diff --git a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java index 2e1bcb0..43100e1 100644 --- a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java +++ b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java @@ -204,23 +204,35 @@ private static void complete(GearOrbSession session, boolean announce) { return; } Map captured = session.capturedFill(); - WeaponRequirement requirement = WeaponRequirement.fromItem(weapon); + int capturedBand = WeaponRequirement.highestBand(captured); + // A paid reset replaces the old attunement only once a run catches something. + // Until then the weapon is left exactly as it was, so it never ends up inert. + boolean reattune = occupancy.isReattune(); + WeaponRequirement requirement = reattune ? WeaponRequirement.empty() : WeaponRequirement.fromItem(weapon); requirement.mergeAmounts(captured); - requirement.persist(weapon); + boolean replaced = reattune && requirement.highestBand() > 0; + // A reset reports the band it keeps; elements a weapon cannot hold do not count. + int shownBand = reattune ? requirement.highestBand() : capturedBand; + if (!reattune || replaced) { + requirement.persist(weapon); - int oldRift = WeaponRift.get(weapon); - // Only a follow-up charge scrubs Rift; the first one has nothing to clean up. - int scrub = oldRift > 0 ? OrbCache.riftPerRecharge : 0; - int newRift = Math.max(0, Math.min(OrbCache.riftCap, oldRift + session.riftDelta() - scrub)); - WeaponRift.set(weapon, newRift); + int oldRift = replaced ? 0 : WeaponRift.get(weapon); + // Only a follow-up charge scrubs Rift; the first one has nothing to clean up. + int scrub = oldRift > 0 ? OrbCache.riftPerRecharge : 0; + int newRift = Math.max(0, Math.min(OrbCache.riftCap, oldRift + session.riftDelta() - scrub)); + WeaponRift.set(weapon, newRift); - int capturedBand = WeaponRequirement.highestBand(captured); - if (capturedBand > 0 && !GearProvenance.socketsLocked(weapon)) { - weapon = GearItemBuilder.rewriteSockets(weapon, capturedBand); - } else { - weapon = WeaponLore.updateItem(weapon); + // A reset never rewrites sockets: that would drop the runes already in them. + if (!replaced && capturedBand > 0 && !GearProvenance.socketsLocked(weapon)) { + weapon = GearItemBuilder.rewriteSockets(weapon, capturedBand); + } else { + weapon = WeaponLore.updateItem(weapon); + } } - if (!GearStationStore.update(session.getStation(), occupancy, weapon)) { + boolean written = replaced + ? GearStationStore.completeReattune(session.getStation(), occupancy, weapon) + : GearStationStore.update(session.getStation(), occupancy, weapon); + if (!written) { net.tfminecraft.magic.gear.ownership.GearEffects.hold(occupancy.generation()); return; } if (player != null && player.isOnline() && session.isCurrent()) { @@ -235,13 +247,18 @@ private static void complete(GearOrbSession session, boolean announce) { if (player == null) { return; } - if (capturedBand <= 0) { + if (shownBand <= 0) { player.sendMessage(Messages.get("gear.orbs.failed")); } else { player.sendMessage(Messages.get( "gear.orbs.complete", "percent", String.valueOf((int) Math.round(session.capturedFraction() * 100)), - "band", TierBands.numeral(capturedBand))); + "band", TierBands.numeral(shownBand))); + } + if (replaced) { + player.sendMessage(Messages.get("gear.reattune.done")); + } else if (reattune) { + player.sendMessage(Messages.get("gear.reattune.kept")); } WeaponAttunementChat.sendPostChargeSummary(player, weapon); } diff --git a/src/main/java/net/tfminecraft/magic/gear/ownership/GearEffects.java b/src/main/java/net/tfminecraft/magic/gear/ownership/GearEffects.java index c1aba10..96b5d90 100644 --- a/src/main/java/net/tfminecraft/magic/gear/ownership/GearEffects.java +++ b/src/main/java/net/tfminecraft/magic/gear/ownership/GearEffects.java @@ -26,6 +26,11 @@ public static ItemStack[] cloneContents(ItemStack[] contents) { } public static GearStationStore.Occupancy deposit(Player player, GearIdentity.Token token, Location location, ItemStack item, Map charged, boolean rested, ItemStack[] inventoryAfter, List before) { + return deposit(player, token, location, item, charged, rested, false, inventoryAfter, before); + } + /** A re-attunement is a rested weapon whose {@code charged} is the paid fee. */ + public static GearStationStore.Occupancy deposit(Player player, GearIdentity.Token token, Location location, ItemStack item, + Map charged, boolean rested, boolean reattune, ItemStack[] inventoryAfter, List before) { GearPropertyStore store = GearStationStore.property(); if (!store.identity().current(token, player) || location == null || location.getWorld() == null || item == null) return null; if (!before.equals(frame(player.getInventory().getContents()))) return null; @@ -33,6 +38,7 @@ public static GearStationStore.Occupancy deposit(Player player, GearIdentity.Tok Map row = new LinkedHashMap<>(); row.put("world", location.getWorld().getName()); row.put("x", location.getBlockX()); row.put("y", location.getBlockY()); row.put("z", location.getBlockZ()); row.put("owner", token.owner().account().toString()); row.put("occupancy-id", operation.toString()); row.put("ownership", GearPropertyStore.ownership(token.owner())); row.put("effect-state", "depositing"); row.put("item-bytes", GearPropertyStore.b64(net.tfminecraft.magic.gear.ownership.GearItems.bytes(item))); row.put("rested", rested); + if (rested && reattune) row.put("reattune", true); if (charged != null) { Map costs = new LinkedHashMap<>(); charged.forEach((path, amount) -> costs.put(Integer.toString(costs.size()), Map.of("path", path, "amount", amount))); row.put("charged", costs); } row.put("deposit-intent", Map.of("operation", operation.toString(), "session", token.session(), "inventory-before", before, "inventory-after", frame(inventoryAfter))); try { @@ -87,10 +93,10 @@ public static boolean abortReturn(Player player, Location station, GearStationSt GearIdentity.Owner recipient = occupancy.characterOwner(); if (recipient == null) return false; GearIdentity.Token actor = token.orElseThrow(); - if (!occupancy.isRested() && charged == null) return false; + if (charged == null && (!occupancy.isRested() || occupancy.isReattune())) return false; List items = new ArrayList<>(); if (occupancy.isRested()) items.add(GearPropertyStore.b64(net.tfminecraft.magic.gear.ownership.GearItems.bytes(occupancy.getItem()))); - else for (var cost : charged.entrySet()) { + if (charged != null) for (var cost : charged.entrySet()) { int remaining = cost.getValue(); while (remaining > 0) { ItemStack item = ItemRef.build(cost.getKey()); if (item == null || item.getType().isAir()) return false; diff --git a/src/main/java/net/tfminecraft/magic/listener/MagicSessionListener.java b/src/main/java/net/tfminecraft/magic/listener/MagicSessionListener.java index 4bda117..c7bcf7c 100644 --- a/src/main/java/net/tfminecraft/magic/listener/MagicSessionListener.java +++ b/src/main/java/net/tfminecraft/magic/listener/MagicSessionListener.java @@ -67,8 +67,8 @@ private void clearActivities(Player player, net.tfminecraft.tlibs.character.Char SacrificeRiteService.cancelForPlayer(player.getUniqueId()); var captured = outgoing == null ? null : net.tfminecraft.magic.gear.ownership.GearCharacterBridge.token(outgoing); if (clearGear) { - if (outgoing == null) { GearOrbService.finishForPlayer(player.getUniqueId()); GearChargeService.clear(player); } - else { GearOrbService.finishForSession(player.getUniqueId(), outgoing); GearChargeService.clear(player, captured); } + if (outgoing == null) { GearOrbService.finishForPlayer(player.getUniqueId()); GearChargeService.clear(player); net.tfminecraft.magic.gear.GearReattuneService.clear(player); } + else { GearOrbService.finishForSession(player.getUniqueId(), outgoing); GearChargeService.clear(player, captured); net.tfminecraft.magic.gear.GearReattuneService.clear(player); } } ArtifactFillChestService.clear(player.getUniqueId()); if (holder instanceof ResonanceGuiHolder) { diff --git a/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java b/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java index a3bf488..53bbdc6 100644 --- a/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java @@ -178,6 +178,10 @@ private static void loadGear(ConfigurationSection section) { net.tfminecraft.magic.gear.GearCache.confirmMillis = Math.max(1L, confirm) * 1000L; net.tfminecraft.magic.gear.GearCache.socketRarityPrefix = section.getBoolean("socket_rarity_prefix", false); + double fraction = section.getDouble("reattune.cost_fraction", + net.tfminecraft.magic.gear.GearCache.DEFAULT_REATTUNE_COST_FRACTION); + net.tfminecraft.magic.gear.GearCache.reattuneCostFraction = Double.isFinite(fraction) && fraction >= 0 + ? fraction : net.tfminecraft.magic.gear.GearCache.DEFAULT_REATTUNE_COST_FRACTION; loadAlignment(section.getConfigurationSection("alignment")); } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 4ebabd3..df3e6ca 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -120,6 +120,13 @@ gear: # without the band prefix (Common / Rare / Epic / Legendary). Band still gates attunement. socket_rarity_prefix: false + # Sneak-right-click an empty station with an attuned weapon to re-attune it from scratch. + # The fee is this share of the weapon's part costs in gear/parts.yml, rounded up per + # material (0.5 = half). It is refunded if the re-attunement is taken back before a + # charge takes hold. 0 makes it free. magic.bypass_crafting_cost skips it. + reattune: + cost_fraction: 0.5 + # One-sided bonus for holding a weapon attuned to the element of the spell you cast. # Keyed by the weapon's tier band in that element. A weapon with no attunement in an # element contributes nothing; there is no penalty side, because a weapon that cannot diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index f5b1c91..f37254e 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -365,6 +365,32 @@ gear: not_yours: "#ff5555Only the one who prepared this craft can take it apart." + # Sneak-right-click an empty station with an attuned weapon to wipe its attunement. + # The fee is gear.reattune.cost_fraction of the weapon's part costs (config.yml). + reattune: + + warn: "#ff5555Re-attuning wipes this weapon's attunement and Rift once a charge takes hold. It costs:" + + free: "#aaaaaaNo materials" + + confirm: "#aaaaaaSneak-right-click again to commit" + + lacking: "#ff5555You lack the materials to re-attune this weapon." + + placed: "#aaaaaa§oThe weapon rests on the station, waiting to be attuned anew" + + locked: "#aaaaaa§oThe weapon waits to be attuned anew. Its owner can shift-left-click the station to take it and the materials back" + + kept: "#aaaaaaThe old attunement holds. Apply another charge to attune the weapon anew." + + done: "#aaaaaa§oThe old attunement is gone" + + returned: "#aaaaaa§oThe re-attunement breaks off. The weapon and the materials go back to its owner" + + not_yours: "#ff5555Only the weapon's owner can attune it anew or take it back." + + held: "#ff5555The materials for this re-attunement cannot be returned. Ask staff to take it apart." + broken: charge: "#ff5555This weapon is damaged. Reclaim its runes before charging it again." diff --git a/src/test/java/net/tfminecraft/magic/ConfigurationTest.java b/src/test/java/net/tfminecraft/magic/ConfigurationTest.java index 910d023..534b489 100644 --- a/src/test/java/net/tfminecraft/magic/ConfigurationTest.java +++ b/src/test/java/net/tfminecraft/magic/ConfigurationTest.java @@ -253,6 +253,28 @@ void runtimeConfigValidatesBoundsAndSupportsLegacyFields() throws Exception { loader.load(Path.of("src/main/resources/config.yml").toFile()); } + @Test + void reattuneFeeShareFallsBackToHalfWhenMissingNegativeOrNotANumber() throws Exception { + var loader = new ConfigLoader(); + try { + assertTrue(loader.loadSafe(write("reattune.yml", "gear: {reattune: {cost_fraction: 0.25}}\n").toFile())); + assertEquals(.25, GearCache.reattuneCostFraction); + assertTrue(loader.loadSafe(write("free.yml", "gear: {reattune: {cost_fraction: 0}}\n").toFile())); + assertEquals(0, GearCache.reattuneCostFraction); + assertTrue(loader.loadSafe(write("negative.yml", "gear: {reattune: {cost_fraction: -1}}\n").toFile())); + assertEquals(.5, GearCache.reattuneCostFraction); + GearCache.reattuneCostFraction = .25; + assertTrue(loader.loadSafe(write("nan.yml", "gear: {reattune: {cost_fraction: .nan}}\n").toFile())); + assertEquals(.5, GearCache.reattuneCostFraction); + GearCache.reattuneCostFraction = .25; + assertTrue(loader.loadSafe(write("missing.yml", "gear: {station: 'iaf(test:station)'}\n").toFile())); + assertEquals(.5, GearCache.reattuneCostFraction); + } finally { + loader.load(Path.of("src/main/resources/config.yml").toFile()); + } + assertEquals(.5, GearCache.reattuneCostFraction); + } + @Test void artifactWearReadsItsMultiplierAndFallsBackToTheDefault() throws Exception { var loader = new ConfigLoader(); diff --git a/src/test/java/net/tfminecraft/magic/GearChargeCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearChargeCoverageTest.java index 9ca7b75..5fa6439 100644 --- a/src/test/java/net/tfminecraft/magic/GearChargeCoverageTest.java +++ b/src/test/java/net/tfminecraft/magic/GearChargeCoverageTest.java @@ -84,6 +84,13 @@ void guardsRejectInvalidEmptyBusyAndBrokenStations() { when(occupancy.getItem()).thenReturn(weapon); broken.when(() -> GearBrokenMarker.isBroken(weapon)).thenReturn(true); GearChargeService.tryApply(player, loc, hand); + broken.when(() -> GearBrokenMarker.isBroken(weapon)).thenReturn(false); + when(occupancy.isReattune()).thenReturn(true); + GearChargeService.tryApply(player, loc, hand); + effects.verify(() -> net.tfminecraft.magic.gear.ownership.GearEffects.charge(any(), any(), any(), any(), any(), any(), anyInt()), never()); + effects.when(() -> net.tfminecraft.magic.gear.ownership.GearEffects.authorize(player, loc, occupancy)).thenReturn(true); + GearChargeService.tryApply(player, loc, hand); + effects.verify(() -> net.tfminecraft.magic.gear.ownership.GearEffects.authorize(player, loc, occupancy), times(2)); orbs.verify(() -> GearOrbService.begin(any(), any(), any(), any(), anyInt()), never()); } } diff --git a/src/test/java/net/tfminecraft/magic/GearOrbServiceCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearOrbServiceCoverageTest.java index 9d57d1c..d0d2cd0 100644 --- a/src/test/java/net/tfminecraft/magic/GearOrbServiceCoverageTest.java +++ b/src/test/java/net/tfminecraft/magic/GearOrbServiceCoverageTest.java @@ -189,6 +189,52 @@ void completionPersistsCaptureRiftAndSocketLockDecisions() throws Exception { } } + @Test + void aPaidResetReplacesTheAttunementOnlyWhenTheRunCatchesSomething() throws Exception { + var p = server.addPlayer(); + var weapon = item(); + var updated = new ItemStack(Material.BLAZE_ROD); + var occupancy = mock(GearStationStore.Occupancy.class); + when(occupancy.getItem()).thenReturn(weapon); + when(occupancy.isReattune()).thenReturn(true); + stores.when(() -> GearStationStore.get(station)).thenReturn(occupancy); + stores.when(() -> GearStationStore.completeReattune(any(), any(), any())).thenReturn(true); + try (var req = mockStatic(WeaponRequirement.class); + var rift = mockStatic(WeaponRift.class); + var provenance = mockStatic(GearProvenance.class); + var lore = mockStatic(WeaponLore.class); + var chat = mockStatic(WeaponAttunementChat.class)) { + var fresh = mock(WeaponRequirement.class); + req.when(WeaponRequirement::empty).thenReturn(fresh); + provenance.when(() -> GearProvenance.socketsLocked(weapon)).thenReturn(true); + lore.when(() -> WeaponLore.updateItem(weapon)).thenReturn(updated); + rift.when(() -> WeaponRift.get(weapon)).thenReturn(40); + for (int mode = 0; mode < 3; mode++) { + var s = begin(p); + when(s.riftDelta()).thenReturn(5); + // 0: nothing caught; 1: caught a band that no shown element keeps; 2: caught. + req.when(() -> WeaponRequirement.highestBand(anyMap())).thenReturn(mode == 0 ? 0 : 2); + when(fresh.highestBand()).thenReturn(mode == 2 ? 2 : 0); + invoke(GearOrbService.class, "complete", new Class[] {GearOrbSession.class, boolean.class}, s, true); + } + verify(fresh, times(3)).mergeAmounts(Map.of("fire", 10d)); + verify(fresh, times(1)).persist(weapon); + req.verify(() -> WeaponRequirement.fromItem(any()), never()); + rift.verify(() -> WeaponRift.set(weapon, 5), times(1)); + stores.verify(() -> GearStationStore.update(station, occupancy, weapon), times(2)); + stores.verify(() -> GearStationStore.completeReattune(station, occupancy, updated), times(1)); + chat.verify(() -> WeaponAttunementChat.sendPostChargeSummary(p, weapon), times(2)); + chat.verify(() -> WeaponAttunementChat.sendPostChargeSummary(p, updated), times(1)); + try (var effects = mockStatic(net.tfminecraft.magic.gear.ownership.GearEffects.class)) { + stores.when(() -> GearStationStore.completeReattune(any(), any(), any())).thenReturn(false); + var failed = begin(p); + invoke(GearOrbService.class, "complete", new Class[] {GearOrbSession.class, boolean.class}, failed, true); + effects.verify(() -> net.tfminecraft.magic.gear.ownership.GearEffects.hold(occupancy.generation())); + chat.verify(() -> WeaponAttunementChat.sendPostChargeSummary(p, updated), times(1)); + } + } + } + GearOrb orb(Location location) { var orb = new GearOrb(location, true, 0, 1, 0, 0, 0, 0, 20); orb.setLocation(location); diff --git a/src/test/java/net/tfminecraft/magic/GearReattuneCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearReattuneCoverageTest.java new file mode 100644 index 0000000..369be2f --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearReattuneCoverageTest.java @@ -0,0 +1,304 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.util.*; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.gear.ownership.*; +import net.tfminecraft.magic.util.ItemRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import org.bukkit.*; +import org.bukkit.entity.Item; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; + +class GearReattuneCoverageTest extends GearStationCoverageSupport { + double oldFraction; + long oldConfirm; + org.mockbukkit.mockbukkit.entity.PlayerMock player; + Location loc; + + @BeforeEach + void reattuneSetup() { + oldFraction = GearCache.reattuneCostFraction; + oldConfirm = GearCache.confirmMillis; + GearCache.reattuneCostFraction = .5; + GearCache.confirmMillis = 100000; + player = server.addPlayer(); + loc = station(); + var api = mock(net.tfminecraft.tlibs.objects.api.ItemAPI.class, RETURNS_DEEP_STUBS); + libs.when(net.tfminecraft.tlibs.TLibs::getItemAPI).thenReturn(api); + when(api.getChecker().checkItemWithPath(any(), any())) + .thenAnswer(call -> ((ItemStack) call.getArgument(0)).getType().name().equalsIgnoreCase(((String) call.getArgument(1)).substring(2))); + PartRegistry.register(new PartDef("paid", "Paid", "core", 1, Set.of(GearType.STAFF), "v.STICK", + Map.of("v.STONE", 4), List.of(), Map.of(), Map.of(), List.of(), null, 1, false)); + ArchetypeRegistry.register(new ArchetypeDef(GearType.STAFF, "Staff", "v.stick", "", false, List.of(), Map.of())); + } + + @AfterEach + void reattuneCleanup() { + GearCache.reattuneCostFraction = oldFraction; + GearCache.confirmMillis = oldConfirm; + GearReattuneService.clear(player); + } + + ItemStack weapon() { + var weapon = attuned(); + tag(weapon, GearKeys.archetype(), PersistentDataType.STRING, "staff"); + tag(weapon, GearKeys.parts(), PersistentDataType.STRING, "paid"); + return weapon; + } + + int count(Material material) { + return player.getInventory().all(material).values().stream().mapToInt(ItemStack::getAmount).sum(); + } + + GearIdentity.Token token() { + return GearStationStore.property().identity().current(player).orElseThrow(); + } + + GearStationStore.Occupancy depositReattune(ItemStack weapon, Map fee) { + player.getInventory().setItemInMainHand(weapon); + var before = GearEffects.frame(player.getInventory().getContents()); + var after = GearEffects.cloneContents(player.getInventory().getContents()); + after[player.getInventory().getHeldItemSlot()] = null; + return GearEffects.deposit(player, token(), loc, weapon, fee, true, true, after, before); + } + + /** Rewrites the live row, as an older or hand-edited file would, and reloads the views. */ + GearStationStore.Occupancy rewrite(GearStationStore.Occupancy occupancy, Map changes) throws Exception { + var store = GearStationStore.property(); + var row = store.station(occupancy.generation()); + var after = GearYaml.copy(row.raw()); + changes.forEach((key, value) -> { if (value == null) after.remove(key); else after.put(key, value); }); + store.patch(row, after); + GearStationStore.load(); + return GearStationStore.get(loc); + } + + @Test + void feeIsTheConfiguredShareOfTheWeaponsPartsRoundedUpPerMaterial() { + assertEquals(Map.of(), GearCosts.fraction(null, .5)); + assertEquals(Map.of(), GearCosts.fraction(Map.of("v.STONE", 3), 0)); + assertEquals(Map.of(), GearCosts.fraction(Map.of("v.STONE", 3), Double.NaN)); + var costs = new HashMap(); + costs.put("v.STONE", 3); + costs.put("v.DIRT", null); + costs.put("v.SAND", 0); + costs.put("v.GRAVEL", -2); + costs.put("v.BOOK", Integer.MAX_VALUE); + assertEquals(Map.of("v.STONE", 2, "v.BOOK", 1073741824), GearCosts.fraction(costs, .5)); + assertEquals(Map.of("v.STONE", 6, "v.BOOK", Integer.MAX_VALUE), GearCosts.fraction(costs, 2)); + assertEquals(Map.of("v.STONE", 3), GearCosts.fraction(Map.of("v.STONE", 10), .3), "float error adds no item"); + assertEquals(Map.of("v.STONE", 1), GearCosts.fraction(Map.of("v.STONE", 1), 1e-12), "a nonzero share is never free"); + assertEquals(Map.of("v.STONE", 2), GearReattuneService.fee(weapon())); + GearCache.reattuneCostFraction = 0; + assertEquals(Map.of(), GearReattuneService.fee(weapon())); + assertFalse(WeaponRequirement.empty().hasStored()); + assertEquals(0, WeaponRequirement.empty().highestBand()); + } + + @Test + void aSecondMatchingSneakClickPaysTheFeeAndRestsOneWeaponForAReset() { + var weapon = weapon(); + weapon.setAmount(2); + player.getInventory().setItemInMainHand(weapon); + player.getInventory().setItem(5, new ItemStack(Material.STONE, 3)); + GearReattuneService.request(player, loc, weapon); + assertFalse(GearStationStore.isOccupied(loc), "the first click only names the fee"); + assertEquals(3, count(Material.STONE)); + GearReattuneService.request(player, loc, weapon); + var occupancy = GearStationStore.get(loc); + assertNotNull(occupancy); + assertTrue(occupancy.isRested()); + assertTrue(occupancy.isReattune()); + assertEquals(Map.of("v.STONE", 2), occupancy.getCharged()); + assertEquals(1, occupancy.getItem().getAmount()); + assertEquals(1, player.getInventory().getItemInMainHand().getAmount()); + assertEquals(1, count(Material.STONE)); + assertEquals(Boolean.TRUE, rawRow(occupancy).get("reattune")); + assertNull(GearStationStore.eject(loc), "a pending reset cannot be picked up for free"); + assertTrue(GearStationStore.isOccupied(loc)); + } + + Map rawRow(GearStationStore.Occupancy occupancy) { + try { + return GearStationStore.property().station(occupancy.generation()).raw(); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + } + + @Test + void aStaleOrDifferentConfirmationOnlyNamesTheFeeAgain() { + var weapon = weapon(); + player.getInventory().setItemInMainHand(weapon); + player.getInventory().setItem(5, new ItemStack(Material.STONE, 8)); + GearCache.confirmMillis = -1; + GearReattuneService.request(player, loc, weapon); + GearReattuneService.request(player, loc, weapon); + assertFalse(GearStationStore.isOccupied(loc), "expired"); + GearCache.confirmMillis = 100000; + GearReattuneService.request(player, loc.clone().add(5, 0, 0), weapon); + GearReattuneService.request(player, loc, weapon); + assertFalse(GearStationStore.isOccupied(loc), "another station"); + var other = weapon(); + tag(other, GearKeys.rift(), PersistentDataType.INTEGER, 7); + GearReattuneService.request(player, loc, other); + assertFalse(GearStationStore.isOccupied(loc), "another weapon"); + var current = characters.currentSession(player).orElseThrow(); + characters.sessions.put(player.getUniqueId(), new CharacterSession(current.ref(), current.sessionId() + 1)); + GearReattuneService.request(player, loc, weapon); + assertFalse(GearStationStore.isOccupied(loc), "another session"); + GearReattuneService.clear(player); + GearReattuneService.request(player, loc, weapon); + assertFalse(GearStationStore.isOccupied(loc), "cleared"); + assertEquals(8, count(Material.STONE)); + var gone = server.addPlayer(); + gone.disconnect(); + GearReattuneService.request(gone, loc, weapon); + assertFalse(GearStationStore.isOccupied(loc)); + } + + @Test + void missingMaterialsOrAFeeThatWouldTakeTheWeaponItselfRefuse() { + var weapon = weapon(); + player.getInventory().setItemInMainHand(weapon); + player.getInventory().setItem(5, new ItemStack(Material.STONE, 1)); + GearReattuneService.request(player, loc, weapon); + GearReattuneService.request(player, loc, weapon); + assertFalse(GearStationStore.isOccupied(loc), "lacking"); + assertEquals(1, count(Material.STONE)); + PartRegistry.register(new PartDef("paid", "Paid", "core", 1, Set.of(GearType.STAFF), "v.STICK", + Map.of("v.STICK", 2), List.of(), Map.of(), Map.of(), List.of(), null, 1, false)); + GearReattuneService.request(player, loc, weapon); + GearReattuneService.request(player, loc, weapon); + assertFalse(GearStationStore.isOccupied(loc), "the fee would consume the held weapon"); + var stacked = weapon.clone(); + stacked.setAmount(2); + player.getInventory().setItemInMainHand(stacked); + GearReattuneService.request(player, loc, stacked); + GearReattuneService.request(player, loc, stacked); + assertFalse(GearStationStore.isOccupied(loc), "the fee would change the held stack"); + assertEquals(2, player.getInventory().getItemInMainHand().getAmount()); + } + + @Test + void staffBypassIsFreeAndAFailedDepositKeepsTheInventory() throws Exception { + var weapon = weapon(); + player.getInventory().setItemInMainHand(weapon); + player.getInventory().setItem(5, new ItemStack(Material.STONE, 4)); + var store = spy(GearStationStore.property()); + setField("property", store); + doThrow(new IOException("disk unavailable")).when(store).insert(any()); + GearReattuneService.request(player, loc, weapon); + GearReattuneService.request(player, loc, weapon); + assertFalse(GearStationStore.isOccupied(loc)); + assertEquals(4, count(Material.STONE)); + assertEquals(weapon, player.getInventory().getItemInMainHand()); + doCallRealMethod().when(store).insert(any()); + player.addAttachment(Magic.plugin, "magic.bypass_crafting_cost", true); + GearReattuneService.request(player, loc, weapon); + GearReattuneService.request(player, loc, weapon); + var occupancy = GearStationStore.get(loc); + assertTrue(occupancy.isReattune()); + assertEquals(Map.of(), occupancy.getCharged()); + assertEquals(4, count(Material.STONE)); + } + + @Test + void onlyARestedRowCarriesTheResetAndCompletingItSpendsTheFee() throws Exception { + var plain = GearEffects.deposit(player, token(), loc, weapon(), Map.of(), false, true, + player.getInventory().getContents(), GearEffects.frame(player.getInventory().getContents())); + assertFalse(plain.isReattune()); + assertFalse(rawRow(plain).containsKey("reattune")); + assertFalse(GearStationStore.completeReattune(loc, plain, weapon())); + assertFalse(GearStationStore.completeReattune(loc, null, weapon())); + var forged = rewrite(plain, Map.of("reattune", true)); + assertFalse(forged.isReattune(), "an unrested row is never a reset"); + takeForAbort(loc); + + var occupancy = depositReattune(weapon(), Map.of("v.STONE", 2)); + assertTrue(GearStationStore.isOccupied(loc)); + GearStationStore.clearAll(); + GearStationStore.load(); + occupancy = GearStationStore.get(loc); + assertTrue(occupancy.isReattune(), "the reset survives a restart"); + assertEquals(Map.of("v.STONE", 2), occupancy.getCharged()); + var reset = item(); + assertFalse(GearStationStore.completeReattune(loc.clone().add(9, 0, 0), occupancy, reset), "another station"); + assertTrue(occupancy.isReattune()); + assertTrue(GearStationStore.completeReattune(loc, occupancy, reset)); + assertFalse(occupancy.isReattune()); + assertEquals(Map.of(), occupancy.getCharged()); + assertEquals(reset, occupancy.getItem()); + var row = rawRow(occupancy); + assertFalse(row.containsKey("reattune")); + assertEquals(Map.of(), row.get("charged")); + assertEquals("active", row.get("effect-state")); + assertTrue(GearStationStore.isAttuned(reset) || GearStationStore.get(loc).isRested()); + assertFalse(GearStationStore.completeReattune(loc, occupancy, reset), "a finished reset is a plain rest"); + assertNotNull(eject(loc)); + } + + @Test + void aStationThatLosesItsFurnitureDropsThePendingFeeWithTheWeapon() throws Exception { + GearStationStore.dropReattuneFee(null, null); + GearStationStore.dropReattuneFee(loc, null); + try (var refs = mockStatic(ItemRef.class)) { + refs.when(() -> ItemRef.build("v.STONE")).thenReturn(new ItemStack(Material.STONE)); + refs.when(() -> ItemRef.build("v.AIR")).thenReturn(new ItemStack(Material.AIR)); + var plain = seedRest(loc, weapon(), player.getUniqueId()); + GearStationStore.dropReattuneFee(loc, plain); + takeForAbort(loc); + var malformed = rewrite(depositReattune(weapon(), Map.of("v.STONE", 2)), Map.of("charged", Map.of("0", Map.of("path", "", "amount", 1)))); + assertTrue(malformed.isReattune()); + assertNull(malformed.getCharged()); + GearStationStore.dropReattuneFee(loc, malformed); + takeForAbort(loc); + var occupancy = depositReattune(weapon(), Map.of("v.STONE", 70, "v.MISSING", 1, "v.AIR", 1)); + GearStationStore.dropReattuneFee(loc, occupancy); + assertEquals(List.of(64, 6), stoneDrops()); + for (var dropped : loc.getWorld().getEntitiesByClass(Item.class)) dropped.remove(); + doReturn(false).when(checker).checkBlock(any(), anyString()); + GearStationStore.reconcile(loc.getChunk()); + assertFalse(GearStationStore.isOccupied(loc)); + assertEquals(List.of(64, 6), stoneDrops()); + } + } + + List stoneDrops() { + return loc.getWorld().getEntitiesByClass(Item.class).stream() + .map(Item::getItemStack) + .filter(stack -> stack.getType() == Material.STONE) + .map(ItemStack::getAmount) + .sorted(Comparator.reverseOrder()) + .toList(); + } + + @Test + void abortReturnsTheWeaponWithTheFeeButHoldsAMalformedFee() throws Exception { + try (var refs = mockStatic(ItemRef.class)) { + refs.when(() -> ItemRef.build("v.STONE")).thenReturn(new ItemStack(Material.STONE)); + var weapon = weapon(); + var malformed = rewrite(depositReattune(weapon, Map.of("v.STONE", 2)), Map.of("charged", "broken")); + assertFalse(GearEffects.abortReturn(player, loc, malformed, malformed.getCharged())); + assertTrue(GearStationStore.isOccupied(loc)); + takeForAbort(loc); + var legacy = rewrite(seedRest(loc, weapon, player.getUniqueId()), new HashMap<>(Collections.singletonMap("charged", null))); + assertNull(legacy.getCharged()); + assertTrue(GearEffects.abortReturn(player, loc, legacy, null), "a plain rest needs no recorded payment"); + assertEquals(weapon, player.getInventory().getItemInMainHand()); + player.getInventory().clear(); + var occupancy = depositReattune(weapon, Map.of("v.STONE", 2)); + assertTrue(GearEffects.abortReturn(player, loc, occupancy, occupancy.getCharged())); + assertFalse(GearStationStore.isOccupied(loc)); + assertEquals(1, player.getInventory().all(Material.STICK).size()); + assertEquals(2, count(Material.STONE)); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearStationListenerCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearStationListenerCoverageTest.java index 977d4bc..36d72eb 100644 --- a/src/test/java/net/tfminecraft/magic/GearStationListenerCoverageTest.java +++ b/src/test/java/net/tfminecraft/magic/GearStationListenerCoverageTest.java @@ -405,4 +405,105 @@ void restedUnattunedWeaponCanBePickedUpAndReturnedFromARunningCharge() throws Ex left(); assertFalse(GearStationStore.isOccupied(loc)); } + + ItemStack craftable() { + ArchetypeRegistry.register( + new ArchetypeDef(GearType.STAFF, "Staff", "v.stick", "", false, List.of(), Map.of())); + var weapon = attuned(); + tag(weapon, GearKeys.archetype(), PersistentDataType.STRING, "staff"); + return weapon; + } + + GearStationStore.Occupancy reattuneRow(ItemStack weapon, UUID owner, Map fee) throws Exception { + var occupancy = seed(loc, weapon, owner, fee, true); + var store = GearStationStore.property(); + var row = store.station(occupancy.generation()); + var after = net.tfminecraft.magic.gear.ownership.GearYaml.copy(row.raw()); + after.put("reattune", true); + store.patch(row, after); + GearStationStore.load(); + assertTrue(GearStationStore.get(loc).isReattune()); + return GearStationStore.get(loc); + } + + @Test + void sneakClickingAnEmptyStationWithAnAttunedCraftableWeaponAsksForAReset() { + try (var service = mockStatic(GearReattuneService.class)) { + player.setSneaking(true); + var unattuned = craftable(); + WeaponRequirement.empty().persist(unattuned); + player.getInventory().setItemInMainHand(unattuned); + right(); + var foreign = attuned(); + player.getInventory().setItemInMainHand(foreign); + right(); + var broken = craftable(); + GearBrokenMarker.mark(broken); + player.getInventory().setItemInMainHand(broken); + right(); + service.verifyNoInteractions(); + var weapon = craftable(); + player.getInventory().setItemInMainHand(weapon); + right(); + service.verify(() -> GearReattuneService.request(eq(player), eq(loc), eq(weapon))); + assertFalse(GearStationStore.isOccupied(loc)); + } + } + + @Test + void aPendingResetStaysOnTheStationUntilItsOwnerTakesItAndTheFeeBack() throws Exception { + try (var refs = mockStatic(net.tfminecraft.magic.util.ItemRef.class)) { + refs.when(() -> net.tfminecraft.magic.util.ItemRef.build("v.stone")).thenReturn(new ItemStack(Material.STONE)); + var weapon = craftable(); + reattuneRow(weapon, player.getUniqueId(), Map.of("v.stone", 2)); + right(); + assertTrue(GearStationStore.isOccupied(loc), "an empty hand cannot pick up a paid reset"); + player.setSneaking(true); + reattuneRow(weapon, UUID.randomUUID(), Map.of("v.stone", 2)); + left(); + assertTrue(GearStationStore.isOccupied(loc), "only the owner calls a reset off"); + var unrecorded = reattuneRow(weapon, player.getUniqueId(), Map.of("v.stone", 2)); + var store = GearStationStore.property(); + var row = store.station(unrecorded.generation()); + var after = net.tfminecraft.magic.gear.ownership.GearYaml.copy(row.raw()); + after.put("charged", "lost"); + store.patch(row, after); + GearStationStore.load(); + left(); + assertTrue(GearStationStore.isOccupied(loc), "a lost fee record is held for staff"); + reattuneRow(weapon, player.getUniqueId(), Map.of("v.stone", 2)); + orbs.when(() -> GearOrbService.isActive(loc)).thenReturn(true); + left(); + assertTrue(GearStationStore.isOccupied(loc), "a running charge cannot be called off for a refund"); + orbs.when(() -> GearOrbService.isActive(loc)).thenReturn(false); + GearStationStore.get(loc).setOrbSessionActive(true); + left(); + assertTrue(GearStationStore.isOccupied(loc)); + GearStationStore.get(loc).setOrbSessionActive(false); + aborts().put("old", 0L); + aborts().put("fresh", System.currentTimeMillis()); + left(); + assertFalse(GearStationStore.isOccupied(loc)); + assertFalse(aborts().containsKey("old")); + assertTrue(aborts().containsKey("fresh")); + assertTrue(aborts().containsKey(GearStationStore.key(loc))); + assertEquals(2, player.getInventory().all(Material.STONE).values().stream().mapToInt(ItemStack::getAmount).sum()); + assertTrue(player.getInventory().contains(weapon)); + } + } + + @Test + void aBrokenStationDropsAPendingResetsFeeWithTheWeapon() throws Exception { + try (var refs = mockStatic(net.tfminecraft.magic.util.ItemRef.class)) { + refs.when(() -> net.tfminecraft.magic.util.ItemRef.build("v.stone")).thenReturn(new ItemStack(Material.STONE)); + reattuneRow(craftable(), player.getUniqueId(), Map.of("v.stone", 3)); + var entity = mock(Entity.class); + when(entity.getLocation()).thenReturn(loc.clone().add(.5, .5, .5)); + listener.onFurnitureBroken(broken("test:station", entity)); + assertFalse(GearStationStore.isOccupied(loc)); + assertTrue( + loc.getWorld().getEntitiesByClass(Item.class).stream() + .anyMatch(dropped -> dropped.getItemStack().getType() == Material.STONE && dropped.getItemStack().getAmount() == 3)); + } + } }