From 2787edd8ae8c173d2c969c05c463f83600abbb2b Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:22:09 +0200 Subject: [PATCH 1/2] feat: the craft roll gives a hit clue instead of blocking Masterworks Since v2.4.0 a finished piece rolled d20 + base Strength, and perfect hits that missed a total of 20 dropped to the tier below. Now 100% hits is always a Masterwork again and the roll never changes the piece. The roll is made only when a piece is finished with less than 100% hits, after the quality line. A total of 12 or more (clue-dc, a natural 20 always passes and a natural 1 always fails, same Strength modifiers) gives a roleplay hint about the hit that was closest to right without being right: which way it was off and roughly how far, never a count. For example, 4 of 5 Hits gives "Looking the piece over, you sense it needed a little more Hit work." Ties go to the hit that is closer for its size, then to the hit id, so a piece always gets the same clue. A miss gives "you can't tell what went wrong with it." masterwork-dc is replaced by clue-dc (default 12); old configs keep working with the default. QualityLoader.below is gone with its only user. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- .../loaders/QualityLoader.java | 10 --- .../objects/crafting/CraftingStation.java | 14 ++- .../advancedcrafting/utils/D20Roll.java | 11 +-- .../advancedcrafting/utils/HitClue.java | 53 +++++++++++ src/main/resources/config.yml | 8 +- .../advancedcrafting/LoaderCoverageTest.java | 4 +- .../advancedcrafting/StationCoverageTest.java | 56 +++++++----- .../advancedcrafting/utils/D20RollTest.java | 6 +- .../advancedcrafting/utils/HitClueTest.java | 90 +++++++++++++++++++ 10 files changed, 205 insertions(+), 55 deletions(-) create mode 100644 src/main/java/net/tfminecraft/advancedcrafting/utils/HitClue.java create mode 100644 src/test/java/net/tfminecraft/advancedcrafting/utils/HitClueTest.java diff --git a/README.md b/README.md index c4d9944..054b477 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,11 @@ finished item. - **Profession progression** — connect recipes and materials to profession access and experience rewards. Crafting XP scales with how well the piece was worked. -- **d20 craft roll** — like `/roll`, finishing rolls a d20 plus a modifier from - the crafter's base Strength. A Masterwork needs perfect hits and a roll of 20 - or more; a natural 20 always succeeds and a natural 1 always fails. +- **d20 craft roll** — like `/roll`, finishing a piece with less than 100% hits + rolls a d20 plus a modifier from the crafter's base Strength. A roll of 12 or + more gives a hint about the hit that was closest to right (more or less of + it, never the count); a natural 20 always succeeds and a natural 1 always + fails. The roll never changes the piece: 100% hits is always a Masterwork. ## Credits diff --git a/src/main/java/net/tfminecraft/advancedcrafting/loaders/QualityLoader.java b/src/main/java/net/tfminecraft/advancedcrafting/loaders/QualityLoader.java index f8611c4..d4651a9 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/loaders/QualityLoader.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/loaders/QualityLoader.java @@ -3,7 +3,6 @@ import java.io.File; import java.io.IOException; import java.util.ArrayList; -import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Set; @@ -46,15 +45,6 @@ public static Quality getByString(String id) { if(map.containsKey(id)) return map.get(id); return null; } - /** The tier just below the given one, or null for the lowest or an unknown tier. */ - public static Quality below(Quality quality) { - if (quality == null) return null; - return map.values().stream() - .filter(candidate -> candidate.getValue() < quality.getValue()) - .max(Comparator.comparingInt(Quality::getValue)) - .orElse(null); - } - public static Quality getByAmount(double a) { Quality q = null; int prev = -1; diff --git a/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java b/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java index 085323d..9169af1 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java @@ -48,6 +48,7 @@ import net.tfminecraft.advancedcrafting.utils.CraftStatCalculator; import net.tfminecraft.advancedcrafting.utils.CraftTierLore; import net.tfminecraft.advancedcrafting.utils.D20Roll; +import net.tfminecraft.advancedcrafting.utils.HitClue; import net.tfminecraft.advancedcrafting.utils.MMOStatApplicator; import net.tfminecraft.advancedcrafting.utils.MajorityTierResolver; import net.tfminecraft.advancedcrafting.utils.ModelApplier; @@ -461,17 +462,14 @@ private StationFeedback createItem(Player p, Double forcedQualityPercent, boolea double percentage = forcedQualityPercent != null ? forcedQualityPercent : calculatePercentage(); Quality q = getQuality(percentage); p.sendMessage("Hit Percentage: §e"+percentage+"%"); - if (forcedQualityPercent == null) { - // Perfect hits alone are not a Masterwork: the craft roll must also meet the DC. Stats still come - // only from the materials, so every Masterwork has the best stats its materials give. + p.sendMessage("Quality: "+q.getName()); + if (forcedQualityPercent == null && percentage < 100) { + // The craft roll never changes the piece. Meeting the DC earns a hint about the hit closest to right. D20Roll roll = D20Roll.roll(p); p.sendMessage("Craft roll: " + roll.describe()); - if (percentage >= 100 && !roll.meets(D20Roll.masterworkDc)) { - Quality below = QualityLoader.below(q); - if (below != null) q = below; - } + String clue = roll.meets(D20Roll.clueDc) ? HitClue.clueFor(hits) : null; + p.sendMessage(clue != null ? clue : HitClue.NO_CLUE); } - p.sendMessage("Quality: "+q.getName()); List sockets = new ArrayList(); SocketGroup socketGroup = SocketGroupLoader.getByString(recipe.getSocketGroupId()); if(socketGroup == null) { diff --git a/src/main/java/net/tfminecraft/advancedcrafting/utils/D20Roll.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/D20Roll.java index b91b859..8fe3a5d 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/utils/D20Roll.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/D20Roll.java @@ -11,8 +11,9 @@ import net.Indyuce.mmocore.api.player.PlayerData; /** - * The forging craft roll: a d20 plus a modifier from the player's base attribute, like RPCharacters' /roll and - * GemInfusion's goldsmithing roll. A natural 20 always succeeds and a natural 1 always fails, whatever the modifier. + * The forging craft roll, made when an imperfect piece is finished: a d20 plus a modifier from the player's base + * attribute, like RPCharacters' /roll. Meeting the DC earns a clue about the hits; the roll never changes the piece. + * A natural 20 always succeeds and a natural 1 always fails, whatever the modifier. */ public final class D20Roll { @@ -22,7 +23,7 @@ public final class D20Roll { private static TreeMap modifiers = defaultModifiers(); private static int forcedNatural; public static String attribute = "strength"; - public static int masterworkDc = 20; + public static int clueDc = 12; private final int natural; private final int modifier; @@ -94,7 +95,7 @@ public static void load(ConfigurationSection section) { modifiers = defaultModifiers(); forcedNatural = 0; attribute = "strength"; - masterworkDc = 20; + clueDc = 12; if (section != null) { ConfigurationSection table = section.getConfigurationSection("attribute-modifiers"); if (table != null) { @@ -110,7 +111,7 @@ public static void load(ConfigurationSection section) { } forcedNatural = section.getInt("force-natural", 0); attribute = section.getString("attribute", attribute); - masterworkDc = section.getInt("masterwork-dc", masterworkDc); + clueDc = section.getInt("clue-dc", clueDc); } } diff --git a/src/main/java/net/tfminecraft/advancedcrafting/utils/HitClue.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/HitClue.java new file mode 100644 index 0000000..cecc482 --- /dev/null +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/HitClue.java @@ -0,0 +1,53 @@ +package net.tfminecraft.advancedcrafting.utils; + +import java.util.Comparator; +import java.util.Map; + +import net.tfminecraft.advancedcrafting.objects.crafting.hits.CraftingHit; +import net.tfminecraft.tlibs.objects.utils.IntCounter; + +/** + * The hint a crafter may get after finishing an imperfect piece. It names the hit that was closest to right + * without being right, and says only which way it was off and roughly how far, never the needed count. + */ +public final class HitClue { + + public static final String PREFIX = "§7Looking the piece over, "; + public static final String NO_CLUE = PREFIX + "you can't tell what went wrong with it."; + + private HitClue() { + } + + /** The clue line, or null when every hit was done exactly. */ + public static String clueFor(Map hits) { + Map.Entry closest = hits.entrySet().stream() + .filter(e -> e.getValue().getCurrent() != e.getValue().getNeeded()) + .min(Comparator.>comparingInt(e -> difference(e.getValue())) + .thenComparingDouble(e -> relativeError(e.getValue())) + .thenComparing(e -> e.getKey().getId())) + .orElse(null); + if (closest == null) return null; + IntCounter c = closest.getValue(); + String name = closest.getKey().getName() + "§7"; + if (c.getCurrent() == 0) return PREFIX + "you sense it needed some " + name + " work."; + if (c.getNeeded() == 0) return PREFIX + "you sense it took " + name + " work it didn't need."; + boolean little = difference(c) == 1 || relativeError(c) <= 0.25; + boolean far = relativeError(c) > 0.75; + if (c.getCurrent() < c.getNeeded()) { + String amount = little ? "a little more" : far ? "much more" : "more"; + return PREFIX + "you sense it needed " + amount + " " + name + " work."; + } + String amount = little ? "a little too much" : far ? "far too much" : "too much"; + return PREFIX + "you sense it took " + amount + " " + name + " work."; + } + + private static int difference(IntCounter c) { + return Math.abs(c.getCurrent() - c.getNeeded()); + } + + /** How far off a hit is for its size; any use of a hit the piece did not need is fully wrong. */ + private static double relativeError(IntCounter c) { + if (c.getNeeded() <= 0) return Double.POSITIVE_INFINITY; + return (double) difference(c) / c.getNeeded(); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 286124b..f812478 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -45,7 +45,8 @@ alloy-legacy-base: xp-min-percent: 0 # Forging craft roll, like /roll: a d20 plus a modifier from the player's base attribute (gear does not count). -# It is rolled on every finish and shown to the crafter. A natural 20 always succeeds and a natural 1 always fails. +# It is rolled when a piece is finished with less than 100% hits and shown to the crafter. +# A natural 20 always succeeds and a natural 1 always fails. d20: # Base attribute value -> modifier. Same as RPCharacters rolls.yml; values past the ends use the nearest end. attribute-modifiers: @@ -71,8 +72,9 @@ d20: 19: 4 20: 5 attribute: strength - # A Masterwork needs 100% hits and a total of at least this. Perfect hits that miss become the tier below. - masterwork-dc: 20 + # A total of at least this earns a hint about the hit that was closest to right ("it needed a little more + # Small Hit work"). The roll never changes the piece; 100% hits is always a Masterwork. + clue-dc: 12 # Dev testing only: 1-20 makes every roll that natural number. 0 rolls for real. force-natural: 0 diff --git a/src/test/java/net/tfminecraft/advancedcrafting/LoaderCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/LoaderCoverageTest.java index 107f9af..9411619 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/LoaderCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/LoaderCoverageTest.java @@ -218,12 +218,12 @@ void configurationClampsPercentagesAndParsesOffsetsCombinationsAndPermissions() config( "list.yml", "global-stat-offsets: ['', invalid, 'speed(0)', 'attack(100)', 'broken(x)']\n" - + "d20:\n attribute: dexterity\n masterwork-dc: 18\n" + + "d20:\n attribute: dexterity\n clue-dc: 18\n" + "xp-min-percent: 150\n")); assertEquals(100, Cache.craftXpMinPercent); assertEquals(Map.of("attack", 100.), Cache.globalStatOffsets); assertEquals("dexterity", D20Roll.attribute); - assertEquals(18, D20Roll.masterworkDc); + assertEquals(18, D20Roll.clueDc); invoke(loader, "parseGlobalStatOffset", new Class[] {String.class}, (Object) null); loader.load(temp.resolve("missing").toFile()); } diff --git a/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java index 8182340..15120d0 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java @@ -269,7 +269,7 @@ void failedCraftDoesNotAwardExperienceAndSuccessUsesMmoOutput() throws Exception } @Test - void masterworkNeedsPerfectHitsAndACraftRollAtTheDc() throws Exception { + void perfectHitsAreAlwaysAMasterworkAndTheCraftRollOnlyEarnsAClue() throws Exception { var p = server.addPlayer(); var iron = ingredient("iron", "hits: ['strike.1']"); var station = new CraftingStation(loc()); @@ -277,7 +277,6 @@ void masterworkNeedsPerfectHitsAndACraftRollAtTheDc() throws Exception { p.getInventory().setItemInMainHand(iron.build()); station.addMaterial(p, iron.build()); QualityLoader.map.put("crude", new Quality("crude", yaml("name: Rusted\namount: 0\nvalue: 1"))); - QualityLoader.map.put("epic", new Quality("epic", yaml("name: Gleaming\namount: 90\nvalue: 4"))); QualityLoader.map.put( "legendary", new Quality("legendary", yaml("name: Masterwork\namount: 100\nvalue: 5"))); try (var nbt = mockStatic(NBTItem.class); @@ -300,32 +299,47 @@ void masterworkNeedsPerfectHitsAndACraftRollAtTheDc() throws Exception { for (String line = p.nextMessage(); line != null; line = p.nextMessage()) lines.add(line); return lines; }; - // No hits yet: finishing is allowed and gives the lowest tier. + IntCounter strike = station.getHits().get(HitLoader.map.get("strike")); + // No hits yet: the lowest tier, and a roll under the DC gives no clue. assertEquals( - List.of("Hit Percentage: §e0.0%", "Craft roll: §e10 §7(+0) = §e10", "Quality: Rusted"), - finish.apply(10)); - station.getHits().get(HitLoader.map.get("strike")).setCurrent(1); + List.of( + "Hit Percentage: §e0.0%", + "Quality: Rusted", + "Craft roll: §e11 §7(+0) = §e11", + HitClue.NO_CLUE), + finish.apply(11)); + // A total at the DC names the hit that was off and which way, never the count. assertEquals( - List.of("Hit Percentage: §e100.0%", "Craft roll: §6Natural 20!", "Quality: Masterwork"), - finish.apply(20)); - // A total at the DC without a natural 20 also makes it. - D20Roll.masterworkDc = 12; - assertTrue(finish.apply(12).contains("Quality: Masterwork")); - D20Roll.masterworkDc = 20; - // Perfect hits that miss the DC become the tier below. + List.of( + "Hit Percentage: §e0.0%", + "Quality: Rusted", + "Craft roll: §e12 §7(+0) = §e12", + HitClue.PREFIX + "you sense it needed some Strike work."), + finish.apply(12)); + // Too many hits: a natural 20 always gets the clue, a natural 1 never does. + strike.setCurrent(2); assertEquals( - List.of("Hit Percentage: §e100.0%", "Craft roll: §cNatural 1!", "Quality: Gleaming"), - finish.apply(1)); - assertTrue(finish.apply(19).contains("Quality: Gleaming")); - // With no tier below, perfect hits keep their tier. - assertNull(QualityLoader.below(null)); - QualityLoader.map.remove("epic"); - QualityLoader.map.remove("crude"); - assertTrue(finish.apply(1).contains("Quality: Masterwork")); + HitClue.PREFIX + "you sense it took a little too much Strike work.", + finish.apply(20).get(3)); + assertEquals(HitClue.NO_CLUE, finish.apply(1).get(3)); + // The DC comes from the config. + D20Roll.clueDc = 5; + assertEquals(HitClue.PREFIX + "you sense it took a little too much Strike work.", finish.apply(5).get(3)); + D20Roll.clueDc = 12; + // A hit nothing is off for leaves nothing to hint at, even on a pass. + strike.setNeeded(0); + strike.setCurrent(0); + assertEquals(HitClue.NO_CLUE, finish.apply(20).get(3)); + // Perfect hits are always a Masterwork and do not roll, however the die would land. + strike.setNeeded(1); + strike.setCurrent(1); + assertEquals(List.of("Hit Percentage: §e100.0%", "Quality: Masterwork"), finish.apply(1)); // Staff-built pieces with a set quality never roll. + strike.setCurrent(0); station.craft(p, 100.); assertEquals("Hit Percentage: §e100.0%", p.nextMessage()); assertEquals("Quality: Masterwork", p.nextMessage()); + assertNull(p.nextMessage()); } } diff --git a/src/test/java/net/tfminecraft/advancedcrafting/utils/D20RollTest.java b/src/test/java/net/tfminecraft/advancedcrafting/utils/D20RollTest.java index 1633ca8..05da2e1 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/utils/D20RollTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/utils/D20RollTest.java @@ -101,17 +101,17 @@ void configOverridesDefaultsAndForcedNaturalPinsTheDie() throws Exception { D20Roll.load( config( "attribute-modifiers:\n 0: -2\n 10: 1\n x: 9\n" - + "force-natural: 7\nattribute: dexterity\nmasterwork-dc: 22\n")); + + "force-natural: 7\nattribute: dexterity\nclue-dc: 9\n")); assertEquals(-2, D20Roll.modifier(9)); assertEquals(1, D20Roll.modifier(20)); assertEquals("dexterity", D20Roll.attribute); - assertEquals(22, D20Roll.masterworkDc); + assertEquals(9, D20Roll.clueDc); assertEquals(7, D20Roll.die.getAsInt()); // Out-of-range forced values and a table with no numbers fall back to real rolls and the default table. D20Roll.load(config("attribute-modifiers:\n x: 9\nforce-natural: 25\n")); assertEquals(5, D20Roll.modifier(20)); assertEquals("strength", D20Roll.attribute); - assertEquals(20, D20Roll.masterworkDc); + assertEquals(12, D20Roll.clueDc); 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/advancedcrafting/utils/HitClueTest.java b/src/test/java/net/tfminecraft/advancedcrafting/utils/HitClueTest.java new file mode 100644 index 0000000..50b0715 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/utils/HitClueTest.java @@ -0,0 +1,90 @@ +package net.tfminecraft.advancedcrafting.utils; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.tfminecraft.advancedcrafting.objects.crafting.hits.CraftingHit; +import net.tfminecraft.tlibs.objects.utils.IntCounter; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +class HitClueTest { + + static CraftingHit hit(String id, String name) throws Exception { + YamlConfiguration c = new YamlConfiguration(); + c.loadFromString("name: '" + name + "'\ntool: v.iron_axe\n"); + return new CraftingHit(id, c); + } + + static IntCounter counter(int current, int needed) { + IntCounter c = new IntCounter(); + c.setNeeded(needed); + c.setCurrent(current); + return c; + } + + static String needed(String amount, String name) { + return HitClue.PREFIX + "you sense it needed " + amount + " " + name + "§7 work."; + } + + static String took(String amount, String name) { + return HitClue.PREFIX + "you sense it took " + amount + " " + name + "§7 work."; + } + + @Test + void exactHitsGiveNoClue() throws Exception { + assertNull(HitClue.clueFor(Map.of())); + assertNull(HitClue.clueFor(Map.of(hit("hit", "Hit"), counter(5, 5)))); + } + + @Test + void theClueIsAboutTheHitClosestToRight() throws Exception { + Map hits = new LinkedHashMap<>(); + hits.put(hit("hit", "Hit"), counter(4, 5)); + hits.put(hit("small_hit", "Small Hit"), counter(0, 3)); + hits.put(hit("etch", "Etch"), counter(2, 2)); + // 4 of 5 Hits is one away; 0 of 3 Small Hits is three away; the exact Etch is not a candidate. + assertEquals(needed("a little more", "Hit"), HitClue.clueFor(hits)); + } + + @Test + void equalMissesPreferTheSmallerShareThenTheId() throws Exception { + Map hits = new LinkedHashMap<>(); + hits.put(hit("small_hit", "Small Hit"), counter(1, 2)); + hits.put(hit("hit", "Hit"), counter(9, 10)); + // Both are one away, but one in ten is closer to right than one in two. + assertEquals(needed("a little more", "Hit"), HitClue.clueFor(hits)); + hits.clear(); + hits.put(hit("small_hit", "Small Hit"), counter(3, 4)); + hits.put(hit("hit", "Hit"), counter(5, 4)); + assertEquals(took("a little too much", "Hit"), HitClue.clueFor(hits)); + // Still level: the hit id decides, so the same piece always gets the same clue. + hits.clear(); + hits.put(hit("sew", "Sew"), counter(1, 0)); + hits.put(hit("etch", "Etch"), counter(1, 0)); + assertEquals(HitClue.PREFIX + "you sense it took Etch§7 work it didn't need.", HitClue.clueFor(hits)); + } + + @Test + void theWordingFollowsHowFarOffTheHitWas() throws Exception { + CraftingHit h = hit("hit", "Hit"); + assertEquals(needed("a little more", "Hit"), HitClue.clueFor(Map.of(h, counter(3, 4)))); + assertEquals(needed("a little more", "Hit"), HitClue.clueFor(Map.of(h, counter(1, 2)))); + assertEquals(needed("a little more", "Hit"), HitClue.clueFor(Map.of(h, counter(6, 8)))); + assertEquals(needed("more", "Hit"), HitClue.clueFor(Map.of(h, counter(2, 4)))); + assertEquals(needed("more", "Hit"), HitClue.clueFor(Map.of(h, counter(5, 8)))); + assertEquals(needed("much more", "Hit"), HitClue.clueFor(Map.of(h, counter(1, 5)))); + assertEquals(took("a little too much", "Hit"), HitClue.clueFor(Map.of(h, counter(5, 4)))); + assertEquals(took("a little too much", "Hit"), HitClue.clueFor(Map.of(h, counter(2, 1)))); + assertEquals(took("too much", "Hit"), HitClue.clueFor(Map.of(h, counter(7, 4)))); + assertEquals(took("far too much", "Hit"), HitClue.clueFor(Map.of(h, counter(8, 4)))); + // None done, or any use of a hit the piece did not need, say so outright. + assertEquals( + HitClue.PREFIX + "you sense it needed some Hit§7 work.", + HitClue.clueFor(Map.of(h, counter(0, 1)))); + assertEquals( + HitClue.PREFIX + "you sense it took Hit§7 work it didn't need.", + HitClue.clueFor(Map.of(h, counter(1, 0)))); + } +} From 37c0977d257bdd34ac28ed08e92f621ac5226ce3 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:35:49 +0200 Subject: [PATCH 2/2] fix: only exact hits make a Masterwork when the Hits % rounds to 100 The Hits % is rounded, so a big piece a hit off (199 of 200) read 100% and became a Masterwork without a roll. A piece that is not exact now reads 99% at most, keeps its tier below Masterwork and rolls for a clue. (CodeRabbit) Co-Authored-By: Claude Opus 5.5 --- .../objects/crafting/CraftingStation.java | 6 ++++-- .../advancedcrafting/StationCoverageTest.java | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java b/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java index 9169af1..523d70c 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java @@ -460,6 +460,9 @@ private StationFeedback createItem(Player p, Double forcedQualityPercent, boolea mmo.setStatHistory(ItemStats.NAME, hist); } double percentage = forcedQualityPercent != null ? forcedQualityPercent : calculatePercentage(); + String clue = forcedQualityPercent == null ? HitClue.clueFor(hits) : null; + // The Hits % is rounded, so a big piece a hit off can read 100%. Only exact hits make a Masterwork. + if (clue != null && percentage >= 100) percentage = 99; Quality q = getQuality(percentage); p.sendMessage("Hit Percentage: §e"+percentage+"%"); p.sendMessage("Quality: "+q.getName()); @@ -467,8 +470,7 @@ private StationFeedback createItem(Player p, Double forcedQualityPercent, boolea // The craft roll never changes the piece. Meeting the DC earns a hint about the hit closest to right. D20Roll roll = D20Roll.roll(p); p.sendMessage("Craft roll: " + roll.describe()); - String clue = roll.meets(D20Roll.clueDc) ? HitClue.clueFor(hits) : null; - p.sendMessage(clue != null ? clue : HitClue.NO_CLUE); + p.sendMessage(clue != null && roll.meets(D20Roll.clueDc) ? clue : HitClue.NO_CLUE); } List sockets = new ArrayList(); SocketGroup socketGroup = SocketGroupLoader.getByString(recipe.getSocketGroupId()); diff --git a/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java index 15120d0..65bbe35 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java @@ -330,6 +330,16 @@ void perfectHitsAreAlwaysAMasterworkAndTheCraftRollOnlyEarnsAClue() throws Excep strike.setNeeded(0); strike.setCurrent(0); assertEquals(HitClue.NO_CLUE, finish.apply(20).get(3)); + // 199 of 200 rounds to 100%, but only exact hits make a Masterwork: it reads 99% and rolls. + strike.setNeeded(200); + strike.setCurrent(199); + assertEquals( + List.of( + "Hit Percentage: §e99.0%", + "Quality: Rusted", + "Craft roll: §6Natural 20!", + HitClue.PREFIX + "you sense it needed a little more Strike work."), + finish.apply(20)); // Perfect hits are always a Masterwork and do not roll, however the die would land. strike.setNeeded(1); strike.setCurrent(1);