From d92b1b6e1529b72884431b5813cf9fb738ad247c Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:12:43 +0200 Subject: [PATCH 1/5] feat: show the hits done in the branding tool status A branding right-click mid-project (sneaking or not) showed the recipe and material groups but nothing about the hits, so there was no way to tell how much work a piece had already had. The status now lists every crafting hit with the hits done so far, plus the total, before the finish and cancel lines. All hits are listed, even ones the recipe does not use, and no needed counts are shown, so nothing about the recipe is given away. Goldsmithing gets the same view in GemInfusion #39. Co-Authored-By: Claude Opus 5.5 --- .../objects/crafting/CraftingStation.java | 13 +++++++++++-- .../advancedcrafting/StationCoverageTest.java | 10 ++++++++-- 2 files changed, 19 insertions(+), 4 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 3764e7a..7fc8620 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java @@ -246,8 +246,8 @@ public StationFeedback addMaterial(Player p, ItemStack i) { } /** - * Lines the branding tool shows on right-click: the recipe and the material groups still to fill. - * Players have to find the hits themselves, so per-hit counts stay out of it. + * Lines the branding tool shows on right-click: the recipe, the material groups still to fill and the hits done. + * Players have to find the hits themselves, so every hit is listed with only what was done, never what is needed. */ public List getStatusLines() { List lines = new ArrayList<>(); @@ -256,6 +256,15 @@ public List getStatusLines() { if (e.getKey() == null) continue; lines.add(e.getKey().getName() + "§7: §e" + e.getValue().getCurrent() + "/" + e.getValue().getNeeded()); } + lines.add("§7Hits done:"); + int total = 0; + for (CraftingHit h : HitLoader.get().values()) { + IntCounter c = hits.get(h); + int done = c == null ? 0 : c.getCurrent(); + total += done; + lines.add(h.getName() + "§7: §e" + done); + } + lines.add("§7Total: §e" + total); lines.add("§7Left-click branding to finish"); lines.add("§cSHIFT + LEFT CLICK with the branding tool to cancel the project!"); return lines; diff --git a/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java index e6ecdd3..8aeee2b 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java @@ -95,7 +95,7 @@ void materialAdmissionChecksItemTypePermissionsRecipeAndCapacity() throws Except } @Test - void statusLinesShowMaterialsButNoHitCounts() throws Exception { + void statusLinesShowMaterialsAndHitsDoneButNoNeededHits() throws Exception { var p = server.addPlayer(); var station = new CraftingStation(loc()); station.setRecipe(recipe("recipe: ['metal.2']")); @@ -105,11 +105,17 @@ void statusLinesShowMaterialsButNoHitCounts() throws Exception { station.getHits().get(HitLoader.map.get("strike")).setCurrent(3); // Unknown types from a stale config are skipped, not printed as "null". station.getTypes().put(null, new IntCounter()); - // Players have to find the hits themselves, so no hit counts are listed. + // A tool the recipe does not use is listed too, so the list does not reveal which hits are needed. + HitLoader.map.put("sew", new CraftingHit("sew", yaml("name: Sew\ntype: artisan\ntool: v.shears"))); + // Every hit shows only the hits done; needed counts stay hidden. assertEquals( List.of( "§7Recipe: Sword", "Metal§7: §e1/2", + "§7Hits done:", + "Strike§7: §e3", + "Sew§7: §e0", + "§7Total: §e3", "§7Left-click branding to finish", "§cSHIFT + LEFT CLICK with the branding tool to cancel the project!"), station.getStatusLines()); From 161b7a4a5f6b93be3039d234c76bf1290ca6eccc Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:42:34 +0200 Subject: [PATCH 2/5] feat: finish forging with any hits, and a d20 Strength roll for Masterworks Forging refused extra hits once a hit type had enough, and refused to finish until every type was exactly complete. Its refusal told the player the needed counts ("You only have 5 out of 12 Metal hits"). Like goldsmithing (GemInfusion #39), a piece can now be finished with any number of hits once its materials are in. Too few or too many hits only lower the Hits %, and with it the quality. The finish-time "over-reliance" message is gone too, so the final Hits % is the only feedback. The hit title (Metal hits: x/needed) and the refusal of a hit type the piece does not need at all are unchanged. Finishing also rolls d20 + a modifier from the crafter's base Strength, like /roll, shown only to them as "Craft roll". A natural 20 always succeeds and a natural 1 always fails. A Masterwork now needs 100% hits and a total of 20+ (or a natural 20); perfect hits that miss become the tier below (Gleaming: Radiant socket / Greater rune slot). The roll never touches stats, which still come only from the materials, so every Masterwork has the best stats its materials give. Staff-built pieces with a set quality do not roll. Alloys are unchanged. Settings live under d20 in config.yml (modifier table, attribute, masterwork-dc, force-natural for Dev tests), with defaults in code. The hit-overshoot-warn keys are removed. "Hit Percenage" is now spelled "Hit Percentage". AC now builds against the same pinned MMOCore jar as GemInfusion to read the base attribute. Co-Authored-By: Claude Opus 5.5 --- .github/dependencies.sha256 | 1 + .github/scripts/install-local-dependencies.sh | 3 + .github/scripts/prepare-release.sh | 1 + pom.xml | 6 + .../advancedcrafting/cache/Cache.java | 2 - .../enums/StationFeedback.java | 1 - .../loaders/ConfigLoader.java | 8 +- .../loaders/QualityLoader.java | 10 ++ .../managers/CraftingManager.java | 7 +- .../objects/crafting/CraftingStation.java | 55 ++------ .../advancedcrafting/utils/D20Roll.java | 130 ++++++++++++++++++ src/main/resources/config.yml | 35 ++++- .../advancedcrafting/CoverageSupport.java | 6 + .../CraftingManagerCoverageTest.java | 4 +- .../advancedcrafting/LoaderCoverageTest.java | 7 +- .../advancedcrafting/StationCoverageTest.java | 91 +++++++++--- .../advancedcrafting/utils/D20RollTest.java | 123 +++++++++++++++++ 17 files changed, 402 insertions(+), 88 deletions(-) create mode 100644 src/main/java/net/tfminecraft/advancedcrafting/utils/D20Roll.java create mode 100644 src/test/java/net/tfminecraft/advancedcrafting/utils/D20RollTest.java diff --git a/.github/dependencies.sha256 b/.github/dependencies.sha256 index f42aed5..e23afe4 100644 --- a/.github/dependencies.sha256 +++ b/.github/dependencies.sha256 @@ -4,3 +4,4 @@ a37f7789fcdcd11c9e5890a2fac742aa4501749588af23182991953e06ee1d17 libs/MMOItems- 225aa7f75d4e6e6ae6b3ad87a7ca678c025c027f00374ab1062275d24f3a73c9 libs/MythicLib-1.7.1-SNAPSHOT.jar 5a01b37bd7442fb0f2c809cadac82ba70f75173956e04efac3336595cc070368 libs/ItemsAdder-4.0.18.jar c571502e768640c7cff10c49b3eef904ccf3fd95bd81803913abfe43cfc09746 libs/NBTAPI-2.16.1.jar +81d511d0830951f988615b6533cc93e82d00a83fb31e8ccbb36441ae3e8157ef libs/MMOCore-1.13.1-SNAPSHOT.jar diff --git a/.github/scripts/install-local-dependencies.sh b/.github/scripts/install-local-dependencies.sh index cfbca14..c18c502 100644 --- a/.github/scripts/install-local-dependencies.sh +++ b/.github/scripts/install-local-dependencies.sh @@ -22,3 +22,6 @@ mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1. mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \ -Dfile="libs/NBTAPI-2.16.1.jar" -DgroupId="local" -DartifactId="item-nbt-api-plugin" \ -Dversion="2.16.1-tfmc-c571502e7686" -Dpackaging=jar -DgeneratePom=true "$@" +mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \ + -Dfile="libs/MMOCore-1.13.1-SNAPSHOT.jar" -DgroupId="local" -DartifactId="MMOCore" \ + -Dversion="1.13.1-SNAPSHOT-tfmc-81d511d08309" -Dpackaging=jar -DgeneratePom=true "$@" diff --git a/.github/scripts/prepare-release.sh b/.github/scripts/prepare-release.sh index a3a7416..e43b135 100755 --- a/.github/scripts/prepare-release.sh +++ b/.github/scripts/prepare-release.sh @@ -9,4 +9,5 @@ curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/225aa7f75d4e/MythicLib-1.7.1-SNAPSHOT.jar?ref=$ref" > "libs/MythicLib-1.7.1-SNAPSHOT.jar" curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/5a01b37bd744/ItemsAdder-4.0.18.jar?ref=$ref" > "libs/ItemsAdder-4.0.18.jar" curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/c571502e7686/NBTAPI-2.16.1.jar?ref=$ref" > "libs/NBTAPI-2.16.1.jar" +curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/81d511d08309/MMOCore-1.13.1-SNAPSHOT.jar?ref=$ref" > "libs/MMOCore-1.13.1-SNAPSHOT.jar" bash .github/scripts/install-local-dependencies.sh "$@" diff --git a/pom.xml b/pom.xml index b4a469b..36d1c83 100644 --- a/pom.xml +++ b/pom.xml @@ -72,6 +72,12 @@ 1.1.1-tfmc-4e69696892b8 provided + + local + MMOCore + 1.13.1-SNAPSHOT-tfmc-81d511d08309 + provided + local MMOItems diff --git a/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java b/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java index 098abf2..f97f708 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java @@ -34,8 +34,6 @@ public class Cache { /** Epoch millis; 0 turns the legacy base stats off. */ public static long alloyLegacyForgedBefore; - public static double hitOvershootWarnPercent = 30.0; - public static String hitOvershootWarnMessage = "§cYour over-reliance on %hit% ruins the result further"; public static boolean debugStatRefresh; diff --git a/src/main/java/net/tfminecraft/advancedcrafting/enums/StationFeedback.java b/src/main/java/net/tfminecraft/advancedcrafting/enums/StationFeedback.java index 32b8637..e5e728f 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/enums/StationFeedback.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/enums/StationFeedback.java @@ -10,7 +10,6 @@ public enum StationFeedback { WRONG_TYPE, NONE, LACKING_ITEMS, - LACKING_HITS, INCOMPATIBLE_TYPE, NO_PERMS, } diff --git a/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java index fdfe206..0e73093 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java @@ -15,6 +15,7 @@ import net.tfminecraft.advancedcrafting.AdvancedCrafting; import net.tfminecraft.advancedcrafting.cache.Cache; import net.tfminecraft.advancedcrafting.objects.data.StatData; +import net.tfminecraft.advancedcrafting.utils.D20Roll; import net.tfminecraft.advancedcrafting.objects.data.PermissionNamespace; import net.tfminecraft.advancedcrafting.objects.ingredients.IngredientType; import net.tfminecraft.advancedcrafting.utils.StatToString; @@ -71,12 +72,7 @@ public void load(File configFile) { } } - Cache.hitOvershootWarnPercent = config.getDouble("hit-overshoot-warn-percent", 30.0); - String overshootMessage = config.getString("hit-overshoot-warn-message"); - if (overshootMessage == null || overshootMessage.isBlank()) { - overshootMessage = "§cYour over-reliance on %hit% ruins the result further"; - } - Cache.hitOvershootWarnMessage = overshootMessage; + D20Roll.load(config.getConfigurationSection("d20")); Cache.debugStatRefresh = config.getBoolean("debug-stat-refresh", false); diff --git a/src/main/java/net/tfminecraft/advancedcrafting/loaders/QualityLoader.java b/src/main/java/net/tfminecraft/advancedcrafting/loaders/QualityLoader.java index d4651a9..f8611c4 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/loaders/QualityLoader.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/loaders/QualityLoader.java @@ -3,6 +3,7 @@ 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; @@ -45,6 +46,15 @@ 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/managers/CraftingManager.java b/src/main/java/net/tfminecraft/advancedcrafting/managers/CraftingManager.java index 5186d4e..57b2de4 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/managers/CraftingManager.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/managers/CraftingManager.java @@ -346,8 +346,7 @@ public void applyHit(PlayerInteractEvent e) { currentStation.remove(p); } else { p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); - p.sendMessage("§c" + (f.equals(StationFeedback.LACKING_HITS) ? "You need to complete all the hits before finishing" : - f.equals(StationFeedback.LACKING_ITEMS) ? "You have to add all the items before smithing" : "")); + p.sendMessage("§c" + (f.equals(StationFeedback.LACKING_ITEMS) ? "You have to add all the items before smithing" : "")); } return; } @@ -367,10 +366,6 @@ public void applyHit(PlayerInteractEvent e) { p.sendMessage("§cThis tool is not needed for this craft"); p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); break; - case CAPACITY: - p.sendMessage("§cYou dont need more hits with this tool"); - p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 0.5f); - break; default: // Successful hit p.getWorld().playSound(station.getLoc(), Sound.BLOCK_ANVIL_USE, 1f, 1f); 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 7fc8620..1455067 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java @@ -47,6 +47,7 @@ import net.tfminecraft.advancedcrafting.objects.stats.StatModifier; import net.tfminecraft.advancedcrafting.utils.CraftStatCalculator; import net.tfminecraft.advancedcrafting.utils.CraftTierLore; +import net.tfminecraft.advancedcrafting.utils.D20Roll; import net.tfminecraft.advancedcrafting.utils.MMOStatApplicator; import net.tfminecraft.advancedcrafting.utils.MajorityTierResolver; import net.tfminecraft.advancedcrafting.utils.ModelApplier; @@ -354,22 +355,12 @@ private boolean checkItems(Player p) { return complete; } - private boolean checkHits(Player p) { - boolean complete = true; - for(HitType t : hitTypes.keySet()) { - IntCounter c = hitTypes.get(t); - if(!c.isEqual()) { - complete = false; - p.sendMessage("§cYou only have "+c.getCurrent()+" out of "+c.getNeeded() + " " +t.getName()+ "§c hits."); - } - } - return complete; - } - private Quality getQuality(double d) { return QualityLoader.getByAmount(d); } private double calculatePercentage() { + // A piece whose materials need no hits has nothing to get wrong. + if (hits.isEmpty()) return 100; int counter = 0; double amount = 0.0; for(CraftingHit h : hits.keySet()) { @@ -382,29 +373,6 @@ private double calculatePercentage() { return Math.round((amount/counter)); } - private void warnOvershootHits(Player p) { - if (Cache.hitOvershootWarnPercent <= 0) { - return; - } - String template = Cache.hitOvershootWarnMessage; - if (template == null || template.isBlank()) { - return; - } - for (CraftingHit hit : hits.keySet()) { - IntCounter counter = hits.get(hit); - int needed = counter.getNeeded(); - int current = counter.getCurrent(); - if (needed <= 0 || current <= needed) { - continue; - } - double overshoot = ((double) (current - needed) / needed) * 100.0; - if (overshoot < Cache.hitOvershootWarnPercent) { - continue; - } - p.sendMessage(template.replace("%hit%", hit.getName())); - } - } - // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") private StationFeedback createItem(Player p, Double forcedQualityPercent) { @@ -426,9 +394,6 @@ private StationFeedback createItem(Player p, Double forcedQualityPercent, boolea if (!checkItems(p)) { return StationFeedback.LACKING_ITEMS; } - if (forcedQualityPercent == null && !checkHits(p)) { - return StationFeedback.LACKING_HITS; - } ItemAPI api = TLibs.getItemAPI(); result = api.getCreator().getItemFromPath("m."+recipe.getTemplate()); MMOItem mmo = new LiveMMOItem(NBTItem.get(result)); @@ -492,11 +457,18 @@ private StationFeedback createItem(Player p, Double forcedQualityPercent, boolea } double percentage = forcedQualityPercent != null ? forcedQualityPercent : calculatePercentage(); Quality q = getQuality(percentage); - p.sendMessage("Quality: "+q.getName()); - p.sendMessage("Hit Percenage: §e"+percentage+"%"); + p.sendMessage("Hit Percentage: §e"+percentage+"%"); if (forcedQualityPercent == null) { - warnOvershootHits(p); + // 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. + 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; + } } + p.sendMessage("Quality: "+q.getName()); List sockets = new ArrayList(); SocketGroup socketGroup = SocketGroupLoader.getByString(recipe.getSocketGroupId()); if(socketGroup == null) { @@ -549,7 +521,6 @@ public StationFeedback hit(Player p, ItemStack i) { CraftingHit hit = HitLoader.getByTool(nbt.getType()+"."+nbt.getString("MMOITEMS_ITEM_ID")); if(hit == null) return StationFeedback.WRONG_TYPE; if(!hitTypes.containsKey(hit.getType())) return StationFeedback.NONE; - if(hitTypes.get(hit.getType()).isEqual()) return StationFeedback.CAPACITY; addHit(p, hit); return StationFeedback.SUCCESS; diff --git a/src/main/java/net/tfminecraft/advancedcrafting/utils/D20Roll.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/D20Roll.java new file mode 100644 index 0000000..b91b859 --- /dev/null +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/D20Roll.java @@ -0,0 +1,130 @@ +package net.tfminecraft.advancedcrafting.utils; + +import java.util.TreeMap; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.IntSupplier; + +import org.bukkit.Bukkit; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; + +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. + */ +public final class D20Roll { + + /** Rolls the die. Swapped by tests; {@code force-natural} in the config pins it for Dev checks. */ + public static IntSupplier die = D20Roll::rollDie; + + private static TreeMap modifiers = defaultModifiers(); + private static int forcedNatural; + public static String attribute = "strength"; + public static int masterworkDc = 20; + + private final int natural; + private final int modifier; + + public D20Roll(int natural, int modifier) { + this.natural = natural; + this.modifier = modifier; + } + + public static D20Roll roll(Player player) { + return new D20Roll(die.getAsInt(), modifierFor(player, attribute)); + } + + public int getNatural() { + return natural; + } + + public int getModifier() { + return modifier; + } + + public boolean isCritical() { + return natural >= 20; + } + + public boolean isFumble() { + return natural <= 1; + } + + /** A natural 1 ignores the modifier. */ + public int getTotal() { + return isFumble() ? natural : natural + modifier; + } + + public boolean meets(int dc) { + return isCritical() || (!isFumble() && getTotal() >= dc); + } + + /** How the roll reads in chat, e.g. "15 (+2) = 17". */ + public String describe() { + if (isCritical()) return "§6Natural 20!"; + if (isFumble()) return "§cNatural 1!"; + return "§e" + natural + " §7(" + (modifier >= 0 ? "+" : "") + modifier + ") = §e" + getTotal(); + } + + public static int modifierFor(Player player, String attributeId) { + Integer value = readBase(player, attributeId); + return value == null ? 0 : modifier(value); + } + + /** Values outside the table use its nearest end, so 25 reads like 20 and -3 like 0. */ + public static int modifier(int attributeValue) { + int key = Math.max(modifiers.firstKey(), Math.min(modifiers.lastKey(), attributeValue)); + return modifiers.floorEntry(key).getValue(); + } + + /** The base attribute, without gear, as /roll reads it. Null when MMOCore cannot tell. */ + static Integer readBase(Player player, String attributeId) { + if (player == null || attributeId == null || attributeId.isBlank()) return null; + if (Bukkit.getPluginManager().getPlugin("MMOCore") == null) return null; + try { + return PlayerData.get(player).getAttributes().getInstance(attributeId).getBase(); + } catch (Exception ex) { + return null; + } + } + + public static void load(ConfigurationSection section) { + modifiers = defaultModifiers(); + forcedNatural = 0; + attribute = "strength"; + masterworkDc = 20; + if (section != null) { + ConfigurationSection table = section.getConfigurationSection("attribute-modifiers"); + if (table != null) { + TreeMap loaded = new TreeMap<>(); + for (String key : table.getKeys(false)) { + try { + loaded.put(Integer.parseInt(key.trim()), table.getInt(key)); + } catch (NumberFormatException ignored) { + // Not an attribute value; skip it. + } + } + if (!loaded.isEmpty()) modifiers = loaded; + } + forcedNatural = section.getInt("force-natural", 0); + attribute = section.getString("attribute", attribute); + masterworkDc = section.getInt("masterwork-dc", masterworkDc); + } + } + + private static int rollDie() { + if (forcedNatural >= 1 && forcedNatural <= 20) return forcedNatural; + return ThreadLocalRandom.current().nextInt(1, 21); + } + + /** The /roll table: (attribute - 10) / 2, rounded down, for 0 to 20. */ + private static TreeMap defaultModifiers() { + TreeMap table = new TreeMap<>(); + for (int value = 0; value <= 20; value++) { + table.put(value, Math.floorDiv(value - 10, 2)); + } + return table; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 1fe01fb..320775c 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -40,10 +40,37 @@ alloy-legacy-base: # steel_ingot: # - weapon_damage(12.0) -# Warn when a tool is this many percent over its requirement (16 vs 12 = 33). -# Set to 0 or below to disable. Existing live configs keep working via loader defaults. -hit-overshoot-warn-percent: 30.0 -hit-overshoot-warn-message: "§cYour over-reliance on %hit% ruins the result further" +# 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. +d20: + # Base attribute value -> modifier. Same as RPCharacters rolls.yml; values past the ends use the nearest end. + attribute-modifiers: + 0: -5 + 1: -5 + 2: -4 + 3: -4 + 4: -3 + 5: -3 + 6: -2 + 7: -2 + 8: -1 + 9: -1 + 10: 0 + 11: 0 + 12: 1 + 13: 1 + 14: 2 + 15: 2 + 16: 3 + 17: 3 + 18: 4 + 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 + # Dev testing only: 1-20 makes every roll that natural number. 0 rolls for real. + force-natural: 0 # Raw stat lines on ingredient/alloy items. Converter GUI still shows per-template factors. show-ingredient-stats: true diff --git a/src/test/java/net/tfminecraft/advancedcrafting/CoverageSupport.java b/src/test/java/net/tfminecraft/advancedcrafting/CoverageSupport.java index 348f684..259d67f 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/CoverageSupport.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/CoverageSupport.java @@ -31,8 +31,12 @@ abstract class CoverageSupport { AdvancedCrafting plugin; Map cacheBefore = new HashMap<>(); + java.util.function.IntSupplier dieBefore; + @BeforeEach void setup() throws Exception { + dieBefore = net.tfminecraft.advancedcrafting.utils.D20Roll.die; + net.tfminecraft.advancedcrafting.utils.D20Roll.load(null); for (var field : Cache.class.getFields()) { Object value = field.get(null); cacheBefore.put(field, value instanceof Map ? new HashMap<>((Map) value) : value); @@ -99,6 +103,8 @@ void cleanup() throws Exception { tlibs.close(); AdvancedCrafting.plugin = null; for (var entry : cacheBefore.entrySet()) entry.getKey().set(null, entry.getValue()); + net.tfminecraft.advancedcrafting.utils.D20Roll.die = dieBefore; + net.tfminecraft.advancedcrafting.utils.D20Roll.load(null); } static YamlConfiguration yaml(String text) throws Exception { diff --git a/src/test/java/net/tfminecraft/advancedcrafting/CraftingManagerCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/CraftingManagerCoverageTest.java index fd18915..e4d908d 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/CraftingManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/CraftingManagerCoverageTest.java @@ -175,7 +175,6 @@ void brandingCancelsFinishesOrReportsIncompleteCraftAndToolsRouteHits() throws E when(s.hasRecipe()).thenReturn(true); for (StationFeedback fb : List.of( - StationFeedback.LACKING_HITS, StationFeedback.LACKING_ITEMS, StationFeedback.NONE, StationFeedback.SUCCESS)) { @@ -199,7 +198,6 @@ void brandingCancelsFinishesOrReportsIncompleteCraftAndToolsRouteHits() throws E StationFeedback.LACKING_ITEMS, StationFeedback.WRONG_TYPE, StationFeedback.NONE, - StationFeedback.CAPACITY, StationFeedback.SUCCESS)) { when(s.hit(eq(p), any())).thenReturn(fb); m.applyHit(event(p, b, Action.LEFT_CLICK_BLOCK)); @@ -207,7 +205,7 @@ void brandingCancelsFinishesOrReportsIncompleteCraftAndToolsRouteHits() throws E when(nbt.hasType()).thenReturn(true); when(nbt.getType()).thenReturn("v"); when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("iron_axe"); - when(s.hit(eq(p), any())).thenReturn(StationFeedback.CAPACITY); + when(s.hit(eq(p), any())).thenReturn(StationFeedback.NONE); var e = event(p, b, Action.LEFT_CLICK_BLOCK); m.applyHit(e); assertTrue(e.isCancelled()); diff --git a/src/test/java/net/tfminecraft/advancedcrafting/LoaderCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/LoaderCoverageTest.java index 25a1b0c..ca2cb86 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/LoaderCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/LoaderCoverageTest.java @@ -217,10 +217,11 @@ void configurationClampsPercentagesAndParsesOffsetsCombinationsAndPermissions() loader.load( config( "list.yml", - "hit-overshoot-warn-message: ' '\n" - + "global-stat-offsets: ['', invalid, 'speed(0)', 'attack(100)', 'broken(x)']")); + "global-stat-offsets: ['', invalid, 'speed(0)', 'attack(100)', 'broken(x)']\n" + + "d20:\n attribute: dexterity\n masterwork-dc: 18\n")); assertEquals(Map.of("attack", 100.), Cache.globalStatOffsets); - assertTrue(Cache.hitOvershootWarnMessage.contains("%hit%")); + assertEquals("dexterity", D20Roll.attribute); + assertEquals(18, D20Roll.masterworkDc); 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 8aeee2b..7b0188f 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java @@ -172,7 +172,7 @@ void restoredStationsAggregateMaterialAndHitCounts() throws Exception { } @Test - void hitSelectionUsesRegisteredMmoToolsAndSharedTypeCapacity() throws Exception { + void hitSelectionUsesRegisteredMmoToolsAndAcceptsAnyNumberOfHits() throws Exception { var p = server.addPlayer(); var iron = ingredient("iron", "hits: ['strike.2']"); var station = new CraftingStation(loc()); @@ -200,15 +200,20 @@ void hitSelectionUsesRegisteredMmoToolsAndSharedTypeCapacity() throws Exception assertEquals(StationFeedback.SUCCESS, station.hit(p, tool)); when(data.getString("MMOITEMS_ITEM_ID")).thenReturn("iron_axe"); assertEquals(StationFeedback.SUCCESS, station.hit(p, tool)); - assertEquals(StationFeedback.CAPACITY, station.hit(p, tool)); + // A full hit type still takes more hits; overworking only lowers the hit percent. + assertEquals(StationFeedback.SUCCESS, station.hit(p, tool)); + assertEquals(StationFeedback.SUCCESS, station.hit(p, tool)); + assertEquals(3, station.getHits().get(HitLoader.map.get("strike")).getCurrent()); assertEquals(1, station.getHits().get(extra).getCurrent()); } } @Test - void qualityReflectsUnderAndOverWorkAndWarningsUseConfiguredThreshold() throws Exception { - var p = server.addPlayer(); + void qualityReflectsUnderAndOverWork() throws Exception { var station = new CraftingStation(loc()); + // No hits needed at all: nothing to get wrong. + assertEquals( + 100, ((Number) invoke(station, "calculatePercentage", new Class[] {})).doubleValue()); var counter = new IntCounter(); counter.setNeeded(4); station.getHits().put(HitLoader.map.get("strike"), counter); @@ -219,22 +224,6 @@ void qualityReflectsUnderAndOverWorkAndWarningsUseConfiguredThreshold() throws E expected, ((Number) invoke(station, "calculatePercentage", new Class[] {})).doubleValue()); } - Cache.hitOvershootWarnPercent = 0; - invoke(station, "warnOvershootHits", new Class[] {Player.class}, p); - Cache.hitOvershootWarnPercent = 30; - Cache.hitOvershootWarnMessage = null; - invoke(station, "warnOvershootHits", new Class[] {Player.class}, p); - Cache.hitOvershootWarnMessage = " "; - invoke(station, "warnOvershootHits", new Class[] {Player.class}, p); - Cache.hitOvershootWarnMessage = "Too many %hit%"; - counter.setNeeded(0); - invoke(station, "warnOvershootHits", new Class[] {Player.class}, p); - counter.setNeeded(4); - for (int n : List.of(2, 5, 6)) { - counter.setCurrent(n); - invoke(station, "warnOvershootHits", new Class[] {Player.class}, p); - } - assertTrue(p.nextMessage().contains("Strike")); } @Test @@ -246,7 +235,6 @@ void failedCraftDoesNotAwardExperienceAndSuccessUsesMmoOutput() throws Exception assertEquals(StationFeedback.LACKING_ITEMS, station.craft(p)); p.getInventory().setItemInMainHand(iron.build()); station.addMaterial(p, iron.build()); - assertEquals(StationFeedback.LACKING_HITS, station.craft(p)); QualityLoader.map.put("fine", new Quality("fine", yaml("name: Fine\namount: 0\nvalue: 0"))); try (var nbt = mockStatic(NBTItem.class); var models = mockStatic(LegacyModelData.class); @@ -272,6 +260,67 @@ void failedCraftDoesNotAwardExperienceAndSuccessUsesMmoOutput() throws Exception } } + @Test + void masterworkNeedsPerfectHitsAndACraftRollAtTheDc() throws Exception { + var p = server.addPlayer(); + var iron = ingredient("iron", "hits: ['strike.1']"); + var station = new CraftingStation(loc()); + station.setRecipe(recipe("recipe: ['metal.1']")); + 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); + var models = mockStatic(LegacyModelData.class); + var mmos = + mockConstruction( + LiveMMOItem.class, + withSettings().defaultAnswer(RETURNS_DEEP_STUBS), + (mmo, ctx) -> { + when(mmo.getData(ItemStats.NAME)).thenReturn(new StringData("Old")); + when(mmo.computeStatHistory(ItemStats.NAME)).thenReturn(null); + when(mmo.newBuilder().build()).thenReturn(new ItemStack(Material.IRON_SWORD)); + })) { + // Finish and return the chat lines the crafter got. + java.util.function.IntFunction> finish = + natural -> { + D20Roll.die = () -> natural; + assertEquals(StationFeedback.SUCCESS, station.craft(p)); + List lines = new ArrayList<>(); + 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. + 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); + 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. + 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")); + // Staff-built pieces with a set quality never roll. + station.craft(p, 100.); + assertEquals("Hit Percentage: §e100.0%", p.nextMessage()); + assertEquals("Quality: Masterwork", p.nextMessage()); + } + } + @Test void dropAndCancelRefundBothIngredientAndAlloyQuantities() throws Exception { var p = server.addPlayer(); diff --git a/src/test/java/net/tfminecraft/advancedcrafting/utils/D20RollTest.java b/src/test/java/net/tfminecraft/advancedcrafting/utils/D20RollTest.java new file mode 100644 index 0000000..1633ca8 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/utils/D20RollTest.java @@ -0,0 +1,123 @@ +package net.tfminecraft.advancedcrafting.utils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.function.IntSupplier; +import net.Indyuce.mmocore.api.player.PlayerData; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; + +class D20RollTest { + IntSupplier previousDie; + + @BeforeEach + void setup() { + previousDie = D20Roll.die; + D20Roll.load(null); + } + + @AfterEach + void cleanup() { + D20Roll.die = previousDie; + D20Roll.load(null); + if (MockBukkit.isMocked()) MockBukkit.unmock(); + } + + static YamlConfiguration config(String text) throws Exception { + YamlConfiguration c = new YamlConfiguration(); + c.loadFromString(text); + return c; + } + + @Test + void defaultModifiersMatchTheRollTableAndClampPastItsEnds() { + assertEquals(-5, D20Roll.modifier(-3)); + assertEquals(-5, D20Roll.modifier(1)); + assertEquals(-4, D20Roll.modifier(2)); + assertEquals(0, D20Roll.modifier(11)); + assertEquals(2, D20Roll.modifier(14)); + assertEquals(5, D20Roll.modifier(20)); + assertEquals(5, D20Roll.modifier(25)); + } + + @Test + void naturalTwentyAlwaysSucceedsAndNaturalOneAlwaysFails() { + D20Roll crit = new D20Roll(20, -5); + assertTrue(crit.isCritical()); + assertFalse(crit.isFumble()); + assertEquals(15, crit.getTotal()); + assertTrue(crit.meets(20)); + assertEquals("§6Natural 20!", crit.describe()); + D20Roll fumble = new D20Roll(1, 5); + assertTrue(fumble.isFumble()); + assertFalse(fumble.isCritical()); + assertEquals(1, fumble.getTotal()); + assertFalse(fumble.meets(1)); + assertEquals("§cNatural 1!", fumble.describe()); + assertEquals(1, fumble.getNatural()); + assertEquals(5, fumble.getModifier()); + } + + @Test + void otherRollsAddTheModifier() { + D20Roll good = new D20Roll(15, 3); + assertEquals(18, good.getTotal()); + assertTrue(good.meets(18)); + assertFalse(good.meets(19)); + assertEquals("§e15 §7(+3) = §e18", good.describe()); + assertEquals("§e12 §7(-4) = §e8", new D20Roll(12, -4).describe()); + } + + @Test + void rollsReadTheBaseAttributeThroughMmoCore() { + var server = MockBukkit.mock(); + Player player = server.addPlayer(); + D20Roll.die = () -> 12; + assertNull(D20Roll.readBase(null, "strength")); + assertNull(D20Roll.readBase(player, null)); + assertNull(D20Roll.readBase(player, " ")); + assertNull(D20Roll.readBase(player, "strength")); + D20Roll none = D20Roll.roll(player); + assertEquals(12, none.getNatural()); + assertEquals(0, none.getModifier()); + MockBukkit.createMockPlugin("MMOCore"); + try (var data = mockStatic(PlayerData.class)) { + data.when(() -> PlayerData.get(player)).thenThrow(new IllegalStateException("unavailable")); + assertNull(D20Roll.readBase(player, "strength")); + assertEquals(0, D20Roll.modifierFor(player, "strength")); + PlayerData record = mock(PlayerData.class, RETURNS_DEEP_STUBS); + when(record.getAttributes().getInstance("strength").getBase()).thenReturn(15); + data.when(() -> PlayerData.get(player)).thenReturn(record); + assertEquals(15, D20Roll.readBase(player, "strength")); + assertEquals(2, D20Roll.roll(player).getModifier()); + } + } + + @Test + 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")); + assertEquals(-2, D20Roll.modifier(9)); + assertEquals(1, D20Roll.modifier(20)); + assertEquals("dexterity", D20Roll.attribute); + assertEquals(22, D20Roll.masterworkDc); + 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); + for (int i = 0; i < 200; i++) { + int natural = D20Roll.die.getAsInt(); + assertTrue(natural >= 1 && natural <= 20, String.valueOf(natural)); + } + D20Roll.load(config("{}")); + int natural = D20Roll.die.getAsInt(); + assertTrue(natural >= 1 && natural <= 20); + } +} From 1285d6b8b6f66be031a97fedf236d2f3bcc5f7c8 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:59:09 +0200 Subject: [PATCH 3/5] feat: crafting XP follows the Hits % With any-hit finishing, a piece finished with no hits still paid the full crafting XP. XP is now the set XP times the finished piece's Hits % (a staff craft's set quality), so a perfect piece pays all of it and an unworked one pays nothing. xp-min-percent in config.yml (default 0) sets a floor. Co-Authored-By: Claude Opus 5.5 --- .../advancedcrafting/cache/Cache.java | 2 ++ .../advancedcrafting/loaders/ConfigLoader.java | 1 + .../objects/crafting/CraftingStation.java | 9 ++++++--- src/main/resources/config.yml | 4 ++++ .../advancedcrafting/LoaderCoverageTest.java | 4 +++- .../advancedcrafting/StationCoverageTest.java | 18 ++++++++++++++++-- 6 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java b/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java index f97f708..1f46cf8 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java @@ -43,6 +43,8 @@ public class Cache { public static Map> legacyModels = new HashMap<>(); public static boolean showIngredientStats = true; + /** Crafting XP never drops below this percent of the set XP, however poor the Hits %. */ + public static double craftXpMinPercent = 0; /** Divisors applied to bucket-averaged stats before template factors (e.g. movement_speed: 100). */ public static Map globalStatOffsets = new HashMap<>(); diff --git a/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java index 0e73093..681a2cc 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java @@ -73,6 +73,7 @@ public void load(File configFile) { } D20Roll.load(config.getConfigurationSection("d20")); + Cache.craftXpMinPercent = Math.max(0, Math.min(100, config.getDouble("xp-min-percent", 0))); Cache.debugStatRefresh = config.getBoolean("debug-stat-refresh", false); 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 1455067..79610da 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java @@ -284,7 +284,9 @@ public StationFeedback craft(Player p, Double forcedQualityPercent) { StationFeedback f = createItem(p, forcedQualityPercent); // Failed attempts keep the station's materials, so paying XP before the checks let every retry pay again. if (f == StationFeedback.SUCCESS) { - giveXP(p); + // XP follows the work: the set XP times the Hits % (a staff craft's set quality), at least xp-min-percent. + double percent = forcedQualityPercent != null ? forcedQualityPercent : calculatePercentage(); + giveXP(p, Math.max(Cache.craftXpMinPercent, Math.min(100, percent))); } return f; } @@ -292,7 +294,7 @@ public StationFeedback craft(Player p, Double forcedQualityPercent) { // skill(amount), e.g. crafter(2.0) private static final Pattern XP_FORMAT = Pattern.compile("([A-Za-z0-9_-]+)\\((\\d+(?:\\.\\d+)?)\\)"); - private void giveXP(Player p) { + private void giveXP(Player p, double percent) { // Map of skill name -> total XP to give Map xpBySkill = new HashMap<>(); @@ -336,7 +338,8 @@ private void giveXP(Player p) { ConsoleCommandSender console = Bukkit.getServer().getConsoleSender(); for (Map.Entry entry : xpBySkill.entrySet()) { String skill = entry.getKey(); - double xp = Math.round(entry.getValue() * 100) / 100.0; // round to 2 decimals + double xp = Math.round(entry.getValue() * percent) / 100.0; // scaled, rounded to 2 decimals + if (xp <= 0) continue; String command = "mmocore admin exp give " + p.getName() + " " + skill + " " + xp; Bukkit.dispatchCommand(console, command); } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 320775c..617fa6b 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -40,6 +40,10 @@ alloy-legacy-base: # steel_ingot: # - weapon_damage(12.0) +# Crafting XP is the set XP times the finished piece's Hits % (a perfect piece pays all of it). +# A piece never pays less than this percent of it; 0 means a piece finished with no hits pays nothing. +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. d20: diff --git a/src/test/java/net/tfminecraft/advancedcrafting/LoaderCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/LoaderCoverageTest.java index ca2cb86..107f9af 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/LoaderCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/LoaderCoverageTest.java @@ -218,7 +218,9 @@ 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 masterwork-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); diff --git a/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java index 7b0188f..eed0378 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java @@ -249,13 +249,22 @@ void failedCraftDoesNotAwardExperienceAndSuccessUsesMmoOutput() throws Exception }); var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { bukkit.when(() -> Bukkit.dispatchCommand(any(), anyString())).thenReturn(true); + // XP is the set 2 times the Hits %: a staff craft at 50% pays half. assertEquals(StationFeedback.SUCCESS, station.craft(p, 50.)); - bukkit.verify(() -> Bukkit.dispatchCommand(any(), contains("smith 2.0"))); + bukkit.verify(() -> Bukkit.dispatchCommand(any(), contains("smith 1.0"))); + // No hits pays nothing, unless xp-min-percent sets a floor. + assertEquals(StationFeedback.SUCCESS, station.craft(p)); + bukkit.verify(() -> Bukkit.dispatchCommand(any(), contains("smith 0.0")), never()); + Cache.craftXpMinPercent = 25; + assertEquals(StationFeedback.SUCCESS, station.craft(p)); + bukkit.verify(() -> Bukkit.dispatchCommand(any(), contains("smith 0.5"))); + Cache.craftXpMinPercent = 0; station.getHits().get(HitLoader.map.get("strike")).setCurrent(1); var ht = CraftingStation.class.getDeclaredField("hitTypes"); ht.setAccessible(true); ((Map) ht.get(station)).values().forEach(c -> c.setCurrent(c.getNeeded())); assertEquals(StationFeedback.SUCCESS, station.craft(p)); + bukkit.verify(() -> Bukkit.dispatchCommand(any(), contains("smith 2.0"))); assertFalse(station.getLoc().getWorld().getEntities().isEmpty()); } } @@ -543,11 +552,16 @@ void experienceAggregatesOnlyExistingPositiveValidDefinitions() throws Exception 1)); try (var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { bukkit.when(() -> Bukkit.dispatchCommand(any(), anyString())).thenReturn(true); - invoke(station, "giveXP", new Class[] {Player.class}, p); + invoke(station, "giveXP", new Class[] {Player.class, double.class}, p, 100.0); bukkit.verify( () -> Bukkit.dispatchCommand( any(), eq("mmocore admin exp give " + p.getName() + " smith 6.25"))); + invoke(station, "giveXP", new Class[] {Player.class, double.class}, p, 50.0); + bukkit.verify( + () -> + Bukkit.dispatchCommand( + any(), eq("mmocore admin exp give " + p.getName() + " smith 3.13"))); } } From 097cff3c2c6896fe22484c655106a4efe6ab8bdb Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:11:11 +0200 Subject: [PATCH 4/5] docs: mention the craft roll and XP scaling in the README Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bc37c5b..c4d9944 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,11 @@ finished item. - **Crafting identity** — retain the recipe, materials, and quality behind a crafted item, with support for naming, appearance, and branding. - **Profession progression** — connect recipes and materials to profession - access and experience rewards. + 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. ## Credits From ae30b933256378515001651085b09dd352db9841 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:20:18 +0200 Subject: [PATCH 5/5] fix: staff crafts pay XP for the hits actually done A staff craft with a set quality paid XP from that quality, so a forced craft could pay for work nobody did. XP now always follows the real Hits %; the set quality only changes the piece. Co-Authored-By: Claude Opus 5.5 --- .../advancedcrafting/objects/crafting/CraftingStation.java | 6 +++--- src/main/resources/config.yml | 2 +- .../tfminecraft/advancedcrafting/StationCoverageTest.java | 7 +++---- 3 files changed, 7 insertions(+), 8 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 79610da..085323d 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java @@ -284,9 +284,9 @@ public StationFeedback craft(Player p, Double forcedQualityPercent) { StationFeedback f = createItem(p, forcedQualityPercent); // Failed attempts keep the station's materials, so paying XP before the checks let every retry pay again. if (f == StationFeedback.SUCCESS) { - // XP follows the work: the set XP times the Hits % (a staff craft's set quality), at least xp-min-percent. - double percent = forcedQualityPercent != null ? forcedQualityPercent : calculatePercentage(); - giveXP(p, Math.max(Cache.craftXpMinPercent, Math.min(100, percent))); + // XP follows the work actually done: the set XP times the Hits %, at least xp-min-percent. + // A staff craft's set quality changes the piece, not the XP. + giveXP(p, Math.max(Cache.craftXpMinPercent, Math.min(100, calculatePercentage()))); } return f; } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 617fa6b..286124b 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -40,7 +40,7 @@ alloy-legacy-base: # steel_ingot: # - weapon_damage(12.0) -# Crafting XP is the set XP times the finished piece's Hits % (a perfect piece pays all of it). +# Crafting XP is the set XP times the finished piece's Hits % (a perfect piece pays all of it), staff crafts included. # A piece never pays less than this percent of it; 0 means a piece finished with no hits pays nothing. xp-min-percent: 0 diff --git a/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java index eed0378..8182340 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java @@ -249,12 +249,11 @@ void failedCraftDoesNotAwardExperienceAndSuccessUsesMmoOutput() throws Exception }); var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { bukkit.when(() -> Bukkit.dispatchCommand(any(), anyString())).thenReturn(true); - // XP is the set 2 times the Hits %: a staff craft at 50% pays half. + // XP is the set 2 times the Hits %. A staff craft's set quality does not pay for work not done, + // and no hits pays nothing, unless xp-min-percent sets a floor. assertEquals(StationFeedback.SUCCESS, station.craft(p, 50.)); - bukkit.verify(() -> Bukkit.dispatchCommand(any(), contains("smith 1.0"))); - // No hits pays nothing, unless xp-min-percent sets a floor. assertEquals(StationFeedback.SUCCESS, station.craft(p)); - bukkit.verify(() -> Bukkit.dispatchCommand(any(), contains("smith 0.0")), never()); + bukkit.verify(() -> Bukkit.dispatchCommand(any(), contains("smith")), never()); Cache.craftXpMinPercent = 25; assertEquals(StationFeedback.SUCCESS, station.craft(p)); bukkit.verify(() -> Bukkit.dispatchCommand(any(), contains("smith 0.5")));