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, IntCounter>) 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);
+ }
+}