From 84df5acb07699c3493874451f1719a12f7425176 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:44:45 +0200 Subject: [PATCH] feat: allow gem-free goldsmithing projects and add the Golden Key Projects with `gem: 0` now finish: they give the configured item as is, without a gem stat or quality lore, and still record the gold used for recycling. Their menu entry and finish messages drop the tier, gem and stat-carry lines, and they need no tier. Adds the Golden Key (m.keys.gold_key) with a 10-gold recipe (rough 5, moldable 1, shiny 4), the greater-tier total, using a mix no other project uses. Also makes the exact stat-carry test switch off the random gem-rarity boost, which made it fail about 15% of the time. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- .../goldsmith/GoldsmithInventoryManager.java | 4 +- .../goldsmith/GoldsmithStationManager.java | 13 ++++-- .../geminfusion/goldsmith/JewelryOutput.java | 33 +++++++++++--- .../geminfusion/goldsmith/JewelryProject.java | 3 +- src/main/resources/goldsmithing/projects.yml | 9 ++++ .../geminfusion/GemOutputTest.java | 45 +++++++++++++++++++ .../goldsmith/ConfigLoadersTest.java | 12 +++-- .../GoldsmithInventoryManagerTest.java | 13 ++++-- .../GoldsmithStationManagerTest.java | 10 +++++ 10 files changed, 125 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index a3fb46a..e1e53eb 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ GemInfusion gives gemstones rolled bonuses and turns them into a resource for eq - **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. - **Character influence** — Intelligence affects an infused gem's rolled stat, while Dexterity separately influences the bonus carried into jewellery. - **Socketed equipment** — connects gems with MMOItems sockets and preserves rarity information through socketing and removal. -- **Goldsmithing projects** — craft rings, necklaces, medals, and other pieces from metal materials and an infused gem at a smithing station. Each finished piece records the metal materials actually used, so recycling can return those rather than the listed recipe. +- **Goldsmithing projects** — craft rings, necklaces, medals, and other pieces from metal materials and an infused gem at a smithing station. Each finished piece records the metal materials actually used, so recycling can return those rather than the listed recipe. Projects set to `gem: 0`, such as the Golden Key, need only gold and give the item without a stat or quality. - **Craftsmanship matters** — recipe accuracy, tool work, project tier, and finishing quality shape how much of the gem's bonus reaches the finished piece. The same gemstone links discovery and craft: its infusion determines the starting bonus, and the goldsmith's work shapes the jewellery made from it. Legendary infusions can also be announced to other players. diff --git a/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithInventoryManager.java b/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithInventoryManager.java index 684a9e4..7070189 100644 --- a/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithInventoryManager.java +++ b/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithInventoryManager.java @@ -49,7 +49,9 @@ private ItemStack decorate(ItemStack icon, JewelryProject project) { ItemMeta meta = icon.getItemMeta(); meta.setDisplayName(project.getName()); List lore = new ArrayList<>(); - lore.add("§7Tier: §e" + formatTierName(project.getTierId())); + if (project.requiresGem()) { + lore.add("§7Tier: §e" + formatTierName(project.getTierId())); + } for (Map.Entry e : project.getMaterialsByType().entrySet()) { lore.add("§7Requires §a" + e.getValue() + " " + GoldsmithMaterialTypeLoader.display(e.getKey())); } diff --git a/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationManager.java b/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationManager.java index f65bf1a..ff497f4 100644 --- a/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationManager.java +++ b/src/main/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationManager.java @@ -321,7 +321,7 @@ private void handleLeftClick(PlayerInteractEvent e) { } GoldsmithFeedback finish = station.canFinish(); if (finish == GoldsmithFeedback.LACKING_ITEMS) { - p.sendMessage("§cYou have to add all the gold and the gem before finishing"); + p.sendMessage(lackingItemsMessage(station, "finishing")); p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); return; } @@ -354,7 +354,7 @@ private void handleLeftClick(PlayerInteractEvent e) { markDirty(); break; case LACKING_ITEMS: - p.sendMessage("§cYou have to add all the gold and the gem before working"); + p.sendMessage(lackingItemsMessage(station, "working")); p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); break; case WRONG_TYPE: @@ -401,11 +401,18 @@ private void completeCraft(Player p, GoldsmithStation station) { if (result.getQuality() != null) { p.sendMessage("§7Quality: " + result.getQuality().getName()); } - p.sendMessage("§7Stat carry: §e" + Math.round(result.getStatCarryPercent()) + "%"); + if (station.getProject().requiresGem()) { + p.sendMessage("§7Stat carry: §e" + Math.round(result.getStatCarryPercent()) + "%"); + } station.cancel(); remove(station.getLoc()); } + private static String lackingItemsMessage(GoldsmithStation station, String action) { + String gem = station.getProject().requiresGem() ? " and the gem" : ""; + return "§cYou have to add all the gold" + gem + " before " + action; + } + private boolean isBranding(ItemStack item) { return TLibs.getItemAPI().getChecker().checkItemWithPath(item, GoldsmithCache.brandingTool); } diff --git a/src/main/java/net/tfminecraft/geminfusion/goldsmith/JewelryOutput.java b/src/main/java/net/tfminecraft/geminfusion/goldsmith/JewelryOutput.java index 8ae6d74..35d091d 100644 --- a/src/main/java/net/tfminecraft/geminfusion/goldsmith/JewelryOutput.java +++ b/src/main/java/net/tfminecraft/geminfusion/goldsmith/JewelryOutput.java @@ -31,6 +31,9 @@ private JewelryOutput() { public static JewelryCraftResult build(GoldsmithStation station, Player player) { JewelryProject project = station.getProject(); + if (project != null && !project.requiresGem()) { + return buildPlain(station, project); + } ItemStack gemStack = station.getGem(); if (project == null || gemStack == null) { GoldsmithLog.warn("Jewelry output missing project or gem."); @@ -61,12 +64,8 @@ public static JewelryCraftResult build(GoldsmithStation station, Player player) double attributeMult = 1.0 + AttributeInfluence.jewelry.forPlayer(player); double amount = Math.floor(roll.value * projectMult * qualityMult * attributeMult * 10000) / 10000; - String path = project.getItem(); - ItemStack base = TLibs.getItemAPI().getCreator().getItemFromPath(path); - if (base == null || (path != null && path.toLowerCase().startsWith("ia.") && base.getType() == Material.DIRT)) { - GoldsmithLog.warn("Could not build output for project " + project.getId() + " (" + path + "). Station left intact."); - return null; - } + ItemStack base = baseItem(project); + if (base == null) return null; LiveMMOItem mmo = new LiveMMOItem(NBTItem.get(base)); if (!applyStat(mmo, roll.statId, amount)) { @@ -85,6 +84,28 @@ public static JewelryCraftResult build(GoldsmithStation station, Player player) return new JewelryCraftResult(out, recipePct, hitPct, finishedTotal, statCarry, quality); } + /** Gem-free projects (e.g. keys) give the configured item as is: no stat, no quality. */ + private static JewelryCraftResult buildPlain(GoldsmithStation station, JewelryProject project) { + ItemStack out = baseItem(project); + if (out == null) return null; + out.setAmount(1); + GoldsmithProvenance.stamp(out, station.getDepositedByMaterial()); + double recipePct = station.getRecipePercent(); + double hitPct = station.getHitPercent(); + return new JewelryCraftResult(out, recipePct, hitPct, + GoldsmithMath.finishedTotal(recipePct, hitPct), 0, null); + } + + private static ItemStack baseItem(JewelryProject project) { + String path = project.getItem(); + ItemStack base = TLibs.getItemAPI().getCreator().getItemFromPath(path); + if (base == null || (path != null && path.toLowerCase().startsWith("ia.") && base.getType() == Material.DIRT)) { + GoldsmithLog.warn("Could not build output for project " + project.getId() + " (" + path + "). Station left intact."); + return null; + } + return base; + } + private static void applyQualityLore(MMOItem mmo, Quality quality) { List loreList = new ArrayList<>(); loreList.add("§fQuality: " + quality.getName()); diff --git a/src/main/java/net/tfminecraft/geminfusion/goldsmith/JewelryProject.java b/src/main/java/net/tfminecraft/geminfusion/goldsmith/JewelryProject.java index 239c345..c5da167 100644 --- a/src/main/java/net/tfminecraft/geminfusion/goldsmith/JewelryProject.java +++ b/src/main/java/net/tfminecraft/geminfusion/goldsmith/JewelryProject.java @@ -25,7 +25,8 @@ public JewelryProject(String key, ConfigurationSection config) { String tier = config.getString("tier"); if (tier == null || tier.isBlank()) { - GoldsmithLog.warn("Project '" + id + "' has no tier set, defaulting to greater."); + // Tiers only scale the gem stat, so gem-free projects need none. + if (requiresGem) GoldsmithLog.warn("Project '" + id + "' has no tier set, defaulting to greater."); this.tierId = "greater"; } else { this.tierId = tier.toLowerCase(); diff --git a/src/main/resources/goldsmithing/projects.yml b/src/main/resources/goldsmithing/projects.yml index d8addf6..1b4a9b5 100644 --- a/src/main/resources/goldsmithing/projects.yml +++ b/src/main/resources/goldsmithing/projects.yml @@ -1,4 +1,5 @@ # gem: 1 means one infused gem must sit on the bench before hammering. +# gem: 0 projects give the item as is (no stat, no quality); tier is not needed. gold_ring: name: "§fGolden Ring" item: m.ring.fine_ring @@ -114,3 +115,11 @@ bracelet: - rough_gold.1 - moldable_gold.4 - shiny_gold.5 +gold_key: + name: "§6Golden Key" + item: m.keys.gold_key + gem: 0 + recipe: + - rough_gold.5 + - moldable_gold.1 + - shiny_gold.4 diff --git a/src/test/java/net/tfminecraft/geminfusion/GemOutputTest.java b/src/test/java/net/tfminecraft/geminfusion/GemOutputTest.java index d8cd4a1..f52cb64 100644 --- a/src/test/java/net/tfminecraft/geminfusion/GemOutputTest.java +++ b/src/test/java/net/tfminecraft/geminfusion/GemOutputTest.java @@ -206,6 +206,7 @@ void infusedValidatorRecognizesOnlyConfiguredInfusedGemsAndCandidates() { void jewelryRejectsMissingInputsInvalidGemsAndUnreadableStats() { GoldsmithStation station = mock(GoldsmithStation.class); JewelryProject project = mock(JewelryProject.class); + when(project.requiresGem()).thenReturn(true); ItemStack gemItem = tagged(Material.DIAMOND); try (var log = mockStatic(GoldsmithLog.class); var valid = mockStatic(InfusedGemValidator.class); @@ -227,8 +228,12 @@ void jewelryRejectsMissingInputsInvalidGemsAndUnreadableStats() { @Test void jewelryCarriesScaledStatIntoFreshHistoryAndQualityLore() throws Exception { + // The rarity boost is a random roll; switch it off so the carry is exact. + var boostChances = GoldsmithCache.jewelryGemStatBoostChances; + GoldsmithCache.jewelryGemStatBoostChances = Map.of(); GoldsmithStation station = mock(GoldsmithStation.class); JewelryProject project = mock(JewelryProject.class); + when(project.requiresGem()).thenReturn(true); ItemStack gemItem = tagged(Material.DIAMOND), base = tagged(Material.GOLD_INGOT), out = tagged(Material.GOLD_INGOT); @@ -282,6 +287,8 @@ void jewelryCarriesScaledStatIntoFreshHistoryAndQualityLore() throws Exception { argThat(v -> v instanceof DoubleData d && d.getValue() == 3.0)); verify(history) .registerExternalData(argThat(v -> v instanceof DoubleData d && d.getValue() == 3.0)); + } finally { + GoldsmithCache.jewelryGemStatBoostChances = boostChances; } } @@ -289,6 +296,7 @@ void jewelryCarriesScaledStatIntoFreshHistoryAndQualityLore() throws Exception { void jewelryFallsBackAfterMatchingNonnumericStatAndHandlesFailedBuild() throws Exception { GoldsmithStation station = mock(GoldsmithStation.class); JewelryProject project = mock(JewelryProject.class); + when(project.requiresGem()).thenReturn(true); ItemStack gemItem = tagged(Material.DIAMOND), base = tagged(Material.DIAMOND); when(station.getProject()).thenReturn(project); when(station.getGem()).thenReturn(gemItem); @@ -326,6 +334,7 @@ void jewelryFallsBackAfterMatchingNonnumericStatAndHandlesFailedBuild() throws E void jewelryHandlesFallbackStatsInvalidOutputPathsAndAbsentHistory() throws Exception { GoldsmithStation station = mock(GoldsmithStation.class); JewelryProject project = mock(JewelryProject.class); + when(project.requiresGem()).thenReturn(true); ItemStack gemItem = tagged(Material.DIAMOND), base = tagged(Material.DIAMOND), out = tagged(Material.DIAMOND); @@ -377,4 +386,40 @@ void jewelryHandlesFallbackStatsInvalidOutputPathsAndAbsentHistory() throws Exce assertNull(JewelryOutput.build(station, player)); } } + + @Test + void gemFreeProjectGivesPlainItemWithProvenanceAndNoQuality() { + GoldsmithStation station = mock(GoldsmithStation.class); + JewelryProject project = mock(JewelryProject.class); + ItemStack key = tagged(Material.GOLD_NUGGET); + key.setAmount(3); + when(station.getProject()).thenReturn(project); + when(station.getRecipePercent()).thenReturn(90.0); + when(station.getHitPercent()).thenReturn(80.0); + when(project.getItem()).thenReturn("m.keys.gold_key"); + GoldsmithMaterial material = mock(GoldsmithMaterial.class); + when(material.getPath()).thenReturn("m.materials.rough_gold"); + when(station.getDepositedByMaterial()).thenReturn(Map.of(material, 5)); + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + when(api.getCreator().getItemFromPath("m.keys.gold_key")).thenReturn(key); + try (var libs = mockStatic(TLibs.class); + var log = mockStatic(GoldsmithLog.class); + var constructed = live(m -> {})) { + libs.when(TLibs::getItemAPI).thenReturn(api); + JewelryCraftResult result = JewelryOutput.build(station, player); + assertNotNull(result); + assertSame(key, result.getItem()); + assertEquals(1, key.getAmount()); + assertEquals("Original", key.getItemMeta().getDisplayName()); + assertEquals(Map.of("m.materials.rough_gold", 5), GoldsmithProvenance.read(key)); + assertEquals(GoldsmithMath.finishedTotal(90, 80), result.getFinishedTotal()); + assertEquals(0, result.getStatCarryPercent()); + assertNull(result.getQuality()); + assertTrue(constructed.constructed().isEmpty()); + verify(station, never()).getGem(); + when(api.getCreator().getItemFromPath("m.keys.gold_key")).thenReturn(null); + assertNull(JewelryOutput.build(station, player)); + log.verify(() -> GoldsmithLog.warn(contains("Could not build output"))); + } + } } diff --git a/src/test/java/net/tfminecraft/geminfusion/goldsmith/ConfigLoadersTest.java b/src/test/java/net/tfminecraft/geminfusion/goldsmith/ConfigLoadersTest.java index 9112954..debb7e7 100644 --- a/src/test/java/net/tfminecraft/geminfusion/goldsmith/ConfigLoadersTest.java +++ b/src/test/java/net/tfminecraft/geminfusion/goldsmith/ConfigLoadersTest.java @@ -79,7 +79,7 @@ void shippedConfigurationDefinitionsLoadAndReferenceEachOther() throws Exception assertTrue(p.getTierMultiplier() > 0); assertFalse(p.getRecipe().isEmpty()); assertFalse(p.getMaterialsByType().isEmpty()); - assertTrue(p.requiresGem()); + assertEquals(!p.getId().equals("gold_key"), p.requiresGem()); assertThrows(UnsupportedOperationException.class, () -> p.getRecipe().clear()); } for (GoldsmithMaterial m : GoldsmithMaterialLoader.get().values()) { @@ -185,8 +185,14 @@ void invalidDefinitionsAreSkippedAndValidHitCountsAreMerged() throws Exception { + " gold.-1, unknown.3]\n" + "blankTier:\n" + " item: m.ring\n" - + " tier: ' '\n")); - assertEquals(Set.of("ring", "blankTier"), JewelryProjectLoader.get().keySet()); + + " tier: ' '\n" + + "gemNoTier:\n" + + " item: m.ring\n" + + " gem: 1\n")); + assertEquals( + Set.of("ring", "blankTier", "gemNoTier"), JewelryProjectLoader.get().keySet()); + assertEquals("greater", JewelryProjectLoader.getByString("gemNoTier").getTierId()); + assertTrue(JewelryProjectLoader.getByString("gemNoTier").requiresGem()); JewelryProject ring = JewelryProjectLoader.getByString("ring"); assertEquals("greater", ring.getTierId()); assertEquals("greater", JewelryProjectLoader.getByString("blankTier").getTierId()); diff --git a/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithInventoryManagerTest.java b/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithInventoryManagerTest.java index 4344830..1b6e6c3 100644 --- a/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithInventoryManagerTest.java +++ b/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithInventoryManagerTest.java @@ -69,15 +69,18 @@ Inventory opened() { @Test void validIconsAreClonedDecoratedAndRemainingSlotsFilled() { project("ring", "minecraft.DIAMOND", "mAJOR", true); - project("band", "ia.band", null, false); - project("blank", "minecraft.GOLD_INGOT", " ", false); + project("band", "ia.band", null, true); + project("blank", "minecraft.GOLD_INGOT", " ", true); + project("key", "minecraft.GOLD_NUGGET", null, false); ItemStack original = new ItemStack(Material.DIAMOND, 6); when(items.getCreator().getItemFromPath("minecraft.DIAMOND")).thenReturn(original); when(items.getCreator().getItemFromPath("ia.band")) .thenReturn(new ItemStack(Material.GOLD_INGOT)); when(items.getCreator().getItemFromPath("minecraft.GOLD_INGOT")) .thenReturn(new ItemStack(Material.GOLD_INGOT)); - assertEquals(3, new GoldsmithInventoryManager().openMenu(player)); + when(items.getCreator().getItemFromPath("minecraft.GOLD_NUGGET")) + .thenReturn(new ItemStack(Material.GOLD_NUGGET)); + assertEquals(4, new GoldsmithInventoryManager().openMenu(player)); Inventory inv = opened(); ItemStack icon = inv.getItem(0); assertEquals(1, icon.getAmount()); @@ -92,7 +95,9 @@ void validIconsAreClonedDecoratedAndRemainingSlotsFilled() { .get(GoldsmithInventoryManager.projectKey(), PersistentDataType.STRING)); assertTrue(inv.getItem(1).getItemMeta().getLore().contains("§7Tier: §eGreater")); assertTrue(inv.getItem(2).getItemMeta().getLore().contains("§7Tier: §eGreater")); - for (int slot = 3; slot < 27; slot++) + assertTrue( + inv.getItem(3).getItemMeta().getLore().stream().noneMatch(l -> l.startsWith("§7Tier"))); + for (int slot = 4; slot < 27; slot++) assertEquals(Material.GRAY_STAINED_GLASS_PANE, inv.getItem(slot).getType()); } diff --git a/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationManagerTest.java b/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationManagerTest.java index ad0c847..1debdc6 100644 --- a/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationManagerTest.java +++ b/src/test/java/net/tfminecraft/geminfusion/goldsmith/GoldsmithStationManagerTest.java @@ -298,6 +298,7 @@ void toolHitsReportProgressWarningsAndFeedback() { when(station.markOverworkWarnedIfNeeded()).thenReturn(feedback == GoldsmithFeedback.SUCCESS); manager.onInteract(click(Action.LEFT_CLICK_BLOCK)); } + verify(player).sendMessage("§cYou have to add all the gold before working"); reset(); when(station.hit(hit)).thenReturn(GoldsmithFeedback.SUCCESS); when(station.markOverworkWarnedIfNeeded()).thenReturn(false); @@ -333,6 +334,12 @@ void finishingReportsMissingRequirementsAndFailedOutputWithoutClearingStation() manager.onInteract(click(Action.LEFT_CLICK_BLOCK)); assertSame(station, manager.get(loc)); } + verify(player).sendMessage("§cYou have to add all the gold before finishing"); + reset(); + when(project.requiresGem()).thenReturn(true); + when(station.canFinish()).thenReturn(GoldsmithFeedback.LACKING_ITEMS); + manager.onInteract(click(Action.LEFT_CLICK_BLOCK)); + verify(player).sendMessage("§cYou have to add all the gold and the gem before finishing"); try (var output = mockStatic(JewelryOutput.class)) { reset(); when(station.canFinish()).thenReturn(GoldsmithFeedback.SUCCESS); @@ -352,6 +359,8 @@ void successfulCraftDropsOutputAndClearsProjectWithOptionalQuality() { try (var output = mockStatic(JewelryOutput.class)) { for (Quality chosen : Arrays.asList(null, quality)) { reset(); + // Gem-free projects (keys) have no quality and no stat carry line. + when(project.requiresGem()).thenReturn(chosen != null); JewelryCraftResult result = new JewelryCraftResult(new ItemStack(Material.DIAMOND), 90, 80, 80, 60, chosen); output.when(() -> JewelryOutput.build(station, player)).thenReturn(result); @@ -361,6 +370,7 @@ void successfulCraftDropsOutputAndClearsProjectWithOptionalQuality() { assertEquals(2, world.getEntitiesByClass(org.bukkit.entity.Item.class).size()); verify(station, times(2)).cancel(); verify(player).sendMessage("§7Quality: Fine"); + verify(player).sendMessage("§7Stat carry: §e60%"); } }