From c897ff19bc7063759fa757400da48b0cda50623a Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:11:15 +0200 Subject: [PATCH 1/2] fix: name infused gems after their rolled stat The drawn rarity still sets the stat's range and the d20 still picks the stat, but the name now comes from how that stat ranks among every result the gem could roll. Each rarity's name goes to the same share of results as its draw chance, so a Flawless epic can no longer beat a weaker "Legendary". The cutoffs are worked out at d20.infusion.name-reference-modifier (default 0) and logged per gem on every config load. The broadcast, the stored gem_rarity tag and the goldsmithing boost chance all follow the name. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../tfminecraft/geminfusion/ConfigLoader.java | 1 + .../net/tfminecraft/geminfusion/D20Roll.java | 4 + .../geminfusion/GemNameCutoffs.java | 137 ++++++++++++++++ .../geminfusion/InfusedGemBuilder.java | 15 +- .../geminfusion/InfusionEvents.java | 7 +- src/main/resources/config.yml | 8 + .../tfminecraft/geminfusion/D20RollTest.java | 3 + .../geminfusion/GemNameCutoffsTest.java | 153 ++++++++++++++++++ .../geminfusion/GemOutputTest.java | 37 +++++ .../geminfusion/InfusionEventsTest.java | 40 +++++ 11 files changed, 399 insertions(+), 8 deletions(-) create mode 100644 src/main/java/net/tfminecraft/geminfusion/GemNameCutoffs.java create mode 100644 src/test/java/net/tfminecraft/geminfusion/GemNameCutoffsTest.java diff --git a/README.md b/README.md index 31fb4f3..6ca1aa7 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ GemInfusion gives gemstones rolled bonuses and turns them into a resource for eq ## Features - **Gem infusion** — process batches of gemstones using an infusion token, with station feedback as the infusion runs. -- **Rarity and stat rolls** — gems range from common to legendary and provide bonuses such as health, armour, damage, or damage reduction according to their type. +- **Rarity and stat rolls** — gems range from common to legendary and provide bonuses such as health, armour, damage, or damage reduction according to their type. A gem is named after how its rolled stat ranks, so a better rarity name never means a weaker gem. - **d20 rolls** — like `/roll`, a d20 plus a modifier from the player's base attribute. Intelligence sets where an infused gem's stat lands, and Dexterity sets a hidden craft roll that adjusts the jewellery stat. A natural 20 always gives the best result and a natural 1 the worst. - **Masterworks** — any gem piece finished with a perfect recipe and hits is a Masterwork, whatever the gem or the craft roll. - **Socketed equipment** — connects gems with MMOItems sockets and preserves rarity information through socketing and removal. diff --git a/src/main/java/net/tfminecraft/geminfusion/ConfigLoader.java b/src/main/java/net/tfminecraft/geminfusion/ConfigLoader.java index 81f6ec4..d6f8c52 100644 --- a/src/main/java/net/tfminecraft/geminfusion/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/geminfusion/ConfigLoader.java @@ -43,6 +43,7 @@ public void loadConfig(FileConfiguration config) { loadedRarities.add(new GemRarity(key, config.getConfigurationSection("rarities."+key))); } D20Roll.load(config.getConfigurationSection("d20")); + GemNameCutoffs.load(loadedGems, loadedRarities); } // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") diff --git a/src/main/java/net/tfminecraft/geminfusion/D20Roll.java b/src/main/java/net/tfminecraft/geminfusion/D20Roll.java index 161a973..7572bf7 100644 --- a/src/main/java/net/tfminecraft/geminfusion/D20Roll.java +++ b/src/main/java/net/tfminecraft/geminfusion/D20Roll.java @@ -28,6 +28,8 @@ public final class D20Roll { public static String infusionAttribute = "intelligence"; public static int flawlessDc = 20; public static double spreadPercent = 20; + /** The modifier the infused-gem name shares are worked out at; see {@link GemNameCutoffs}. */ + public static int nameReferenceModifier = 0; public static String goldsmithAttribute = "dexterity"; public static double percentPerPoint = 2; public static double maxPercent = 20; @@ -123,6 +125,7 @@ public static void load(ConfigurationSection section) { infusionAttribute = "intelligence"; flawlessDc = 20; spreadPercent = 20; + nameReferenceModifier = 0; goldsmithAttribute = "dexterity"; percentPerPoint = 2; maxPercent = 20; @@ -150,6 +153,7 @@ public static void load(ConfigurationSection section) { spread = clamped; } spreadPercent = spread; + nameReferenceModifier = section.getInt("infusion.name-reference-modifier", nameReferenceModifier); goldsmithAttribute = section.getString("goldsmithing.attribute", goldsmithAttribute); percentPerPoint = section.getDouble("goldsmithing.percent-per-point", percentPerPoint); maxPercent = section.getDouble("goldsmithing.max-percent", maxPercent); diff --git a/src/main/java/net/tfminecraft/geminfusion/GemNameCutoffs.java b/src/main/java/net/tfminecraft/geminfusion/GemNameCutoffs.java new file mode 100644 index 0000000..f9cdbfa --- /dev/null +++ b/src/main/java/net/tfminecraft/geminfusion/GemNameCutoffs.java @@ -0,0 +1,137 @@ +package net.tfminecraft.geminfusion; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.logging.Logger; + +/** + * Infused gems are named after their rolled stat, not their drawn rarity. Each rarity's name goes to the same share + * of a gem's possible results as its draw chance, best results first, so a better name never means a weaker gem. + * The shares are worked out at {@link D20Roll#nameReferenceModifier}; players above it get better names more often. + */ +public final class GemNameCutoffs { + + /** The lowest stat that earns a rarity's name. */ + record Cutoff(GemRarity rarity, double from) { + } + + /** A gem's cutoffs, highest rarity first; stats below all of them get the lowest rarity's name. */ + record Names(List cutoffs, GemRarity lowest) { + } + + /** Gem id to its names. Gems missing here keep the drawn rarity's name. */ + private static Map names = new HashMap<>(); + + private GemNameCutoffs() { + } + + /** Rarities are listed lowest to highest; only the ones that can be drawn (finite positive chance) count. */ + public static void load(List gems, List rarities) { + Map loaded = new HashMap<>(); + List drawable = new ArrayList<>(); + double total = 0; + for (GemRarity rarity : rarities) { + double chance = rarity.getChance(); + if (Double.isFinite(chance) && chance > 0) { + drawable.add(rarity); + total += chance; + } + } + if (!drawable.isEmpty()) { + for (Gemstone gem : gems) { + Names gemNames = forGem(gem, drawable, total); + if (gemNames != null) { + loaded.put(gem.getId(), gemNames); + } + } + } + names = loaded; + } + + /** The rarity a gem with this stat is named after. A NaN stat (none was rolled) keeps the drawn rarity. */ + public static GemRarity name(Gemstone gem, GemRarity drawn, double stat) { + Names gemNames = names.get(gem.getId()); + if (gemNames == null || Double.isNaN(stat)) { + return drawn; + } + for (Cutoff cutoff : gemNames.cutoffs()) { + if (stat >= cutoff.from()) { + return cutoff.rarity(); + } + } + return gemNames.lowest(); + } + + private static Names forGem(Gemstone gem, List drawable, double total) { + // Every result the infusion can give: each drawable rarity with each face of the die, best stat first. + TreeMap chanceByStat = new TreeMap<>((a, b) -> Double.compare(b, a)); + String statId = null; + for (GemRarity rarity : drawable) { + GemStat block = block(gem, rarity); + if (block == null || (statId != null && !statId.equalsIgnoreCase(block.getStatId()))) { + warn("Gem '" + gem.getId() + "' needs one stat for every rarity to be named by its roll;" + + " it keeps the drawn rarity's name."); + return null; + } + statId = block.getStatId(); + for (int natural = 1; natural <= 20; natural++) { + double position = new D20Roll(natural, D20Roll.nameReferenceModifier).position(D20Roll.flawlessDc); + chanceByStat.merge(D20Roll.gemStat(block, position), rarity.getChance() / total / 20, Double::sum); + } + } + + List gemCutoffs = new ArrayList<>(); + StringBuilder summary = new StringBuilder("Gem '" + gem.getId() + "' names:"); + double share = 0; + for (int i = drawable.size() - 1; i > 0; i--) { + GemRarity rarity = drawable.get(i); + share += rarity.getChance() / total; + double from = nearestCutoff(chanceByStat, share); + gemCutoffs.add(new Cutoff(rarity, from)); + summary.append(' ').append(rarity.getId()).append(" from ").append(from).append(','); + } + GemRarity lowest = drawable.getFirst(); + info(summary.append(' ').append(lowest.getId()).append(" below.").toString()); + return new Names(gemCutoffs, lowest); + } + + /** The stat whose results at or above it come closest to the share. Ties keep the higher stat. */ + private static double nearestCutoff(TreeMap chanceByStat, double share) { + double best = chanceByStat.firstKey(); + double bestGap = Double.POSITIVE_INFINITY; + double atOrAbove = 0; + for (Map.Entry entry : chanceByStat.entrySet()) { + atOrAbove += entry.getValue(); + double gap = Math.abs(atOrAbove - share); + if (gap < bestGap - 1e-9) { + best = entry.getKey(); + bestGap = gap; + } + } + return best; + } + + private static GemStat block(Gemstone gem, GemRarity rarity) { + for (GemStat block : gem.getStats()) { + if (block.getId().equalsIgnoreCase(rarity.getId())) { + return block; + } + } + return null; + } + + private static Logger logger() { + return InfusionMain.plugin == null ? Logger.getLogger("GemInfusion") : InfusionMain.plugin.getLogger(); + } + + private static void info(String message) { + logger().info(message); + } + + private static void warn(String message) { + logger().warning(message); + } +} diff --git a/src/main/java/net/tfminecraft/geminfusion/InfusedGemBuilder.java b/src/main/java/net/tfminecraft/geminfusion/InfusedGemBuilder.java index 06fe8a9..ae18e5f 100644 --- a/src/main/java/net/tfminecraft/geminfusion/InfusedGemBuilder.java +++ b/src/main/java/net/tfminecraft/geminfusion/InfusedGemBuilder.java @@ -28,7 +28,9 @@ public final class InfusedGemBuilder { private InfusedGemBuilder() { } - public static void rollStats(MMOItem mmo, Gemstone gem, GemRarity rarity, int infusionAmount, Player player) { + /** Returns the rolled stat, or NaN when the gem has no usable stat for this rarity. */ + public static double rollStats(MMOItem mmo, Gemstone gem, GemRarity rarity, int infusionAmount, Player player) { + double statAmount = Double.NaN; GemStat block = null; for (GemStat statBlock : gem.getStats()) { if (statBlock.getId().equalsIgnoreCase(rarity.getId())) { @@ -44,7 +46,7 @@ public static void rollStats(MMOItem mmo, Gemstone gem, GemRarity rarity, int in } else { // d20 + Intelligence picks the spot in the rarity's spread; the DC or a natural 20 makes it Flawless. D20Roll roll = D20Roll.roll(player, D20Roll.infusionAttribute); - double statAmount = D20Roll.gemStat(block, roll.position(D20Roll.flawlessDc)); + statAmount = D20Roll.gemStat(block, roll.position(D20Roll.flawlessDc)); mmo.setData(stat, new DoubleData(statAmount)); } } @@ -54,6 +56,7 @@ public static void rollStats(MMOItem mmo, Gemstone gem, GemRarity rarity, int in maxChance = 0.0; } mmo.setData(ItemStats.SUCCESS_RATE, new DoubleData(Math.floor(Math.random() * maxChance) + 40)); + return statAmount; } /** A null rarity (unknown after a reset) leaves the rarity out of the name and lore. */ @@ -114,12 +117,14 @@ public static ItemStack restoreReset(ItemStack item) { return applyCosmeticsToItem(item, gem, rarity); } + /** The drawn rarity sets the stat's range; the gem is named after where the rolled stat ranks. */ public static ItemStack buildInfusedGem(Gemstone gem, GemRarity rarity, int infusionAmount, Player player) { ItemStack blankGem = gem.getMMOItem().newBuilder().build(); MMOItem infusedGem = new LiveMMOItem(NBTItem.get(blankGem)); - rollStats(infusedGem, gem, rarity, infusionAmount, player); - applyCosmetics(infusedGem, gem, rarity); - return finalizeItem(infusedGem.newBuilder().build(), rarity.getId()); + double stat = rollStats(infusedGem, gem, rarity, infusionAmount, player); + GemRarity named = GemNameCutoffs.name(gem, rarity, stat); + applyCosmetics(infusedGem, gem, named); + return finalizeItem(infusedGem.newBuilder().build(), named.getId()); } public static ItemStack finalizeItem(ItemStack item, String rarityId) { diff --git a/src/main/java/net/tfminecraft/geminfusion/InfusionEvents.java b/src/main/java/net/tfminecraft/geminfusion/InfusionEvents.java index 66dec44..f41facf 100644 --- a/src/main/java/net/tfminecraft/geminfusion/InfusionEvents.java +++ b/src/main/java/net/tfminecraft/geminfusion/InfusionEvents.java @@ -186,8 +186,11 @@ public void infuseHitEvent(PlayerInteractEvent e) { for (Gemstone gem : b.getCurrentItems()) { GemRarity rarity = getRarity(); ItemStack output = Objects.requireNonNull(getInfusedGem(p, gem, b.getCurrentItems().size(), rarity), "Cannot build infused gem"); - String announcement = rarity.shouldAnnounce() - ? "§e" + p.getName() + " just infused a " + rarity.getName() + " " + gem.getColour() + gem.getName() + "§e Gemstone" : ""; + // Announce the rarity the gem is named after, which can differ from the drawn one. + GemRarity named = ConfigLoader.findRarityById(GemRarityPdc.read(output)); + if (named == null) named = rarity; + String announcement = named.shouldAnnounce() + ? "§e" + p.getName() + " just infused a " + named.getName() + " " + gem.getColour() + gem.getName() + "§e Gemstone" : ""; prepared.add(new InfusionBlock.Drop(output, announcement)); } } catch (RuntimeException invalidBatch) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 69cc8bb..e620642 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -39,6 +39,12 @@ d20: flawless-dc: 20 # How far the spread reaches past each rarity's min and max, in percent. spread-percent: 20 + # A gem is named after how its stat ranks among every result it could have rolled, not after the drawn + # rarity, so a better name never means a weaker gem. Each name goes to the same share of results as its + # rarity's chance, worked out for this d20 modifier (0 is Intelligence 10-11 in the table above); players + # above it get better names more often. + # The cutoffs for each gem are logged when the config loads. + name-reference-modifier: 0 # Goldsmithing: one craft roll when a gem piece is finished. It only moves the jewelry stat and is not # shown to the player. The quality, Masterwork included, comes from the recipe and hits alone. goldsmithing: @@ -49,6 +55,8 @@ d20: # Dev testing only: 1-20 makes every roll that natural number. 0 rolls for real. force-natural: 0 +# List rarities from lowest to highest. chance is how often each is drawn, and also the share of gems +# named after it. rarities: common: name: "§aCommon" diff --git a/src/test/java/net/tfminecraft/geminfusion/D20RollTest.java b/src/test/java/net/tfminecraft/geminfusion/D20RollTest.java index 7e062d1..1de192b 100644 --- a/src/test/java/net/tfminecraft/geminfusion/D20RollTest.java +++ b/src/test/java/net/tfminecraft/geminfusion/D20RollTest.java @@ -127,6 +127,7 @@ void configOverridesDefaultsAndForcedNaturalPinsTheDie() throws Exception { "attribute-modifiers:\n 0: -2\n 10: 1\n x: 9\n" + "force-natural: 7\n" + "infusion:\n attribute: wisdom\n flawless-dc: 18\n spread-percent: 10\n" + + " name-reference-modifier: 2\n" + "goldsmithing:\n attribute: strength\n" + " percent-per-point: 3\n max-percent: 30\n")); assertEquals(-2, D20Roll.modifier(0)); @@ -135,6 +136,7 @@ void configOverridesDefaultsAndForcedNaturalPinsTheDie() throws Exception { assertEquals("wisdom", D20Roll.infusionAttribute); assertEquals(18, D20Roll.flawlessDc); assertEquals(10, D20Roll.spreadPercent); + assertEquals(2, D20Roll.nameReferenceModifier); assertEquals("strength", D20Roll.goldsmithAttribute); assertEquals(3, D20Roll.percentPerPoint); assertEquals(30, D20Roll.maxPercent); @@ -143,6 +145,7 @@ void configOverridesDefaultsAndForcedNaturalPinsTheDie() throws Exception { D20Roll.load(config("attribute-modifiers:\n x: 9\nforce-natural: 25\n")); assertEquals(5, D20Roll.modifier(20)); assertEquals("intelligence", D20Roll.infusionAttribute); + assertEquals(0, D20Roll.nameReferenceModifier); for (int i = 0; i < 200; i++) { int natural = D20Roll.die.getAsInt(); assertTrue(natural >= 1 && natural <= 20, String.valueOf(natural)); diff --git a/src/test/java/net/tfminecraft/geminfusion/GemNameCutoffsTest.java b/src/test/java/net/tfminecraft/geminfusion/GemNameCutoffsTest.java new file mode 100644 index 0000000..6a1dcd8 --- /dev/null +++ b/src/test/java/net/tfminecraft/geminfusion/GemNameCutoffsTest.java @@ -0,0 +1,153 @@ +package net.tfminecraft.geminfusion; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.util.*; +import java.util.logging.Logger; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.*; + +class GemNameCutoffsTest { + InfusionMain previous; + + @BeforeEach + void setup() { + previous = InfusionMain.plugin; + D20Roll.load(null); + } + + @AfterEach + void cleanup() { + InfusionMain.plugin = previous; + D20Roll.load(null); + ConfigLoader.loadedGems.clear(); + ConfigLoader.loadedRarities.clear(); + GemNameCutoffs.load(List.of(), List.of()); + } + + static YamlConfiguration config(String text) throws Exception { + YamlConfiguration c = new YamlConfiguration(); + c.loadFromString(text); + return c; + } + + static GemRarity rarity(String id, double chance) throws Exception { + GemRarity rarity = new GemRarity(id, config("name: " + id)); + rarity.setChance(chance); + return rarity; + } + + static Gemstone gem(String id, String... blocks) throws Exception { + Gemstone gem = new Gemstone(); + gem.setId(id); + for (int i = 0; i < blocks.length; i += 4) { + gem.addStat( + GemStat.parse( + id, + blocks[i], + config("stat: " + blocks[i + 1] + "\nmin: " + blocks[i + 2] + "\nmax: " + blocks[i + 3]))); + } + return gem; + } + + /** Every stat the infusion can give this gem at the given modifier, with its chance. */ + static Map results(Gemstone gem, List rarities, int modifier) { + double total = rarities.stream().mapToDouble(GemRarity::getChance).sum(); + Map results = new TreeMap<>(); + for (GemRarity rarity : rarities) { + GemStat block = + gem.getStats().stream().filter(b -> b.getId().equals(rarity.getId())).findFirst().orElseThrow(); + for (int natural = 1; natural <= 20; natural++) { + double stat = D20Roll.gemStat(block, new D20Roll(natural, modifier).position(D20Roll.flawlessDc)); + results.merge(stat, rarity.getChance() / total / 20, Double::sum); + } + } + return results; + } + + @Test + void bundledGemsAreNamedByRankAtTheirRarityShares() { + new ConfigLoader() + .loadConfig(YamlConfiguration.loadConfiguration(new File("src/main/resources/config.yml"))); + List rarities = ConfigLoader.loadedRarities; + Gemstone agate = ConfigLoader.loadedGems.getFirst(); + assertEquals("agate", agate.getId()); + GemRarity epic = ConfigLoader.findRarityById("epic"); + GemRarity legendary = ConfigLoader.findRarityById("legendary"); + // A Flawless epic agate (0.60) beats a fumbled legendary one (0.40), so it gets the better name. + assertSame(legendary, GemNameCutoffs.name(agate, epic, 0.6)); + assertSame(ConfigLoader.findRarityById("rare"), GemNameCutoffs.name(agate, legendary, 0.4)); + assertSame(legendary, GemNameCutoffs.name(agate, epic, 0.5516)); + assertSame(epic, GemNameCutoffs.name(agate, legendary, 0.5515)); + assertSame(ConfigLoader.findRarityById("rare"), GemNameCutoffs.name(agate, epic, 0.3347)); + assertSame(ConfigLoader.findRarityById("common"), GemNameCutoffs.name(agate, epic, 0.3346)); + + double total = rarities.stream().mapToDouble(GemRarity::getChance).sum(); + for (Gemstone gem : ConfigLoader.loadedGems) { + // Better stats never get a lower name, and each name's share stays near its rarity's chance + // (results that tie on one stat can't be split, so up to 2.25 points off for the bundled gems). + Map shares = new HashMap<>(); + int previousRank = -1; + for (Map.Entry result : results(gem, rarities, 0).entrySet()) { + GemRarity named = GemNameCutoffs.name(gem, null, result.getKey()); + int rank = rarities.indexOf(named); + assertTrue(rank >= previousRank, gem.getId() + " at " + result.getKey()); + previousRank = rank; + shares.merge(named, result.getValue(), Double::sum); + } + for (GemRarity rarity : rarities) { + double share = shares.getOrDefault(rarity, 0.0); + assertEquals(rarity.getChance() / total, share, 0.025, gem.getId() + " " + rarity.getId()); + } + } + } + + @Test + void higherReferenceModifierRaisesTheCutoffs() throws Exception { + List rarities = List.of(rarity("common", 90), rarity("legendary", 10)); + Gemstone ruby = gem("ruby", "common", "ATTACK_DAMAGE", "1", "2", "legendary", "ATTACK_DAMAGE", "2", "3"); + GemNameCutoffs.load(List.of(ruby), rarities); + // At +0 the best 10% of results start at 2.4; at +5 legendary draws roll higher, so the cutoff rises. + assertSame(rarities.get(1), GemNameCutoffs.name(ruby, null, 2.4)); + assertSame(rarities.get(0), GemNameCutoffs.name(ruby, null, 2.3999)); + D20Roll.load(config("infusion:\n name-reference-modifier: 5\n")); + assertEquals(5, D20Roll.nameReferenceModifier); + GemNameCutoffs.load(List.of(ruby), rarities); + assertSame(rarities.get(0), GemNameCutoffs.name(ruby, null, 2.4)); + assertSame(rarities.get(1), GemNameCutoffs.name(ruby, null, 2.4421)); + } + + @Test + void gemsThatCannotBeRankedKeepTheDrawnName() throws Exception { + Logger logger = mock(Logger.class); + InfusionMain.plugin = mock(InfusionMain.class); + when(InfusionMain.plugin.getLogger()).thenReturn(logger); + GemRarity common = rarity("common", 3); + GemRarity rare = rarity("rare", 1); + // Rarities that can never be drawn neither name gems nor count towards the shares. + List rarities = + List.of(common, rarity("never", 0), rare, rarity("broken", Double.POSITIVE_INFINITY)); + Gemstone ranked = gem("ranked", "common", "ARMOR", "1", "2", "rare", "ARMOR", "2", "3"); + Gemstone missing = gem("missing", "common", "ARMOR", "1", "2"); + Gemstone mixed = gem("mixed", "common", "ARMOR", "1", "2", "rare", "MAX_HEALTH", "2", "3"); + GemNameCutoffs.load(List.of(ranked, missing, mixed), rarities); + verify(logger).info(startsWith("Gem 'ranked' names: rare from ")); + verify(logger).warning(contains("'missing'")); + verify(logger).warning(contains("'mixed'")); + assertSame(rare, GemNameCutoffs.name(ranked, common, 3.6)); + assertSame(common, GemNameCutoffs.name(ranked, rare, 0.8)); + assertSame(rare, GemNameCutoffs.name(ranked, rare, Double.NaN)); + assertSame(rare, GemNameCutoffs.name(missing, rare, 0.8)); + assertSame(rare, GemNameCutoffs.name(mixed, rare, 0.8)); + assertSame(rare, GemNameCutoffs.name(new Gemstone(), rare, 0.8)); + + // With nothing drawable there is nothing to rank against; the default logger is used without a plugin. + InfusionMain.plugin = null; + GemNameCutoffs.load(List.of(ranked), List.of(rarity("never", 0))); + assertSame(rare, GemNameCutoffs.name(ranked, rare, 0.8)); + GemNameCutoffs.load(List.of(ranked), List.of(common, rare)); + assertSame(rare, GemNameCutoffs.name(ranked, common, 3.6)); + } +} diff --git a/src/test/java/net/tfminecraft/geminfusion/GemOutputTest.java b/src/test/java/net/tfminecraft/geminfusion/GemOutputTest.java index db8f5a6..cb802f8 100644 --- a/src/test/java/net/tfminecraft/geminfusion/GemOutputTest.java +++ b/src/test/java/net/tfminecraft/geminfusion/GemOutputTest.java @@ -195,6 +195,43 @@ void buildsAndRebuildsGemWithCosmeticsGlintAndPdc() { assertSame(air, InfusedGemBuilder.finalizeItem(air, "rare")); } + @Test + void builtGemIsNamedAfterItsRolledStatNotItsDrawnRarity() throws Exception { + GemRarity common = new GemRarity("common", config("name: Common\nchance: 90")); + GemRarity legendary = new GemRarity("legendary", config("name: Legendary\nchance: 10")); + Gemstone template = mock(Gemstone.class); + when(template.getId()).thenReturn("ruby"); + when(template.getStats()) + .thenReturn( + List.of( + GemStat.parse("ruby", "common", config("stat: ATTACK_DAMAGE\nmin: 1\nmax: 2")), + GemStat.parse("ruby", "legendary", config("stat: ATTACK_DAMAGE\nmin: 2\nmax: 3")))); + when(template.getMMOItem()).thenReturn(mock(MMOItem.class, RETURNS_DEEP_STUBS)); + when(template.getName()).thenReturn("Ruby"); + GemNameCutoffs.load(List.of(template), List.of(common, legendary)); + try { + // A Flawless common (2.4) is among the best 10% of results; a fumbled legendary (1.6) is not. + D20Roll.die = () -> 20; + ItemStack flawless = tagged(Material.DIAMOND); + try (var constructed = live(m -> when(m.newBuilder().build()).thenReturn(flawless))) { + InfusedGemBuilder.buildInfusedGem(template, common, 3, player); + verify(constructed.constructed().getFirst()) + .replaceData( + eq(ItemStats.NAME), + argThat(n -> "Legendary Infused Ruby".equals(((StringData) n).getString()))); + } + assertEquals("legendary", GemRarityPdc.read(flawless)); + D20Roll.die = () -> 1; + ItemStack fumbled = tagged(Material.DIAMOND); + try (var constructed = live(m -> when(m.newBuilder().build()).thenReturn(fumbled))) { + InfusedGemBuilder.buildInfusedGem(template, legendary, 3, player); + } + assertEquals("common", GemRarityPdc.read(fumbled)); + } finally { + GemNameCutoffs.load(List.of(), List.of()); + } + } + @Test void infusedValidatorRecognizesOnlyConfiguredInfusedGemsAndCandidates() { ItemStack item = tagged(Material.DIAMOND); diff --git a/src/test/java/net/tfminecraft/geminfusion/InfusionEventsTest.java b/src/test/java/net/tfminecraft/geminfusion/InfusionEventsTest.java index e2e8a70..c4b94e3 100644 --- a/src/test/java/net/tfminecraft/geminfusion/InfusionEventsTest.java +++ b/src/test/java/net/tfminecraft/geminfusion/InfusionEventsTest.java @@ -277,6 +277,46 @@ void fifthHitConsumesStaffAndScheduledTaskDropsBuiltGemThenStops() { } } + @Test + void announcementFollowsTheRarityTheGemIsNamedAfter() { + InfusionBlock s = station(location, 2, 4); + when(nbt.getType()).thenReturn("STAFF"); + when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("INFUSER"); + GemRarity drawn = mock(GemRarity.class); + when(drawn.getId()).thenReturn("common"); + when(drawn.getChance()).thenReturn(1.0); + GemRarity named = mock(GemRarity.class); + when(named.getId()).thenReturn("legendary"); + when(named.getName()).thenReturn("Legendary"); + when(named.shouldAnnounce()).thenReturn(true); + ConfigLoader.loadedRarities.add(drawn); + ConfigLoader.loadedRarities.add(named); + InfusionMain previous = InfusionMain.plugin; + InfusionMain.plugin = mock(InfusionMain.class); + ItemStack promoted = mock(ItemStack.class); + ItemStack plain = mock(ItemStack.class); + try (var bukkit = mockStatic(Bukkit.class); + var builder = mockStatic(InfusedGemBuilder.class); + var pdc = mockStatic(GemRarityPdc.class)) { + bukkit.when(Bukkit::getScheduler).thenReturn(mock(BukkitScheduler.class)); + builder + .when(() -> InfusedGemBuilder.buildInfusedGem(gem, drawn, 2, player)) + .thenReturn(promoted, plain); + pdc.when(() -> GemRarityPdc.read(promoted)).thenReturn("legendary"); + pdc.when(() -> GemRarityPdc.read(plain)).thenReturn("common"); + when(player.getName()).thenReturn("Fran"); + events.infuseHitEvent(event(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND)); + // The drawn common never announces; the gem named Legendary does. + assertEquals(2, s.pendingDrops.size()); + assertEquals( + "§eFran just infused a Legendary " + ChatColor.RED + "Ruby§e Gemstone", + s.pendingDrops.get(0).announcement()); + assertEquals("", s.pendingDrops.get(1).announcement()); + } finally { + InfusionMain.plugin = previous; + } + } + @Test void failedDepositCheckpointKeepsNewAndExistingGemsInHand() { when(store.save(any())).thenReturn(false); From d491c4392a0937fd40d2a4ac7abf554963408c1b Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:27:54 +0200 Subject: [PATCH 2/2] fix: give tied gem results the lower name Results that tie on one stat can't be split between two names. When a tie would push a rarer name past its chance, the whole tie now gets the lower name, so Legendary (and Legendary plus Epic, and so on) never go over their chances. A rarity whose best tie alone is too big is never named, and the load log says so. Co-Authored-By: Claude Opus 5.5 --- .../geminfusion/GemNameCutoffs.java | 24 +++++----- src/main/resources/config.yml | 3 +- .../geminfusion/GemNameCutoffsTest.java | 47 ++++++++++++++----- .../geminfusion/GemOutputTest.java | 4 +- 4 files changed, 50 insertions(+), 28 deletions(-) diff --git a/src/main/java/net/tfminecraft/geminfusion/GemNameCutoffs.java b/src/main/java/net/tfminecraft/geminfusion/GemNameCutoffs.java index f9cdbfa..606c2e0 100644 --- a/src/main/java/net/tfminecraft/geminfusion/GemNameCutoffs.java +++ b/src/main/java/net/tfminecraft/geminfusion/GemNameCutoffs.java @@ -10,11 +10,13 @@ /** * Infused gems are named after their rolled stat, not their drawn rarity. Each rarity's name goes to the same share * of a gem's possible results as its draw chance, best results first, so a better name never means a weaker gem. + * Results that tie on one stat keep one name; when the tie would push a rarer name past its share, the whole tie gets + * the lower name, so rarer names can fall slightly short of their chance but never go over it. * The shares are worked out at {@link D20Roll#nameReferenceModifier}; players above it get better names more often. */ public final class GemNameCutoffs { - /** The lowest stat that earns a rarity's name. */ + /** The lowest stat that earns a rarity's name; positive infinity when no result fits in its share. */ record Cutoff(GemRarity rarity, double from) { } @@ -89,29 +91,27 @@ private static Names forGem(Gemstone gem, List drawable, double total for (int i = drawable.size() - 1; i > 0; i--) { GemRarity rarity = drawable.get(i); share += rarity.getChance() / total; - double from = nearestCutoff(chanceByStat, share); + double from = cutoff(chanceByStat, share); gemCutoffs.add(new Cutoff(rarity, from)); - summary.append(' ').append(rarity.getId()).append(" from ").append(from).append(','); + summary.append(' ').append(rarity.getId()) + .append(from == Double.POSITIVE_INFINITY ? " never" : " from " + from).append(','); } GemRarity lowest = drawable.getFirst(); info(summary.append(' ').append(lowest.getId()).append(" below.").toString()); return new Names(gemCutoffs, lowest); } - /** The stat whose results at or above it come closest to the share. Ties keep the higher stat. */ - private static double nearestCutoff(TreeMap chanceByStat, double share) { - double best = chanceByStat.firstKey(); - double bestGap = Double.POSITIVE_INFINITY; + /** The lowest stat whose results at or above it still fit in the share; a tie that would go over is left out. */ + private static double cutoff(TreeMap chanceByStat, double share) { + double cutoff = Double.POSITIVE_INFINITY; double atOrAbove = 0; for (Map.Entry entry : chanceByStat.entrySet()) { atOrAbove += entry.getValue(); - double gap = Math.abs(atOrAbove - share); - if (gap < bestGap - 1e-9) { - best = entry.getKey(); - bestGap = gap; + if (atOrAbove <= share + 1e-9) { + cutoff = entry.getKey(); } } - return best; + return cutoff; } private static GemStat block(Gemstone gem, GemRarity rarity) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index e620642..ff85a28 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -42,7 +42,8 @@ d20: # A gem is named after how its stat ranks among every result it could have rolled, not after the drawn # rarity, so a better name never means a weaker gem. Each name goes to the same share of results as its # rarity's chance, worked out for this d20 modifier (0 is Intelligence 10-11 in the table above); players - # above it get better names more often. + # above it get better names more often. Results that tie on one stat share a name, and a tie that would + # push a rarer name past its chance gets the lower name, so rarer names can fall slightly short. # The cutoffs for each gem are logged when the config loads. name-reference-modifier: 0 # Goldsmithing: one craft roll when a gem piece is finished. It only moves the jewelry stat and is not diff --git a/src/test/java/net/tfminecraft/geminfusion/GemNameCutoffsTest.java b/src/test/java/net/tfminecraft/geminfusion/GemNameCutoffsTest.java index 6a1dcd8..2b27a9f 100644 --- a/src/test/java/net/tfminecraft/geminfusion/GemNameCutoffsTest.java +++ b/src/test/java/net/tfminecraft/geminfusion/GemNameCutoffsTest.java @@ -79,15 +79,16 @@ void bundledGemsAreNamedByRankAtTheirRarityShares() { // A Flawless epic agate (0.60) beats a fumbled legendary one (0.40), so it gets the better name. assertSame(legendary, GemNameCutoffs.name(agate, epic, 0.6)); assertSame(ConfigLoader.findRarityById("rare"), GemNameCutoffs.name(agate, legendary, 0.4)); - assertSame(legendary, GemNameCutoffs.name(agate, epic, 0.5516)); - assertSame(epic, GemNameCutoffs.name(agate, legendary, 0.5515)); + // 0.5516 would take Legendary past its 3%, so that tie stays Epic. + assertSame(legendary, GemNameCutoffs.name(agate, epic, 0.5558)); + assertSame(epic, GemNameCutoffs.name(agate, legendary, 0.5516)); assertSame(ConfigLoader.findRarityById("rare"), GemNameCutoffs.name(agate, epic, 0.3347)); assertSame(ConfigLoader.findRarityById("common"), GemNameCutoffs.name(agate, epic, 0.3346)); double total = rarities.stream().mapToDouble(GemRarity::getChance).sum(); for (Gemstone gem : ConfigLoader.loadedGems) { - // Better stats never get a lower name, and each name's share stays near its rarity's chance - // (results that tie on one stat can't be split, so up to 2.25 points off for the bundled gems). + // Better stats never get a lower name. Ties go to the lower name, so each rarity and the ones above it + // never take more than their chances, and each name stays within 3.1 points of its own chance. Map shares = new HashMap<>(); int previousRank = -1; for (Map.Entry result : results(gem, rarities, 0).entrySet()) { @@ -97,9 +98,14 @@ void bundledGemsAreNamedByRankAtTheirRarityShares() { previousRank = rank; shares.merge(named, result.getValue(), Double::sum); } - for (GemRarity rarity : rarities) { + double target = 0; + double named = 0; + for (GemRarity rarity : rarities.reversed()) { double share = shares.getOrDefault(rarity, 0.0); - assertEquals(rarity.getChance() / total, share, 0.025, gem.getId() + " " + rarity.getId()); + assertEquals(rarity.getChance() / total, share, 0.031, gem.getId() + " " + rarity.getId()); + target += rarity.getChance() / total; + named += share; + assertTrue(named <= target + 1e-9, gem.getId() + " " + rarity.getId() + " and above"); } } } @@ -109,14 +115,29 @@ void higherReferenceModifierRaisesTheCutoffs() throws Exception { List rarities = List.of(rarity("common", 90), rarity("legendary", 10)); Gemstone ruby = gem("ruby", "common", "ATTACK_DAMAGE", "1", "2", "legendary", "ATTACK_DAMAGE", "2", "3"); GemNameCutoffs.load(List.of(ruby), rarities); - // At +0 the best 10% of results start at 2.4; at +5 legendary draws roll higher, so the cutoff rises. - assertSame(rarities.get(1), GemNameCutoffs.name(ruby, null, 2.4)); - assertSame(rarities.get(0), GemNameCutoffs.name(ruby, null, 2.3999)); - D20Roll.load(config("infusion:\n name-reference-modifier: 5\n")); - assertEquals(5, D20Roll.nameReferenceModifier); - GemNameCutoffs.load(List.of(ruby), rarities); - assertSame(rarities.get(0), GemNameCutoffs.name(ruby, null, 2.4)); + // At +0 the best 10% of results start at 2.4421 (2.4 ties a Flawless common and would go over). + // At +10 legendary draws roll higher, so the cutoff rises. assertSame(rarities.get(1), GemNameCutoffs.name(ruby, null, 2.4421)); + assertSame(rarities.get(0), GemNameCutoffs.name(ruby, null, 2.4)); + D20Roll.load(config("infusion:\n name-reference-modifier: 10\n")); + assertEquals(10, D20Roll.nameReferenceModifier); + GemNameCutoffs.load(List.of(ruby), rarities); + assertSame(rarities.get(0), GemNameCutoffs.name(ruby, null, 2.4421)); + assertSame(rarities.get(1), GemNameCutoffs.name(ruby, null, 2.7579)); + } + + @Test + void rarityWhoseBestTieIsTooBigIsNeverNamed() throws Exception { + Logger logger = mock(Logger.class); + InfusionMain.plugin = mock(InfusionMain.class); + when(InfusionMain.plugin.getLogger()).thenReturn(logger); + // With +19 every face but a natural 1 is Flawless, so 95% of results tie on the top stat. + D20Roll.load(config("infusion:\n name-reference-modifier: 19\n")); + List rarities = List.of(rarity("common", 90), rarity("legendary", 10)); + Gemstone flat = gem("flat", "common", "ARMOR", "1", "2", "legendary", "ARMOR", "1", "2"); + GemNameCutoffs.load(List.of(flat), rarities); + verify(logger).info("Gem 'flat' names: legendary never, common below."); + assertSame(rarities.get(0), GemNameCutoffs.name(flat, rarities.get(1), 2.4)); } @Test diff --git a/src/test/java/net/tfminecraft/geminfusion/GemOutputTest.java b/src/test/java/net/tfminecraft/geminfusion/GemOutputTest.java index cb802f8..7e5343b 100644 --- a/src/test/java/net/tfminecraft/geminfusion/GemOutputTest.java +++ b/src/test/java/net/tfminecraft/geminfusion/GemOutputTest.java @@ -204,13 +204,13 @@ void builtGemIsNamedAfterItsRolledStatNotItsDrawnRarity() throws Exception { when(template.getStats()) .thenReturn( List.of( - GemStat.parse("ruby", "common", config("stat: ATTACK_DAMAGE\nmin: 1\nmax: 2")), + GemStat.parse("ruby", "common", config("stat: ATTACK_DAMAGE\nmin: 1\nmax: 2.5")), GemStat.parse("ruby", "legendary", config("stat: ATTACK_DAMAGE\nmin: 2\nmax: 3")))); when(template.getMMOItem()).thenReturn(mock(MMOItem.class, RETURNS_DEEP_STUBS)); when(template.getName()).thenReturn("Ruby"); GemNameCutoffs.load(List.of(template), List.of(common, legendary)); try { - // A Flawless common (2.4) is among the best 10% of results; a fumbled legendary (1.6) is not. + // A Flawless common (3.0) is among the best 10% of results; a fumbled legendary (1.6) is not. D20Roll.die = () -> 20; ItemStack flawless = tagged(Material.DIAMOND); try (var constructed = live(m -> when(m.newBuilder().build()).thenReturn(flawless))) {