Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ finished item.
- **Profession progression** — connect recipes and materials to profession
access and experience rewards. Crafting XP scales with how well the piece
was worked.
- **d20 craft roll** — like `/roll`, finishing rolls a d20 plus a modifier from
the crafter's base Strength. A Masterwork needs perfect hits and a roll of 20
or more; a natural 20 always succeeds and a natural 1 always fails.
- **d20 craft roll** — like `/roll`, finishing a piece with less than 100% hits
rolls a d20 plus a modifier from the crafter's base Strength. A roll of 12 or
more gives a hint about the hit that was closest to right (more or less of
it, never the count); a natural 20 always succeeds and a natural 1 always
fails. The roll never changes the piece: 100% hits is always a Masterwork.

## Credits

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Set;
Expand Down Expand Up @@ -46,15 +45,6 @@ public static Quality getByString(String id) {
if(map.containsKey(id)) return map.get(id);
return null;
}
/** The tier just below the given one, or null for the lowest or an unknown tier. */
public static Quality below(Quality quality) {
if (quality == null) return null;
return map.values().stream()
.filter(candidate -> candidate.getValue() < quality.getValue())
.max(Comparator.comparingInt(Quality::getValue))
.orElse(null);
}

public static Quality getByAmount(double a) {
Quality q = null;
int prev = -1;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
import net.tfminecraft.advancedcrafting.utils.CraftStatCalculator;
import net.tfminecraft.advancedcrafting.utils.CraftTierLore;
import net.tfminecraft.advancedcrafting.utils.D20Roll;
import net.tfminecraft.advancedcrafting.utils.HitClue;
import net.tfminecraft.advancedcrafting.utils.MMOStatApplicator;
import net.tfminecraft.advancedcrafting.utils.MajorityTierResolver;
import net.tfminecraft.advancedcrafting.utils.ModelApplier;
Expand Down Expand Up @@ -459,19 +460,18 @@ private StationFeedback createItem(Player p, Double forcedQualityPercent, boolea
mmo.setStatHistory(ItemStats.NAME, hist);
}
double percentage = forcedQualityPercent != null ? forcedQualityPercent : calculatePercentage();
String clue = forcedQualityPercent == null ? HitClue.clueFor(hits) : null;
// The Hits % is rounded, so a big piece a hit off can read 100%. Only exact hits make a Masterwork.
if (clue != null && percentage >= 100) percentage = 99;
Quality q = getQuality(percentage);
p.sendMessage("Hit Percentage: §e"+percentage+"%");
if (forcedQualityPercent == null) {
// Perfect hits alone are not a Masterwork: the craft roll must also meet the DC. Stats still come
// only from the materials, so every Masterwork has the best stats its materials give.
p.sendMessage("Quality: "+q.getName());
if (forcedQualityPercent == null && percentage < 100) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// The craft roll never changes the piece. Meeting the DC earns a hint about the hit closest to right.
D20Roll roll = D20Roll.roll(p);
p.sendMessage("Craft roll: " + roll.describe());
if (percentage >= 100 && !roll.meets(D20Roll.masterworkDc)) {
Quality below = QualityLoader.below(q);
if (below != null) q = below;
}
p.sendMessage(clue != null && roll.meets(D20Roll.clueDc) ? clue : HitClue.NO_CLUE);
}
p.sendMessage("Quality: "+q.getName());
List<String> sockets = new ArrayList<String>();
SocketGroup socketGroup = SocketGroupLoader.getByString(recipe.getSocketGroupId());
if(socketGroup == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
import net.Indyuce.mmocore.api.player.PlayerData;

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

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

private final int natural;
private final int modifier;
Expand Down Expand Up @@ -94,7 +95,7 @@ public static void load(ConfigurationSection section) {
modifiers = defaultModifiers();
forcedNatural = 0;
attribute = "strength";
masterworkDc = 20;
clueDc = 12;
if (section != null) {
ConfigurationSection table = section.getConfigurationSection("attribute-modifiers");
if (table != null) {
Expand All @@ -110,7 +111,7 @@ public static void load(ConfigurationSection section) {
}
forcedNatural = section.getInt("force-natural", 0);
attribute = section.getString("attribute", attribute);
masterworkDc = section.getInt("masterwork-dc", masterworkDc);
clueDc = section.getInt("clue-dc", clueDc);
}
}

Expand Down
53 changes: 53 additions & 0 deletions src/main/java/net/tfminecraft/advancedcrafting/utils/HitClue.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package net.tfminecraft.advancedcrafting.utils;

import java.util.Comparator;
import java.util.Map;

import net.tfminecraft.advancedcrafting.objects.crafting.hits.CraftingHit;
import net.tfminecraft.tlibs.objects.utils.IntCounter;

/**
* The hint a crafter may get after finishing an imperfect piece. It names the hit that was closest to right
* without being right, and says only which way it was off and roughly how far, never the needed count.
*/
public final class HitClue {

public static final String PREFIX = "§7Looking the piece over, ";
public static final String NO_CLUE = PREFIX + "you can't tell what went wrong with it.";

private HitClue() {
}

/** The clue line, or null when every hit was done exactly. */
public static String clueFor(Map<CraftingHit, IntCounter> hits) {
Map.Entry<CraftingHit, IntCounter> closest = hits.entrySet().stream()
.filter(e -> e.getValue().getCurrent() != e.getValue().getNeeded())
.min(Comparator.<Map.Entry<CraftingHit, IntCounter>>comparingInt(e -> difference(e.getValue()))
.thenComparingDouble(e -> relativeError(e.getValue()))
.thenComparing(e -> e.getKey().getId()))
.orElse(null);
if (closest == null) return null;
IntCounter c = closest.getValue();
String name = closest.getKey().getName() + "§7";
if (c.getCurrent() == 0) return PREFIX + "you sense it needed some " + name + " work.";
if (c.getNeeded() == 0) return PREFIX + "you sense it took " + name + " work it didn't need.";
boolean little = difference(c) == 1 || relativeError(c) <= 0.25;
boolean far = relativeError(c) > 0.75;
if (c.getCurrent() < c.getNeeded()) {
String amount = little ? "a little more" : far ? "much more" : "more";
return PREFIX + "you sense it needed " + amount + " " + name + " work.";
}
String amount = little ? "a little too much" : far ? "far too much" : "too much";
return PREFIX + "you sense it took " + amount + " " + name + " work.";
}

private static int difference(IntCounter c) {
return Math.abs(c.getCurrent() - c.getNeeded());
}

/** How far off a hit is for its size; any use of a hit the piece did not need is fully wrong. */
private static double relativeError(IntCounter c) {
if (c.getNeeded() <= 0) return Double.POSITIVE_INFINITY;
return (double) difference(c) / c.getNeeded();
}
}
8 changes: 5 additions & 3 deletions src/main/resources/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ alloy-legacy-base:
xp-min-percent: 0

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,12 +218,12 @@ void configurationClampsPercentagesAndParsesOffsetsCombinationsAndPermissions()
config(
"list.yml",
"global-stat-offsets: ['', invalid, 'speed(0)', 'attack(100)', 'broken(x)']\n"
+ "d20:\n attribute: dexterity\n masterwork-dc: 18\n"
+ "d20:\n attribute: dexterity\n clue-dc: 18\n"
+ "xp-min-percent: 150\n"));
assertEquals(100, Cache.craftXpMinPercent);
assertEquals(Map.of("attack", 100.), Cache.globalStatOffsets);
assertEquals("dexterity", D20Roll.attribute);
assertEquals(18, D20Roll.masterworkDc);
assertEquals(18, D20Roll.clueDc);
invoke(loader, "parseGlobalStatOffset", new Class[] {String.class}, (Object) null);
loader.load(temp.resolve("missing").toFile());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,15 +269,14 @@ void failedCraftDoesNotAwardExperienceAndSuccessUsesMmoOutput() throws Exception
}

@Test
void masterworkNeedsPerfectHitsAndACraftRollAtTheDc() throws Exception {
void perfectHitsAreAlwaysAMasterworkAndTheCraftRollOnlyEarnsAClue() throws Exception {
var p = server.addPlayer();
var iron = ingredient("iron", "hits: ['strike.1']");
var station = new CraftingStation(loc());
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);
Expand All @@ -300,32 +299,57 @@ void masterworkNeedsPerfectHitsAndACraftRollAtTheDc() throws Exception {
for (String line = p.nextMessage(); line != null; line = p.nextMessage()) lines.add(line);
return lines;
};
// No hits yet: finishing is allowed and gives the lowest tier.
IntCounter strike = station.getHits().get(HitLoader.map.get("strike"));
// No hits yet: the lowest tier, and a roll under the DC gives no clue.
assertEquals(
List.of("Hit Percentage: §e0.0%", "Craft roll: §e10 §7(+0) = §e10", "Quality: Rusted"),
finish.apply(10));
station.getHits().get(HitLoader.map.get("strike")).setCurrent(1);
List.of(
"Hit Percentage: §e0.0%",
"Quality: Rusted",
"Craft roll: §e11 §7(+0) = §e11",
HitClue.NO_CLUE),
finish.apply(11));
// A total at the DC names the hit that was off and which way, never the count.
assertEquals(
List.of("Hit Percentage: §e100.0%", "Craft roll: §6Natural 20!", "Quality: Masterwork"),
finish.apply(20));
// A total at the DC without a natural 20 also makes it.
D20Roll.masterworkDc = 12;
assertTrue(finish.apply(12).contains("Quality: Masterwork"));
D20Roll.masterworkDc = 20;
// Perfect hits that miss the DC become the tier below.
List.of(
"Hit Percentage: §e0.0%",
"Quality: Rusted",
"Craft roll: §e12 §7(+0) = §e12",
HitClue.PREFIX + "you sense it needed some Strike work."),
finish.apply(12));
// Too many hits: a natural 20 always gets the clue, a natural 1 never does.
strike.setCurrent(2);
assertEquals(
List.of("Hit Percentage: §e100.0%", "Craft roll: §cNatural 1!", "Quality: Gleaming"),
finish.apply(1));
assertTrue(finish.apply(19).contains("Quality: Gleaming"));
// With no tier below, perfect hits keep their tier.
assertNull(QualityLoader.below(null));
QualityLoader.map.remove("epic");
QualityLoader.map.remove("crude");
assertTrue(finish.apply(1).contains("Quality: Masterwork"));
HitClue.PREFIX + "you sense it took a little too much Strike work.",
finish.apply(20).get(3));
assertEquals(HitClue.NO_CLUE, finish.apply(1).get(3));
// The DC comes from the config.
D20Roll.clueDc = 5;
assertEquals(HitClue.PREFIX + "you sense it took a little too much Strike work.", finish.apply(5).get(3));
D20Roll.clueDc = 12;
// A hit nothing is off for leaves nothing to hint at, even on a pass.
strike.setNeeded(0);
strike.setCurrent(0);
assertEquals(HitClue.NO_CLUE, finish.apply(20).get(3));
// 199 of 200 rounds to 100%, but only exact hits make a Masterwork: it reads 99% and rolls.
strike.setNeeded(200);
strike.setCurrent(199);
assertEquals(
List.of(
"Hit Percentage: §e99.0%",
"Quality: Rusted",
"Craft roll: §6Natural 20!",
HitClue.PREFIX + "you sense it needed a little more Strike work."),
finish.apply(20));
// Perfect hits are always a Masterwork and do not roll, however the die would land.
strike.setNeeded(1);
strike.setCurrent(1);
assertEquals(List.of("Hit Percentage: §e100.0%", "Quality: Masterwork"), finish.apply(1));
// Staff-built pieces with a set quality never roll.
strike.setCurrent(0);
station.craft(p, 100.);
assertEquals("Hit Percentage: §e100.0%", p.nextMessage());
assertEquals("Quality: Masterwork", p.nextMessage());
assertNull(p.nextMessage());
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,17 +101,17 @@ void configOverridesDefaultsAndForcedNaturalPinsTheDie() throws Exception {
D20Roll.load(
config(
"attribute-modifiers:\n 0: -2\n 10: 1\n x: 9\n"
+ "force-natural: 7\nattribute: dexterity\nmasterwork-dc: 22\n"));
+ "force-natural: 7\nattribute: dexterity\nclue-dc: 9\n"));
assertEquals(-2, D20Roll.modifier(9));
assertEquals(1, D20Roll.modifier(20));
assertEquals("dexterity", D20Roll.attribute);
assertEquals(22, D20Roll.masterworkDc);
assertEquals(9, D20Roll.clueDc);
assertEquals(7, D20Roll.die.getAsInt());
// Out-of-range forced values and a table with no numbers fall back to real rolls and the default table.
D20Roll.load(config("attribute-modifiers:\n x: 9\nforce-natural: 25\n"));
assertEquals(5, D20Roll.modifier(20));
assertEquals("strength", D20Roll.attribute);
assertEquals(20, D20Roll.masterworkDc);
assertEquals(12, D20Roll.clueDc);
for (int i = 0; i < 200; i++) {
int natural = D20Roll.die.getAsInt();
assertTrue(natural >= 1 && natural <= 20, String.valueOf(natural));
Expand Down
Loading
Loading