Skip to content

Commit 44a979b

Browse files
XxFran10xXclaude
andauthored
feat: the craft roll gives a hit clue instead of blocking Masterworks (#42)
* feat: the craft roll gives a hit clue instead of blocking Masterworks Since v2.4.0 a finished piece rolled d20 + base Strength, and perfect hits that missed a total of 20 dropped to the tier below. Now 100% hits is always a Masterwork again and the roll never changes the piece. The roll is made only when a piece is finished with less than 100% hits, after the quality line. A total of 12 or more (clue-dc, a natural 20 always passes and a natural 1 always fails, same Strength modifiers) gives a roleplay hint about the hit that was closest to right without being right: which way it was off and roughly how far, never a count. For example, 4 of 5 Hits gives "Looking the piece over, you sense it needed a little more Hit work." Ties go to the hit that is closer for its size, then to the hit id, so a piece always gets the same clue. A miss gives "you can't tell what went wrong with it." masterwork-dc is replaced by clue-dc (default 12); old configs keep working with the default. QualityLoader.below is gone with its only user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: only exact hits make a Masterwork when the Hits % rounds to 100 The Hits % is rounded, so a big piece a hit off (199 of 200) read 100% and became a Masterwork without a roll. A piece that is not exact now reads 99% at most, keeps its tier below Masterwork and rolls for a clue. (CodeRabbit) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 61e14f4 commit 44a979b

10 files changed

Lines changed: 217 additions & 55 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@ finished item.
2222
- **Profession progression** — connect recipes and materials to profession
2323
access and experience rewards. Crafting XP scales with how well the piece
2424
was worked.
25-
- **d20 craft roll** — like `/roll`, finishing rolls a d20 plus a modifier from
26-
the crafter's base Strength. A Masterwork needs perfect hits and a roll of 20
27-
or more; a natural 20 always succeeds and a natural 1 always fails.
25+
- **d20 craft roll** — like `/roll`, finishing a piece with less than 100% hits
26+
rolls a d20 plus a modifier from the crafter's base Strength. A roll of 12 or
27+
more gives a hint about the hit that was closest to right (more or less of
28+
it, never the count); a natural 20 always succeeds and a natural 1 always
29+
fails. The roll never changes the piece: 100% hits is always a Masterwork.
2830

2931
## Credits
3032

‎src/main/java/net/tfminecraft/advancedcrafting/loaders/QualityLoader.java‎

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import java.io.File;
44
import java.io.IOException;
55
import java.util.ArrayList;
6-
import java.util.Comparator;
76
import java.util.HashMap;
87
import java.util.List;
98
import java.util.Set;
@@ -46,15 +45,6 @@ public static Quality getByString(String id) {
4645
if(map.containsKey(id)) return map.get(id);
4746
return null;
4847
}
49-
/** The tier just below the given one, or null for the lowest or an unknown tier. */
50-
public static Quality below(Quality quality) {
51-
if (quality == null) return null;
52-
return map.values().stream()
53-
.filter(candidate -> candidate.getValue() < quality.getValue())
54-
.max(Comparator.comparingInt(Quality::getValue))
55-
.orElse(null);
56-
}
57-
5848
public static Quality getByAmount(double a) {
5949
Quality q = null;
6050
int prev = -1;

‎src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
import net.tfminecraft.advancedcrafting.utils.CraftStatCalculator;
4949
import net.tfminecraft.advancedcrafting.utils.CraftTierLore;
5050
import net.tfminecraft.advancedcrafting.utils.D20Roll;
51+
import net.tfminecraft.advancedcrafting.utils.HitClue;
5152
import net.tfminecraft.advancedcrafting.utils.MMOStatApplicator;
5253
import net.tfminecraft.advancedcrafting.utils.MajorityTierResolver;
5354
import net.tfminecraft.advancedcrafting.utils.ModelApplier;
@@ -459,19 +460,18 @@ private StationFeedback createItem(Player p, Double forcedQualityPercent, boolea
459460
mmo.setStatHistory(ItemStats.NAME, hist);
460461
}
461462
double percentage = forcedQualityPercent != null ? forcedQualityPercent : calculatePercentage();
463+
String clue = forcedQualityPercent == null ? HitClue.clueFor(hits) : null;
464+
// The Hits % is rounded, so a big piece a hit off can read 100%. Only exact hits make a Masterwork.
465+
if (clue != null && percentage >= 100) percentage = 99;
462466
Quality q = getQuality(percentage);
463467
p.sendMessage("Hit Percentage: §e"+percentage+"%");
464-
if (forcedQualityPercent == null) {
465-
// Perfect hits alone are not a Masterwork: the craft roll must also meet the DC. Stats still come
466-
// only from the materials, so every Masterwork has the best stats its materials give.
468+
p.sendMessage("Quality: "+q.getName());
469+
if (forcedQualityPercent == null && percentage < 100) {
470+
// The craft roll never changes the piece. Meeting the DC earns a hint about the hit closest to right.
467471
D20Roll roll = D20Roll.roll(p);
468472
p.sendMessage("Craft roll: " + roll.describe());
469-
if (percentage >= 100 && !roll.meets(D20Roll.masterworkDc)) {
470-
Quality below = QualityLoader.below(q);
471-
if (below != null) q = below;
472-
}
473+
p.sendMessage(clue != null && roll.meets(D20Roll.clueDc) ? clue : HitClue.NO_CLUE);
473474
}
474-
p.sendMessage("Quality: "+q.getName());
475475
List<String> sockets = new ArrayList<String>();
476476
SocketGroup socketGroup = SocketGroupLoader.getByString(recipe.getSocketGroupId());
477477
if(socketGroup == null) {

‎src/main/java/net/tfminecraft/advancedcrafting/utils/D20Roll.java‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
import net.Indyuce.mmocore.api.player.PlayerData;
1212

1313
/**
14-
* The forging craft roll: a d20 plus a modifier from the player's base attribute, like RPCharacters' /roll and
15-
* GemInfusion's goldsmithing roll. A natural 20 always succeeds and a natural 1 always fails, whatever the modifier.
14+
* The forging craft roll, made when an imperfect piece is finished: a d20 plus a modifier from the player's base
15+
* attribute, like RPCharacters' /roll. Meeting the DC earns a clue about the hits; the roll never changes the piece.
16+
* A natural 20 always succeeds and a natural 1 always fails, whatever the modifier.
1617
*/
1718
public final class D20Roll {
1819

@@ -22,7 +23,7 @@ public final class D20Roll {
2223
private static TreeMap<Integer, Integer> modifiers = defaultModifiers();
2324
private static int forcedNatural;
2425
public static String attribute = "strength";
25-
public static int masterworkDc = 20;
26+
public static int clueDc = 12;
2627

2728
private final int natural;
2829
private final int modifier;
@@ -94,7 +95,7 @@ public static void load(ConfigurationSection section) {
9495
modifiers = defaultModifiers();
9596
forcedNatural = 0;
9697
attribute = "strength";
97-
masterworkDc = 20;
98+
clueDc = 12;
9899
if (section != null) {
99100
ConfigurationSection table = section.getConfigurationSection("attribute-modifiers");
100101
if (table != null) {
@@ -110,7 +111,7 @@ public static void load(ConfigurationSection section) {
110111
}
111112
forcedNatural = section.getInt("force-natural", 0);
112113
attribute = section.getString("attribute", attribute);
113-
masterworkDc = section.getInt("masterwork-dc", masterworkDc);
114+
clueDc = section.getInt("clue-dc", clueDc);
114115
}
115116
}
116117

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package net.tfminecraft.advancedcrafting.utils;
2+
3+
import java.util.Comparator;
4+
import java.util.Map;
5+
6+
import net.tfminecraft.advancedcrafting.objects.crafting.hits.CraftingHit;
7+
import net.tfminecraft.tlibs.objects.utils.IntCounter;
8+
9+
/**
10+
* The hint a crafter may get after finishing an imperfect piece. It names the hit that was closest to right
11+
* without being right, and says only which way it was off and roughly how far, never the needed count.
12+
*/
13+
public final class HitClue {
14+
15+
public static final String PREFIX = "§7Looking the piece over, ";
16+
public static final String NO_CLUE = PREFIX + "you can't tell what went wrong with it.";
17+
18+
private HitClue() {
19+
}
20+
21+
/** The clue line, or null when every hit was done exactly. */
22+
public static String clueFor(Map<CraftingHit, IntCounter> hits) {
23+
Map.Entry<CraftingHit, IntCounter> closest = hits.entrySet().stream()
24+
.filter(e -> e.getValue().getCurrent() != e.getValue().getNeeded())
25+
.min(Comparator.<Map.Entry<CraftingHit, IntCounter>>comparingInt(e -> difference(e.getValue()))
26+
.thenComparingDouble(e -> relativeError(e.getValue()))
27+
.thenComparing(e -> e.getKey().getId()))
28+
.orElse(null);
29+
if (closest == null) return null;
30+
IntCounter c = closest.getValue();
31+
String name = closest.getKey().getName() + "§7";
32+
if (c.getCurrent() == 0) return PREFIX + "you sense it needed some " + name + " work.";
33+
if (c.getNeeded() == 0) return PREFIX + "you sense it took " + name + " work it didn't need.";
34+
boolean little = difference(c) == 1 || relativeError(c) <= 0.25;
35+
boolean far = relativeError(c) > 0.75;
36+
if (c.getCurrent() < c.getNeeded()) {
37+
String amount = little ? "a little more" : far ? "much more" : "more";
38+
return PREFIX + "you sense it needed " + amount + " " + name + " work.";
39+
}
40+
String amount = little ? "a little too much" : far ? "far too much" : "too much";
41+
return PREFIX + "you sense it took " + amount + " " + name + " work.";
42+
}
43+
44+
private static int difference(IntCounter c) {
45+
return Math.abs(c.getCurrent() - c.getNeeded());
46+
}
47+
48+
/** How far off a hit is for its size; any use of a hit the piece did not need is fully wrong. */
49+
private static double relativeError(IntCounter c) {
50+
if (c.getNeeded() <= 0) return Double.POSITIVE_INFINITY;
51+
return (double) difference(c) / c.getNeeded();
52+
}
53+
}

‎src/main/resources/config.yml‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ alloy-legacy-base:
4545
xp-min-percent: 0
4646

4747
# Forging craft roll, like /roll: a d20 plus a modifier from the player's base attribute (gear does not count).
48-
# It is rolled on every finish and shown to the crafter. A natural 20 always succeeds and a natural 1 always fails.
48+
# It is rolled when a piece is finished with less than 100% hits and shown to the crafter.
49+
# A natural 20 always succeeds and a natural 1 always fails.
4950
d20:
5051
# Base attribute value -> modifier. Same as RPCharacters rolls.yml; values past the ends use the nearest end.
5152
attribute-modifiers:
@@ -71,8 +72,9 @@ d20:
7172
19: 4
7273
20: 5
7374
attribute: strength
74-
# A Masterwork needs 100% hits and a total of at least this. Perfect hits that miss become the tier below.
75-
masterwork-dc: 20
75+
# A total of at least this earns a hint about the hit that was closest to right ("it needed a little more
76+
# Small Hit work"). The roll never changes the piece; 100% hits is always a Masterwork.
77+
clue-dc: 12
7678
# Dev testing only: 1-20 makes every roll that natural number. 0 rolls for real.
7779
force-natural: 0
7880

‎src/test/java/net/tfminecraft/advancedcrafting/LoaderCoverageTest.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,12 +218,12 @@ void configurationClampsPercentagesAndParsesOffsetsCombinationsAndPermissions()
218218
config(
219219
"list.yml",
220220
"global-stat-offsets: ['', invalid, 'speed(0)', 'attack(100)', 'broken(x)']\n"
221-
+ "d20:\n attribute: dexterity\n masterwork-dc: 18\n"
221+
+ "d20:\n attribute: dexterity\n clue-dc: 18\n"
222222
+ "xp-min-percent: 150\n"));
223223
assertEquals(100, Cache.craftXpMinPercent);
224224
assertEquals(Map.of("attack", 100.), Cache.globalStatOffsets);
225225
assertEquals("dexterity", D20Roll.attribute);
226-
assertEquals(18, D20Roll.masterworkDc);
226+
assertEquals(18, D20Roll.clueDc);
227227
invoke(loader, "parseGlobalStatOffset", new Class[] {String.class}, (Object) null);
228228
loader.load(temp.resolve("missing").toFile());
229229
}

‎src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java‎

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -269,15 +269,14 @@ void failedCraftDoesNotAwardExperienceAndSuccessUsesMmoOutput() throws Exception
269269
}
270270

271271
@Test
272-
void masterworkNeedsPerfectHitsAndACraftRollAtTheDc() throws Exception {
272+
void perfectHitsAreAlwaysAMasterworkAndTheCraftRollOnlyEarnsAClue() throws Exception {
273273
var p = server.addPlayer();
274274
var iron = ingredient("iron", "hits: ['strike.1']");
275275
var station = new CraftingStation(loc());
276276
station.setRecipe(recipe("recipe: ['metal.1']"));
277277
p.getInventory().setItemInMainHand(iron.build());
278278
station.addMaterial(p, iron.build());
279279
QualityLoader.map.put("crude", new Quality("crude", yaml("name: Rusted\namount: 0\nvalue: 1")));
280-
QualityLoader.map.put("epic", new Quality("epic", yaml("name: Gleaming\namount: 90\nvalue: 4")));
281280
QualityLoader.map.put(
282281
"legendary", new Quality("legendary", yaml("name: Masterwork\namount: 100\nvalue: 5")));
283282
try (var nbt = mockStatic(NBTItem.class);
@@ -300,32 +299,57 @@ void masterworkNeedsPerfectHitsAndACraftRollAtTheDc() throws Exception {
300299
for (String line = p.nextMessage(); line != null; line = p.nextMessage()) lines.add(line);
301300
return lines;
302301
};
303-
// No hits yet: finishing is allowed and gives the lowest tier.
302+
IntCounter strike = station.getHits().get(HitLoader.map.get("strike"));
303+
// No hits yet: the lowest tier, and a roll under the DC gives no clue.
304304
assertEquals(
305-
List.of("Hit Percentage: §e0.0%", "Craft roll: §e10 §7(+0) = §e10", "Quality: Rusted"),
306-
finish.apply(10));
307-
station.getHits().get(HitLoader.map.get("strike")).setCurrent(1);
305+
List.of(
306+
"Hit Percentage: §e0.0%",
307+
"Quality: Rusted",
308+
"Craft roll: §e11 §7(+0) = §e11",
309+
HitClue.NO_CLUE),
310+
finish.apply(11));
311+
// A total at the DC names the hit that was off and which way, never the count.
308312
assertEquals(
309-
List.of("Hit Percentage: §e100.0%", "Craft roll: §6Natural 20!", "Quality: Masterwork"),
310-
finish.apply(20));
311-
// A total at the DC without a natural 20 also makes it.
312-
D20Roll.masterworkDc = 12;
313-
assertTrue(finish.apply(12).contains("Quality: Masterwork"));
314-
D20Roll.masterworkDc = 20;
315-
// Perfect hits that miss the DC become the tier below.
313+
List.of(
314+
"Hit Percentage: §e0.0%",
315+
"Quality: Rusted",
316+
"Craft roll: §e12 §7(+0) = §e12",
317+
HitClue.PREFIX + "you sense it needed some Strike work."),
318+
finish.apply(12));
319+
// Too many hits: a natural 20 always gets the clue, a natural 1 never does.
320+
strike.setCurrent(2);
316321
assertEquals(
317-
List.of("Hit Percentage: §e100.0%", "Craft roll: §cNatural 1!", "Quality: Gleaming"),
318-
finish.apply(1));
319-
assertTrue(finish.apply(19).contains("Quality: Gleaming"));
320-
// With no tier below, perfect hits keep their tier.
321-
assertNull(QualityLoader.below(null));
322-
QualityLoader.map.remove("epic");
323-
QualityLoader.map.remove("crude");
324-
assertTrue(finish.apply(1).contains("Quality: Masterwork"));
322+
HitClue.PREFIX + "you sense it took a little too much Strike work.",
323+
finish.apply(20).get(3));
324+
assertEquals(HitClue.NO_CLUE, finish.apply(1).get(3));
325+
// The DC comes from the config.
326+
D20Roll.clueDc = 5;
327+
assertEquals(HitClue.PREFIX + "you sense it took a little too much Strike work.", finish.apply(5).get(3));
328+
D20Roll.clueDc = 12;
329+
// A hit nothing is off for leaves nothing to hint at, even on a pass.
330+
strike.setNeeded(0);
331+
strike.setCurrent(0);
332+
assertEquals(HitClue.NO_CLUE, finish.apply(20).get(3));
333+
// 199 of 200 rounds to 100%, but only exact hits make a Masterwork: it reads 99% and rolls.
334+
strike.setNeeded(200);
335+
strike.setCurrent(199);
336+
assertEquals(
337+
List.of(
338+
"Hit Percentage: §e99.0%",
339+
"Quality: Rusted",
340+
"Craft roll: §6Natural 20!",
341+
HitClue.PREFIX + "you sense it needed a little more Strike work."),
342+
finish.apply(20));
343+
// Perfect hits are always a Masterwork and do not roll, however the die would land.
344+
strike.setNeeded(1);
345+
strike.setCurrent(1);
346+
assertEquals(List.of("Hit Percentage: §e100.0%", "Quality: Masterwork"), finish.apply(1));
325347
// Staff-built pieces with a set quality never roll.
348+
strike.setCurrent(0);
326349
station.craft(p, 100.);
327350
assertEquals("Hit Percentage: §e100.0%", p.nextMessage());
328351
assertEquals("Quality: Masterwork", p.nextMessage());
352+
assertNull(p.nextMessage());
329353
}
330354
}
331355

‎src/test/java/net/tfminecraft/advancedcrafting/utils/D20RollTest.java‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,17 +101,17 @@ void configOverridesDefaultsAndForcedNaturalPinsTheDie() throws Exception {
101101
D20Roll.load(
102102
config(
103103
"attribute-modifiers:\n 0: -2\n 10: 1\n x: 9\n"
104-
+ "force-natural: 7\nattribute: dexterity\nmasterwork-dc: 22\n"));
104+
+ "force-natural: 7\nattribute: dexterity\nclue-dc: 9\n"));
105105
assertEquals(-2, D20Roll.modifier(9));
106106
assertEquals(1, D20Roll.modifier(20));
107107
assertEquals("dexterity", D20Roll.attribute);
108-
assertEquals(22, D20Roll.masterworkDc);
108+
assertEquals(9, D20Roll.clueDc);
109109
assertEquals(7, D20Roll.die.getAsInt());
110110
// Out-of-range forced values and a table with no numbers fall back to real rolls and the default table.
111111
D20Roll.load(config("attribute-modifiers:\n x: 9\nforce-natural: 25\n"));
112112
assertEquals(5, D20Roll.modifier(20));
113113
assertEquals("strength", D20Roll.attribute);
114-
assertEquals(20, D20Roll.masterworkDc);
114+
assertEquals(12, D20Roll.clueDc);
115115
for (int i = 0; i < 200; i++) {
116116
int natural = D20Roll.die.getAsInt();
117117
assertTrue(natural >= 1 && natural <= 20, String.valueOf(natural));

0 commit comments

Comments
 (0)