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..606c2e0 --- /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. + * 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; positive infinity when no result fits in its share. */ + 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 = cutoff(chanceByStat, share); + gemCutoffs.add(new Cutoff(rarity, from)); + 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 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(); + if (atOrAbove <= share + 1e-9) { + cutoff = entry.getKey(); + } + } + return cutoff; + } + + 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..ff85a28 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -39,6 +39,13 @@ 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. 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 # shown to the player. The quality, Masterwork included, comes from the recipe and hits alone. goldsmithing: @@ -49,6 +56,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..2b27a9f --- /dev/null +++ b/src/test/java/net/tfminecraft/geminfusion/GemNameCutoffsTest.java @@ -0,0 +1,174 @@ +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)); + // 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. 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()) { + 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); + } + double target = 0; + double named = 0; + for (GemRarity rarity : rarities.reversed()) { + double share = shares.getOrDefault(rarity, 0.0); + 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"); + } + } + } + + @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.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 + 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..7e5343b 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.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 (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))) { + 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);