Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/main/java/net/tfminecraft/magic/gear/GearCache.java
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> over = overResonance(player, charge);
if (!over.isEmpty() && !confirmed(player, stationKey, occupancy, token.orElseThrow())) {
Expand Down
31 changes: 29 additions & 2 deletions src/main/java/net/tfminecraft/magic/gear/GearCosts.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Integer> fraction(Map<String, Integer> costs, double fraction) {
Map<String, Integer> share = new HashMap<>();
if (costs == null || !(fraction > 0)) {
return share;
}
for (Map.Entry<String, Integer> 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<PartDef> parts) {
return has(player, total(parts));
}

public static boolean has(Player player, Map<String, Integer> costs) {
if (bypasses(player)) {
return true;
}
Map<String, Integer> costs = total(parts);
for (Map.Entry<String, Integer> entry : costs.entrySet()) {
int remaining = entry.getValue();
for (ItemStack item : player.getInventory().getContents()) {
Expand Down Expand Up @@ -92,9 +115,13 @@ public static void take(Player player, Collection<PartDef> parts) {

/** Plans removal on clones; a deposit checks the captured frame again before commit. */
public static ItemStack[] plan(Player player, Collection<PartDef> parts) {
return plan(player, total(parts));
}

public static ItemStack[] plan(Player player, Map<String, Integer> 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;
Expand Down
85 changes: 85 additions & 0 deletions src/main/java/net/tfminecraft/magic/gear/GearReattuneService.java
Original file line number Diff line number Diff line change
@@ -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<UUID, Confirm> 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<String, Integer> 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<String, Integer> 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<String> 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<String> 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);
}
}
37 changes: 37 additions & 0 deletions src/main/java/net/tfminecraft/magic/gear/GearStationListener.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down
52 changes: 46 additions & 6 deletions src/main/java/net/tfminecraft/magic/gear/GearStationStore.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,19 @@ public static final class Occupancy {
private UUID displayId;
private boolean orbSessionActive;
private final UUID owner;
private final Map<String, Integer> charged;
private Map<String, Integer> 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<String, Integer> charged, boolean rested) {
Occupancy(ItemStack item, UUID displayId, UUID owner, Map<String, Integer> 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. */
Expand All @@ -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();
}
Expand Down Expand Up @@ -165,7 +175,7 @@ public static Occupancy install(Location location, Map<Object, Object> 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);
Expand All @@ -176,7 +186,7 @@ public static Occupancy install(Location location, Map<Object, Object> 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. */
Expand Down Expand Up @@ -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; }
}
Expand Down Expand Up @@ -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); }
Expand Down Expand Up @@ -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.");
}
}
Expand Down Expand Up @@ -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<String, Integer> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading