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
5 changes: 5 additions & 0 deletions src/main/java/net/tfminecraft/magic/gear/GearKeys.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ public static NamespacedKey partPick() {
return new NamespacedKey(Magic.plugin, "gear_part_id");
}

/** Template revision of each socketed rune, by its MMOItems gem history id. */
public static NamespacedKey runeRevisions() {
return new NamespacedKey(Magic.plugin, "gear_rune_revisions");
}

/** Marks the ItemDisplay that shows the weapon resting on a gear station. */
public static NamespacedKey stationDisplay() {
return new NamespacedKey(Magic.plugin, "gear_station_display");
Expand Down
16 changes: 12 additions & 4 deletions src/main/java/net/tfminecraft/magic/gear/GearRefresher.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package net.tfminecraft.magic.gear;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
Expand Down Expand Up @@ -52,7 +55,7 @@ public static ItemStack refresh(ItemStack stack, Player holder, boolean force) {
return broken;
}

if (!force && !GearProvenance.isOutdated(stack)) {
if (!force && !GearProvenance.isOutdated(stack) && !RuneRefresher.isOutdated(stack)) {
int live = MajorityTierResolver.resolve(GearProvenance.resolveParts(stack));
if (live > 0 && GearProvenance.majorityOf(stack) != live) {
ItemStack clone = stack.clone();
Expand All @@ -71,12 +74,14 @@ public static ItemStack refresh(ItemStack stack, Player holder, boolean force) {
List<String> colours = SocketLayout.colours(archetype, GearProvenance.resolveParts(stack), band);

List<GemstoneData> orphaned = new ArrayList<>();
ItemStack rebuilt = rewrite(stack, colours, orphaned);
Map<UUID, Integer> runeRevisions = new HashMap<>();
ItemStack rebuilt = rewrite(stack, colours, orphaned, runeRevisions);
if (rebuilt == null) {
return null;
}

copyGearPdc(stack, rebuilt);
RuneRefresher.stamp(rebuilt, runeRevisions);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
WeaponRequirement.fromItem(stack).persist(rebuilt);
WeaponRift.copy(stack, rebuilt);
GearProvenance.syncRevisions(rebuilt);
Expand All @@ -99,9 +104,11 @@ public static ItemStack refresh(ItemStack stack, Player holder, boolean force) {
/**
* Writes the target socket colours while carrying existing runes across. Unlike
* {@code GearItemBuilder} craft, which builds fresh socket data at craft time,
* this puts every gem it can back into a matching empty socket first.
* this puts every gem it can back into a matching empty socket first, then brings
* socketed runes up to their current template.
*/
private static ItemStack rewrite(ItemStack stack, List<String> colours, List<GemstoneData> orphaned) {
private static ItemStack rewrite(ItemStack stack, List<String> colours, List<GemstoneData> orphaned,
Map<UUID, Integer> runeRevisions) {
try {
LiveMMOItem mmo = new LiveMMOItem(NBTItem.get(stack));
List<GemstoneData> existing = new ArrayList<>();
Expand All @@ -121,6 +128,7 @@ private static ItemStack rewrite(ItemStack stack, List<String> colours, List<Gem
}
mmo.setData(ItemStats.GEM_SOCKETS, next);
GearStatApplicator.apply(mmo, GearProvenance.resolveParts(stack));
runeRevisions.putAll(RuneRefresher.refresh(mmo, stack));
ItemStack built = mmo.newBuilder().build();
if (built == null || built.getType().isAir()) {
return null;
Expand Down
208 changes: 208 additions & 0 deletions src/main/java/net/tfminecraft/magic/gear/RuneRefresher.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package net.tfminecraft.magic.gear;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.StringJoiner;
import java.util.UUID;

import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;

import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;

import io.lumine.mythic.lib.api.item.NBTItem;
import net.Indyuce.mmoitems.ItemStats;
import net.Indyuce.mmoitems.MMOItems;
import net.Indyuce.mmoitems.api.Type;
import net.Indyuce.mmoitems.api.item.mmoitem.MMOItem;
import net.Indyuce.mmoitems.api.item.template.MMOItemTemplate;
import net.Indyuce.mmoitems.stat.data.AbilityData;
import net.Indyuce.mmoitems.stat.data.AbilityListData;
import net.Indyuce.mmoitems.stat.data.type.Mergeable;
import net.Indyuce.mmoitems.stat.data.type.StatData;
import net.Indyuce.mmoitems.stat.type.GemStoneStat;
import net.Indyuce.mmoitems.stat.type.ItemStat;
import net.Indyuce.mmoitems.stat.type.StatHistory;
import net.tfminecraft.magic.Magic;

/**
* Socketed runes keep a copy of the rune's stats and abilities from the day they were
* socketed. This swaps that copy for the rune's current MMOItems template, keeping the cast
* trigger the player picked for each ability, and records the template revision on the
* weapon so each rune change is applied once.
*/
public final class RuneRefresher {

record Socketed(UUID uuid, String type, String id) {}

private RuneRefresher() {}

/** True when a socketed rune's template changed since the weapon last took its data. */
public static boolean isOutdated(ItemStack stack) {
Map<UUID, Integer> stamped = stamps(stack);
for (Socketed gem : socketed(stack)) {
int live = liveRevision(gem);
if (live >= 0 && !Integer.valueOf(live).equals(stamped.get(gem.uuid()))) {
return true;
}
}
return false;
}

/**
* Brings every outdated rune in {@code mmo} up to its template. {@code stack} is the
* weapon {@code mmo} was read from. Returns the revisions to stamp on the rebuilt weapon;
* a rune that could not be rebuilt is left out, so it is tried again next time.
*/
public static Map<UUID, Integer> refresh(MMOItem mmo, ItemStack stack) {
Map<UUID, Integer> stamped = stamps(stack);
Map<UUID, Integer> revisions = new LinkedHashMap<>();
for (Socketed gem : socketed(stack)) {
int live = liveRevision(gem);
if (live < 0) {
continue;
}
if (Integer.valueOf(live).equals(stamped.get(gem.uuid())) || replace(mmo, gem)) {
revisions.put(gem.uuid(), live);
}
}
return revisions;
}

public static void stamp(ItemStack stack, Map<UUID, Integer> revisions) {
if (stack == null || !stack.hasItemMeta()) {
return;
}
ItemMeta meta = stack.getItemMeta();
if (revisions.isEmpty()) {
meta.getPersistentDataContainer().remove(GearKeys.runeRevisions());
} else {
StringJoiner raw = new StringJoiner(",");
revisions.forEach((uuid, revision) -> raw.add(uuid + "@" + revision));
meta.getPersistentDataContainer().set(GearKeys.runeRevisions(), PersistentDataType.STRING, raw.toString());
}
stack.setItemMeta(meta);
}

static Map<UUID, Integer> stamps(ItemStack stack) {
Map<UUID, Integer> stamps = new HashMap<>();
if (stack == null || !stack.hasItemMeta()) {
return stamps;
}
String raw = stack.getItemMeta().getPersistentDataContainer()
.get(GearKeys.runeRevisions(), PersistentDataType.STRING);
if (raw == null || raw.isBlank()) {
return stamps;
}
for (String entry : raw.split(",")) {
String[] split = entry.split("@", 2);
try {
stamps.put(UUID.fromString(split[0].trim()), Integer.parseInt(split[1].trim()));
} catch (IllegalArgumentException | ArrayIndexOutOfBoundsException ignored) {
// A damaged entry only means that rune is refreshed once more.
}
}
return stamps;
}

/** Socketed gems, read from MMOItems' socket NBT without building the whole item. */
static List<Socketed> socketed(ItemStack stack) {
List<Socketed> gems = new ArrayList<>();
if (stack == null || stack.getType().isAir()) {
return gems;
}
try {
NBTItem nbt = NBTItem.get(stack);
String raw = nbt == null ? null : nbt.getString(ItemStats.GEM_SOCKETS.getNBTPath());
if (raw == null || raw.isBlank()) {
return gems;
}
JsonObject root = JsonParser.parseString(raw).getAsJsonObject();
if (!root.has("Gemstones")) {
return gems;
}
for (JsonElement element : root.getAsJsonArray("Gemstones")) {
JsonObject gem = element.getAsJsonObject();
if (gem.has("History") && gem.has("Type") && gem.has("Id")) {
gems.add(new Socketed(UUID.fromString(gem.get("History").getAsString()),
gem.get("Type").getAsString(), gem.get("Id").getAsString()));
}
}
} catch (RuntimeException ex) {
return new ArrayList<>();
}
return gems;
}

/** The rune's current template revision, or -1 when MMOItems no longer has it. */
static int liveRevision(Socketed gem) {
Type type = MMOItems.plugin.getTypes().get(gem.type());
if (type == null) {
return -1;
}
MMOItemTemplate template = MMOItems.plugin.getTemplates().getTemplate(type, gem.id());
return template == null ? -1 : template.getRevisionId();
}

/** Same steps as MMOItems socketing a gem, but over the gem's existing history id. False when the template could not be built. */
@SuppressWarnings({"rawtypes", "unchecked"})
static boolean replace(MMOItem mmo, Socketed gem) {
MMOItem fresh = MMOItems.plugin.getMMOItem(MMOItems.plugin.getTypes().get(gem.type()), gem.id());
if (fresh == null) {
Magic.plugin.getLogger().warning("[Magic] Rune " + gem.type() + "." + gem.id() + " could not be rebuilt; kept as socketed.");
return false;
}
AbilityListData oldAbilities = null;
for (StatHistory hist : new ArrayList<>(mmo.getStatHistories())) {
StatData old = hist.getGemstoneData(gem.uuid());
if (old == null) {
continue;
}
if (old instanceof AbilityListData list) {
oldAbilities = list;
}
hist.removeGemData(gem.uuid());
mmo.setData(hist.getItemStat(), hist.recalculate(mmo.getUpgradeLevel()));
}
for (ItemStat stat : fresh.getStats()) {
StatData data = fresh.getData(stat);
if (stat instanceof GemStoneStat || !(data instanceof Mergeable)) {
continue;
}
if (data instanceof AbilityListData list) {
data = keepTriggers(list, oldAbilities);
}
mmo.mergeData(stat, data, gem.uuid());
}
return true;
}

/** The template's abilities with the trigger each one had in the weapon, by position. */
static AbilityListData keepTriggers(AbilityListData fresh, AbilityListData old) {
if (old == null) {
return fresh;
}
List<AbilityData> was = old.getAbilities();
List<AbilityData> copies = new ArrayList<>();
int index = 0;
for (AbilityData ability : fresh.getAbilities()) {
if (index >= was.size()) {
copies.add(ability);
} else {
AbilityData copy = new AbilityData(ability.getAbility(), was.get(index).getTrigger());
for (String modifier : ability.getModifiers()) {
copy.setModifier(modifier, ability.getParameter(modifier));
}
copies.add(copy);
}
index++;
}
return new AbilityListData(copies);
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
package net.tfminecraft.magic.listener;

import org.bukkit.block.DoubleChest;
import org.bukkit.entity.Entity;
import org.bukkit.entity.HumanEntity;
import org.bukkit.entity.Item;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryOpenEvent;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.event.player.PlayerItemHeldEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.inventory.BlockInventoryHolder;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.ItemStack;

import net.tfminecraft.magic.Magic;
Expand All @@ -17,7 +24,9 @@
/**
* Lazy refresh. A crafted weapon catches up with the gear config the next time the
* player touches it, so a reload never has to walk every inventory on the server.
* Runs next tick because the slot contents have not settled when these events fire.
* Joining and opening a chest also check every slot, so after a restart weapons catch
* up without anyone touching them. Runs next tick because the slot contents have not
* settled when these events fire.
*/
public final class GearRefreshListener implements Listener {

Expand Down Expand Up @@ -78,6 +87,44 @@ public void onItemHeld(PlayerItemHeldEvent event) {
});
}

@EventHandler(priority = EventPriority.MONITOR)
public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
later(() -> {
if (!player.isOnline()) {
return;
}
sweep(player.getInventory(), player);
sweep(player.getEnderChest(), player);
});
}

/** Chests, barrels and storage entities only; plugin menus are left alone. */
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onOpen(InventoryOpenEvent event) {
Inventory inventory = event.getInventory();
if (!isWorldStorage(inventory.getHolder(false)) || !(event.getPlayer() instanceof Player player)) {
return;
}
later(() -> sweep(inventory, player));
}

/** Players are entities too, but a menu owned by a player is not storage. */
public static boolean isWorldStorage(InventoryHolder holder) {
return holder instanceof BlockInventoryHolder || holder instanceof DoubleChest
|| (holder instanceof Entity && !(holder instanceof HumanEntity));
}

private static void sweep(Inventory inventory, Player player) {
ItemStack[] contents = inventory.getContents();
for (int slot = 0; slot < contents.length; slot++) {
ItemStack rebuilt = GearRefresher.refreshIfOutdated(contents[slot], player);
if (rebuilt != null) {
inventory.setItem(slot, rebuilt);
}
}
}

private static void later(Runnable task) {
if (Magic.plugin == null) {
return;
Expand Down
33 changes: 33 additions & 0 deletions src/test/java/net/tfminecraft/magic/GearRefresherCoverageTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,39 @@ void rebuildPreservesGearPdcAmountsAndExistingGems() {
}
}

@Test
void outdatedRunesRebuildCurrentGearAndStampTheirRevisions() {
var item = managed();
MockBukkit.createMockPlugin("MMOItems");
MockBukkit.createMockPlugin("MythicLib");
var built = item();
var revisions = Map.of(UUID.randomUUID(), 3);
try (var nbt = mockStatic(NBTItem.class);
var stats = mockStatic(GearStatApplicator.class);
var model = mockStatic(GearModelResolver.class);
var lore = mockStatic(WeaponLore.class);
var runes = mockStatic(RuneRefresher.class);
var items =
mockConstruction(
LiveMMOItem.class,
(m, c) -> {
var builder = mock(ItemStackBuilder.class);
when(m.newBuilder()).thenReturn(builder);
when(builder.build()).thenReturn(built);
})) {
model
.when(() -> GearModelResolver.apply(any(), any(), any()))
.thenAnswer(i -> i.getArgument(0));
lore.when(() -> WeaponLore.updateItem(any())).thenAnswer(i -> i.getArgument(0));
runes.when(() -> RuneRefresher.isOutdated(item)).thenReturn(false);
assertNull(GearRefresher.refreshIfOutdated(item, null));
runes.when(() -> RuneRefresher.isOutdated(item)).thenReturn(true);
runes.when(() -> RuneRefresher.refresh(any(), eq(item))).thenReturn(revisions);
assertSame(built, GearRefresher.refreshIfOutdated(item, null));
runes.verify(() -> RuneRefresher.stamp(built, revisions));
}
}

@Test
void incompatibleGemsAreHeldAndFailuresNeverReplaceOriginals() {
MockBukkit.createMockPlugin("MMOItems");
Expand Down
Loading
Loading