diff --git a/src/main/java/net/tfminecraft/advancedcrafting/managers/IngredientManager.java b/src/main/java/net/tfminecraft/advancedcrafting/managers/IngredientManager.java index 3a703c7..d93fff9 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/managers/IngredientManager.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/managers/IngredientManager.java @@ -19,6 +19,7 @@ import net.tfminecraft.advancedcrafting.objects.CraftStack; import net.tfminecraft.advancedcrafting.objects.data.StatData; import net.tfminecraft.advancedcrafting.objects.ingredients.Ingredient; +import net.tfminecraft.advancedcrafting.utils.TierBonusCalculator.TierSource; public class IngredientManager implements Listener{ private HashMap ingredients = new HashMap<>(); @@ -48,7 +49,7 @@ public void convertItem(PlayerInteractEvent e) { if(i.getType().isAir()) return; CraftStack cs = new CraftStack(i); - StatData source = resolveStatData(cs, i); + PreviewSource source = resolveSource(cs, i); if(source == null) return; e.setCancelled(true); @@ -59,25 +60,32 @@ public void convertItem(PlayerInteractEvent e) { } InventoryManager inv = new InventoryManager(); - inv.templatePreviewView(p, source); + inv.templatePreviewView(p, source.stats(), source.tier()); } - private StatData resolveStatData(CraftStack cs, ItemStack item) { + /** The hovered material's stats plus its type and tier (for templates with a tier-bonus). */ + private record PreviewSource(StatData stats, TierSource tier) { + static PreviewSource of(Ingredient ing) { + return new PreviewSource(ing.getIngredientData().getStatData(), TierSource.of(ing.getIngredientData())); + } + } + + private PreviewSource resolveSource(CraftStack cs, ItemStack item) { if(cs.isIngredient()) { Ingredient ing = cs.getIngredient(); if(ing != null) { - return ing.getIngredientData().getStatData(); + return PreviewSource.of(ing); } } if(cs.isAlloy()) { Alloy alloy = cs.getAlloy(); if(alloy != null) { - return alloy.getData().getStatData(); + return new PreviewSource(alloy.getData().getStatData(), TierSource.of(alloy.getData())); } } Ingredient ing = getFromItem(item); if(ing != null) { - return ing.getIngredientData().getStatData(); + return PreviewSource.of(ing); } return null; } diff --git a/src/main/java/net/tfminecraft/advancedcrafting/managers/InventoryManager.java b/src/main/java/net/tfminecraft/advancedcrafting/managers/InventoryManager.java index 519af6c..cc40063 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/managers/InventoryManager.java @@ -26,6 +26,8 @@ import net.tfminecraft.advancedcrafting.objects.ingredients.IngredientType; import net.tfminecraft.advancedcrafting.objects.stats.StatTemplate; import net.tfminecraft.advancedcrafting.utils.StatTemplateMath; +import net.tfminecraft.advancedcrafting.utils.TierBonusCalculator; +import net.tfminecraft.advancedcrafting.utils.TierBonusCalculator.TierSource; public class InventoryManager { @@ -76,12 +78,17 @@ public void recipeView(Player p, RecipeCategory c) { p.openInventory(i); } + public void templatePreviewView(Player p, StatData source) { + templatePreviewView(p, source, null); + } + + /** {@code tier}: the hovered material's type and tier, so templates with a tier-bonus show it. */ // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") - public void templatePreviewView(Player p, StatData source) { + public void templatePreviewView(Player p, StatData source, TierSource tier) { int count = 0; for (StatTemplate template : StatTemplateLoader.getAll()) { - if (StatTemplateMath.hasOverlap(source, template)) { + if (StatTemplateMath.hasOverlap(TierBonusCalculator.withBonus(source, template, tier), template)) { count++; } } @@ -93,13 +100,14 @@ public void templatePreviewView(Player p, StatData source) { Inventory i = AdvancedCrafting.plugin.getServer().createInventory(null, size, STAT_PREVIEW_TITLE); int slot = 0; for (StatTemplate template : StatTemplateLoader.getAll()) { - if (!StatTemplateMath.hasOverlap(source, template)) { + StatData shown = TierBonusCalculator.withBonus(source, template, tier); + if (!StatTemplateMath.hasOverlap(shown, template)) { continue; } if (slot >= i.getSize()) { break; } - i.setItem(slot, getTemplatePreviewItem(source, template)); + i.setItem(slot, getTemplatePreviewItem(shown, template)); slot++; } int slotn = 0; diff --git a/src/main/java/net/tfminecraft/advancedcrafting/objects/stats/StatTemplate.java b/src/main/java/net/tfminecraft/advancedcrafting/objects/stats/StatTemplate.java index 57f1af2..cf5dac3 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/objects/stats/StatTemplate.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/objects/stats/StatTemplate.java @@ -26,6 +26,9 @@ public class StatTemplate { private Map factors = new HashMap<>(); private List baseStats = new ArrayList<>(); private Map baseStatAmounts = new HashMap<>(); + /** Ingredient type id -> stat added per tier of that type's average material tier. */ + private Map tierBonuses = new TreeMap<>(); + private double tierBonusNeutral; private int revision; public StatTemplate(String key, ConfigurationSection config) { @@ -52,6 +55,34 @@ public StatTemplate(String key, ConfigurationSection config) { baseStatAmounts.put(base.getType().toLowerCase(), base.getAmount()); } } + loadTierBonuses(key, config); + } + + /** + * {@code tier-bonus: {leather: attack_damage(0.125)}} adds 0.125 x (average leather tier - + * {@code tier-bonus-neutral}) to the crafted item. The stat must also be in {@code stats}. + */ + private void loadTierBonuses(String key, ConfigurationSection config) { + tierBonusNeutral = config.getDouble("tier-bonus-neutral", 0); + ConfigurationSection section = config.getConfigurationSection("tier-bonus"); + if (section == null) { + return; + } + for (String typeId : section.getKeys(false)) { + StatModifier bonus; + try { + bonus = new StatModifier(section.getString(typeId)); + } catch (RuntimeException e) { + Bukkit.getLogger().warning("AC: Stat template " + key + " has an invalid tier-bonus for " + + typeId + ": " + section.get(typeId)); + continue; + } + if (!allowsIngredientStat(bonus.getType())) { + Bukkit.getLogger().warning("AC: Stat template " + key + " tier-bonus stat " + bonus.getType() + + " is not in its stats list, so it has no effect"); + } + tierBonuses.put(typeId.toLowerCase(), new StatModifier(bonus.getType().toLowerCase(), bonus.getAmount())); + } } public String getId() { @@ -92,6 +123,30 @@ public double getBaseAmount(String statId) { return amount != null ? amount : 0; } + public boolean hasTierBonuses() { + return !tierBonuses.isEmpty(); + } + + public boolean hasTierBonus(String typeId) { + return typeId != null && tierBonuses.containsKey(typeId.toLowerCase()); + } + + /** + * The stat this template adds for materials of {@code typeId} at {@code averageTier}, or null when + * the type has no bonus or the bonus is zero (average tier equal to the neutral tier). + */ + public StatModifier getTierBonus(String typeId, double averageTier) { + if (!hasTierBonus(typeId)) { + return null; + } + StatModifier perTier = tierBonuses.get(typeId.toLowerCase()); + double amount = perTier.getAmount() * (averageTier - tierBonusNeutral); + if (amount == 0) { + return null; + } + return new StatModifier(perTier.getType(), amount); + } + public int getRevision() { return revision; } @@ -116,6 +171,14 @@ public String buildRevisionContent() { .map(e -> e.getKey().toLowerCase() + "(" + e.getValue() + ")") .collect(Collectors.toList()); sb.append("factors=").append(String.join(",", factorParts)); + // Only templates that use tier bonuses add this part, so every other template keeps its revision. + if (hasTierBonuses()) { + List bonusParts = tierBonuses.entrySet().stream() + .map(e -> e.getKey() + ":" + e.getValue().getType() + "(" + e.getValue().getAmount() + ")") + .collect(Collectors.toList()); + sb.append(";tierBonus=").append(String.join(",", bonusParts)) + .append(";tierBonusNeutral=").append(tierBonusNeutral); + } return sb.toString(); } } diff --git a/src/main/java/net/tfminecraft/advancedcrafting/utils/BucketStatAverager.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/BucketStatAverager.java index 6147fd8..c6b8195 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/utils/BucketStatAverager.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/BucketStatAverager.java @@ -73,10 +73,15 @@ public static StatData compute(Map materials) { } public static StatData computeFromInputs(List inputs) { - if (inputs == null || inputs.isEmpty()) { - return new StatData(); - } + return compute(toMaterials(inputs)); + } + + /** Craft record inputs as a {@code kind.id -> amount} material map (empty for no inputs). */ + public static Map toMaterials(List inputs) { Map materials = new HashMap<>(); + if (inputs == null) { + return materials; + } for (CraftInput input : inputs) { if (input == null || input.getAmount() <= 0) { continue; @@ -88,7 +93,7 @@ public static StatData computeFromInputs(List inputs) { String key = kind.toLowerCase() + "." + input.getId().toLowerCase(); materials.merge(key, input.getAmount(), Integer::sum); } - return compute(materials); + return materials; } private static String resolveBucket(String key) { diff --git a/src/main/java/net/tfminecraft/advancedcrafting/utils/CraftStatCalculator.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/CraftStatCalculator.java index cf9a61a..4fa82e0 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/utils/CraftStatCalculator.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/CraftStatCalculator.java @@ -21,14 +21,16 @@ public static StatData compute(CraftingRecipe recipe, List inputs) { if (recipe == null) { return new StatData(); } - return applyPipeline(BucketStatAverager.computeFromInputs(inputs), recipe); + return compute(recipe, BucketStatAverager.toMaterials(inputs)); } public static StatData compute(CraftingRecipe recipe, Map materials) { if (recipe == null) { return new StatData(); } - return applyPipeline(BucketStatAverager.compute(materials), recipe); + StatData stats = BucketStatAverager.compute(materials); + TierBonusCalculator.apply(stats, recipe.getStatTemplate(), materials); + return applyTemplate(stats, recipe); } public static Set collectManagedStatIds(CraftingRecipe recipe) { @@ -48,10 +50,6 @@ public static Set collectManagedStatIds(CraftingRecipe recipe) { return ids; } - private static StatData applyPipeline(StatData stats, CraftingRecipe recipe) { - return applyTemplate(stats, recipe); - } - private static StatData applyTemplate(StatData stats, CraftingRecipe recipe) { StatTemplate template = recipe.getStatTemplate(); if (template == null) { diff --git a/src/main/java/net/tfminecraft/advancedcrafting/utils/TierBonusCalculator.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/TierBonusCalculator.java new file mode 100644 index 0000000..680fcf7 --- /dev/null +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/TierBonusCalculator.java @@ -0,0 +1,109 @@ +package net.tfminecraft.advancedcrafting.utils; + +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; + +import net.tfminecraft.advancedcrafting.loaders.IngredientLoader; +import net.tfminecraft.advancedcrafting.managers.AlloyManager; +import net.tfminecraft.advancedcrafting.objects.alloys.Alloy; +import net.tfminecraft.advancedcrafting.objects.data.AlloyData; +import net.tfminecraft.advancedcrafting.objects.data.IngredientData; +import net.tfminecraft.advancedcrafting.objects.data.StatData; +import net.tfminecraft.advancedcrafting.objects.ingredients.Ingredient; +import net.tfminecraft.advancedcrafting.objects.ingredients.IngredientType; +import net.tfminecraft.advancedcrafting.objects.stats.StatModifier; +import net.tfminecraft.advancedcrafting.objects.stats.StatTemplate; + +/** + * Applies a stat template's {@code tier-bonus}: for each ingredient type the template names, the + * average tier of the materials of that type (weighted by amount) sets the bonus. + */ +public final class TierBonusCalculator { + private TierBonusCalculator() { + } + + /** Type id and tier of one material; null-safe factory methods return null when either is missing. */ + public record TierSource(String typeId, int tier) { + public static TierSource of(IngredientType type, int tier) { + if (type == null || tier <= 0) { + return null; + } + return new TierSource(type.getId().toLowerCase(), tier); + } + + public static TierSource of(IngredientData data) { + return data == null ? null : of(data.getType(), data.getTier()); + } + + public static TierSource of(AlloyData data) { + return data == null ? null : of(data.getType(), data.getTier()); + } + } + + /** Adds the template's tier bonuses for these materials ({@code kind.id -> amount}) to {@code stats}. */ + public static void apply(StatData stats, StatTemplate template, Map materials) { + if (template == null || !template.hasTierBonuses() || materials == null) { + return; + } + Map tierSums = new HashMap<>(); // type id -> {sum of tier x amount, amount} + for (Map.Entry entry : materials.entrySet()) { + int amount = entry.getValue(); + if (amount <= 0) { + continue; + } + TierSource source = resolve(entry.getKey()); + if (source == null || !template.hasTierBonus(source.typeId())) { + continue; + } + int[] sum = tierSums.computeIfAbsent(source.typeId(), t -> new int[2]); + sum[0] += source.tier() * amount; + sum[1] += amount; + } + // Sum per stat first: StatData rounds to 2 decimals on every merge, so adding -0.125 twice + // would give 3.26 instead of 3.25. + Map totals = new TreeMap<>(); + for (Map.Entry entry : tierSums.entrySet()) { + double averageTier = (double) entry.getValue()[0] / entry.getValue()[1]; + StatModifier bonus = template.getTierBonus(entry.getKey(), averageTier); + if (bonus != null) { + totals.merge(bonus.getType(), bonus.getAmount(), Double::sum); + } + } + for (Map.Entry total : totals.entrySet()) { + stats.addModifier(new StatModifier(total.getKey(), total.getValue())); + } + } + + /** {@code source} plus the template's bonus for one material of that type and tier (preview). */ + public static StatData withBonus(StatData source, StatTemplate template, TierSource tier) { + if (tier == null) { + return source; + } + StatModifier bonus = template.getTierBonus(tier.typeId(), tier.tier()); + if (bonus == null) { + return source; + } + StatData result = StatData.copyOf(source); + result.addModifier(bonus); + return result; + } + + static TierSource resolve(String materialKey) { + String[] split = materialKey.split("\\.", 2); + if (split.length < 2) { + return null; + } + String kind = split[0].toLowerCase(); + String id = split[1].toLowerCase(); + if (kind.equals("ingredient")) { + Ingredient ingredient = IngredientLoader.getByString(id); + return ingredient == null ? null : TierSource.of(ingredient.getIngredientData()); + } + if (kind.equals("alloy")) { + Alloy alloy = AlloyManager.getAlloyById(id); + return alloy == null ? null : TierSource.of(alloy.getData()); + } + return null; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index f812478..2c81cff 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -107,6 +107,7 @@ global-stat-offsets: stat-aliases: - max_item_damage->Durability + - entity_interaction_range->Reach - max_health->Max Health - health_regeneration->Health Regeneration - armor->Armor diff --git a/src/main/resources/ingredients.yml b/src/main/resources/ingredients.yml index 939a96e..98024ae 100644 --- a/src/main/resources/ingredients.yml +++ b/src/main/resources/ingredients.yml @@ -777,9 +777,11 @@ leather: scheme: leather model-scheme: default stats: + - armor_toughness(0.25) - critical_strike_chance(5.0) - damage_reduction(2.0) - movement_speed(0.3) + - entity_interaction_range(0.03) hits: - etch.1 - whittle.2 @@ -793,10 +795,11 @@ rare_leather: scheme: leather model-scheme: default stats: - - armor_toughness(0.25) + - armor_toughness(0.5) - critical_strike_chance(10.0) - damage_reduction(4.0) - movement_speed(0.4) + - entity_interaction_range(0.05) hits: - etch.3 - whittle.2 @@ -810,10 +813,11 @@ epic_leather: scheme: leather model-scheme: default stats: - - armor_toughness(0.375) + - armor_toughness(0.75) - critical_strike_chance(15.0) - - damage_reduction(8.0) + - damage_reduction(6.0) - movement_speed(0.5) + - entity_interaction_range(0.08) hits: - etch.2 - whittle.1 @@ -827,10 +831,11 @@ legendary_leather: scheme: leather model-scheme: default stats: - - armor_toughness(0.5) + - armor_toughness(1.0) - critical_strike_chance(20.0) - - damage_reduction(16.0) + - damage_reduction(8.0) - movement_speed(0.6) + - entity_interaction_range(0.10) hits: - etch.2 - whittle.2 @@ -844,6 +849,7 @@ feather: scheme: feather model-scheme: default stats: + - armor_toughness(0.25) - critical_strike_chance(5.0) - damage_reduction(2.0) - movement_speed(0.3) @@ -861,7 +867,7 @@ rare_feather: scheme: feather model-scheme: default stats: - - armor_toughness(0.25) + - armor_toughness(0.5) - critical_strike_chance(10.0) - damage_reduction(4.0) - movement_speed(0.4) @@ -879,9 +885,9 @@ epic_feather: scheme: feather model-scheme: default stats: - - armor_toughness(0.375) + - armor_toughness(0.75) - critical_strike_chance(15.0) - - damage_reduction(8.0) + - damage_reduction(6.0) - movement_speed(0.5) - arrow_velocity(0.3) hits: @@ -897,9 +903,9 @@ legendary_feather: scheme: feather model-scheme: default stats: - - armor_toughness(0.5) + - armor_toughness(1.0) - critical_strike_chance(20.0) - - damage_reduction(16.0) + - damage_reduction(8.0) - movement_speed(0.6) - arrow_velocity(0.4) hits: @@ -915,9 +921,11 @@ wool: scheme: wool model-scheme: default stats: + - armor_toughness(0.25) - critical_strike_chance(5.0) - damage_reduction(2.0) - movement_speed(0.3) + - entity_interaction_range(0.03) hits: - etch.1 - whittle.2 @@ -931,10 +939,11 @@ rare_wool: scheme: wool model-scheme: default stats: - - armor_toughness(0.25) + - armor_toughness(0.5) - critical_strike_chance(10.0) - damage_reduction(4.0) - movement_speed(0.4) + - entity_interaction_range(0.05) hits: - etch.2 - whittle.2 @@ -948,10 +957,11 @@ epic_wool: scheme: wool model-scheme: default stats: - - armor_toughness(0.375) + - armor_toughness(0.75) - critical_strike_chance(15.0) - - damage_reduction(8.0) + - damage_reduction(6.0) - movement_speed(0.5) + - entity_interaction_range(0.08) hits: - etch.1 - whittle.1 @@ -965,10 +975,11 @@ legendary_wool: scheme: wool model-scheme: default stats: - - armor_toughness(0.5) + - armor_toughness(1.0) - critical_strike_chance(20.0) - - damage_reduction(16.0) + - damage_reduction(8.0) - movement_speed(0.6) + - entity_interaction_range(0.10) hits: - etch.2 - whittle.1 diff --git a/src/main/resources/stats.yml b/src/main/resources/stats.yml index efbd2b5..0bef83c 100644 --- a/src/main/resources/stats.yml +++ b/src/main/resources/stats.yml @@ -28,6 +28,12 @@ sword: - spell_vampirism - lifesteal - max_item_damage + # Sword = steady damage, no extra bonus: each tier of leather and wool adds 0.125 attack damage, + # around rare (tier 2), so common/rare/epic/legendary give -0.25/0/+0.25/+0.5 (2026-11-11). + tier-bonus: + leather: attack_damage(0.125) + wool: attack_damage(0.125) + tier-bonus-neutral: 2 battleaxe: name: "§fBattleaxe" @@ -93,7 +99,9 @@ spear: - magic_damage - spell_vampirism - lifesteal + - entity_interaction_range - max_item_damage + # Reach: 0.2 from the spear + leather/wool 0.03-0.10 each = 0.26-0.40 (2026-11-11). factors: attack_damage: 0.90 @@ -110,7 +118,9 @@ polearm: - magic_damage - spell_vampirism - lifesteal + - entity_interaction_range - max_item_damage + # Reach: 0.4 from the polearm + leather/wool 0.03-0.10 each = 0.46-0.60 (2026-11-11). # Two-handed (2026-11-01): no off-hand, so hits deal 1.5x to keep up with a one-hander # plus shortsword/shield at the slower 1.2 attack speed. factors: @@ -167,7 +177,6 @@ greataxe: base-stats: - attack_speed(1.2) - blunt_power(3) - - movement_speed(0.1) stats: - attack_damage - attack_speed @@ -179,14 +188,13 @@ greataxe: - lifesteal - movement_speed - max_item_damage - # Two-handed, so it never has an off-hand shortsword: movement is (0.1 + leather/wool) x 2, - # what a battleaxe plus an off-hand shortsword with a 0.2 movement base give with the same - # leather and wool. + # Two-handed, so it never has an off-hand shortsword: movement is leather/wool x 1.5 = 0.9-1.8, + # a battleaxe plus a shortsword (half a battleaxe), so a battleaxe is 2/3 of it (2026-11-11). # Two-handed (2026-11-01): no off-hand, so hits deal 1.5x to keep up with a one-hander # plus shortsword/shield at the slower 1.2 attack speed. # Blunt splash = blunt_rating % of the hit (MythicLib base 33 does not add): x0.9 = 45-72%. factors: - movement_speed: 2.0 + movement_speed: 1.5 attack_damage: 1.5 blunt_rating: 0.9 diff --git a/src/test/java/net/tfminecraft/advancedcrafting/IngredientManagerCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/IngredientManagerCoverageTest.java index 235cbae..3968eff 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/IngredientManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/IngredientManagerCoverageTest.java @@ -85,7 +85,7 @@ void converterIgnoresOtherBlocksAndShowsPreviewForManagedOrConvertibleMaterials( manager.convertItem(event); assertTrue(event.isCancelled()); verify(menu.constructed().getFirst()) - .templatePreviewView(player, iron.getIngredientData().getStatData()); + .templatePreviewView(player, iron.getIngredientData().getStatData(), null); manager.convertItem(event); assertEquals(2, menu.constructed().size()); var alloy = diff --git a/src/test/java/net/tfminecraft/advancedcrafting/TierBonusTest.java b/src/test/java/net/tfminecraft/advancedcrafting/TierBonusTest.java new file mode 100644 index 0000000..0eb66f0 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/TierBonusTest.java @@ -0,0 +1,210 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.managers.AlloyManager; +import net.tfminecraft.advancedcrafting.managers.InventoryManager; +import net.tfminecraft.advancedcrafting.objects.alloys.Alloy; +import net.tfminecraft.advancedcrafting.objects.crafting.CraftingRecipe; +import net.tfminecraft.advancedcrafting.objects.data.*; +import net.tfminecraft.advancedcrafting.objects.ingredients.*; +import net.tfminecraft.advancedcrafting.objects.stats.*; +import net.tfminecraft.advancedcrafting.utils.*; +import net.tfminecraft.advancedcrafting.utils.TierBonusCalculator.TierSource; +import org.bukkit.Material; +import org.junit.jupiter.api.Test; + +/** Stat template {@code tier-bonus}: sword damage that grows with the leather and wool tier. */ +class TierBonusTest extends CoverageSupport { + static final StatModifier DAMAGE = new StatModifier("attack_damage", 0); + + static StatTemplate sword() throws Exception { + return new StatTemplate( + "sword", + yaml( + "stats: [attack_damage]\n" + + "tier-bonus:\n" + + " leather: attack_damage(0.125)\n" + + " WOOL: ATTACK_DAMAGE(0.125)\n" + + "tier-bonus-neutral: 2")); + } + + Ingredient animal(String id, String type, int tier) throws Exception { + var result = new Ingredient(id, yaml("path: v.leather\ntype: " + type + "\ntier: " + tier)); + IngredientLoader.oList.add(result); + return result; + } + + void animals() throws Exception { + TypeLoader.map.put("leather", new IngredientType("leather", yaml("name: Leather"))); + TypeLoader.map.put("wool", new IngredientType("wool", yaml("name: Wool"))); + for (int tier = 1; tier <= 4; tier++) { + animal("leather" + tier, "leather", tier); + animal("wool" + tier, "wool", tier); + } + ingredient("iron", "tier: 1\nstats: ['attack_damage(3.5)']"); + } + + double damage(CraftingRecipe craft, Map materials) { + return CraftStatCalculator.compute(craft, materials).getAmount(DAMAGE); + } + + @Test + void templateParsesBonusesAroundTheNeutralTier() throws Exception { + var sword = sword(); + assertTrue(sword.hasTierBonuses()); + assertTrue(sword.hasTierBonus("LEATHER")); + assertTrue(sword.hasTierBonus("wool")); + assertFalse(sword.hasTierBonus("metal")); + assertFalse(sword.hasTierBonus(null)); + assertEquals(-.125, sword.getTierBonus("leather", 1).getAmount()); + assertEquals("attack_damage", sword.getTierBonus("wool", 1).getType()); + assertEquals(.25, sword.getTierBonus("Wool", 4).getAmount()); + assertNull(sword.getTierBonus("leather", 2), "the neutral tier adds nothing"); + assertNull(sword.getTierBonus("metal", 4)); + assertTrue( + sword + .buildRevisionContent() + .endsWith( + ";tierBonus=leather:attack_damage(0.125),wool:attack_damage(0.125)" + + ";tierBonusNeutral=2.0")); + + var plain = new StatTemplate("plain", yaml("stats: [attack_damage]")); + assertFalse(plain.hasTierBonuses()); + assertFalse(plain.buildRevisionContent().contains("tierBonus"), "other templates keep revision"); + + var noNeutral = + new StatTemplate("spear", yaml("stats: [reach]\ntier-bonus:\n leather: reach(0.05)")); + assertEquals(.05, noNeutral.getTierBonus("leather", 1).getAmount(), "neutral defaults to 0"); + + var broken = + new StatTemplate( + "broken", + yaml( + "stats: [armor]\n" + + "tier-bonus:\n" + + " leather: broken\n" + + " wool:\n" + + " nested: 1\n" + + " paper: armor(x)\n" + + " feather: speed(2)")); + assertFalse(broken.hasTierBonus("leather")); + assertFalse(broken.hasTierBonus("wool")); + assertFalse(broken.hasTierBonus("paper")); + assertTrue(broken.hasTierBonus("feather"), "kept, with a warning that speed is not in stats"); + } + + @Test + void swordDamageGrowsWithLeatherAndWoolTier() throws Exception { + animals(); + StatTemplateLoader.get().put("sword", sword()); + var craft = recipe("stat-template: sword"); + double[] expected = {3.25, 3.5, 3.75, 4.0}; + for (int tier = 1; tier <= 4; tier++) { + assertEquals( + expected[tier - 1], + damage( + craft, + Map.of( + "ingredient.iron", 4, "ingredient.leather" + tier, 2, "ingredient.wool" + tier, 2)), + "tier " + tier); + } + // Mixed leather averages to tier 1.5: -0.0625 + 0 = 3.4375, rounded half-up + assertEquals( + 3.44, + damage( + craft, + Map.of( + "ingredient.iron", 4, + "ingredient.leather1", 1, + "ingredient.leather2", 1, + "ingredient.wool2", 2))); + // Craft records (refresh path) give the same result + assertEquals( + 4.0, + CraftStatCalculator.compute( + craft, + List.of( + new CraftInput("ingredient", "iron", 4, 0), + new CraftInput("ingredient", "leather4", 2, 0), + new CraftInput("ingredient", "wool4", 2, 0))) + .getAmount(DAMAGE)); + } + + @Test + void materialsWithoutATypeOrTierAreIgnored() throws Exception { + animals(); + animal("paper", "leather", 0); + animal("ghost", "missing", 3); + StatTemplateLoader.get().put("sword", sword()); + var craft = recipe("stat-template: sword"); + var materials = new HashMap(); + materials.put("ingredient.iron", 4); + materials.put("ingredient.leather4", 0); + materials.put("nodot", 2); + materials.put("thing.leather4", 2); + materials.put("ingredient.unknown", 2); + materials.put("alloy.unknown", 2); + materials.put("ingredient.paper", 2); + materials.put("ingredient.ghost", 2); + assertEquals(3.5, damage(craft, materials)); + + var stats = new StatData(); + TierBonusCalculator.apply(stats, null, Map.of("ingredient.leather4", 2)); + TierBonusCalculator.apply( + stats, new StatTemplate("plain", yaml("stats: [x]")), Map.of("ingredient.leather4", 2)); + TierBonusCalculator.apply(stats, sword(), null); + assertFalse(stats.hasModifiers()); + assertNull(TierSource.of((IngredientData) null)); + assertNull(TierSource.of((AlloyData) null)); + assertTrue(BucketStatAverager.toMaterials(null).isEmpty()); + } + + @Test + void alloysCountWithTheirTypeAndTier() throws Exception { + animals(); + var hide = animal("hide", "leather", 3); + AlloyManager.addAlloy( + new Alloy("tanned", "Tanned", new AlloyData(hide, new StatData(), new HashMap<>(), null))); + StatTemplateLoader.get().put("sword", sword()); + var craft = recipe("stat-template: sword"); + assertEquals( + 3.75, damage(craft, Map.of("ingredient.iron", 4, "alloy.tanned", 2, "ingredient.wool3", 2))); + } + + @Test + void previewAddsTheHoveredMaterialsBonus() throws Exception { + var sword = sword(); + var source = new StatData(List.of("critical_strike_chance(5)")); + assertSame(source, TierBonusCalculator.withBonus(source, sword, null)); + assertSame(source, TierBonusCalculator.withBonus(source, sword, new TierSource("metal", 4))); + assertSame(source, TierBonusCalculator.withBonus(source, sword, new TierSource("leather", 2))); + var shown = TierBonusCalculator.withBonus(source, sword, new TierSource("leather", 4)); + assertEquals(.25, shown.getAmount(DAMAGE)); + assertFalse(source.hasModifier(DAMAGE), "the material's own stats are not changed"); + + when(plugin.getServer()).thenReturn(server); + new StatTemplateLoader() + .load( + java.nio.file.Files.writeString( + temp.resolve("tier-stats.yml"), + "sword:\n" + + " icon: v.paper\n" + + " stats: [attack_damage]\n" + + " tier-bonus:\n" + + " leather: attack_damage(0.125)\n" + + " tier-bonus-neutral: 2") + .toFile()); + var p = server.addPlayer(); + var menu = new InventoryManager(); + menu.templatePreviewView(p, source, new TierSource("leather", 4)); + assertEquals(Material.PAPER, p.getOpenInventory().getTopInventory().getItem(0).getType()); + var lore = p.getOpenInventory().getTopInventory().getItem(0).getItemMeta().getLore(); + assertTrue(lore.getFirst().contains("+0.25"), lore.toString()); + menu.templatePreviewView(p, source, new TierSource("leather", 2)); + assertTrue(p.nextMessage().contains("no stats")); + } +}