Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ It connects several crafting systems to a shared recycling experience, helping s
- **Preview before committing** — see the expected material return in the station's interface before confirming the recycle.
- **Condition-based recovery** — worn equipment yields less than an equivalent item at full durability.
- **Crafting-system support** — recover materials from supported AdvancedCrafting items, Magic gear, GunsAndGadgets items, and goldsmithing jewelry. Each returns the materials that actually went into the item, not the recipe as it reads today; items crafted before their plugin recorded this cannot be recycled.
- **Alloy scrap salvage** — turn scrap from a failed AdvancedCrafting alloy forge back into part of the base metal it used.
- **Alloy scrap salvage** — recover recorded base materials and catalysts, including gems, from a failed AdvancedCrafting alloy forge. Each consumed material unit rolls independently.
- **Per-type return rates** — each kind of item (AdvancedCrafting gear, alloy scrap, Magic gear, guns, jewelry, and recipe items) returns its own configurable share of the materials it was made from.
- **Additional salvage recipes** — handle other supported items through dedicated recycling recipes, including runes that return enchanted dust.
- **Protection for socketed items** — refuse supported mage weapons containing runes, and goldsmithing jewelry containing socketed gems, so dismantling does not silently consume them.
Expand All @@ -36,3 +36,5 @@ Copyright (c) 2026 TF-Minecraft contributors.
TF-Minecraft-authored material in this repository is licensed under the
[Artistic License 2.0](LICENSE). Third-party dependencies and bundled material
retain their own licenses.

Scrap recovery requires AdvancedCrafting 2.2.5 or newer. `return_rates.alloy_scrap` sets the chance per non-gem material unit. `scrap_gem_return_rates.tiers` sets gem chances by the live AdvancedCrafting ingredient tier (defaults: 1% / 25% / 50% / 75% for tiers 1-4); `default` covers unlisted tiers. Rates use 0.0-1.0 and invalid values are clamped or reset to defaults. Each recorded unit in each stacked scrap rolls once on confirmation, and failed rolls still consume the scrap. The preview lists possible quantities and chances without rolling. Older base-tagged scrap returns only its recorded base, since its catalysts were never saved.
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.proc>full</maven.compiler.proc>
<advancedcrafting.version>2.2.0</advancedcrafting.version>
<advancedcrafting.version>2.2.5</advancedcrafting.version>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<geminfusion.version>2.2.5</geminfusion.version>
<gunsandgadgets.version>2.0.6</gunsandgadgets.version>
<magic.version>0.4.7</magic.version>
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/net/tfminecraft/recycler/Cache.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ private Cache() {}

public static double advancedCraftingReturnRate = 0.5;
public static double scrapReturnRate = 0.5;
public static java.util.Map<String, Double> scrapGemRates = java.util.Map.of(
"1", 0.01, "2", 0.25, "3", 0.5, "4", 0.75);
public static double scrapGemDefaultRate = 0.01;
public static double magicGearReturnRate = 0.5;
public static double gunsReturnRate = 0.5;
public static double goldsmithReturnRate = 0.5;
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/net/tfminecraft/recycler/loader/ConfigLoader.java
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ public boolean loadSafe(File configFile) {
ConfigurationSection rates = config.getConfigurationSection("return_rates");
Cache.advancedCraftingReturnRate = readRate(rates, "advanced_crafting", craftedFallback, DEFAULT_CRAFTED_RATE);
Cache.scrapReturnRate = readRate(rates, "alloy_scrap", scrapFallback, DEFAULT_SCRAP_RATE);
ConfigurationSection gemRates = config.getConfigurationSection("scrap_gem_return_rates.tiers");
var tierRates = new java.util.HashMap<String, Double>();
var defaults = java.util.Map.of("1", 0.01, "2", 0.25, "3", 0.5, "4", 0.75);
defaults.forEach((tier, rate) -> tierRates.put(tier, readRate(gemRates, tier, rate, rate)));
if (gemRates != null) {
for (String tier : gemRates.getKeys(false)) {
double defaultRate = defaults.getOrDefault(tier, 0.01);
tierRates.put(tier, readRate(gemRates, tier, defaultRate, defaultRate));
}
}
Cache.scrapGemRates = java.util.Map.copyOf(tierRates);
Cache.scrapGemDefaultRate = readRate(config.getConfigurationSection("scrap_gem_return_rates"),
"default", 0.01, 0.01);
Cache.magicGearReturnRate = readRate(rates, "magic_gear", craftedFallback, DEFAULT_CRAFTED_RATE);
Cache.gunsReturnRate = readRate(rates, "guns", craftedFallback, DEFAULT_CRAFTED_RATE);
Cache.goldsmithReturnRate = readRate(rates, "goldsmith_jewelry", craftedFallback, DEFAULT_CRAFTED_RATE);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,16 @@ private void paintPreview(org.bukkit.inventory.Inventory inv, RecycleResult resu
continue;
}
stack.setAmount(Math.min(64, output.baseAmount()));
if (output.returnChance() >= 0) {
var meta = stack.getItemMeta();
var lore = meta.hasLore() ? new java.util.ArrayList<>(meta.getLore()) : new java.util.ArrayList<String>();
lore.add(String.format(java.util.Locale.ROOT, "Recovery chance: %.1f%% per material", output.returnChance() * 100));
meta.setLore(lore);
stack.setItemMeta(meta);
}
inv.setItem(GridLayout.previewSlots().get(index), stack);
index++;
}
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ private void handleConfirm(Player player, RecycleSession session) {
return;
}

result = result.roll();
ItemStack inputClone = escrow.clone();
String providerId = result.getProviderId();
List<RecycleOutput> outputLines = result.getOutputs();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@
import java.util.Map;

/**
* One output line before return-rate and durability scaling.
* One output line. A nonnegative returnChance rolls per unit; -1 uses deterministic scaling.
*/
public record RecycleOutput(String itemPath, int baseAmount) {
public record RecycleOutput(String itemPath, int baseAmount, double returnChance) {

public RecycleOutput(String itemPath, int baseAmount) {
this(itemPath, baseAmount, -1);
}

public RecycleOutput {
if (itemPath == null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package net.tfminecraft.recycler.provider;

import java.util.List;
import java.util.ArrayList;

import org.bukkit.inventory.ItemStack;

Expand All @@ -12,7 +13,7 @@
import net.tfminecraft.recycler.model.RecycleOutput;

/**
* Returns the base metal of the failed alloy forge that produced a piece of AdvancedCrafting scrap.
* Returns recorded failed-forge ingredients with independent per-unit recovery chances.
* Scrap forged before AdvancedCrafting tagged it has no base and is not handled.
* Requires AdvancedCrafting on the server (registered only when plugin is present).
*/
Expand All @@ -30,21 +31,27 @@ public boolean canHandle(ItemStack item) {

@Override
public List<RecycleOutput> resolveBaseOutputs(ItemStack item) {
String baseId = ScrapProvenance.readBaseId(item);
if (baseId == null || baseId.isBlank()) {
return List.of();
}
List<RecycleOutput> outputs = new ArrayList<>();
ScrapProvenance.readInputs(item).forEach((id, amount) -> addOutput(outputs, id, amount));
return outputs;
}

private void addOutput(List<RecycleOutput> outputs, String baseId, int amount) {
Ingredient ingredient = IngredientLoader.getByString(baseId);
if (ingredient == null) {
logMissing("ingredient", baseId);
return List.of();
return;
}
String path = ingredient.getPath();
if (path == null || path.isBlank()) {
logMissing("ingredient path", baseId);
return List.of();
return;
}
return List.of(new RecycleOutput(path, 1));
double rate = path.toLowerCase(java.util.Locale.ROOT).startsWith("m.gemstones.")
? Cache.scrapGemRates.getOrDefault(Integer.toString(ingredient.getIngredientData().getTier()),
Cache.scrapGemDefaultRate)
: Cache.scrapReturnRate;
outputs.add(new RecycleOutput(path, amount, rate));
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,38 @@ public static RecycleResult notHandled() {
}

public static RecycleResult of(String providerId, List<RecycleOutput> baseOutputs, RecycleContext ctx) {
return of(providerId, baseOutputs, ctx, false, null);
}

public static RecycleResult of(String providerId, List<RecycleOutput> baseOutputs, RecycleContext ctx,
boolean roll, java.util.function.DoubleSupplier random) {
Map<String, Integer> merged = new HashMap<>();
List<RecycleOutput> finalChanceOutputs = new ArrayList<>();
double scale = ctx.combinedScale() * ctx.stackAmount();
for (RecycleOutput line : baseOutputs) {
if (line.returnChance() >= 0) {
int amount = line.baseAmount() * ctx.stackAmount();
double chance = line.returnChance();
if (chance == 0) continue;
int returned = 0;
if (roll) {
for (int unit = 0; unit < amount; unit++) {
if (random.getAsDouble() < chance) returned++;
}
} else {
returned = amount;
}
if (returned > 0) finalChanceOutputs.add(new RecycleOutput(line.itemPath(), returned,
roll ? -1 : chance));
continue;
}
int scaled = (int) Math.floor(line.baseAmount() * scale);
if (scaled <= 0) {
continue;
}
merged.merge(line.itemPath(), scaled, Integer::sum);
}
List<RecycleOutput> finalOutputs = new ArrayList<>();
List<RecycleOutput> finalOutputs = new ArrayList<>(finalChanceOutputs);
for (Map.Entry<String, Integer> entry : merged.entrySet()) {
finalOutputs.add(new RecycleOutput(entry.getKey(), entry.getValue()));
}
Expand All @@ -50,6 +72,12 @@ public boolean isHandled() {
return handled;
}

/** Roll only after confirmation; a failed roll still consumes the scrap. */
public RecycleResult roll() {
return of(providerId, outputs, new RecycleContext(1, 1, 1), true,
() -> java.util.concurrent.ThreadLocalRandom.current().nextDouble());
}

public String getProviderId() {
return providerId;
}
Expand Down
17 changes: 15 additions & 2 deletions src/main/resources/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ station:
return_rates:
# AdvancedCrafting equipment: its recorded ingredients, and each alloy's base + catalysts.
advanced_crafting: 0.5
# Scrap from a failed alloy forge: the base metal that forge used (0.5 = 1 base metal per 2 scrap).
# Independent chance per recorded non-gem material unit in failed-forge scrap.
# Scrap forged before AdvancedCrafting 2.2.0 has no recorded base and cannot be recycled.
alloy_scrap: 0.5
# Magic mage weapons: the materials charged when the weapon was crafted.
Expand All @@ -48,7 +48,20 @@ return_rates:
# This multiplies those listed amounts: 1.0 gives exactly what the file says, 0.5 gives half.
recipes: 1.0

# Round outputs down (floor). Zero-yield confirms are blocked when true.
# Independent recovery chance per gem unit consumed by a failed alloy forge (0.0-1.0).
# Uses the gem ingredient's tier in AdvancedCrafting, regardless of its ingredient type.
# Amounts and stacked scrap roll each material unit separately. Preview shows possible returns.
# Older scrap has no catalyst record and can return only its recorded base.
scrap_gem_return_rates:
default: 0.01
tiers:
'1': 0.01
'2': 0.25
'3': 0.5
'4': 0.75

# Deterministic outputs round down. Blocks confirms with no possible yield;
# a failed scrap recovery roll still consumes the scrap.
block_confirm_when_zero_yield: true

# Deposit policy (checked before provider resolution).
Expand Down
27 changes: 27 additions & 0 deletions src/test/java/net/tfminecraft/recycler/InventoryManagerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,33 @@
import org.junit.jupiter.api.Test;

class InventoryManagerTest extends TestSupport {
@Test
void chancePreviewShowsPossibleAmountAndPreservesExistingLore() {
var chain = mock(RecycleProviderChain.class);
var manager = new InventoryManager(chain);
var player = server.addPlayer();
var session = new RecycleSession(player.getUniqueId());
var escrow = mock(EscrowManager.class);
var input = item(Material.IRON_NUGGET);
when(escrow.getEscrow(session.getPlayerId())).thenReturn(input);
when(chain.resolve(input)).thenReturn(RecycleResult.of("scrap",
List.of(new RecycleOutput("gem", 1, .01)), new RecycleContext(.5, 1, 1)));
manager.openMain(player, session);
for (boolean hasLore : List.of(false, true)) {
when(api.getCreator().getItemFromPath("gem")).thenAnswer(x -> {
var gem = item(Material.DIAMOND);
var meta = gem.getItemMeta();
if (hasLore) meta.setLore(List.of("Original lore"));
gem.setItemMeta(meta);
return gem;
});
manager.refreshPreview(player, session, escrow);
var shown = player.getOpenInventory().getTopInventory().getItem(GridLayout.previewSlots().getFirst());
assertEquals(1, shown.getAmount());
assertEquals("Recovery chance: 1.0% per material", shown.getItemMeta().getLore().getLast());
assertEquals(hasLore ? 2 : 1, shown.getItemMeta().getLore().size());
}
}
@Test
void shellPreviewReplacementAndCapacityLimit() {
var chain = mock(RecycleProviderChain.class);
Expand Down
3 changes: 2 additions & 1 deletion src/test/java/net/tfminecraft/recycler/ProvidersTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ void scrapUsesIndependentRateAndRejectsMissingBasePath() {
prov.when(() -> ScrapProvenance.readBaseId(stack)).thenReturn(" ");
assertTrue(provider.resolveBaseOutputs(stack).isEmpty());
prov.when(() -> ScrapProvenance.readBaseId(stack)).thenReturn("iron");
prov.when(() -> ScrapProvenance.readInputs(stack)).thenReturn(Map.of("iron", 1));
assertTrue(provider.canHandle(stack));
assertTrue(provider.resolveBaseOutputs(stack).isEmpty());
var ingredient = mock(Ingredient.class);
Expand All @@ -84,7 +85,7 @@ void scrapUsesIndependentRateAndRejectsMissingBasePath() {
assertTrue(provider.resolveBaseOutputs(stack).isEmpty());
when(ingredient.getPath()).thenReturn("v.iron_ingot");
assertEquals(
List.of(new RecycleOutput("v.iron_ingot", 1)), provider.resolveBaseOutputs(stack));
List.of(new RecycleOutput("v.iron_ingot", 1, Cache.scrapReturnRate)), provider.resolveBaseOutputs(stack));
}
}

Expand Down
64 changes: 64 additions & 0 deletions src/test/java/net/tfminecraft/recycler/ScrapRecoveryTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package net.tfminecraft.recycler;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.*;
import net.tfminecraft.advancedcrafting.loaders.IngredientLoader;
import net.tfminecraft.advancedcrafting.objects.data.ScrapProvenance;
import net.tfminecraft.advancedcrafting.objects.ingredients.Ingredient;
import net.tfminecraft.recycler.loader.ConfigLoader;
import net.tfminecraft.recycler.model.RecycleOutput;
import net.tfminecraft.recycler.provider.*;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;

class ScrapRecoveryTest extends TestSupport {
@Test
void ratesAreConfigurableClampedAndResetOnReload() throws Exception {
var loader = new ConfigLoader();
loader.load(yaml("rates", "scrap_gem_return_rates:\n default: 0.12\n tiers:\n '1': 0\n '2': 2\n '3': .NaN\n '5': 0.9\n").toFile());
assertEquals(Map.of("1", 0., "2", 1., "3", .5, "4", .75, "5", .9), Cache.scrapGemRates);
assertEquals(.12, Cache.scrapGemDefaultRate);
loader.load(yaml("rates", "{}").toFile());
assertEquals(Map.of("1", .01, "2", .25, "3", .5, "4", .75), Cache.scrapGemRates);
assertEquals(.01, Cache.scrapGemDefaultRate);
}

@Test
void allRecordedGemsUseTheirTierIncludingMetalTypedGemsAndFutureTiers() {
var scrap = item(Material.IRON_NUGGET);
try (var provenance = mockStatic(ScrapProvenance.class);
var ingredients = mockStatic(IngredientLoader.class)) {
for (int tier : List.of(1, 2, 3, 4, 5)) {
var gem = mock(Ingredient.class, RETURNS_DEEP_STUBS);
when(gem.getPath()).thenReturn("m.gemstones.gem" + tier);
when(gem.getIngredientData().getTier()).thenReturn(tier);
ingredients.when(() -> IngredientLoader.getByString("gem")).thenReturn(gem);
provenance.when(() -> ScrapProvenance.readInputs(scrap)).thenReturn(Map.of("gem", 3));
double rate = Cache.scrapGemRates.getOrDefault(Integer.toString(tier), Cache.scrapGemDefaultRate);
assertEquals(List.of(new RecycleOutput("m.gemstones.gem" + tier, 3, rate)),
new AlloyScrapProvider().resolveBaseOutputs(scrap));
}
}
}

@Test
void rollsEachMaterialUnitAcrossAmountsAndStacksWithoutFlooringOrRerollingPreview() {
var lines = List.of(new RecycleOutput("iron", 1, .5), new RecycleOutput("ruby", 2, .75),
new RecycleOutput("disabled", 1, 0), new RecycleOutput("guaranteed", 1, 1));
var ctx = new RecycleContext(.5, 1, 2);
var preview = RecycleResult.of("scrap", lines, ctx);
assertEquals(List.of(new RecycleOutput("iron", 2, .5), new RecycleOutput("ruby", 4, .75),
new RecycleOutput("guaranteed", 2, 1)), preview.getOutputs());
var rolls = new ArrayDeque<>(List.of(.49, .5, .74, .75, .1, .9, .99, .99));
var result = RecycleResult.of("scrap", lines, ctx, true, rolls::remove);
assertEquals(List.of(new RecycleOutput("iron", 1), new RecycleOutput("ruby", 2),
new RecycleOutput("guaranteed", 2)), result.getOutputs());
assertTrue(rolls.isEmpty());
assertTrue(RecycleResult.of("scrap", List.of(new RecycleOutput("iron", 1, .5)),
new RecycleContext(1, 1, 1), true, () -> .9).getOutputs().isEmpty());
assertEquals(List.of(new RecycleOutput("guaranteed", 2)),
RecycleResult.of("scrap", List.of(new RecycleOutput("guaranteed", 2, 1)),
new RecycleContext(1, 1, 1)).roll().getOutputs());
}
}
Loading