From 3173b51d132522866aaaa63ceeb76a36a6124720 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:37:53 +0200 Subject: [PATCH 1/4] Wear artifacts out in meditation and drop the Epic rarity Every point of resonance an artifact grants wears it. At artifacts.wear.budget_multiplier (default 2.0) times its highest cap it crumbles into a spent husk with no aura or id that only the Recycler takes. Epic is removed: Rare and Legendary split its cap range, weight, adjectives and models. Co-Authored-By: Claude Opus 5.5 --- .../java/net/tfminecraft/magic/Cache.java | 6 + .../tfminecraft/magic/artifact/Artifact.java | 4 + .../magic/artifact/ArtifactKeys.java | 10 + .../magic/artifact/ArtifactLore.java | 37 +++ .../magic/artifact/ArtifactWear.java | 128 ++++++++++ .../magic/loader/ConfigLoader.java | 4 + .../magic/meditation/MeditationCircle.java | 39 ++++ .../magic/meditation/MeditationSession.java | 38 +++ src/main/resources/artifacts/adjectives.yml | 56 ++--- src/main/resources/artifacts/generator.yml | 106 +++------ .../resources/artifacts/model-schemes.yml | 42 ++-- src/main/resources/config.yml | 6 + src/main/resources/messages.yml | 8 + .../tfminecraft/magic/ConfigurationTest.java | 23 ++ .../magic/MeditationCircleTest.java | 44 ++++ .../magic/MeditationSessionTest.java | 82 +++++++ .../magic/artifact/ArtifactWearTest.java | 218 ++++++++++++++++++ 17 files changed, 725 insertions(+), 126 deletions(-) create mode 100644 src/main/java/net/tfminecraft/magic/artifact/ArtifactWear.java create mode 100644 src/test/java/net/tfminecraft/magic/artifact/ArtifactWearTest.java diff --git a/src/main/java/net/tfminecraft/magic/Cache.java b/src/main/java/net/tfminecraft/magic/Cache.java index bd12e58..e8fe53c 100644 --- a/src/main/java/net/tfminecraft/magic/Cache.java +++ b/src/main/java/net/tfminecraft/magic/Cache.java @@ -28,6 +28,12 @@ public final class Cache { public static double secondsPerHour = 3600.0; public static boolean artifactGlint = true; public static double artifactAuraCap = 150.0; + public static final double DEFAULT_ARTIFACT_WEAR_MULTIPLIER = 2.0; + /** + * Lifetime resonance an artifact can grant, as a multiple of its highest aura cap. + * At that total it crumbles into a spent husk. 0 turns wear off. + */ + public static double artifactWearMultiplier = DEFAULT_ARTIFACT_WEAR_MULTIPLIER; public static boolean loggingEnabled = true; public static boolean wipeLog = true; /** Real mana spent divided by this equals drift. 100 means 4 mana -> 0.04. */ diff --git a/src/main/java/net/tfminecraft/magic/artifact/Artifact.java b/src/main/java/net/tfminecraft/magic/artifact/Artifact.java index d6fdd88..24d42b2 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/Artifact.java +++ b/src/main/java/net/tfminecraft/magic/artifact/Artifact.java @@ -52,6 +52,10 @@ public static Artifact fromItem(ItemStack stack) { artifact.primary = readPrimary(stack); return artifact; } + // A husk has no aura data, and its lore must never be read back into an artifact. + if (ArtifactWear.isHusk(stack)) { + return null; + } Artifact fromLore = ArtifactLore.readAura(stack); if (fromLore != null && !ChargeIds.isCharge(stack)) { fromLore.primary = readPrimary(stack); diff --git a/src/main/java/net/tfminecraft/magic/artifact/ArtifactKeys.java b/src/main/java/net/tfminecraft/magic/artifact/ArtifactKeys.java index 73619e6..0eb19dc 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/ArtifactKeys.java +++ b/src/main/java/net/tfminecraft/magic/artifact/ArtifactKeys.java @@ -28,6 +28,16 @@ public static NamespacedKey artifactId() { return new NamespacedKey(Magic.plugin, "artifact_id"); } + /** Resonance this artifact has granted in meditation, summed over every user. */ + public static NamespacedKey artifactWear() { + return new NamespacedKey(Magic.plugin, "artifact_wear"); + } + + /** Set on a spent husk. Holds the id the artifact had before it crumbled. */ + public static NamespacedKey artifactSpent() { + return new NamespacedKey(Magic.plugin, "artifact_spent"); + } + public static NamespacedKey attuneStart() { return new NamespacedKey(Magic.plugin, "attune_start"); } diff --git a/src/main/java/net/tfminecraft/magic/artifact/ArtifactLore.java b/src/main/java/net/tfminecraft/magic/artifact/ArtifactLore.java index b17203b..fb10975 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/ArtifactLore.java +++ b/src/main/java/net/tfminecraft/magic/artifact/ArtifactLore.java @@ -10,6 +10,7 @@ import org.bukkit.persistence.PersistentDataType; import net.tfminecraft.magic.Cache; +import net.tfminecraft.magic.Messages; import net.tfminecraft.magic.artifact.config.ArtifactRarityDef; import net.tfminecraft.magic.artifact.config.ArtifactRarityRegistry; import net.tfminecraft.magic.artifact.sacrifice.SacrificeElementDef; @@ -144,6 +145,38 @@ static boolean careVisibleWouldChange(ItemStack stack, double newMuffle, double return false; } + /** Whole percent, rounded up so a living artifact never reads 0%. */ + static String integrityLine(double integrity) { + long percent = (long) Math.ceil(Math.max(0.0, Math.min(1.0, integrity)) * 100.0 - 0.0001); + return MagicText.format("{color:label_muted}Integrity: " + percent + "%"); + } + + /** + * Replaces the aura block with the spent-husk block and renames the item. The husk + * keeps its rarity line, so the Recycler's price is still visible. + */ + // Keep the existing legacy text representation, formatting, and exact-string comparisons. + @SuppressWarnings("deprecation") + public static void applyHusk(ItemStack stack) { + ItemMeta meta = stack.getItemMeta(); + List lore = meta.hasLore() ? new ArrayList<>(meta.getLore()) : new ArrayList<>(); + lore = stripMagicBlocks(lore); + List block = new ArrayList<>(); + String rarityLine = rarityLine(stack); + if (rarityLine != null) { + block.add(rarityLine); + } + block.add(Messages.get("artifact.husk.lore")); + block.set(0, BEGIN + block.get(0)); + lore.addAll(block); + meta.setLore(lore); + if (meta.hasDisplayName()) { + meta.setDisplayName(Messages.get("artifact.husk.name", "name", plain(meta.getDisplayName()))); + } + meta.setEnchantmentGlintOverride(Boolean.FALSE); + stack.setItemMeta(meta); + } + private static String muffleLine(double muffle) { if (muffle <= 0.0001) { return ""; @@ -227,6 +260,10 @@ private static List buildAttuneLines(ItemStack stack, Artifact artifact) private static List buildAttuneLines(ItemStack stack, Artifact artifact, double muffle) { List lines = new ArrayList<>(); + // Shown once the artifact has been worn, so untouched artifacts keep their lore. + if (ArtifactWear.enabled() && ArtifactWear.readWear(stack) > 0.0) { + lines.add(integrityLine(ArtifactWear.integrity(stack, artifact))); + } if (muffle > 0.0001) { lines.add(muffleLine(muffle)); } diff --git a/src/main/java/net/tfminecraft/magic/artifact/ArtifactWear.java b/src/main/java/net/tfminecraft/magic/artifact/ArtifactWear.java new file mode 100644 index 0000000..e63f65f --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/artifact/ArtifactWear.java @@ -0,0 +1,128 @@ +package net.tfminecraft.magic.artifact; + +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; + +import net.tfminecraft.magic.Cache; + +/** + * Lifetime wear. Every point of resonance an artifact grants in meditation wears it, + * whoever receives it. Its budget is {@code artifacts.wear.budget_multiplier} times its + * highest aura cap. At the budget it crumbles into a spent husk: no aura and no artifact + * id, so every Magic system ignores it, and only the Recycler still takes it. + */ +public final class ArtifactWear { + + private static final double EPSILON = 0.0001; + + public enum Result { + NONE, + WORN, + CRUMBLED + } + + private ArtifactWear() {} + + public static boolean enabled() { + return Cache.artifactWearMultiplier > 0.0; + } + + public static double readWear(ItemStack stack) { + if (stack == null || stack.getType().isAir()) { + return 0.0; + } + Double stored = stack.getItemMeta().getPersistentDataContainer().get( + ArtifactKeys.artifactWear(), PersistentDataType.DOUBLE); + if (stored == null || !Double.isFinite(stored) || stored < 0.0) { + return 0.0; + } + return stored; + } + + /** Total resonance the artifact can grant before it crumbles. 0 when wear is off. */ + public static double budget(Artifact artifact) { + if (artifact == null || !enabled()) { + return 0.0; + } + double highest = 0.0; + for (String elementId : artifact.getCappedElementIds()) { + highest = Math.max(highest, artifact.getCap(elementId)); + } + return highest * Cache.artifactWearMultiplier; + } + + /** Share of the budget left, 0-1. Always 1 when wear is off. */ + public static double integrity(ItemStack stack, Artifact artifact) { + double budget = budget(artifact); + if (budget <= 0.0) { + return 1.0; + } + return Math.max(0.0, Math.min(1.0, 1.0 - readWear(stack) / budget)); + } + + /** + * Adds {@code amount} of wear to the artifact. Crumbles it in place when that reaches + * its budget. The caller writes the stack back to wherever it lives. + */ + public static Result wear(ItemStack stack, double amount) { + if (amount <= 0.0 || !enabled()) { + return Result.NONE; + } + Artifact artifact = Artifact.fromItem(stack); + if (artifact == null) { + return Result.NONE; + } + double budget = budget(artifact); + double next = readWear(stack) + amount; + if (next + EPSILON >= budget) { + crumble(stack); + return Result.CRUMBLED; + } + ItemMeta meta = stack.getItemMeta(); + meta.getPersistentDataContainer().set(ArtifactKeys.artifactWear(), PersistentDataType.DOUBLE, next); + stack.setItemMeta(meta); + ArtifactLore.refreshAttune(stack); + return Result.WORN; + } + + public static boolean isHusk(ItemStack stack) { + if (stack == null || stack.getType().isAir()) { + return false; + } + return stack.getItemMeta().getPersistentDataContainer().has( + ArtifactKeys.artifactSpent(), PersistentDataType.STRING); + } + + /** + * Turns the artifact into a spent husk. Its aura, care state, users and id are removed; + * rarity, primary element and sacrifice history stay, so the husk still shows what it + * was and the Recycler can price it. + */ + public static void crumble(ItemStack stack) { + if (stack == null || stack.getType().isAir()) { + return; + } + ItemMeta meta = stack.getItemMeta(); + PersistentDataContainer root = meta.getPersistentDataContainer(); + String id = root.get(ArtifactKeys.artifactId(), PersistentDataType.STRING); + for (NamespacedKey key : new NamespacedKey[] { + ArtifactKeys.auraCap(), + ArtifactKeys.auraFill(), + ArtifactKeys.auraData(), + ArtifactKeys.careUsers(), + ArtifactKeys.careMuffle(), + ArtifactKeys.careLastTick(), + ArtifactKeys.attuneStart(), + ArtifactKeys.attuneCount(), + ArtifactKeys.artifactId(), + ArtifactKeys.artifactWear()}) { + root.remove(key); + } + root.set(ArtifactKeys.artifactSpent(), PersistentDataType.STRING, id == null || id.isBlank() ? "-" : id); + stack.setItemMeta(meta); + ArtifactLore.applyHusk(stack); + } +} diff --git a/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java b/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java index 69065a5..a3bf488 100644 --- a/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java @@ -103,7 +103,11 @@ public boolean loadSafe(File configFile) { if (auraCap > 0) { Cache.artifactAuraCap = auraCap; } + double wear = artifacts.getDouble( + "wear.budget_multiplier", Cache.DEFAULT_ARTIFACT_WEAR_MULTIPLIER); + Cache.artifactWearMultiplier = Math.max(0.0, wear); } else { + Cache.artifactWearMultiplier = Cache.DEFAULT_ARTIFACT_WEAR_MULTIPLIER; Cache.artifactGlint = config.getBoolean("artifacts.glint", Cache.artifactGlint); double auraCap = config.getDouble("artifacts.aura_cap", Cache.artifactAuraCap); if (auraCap > 0) { diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationCircle.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationCircle.java index c1b1047..5579692 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationCircle.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationCircle.java @@ -25,6 +25,8 @@ import net.tfminecraft.magic.artifact.ArtifactCareStore; import net.tfminecraft.magic.artifact.ArtifactIds; import net.tfminecraft.magic.artifact.ArtifactLore; +import net.tfminecraft.magic.artifact.ArtifactWear; +import net.tfminecraft.magic.attunement.AttunementCaptureService; import net.tfminecraft.magic.model.ElementDef; import net.tfminecraft.magic.registry.ElementRegistry; import net.tfminecraft.magic.session.ResonanceSession; @@ -173,6 +175,43 @@ public ItemStack itemOn(Furniture furniture) { return items.isEmpty() ? null : items.get(0); } + /** + * Wears the artifact with this id on the pedestal by {@code gain}. A crumbled artifact + * becomes a husk in the same slot. Returns the artifact's name as it was before. + */ + public WearOutcome wearArtifact(Furniture furniture, String artifactId, double gain) { + if (furniture != null && artifactId != null) { + for (PlacedSlot slot : pedestalSlots(furniture)) { + ItemStack item = itemFromSlot(slot); + UUID id = ArtifactIds.read(item); + if (id == null || !artifactId.equals(id.toString())) { + continue; + } + String name = AttunementCaptureService.displayNameOf(item); + ArtifactWear.Result result = ArtifactWear.wear(item, gain); + if (result != ArtifactWear.Result.NONE) { + slot.setCurrentItem(item); + } + return new WearOutcome(result, name); + } + } + return new WearOutcome(ArtifactWear.Result.NONE, ""); + } + + public record WearOutcome(ArtifactWear.Result result, String name) {} + + /** Saves the pedestal's items through InteractibleFurniture. */ + public static void persist(Furniture furniture) { + if (furniture == null) { + return; + } + try { + InteractibleFurniture.getInstance().getFurnitureManager().persistFurniture(furniture); + } catch (Exception ignored) { + // IF may already be disabled + } + } + public String artifactIdOn(Furniture furniture) { UUID id = ArtifactIds.read(itemOn(furniture)); return id != null ? id.toString() : null; diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java index e91f6e9..2b608ad 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java @@ -3,8 +3,10 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import net.tfminecraft.tlibs.character.CharacterSession; @@ -20,7 +22,10 @@ import net.tfminecraft.interactiblefurniture.furniture.Furniture; import net.tfminecraft.magic.GuiCache; +import net.tfminecraft.magic.Messages; +import net.tfminecraft.magic.artifact.ArtifactWear; import net.tfminecraft.magic.attunement.AttunementCaptureService; +import net.tfminecraft.magic.attunement.AuraLog; import net.tfminecraft.magic.model.ElementDef; import net.tfminecraft.magic.profile.MagicProfileService; import net.tfminecraft.magic.registry.ElementRegistry; @@ -53,6 +58,7 @@ public enum Phase { private final List orbs = new ArrayList<>(); private final Map attunedByArtifact = new HashMap<>(); private final Map furnitureById = new HashMap<>(); + private final Set worn = new LinkedHashSet<>(); private boolean windingDown; public MeditationSession( @@ -218,6 +224,10 @@ public void end() { } orbs.clear(); windingDown = false; + for (Furniture furniture : worn) { + MeditationCircle.persist(furniture); + } + worn.clear(); } public void playComplete(Player player) { @@ -514,6 +524,7 @@ private void applyRewards( } attunedByArtifact.put(artifactId, credit + gain); AttunementCaptureService.credit(player, session, artifactId, artifact.elementId, gain); + wear(player, furniture, artifactId, gain); if (MeditationCeiling.allowed( session.getResonance(artifact.elementId), yield.elementShare(artifact.elementId), @@ -524,6 +535,33 @@ private void applyRewards( save(player, profiles); } + /** + * Every point of resonance an artifact grants wears it. A crumbled artifact is closed + * for the rest of the sit and its pedestal is saved straight away; worn ones are saved + * when the sit ends. + */ + private void wear(Player player, Furniture furniture, String artifactId, double gain) { + MeditationCircle.WearOutcome outcome = circle.wearArtifact(furniture, artifactId, gain); + if (outcome == null || outcome.result() == ArtifactWear.Result.NONE) { + return; + } + if (outcome.result() == ArtifactWear.Result.WORN) { + worn.add(furniture); + return; + } + worn.remove(furniture); + MeditationCircle.persist(furniture); + attunedByArtifact.put(artifactId, yield.sessionCap(artifactId)); + AuraLog.append("crumbled player=%s artifact=%s", player.getName(), artifactId); + String name = outcome.name().isBlank() ? "An artifact" : outcome.name(); + player.sendMessage(Messages.get("meditation.crumbled", "artifact", name)); + Location loc = PedestalFx.artifactPoint(furniture); + if (loc != null) { + loc.getWorld().playSound(loc, Sound.BLOCK_GLASS_BREAK, 1.0f, 0.6f); + loc.getWorld().spawnParticle(Particle.ASH, loc, 30, 0.2, 0.3, 0.2, 0.01); + } + } + private static void save(Player player, MagicProfileService profiles) { if (profiles != null) { profiles.savePlayer(player); diff --git a/src/main/resources/artifacts/adjectives.yml b/src/main/resources/artifacts/adjectives.yml index 2aeecb1..02abf51 100644 --- a/src/main/resources/artifacts/adjectives.yml +++ b/src/main/resources/artifacts/adjectives.yml @@ -2,7 +2,6 @@ chance: common: 1.0 uncommon: 1.0 rare: 0.5 - epic: 0.25 legendary: 0.15 global: @@ -17,13 +16,12 @@ global: rare: - Steady - Keen - epic: - - Vivid - - Prime legendary: - True - Unbound - Ancient + - Vivid + - Prime by_element: arcanum: @@ -37,12 +35,11 @@ by_element: rare: - Lucid - Unsealed - epic: - - Living - - Prime legendary: - Primordial - Absolute + - Living + - Prime oseni: common: - Emberless @@ -53,12 +50,11 @@ by_element: rare: - Searing - Molten - epic: - - Incandescent - - Blazing legendary: - Primordial - Solar + - Incandescent + - Blazing cerrith: common: - Wilted @@ -69,12 +65,11 @@ by_element: rare: - Verdant - Sylvan - epic: - - Canopy - - Primeval legendary: - Mythic - Eldest + - Canopy + - Primeval seithr: common: - Windless @@ -85,12 +80,11 @@ by_element: rare: - Howling - Aerial - epic: - - Tempestuous - - Roaring legendary: - Empyrean - Unbound + - Tempestuous + - Roaring mitlan: common: - Brackish @@ -101,12 +95,11 @@ by_element: rare: - Surging - Pelagic - epic: - - Abyssal - - Flooding legendary: - Primordial - Oceanic + - Abyssal + - Flooding bloodmagic: common: - Dry @@ -117,12 +110,11 @@ by_element: rare: - Vital - Crimson - epic: - - Living - - Prime legendary: - Primordial - Sovereign + - Living + - Prime necromancy: common: - Quiet @@ -133,12 +125,11 @@ by_element: rare: - Sepulchral - Dire - epic: - - Dread - - Sovereign legendary: - Primordial - Absolute + - Dread + - Sovereign shadowmancy: common: - Grey @@ -149,12 +140,11 @@ by_element: rare: - Tenebrous - Deep - epic: - - Stygian - - Prime legendary: - Absolute - Unbound + - Stygian + - Prime illusion: common: - Paper @@ -165,12 +155,11 @@ by_element: rare: - Glamoured - Deceptive - epic: - - Oneiric - - Perfect legendary: - Absolute - Unbound + - Oneiric + - Perfect spirit: common: - Faint @@ -181,9 +170,8 @@ by_element: rare: - Numinous - Lucid - epic: - - Living - - Transcendent legendary: - Primordial - Absolute + - Living + - Transcendent diff --git a/src/main/resources/artifacts/generator.yml b/src/main/resources/artifacts/generator.yml index 01af220..522e4ac 100644 --- a/src/main/resources/artifacts/generator.yml +++ b/src/main/resources/artifacts/generator.yml @@ -21,21 +21,15 @@ rarities: rare: name: Rare color: "#5b6fd4" - weight: 10 + weight: 12 elements: 1-2 - aura_cap: "46-65" - epic: - name: Epic - color: "#9b4dc4" - weight: 4 - elements: 1-3 - aura_cap: "66-90" + aura_cap: "46-75" legendary: name: Legendary color: "#d4a017" - weight: 1 + weight: 3 elements: 1-4 - aura_cap: "91-150" + aura_cap: "76-150" affinity: groups: @@ -86,20 +80,17 @@ types: aura_cap: common: "10-25" uncommon: "26-45" - rare: "46-65" - epic: "66-90" - legendary: "91-150" + rare: "46-75" + legendary: "76-150" secondary_cap: common: "0-18" uncommon: "18-32" - rare: "32-48" - epic: "48-70" - legendary: "70-110" + rare: "32-58" + legendary: "59-110" rarities: common: {} uncommon: {} rare: {} - epic: {} legendary: {} seithr: disable: false @@ -109,20 +100,17 @@ types: aura_cap: common: "10-25" uncommon: "26-45" - rare: "46-65" - epic: "66-90" - legendary: "91-150" + rare: "46-75" + legendary: "76-150" secondary_cap: common: "0-18" uncommon: "18-32" - rare: "32-48" - epic: "48-70" - legendary: "70-110" + rare: "32-58" + legendary: "59-110" rarities: common: {} uncommon: {} rare: {} - epic: {} legendary: {} oseni: disable: false @@ -132,20 +120,17 @@ types: aura_cap: common: "10-25" uncommon: "26-45" - rare: "46-65" - epic: "66-90" - legendary: "91-150" + rare: "46-75" + legendary: "76-150" secondary_cap: common: "0-18" uncommon: "18-32" - rare: "32-48" - epic: "48-70" - legendary: "70-110" + rare: "32-58" + legendary: "59-110" rarities: common: {} uncommon: {} rare: {} - epic: {} legendary: {} mitlan: disable: false @@ -155,20 +140,17 @@ types: aura_cap: common: "10-25" uncommon: "26-45" - rare: "46-65" - epic: "66-90" - legendary: "91-150" + rare: "46-75" + legendary: "76-150" secondary_cap: common: "0-18" uncommon: "18-32" - rare: "32-48" - epic: "48-70" - legendary: "70-110" + rare: "32-58" + legendary: "59-110" rarities: common: {} uncommon: {} rare: {} - epic: {} legendary: {} necromancy: disable: false @@ -178,20 +160,17 @@ types: aura_cap: common: "3-10" uncommon: "10-18" - rare: "18-30" - epic: "30-48" - legendary: "48-70" + rare: "18-39" + legendary: "39-70" secondary_cap: common: "2-5" uncommon: "4-8" - rare: "6-12" - epic: "10-16" - legendary: "12-22" + rare: "6-13" + legendary: "13-22" rarities: common: {} uncommon: {} rare: {} - epic: {} legendary: {} shadowmancy: disable: false @@ -201,20 +180,17 @@ types: aura_cap: common: "2-7" uncommon: "7-14" - rare: "14-24" - epic: "24-40" - legendary: "40-60" + rare: "14-32" + legendary: "32-60" secondary_cap: common: "1-3" uncommon: "2-5" rare: "3-6" - epic: "4-7" legendary: "5-10" rarities: common: {} uncommon: {} rare: {} - epic: {} legendary: {} bloodmagic: disable: false @@ -224,20 +200,17 @@ types: aura_cap: common: "4-12" uncommon: "12-22" - rare: "22-36" - epic: "36-55" - legendary: "55-80" + rare: "22-46" + legendary: "46-80" secondary_cap: common: "2-6" uncommon: "5-10" - rare: "8-14" - epic: "12-20" + rare: "8-16" legendary: "16-28" rarities: common: {} uncommon: {} rare: {} - epic: {} legendary: {} arcanum: disable: false @@ -247,20 +220,17 @@ types: aura_cap: common: "1-2" uncommon: "2-4" - rare: "4-8" - epic: "8-14" - legendary: "14-24" + rare: "4-11" + legendary: "11-24" secondary_cap: common: "1-1" uncommon: "1-2" rare: "1-2" - epic: "1-3" legendary: "1-3" rarities: common: {} uncommon: {} rare: {} - epic: {} legendary: {} spirit: disable: false @@ -270,20 +240,17 @@ types: aura_cap: common: "2-6" uncommon: "6-12" - rare: "12-22" - epic: "22-36" - legendary: "36-55" + rare: "12-29" + legendary: "29-55" secondary_cap: common: "1-3" uncommon: "2-4" rare: "3-5" - epic: "4-6" legendary: "5-8" rarities: common: {} uncommon: {} rare: {} - epic: {} legendary: {} illusion: disable: false @@ -293,18 +260,15 @@ types: aura_cap: common: "2-7" uncommon: "7-14" - rare: "14-24" - epic: "24-40" - legendary: "40-60" + rare: "14-32" + legendary: "32-60" secondary_cap: common: "1-3" uncommon: "2-5" rare: "3-6" - epic: "4-7" legendary: "5-10" rarities: common: {} uncommon: {} rare: {} - epic: {} legendary: {} diff --git a/src/main/resources/artifacts/model-schemes.yml b/src/main/resources/artifacts/model-schemes.yml index ca0bdc7..80fff56 100644 --- a/src/main/resources/artifacts/model-schemes.yml +++ b/src/main/resources/artifacts/model-schemes.yml @@ -1,7 +1,7 @@ oseni: models: - orb(ia.tfmc_magic:oseni_orb_0) all - - orb(ia.tfmc_magic:oseni_orb_11) epic-legendary + - orb(ia.tfmc_magic:oseni_orb_11) legendary - brooch(ia.tfmc_magic:oseni_brooch_13) all - brooch(ia.tfmc_magic:oseni_brooch_15) all - book(ia.tfmc_magic:oseni_book_20) all @@ -37,14 +37,14 @@ cerrith: - brooch(ia.tfmc_magic:cerrith_brooch_16) rare-legendary - book(ia.tfmc_magic:cerrith_book_23) all - book(ia.tfmc_magic:cerrith_book_43) all - - book(ia.tfmc_magic:cerrith_book_56) epic-legendary + - book(ia.tfmc_magic:cerrith_book_56) legendary - ring(ia.tfmc_magic:cerrith_ring_149) all - - ring(ia.tfmc_magic:cerrith_ring_174) epic-legendary + - ring(ia.tfmc_magic:cerrith_ring_174) legendary - bracelet(ia.tfmc_magic:cerrith_bracelet_154) all - amulet(ia.tfmc_magic:cerrith_amulet_158) all - jar(ia.tfmc_magic:cerrith_jar_116) all - gem(ia.tfmc_magic:cerrith_gem_165) all - - gem(ia.tfmc_magic:cerrith_gem_185) epic-legendary + - gem(ia.tfmc_magic:cerrith_gem_185) legendary - brooch(ia.tfmc_magic:shared_brooch_6) all - brooch(ia.tfmc_magic:shared_brooch_17) all - book(ia.tfmc_magic:shared_book_37) common-uncommon @@ -58,8 +58,8 @@ seithr: models: - orb(ia.tfmc_magic:seithr_orb_2) all - orb(ia.tfmc_magic:seithr_orb_10) all - - orb(ia.tfmc_magic:seithr_orb_5) epic-legendary - - orb(ia.tfmc_magic:seithr_orb_9) epic-legendary + - orb(ia.tfmc_magic:seithr_orb_5) legendary + - orb(ia.tfmc_magic:seithr_orb_9) legendary - book(ia.tfmc_magic:seithr_book_21) all - book(ia.tfmc_magic:seithr_book_24) all - book(ia.tfmc_magic:seithr_book_26) all @@ -67,7 +67,7 @@ seithr: - book(ia.tfmc_magic:seithr_book_44) all - ring(ia.tfmc_magic:seithr_ring_148) all - ring(ia.tfmc_magic:seithr_ring_168) all - - ring(ia.tfmc_magic:seithr_ring_173) epic-legendary + - ring(ia.tfmc_magic:seithr_ring_173) legendary - bracelet(ia.tfmc_magic:seithr_bracelet_153) all - amulet(ia.tfmc_magic:seithr_amulet_157) all - necklace(ia.tfmc_magic:seithr_necklace_198) all @@ -84,13 +84,13 @@ seithr: mitlan: models: - orb(ia.tfmc_magic:seithr_orb_10) all - - orb(ia.tfmc_magic:seithr_orb_9) epic-legendary + - orb(ia.tfmc_magic:seithr_orb_9) legendary - book(ia.tfmc_magic:seithr_book_21) all - book(ia.tfmc_magic:mitlan_book_41) all - - book(ia.tfmc_magic:mitlan_book_55) epic-legendary + - book(ia.tfmc_magic:mitlan_book_55) legendary - ring(ia.tfmc_magic:seithr_ring_148) all - ring(ia.tfmc_magic:seithr_ring_168) all - - ring(ia.tfmc_magic:seithr_ring_173) epic-legendary + - ring(ia.tfmc_magic:seithr_ring_173) legendary - bracelet(ia.tfmc_magic:seithr_bracelet_153) all - amulet(ia.tfmc_magic:seithr_amulet_157) all - necklace(ia.tfmc_magic:seithr_necklace_198) all @@ -113,8 +113,8 @@ arcanum: - orb(ia.tfmc_magic:oseni_orb_11) legendary - book(ia.tfmc_magic:arcanum_book_22) all - book(ia.tfmc_magic:arcanum_book_42) all - - book(ia.tfmc_magic:arcanum_book_29) epic-legendary - - book(ia.tfmc_magic:arcanum_book_30) epic-legendary + - book(ia.tfmc_magic:arcanum_book_29) legendary + - book(ia.tfmc_magic:arcanum_book_30) legendary - ring(ia.tfmc_magic:arcanum_ring_150) all - ring(ia.tfmc_magic:arcanum_ring_170) all - ring(ia.tfmc_magic:arcanum_ring_175) all @@ -122,7 +122,7 @@ arcanum: - bracelet(ia.tfmc_magic:arcanum_bracelet_155) all - amulet(ia.tfmc_magic:oseni_amulet_159) all - amulet(ia.tfmc_magic:oseni_amulet_177) all - - amulet(ia.tfmc_magic:arcanum_amulet_178) epic-legendary + - amulet(ia.tfmc_magic:arcanum_amulet_178) legendary - gem(ia.tfmc_magic:arcanum_gem_181) all - gem(ia.tfmc_magic:arcanum_gem_180) all - gem(ia.tfmc_magic:arcanum_gem_141) all @@ -178,10 +178,10 @@ necromancy: - book(ia.tfmc_magic:shared_book_37) common-uncommon - book(ia.tfmc_magic:seithr_book_44) all - book(ia.tfmc_magic:necromancy_book_45) all - - book(ia.tfmc_magic:necromancy_book_28) epic-legendary + - book(ia.tfmc_magic:necromancy_book_28) legendary - ring(ia.tfmc_magic:oseni_ring_166) all - ring(ia.tfmc_magic:oseni_ring_171) all - - necklace(ia.tfmc_magic:necromancy_necklace_196) epic-legendary + - necklace(ia.tfmc_magic:necromancy_necklace_196) legendary - brooch(ia.tfmc_magic:shared_brooch_6) all - brooch(ia.tfmc_magic:shared_brooch_17) all - manuscript(ia.tfmc_magic:manuscript_47) all @@ -194,7 +194,7 @@ shadowmancy: models: - orb(ia.tfmc_magic:necromancy_orb_12) all - orb(ia.tfmc_magic:arcanum_orb_3) all - - orb(ia.tfmc_magic:arcanum_orb_4) epic-legendary + - orb(ia.tfmc_magic:arcanum_orb_4) legendary - eye_orb(ia.tfmc_magic:bloodmagic_eye_orb_18) all - eye_orb(ia.tfmc_magic:bloodmagic_eye_orb_19) all - book(ia.tfmc_magic:cerrith_book_23) all @@ -202,12 +202,12 @@ shadowmancy: - book(ia.tfmc_magic:shared_book_37) common-uncommon - book(ia.tfmc_magic:shadowmancy_book_39) all - book(ia.tfmc_magic:necromancy_book_45) all - - book(ia.tfmc_magic:shadowmancy_book_57) epic-legendary + - book(ia.tfmc_magic:shadowmancy_book_57) legendary - jar(ia.tfmc_magic:shadowmancy_jar_115) all - jar(ia.tfmc_magic:shadowmancy_jar_114) all - ring(ia.tfmc_magic:seithr_ring_148) all - amulet(ia.tfmc_magic:oseni_amulet_195) all - - necklace(ia.tfmc_magic:necromancy_necklace_196) epic-legendary + - necklace(ia.tfmc_magic:necromancy_necklace_196) legendary - brooch(ia.tfmc_magic:shared_brooch_6) all - brooch(ia.tfmc_magic:shared_brooch_17) all - manuscript(ia.tfmc_magic:manuscript_47) all @@ -226,7 +226,7 @@ spirit: - ring(ia.tfmc_magic:seithr_ring_173) all - amulet(ia.tfmc_magic:seithr_amulet_157) all - amulet(ia.tfmc_magic:spirit_amulet_176) all - - amulet(ia.tfmc_magic:arcanum_amulet_178) epic-legendary + - amulet(ia.tfmc_magic:arcanum_amulet_178) legendary - necklace(ia.tfmc_magic:spirit_necklace_199) all - gem(ia.tfmc_magic:seithr_gem_163) all - brooch(ia.tfmc_magic:shared_brooch_6) all @@ -241,11 +241,11 @@ spirit: illusion: models: - orb(ia.tfmc_magic:seithr_orb_2) all - - orb(ia.tfmc_magic:arcanum_orb_4) epic-legendary + - orb(ia.tfmc_magic:arcanum_orb_4) legendary - book(ia.tfmc_magic:seithr_book_26) all - book(ia.tfmc_magic:shadowmancy_book_39) all - book(ia.tfmc_magic:seithr_book_46) all - - book(ia.tfmc_magic:illusion_book_59) epic-legendary + - book(ia.tfmc_magic:illusion_book_59) legendary - jar(ia.tfmc_magic:spirit_jar_106) all - ring(ia.tfmc_magic:seithr_ring_173) all - bracelet(ia.tfmc_magic:arcanum_bracelet_155) all diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index b4f547e..05dfae7 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -53,6 +53,12 @@ artifacts: glint: true # Hard ceiling per element. Rarity aura_cap ranges in generator.yml cannot exceed this. aura_cap: 150 + # Every point of resonance an artifact grants in meditation wears it, whoever gets it. + # It can grant budget_multiplier x its highest aura cap in total, then it crumbles into + # a spent husk with no aura that only the Recycler takes. 0 turns wear off. + # 2.0: a 20-cap common can raise one character from 0 to 20 twice. + wear: + budget_multiplier: 2.0 meditation: pedestal: pedestal diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 3ab71df..f5b1c91 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -117,6 +117,12 @@ resonance: artifact: + husk: + + name: "#8a8a8aSpent {name}" + + lore: "#8a8a8a§oIts aura is spent. Only a recycler has a use for it now." + usage: "#aaaaaaUsage: /magic artifact roll #555555| #aaaaaa/magic artifact give #555555| #aaaaaa/magic artifact create #555555| #aaaaaa/magic artifact path #555555| #aaaaaa/magic artifact setfill #555555| #aaaaaa/magic artifact setmuffle #555555| #aaaaaa/magic artifact unmuffle" path: @@ -225,6 +231,8 @@ meditation: stop: "#aaaaaa§oYou stop meditating" + crumbled: "#aaaaaa§o{artifact} crumbles, its aura spent" + charge: imprint: diff --git a/src/test/java/net/tfminecraft/magic/ConfigurationTest.java b/src/test/java/net/tfminecraft/magic/ConfigurationTest.java index 97c9499..910d023 100644 --- a/src/test/java/net/tfminecraft/magic/ConfigurationTest.java +++ b/src/test/java/net/tfminecraft/magic/ConfigurationTest.java @@ -253,6 +253,29 @@ void runtimeConfigValidatesBoundsAndSupportsLegacyFields() throws Exception { loader.load(Path.of("src/main/resources/config.yml").toFile()); } + @Test + void artifactWearReadsItsMultiplierAndFallsBackToTheDefault() throws Exception { + var loader = new ConfigLoader(); + try { + assertTrue( + loader.loadSafe( + write("wear.yml", "artifacts: {wear: {budget_multiplier: 3.5}}\n").toFile())); + assertEquals(3.5, Cache.artifactWearMultiplier); + assertTrue( + loader.loadSafe( + write("negative.yml", "artifacts: {wear: {budget_multiplier: -1}}\n").toFile())); + assertEquals(0, Cache.artifactWearMultiplier); + assertTrue(loader.loadSafe(write("unset.yml", "artifacts: {glint: false}\n").toFile())); + assertEquals(Cache.DEFAULT_ARTIFACT_WEAR_MULTIPLIER, Cache.artifactWearMultiplier); + Cache.artifactWearMultiplier = 0; + assertTrue(loader.loadSafe(write("none.yml", "debug: false\n").toFile())); + assertEquals(Cache.DEFAULT_ARTIFACT_WEAR_MULTIPLIER, Cache.artifactWearMultiplier); + } finally { + Cache.artifactWearMultiplier = Cache.DEFAULT_ARTIFACT_WEAR_MULTIPLIER; + Cache.artifactGlint = true; + } + } + @Test void attunementWithoutMuffledSectionDisablesMuffling() throws Exception { assertTrue( diff --git a/src/test/java/net/tfminecraft/magic/MeditationCircleTest.java b/src/test/java/net/tfminecraft/magic/MeditationCircleTest.java index b25213e..8bc4fde 100644 --- a/src/test/java/net/tfminecraft/magic/MeditationCircleTest.java +++ b/src/test/java/net/tfminecraft/magic/MeditationCircleTest.java @@ -105,6 +105,50 @@ void ring() { } } + @Test + void wearFindsTheArtifactByIdWritesItBackAndSavesThroughFurniture() { + ring(); + var item = artifact(); + when(slots.getFirst().getCurrentItem()).thenReturn(item); + when(slots.get(1).getCurrentItem()).thenReturn(new ItemStack(Material.STONE)); + var circle = MeditationCircle.detect(center); + String id = circle.artifactIdOn(furniture.getFirst()); + try { + Cache.artifactWearMultiplier = 2; + assertEquals(ArtifactWear.Result.NONE, circle.wearArtifact(null, id, 1).result()); + assertEquals( + ArtifactWear.Result.NONE, circle.wearArtifact(furniture.getFirst(), null, 1).result()); + assertEquals( + ArtifactWear.Result.NONE, + circle.wearArtifact(furniture.getFirst(), "other", 1).result()); + assertEquals( + ArtifactWear.Result.NONE, circle.wearArtifact(furniture.get(1), id, 1).result()); + verify(slots.getFirst(), never()).setCurrentItem(any()); + var worn = circle.wearArtifact(furniture.getFirst(), id, 1); + assertEquals(ArtifactWear.Result.WORN, worn.result()); + assertEquals("", worn.name()); + verify(slots.getFirst()).setCurrentItem(item); + Cache.artifactWearMultiplier = 0; + assertEquals( + ArtifactWear.Result.NONE, circle.wearArtifact(furniture.getFirst(), id, 1).result()); + verify(slots.getFirst()).setCurrentItem(item); + Cache.artifactWearMultiplier = 2; + assertEquals( + ArtifactWear.Result.CRUMBLED, + circle.wearArtifact(furniture.getFirst(), id, 50).result()); + verify(slots.getFirst(), times(2)).setCurrentItem(item); + assertTrue(ArtifactWear.isHusk(item)); + assertNull(circle.artifactIdOn(furniture.getFirst())); + } finally { + Cache.artifactWearMultiplier = Cache.DEFAULT_ARTIFACT_WEAR_MULTIPLIER; + } + MeditationCircle.persist(null); + MeditationCircle.persist(furniture.getFirst()); + verify(dependency.getFurnitureManager()).persistFurniture(furniture.getFirst()); + when(dependency.getFurnitureManager()).thenThrow(new IllegalStateException("disabled")); + MeditationCircle.persist(furniture.getFirst()); + } + @Test void lonePreviewUserGetsFullYieldInsteadOfBeingCountedTwice() { ring(); diff --git a/src/test/java/net/tfminecraft/magic/MeditationSessionTest.java b/src/test/java/net/tfminecraft/magic/MeditationSessionTest.java index bbd76c5..bdcf2c9 100644 --- a/src/test/java/net/tfminecraft/magic/MeditationSessionTest.java +++ b/src/test/java/net/tfminecraft/magic/MeditationSessionTest.java @@ -5,6 +5,7 @@ import java.util.*; import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.magic.artifact.ArtifactWear; import net.tfminecraft.magic.attunement.AuraLog; import net.tfminecraft.magic.meditation.*; import net.tfminecraft.magic.profile.MagicProfileService; @@ -152,6 +153,87 @@ void exhaustedElementsNeverSpawnAndCloseWhenResonanceReachesCeiling() { partial.end(); } + MeditationCircle.WearOutcome outcome(ArtifactWear.Result result) { + return new MeditationCircle.WearOutcome(result, "Ember Ring"); + } + + MeditationOrb nextOrb(MeditationSession session) { + for (int i = 0; i < 200 && session.livingOrbs().isEmpty(); i++) session.tick(player, resonance); + return session.livingOrbs().getFirst(); + } + + void loadMessages() { + Magic.plugin = mock(Magic.class); + when(Magic.plugin.getResource("messages.yml")) + .thenReturn(getClass().getResourceAsStream("/messages.yml")); + Messages.loadFromResources(); + } + + boolean heard(String text) { + String message; + while ((message = player.nextMessage()) != null) { + if (org.bukkit.ChatColor.stripColor(message).contains(text)) return true; + } + return false; + } + + @Test + void everyGainWearsItsArtifactAndACrumbledOneClosesForTheRestOfTheSit() { + loadMessages(); + try { + when(circle.wearArtifact(eq(post), eq("artifact"), anyDouble())) + .thenReturn(outcome(ArtifactWear.Result.NONE)) + .thenReturn(outcome(ArtifactWear.Result.WORN)) + .thenReturn(outcome(ArtifactWear.Result.CRUMBLED)); + var session = session(10); + activate(session); + assertFalse(session.onOrbHit(player, nextOrb(session), resonance, null)); + assertFalse(session.onOrbHit(player, nextOrb(session), resonance, null)); + verify(circle, times(2)).wearArtifact(post, "artifact", 1.0); + assertFalse(heard("crumbles")); + assertTrue(session.onOrbHit(player, nextOrb(session), resonance, null)); + assertTrue(heard("Ember Ring crumbles, its aura spent")); + assertEquals(3, resonance.getResonance("fire")); + for (int i = 0; i < 100; i++) session.tick(player, resonance); + assertTrue(session.livingOrbs().isEmpty()); + session.end(); + session.end(); + } finally { + Magic.plugin = null; + } + } + + @Test + void aCrumbleWithNoPedestalPointStillClosesWithoutEffects() { + when(circle.wearArtifact(eq(post), eq("artifact"), anyDouble())) + .thenReturn(outcome(ArtifactWear.Result.WORN)) + .thenReturn(new MeditationCircle.WearOutcome(ArtifactWear.Result.CRUMBLED, "")); + var session = session(10); + activate(session); + assertFalse(session.onOrbHit(player, nextOrb(session), resonance, null)); + var orb = nextOrb(session); + when(post.getOriginBlockLocation()).thenReturn(Optional.empty()); + when(post.getLoc()).thenReturn(null); + assertTrue(session.onOrbHit(player, orb, resonance, null)); + assertNotNull(player.nextMessage()); + session.end(); + } + + @Test + void wornArtifactsAreSavedOnceWhenTheSitEnds() { + when(circle.wearArtifact(eq(post), eq("artifact"), anyDouble())) + .thenReturn(outcome(ArtifactWear.Result.WORN)); + var session = session(10); + activate(session); + assertFalse(session.onOrbHit(player, nextOrb(session), resonance, null)); + assertFalse(session.onOrbHit(player, nextOrb(session), resonance, null)); + try (var persist = mockStatic(MeditationCircle.class)) { + session.end(); + session.end(); + persist.verify(() -> MeditationCircle.persist(post), times(1)); + } + } + @Test void orbExpirationAndWindDownReturnToPedestalWithoutReward() { var session = session(10); diff --git a/src/test/java/net/tfminecraft/magic/artifact/ArtifactWearTest.java b/src/test/java/net/tfminecraft/magic/artifact/ArtifactWearTest.java new file mode 100644 index 0000000..49c7216 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/artifact/ArtifactWearTest.java @@ -0,0 +1,218 @@ +package net.tfminecraft.magic.artifact; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.*; +import net.tfminecraft.magic.artifact.config.*; +import net.tfminecraft.magic.model.ElementDef; +import net.tfminecraft.magic.registry.ElementRegistry; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; + +class ArtifactWearTest { + @BeforeEach + void setup() { + MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.namespace()).thenReturn("magic"); + when(Magic.plugin.getResource("messages.yml")) + .thenReturn(getClass().getResourceAsStream("/messages.yml")); + Messages.loadFromResources(); + ElementRegistry.clear(); + ArtifactRarityRegistry.clear(); + ArtifactRarityRegistry.register( + new ArtifactRarityDef("rare", "Rare", null, 1, 1, 2, new CapRange(46, 75))); + var cfg = new YamlConfiguration(); + cfg.set("name", "Fire"); + ElementRegistry.register(new ElementDef("fire", cfg)); + var water = new YamlConfiguration(); + water.set("name", "Water"); + ElementRegistry.register(new ElementDef("water", water)); + Cache.artifactWearMultiplier = 2.0; + Cache.artifactGlint = true; + } + + @AfterEach + void cleanup() { + Cache.artifactWearMultiplier = Cache.DEFAULT_ARTIFACT_WEAR_MULTIPLIER; + Magic.plugin = null; + MockBukkit.unmock(); + ElementRegistry.clear(); + ArtifactRarityRegistry.clear(); + } + + ItemStack artifact() { + var item = new ItemStack(Material.STONE); + var meta = item.getItemMeta(); + meta.setDisplayName("§6Ember Ring"); + meta.setLore(List.of("Original lore")); + item.setItemMeta(meta); + var a = Artifact.create(); + a.setCap("fire", 10); + a.setFill("fire", 10); + a.setCap("water", 4); + a.setFill("water", 4); + a.persistPdc(item); + ArtifactIds.writeNew(item); + var withRarity = item.getItemMeta(); + withRarity.getPersistentDataContainer() + .set(ArtifactKeys.artifactRarity(), PersistentDataType.STRING, "rare"); + item.setItemMeta(withRarity); + ArtifactCareStore.stampUser(item, "someone", 1000); + ArtifactCareStore.setMuffle(item, .25, 1000); + ArtifactLore.apply(item); + return item; + } + + List plainLore(ItemStack item) { + return item.getItemMeta().getLore().stream().map(ChatColor::stripColor).toList(); + } + + void setWear(ItemStack item, double value) { + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(ArtifactKeys.artifactWear(), PersistentDataType.DOUBLE, value); + item.setItemMeta(meta); + } + + @Test + void budgetIsTheMultipleOfTheHighestCapAndWearOffIgnoresEverything() { + var item = artifact(); + var artifact = Artifact.fromItem(item); + assertTrue(ArtifactWear.enabled()); + assertEquals(20, ArtifactWear.budget(artifact)); + assertEquals(0, ArtifactWear.budget(null)); + assertEquals(1, ArtifactWear.integrity(item, artifact)); + assertEquals(0, ArtifactWear.budget(Artifact.create())); + assertEquals(1, ArtifactWear.integrity(item, Artifact.create())); + + Cache.artifactWearMultiplier = 0; + assertFalse(ArtifactWear.enabled()); + assertEquals(0, ArtifactWear.budget(artifact)); + assertEquals(1, ArtifactWear.integrity(item, artifact)); + assertEquals(ArtifactWear.Result.NONE, ArtifactWear.wear(item, 5)); + assertEquals(0, ArtifactWear.readWear(item)); + } + + @Test + void readWearIgnoresMissingAndBrokenValues() { + assertEquals(0, ArtifactWear.readWear(null)); + assertEquals(0, ArtifactWear.readWear(new ItemStack(Material.AIR))); + assertEquals(0, ArtifactWear.readWear(new ItemStack(Material.STONE))); + var item = artifact(); + assertEquals(0, ArtifactWear.readWear(item)); + setWear(item, -3); + assertEquals(0, ArtifactWear.readWear(item)); + setWear(item, Double.NaN); + assertEquals(0, ArtifactWear.readWear(item)); + setWear(item, 30); + assertEquals(30, ArtifactWear.readWear(item)); + assertEquals(0, ArtifactWear.integrity(item, Artifact.fromItem(item))); + } + + @Test + void wearAddsUpAndShowsIntegrityOnceWorn() { + var item = artifact(); + assertTrue(plainLore(item).stream().noneMatch(l -> l.startsWith("Integrity"))); + assertEquals(ArtifactWear.Result.NONE, ArtifactWear.wear(item, 0)); + assertEquals(ArtifactWear.Result.NONE, ArtifactWear.wear(new ItemStack(Material.STONE), 1)); + assertEquals(ArtifactWear.Result.WORN, ArtifactWear.wear(item, 5)); + assertEquals(ArtifactWear.Result.WORN, ArtifactWear.wear(item, 0.1)); + assertEquals(5.1, ArtifactWear.readWear(item), 1e-9); + assertEquals(.745, ArtifactWear.integrity(item, Artifact.fromItem(item)), 1e-9); + assertTrue(plainLore(item).contains("Integrity: 75%")); + assertTrue(plainLore(item).contains("Muffled: 25%")); + Cache.artifactWearMultiplier = 0; + ArtifactLore.apply(item); + assertTrue(plainLore(item).stream().noneMatch(l -> l.startsWith("Integrity"))); + Cache.artifactWearMultiplier = 2; + assertEquals("Integrity: 1%", ChatColor.stripColor(ArtifactLore.integrityLine(.001))); + assertEquals("Integrity: 0%", ChatColor.stripColor(ArtifactLore.integrityLine(-1))); + assertEquals("Integrity: 100%", ChatColor.stripColor(ArtifactLore.integrityLine(2))); + } + + @Test + void reachingTheBudgetCrumblesIntoARecyclableHusk() { + var item = artifact(); + var id = ArtifactIds.read(item); + setWear(item, 19.99995); + assertEquals(ArtifactWear.Result.CRUMBLED, ArtifactWear.wear(item, 0.00001)); + assertTrue(ArtifactWear.isHusk(item)); + assertNull(Artifact.fromItem(item)); + var pdc = item.getItemMeta().getPersistentDataContainer(); + assertEquals(id.toString(), pdc.get(ArtifactKeys.artifactSpent(), PersistentDataType.STRING)); + assertEquals("rare", pdc.get(ArtifactKeys.artifactRarity(), PersistentDataType.STRING)); + for (var key : + List.of( + ArtifactKeys.auraCap(), + ArtifactKeys.auraFill(), + ArtifactKeys.auraData(), + ArtifactKeys.careUsers(), + ArtifactKeys.careMuffle(), + ArtifactKeys.careLastTick(), + ArtifactKeys.attuneStart(), + ArtifactKeys.attuneCount(), + ArtifactKeys.artifactId(), + ArtifactKeys.artifactWear())) assertFalse(pdc.has(key), key.toString()); + var lore = plainLore(item); + assertEquals("Original lore", lore.getFirst()); + assertEquals("Rare", lore.get(1)); + assertEquals("Its aura is spent. Only a recycler has a use for it now.", lore.get(2)); + assertEquals(3, lore.size()); + assertEquals("Spent Ember Ring", ChatColor.stripColor(item.getItemMeta().getDisplayName())); + assertEquals(Boolean.FALSE, item.getItemMeta().getEnchantmentGlintOverride()); + assertEquals(ArtifactWear.Result.NONE, ArtifactWear.wear(item, 1)); + } + + @Test + void crumbleWithoutIdNameLoreOrRarityStillMarksTheHusk() { + ArtifactWear.crumble(null); + ArtifactWear.crumble(new ItemStack(Material.AIR)); + var bare = new ItemStack(Material.STONE); + var a = Artifact.create(); + a.setCap("fire", 3); + a.persistPdc(bare); + ArtifactWear.crumble(bare); + assertEquals( + "-", + bare.getItemMeta().getPersistentDataContainer() + .get(ArtifactKeys.artifactSpent(), PersistentDataType.STRING)); + assertEquals( + List.of("Its aura is spent. Only a recycler has a use for it now."), plainLore(bare)); + assertFalse(bare.getItemMeta().hasDisplayName()); + + var blankId = new ItemStack(Material.STONE); + a.persistPdc(blankId); + var meta = blankId.getItemMeta(); + meta.getPersistentDataContainer() + .set(ArtifactKeys.artifactId(), PersistentDataType.STRING, " "); + blankId.setItemMeta(meta); + ArtifactWear.crumble(blankId); + assertEquals( + "-", + blankId.getItemMeta().getPersistentDataContainer() + .get(ArtifactKeys.artifactSpent(), PersistentDataType.STRING)); + } + + @Test + void huskCheckIgnoresEmptyStacksAndHuskLoreIsNeverReadBack() { + assertFalse(ArtifactWear.isHusk(null)); + assertFalse(ArtifactWear.isHusk(new ItemStack(Material.AIR))); + assertFalse(ArtifactWear.isHusk(new ItemStack(Material.STONE))); + var item = new ItemStack(Material.STONE); + var meta = item.getItemMeta(); + meta.setLore(List.of("Fire 5 / 10")); + meta.getPersistentDataContainer() + .set(ArtifactKeys.artifactSpent(), PersistentDataType.STRING, "-"); + item.setItemMeta(meta); + assertTrue(ArtifactWear.isHusk(item)); + assertNull(Artifact.fromItem(item)); + } +} From df3cb484dbd4d20041ba0c2e945ed916202cb6c7 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:01:14 +0200 Subject: [PATCH 2/4] Default wear to one climb, save wear mid-sit, finish sits after a crumble budget_multiplier defaults to 1.0. Worn pedestals are saved at most every 5 s as well as at sit end, so a crash loses little wear. A sit whose artifact crumbled under another meditator now completes instead of hanging with no orbs. Co-Authored-By: Claude Opus 5.5 --- .../java/net/tfminecraft/magic/Cache.java | 2 +- .../magic/meditation/MeditationService.java | 15 +++++-- .../magic/meditation/MeditationSession.java | 43 ++++++++++++++++++- src/main/resources/config.yml | 4 +- .../magic/MeditationServiceTest.java | 9 +++- .../magic/MeditationSessionTest.java | 33 ++++++++++++-- 6 files changed, 92 insertions(+), 14 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/Cache.java b/src/main/java/net/tfminecraft/magic/Cache.java index e8fe53c..ba48a9b 100644 --- a/src/main/java/net/tfminecraft/magic/Cache.java +++ b/src/main/java/net/tfminecraft/magic/Cache.java @@ -28,7 +28,7 @@ public final class Cache { public static double secondsPerHour = 3600.0; public static boolean artifactGlint = true; public static double artifactAuraCap = 150.0; - public static final double DEFAULT_ARTIFACT_WEAR_MULTIPLIER = 2.0; + public static final double DEFAULT_ARTIFACT_WEAR_MULTIPLIER = 1.0; /** * Lifetime resonance an artifact can grant, as a multiple of its highest aura cap. * At that total it crumbles into a spent husk. 0 turns wear off. diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java index 57eaea0..bf5c522 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java @@ -130,6 +130,9 @@ private void tick() { continue; } session.tick(player, session.getResonance()); + if (session.isStalledFinished()) { + complete(player, session); + } } for (Player player : Bukkit.getOnlinePlayers()) { UUID id = player.getUniqueId(); @@ -255,13 +258,17 @@ private void tryHit(Player player) { ResonanceSession resonance = session.getResonance(); boolean complete = session.onOrbHit(player, orb, resonance, profileService); if (complete) { - session.playComplete(player); - player.sendMessage(Messages.get("meditation.complete")); - sitNotices.add(player.getUniqueId()); - session.beginWindDown(); + complete(player, session); } } + private void complete(Player player, MeditationSession session) { + session.playComplete(player); + player.sendMessage(Messages.get("meditation.complete")); + sitNotices.add(player.getUniqueId()); + session.beginWindDown(); + } + private MeditationOrb hitscan(Player player, MeditationSession session) { Location eye = player.getEyeLocation(); Vector dir = eye.getDirection().normalize(); diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java index 2b608ad..df1ccc7 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; @@ -40,6 +41,7 @@ public enum Phase { RUNNING } + static final long WEAR_SAVE_MS = 5000L; private static final Color FLOW_FALLBACK = Color.fromRGB(0x5bc4d4); private static final Color SURGE_FALLBACK = Color.fromRGB(0xe85d4a); @@ -59,6 +61,7 @@ public enum Phase { private final Map attunedByArtifact = new HashMap<>(); private final Map furnitureById = new HashMap<>(); private final Set worn = new LinkedHashSet<>(); + private long lastWearSaveMs; private boolean windingDown; public MeditationSession( @@ -147,9 +150,35 @@ public boolean onOrbHit(Player player, MeditationOrb orb, ResonanceSession sessi despawnOrb(orb); applyRewards(player, session, orb, profiles); playHitFx(player, orb); + return isFinished(); + } + + /** + * True when every artifact this sit drew from is spent, capped or gone from the ring. + * An artifact can crumble under another meditator mid-sit, so a missing one counts + * as spent here. + */ + public boolean isFinished() { + Set present = new HashSet<>(); + for (Furniture furniture : circle.getArtifactPedestals()) { + String artifactId = circle.artifactIdOn(furniture); + if (artifactId != null) { + present.add(artifactId); + } + } + for (String artifactId : yield.sessionCaps().keySet()) { + if (!present.contains(artifactId)) { + attunedByArtifact.put(artifactId, yield.sessionCap(artifactId)); + } + } return yield.exhausted(attunedByArtifact); } + /** A running sit with no orbs left and nothing to draw from, which no hit would end. */ + public boolean isStalledFinished() { + return phase == Phase.RUNNING && livingOrbs().isEmpty() && isFinished(); + } + public void tick(Player player, ResonanceSession session) { tickCount++; if (windingDown) { @@ -224,6 +253,10 @@ public void end() { } orbs.clear(); windingDown = false; + saveWorn(); + } + + private void saveWorn() { for (Furniture furniture : worn) { MeditationCircle.persist(furniture); } @@ -537,8 +570,9 @@ private void applyRewards( /** * Every point of resonance an artifact grants wears it. A crumbled artifact is closed - * for the rest of the sit and its pedestal is saved straight away; worn ones are saved - * when the sit ends. + * for the rest of the sit and its pedestal is saved straight away. Worn pedestals are + * saved at most every {@link #WEAR_SAVE_MS} and when the sit ends, so a crash loses + * only a few seconds of wear while resonance is saved on every hit. */ private void wear(Player player, Furniture furniture, String artifactId, double gain) { MeditationCircle.WearOutcome outcome = circle.wearArtifact(furniture, artifactId, gain); @@ -547,6 +581,11 @@ private void wear(Player player, Furniture furniture, String artifactId, double } if (outcome.result() == ArtifactWear.Result.WORN) { worn.add(furniture); + long now = System.currentTimeMillis(); + if (now - lastWearSaveMs >= WEAR_SAVE_MS) { + lastWearSaveMs = now; + saveWorn(); + } return; } worn.remove(furniture); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 05dfae7..4ebabd3 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -56,9 +56,9 @@ artifacts: # Every point of resonance an artifact grants in meditation wears it, whoever gets it. # It can grant budget_multiplier x its highest aura cap in total, then it crumbles into # a spent husk with no aura that only the Recycler takes. 0 turns wear off. - # 2.0: a 20-cap common can raise one character from 0 to 20 twice. + # 1.0: a 20-cap common can raise one character from 0 to 20 once, or two sharers to 10. wear: - budget_multiplier: 2.0 + budget_multiplier: 1.0 meditation: pedestal: pedestal diff --git a/src/test/java/net/tfminecraft/magic/MeditationServiceTest.java b/src/test/java/net/tfminecraft/magic/MeditationServiceTest.java index f575b7a..b1497ab 100644 --- a/src/test/java/net/tfminecraft/magic/MeditationServiceTest.java +++ b/src/test/java/net/tfminecraft/magic/MeditationServiceTest.java @@ -120,9 +120,16 @@ void sittingStartsTicksLocksAndLeavingPersistsAfterWindDown() { assertFalse(MeditationService.locksFurniture(other)); tick(); verify(session).tick(player, sessions.get(player)); - player.leaveVehicle(); + verify(session, never()).playComplete(player); + when(session.isStalledFinished()).thenReturn(true); tick(); + verify(session).playComplete(player); verify(session).beginWindDown(); + assertEquals("meditation.complete", player.nextMessage()); + when(session.isStalledFinished()).thenReturn(false); + player.leaveVehicle(); + tick(); + verify(session, times(2)).beginWindDown(); verify(session).end(); verify(profiles).savePlayer(player); assertEquals("meditation.stop", player.nextMessage()); diff --git a/src/test/java/net/tfminecraft/magic/MeditationSessionTest.java b/src/test/java/net/tfminecraft/magic/MeditationSessionTest.java index bdcf2c9..4c27ded 100644 --- a/src/test/java/net/tfminecraft/magic/MeditationSessionTest.java +++ b/src/test/java/net/tfminecraft/magic/MeditationSessionTest.java @@ -220,18 +220,43 @@ void aCrumbleWithNoPedestalPointStillClosesWithoutEffects() { } @Test - void wornArtifactsAreSavedOnceWhenTheSitEnds() { + void wornArtifactsAreSavedAtOnceThenThrottledAndAgainWhenTheSitEnds() { when(circle.wearArtifact(eq(post), eq("artifact"), anyDouble())) .thenReturn(outcome(ArtifactWear.Result.WORN)); var session = session(10); activate(session); - assertFalse(session.onOrbHit(player, nextOrb(session), resonance, null)); - assertFalse(session.onOrbHit(player, nextOrb(session), resonance, null)); try (var persist = mockStatic(MeditationCircle.class)) { + assertFalse(session.onOrbHit(player, nextOrb(session), resonance, null)); + persist.verify(() -> MeditationCircle.persist(post), times(1)); + assertFalse(session.onOrbHit(player, nextOrb(session), resonance, null)); + persist.verify(() -> MeditationCircle.persist(post), times(1)); session.end(); session.end(); - persist.verify(() -> MeditationCircle.persist(post), times(1)); + persist.verify(() -> MeditationCircle.persist(post), times(2)); + } + } + + @Test + void anArtifactCrumbledUnderAnotherMeditatorFinishesThisSit() { + var session = session(10); + assertFalse(session.isStalledFinished(), "Not running yet"); + activate(session); + assertFalse(session.isStalledFinished(), "Orbs are still alive"); + assertFalse(session.isFinished()); + when(circle.artifactIdOn(post)).thenReturn(null); + assertTrue(session.isFinished()); + for (int i = 0; i < 100 && !session.livingOrbs().isEmpty(); i++) session.tick(player, resonance); + assertTrue(session.isStalledFinished()); + when(circle.artifactIdOn(post)).thenReturn("artifact"); + var fresh = session(10); + activate(fresh); + for (int i = 0; i < 100 && !fresh.livingOrbs().isEmpty(); i++) { + fresh.livingOrbs().forEach(o -> o.setConsumed(true)); + fresh.tick(player, resonance); } + assertFalse(fresh.isStalledFinished(), "Nothing spent, so not finished"); + session.end(); + fresh.end(); } @Test From aa667b5e7d197304a53d1b0af9c3da3f179fc2c3 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:32:05 +0200 Subject: [PATCH 3/4] Never let a meditation hit grant past an artifact's wear budget A shared artifact could be worn past its budget by a sitter whose yield snapshot predates another user's gains. Wear is now applied first and the hit credits only what the artifact had left. Co-Authored-By: Claude Opus 5.5 --- .../magic/artifact/ArtifactWear.java | 13 ++++++++++++ .../magic/meditation/MeditationCircle.java | 21 +++++++++++++------ .../magic/meditation/MeditationSession.java | 16 +++++++++----- .../magic/MeditationCircleTest.java | 21 +++++++++++++++++++ .../magic/MeditationSessionTest.java | 18 ++++++++++++++-- .../magic/artifact/ArtifactWearTest.java | 8 +++++++ 6 files changed, 84 insertions(+), 13 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/artifact/ArtifactWear.java b/src/main/java/net/tfminecraft/magic/artifact/ArtifactWear.java index e63f65f..4d9ed9a 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/ArtifactWear.java +++ b/src/main/java/net/tfminecraft/magic/artifact/ArtifactWear.java @@ -54,6 +54,19 @@ public static double budget(Artifact artifact) { return highest * Cache.artifactWearMultiplier; } + /** + * Resonance the artifact can still grant before it crumbles. Infinite when wear is + * off or the stack is not an artifact, so callers can always clamp with it. + */ + public static double remaining(ItemStack stack) { + Artifact artifact = Artifact.fromItem(stack); + double budget = budget(artifact); + if (budget <= 0.0) { + return Double.POSITIVE_INFINITY; + } + return Math.max(0.0, budget - readWear(stack)); + } + /** Share of the budget left, 0-1. Always 1 when wear is off. */ public static double integrity(ItemStack stack, Artifact artifact) { double budget = budget(artifact); diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationCircle.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationCircle.java index 5579692..4f98f21 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationCircle.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationCircle.java @@ -176,8 +176,10 @@ public ItemStack itemOn(Furniture furniture) { } /** - * Wears the artifact with this id on the pedestal by {@code gain}. A crumbled artifact - * becomes a husk in the same slot. Returns the artifact's name as it was before. + * Wears the artifact with this id on the pedestal by up to {@code gain}: never more + * than it has left, so other meditators sharing it cannot push it past its budget. + * A crumbled artifact becomes a husk in the same slot. The outcome carries how much + * the artifact actually granted and its name as it was before. */ public WearOutcome wearArtifact(Furniture furniture, String artifactId, double gain) { if (furniture != null && artifactId != null) { @@ -188,17 +190,24 @@ public WearOutcome wearArtifact(Furniture furniture, String artifactId, double g continue; } String name = AttunementCaptureService.displayNameOf(item); - ArtifactWear.Result result = ArtifactWear.wear(item, gain); + double granted = Math.min(gain, ArtifactWear.remaining(item)); + ArtifactWear.Result result; + if (granted <= 0.0) { + ArtifactWear.crumble(item); + result = ArtifactWear.Result.CRUMBLED; + } else { + result = ArtifactWear.wear(item, granted); + } if (result != ArtifactWear.Result.NONE) { slot.setCurrentItem(item); } - return new WearOutcome(result, name); + return new WearOutcome(result, name, granted); } } - return new WearOutcome(ArtifactWear.Result.NONE, ""); + return new WearOutcome(ArtifactWear.Result.NONE, "", gain); } - public record WearOutcome(ArtifactWear.Result result, String name) {} + public record WearOutcome(ArtifactWear.Result result, String name, double granted) {} /** Saves the pedestal's items through InteractibleFurniture. */ public static void persist(Furniture furniture) { diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java index df1ccc7..f151c10 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java @@ -555,9 +555,15 @@ private void applyRewards( save(player, profiles); return; } - attunedByArtifact.put(artifactId, credit + gain); - AttunementCaptureService.credit(player, session, artifactId, artifact.elementId, gain); - wear(player, furniture, artifactId, gain); + MeditationCircle.WearOutcome outcome = circle.wearArtifact(furniture, artifactId, gain); + if (outcome != null) { + gain = Math.min(gain, outcome.granted()); + } + if (gain > 0) { + attunedByArtifact.put(artifactId, credit + gain); + AttunementCaptureService.credit(player, session, artifactId, artifact.elementId, gain); + } + afterWear(player, furniture, artifactId, outcome); if (MeditationCeiling.allowed( session.getResonance(artifact.elementId), yield.elementShare(artifact.elementId), @@ -574,8 +580,8 @@ private void applyRewards( * saved at most every {@link #WEAR_SAVE_MS} and when the sit ends, so a crash loses * only a few seconds of wear while resonance is saved on every hit. */ - private void wear(Player player, Furniture furniture, String artifactId, double gain) { - MeditationCircle.WearOutcome outcome = circle.wearArtifact(furniture, artifactId, gain); + private void afterWear( + Player player, Furniture furniture, String artifactId, MeditationCircle.WearOutcome outcome) { if (outcome == null || outcome.result() == ArtifactWear.Result.NONE) { return; } diff --git a/src/test/java/net/tfminecraft/magic/MeditationCircleTest.java b/src/test/java/net/tfminecraft/magic/MeditationCircleTest.java index 8bc4fde..06a50f4 100644 --- a/src/test/java/net/tfminecraft/magic/MeditationCircleTest.java +++ b/src/test/java/net/tfminecraft/magic/MeditationCircleTest.java @@ -139,6 +139,27 @@ void wearFindsTheArtifactByIdWritesItBackAndSavesThroughFurniture() { verify(slots.getFirst(), times(2)).setCurrentItem(item); assertTrue(ArtifactWear.isHusk(item)); assertNull(circle.artifactIdOn(furniture.getFirst())); + + Cache.artifactWearMultiplier = 1; + var nearly = artifact(); + when(slots.getFirst().getCurrentItem()).thenReturn(nearly); + String nearlyId = ArtifactIds.read(nearly).toString(); + assertEquals(ArtifactWear.Result.WORN, circle.wearArtifact(furniture.getFirst(), nearlyId, 8).result()); + var last = circle.wearArtifact(furniture.getFirst(), nearlyId, 5); + assertEquals(ArtifactWear.Result.CRUMBLED, last.result()); + assertEquals(2, last.granted(), 1e-9, "Only the 2 left of a 10 budget is granted"); + + var spent = artifact(); + var meta = spent.getItemMeta(); + meta.getPersistentDataContainer().set( + ArtifactKeys.artifactWear(), org.bukkit.persistence.PersistentDataType.DOUBLE, 10.0); + spent.setItemMeta(meta); + when(slots.getFirst().getCurrentItem()).thenReturn(spent); + var none = circle.wearArtifact(furniture.getFirst(), ArtifactIds.read(spent).toString(), 1); + assertEquals(ArtifactWear.Result.CRUMBLED, none.result()); + assertEquals(0, none.granted()); + assertTrue(ArtifactWear.isHusk(spent)); + assertEquals(4, circle.wearArtifact(null, nearlyId, 4).granted(), "Nothing found grants in full"); } finally { Cache.artifactWearMultiplier = Cache.DEFAULT_ARTIFACT_WEAR_MULTIPLIER; } diff --git a/src/test/java/net/tfminecraft/magic/MeditationSessionTest.java b/src/test/java/net/tfminecraft/magic/MeditationSessionTest.java index 4c27ded..3ac204b 100644 --- a/src/test/java/net/tfminecraft/magic/MeditationSessionTest.java +++ b/src/test/java/net/tfminecraft/magic/MeditationSessionTest.java @@ -154,7 +154,7 @@ void exhaustedElementsNeverSpawnAndCloseWhenResonanceReachesCeiling() { } MeditationCircle.WearOutcome outcome(ArtifactWear.Result result) { - return new MeditationCircle.WearOutcome(result, "Ember Ring"); + return new MeditationCircle.WearOutcome(result, "Ember Ring", 100); } MeditationOrb nextOrb(MeditationSession session) { @@ -207,7 +207,7 @@ void everyGainWearsItsArtifactAndACrumbledOneClosesForTheRestOfTheSit() { void aCrumbleWithNoPedestalPointStillClosesWithoutEffects() { when(circle.wearArtifact(eq(post), eq("artifact"), anyDouble())) .thenReturn(outcome(ArtifactWear.Result.WORN)) - .thenReturn(new MeditationCircle.WearOutcome(ArtifactWear.Result.CRUMBLED, "")); + .thenReturn(new MeditationCircle.WearOutcome(ArtifactWear.Result.CRUMBLED, "", 100)); var session = session(10); activate(session); assertFalse(session.onOrbHit(player, nextOrb(session), resonance, null)); @@ -219,6 +219,20 @@ void aCrumbleWithNoPedestalPointStillClosesWithoutEffects() { session.end(); } + @Test + void anArtifactNearlySpentGrantsOnlyWhatItHasLeft() { + when(circle.wearArtifact(eq(post), eq("artifact"), anyDouble())) + .thenReturn(new MeditationCircle.WearOutcome(ArtifactWear.Result.WORN, "Ember Ring", .25)) + .thenReturn(new MeditationCircle.WearOutcome(ArtifactWear.Result.CRUMBLED, "Ember Ring", 0)); + var session = session(10); + activate(session); + assertFalse(session.onOrbHit(player, nextOrb(session), resonance, null)); + assertEquals(.25, resonance.getResonance("fire"), 1e-9); + assertTrue(session.onOrbHit(player, nextOrb(session), resonance, null)); + assertEquals(.25, resonance.getResonance("fire"), 1e-9, "A spent artifact grants nothing more"); + session.end(); + } + @Test void wornArtifactsAreSavedAtOnceThenThrottledAndAgainWhenTheSitEnds() { when(circle.wearArtifact(eq(post), eq("artifact"), anyDouble())) diff --git a/src/test/java/net/tfminecraft/magic/artifact/ArtifactWearTest.java b/src/test/java/net/tfminecraft/magic/artifact/ArtifactWearTest.java index 49c7216..39f9903 100644 --- a/src/test/java/net/tfminecraft/magic/artifact/ArtifactWearTest.java +++ b/src/test/java/net/tfminecraft/magic/artifact/ArtifactWearTest.java @@ -93,7 +93,15 @@ void budgetIsTheMultipleOfTheHighestCapAndWearOffIgnoresEverything() { assertEquals(0, ArtifactWear.budget(Artifact.create())); assertEquals(1, ArtifactWear.integrity(item, Artifact.create())); + assertEquals(20, ArtifactWear.remaining(item)); + setWear(item, 15); + assertEquals(5, ArtifactWear.remaining(item)); + setWear(item, 25); + assertEquals(0, ArtifactWear.remaining(item)); + setWear(item, 0); + assertEquals(Double.POSITIVE_INFINITY, ArtifactWear.remaining(new ItemStack(Material.STONE))); Cache.artifactWearMultiplier = 0; + assertEquals(Double.POSITIVE_INFINITY, ArtifactWear.remaining(item)); assertFalse(ArtifactWear.enabled()); assertEquals(0, ArtifactWear.budget(artifact)); assertEquals(1, ArtifactWear.integrity(item, artifact)); From f2eb723eb5c9f511810759e297efcc50c4cb4fe3 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:50:26 +0200 Subject: [PATCH 4/4] Only count a ring artifact as spent once its husk is on the ring A sit used to close any artifact missing from the ring, so one lifted off and put back gave nothing more. Now only artifacts whose husk sits on a pedestal (ArtifactWear.spentId) count as spent. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/artifact/ArtifactWear.java | 9 +++++++++ .../magic/meditation/MeditationCircle.java | 16 ++++++++++++++++ .../magic/meditation/MeditationSession.java | 16 ++++------------ .../tfminecraft/magic/MeditationCircleTest.java | 2 ++ .../tfminecraft/magic/MeditationSessionTest.java | 3 +++ .../magic/artifact/ArtifactWearTest.java | 2 ++ 6 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/artifact/ArtifactWear.java b/src/main/java/net/tfminecraft/magic/artifact/ArtifactWear.java index 4d9ed9a..9156484 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/ArtifactWear.java +++ b/src/main/java/net/tfminecraft/magic/artifact/ArtifactWear.java @@ -101,6 +101,15 @@ public static Result wear(ItemStack stack, double amount) { return Result.WORN; } + /** The id the husk had as an artifact, or null when the stack is not a husk. */ + public static String spentId(ItemStack stack) { + if (!isHusk(stack)) { + return null; + } + return stack.getItemMeta().getPersistentDataContainer().get( + ArtifactKeys.artifactSpent(), PersistentDataType.STRING); + } + public static boolean isHusk(ItemStack stack) { if (stack == null || stack.getType().isAir()) { return false; diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationCircle.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationCircle.java index 4f98f21..e9b815a 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationCircle.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationCircle.java @@ -2,8 +2,10 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import org.bukkit.Bukkit; @@ -207,6 +209,20 @@ public WearOutcome wearArtifact(Furniture furniture, String artifactId, double g return new WearOutcome(ArtifactWear.Result.NONE, "", gain); } + /** Ids of artifacts that crumbled on this ring and now sit on it as husks. */ + public Set spentIds() { + Set spent = new HashSet<>(); + for (Furniture furniture : pedestals) { + for (ItemStack item : pedestalItems(furniture)) { + String id = ArtifactWear.spentId(item); + if (id != null) { + spent.add(id); + } + } + } + return spent; + } + public record WearOutcome(ArtifactWear.Result result, String name, double granted) {} /** Saves the pedestal's items through InteractibleFurniture. */ diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java index f151c10..3a2a030 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java @@ -2,7 +2,6 @@ import java.util.ArrayList; import java.util.HashMap; -import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; @@ -154,20 +153,13 @@ public boolean onOrbHit(Player player, MeditationOrb orb, ResonanceSession sessi } /** - * True when every artifact this sit drew from is spent, capped or gone from the ring. - * An artifact can crumble under another meditator mid-sit, so a missing one counts - * as spent here. + * True when every artifact this sit drew from is spent or capped. An artifact can + * crumble under another meditator mid-sit; its husk on the ring marks it spent here. */ public boolean isFinished() { - Set present = new HashSet<>(); - for (Furniture furniture : circle.getArtifactPedestals()) { - String artifactId = circle.artifactIdOn(furniture); - if (artifactId != null) { - present.add(artifactId); - } - } + Set spent = circle.spentIds(); for (String artifactId : yield.sessionCaps().keySet()) { - if (!present.contains(artifactId)) { + if (spent.contains(artifactId)) { attunedByArtifact.put(artifactId, yield.sessionCap(artifactId)); } } diff --git a/src/test/java/net/tfminecraft/magic/MeditationCircleTest.java b/src/test/java/net/tfminecraft/magic/MeditationCircleTest.java index 06a50f4..94e86ae 100644 --- a/src/test/java/net/tfminecraft/magic/MeditationCircleTest.java +++ b/src/test/java/net/tfminecraft/magic/MeditationCircleTest.java @@ -113,6 +113,7 @@ void wearFindsTheArtifactByIdWritesItBackAndSavesThroughFurniture() { when(slots.get(1).getCurrentItem()).thenReturn(new ItemStack(Material.STONE)); var circle = MeditationCircle.detect(center); String id = circle.artifactIdOn(furniture.getFirst()); + assertTrue(circle.spentIds().isEmpty()); try { Cache.artifactWearMultiplier = 2; assertEquals(ArtifactWear.Result.NONE, circle.wearArtifact(null, id, 1).result()); @@ -139,6 +140,7 @@ void wearFindsTheArtifactByIdWritesItBackAndSavesThroughFurniture() { verify(slots.getFirst(), times(2)).setCurrentItem(item); assertTrue(ArtifactWear.isHusk(item)); assertNull(circle.artifactIdOn(furniture.getFirst())); + assertEquals(Set.of(id), circle.spentIds(), "Only the husk counts; the plain stone does not"); Cache.artifactWearMultiplier = 1; var nearly = artifact(); diff --git a/src/test/java/net/tfminecraft/magic/MeditationSessionTest.java b/src/test/java/net/tfminecraft/magic/MeditationSessionTest.java index 3ac204b..bd8b0fe 100644 --- a/src/test/java/net/tfminecraft/magic/MeditationSessionTest.java +++ b/src/test/java/net/tfminecraft/magic/MeditationSessionTest.java @@ -258,9 +258,12 @@ void anArtifactCrumbledUnderAnotherMeditatorFinishesThisSit() { assertFalse(session.isStalledFinished(), "Orbs are still alive"); assertFalse(session.isFinished()); when(circle.artifactIdOn(post)).thenReturn(null); + assertFalse(session.isFinished(), "Lifted off, not spent: it can come back"); + when(circle.spentIds()).thenReturn(Set.of("artifact")); assertTrue(session.isFinished()); for (int i = 0; i < 100 && !session.livingOrbs().isEmpty(); i++) session.tick(player, resonance); assertTrue(session.isStalledFinished()); + when(circle.spentIds()).thenReturn(Set.of()); when(circle.artifactIdOn(post)).thenReturn("artifact"); var fresh = session(10); activate(fresh); diff --git a/src/test/java/net/tfminecraft/magic/artifact/ArtifactWearTest.java b/src/test/java/net/tfminecraft/magic/artifact/ArtifactWearTest.java index 39f9903..0c574e2 100644 --- a/src/test/java/net/tfminecraft/magic/artifact/ArtifactWearTest.java +++ b/src/test/java/net/tfminecraft/magic/artifact/ArtifactWearTest.java @@ -153,6 +153,8 @@ void reachingTheBudgetCrumblesIntoARecyclableHusk() { setWear(item, 19.99995); assertEquals(ArtifactWear.Result.CRUMBLED, ArtifactWear.wear(item, 0.00001)); assertTrue(ArtifactWear.isHusk(item)); + assertEquals(id.toString(), ArtifactWear.spentId(item)); + assertNull(ArtifactWear.spentId(new ItemStack(Material.STONE))); assertNull(Artifact.fromItem(item)); var pdc = item.getItemMeta().getPersistentDataContainer(); assertEquals(id.toString(), pdc.get(ArtifactKeys.artifactSpent(), PersistentDataType.STRING));