diff --git a/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java b/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java index 0f03bb6..189c7f5 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java @@ -29,6 +29,10 @@ public class Cache { public static HashMap> combinations = new HashMap<>(); public static double maxFactor; + /** Base stats that alloys forged before {@link #alloyLegacyForgedBefore} were made with, per base ingredient. */ + public static Map alloyLegacyBaseStats = new HashMap<>(); + /** Epoch millis; 0 turns the legacy base stats off. */ + public static long alloyLegacyForgedBefore; public static double hitOvershootWarnPercent = 30.0; public static String hitOvershootWarnMessage = "§cYour over-reliance on %hit% ruins the result further"; diff --git a/src/main/java/net/tfminecraft/advancedcrafting/database/AlloyDatabase.java b/src/main/java/net/tfminecraft/advancedcrafting/database/AlloyDatabase.java index 8c35d19..01ef867 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/database/AlloyDatabase.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/database/AlloyDatabase.java @@ -1,6 +1,11 @@ package net.tfminecraft.advancedcrafting.database; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.text.SimpleDateFormat; +import java.util.Date; import java.io.FileInputStream; import java.io.FileWriter; import java.io.InputStreamReader; @@ -19,6 +24,8 @@ import com.google.gson.GsonBuilder; import net.tfminecraft.advancedcrafting.AdvancedCrafting; +import net.tfminecraft.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.loaders.IngredientLoader; import net.tfminecraft.advancedcrafting.loaders.HitLoader; import net.tfminecraft.advancedcrafting.loaders.SchemeLoader; import net.tfminecraft.advancedcrafting.loaders.TypeLoader; @@ -26,6 +33,7 @@ import net.tfminecraft.advancedcrafting.objects.alloys.Alloy; import net.tfminecraft.advancedcrafting.objects.alloys.AlloyStation; import net.tfminecraft.advancedcrafting.objects.crafting.hits.CraftingHit; +import net.tfminecraft.advancedcrafting.objects.ingredients.Ingredient; import net.tfminecraft.advancedcrafting.objects.data.AlloyData; import net.tfminecraft.advancedcrafting.objects.data.AlloyRecipe; import net.tfminecraft.advancedcrafting.objects.data.StatData; @@ -33,10 +41,12 @@ import net.tfminecraft.advancedcrafting.objects.schemes.ColourScheme; import net.tfminecraft.advancedcrafting.objects.schemes.ModelScheme; import net.tfminecraft.advancedcrafting.objects.stats.StatModifier; +import net.tfminecraft.advancedcrafting.utils.AlloyRebaser; import net.tfminecraft.advancedcrafting.utils.IngredientLore; import net.tfminecraft.advancedcrafting.utils.RevisionTracker; public class AlloyDatabase { + private static final String BACKUP_STAMP = new SimpleDateFormat("yyyyMMdd-HHmmss").format(new Date()); private JSONObject json; private final JSONParser parser = new JSONParser(); @@ -44,13 +54,20 @@ private File alloysFolder() { return new File(AdvancedCrafting.plugin.getDataFolder(), "data/alloys"); } + /** Closes the file again; an open reader keeps Windows from deleting or replacing it. */ + private JSONObject read(File file) throws Exception { + try (InputStreamReader reader = new InputStreamReader(new FileInputStream(file), "UTF-8")) { + return (JSONObject) parser.parse(reader); + } + } + public Alloy loadAlloy(String result) { File file = new File(alloysFolder(), result.toLowerCase() + ".json"); if (!file.exists()) { return null; } try { - json = (JSONObject) parser.parse(new InputStreamReader(new FileInputStream(file), "UTF-8")); + json = read(file); return finishAlloy(parseAlloyFromJson(json)); } catch (Exception ex) { ex.printStackTrace(); @@ -67,8 +84,10 @@ public void loadAlloys() { for (final File file : files) { if (!file.isDirectory()) { try { - json = (JSONObject) parser.parse(new InputStreamReader(new FileInputStream(file), "UTF-8")); - AlloyManager.addAlloy(finishAlloy(parseAlloyFromJson(json))); + json = read(file); + Alloy alloy = parseAlloyFromJson(json); + followBase(file, alloy); + AlloyManager.addAlloy(finishAlloy(alloy)); } catch (Exception ex) { ex.printStackTrace(); } @@ -76,6 +95,74 @@ public void loadAlloys() { } } + /** + * Startup only: moves the alloy's stats by how much its base ingredient changed since it was + * forged, then records the live base. The file is backed up before it is rewritten. + */ + void followBase(File file, Alloy alloy) { + AlloyData data = alloy.getData(); + AlloyRecipe recipe = data.getRecipe(); + if (recipe == null) { + return; + } + Ingredient base = IngredientLoader.getByString(recipe.getBaseId()); + if (base == null) { + return; + } + StatData live = base.getIngredientData().getStatData(); + StatData snapshot = AlloyRebaser.snapshotFor(data.getBaseStats(), recipe.getBaseId(), live, file.lastModified()); + StatData moved = AlloyRebaser.rebase(data.getStatData(), snapshot, live, Cache.maxFactor); + if (moved == null && data.getBaseStats() != null) { + return; + } + if (!backup(file)) { + return; + } + StatData oldStats = data.getStatData(); + StatData oldBase = data.getBaseStats(); + if (moved != null) { + data.setStatData(moved); + } + data.setBaseStats(StatData.copyOf(live)); + if (!saveAlloy(alloy)) { + data.setStatData(oldStats); + data.setBaseStats(oldBase); + restore(file); + return; + } + if (moved != null) { + AdvancedCrafting.plugin.getLogger().info("AC: alloy " + alloy.getId() + " follows " + recipe.getBaseId() + ": " + + AlloyRebaser.describe(oldStats, moved)); + } + } + + /** Puts the backed-up file back after a failed rewrite, so the file matches the alloy kept in memory. */ + private void restore(File file) { + File copy = new File(AdvancedCrafting.plugin.getDataFolder(), "data/alloy-backups/" + BACKUP_STAMP + "/" + file.getName()); + try { + Files.copy(copy.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES); + AdvancedCrafting.plugin.getLogger().warning("AC: could not rewrite " + file.getName() + "; restored it unchanged."); + } catch (IOException ex) { + AdvancedCrafting.plugin.getLogger().severe("AC: could not rewrite or restore " + file.getName() + + "; the original is in " + copy.getPath() + ": " + ex.getMessage()); + } + } + + /** False when the copy failed; the alloy is then left exactly as it is. */ + private boolean backup(File file) { + File folder = new File(AdvancedCrafting.plugin.getDataFolder(), "data/alloy-backups/" + BACKUP_STAMP); + folder.mkdirs(); + try { + Files.copy(file.toPath(), new File(folder, file.getName()).toPath(), StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.COPY_ATTRIBUTES); + return true; + } catch (IOException ex) { + AdvancedCrafting.plugin.getLogger().warning("AC: could not back up " + file.getName() + + ", leaving it unchanged: " + ex.getMessage()); + return false; + } + } + public String getResult(AlloyStation station) { return AdvancedCrafting.getAlloyRecipeStore().getResult(station); } @@ -105,8 +192,9 @@ public void editAlloy(Alloy newAlloy, String oldId) { } } + /** False when the file could not be written. */ @SuppressWarnings("unchecked") - public void saveAlloy(Alloy a) { + public boolean saveAlloy(Alloy a) { String hash = RevisionTracker.sha256(a.getData().buildRevisionContent()); int revision = AdvancedCrafting.getRevisionTracker().resolveAlloy(a.getId(), hash); a.setRevision(revision); @@ -126,7 +214,7 @@ public void saveAlloy(Alloy a) { pw.flush(); pw.close(); HashMap defaults = new HashMap<>(); - json = (JSONObject) parser.parse(new InputStreamReader(new FileInputStream(file), "UTF-8")); + json = read(file); defaults.put("id", a.getId().toLowerCase()); defaults.put("name", a.getName()); defaults.put("model", a.getData().getModel()); @@ -150,21 +238,51 @@ public void saveAlloy(Alloy a) { i++; } defaults.put("stats", statArray); + if (a.getData().getBaseStats() != null) { + defaults.put("baseStats", toArray(a.getData().getBaseStats())); + } JSONArray hitArray = new JSONArray(); for (CraftingHit h : a.getData().getHits().keySet()) { String hit = h.getId() + "." + a.getData().getHits().get(h); hitArray.add(hit); } defaults.put("hits", hitArray); - save(file, defaults); + boolean saved = save(file, defaults); if (recipe != null) { AdvancedCrafting.getAlloyRecipeStore().upsert(recipe, a.getId()); } + return saved; } catch (Throwable ex) { ex.printStackTrace(); + return false; } } + @SuppressWarnings("unchecked") + private static JSONArray toArray(StatData stats) { + JSONArray array = new JSONArray(); + for (StatModifier m : stats.getModifiers()) { + array.add(m.getType() + "(" + m.getAmount() + ")"); + } + return array; + } + + /** {@code merge} adds repeated stat types together (alloy stats); otherwise each entry stays separate. */ + private static StatData parseStats(JSONArray array, boolean merge) { + StatData stats = new StatData(); + for (Object entry : array) { + String s = entry.toString(); + String st = s.split("\\(")[0]; + double amount = Double.parseDouble(s.split("\\(")[1].replace(")", "")); + if (merge) { + stats.addModifier(new StatModifier(st, amount)); + } else { + stats.getModifiers().add(new StatModifier(st, amount)); + } + } + return stats; + } + @SuppressWarnings("unchecked") private Alloy parseAlloyFromJson(JSONObject json) throws Exception { String id = ((String) json.get("id")).toLowerCase(); @@ -174,18 +292,9 @@ private Alloy parseAlloyFromJson(JSONObject json) throws Exception { ColourScheme colourScheme = SchemeLoader.getColourSchemeByString((String) json.get("colour scheme")); IngredientType type = TypeLoader.getIngredientTypeByString((String) json.get("type")); ModelScheme scheme = SchemeLoader.getModelSchemeByString((String) json.get("scheme")); - StatData stats = new StatData(); - int i = 0; - JSONArray statArray = (JSONArray) json.get("stats"); - while (i < statArray.size()) { - String s = statArray.get(i).toString(); - String st = s.split("\\(")[0]; - double amount = Double.parseDouble(s.split("\\(")[1].replace(")", "")); - stats.addModifier(new StatModifier(st, amount)); - i++; - } + StatData stats = parseStats((JSONArray) json.get("stats"), true); HashMap hits = new HashMap<>(); - i = 0; + int i = 0; JSONArray hitArray = (JSONArray) json.get("hits"); while (i < hitArray.size()) { String s = hitArray.get(i).toString(); @@ -202,8 +311,11 @@ private Alloy parseAlloyFromJson(JSONObject json) throws Exception { if (statMergeBucketId == null || statMergeBucketId.isBlank()) { statMergeBucketId = type != null ? type.getId() : null; } - return new Alloy(id, name, - new AlloyData(colourScheme, model, type, scheme, stats, hits, xp, recipe, tier, statMergeBucketId)); + AlloyData data = new AlloyData(colourScheme, model, type, scheme, stats, hits, xp, recipe, tier, statMergeBucketId); + if (json.get("baseStats") instanceof JSONArray recorded) { + data.setBaseStats(parseStats(recorded, false)); + } + return new Alloy(id, name, data); } @SuppressWarnings("unchecked") @@ -264,10 +376,9 @@ public boolean save(File file, HashMap defaults) { Gson g = new GsonBuilder().setPrettyPrinting().create(); String prettyJsonString = g.toJson(treeMap); - FileWriter fw = new FileWriter(file); - fw.write(prettyJsonString); - fw.flush(); - fw.close(); + try (FileWriter fw = new FileWriter(file)) { + fw.write(prettyJsonString); + } return true; } catch (Exception ex) { diff --git a/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java index 1f8d352..1ff236d 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java @@ -12,7 +12,9 @@ import net.tfminecraft.tlibs.interfaces.LoaderInterface; import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; +import net.tfminecraft.advancedcrafting.AdvancedCrafting; import net.tfminecraft.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.objects.data.StatData; import net.tfminecraft.advancedcrafting.objects.data.PermissionNamespace; import net.tfminecraft.advancedcrafting.objects.ingredients.IngredientType; import net.tfminecraft.advancedcrafting.utils.StatToString; @@ -60,6 +62,15 @@ public void load(File configFile) { Cache.maxFactor = config.getDouble("max-factor", 1.5); + Cache.alloyLegacyBaseStats.clear(); + Cache.alloyLegacyForgedBefore = parseInstant(config.getString("alloy-legacy-base.forged-before", "")); + if (config.isConfigurationSection("alloy-legacy-base.stats")) { + for (String ingredientId : config.getConfigurationSection("alloy-legacy-base.stats").getKeys(false)) { + Cache.alloyLegacyBaseStats.put(ingredientId.toLowerCase(), + new StatData(config.getStringList("alloy-legacy-base.stats." + ingredientId))); + } + } + Cache.hitOvershootWarnPercent = config.getDouble("hit-overshoot-warn-percent", 30.0); String overshootMessage = config.getString("hit-overshoot-warn-message"); if (overshootMessage == null || overshootMessage.isBlank()) { @@ -140,6 +151,19 @@ private void parseGlobalStatOffset(String entry) { } } + /** ISO-8601 time to epoch millis; blank or invalid gives 0, which turns the setting off. */ + public static long parseInstant(String text) { + if (text == null || text.isBlank()) { + return 0L; + } + try { + return java.time.Instant.parse(text.trim()).toEpochMilli(); + } catch (java.time.format.DateTimeParseException ex) { + AdvancedCrafting.plugin.getLogger().warning("AC: alloy-legacy-base.forged-before is not an ISO-8601 time: " + text); + return 0L; + } + } + private static double clampPercent(double value) { if (value < 0.0) { return 0.0; diff --git a/src/main/java/net/tfminecraft/advancedcrafting/managers/CraftRefreshListener.java b/src/main/java/net/tfminecraft/advancedcrafting/managers/CraftRefreshListener.java index 16dfede..3f7cc1f 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/managers/CraftRefreshListener.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/managers/CraftRefreshListener.java @@ -1,14 +1,21 @@ package net.tfminecraft.advancedcrafting.managers; import org.bukkit.Bukkit; +import org.bukkit.block.DoubleChest; +import org.bukkit.entity.Entity; +import org.bukkit.entity.HumanEntity; 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 org.bukkit.plugin.java.JavaPlugin; @@ -78,6 +85,43 @@ public void onArmorEquip(ArmorEquipEvent event) { }); } + /** After a restart every player rejoins, so this brings their carried items up to date without anyone acting. */ + @EventHandler(priority = EventPriority.MONITOR) + public void onJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + Bukkit.getScheduler().runTask(AdvancedCrafting.plugin, () -> { + if (!player.isOnline()) { + return; + } + sweep(player.getInventory()); + sweep(player.getEnderChest()); + }); + } + + /** Chests, barrels and storage entities are checked when opened; plugin menus are left alone. */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onInventoryOpen(InventoryOpenEvent event) { + Inventory inventory = event.getInventory(); + if (!isWorldStorage(inventory.getHolder(false))) { + return; + } + Bukkit.getScheduler().runTask(AdvancedCrafting.plugin, () -> sweep(inventory)); + } + + /** 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 void sweep(Inventory inventory) { + ItemStack[] contents = inventory.getContents(); + for (int slot = 0; slot < contents.length; slot++) { + int target = slot; + tryRefresh(contents[slot], item -> inventory.setItem(target, item)); + } + } + private void tryRefresh(ItemStack item, ItemConsumer writer) { if (item == null || item.getType().isAir() || !AcItemRefresher.isManaged(item)) { return; diff --git a/src/main/java/net/tfminecraft/advancedcrafting/objects/data/AlloyData.java b/src/main/java/net/tfminecraft/advancedcrafting/objects/data/AlloyData.java index b14ef94..d5a95bf 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/objects/data/AlloyData.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/objects/data/AlloyData.java @@ -26,6 +26,8 @@ public class AlloyData { private AlloyRecipe recipe; private int tier; private String statMergeBucketId; + /** The base ingredient's stats when this alloy was forged; null for alloys forged before this was recorded. */ + private StatData baseStats; public AlloyData(Ingredient base, StatData stats, HashMap hits, String xp) { colourScheme = base.getIngredientData().getScheme().getColourScheme(); @@ -37,6 +39,7 @@ public AlloyData(Ingredient base, StatData stats, HashMap this.xp = xp; this.tier = base.getIngredientData().hasTier() ? base.getIngredientData().getTier() : 1; this.statMergeBucketId = base.getIngredientData().getStatMergeBucketId(); + this.baseStats = StatData.copyOf(base.getIngredientData().getStatData()); } public AlloyData(ColourScheme colourScheme, int model, IngredientType type, ModelScheme scheme, @@ -78,6 +81,18 @@ public ModelScheme getModelScheme() { return modelScheme; } + public void setStatData(StatData stats) { + this.stats = stats; + } + + public StatData getBaseStats() { + return baseStats; + } + + public void setBaseStats(StatData baseStats) { + this.baseStats = baseStats; + } + public StatData getStatData() { return stats; } diff --git a/src/main/java/net/tfminecraft/advancedcrafting/objects/data/StatData.java b/src/main/java/net/tfminecraft/advancedcrafting/objects/data/StatData.java index 5e5d9c5..658006e 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/objects/data/StatData.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/objects/data/StatData.java @@ -18,6 +18,15 @@ public StatData() { } + /** Entry-for-entry copy; repeated stat types stay separate, as in the source. */ + public static StatData copyOf(StatData source) { + StatData copy = new StatData(); + for (StatModifier m : source.getModifiers()) { + copy.modifiers.add(m.copy()); + } + return copy; + } + public boolean hasModifiers() { if(modifiers.size() > 0) return true; return false; diff --git a/src/main/java/net/tfminecraft/advancedcrafting/utils/AlloyRebaser.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/AlloyRebaser.java new file mode 100644 index 0000000..70de927 --- /dev/null +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/AlloyRebaser.java @@ -0,0 +1,115 @@ +package net.tfminecraft.advancedcrafting.utils; + +import java.util.Set; +import java.util.TreeSet; + +import net.tfminecraft.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.objects.data.StatData; +import net.tfminecraft.advancedcrafting.objects.stats.StatModifier; + +/** + * Keeps alloys in line with later changes to their base ingredient. An alloy keeps its own + * difference from the base (what the catalysts did); only the base's share moves. + */ +public final class AlloyRebaser { + private static final double EPSILON = 1e-9; + + private AlloyRebaser() { + } + + /** + * The base stats an alloy was forged with. Alloys that predate the record use the live base, + * overridden by {@code alloy-legacy-base.stats} when their file is older than the cutoff. + */ + public static StatData snapshotFor(StatData recorded, String baseId, StatData liveBase, long fileModified) { + if (recorded != null) { + return recorded; + } + StatData snapshot = StatData.copyOf(liveBase); + StatData legacy = Cache.alloyLegacyBaseStats.get(baseId.toLowerCase()); + if (legacy == null || Cache.alloyLegacyForgedBefore <= 0 || fileModified >= Cache.alloyLegacyForgedBefore) { + return snapshot; + } + for (StatModifier m : legacy.getModifiers()) { + set(snapshot, m.getType(), m.getAmount()); + } + return snapshot; + } + + /** + * Moves each stat by how much the base changed since forging, clamped to 0 and to the + * forge cap (live base x max-factor). Returns null when the base did not change. + */ + public static StatData rebase(StatData alloy, StatData snapshot, StatData liveBase, double maxFactor) { + Set types = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + for (StatModifier m : snapshot.getModifiers()) { + types.add(m.getType()); + } + for (StatModifier m : liveBase.getModifiers()) { + types.add(m.getType()); + } + StatData result = StatData.copyOf(alloy); + boolean changed = false; + for (String type : types) { + double now = amount(liveBase, type); + double delta = now - amount(snapshot, type); + if (Math.abs(delta) < EPSILON) { + continue; + } + double old = amount(result, type); + double value = old + delta; + if (old >= 0 && value < 0) { + value = 0; + } + if (now > 0) { + value = Math.min(value, now * maxFactor); + } + value = Math.round(value * 100.0) / 100.0; + if (Math.abs(value) < EPSILON && Math.abs(now) < EPSILON) { + result.getModifiers().removeIf(m -> m.getType().equalsIgnoreCase(type)); + } else { + set(result, type, value); + } + changed = true; + } + return changed ? result : null; + } + + /** One line per stat that differs, for the startup log. */ + public static String describe(StatData before, StatData after) { + Set types = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + for (StatModifier m : before.getModifiers()) { + types.add(m.getType()); + } + for (StatModifier m : after.getModifiers()) { + types.add(m.getType()); + } + StringBuilder sb = new StringBuilder(); + for (String type : types) { + double was = amount(before, type); + double now = amount(after, type); + if (Math.abs(was - now) < EPSILON) { + continue; + } + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(type).append(' ').append(was).append(" -> ").append(now); + } + return sb.toString(); + } + + private static double amount(StatData data, String type) { + return data.getAmount(new StatModifier(type, 0)); + } + + private static void set(StatData data, String type, double value) { + for (StatModifier m : data.getModifiers()) { + if (m.getType().equalsIgnoreCase(type)) { + m.setAmount(value); + return; + } + } + data.getModifiers().add(new StatModifier(type, value)); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 33f360b..825057a 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -30,6 +30,16 @@ alloy-forge: max-factor: 1.2 +# Alloys follow later changes to their base ingredient's stats on startup: each alloy keeps its own +# difference from the base and only the base's share moves (alloy files are backed up to +# data/alloy-backups first). New alloys record their base. Alloys forged before that was recorded +# use these base values when their file is older than forged-before (ISO-8601, blank = off). +alloy-legacy-base: + forged-before: "" + stats: {} +# steel_ingot: +# - weapon_damage(12.0) + # Warn when a tool is this many percent over its requirement (16 vs 12 = 33). # Set to 0 or below to disable. Existing live configs keep working via loader defaults. hit-overshoot-warn-percent: 30.0 diff --git a/src/test/java/net/tfminecraft/advancedcrafting/AlloyFollowBaseTest.java b/src/test/java/net/tfminecraft/advancedcrafting/AlloyFollowBaseTest.java new file mode 100644 index 0000000..cfca6f5 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/AlloyFollowBaseTest.java @@ -0,0 +1,331 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.database.*; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.managers.AlloyManager; +import net.tfminecraft.advancedcrafting.objects.alloys.Alloy; +import net.tfminecraft.advancedcrafting.objects.data.*; +import net.tfminecraft.advancedcrafting.objects.stats.StatModifier; +import net.tfminecraft.advancedcrafting.utils.AlloyRebaser; +import org.junit.jupiter.api.Test; + +class AlloyFollowBaseTest extends CoverageSupport { + static final long CUTOFF = 1_000_000_000_000L; + + static StatData stats(String... entries) { + return new StatData(List.of(entries)); + } + + static double amount(StatData data, String type) { + return data.getAmount(new StatModifier(type, 0)); + } + + void store() throws Exception { + var field = AdvancedCrafting.class.getDeclaredField("alloyRecipeStore"); + field.setAccessible(true); + field.set(plugin, new AlloyRecipeStore(temp.resolve("recipes").toFile())); + } + + File alloyFile(String id, String recipe, String stats, String baseStats, long modified) + throws Exception { + Path folder = temp.resolve("data/alloys"); + Files.createDirectories(folder); + String json = + "{\"id\":\"" + + id + + "\",\"name\":\"" + + id + + "\",\"model\":30.0,\"colour scheme\":\"default\",\"type\":\"metal\"," + + "\"scheme\":\"default\",\"hits\":[\"strike.2\"],\"stats\":[" + + stats + + "]" + + (recipe == null ? "" : ",\"recipe\":{\"base\":\"" + recipe + "\",\"catalysts\":[]}") + + (baseStats == null ? "" : ",\"baseStats\":[" + baseStats + "]") + + "}"; + File file = folder.resolve(id + ".json").toFile(); + Files.writeString(file.toPath(), json); + assertTrue(file.setLastModified(modified)); + return file; + } + + String stamp() throws Exception { + var field = AlloyDatabase.class.getDeclaredField("BACKUP_STAMP"); + field.setAccessible(true); + return (String) field.get(null); + } + + @Test + void snapshotUsesTheRecordOrTheLiveBaseWithLegacyOverridesForOldFiles() { + StatData live = stats("weapon_damage(5.0)", "armor(1.0)"); + StatData recorded = stats("weapon_damage(9.0)"); + assertSame(recorded, AlloyRebaser.snapshotFor(recorded, "steel", live, 0)); + assertEquals(5.0, amount(AlloyRebaser.snapshotFor(null, "steel", live, 0), "weapon_damage")); + Cache.alloyLegacyBaseStats.put("steel", stats("weapon_damage(12.0)", "max_mana(2.0)")); + Cache.alloyLegacyForgedBefore = 0; + assertEquals(5.0, amount(AlloyRebaser.snapshotFor(null, "STEEL", live, 0), "weapon_damage")); + Cache.alloyLegacyForgedBefore = CUTOFF; + assertEquals( + 5.0, amount(AlloyRebaser.snapshotFor(null, "steel", live, CUTOFF), "weapon_damage")); + assertEquals(5.0, amount(AlloyRebaser.snapshotFor(null, "iron", live, 0), "weapon_damage")); + StatData legacy = AlloyRebaser.snapshotFor(null, "Steel", live, CUTOFF - 1); + assertEquals(12.0, amount(legacy, "weapon_damage")); + assertEquals(2.0, amount(legacy, "max_mana")); + assertEquals(1.0, amount(legacy, "armor")); + assertEquals(5.0, amount(live, "weapon_damage")); + } + + @Test + void rebaseShiftsByTheBaseChangeAndClampsToZeroAndTheForgeCap() { + StatData base = stats("weapon_damage(12.0)", "armor(1.0)"); + assertNull(AlloyRebaser.rebase(stats("weapon_damage(13.0)"), base, base, 1.2)); + StatData moved = + AlloyRebaser.rebase( + stats("weapon_damage(12.15)", "armor(1.1)", "speed(-2.0)", "gone(3.0)", "mana(4.0)"), + stats("weapon_damage(12.0)", "armor(1.0)", "speed(1.0)", "gone(3.0)", "mana(1.0)"), + stats("weapon_damage(5.0)", "armor(2.0)", "speed(0.0)", "new_stat(1.5)", "mana(2.0)"), + 1.2); + assertEquals(5.15, amount(moved, "weapon_damage")); + assertEquals(2.1, amount(moved, "armor")); + assertEquals(-3.0, amount(moved, "speed")); + assertEquals(1.5, amount(moved, "new_stat")); + assertEquals(2.4, amount(moved, "mana")); + assertTrue(moved.getModifiers().stream().noneMatch(m -> m.getType().equals("gone"))); + StatData floored = + AlloyRebaser.rebase( + stats("weapon_damage(2.0)"), + stats("weapon_damage(12.0)"), + stats("weapon_damage(5.0)"), + 1.2); + assertEquals(0.0, amount(floored, "weapon_damage")); + assertEquals(1, floored.getModifiers().size()); + } + + @Test + void describeListsOnlyStatsThatChanged() { + assertEquals( + "armor 1.0 -> 0.0, weapon_damage 12.15 -> 5.15, zinc 0.0 -> 2.0", + AlloyRebaser.describe( + stats("weapon_damage(12.15)", "armor(1.0)", "mana(3.0)"), + stats("weapon_damage(5.15)", "mana(3.0)", "zinc(2.0)"))); + assertEquals("", AlloyRebaser.describe(stats("a(1.0)"), stats("a(1.0)"))); + } + + @Test + void forgedAlloysRecordTheirBaseAndStatDataCopiesAreIndependent() throws Exception { + var steel = ingredient("steel", "stats:\n - weapon_damage(5.0)"); + var data = new AlloyData(steel, stats("weapon_damage(6.0)"), new HashMap<>(), null); + assertEquals(5.0, amount(data.getBaseStats(), "weapon_damage")); + data.getBaseStats().getModifiers().getFirst().setAmount(1.0); + assertEquals(5.0, amount(steel.getIngredientData().getStatData(), "weapon_damage")); + data.setStatData(stats("armor(1.0)")); + assertEquals(1.0, amount(data.getStatData(), "armor")); + data.setBaseStats(null); + assertNull(data.getBaseStats()); + } + + @Test + void configParsesLegacyBaseStatsAndCutoff() throws Exception { + Path config = temp.resolve("legacy.yml"); + Files.writeString( + config, + """ + alloy-legacy-base: + forged-before: "2026-10-05T20:29:00Z" + stats: + Steel_Ingot: + - weapon_damage(12.0) + """); + new ConfigLoader().load(config.toFile()); + assertEquals( + java.time.Instant.parse("2026-10-05T20:29:00Z").toEpochMilli(), + Cache.alloyLegacyForgedBefore); + assertEquals(12.0, amount(Cache.alloyLegacyBaseStats.get("steel_ingot"), "weapon_damage")); + Files.writeString(config, "alloy-legacy-base:\n forged-before: soon\n"); + new ConfigLoader().load(config.toFile()); + assertEquals(0, Cache.alloyLegacyForgedBefore); + assertTrue(Cache.alloyLegacyBaseStats.isEmpty()); + assertEquals(0, ConfigLoader.parseInstant(null)); + assertEquals(0, ConfigLoader.parseInstant(" ")); + } + + @Test + void startupMovesLegacyAlloysWithTheirBaseAndBacksThemUp() throws Exception { + store(); + ingredient("steel", "stats:\n - weapon_damage(5.0)\n - armor(0.5)"); + Cache.maxFactor = 1.2; + Cache.alloyLegacyBaseStats.put("steel", stats("weapon_damage(12.0)")); + Cache.alloyLegacyForgedBefore = CUTOFF; + File legacy = + alloyFile( + "parsusite", "steel", "\"weapon_damage(12.15)\",\"armor(0.6)\"", null, CUTOFF - 5); + File newer = alloyFile("newite", "steel", "\"weapon_damage(5.3)\"", null, CUTOFF + 5); + File recorded = + alloyFile( + "recordite", + "steel", + "\"weapon_damage(5.4)\"", + "\"weapon_damage(5.0)\",\"armor(0.5)\"", + CUTOFF - 5); + File noRecipe = alloyFile("loose", null, "\"weapon_damage(9.0)\"", null, CUTOFF - 5); + File noBase = alloyFile("orphan", "missing", "\"weapon_damage(9.0)\"", null, CUTOFF - 5); + new AlloyDatabase().loadAlloys(); + + var moved = AlloyManager.getAlloyById("parsusite").getData(); + assertEquals(5.15, amount(moved.getStatData(), "weapon_damage")); + assertEquals(0.6, amount(moved.getStatData(), "armor")); + assertEquals(5.0, amount(moved.getBaseStats(), "weapon_damage")); + Alloy reloaded = new AlloyDatabase().loadAlloy("parsusite"); + assertEquals(5.15, amount(reloaded.getData().getStatData(), "weapon_damage")); + assertEquals(5.0, amount(reloaded.getData().getBaseStats(), "weapon_damage")); + Path backups = temp.resolve("data/alloy-backups/" + stamp()); + assertTrue(Files.readString(backups.resolve("parsusite.json")).contains("12.15")); + + assertEquals(5.3, amount(new AlloyDatabase().loadAlloy("newite").getData().getStatData(), "weapon_damage")); + assertNotNull(new AlloyDatabase().loadAlloy("newite").getData().getBaseStats()); + assertTrue(Files.exists(backups.resolve("newite.json"))); + + assertFalse(Files.exists(backups.resolve("recordite.json"))); + assertEquals(CUTOFF - 5, recorded.lastModified()); + assertEquals(CUTOFF - 5, noRecipe.lastModified()); + assertEquals(CUTOFF - 5, noBase.lastModified()); + assertNull(new AlloyDatabase().loadAlloy("loose").getData().getBaseStats()); + assertTrue(legacy.exists() && newer.exists()); + + // A second start finds the record and leaves every file alone. + long movedStamp = legacy.lastModified(); + for (String id : new ArrayList<>(AlloyManager.getAlloyIds())) AlloyManager.removeAlloy(id); + new AlloyDatabase().loadAlloys(); + assertEquals(movedStamp, legacy.lastModified()); + assertEquals(5.15, amount(AlloyManager.getAlloyById("parsusite").getData().getStatData(), "weapon_damage")); + } + + @Test + void failedBackupLeavesTheAlloyUnchanged() throws Exception { + store(); + ingredient("steel", "stats:\n - weapon_damage(5.0)"); + Cache.maxFactor = 1.2; + Cache.alloyLegacyBaseStats.put("steel", stats("weapon_damage(12.0)")); + Cache.alloyLegacyForgedBefore = CUTOFF; + File blocked = alloyFile("blocked", "steel", "\"weapon_damage(12.0)\"", null, CUTOFF - 5); + Files.createDirectories(temp.resolve("data/alloy-backups/" + stamp() + "/blocked.json/inner")); + new AlloyDatabase().loadAlloys(); + var data = AlloyManager.getAlloyById("blocked").getData(); + assertEquals(12.0, amount(data.getStatData(), "weapon_damage")); + assertNull(data.getBaseStats()); + assertEquals(CUTOFF - 5, blocked.lastModified()); + assertFalse(Files.readString(blocked.toPath()).contains("baseStats")); + } + + @Test + void repeatedBaseStatEntriesStaySeparateSoAnUnchangedBaseIsLeftAlone() throws Exception { + store(); + var base = ingredient("doubled", "stats:\n - damage(5.0)\n - damage(2.0)"); + var copy = StatData.copyOf(base.getIngredientData().getStatData()); + assertEquals(2, copy.getModifiers().size()); + assertNotSame(base.getIngredientData().getStatData().getModifiers().getFirst(), copy.getModifiers().getFirst()); + File file = + alloyFile("twin", "doubled", "\"damage(6.0)\",\"damage(1.0)\"", "\"damage(5.0)\",\"damage(2.0)\"", CUTOFF - 5); + new AlloyDatabase().loadAlloys(); + assertEquals(CUTOFF - 5, file.lastModified()); + var data = AlloyManager.getAlloyById("twin").getData(); + assertEquals(2, data.getBaseStats().getModifiers().size()); + assertEquals(1, data.getStatData().getModifiers().size()); + assertEquals(7.0, amount(data.getStatData(), "damage")); + } + + @Test + void failedRewriteKeepsTheOldStatsAndRestoresTheFile() throws Exception { + store(); + ingredient("steel", "stats:\n - weapon_damage(5.0)"); + Cache.maxFactor = 1.2; + Cache.alloyLegacyBaseStats.put("steel", stats("weapon_damage(12.0)")); + Cache.alloyLegacyForgedBefore = CUTOFF; + File file = alloyFile("brittle", "steel", "\"weapon_damage(12.0)\"", null, CUTOFF - 5); + String original = Files.readString(file.toPath()); + var db = spy(new AlloyDatabase()); + doAnswer( + i -> { + Files.writeString(file.toPath(), "{broken"); + return false; + }) + .when(db) + .saveAlloy(any()); + db.loadAlloys(); + var data = AlloyManager.getAlloyById("brittle").getData(); + assertEquals(12.0, amount(data.getStatData(), "weapon_damage")); + assertNull(data.getBaseStats()); + assertEquals(original, Files.readString(file.toPath())); + // The legacy cutoff reads the file time, so backup and restore both keep it. + assertEquals(CUTOFF - 5, file.lastModified()); + assertEquals( + CUTOFF - 5, + temp.resolve("data/alloy-backups/" + stamp() + "/brittle.json").toFile().lastModified()); + } + + @Test + void failedRewriteWithoutABackupLeavesTheBrokenFileAndKeepsTheOldStats() throws Exception { + store(); + ingredient("steel", "stats:\n - weapon_damage(5.0)"); + Cache.maxFactor = 1.2; + Cache.alloyLegacyBaseStats.put("steel", stats("weapon_damage(12.0)")); + Cache.alloyLegacyForgedBefore = CUTOFF; + File file = alloyFile("lost", "steel", "\"weapon_damage(12.0)\"", null, CUTOFF - 5); + var db = spy(new AlloyDatabase()); + doAnswer( + i -> { + Files.delete(temp.resolve("data/alloy-backups/" + stamp() + "/lost.json")); + return false; + }) + .when(db) + .saveAlloy(any()); + db.loadAlloys(); + assertEquals( + 12.0, amount(AlloyManager.getAlloyById("lost").getData().getStatData(), "weapon_damage")); + assertTrue(file.exists()); + } + + @Test + void saveReportsAFailedWrite() throws Exception { + store(); + var data = + new AlloyData( + SchemeLoader.colours.get("default"), + 2, + null, + SchemeLoader.models.get("default"), + stats("armor(1.0)"), + new HashMap<>(), + null, + null, + 1, + null); + assertFalse(new AlloyDatabase().saveAlloy(new Alloy("typeless", "typeless", data))); + } + + @Test + void alloysWithoutARecordAreSavedWithoutOne() throws Exception { + store(); + var data = + new AlloyData( + SchemeLoader.colours.get("default"), + 2, + TypeLoader.map.get("metal"), + SchemeLoader.models.get("default"), + stats("armor(1.0)"), + new HashMap<>(), + null, + null, + 1, + null); + assertTrue(new AlloyDatabase().saveAlloy(new Alloy("plain", "plain", data))); + assertFalse(Files.readString(temp.resolve("data/alloys/plain.json")).contains("baseStats")); + assertNull(new AlloyDatabase().loadAlloy("plain").getData().getBaseStats()); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/RefreshListenerCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/RefreshListenerCoverageTest.java index 094b8e9..03446fd 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/RefreshListenerCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/RefreshListenerCoverageTest.java @@ -9,6 +9,8 @@ import net.tfminecraft.tlibs.armour.*; import net.tfminecraft.tlibs.event.MMOItemRebuildEvent; import org.bukkit.Material; +import org.bukkit.block.DoubleChest; +import org.bukkit.entity.minecart.StorageMinecart; import org.bukkit.entity.*; import org.bukkit.event.inventory.*; import org.bukkit.event.player.*; @@ -123,4 +125,73 @@ void rebuildRestoresTierFromOldMetadataWhenBuilderOmitsIt() { .getPersistentDataContainer() .get(PDCKeys.craftMajorityTier(), PersistentDataType.INTEGER)); } + + @Test + void joinSweepRefreshesInventoryArmourOffhandAndEnderChestOneTickLater() throws Exception { + when(plugin.isEnabled()).thenReturn(true); + var p = server.addPlayer(); + var iron = ingredient("iron", ""); + iron.setRevision(1); + var stale = iron.build(); + iron.setRevision(2); + p.getInventory().setItem(3, stale.clone()); + p.getInventory().setItemInOffHand(stale.clone()); + p.getInventory().setHelmet(stale.clone()); + p.getInventory().setItem(4, new ItemStack(Material.PAPER)); + p.getEnderChest().setItem(2, stale.clone()); + var join = mock(PlayerJoinEvent.class); + when(join.getPlayer()).thenReturn(p); + new CraftRefreshListener().onJoin(join); + assertEquals(1, AcItemTags.getStoredRevision(p.getInventory().getItem(3))); + server.getScheduler().performOneTick(); + assertEquals(2, AcItemTags.getStoredRevision(p.getInventory().getItem(3))); + assertEquals(2, AcItemTags.getStoredRevision(p.getInventory().getItemInOffHand())); + assertEquals(2, AcItemTags.getStoredRevision(p.getInventory().getHelmet())); + assertEquals(2, AcItemTags.getStoredRevision(p.getEnderChest().getItem(2))); + assertEquals(Material.PAPER, p.getInventory().getItem(4).getType()); + + Player gone = mock(Player.class); + when(gone.isOnline()).thenReturn(false); + when(join.getPlayer()).thenReturn(gone); + new CraftRefreshListener().onJoin(join); + server.getScheduler().performOneTick(); + verify(gone, never()).getInventory(); + } + + @Test + void openSweepOnlyTouchesWorldStorageAfterTheEvent() throws Exception { + when(plugin.isEnabled()).thenReturn(true); + var iron = ingredient("iron", ""); + iron.setRevision(1); + var stale = iron.build(); + iron.setRevision(2); + var listener = new CraftRefreshListener(); + Inventory menu = mock(Inventory.class); + when(menu.getHolder(false)).thenReturn(null); + var menuOpen = mock(InventoryOpenEvent.class); + when(menuOpen.getInventory()).thenReturn(menu); + listener.onInventoryOpen(menuOpen); + server.getScheduler().performOneTick(); + verify(menu, never()).getContents(); + + Inventory chest = mock(Inventory.class); + when(chest.getHolder(false)).thenReturn(mock(BlockInventoryHolder.class)); + when(chest.getContents()).thenReturn(new ItemStack[] {null, stale}); + var open = mock(InventoryOpenEvent.class); + when(open.getInventory()).thenReturn(chest); + listener.onInventoryOpen(open); + verify(chest, never()).setItem(anyInt(), any()); + server.getScheduler().performOneTick(); + verify(chest).setItem(eq(1), argThat(item -> AcItemTags.getStoredRevision(item) == 2)); + } + + @Test + void worldStorageCoversBlocksDoubleChestsAndEntitiesButNotMenus() { + assertTrue(CraftRefreshListener.isWorldStorage(mock(BlockInventoryHolder.class))); + assertTrue(CraftRefreshListener.isWorldStorage(mock(DoubleChest.class))); + assertTrue(CraftRefreshListener.isWorldStorage(mock(StorageMinecart.class))); + assertFalse(CraftRefreshListener.isWorldStorage(mock(InventoryHolder.class))); + assertFalse(CraftRefreshListener.isWorldStorage(mock(Player.class))); + assertFalse(CraftRefreshListener.isWorldStorage(null)); + } }