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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ GemInfusion gives gemstones rolled bonuses and turns them into a resource for eq
## Features

- **Gem infusion** — process batches of gemstones using an infusion token, with station feedback as the infusion runs.
- **Rarity and stat rolls** — gems range from common to legendary and provide bonuses such as health, armour, damage, or damage reduction according to their type.
- **Rarity and stat rolls** — gems range from common to legendary and provide bonuses such as health, armour, damage, or damage reduction according to their type. A gem is named after how its rolled stat ranks, so a better rarity name never means a weaker gem.
- **d20 rolls** — like `/roll`, a d20 plus a modifier from the player's base attribute. Intelligence sets where an infused gem's stat lands, and Dexterity sets a hidden craft roll that adjusts the jewellery stat. A natural 20 always gives the best result and a natural 1 the worst.
- **Masterworks** — any gem piece finished with a perfect recipe and hits is a Masterwork, whatever the gem or the craft roll.
- **Socketed equipment** — connects gems with MMOItems sockets and preserves rarity information through socketing and removal.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public void loadConfig(FileConfiguration config) {
loadedRarities.add(new GemRarity(key, config.getConfigurationSection("rarities."+key)));
}
D20Roll.load(config.getConfigurationSection("d20"));
GemNameCutoffs.load(loadedGems, loadedRarities);
}
// Keep the existing legacy text representation, formatting, and exact-string comparisons.
@SuppressWarnings("deprecation")
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/net/tfminecraft/geminfusion/D20Roll.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ public final class D20Roll {
public static String infusionAttribute = "intelligence";
public static int flawlessDc = 20;
public static double spreadPercent = 20;
/** The modifier the infused-gem name shares are worked out at; see {@link GemNameCutoffs}. */
public static int nameReferenceModifier = 0;
public static String goldsmithAttribute = "dexterity";
public static double percentPerPoint = 2;
public static double maxPercent = 20;
Expand Down Expand Up @@ -123,6 +125,7 @@ public static void load(ConfigurationSection section) {
infusionAttribute = "intelligence";
flawlessDc = 20;
spreadPercent = 20;
nameReferenceModifier = 0;
goldsmithAttribute = "dexterity";
percentPerPoint = 2;
maxPercent = 20;
Expand Down Expand Up @@ -150,6 +153,7 @@ public static void load(ConfigurationSection section) {
spread = clamped;
}
spreadPercent = spread;
nameReferenceModifier = section.getInt("infusion.name-reference-modifier", nameReferenceModifier);
goldsmithAttribute = section.getString("goldsmithing.attribute", goldsmithAttribute);
percentPerPoint = section.getDouble("goldsmithing.percent-per-point", percentPerPoint);
maxPercent = section.getDouble("goldsmithing.max-percent", maxPercent);
Expand Down
137 changes: 137 additions & 0 deletions src/main/java/net/tfminecraft/geminfusion/GemNameCutoffs.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package net.tfminecraft.geminfusion;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.logging.Logger;

/**
* Infused gems are named after their rolled stat, not their drawn rarity. Each rarity's name goes to the same share
* of a gem's possible results as its draw chance, best results first, so a better name never means a weaker gem.
* Results that tie on one stat keep one name; when the tie would push a rarer name past its share, the whole tie gets
* the lower name, so rarer names can fall slightly short of their chance but never go over it.
* The shares are worked out at {@link D20Roll#nameReferenceModifier}; players above it get better names more often.
*/
public final class GemNameCutoffs {

/** The lowest stat that earns a rarity's name; positive infinity when no result fits in its share. */
record Cutoff(GemRarity rarity, double from) {
}

/** A gem's cutoffs, highest rarity first; stats below all of them get the lowest rarity's name. */
record Names(List<Cutoff> cutoffs, GemRarity lowest) {
}

/** Gem id to its names. Gems missing here keep the drawn rarity's name. */
private static Map<String, Names> names = new HashMap<>();

private GemNameCutoffs() {
}

/** Rarities are listed lowest to highest; only the ones that can be drawn (finite positive chance) count. */
public static void load(List<Gemstone> gems, List<GemRarity> rarities) {
Map<String, Names> loaded = new HashMap<>();
List<GemRarity> drawable = new ArrayList<>();
double total = 0;
for (GemRarity rarity : rarities) {
double chance = rarity.getChance();
if (Double.isFinite(chance) && chance > 0) {
drawable.add(rarity);
total += chance;
}
}
if (!drawable.isEmpty()) {
for (Gemstone gem : gems) {
Names gemNames = forGem(gem, drawable, total);
if (gemNames != null) {
loaded.put(gem.getId(), gemNames);
}
}
}
names = loaded;
}

/** The rarity a gem with this stat is named after. A NaN stat (none was rolled) keeps the drawn rarity. */
public static GemRarity name(Gemstone gem, GemRarity drawn, double stat) {
Names gemNames = names.get(gem.getId());
if (gemNames == null || Double.isNaN(stat)) {
return drawn;
}
for (Cutoff cutoff : gemNames.cutoffs()) {
if (stat >= cutoff.from()) {
return cutoff.rarity();
}
}
return gemNames.lowest();
}

private static Names forGem(Gemstone gem, List<GemRarity> drawable, double total) {
// Every result the infusion can give: each drawable rarity with each face of the die, best stat first.
TreeMap<Double, Double> chanceByStat = new TreeMap<>((a, b) -> Double.compare(b, a));
String statId = null;
for (GemRarity rarity : drawable) {
GemStat block = block(gem, rarity);
if (block == null || (statId != null && !statId.equalsIgnoreCase(block.getStatId()))) {
warn("Gem '" + gem.getId() + "' needs one stat for every rarity to be named by its roll;"
+ " it keeps the drawn rarity's name.");
return null;
}
statId = block.getStatId();
for (int natural = 1; natural <= 20; natural++) {
double position = new D20Roll(natural, D20Roll.nameReferenceModifier).position(D20Roll.flawlessDc);
chanceByStat.merge(D20Roll.gemStat(block, position), rarity.getChance() / total / 20, Double::sum);
}
}

List<Cutoff> gemCutoffs = new ArrayList<>();
StringBuilder summary = new StringBuilder("Gem '" + gem.getId() + "' names:");
double share = 0;
for (int i = drawable.size() - 1; i > 0; i--) {
GemRarity rarity = drawable.get(i);
share += rarity.getChance() / total;
double from = cutoff(chanceByStat, share);
gemCutoffs.add(new Cutoff(rarity, from));
summary.append(' ').append(rarity.getId())
.append(from == Double.POSITIVE_INFINITY ? " never" : " from " + from).append(',');
}
GemRarity lowest = drawable.getFirst();
info(summary.append(' ').append(lowest.getId()).append(" below.").toString());
return new Names(gemCutoffs, lowest);
}

/** The lowest stat whose results at or above it still fit in the share; a tie that would go over is left out. */
private static double cutoff(TreeMap<Double, Double> chanceByStat, double share) {
double cutoff = Double.POSITIVE_INFINITY;
double atOrAbove = 0;
for (Map.Entry<Double, Double> entry : chanceByStat.entrySet()) {
atOrAbove += entry.getValue();
if (atOrAbove <= share + 1e-9) {
cutoff = entry.getKey();
}
}
return cutoff;
}

private static GemStat block(Gemstone gem, GemRarity rarity) {
for (GemStat block : gem.getStats()) {
if (block.getId().equalsIgnoreCase(rarity.getId())) {
return block;
}
}
return null;
}

private static Logger logger() {
return InfusionMain.plugin == null ? Logger.getLogger("GemInfusion") : InfusionMain.plugin.getLogger();
}

private static void info(String message) {
logger().info(message);
}

private static void warn(String message) {
logger().warning(message);
}
}
15 changes: 10 additions & 5 deletions src/main/java/net/tfminecraft/geminfusion/InfusedGemBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ public final class InfusedGemBuilder {
private InfusedGemBuilder() {
}

public static void rollStats(MMOItem mmo, Gemstone gem, GemRarity rarity, int infusionAmount, Player player) {
/** Returns the rolled stat, or NaN when the gem has no usable stat for this rarity. */
public static double rollStats(MMOItem mmo, Gemstone gem, GemRarity rarity, int infusionAmount, Player player) {
double statAmount = Double.NaN;
GemStat block = null;
for (GemStat statBlock : gem.getStats()) {
if (statBlock.getId().equalsIgnoreCase(rarity.getId())) {
Expand All @@ -44,7 +46,7 @@ public static void rollStats(MMOItem mmo, Gemstone gem, GemRarity rarity, int in
} else {
// d20 + Intelligence picks the spot in the rarity's spread; the DC or a natural 20 makes it Flawless.
D20Roll roll = D20Roll.roll(player, D20Roll.infusionAttribute);
double statAmount = D20Roll.gemStat(block, roll.position(D20Roll.flawlessDc));
statAmount = D20Roll.gemStat(block, roll.position(D20Roll.flawlessDc));
mmo.setData(stat, new DoubleData(statAmount));
}
}
Expand All @@ -54,6 +56,7 @@ public static void rollStats(MMOItem mmo, Gemstone gem, GemRarity rarity, int in
maxChance = 0.0;
}
mmo.setData(ItemStats.SUCCESS_RATE, new DoubleData(Math.floor(Math.random() * maxChance) + 40));
return statAmount;
}

/** A null rarity (unknown after a reset) leaves the rarity out of the name and lore. */
Expand Down Expand Up @@ -114,12 +117,14 @@ public static ItemStack restoreReset(ItemStack item) {
return applyCosmeticsToItem(item, gem, rarity);
}

/** The drawn rarity sets the stat's range; the gem is named after where the rolled stat ranks. */
public static ItemStack buildInfusedGem(Gemstone gem, GemRarity rarity, int infusionAmount, Player player) {
ItemStack blankGem = gem.getMMOItem().newBuilder().build();
MMOItem infusedGem = new LiveMMOItem(NBTItem.get(blankGem));
rollStats(infusedGem, gem, rarity, infusionAmount, player);
applyCosmetics(infusedGem, gem, rarity);
return finalizeItem(infusedGem.newBuilder().build(), rarity.getId());
double stat = rollStats(infusedGem, gem, rarity, infusionAmount, player);
GemRarity named = GemNameCutoffs.name(gem, rarity, stat);
applyCosmetics(infusedGem, gem, named);
return finalizeItem(infusedGem.newBuilder().build(), named.getId());
}

public static ItemStack finalizeItem(ItemStack item, String rarityId) {
Expand Down
7 changes: 5 additions & 2 deletions src/main/java/net/tfminecraft/geminfusion/InfusionEvents.java
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,11 @@ public void infuseHitEvent(PlayerInteractEvent e) {
for (Gemstone gem : b.getCurrentItems()) {
GemRarity rarity = getRarity();
ItemStack output = Objects.requireNonNull(getInfusedGem(p, gem, b.getCurrentItems().size(), rarity), "Cannot build infused gem");
String announcement = rarity.shouldAnnounce()
? "§e" + p.getName() + " just infused a " + rarity.getName() + " " + gem.getColour() + gem.getName() + "§e Gemstone" : "";
// Announce the rarity the gem is named after, which can differ from the drawn one.
GemRarity named = ConfigLoader.findRarityById(GemRarityPdc.read(output));
if (named == null) named = rarity;
String announcement = named.shouldAnnounce()
? "§e" + p.getName() + " just infused a " + named.getName() + " " + gem.getColour() + gem.getName() + "§e Gemstone" : "";
prepared.add(new InfusionBlock.Drop(output, announcement));
}
} catch (RuntimeException invalidBatch) {
Expand Down
9 changes: 9 additions & 0 deletions src/main/resources/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ d20:
flawless-dc: 20
# How far the spread reaches past each rarity's min and max, in percent.
spread-percent: 20
# A gem is named after how its stat ranks among every result it could have rolled, not after the drawn
# rarity, so a better name never means a weaker gem. Each name goes to the same share of results as its
# rarity's chance, worked out for this d20 modifier (0 is Intelligence 10-11 in the table above); players
# above it get better names more often. Results that tie on one stat share a name, and a tie that would
# push a rarer name past its chance gets the lower name, so rarer names can fall slightly short.
# The cutoffs for each gem are logged when the config loads.
name-reference-modifier: 0
# Goldsmithing: one craft roll when a gem piece is finished. It only moves the jewelry stat and is not
# shown to the player. The quality, Masterwork included, comes from the recipe and hits alone.
goldsmithing:
Expand All @@ -49,6 +56,8 @@ d20:
# Dev testing only: 1-20 makes every roll that natural number. 0 rolls for real.
force-natural: 0

# List rarities from lowest to highest. chance is how often each is drawn, and also the share of gems
# named after it.
rarities:
common:
name: "§aCommon"
Expand Down
3 changes: 3 additions & 0 deletions src/test/java/net/tfminecraft/geminfusion/D20RollTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ void configOverridesDefaultsAndForcedNaturalPinsTheDie() throws Exception {
"attribute-modifiers:\n 0: -2\n 10: 1\n x: 9\n"
+ "force-natural: 7\n"
+ "infusion:\n attribute: wisdom\n flawless-dc: 18\n spread-percent: 10\n"
+ " name-reference-modifier: 2\n"
+ "goldsmithing:\n attribute: strength\n"
+ " percent-per-point: 3\n max-percent: 30\n"));
assertEquals(-2, D20Roll.modifier(0));
Expand All @@ -135,6 +136,7 @@ void configOverridesDefaultsAndForcedNaturalPinsTheDie() throws Exception {
assertEquals("wisdom", D20Roll.infusionAttribute);
assertEquals(18, D20Roll.flawlessDc);
assertEquals(10, D20Roll.spreadPercent);
assertEquals(2, D20Roll.nameReferenceModifier);
assertEquals("strength", D20Roll.goldsmithAttribute);
assertEquals(3, D20Roll.percentPerPoint);
assertEquals(30, D20Roll.maxPercent);
Expand All @@ -143,6 +145,7 @@ void configOverridesDefaultsAndForcedNaturalPinsTheDie() throws Exception {
D20Roll.load(config("attribute-modifiers:\n x: 9\nforce-natural: 25\n"));
assertEquals(5, D20Roll.modifier(20));
assertEquals("intelligence", D20Roll.infusionAttribute);
assertEquals(0, D20Roll.nameReferenceModifier);
for (int i = 0; i < 200; i++) {
int natural = D20Roll.die.getAsInt();
assertTrue(natural >= 1 && natural <= 20, String.valueOf(natural));
Expand Down
Loading