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/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 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..1f46cf8 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; @@ -45,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/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..681a2cc 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,8 @@ 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.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/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 3764e7a..085323d 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; @@ -246,8 +247,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 +257,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; @@ -274,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 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; } @@ -282,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<>(); @@ -326,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); } @@ -345,22 +358,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()) { @@ -373,29 +376,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) { @@ -417,9 +397,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)); @@ -483,11 +460,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) { @@ -540,7 +524,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..286124b 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -40,10 +40,41 @@ 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" +# 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 + +# 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..107f9af 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/LoaderCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/LoaderCoverageTest.java @@ -217,10 +217,13 @@ 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" + + "xp-min-percent: 150\n")); + assertEquals(100, Cache.craftXpMinPercent); 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 e6ecdd3..8182340 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()); @@ -166,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()); @@ -194,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); @@ -213,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 @@ -240,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); @@ -255,17 +249,86 @@ 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'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 2.0"))); + assertEquals(StationFeedback.SUCCESS, station.craft(p)); + 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"))); + 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()); } } + @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(); @@ -488,11 +551,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"))); } } 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); + } +}