From 884bc653e020e38e1a668a8ca9b08b085ae84f1e Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:20:23 +0200 Subject: [PATCH 1/2] Let sealed artifacts from the Rune Crafting Station break open into rolled artifacts Right-clicking a sealed item listed in the new artifacts/sealed.yml rolls an artifact the same way a Dowsing Artifact Mine drop is made: rarity by the file's weights (65/25/7/3, as the live node), then the usual roller and builder, so caps, secondary schools, wear and Recycler value match. A seal can lock the school or roll it from random_pool. The item is only used up when the artifact was made; a full inventory drops it at the player's feet. Each opened seal is logged to the console for counting. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + .../java/net/tfminecraft/magic/Magic.java | 5 +- .../artifact/config/ArtifactConfigLoader.java | 7 +- .../sealed/SealedArtifactConfigLoader.java | 127 ++++++ .../artifact/sealed/SealedArtifactDef.java | 43 ++ .../sealed/SealedArtifactRegistry.java | 107 +++++ .../sealed/SealedArtifactService.java | 94 ++++ .../listener/SealedArtifactListener.java | 75 ++++ src/main/resources/artifacts/sealed.yml | 37 ++ src/main/resources/messages.yml | 6 + .../artifact/sealed/SealedArtifactTest.java | 402 ++++++++++++++++++ .../listener/SealedArtifactListenerTest.java | 137 ++++++ 12 files changed, 1039 insertions(+), 2 deletions(-) create mode 100644 src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactConfigLoader.java create mode 100644 src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactDef.java create mode 100644 src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactRegistry.java create mode 100644 src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactService.java create mode 100644 src/main/java/net/tfminecraft/magic/listener/SealedArtifactListener.java create mode 100644 src/main/resources/artifacts/sealed.yml create mode 100644 src/test/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactTest.java create mode 100644 src/test/java/net/tfminecraft/magic/listener/SealedArtifactListenerTest.java diff --git a/README.md b/README.md index eb2d39c..aef1f57 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Magic connects a character's relationship with the elements to meditation, artif - **Flow and Surge** — casting modes interact with equilibrium, tranquility, and corruption to shape a character's magical state over time. - **Interactive meditation** — meditate around artifacts and engage with orbiting orbs, spending mental focus to develop resonance. - **Artifacts and attunement** — artifacts carry elemental aura, with display, storage, and care affecting how that aura remains available. +- **Sealed artifacts** — sealed items crafted elsewhere break open into a rolled artifact, with a random or chosen school and weighted rarity set in `artifacts/sealed.yml`. - **Crafted mage equipment** — assemble staffs, wands, and swords from parts, then work with charges and orbs to shape their elemental capabilities. - **Casting consequences** — insufficient attunement can prevent a cast, while unstable equipment or carrying multiple staffs can cause a spell to fumble. diff --git a/src/main/java/net/tfminecraft/magic/Magic.java b/src/main/java/net/tfminecraft/magic/Magic.java index 8e4f707..002584a 100644 --- a/src/main/java/net/tfminecraft/magic/Magic.java +++ b/src/main/java/net/tfminecraft/magic/Magic.java @@ -30,6 +30,7 @@ import net.tfminecraft.magic.listener.MagicSessionListener; import net.tfminecraft.magic.listener.ResonanceCastListener; import net.tfminecraft.magic.listener.SacrificeListener; +import net.tfminecraft.magic.listener.SealedArtifactListener; import net.tfminecraft.magic.loader.ChargesLoader; import net.tfminecraft.magic.loader.ConfigLoader; import net.tfminecraft.magic.loader.ElementsLoader; @@ -111,6 +112,7 @@ public void onEnable() { getServer().getPluginManager().registerEvents(new ArtifactListener(), this); getServer().getPluginManager().registerEvents(new ArtifactFillChestListener(), this); getServer().getPluginManager().registerEvents(new SacrificeListener(), this); + getServer().getPluginManager().registerEvents(new SealedArtifactListener(), this); GearStationListener gearStationListener = new GearStationListener(); getServer().getPluginManager().registerEvents(gearStationListener, this); getServer().getPluginManager().registerEvents(gearStationListener.inventory(), this); @@ -328,7 +330,8 @@ private void createConfigs() { "artifacts/naming-schemes.yml", "artifacts/adjectives.yml", "artifacts/shrines.yml", - "artifacts/sacrifice.yml" + "artifacts/sacrifice.yml", + "artifacts/sealed.yml" }; for (String path : defaultFiles) { copyResourceIfMissing(path); diff --git a/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactConfigLoader.java b/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactConfigLoader.java index 45361a4..5e8d408 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactConfigLoader.java +++ b/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactConfigLoader.java @@ -17,6 +17,8 @@ import net.tfminecraft.magic.Magic; import net.tfminecraft.magic.artifact.sacrifice.SacrificeConfigLoader; import net.tfminecraft.magic.artifact.sacrifice.SacrificeRegistry; +import net.tfminecraft.magic.artifact.sealed.SealedArtifactConfigLoader; +import net.tfminecraft.magic.artifact.sealed.SealedArtifactRegistry; import net.tfminecraft.magic.artifact.shrine.ShrineConfigLoader; import net.tfminecraft.magic.artifact.shrine.ShrineRegistry; import net.tfminecraft.magic.registry.ElementRegistry; @@ -40,6 +42,7 @@ public static boolean loadFolder(File folder) { ok &= ArtifactAdjectiveLoader.load(new File(folder, "adjectives.yml")); ok &= ShrineConfigLoader.load(new File(folder, "shrines.yml")); ok &= SacrificeConfigLoader.load(new File(folder, "sacrifice.yml")); + ok &= SealedArtifactConfigLoader.load(new File(folder, "sealed.yml")); StringBuilder groupSizes = new StringBuilder(); for (Map.Entry> entry : ArtifactAffinityRegistry.getGroups().entrySet()) { @@ -63,7 +66,8 @@ public static boolean loadFolder(File folder) { + ", model-schemes=" + ArtifactModelSchemeRegistry.size() + ", naming-schemes=" + ArtifactNamingSchemeRegistry.size() + ", shrines=" + ShrineRegistry.size() - + ", sacrifice=" + SacrificeRegistry.size()); + + ", sacrifice=" + SacrificeRegistry.size() + + ", sealed=" + SealedArtifactRegistry.size()); if (Cache.debug) { log.info("[Magic] Debug: artifact types=" + ArtifactTypeRegistry.getAllIds()); for (ArtifactTypeDef type : ArtifactTypeRegistry.getAll()) { @@ -84,6 +88,7 @@ private static void clearAll() { ArtifactGeneratorCache.reset(); ShrineRegistry.clear(); SacrificeRegistry.clear(); + SealedArtifactRegistry.clear(); } private static boolean loadModelSchemes(File file) { diff --git a/src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactConfigLoader.java b/src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactConfigLoader.java new file mode 100644 index 0000000..0debf32 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactConfigLoader.java @@ -0,0 +1,127 @@ +package net.tfminecraft.magic.artifact.sealed; + +import java.io.File; +import java.io.IOException; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.logging.Logger; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; + +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.artifact.config.ArtifactRarityRegistry; +import net.tfminecraft.magic.artifact.config.ArtifactTypeDef; +import net.tfminecraft.magic.artifact.config.ArtifactTypeRegistry; + +/** + * Loads artifacts/sealed.yml. Runs after generator.yml so rarities and types can be checked. + * + *

Like shrines.yml and sacrifice.yml, problems here never fail the artifact configs: a bad + * entry or weight is skipped with a warning, and an unreadable file turns sealed artifacts off. + */ +public final class SealedArtifactConfigLoader { + + static final String RANDOM = "random"; + + private final Logger log; + + private SealedArtifactConfigLoader(Logger log) { + this.log = log; + } + + public static boolean load(File file) { + SealedArtifactRegistry.clear(); + Logger log = Magic.plugin.getLogger(); + if (file == null || !file.isFile()) { + log.info("[Magic] artifacts/sealed.yml missing; sealed artifacts disabled"); + return true; + } + YamlConfiguration config = new YamlConfiguration(); + try { + config.load(file); + } catch (IOException | InvalidConfigurationException ex) { + log.severe("[Magic] Failed to load sealed.yml, sealed artifacts disabled: " + ex.getMessage()); + return true; + } + new SealedArtifactConfigLoader(log).read(config); + return true; + } + + private void read(YamlConfiguration config) { + SealedArtifactRegistry.setSound(config.getString("sound", "")); + Map shared = readWeights(config.getConfigurationSection("rarities"), "rarities"); + ConfigurationSection sealed = config.getConfigurationSection("sealed"); + if (sealed == null) { + return; + } + for (String key : sealed.getKeys(false)) { + readEntry(key, sealed.getConfigurationSection(key), shared); + } + } + + private void readEntry(String id, ConfigurationSection section, Map shared) { + if (section == null) { + warn("Sealed artifact '" + id + "' has no data"); + return; + } + String item = section.getString("item", "").trim(); + if (item.isEmpty()) { + warn("Sealed artifact '" + id + "' has no item"); + return; + } + String element = section.getString("element", RANDOM).trim().toLowerCase(Locale.ROOT); + ArtifactTypeDef type = null; + if (!RANDOM.equals(element)) { + type = ArtifactTypeRegistry.getById(element); + if (type == null || !type.isEnabled()) { + warn("Sealed artifact '" + id + "' has unknown or disabled element '" + element + "'"); + return; + } + } + Map weights = section.isConfigurationSection("rarities") + ? readWeights(section.getConfigurationSection("rarities"), id + ".rarities") + : new LinkedHashMap<>(shared); + if (type != null) { + Iterator rarities = weights.keySet().iterator(); + while (rarities.hasNext()) { + String rarity = rarities.next(); + if (!type.hasRarity(rarity)) { + warn("Sealed artifact '" + id + "': " + element + " has no " + rarity + " bracket"); + rarities.remove(); + } + } + } + if (weights.isEmpty()) { + warn("Sealed artifact '" + id + "' has no rarity with a weight above 0"); + return; + } + SealedArtifactRegistry.register(new SealedArtifactDef(id, item, type == null ? null : element, weights)); + } + + private Map readWeights(ConfigurationSection section, String where) { + Map weights = new LinkedHashMap<>(); + if (section == null) { + return weights; + } + for (String key : section.getKeys(false)) { + String rarity = key.trim().toLowerCase(Locale.ROOT); + double weight = section.getDouble(key, -1.0); + if (!ArtifactRarityRegistry.contains(rarity)) { + warn("Unknown rarity '" + key + "' in sealed.yml " + where); + } else if (weight < 0.0 || !Double.isFinite(weight)) { + warn("Rarity '" + key + "' in sealed.yml " + where + " needs a weight of 0 or more"); + } else if (weight > 0.0) { + weights.put(rarity, weight); + } + } + return weights; + } + + private void warn(String message) { + log.warning("[Magic] " + message); + } +} diff --git a/src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactDef.java b/src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactDef.java new file mode 100644 index 0000000..206ade2 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactDef.java @@ -0,0 +1,43 @@ +package net.tfminecraft.magic.artifact.sealed; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * One sealed artifact item from artifacts/sealed.yml. + * + *

A null element means the school is rolled from generator.yml's random_pool. + */ +public final class SealedArtifactDef { + + private final String id; + private final String itemPath; + private final String elementId; + private final Map rarities; + + public SealedArtifactDef(String id, String itemPath, String elementId, Map rarities) { + this.id = id; + this.itemPath = itemPath; + this.elementId = elementId; + this.rarities = Collections.unmodifiableMap(new LinkedHashMap<>(rarities)); + } + + public String getId() { + return id; + } + + public String getItemPath() { + return itemPath; + } + + /** The locked school, or null for a random school. */ + public String getElementId() { + return elementId; + } + + /** Rarity id to weight, every weight above zero. */ + public Map getRarities() { + return rarities; + } +} diff --git a/src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactRegistry.java b/src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactRegistry.java new file mode 100644 index 0000000..e78a741 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactRegistry.java @@ -0,0 +1,107 @@ +package net.tfminecraft.magic.artifact.sealed; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import net.tfminecraft.tlibs.TLibs; + +/** + * Sealed artifact items loaded from artifacts/sealed.yml. + */ +public final class SealedArtifactRegistry { + + private static final Map defs = new LinkedHashMap<>(); + private static Set materials; + private static String sound = ""; + + private SealedArtifactRegistry() {} + + public static void clear() { + defs.clear(); + materials = null; + sound = ""; + } + + public static void register(SealedArtifactDef def) { + defs.put(def.getId(), def); + materials = null; + } + + public static List getAll() { + return new ArrayList<>(defs.values()); + } + + public static int size() { + return defs.size(); + } + + /** Sound key played when a seal breaks; blank for none. */ + public static String getSound() { + return sound; + } + + public static void setSound(String value) { + sound = value == null ? "" : value.trim(); + } + + /** + * Matches an item against every sealed artifact. + * + * @return the matching definition, or null when the item is not sealed + */ + public static SealedArtifactDef match(ItemStack item) { + if (item == null || item.getType().isAir() || defs.isEmpty()) { + return null; + } + Set known = knownMaterials(); + if (!known.isEmpty() && !known.contains(item.getType())) { + return null; + } + for (SealedArtifactDef def : defs.values()) { + try { + if (TLibs.getItemAPI().getChecker().checkItemWithPath(item, def.getItemPath())) { + return def; + } + } catch (Exception ignored) { + // bad path in config, skip this entry + } + } + return null; + } + + /** + * Resolves each configured path once to learn its material, so most right-clicks + * skip the path checks. + * + *

An entry that fails to resolve yields an empty set, which disables the fast path + * rather than making that entry unmatchable. + */ + private static Set knownMaterials() { + if (materials != null) { + return materials; + } + Set found = EnumSet.noneOf(Material.class); + for (SealedArtifactDef def : defs.values()) { + ItemStack resolved; + try { + resolved = TLibs.getItemAPI().getCreator().getItemFromPath(def.getItemPath()); + } catch (Exception ex) { + resolved = null; + } + if (resolved == null || resolved.getType().isAir()) { + materials = Set.of(); + return materials; + } + found.add(resolved.getType()); + } + materials = found; + return materials; + } +} diff --git a/src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactService.java b/src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactService.java new file mode 100644 index 0000000..c9fedd6 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactService.java @@ -0,0 +1,94 @@ +package net.tfminecraft.magic.artifact.sealed; + +import java.util.Map; +import java.util.Random; +import java.util.function.Function; + +import org.bukkit.entity.Player; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; + +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.Messages; +import net.tfminecraft.magic.artifact.ArtifactIds; +import net.tfminecraft.magic.artifact.generate.ArtifactItemBuilder; +import net.tfminecraft.magic.artifact.generate.ArtifactRoll; +import net.tfminecraft.magic.artifact.generate.ArtifactRoller; + +/** + * Breaks a sealed artifact: picks a rarity by the item's weights, then rolls and builds the + * artifact the same way a Dowsing Artifact Mine drop is made. + */ +public final class SealedArtifactService { + + private final Random rng; + private final ArtifactRoller roller; + private final Function builder; + + public SealedArtifactService() { + this(new Random(), new ArtifactRoller(), roll -> new ArtifactItemBuilder().build(roll)); + } + + SealedArtifactService(Random rng, ArtifactRoller roller, Function builder) { + this.rng = rng; + this.roller = roller; + this.builder = builder; + } + + /** Picks a rarity id by weight. Weights must be above zero, as the loader keeps them. */ + String pickRarity(Map weights) { + double total = 0.0; + for (double weight : weights.values()) { + total += weight; + } + double pick = rng.nextDouble() * total; + String last = null; + for (Map.Entry entry : weights.entrySet()) { + last = entry.getKey(); + pick -= entry.getValue(); + if (pick < 0.0) { + return last; + } + } + return last; + } + + /** + * Breaks the seal on the item in {@code hand}. The sealed item is only used up when the + * artifact was made. + * + * @return true when an artifact was given + */ + public boolean open(Player player, EquipmentSlot hand, SealedArtifactDef def) { + String rarity = pickRarity(def.getRarities()); + String element = def.getElementId() == null ? SealedArtifactConfigLoader.RANDOM : def.getElementId(); + ArtifactRoll roll = roller.roll(element, rarity); + ItemStack artifact = roll.isError() ? null : builder.apply(roll); + if (artifact == null) { + Magic.plugin.getLogger().warning("[Magic] Sealed artifact '" + def.getId() + "' (" + element + ", " + + rarity + ") could not be made: " + (roll.isError() ? roll.getErrorKey() : "build failed")); + player.sendMessage(Messages.get("artifact.sealed.failed")); + return false; + } + ItemStack sealed = player.getInventory().getItem(hand); + if (sealed.getAmount() > 1) { + sealed.setAmount(sealed.getAmount() - 1); + player.getInventory().setItem(hand, sealed); + } else { + player.getInventory().setItem(hand, null); + } + for (ItemStack extra : player.getInventory().addItem(artifact).values()) { + player.getWorld().dropItemNaturally(player.getLocation(), extra); + } + player.sendMessage(Messages.get("artifact.sealed.opened")); + String sound = SealedArtifactRegistry.getSound(); + if (!sound.isEmpty()) { + player.playSound(player.getLocation(), sound, 1.0f, 1.0f); + } + // Console, not aura.log: aura.log is wiped on every start, and these are counted over weeks. + Magic.plugin.getLogger().info("[Magic] Sealed artifact opened: player=" + player.getName() + + " seal=" + def.getId() + " element=" + roll.getPrimaryId() + " rarity=" + roll.getRarityId() + + " artifact=" + ArtifactIds.read(artifact)); + return true; + } +} diff --git a/src/main/java/net/tfminecraft/magic/listener/SealedArtifactListener.java b/src/main/java/net/tfminecraft/magic/listener/SealedArtifactListener.java new file mode 100644 index 0000000..349ed19 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/listener/SealedArtifactListener.java @@ -0,0 +1,75 @@ +package net.tfminecraft.magic.listener; + +import java.util.UUID; +import java.util.function.Supplier; + +import org.bukkit.Bukkit; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; + +import net.tfminecraft.magic.artifact.sealed.SealedArtifactDef; +import net.tfminecraft.magic.artifact.sealed.SealedArtifactRegistry; +import net.tfminecraft.magic.artifact.sealed.SealedArtifactService; + +/** + * Right-clicking a sealed artifact breaks the seal. Clicks on chests, doors and other usable + * blocks still use the block. + */ +public final class SealedArtifactListener implements Listener { + + private final Supplier services; + private UUID lastOpener; + private int lastOpenTick; + + public SealedArtifactListener() { + this(SealedArtifactService::new); + } + + SealedArtifactListener(Supplier services) { + this.services = services; + } + + // Right-clicks on air arrive already cancelled, so cancelled events are not ignored here. + @EventHandler(priority = EventPriority.HIGH) + public void onInteract(PlayerInteractEvent event) { + Action action = event.getAction(); + if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK) { + return; + } + if (event.useItemInHand() == Event.Result.DENY || usesBlock(event)) { + return; + } + EquipmentSlot hand = event.getHand(); + SealedArtifactDef def = SealedArtifactRegistry.match(event.getItem()); + if (hand == null || def == null) { + return; + } + event.setUseItemInHand(Event.Result.DENY); + event.setUseInteractedBlock(Event.Result.DENY); + // One click can fire several interact events (block then air, main then off hand), + // all in the same tick. Only the first one breaks a seal. + Player player = event.getPlayer(); + int tick = Bukkit.getCurrentTick(); + if (player.getUniqueId().equals(lastOpener) && tick == lastOpenTick) { + return; + } + lastOpener = player.getUniqueId(); + lastOpenTick = tick; + services.get().open(player, hand, def); + } + + private static boolean usesBlock(PlayerInteractEvent event) { + Block block = event.getClickedBlock(); + return block != null + && event.getAction() == Action.RIGHT_CLICK_BLOCK + && block.getType().isInteractable() + && !event.getPlayer().isSneaking(); + } +} diff --git a/src/main/resources/artifacts/sealed.yml b/src/main/resources/artifacts/sealed.yml new file mode 100644 index 0000000..2f15b44 --- /dev/null +++ b/src/main/resources/artifacts/sealed.yml @@ -0,0 +1,37 @@ +# Sealed artifacts. Jar does not overwrite a live copy of this file. +# +# Sealed artifacts are MMOItems crafted at the Rune Crafting Station. Right-clicking one +# breaks the seal and gives an artifact rolled the same way as a Dowsing Artifact Mine +# drop: same aura caps, secondary schools, wear and Recycler value. +# +# rarities -> weight per generator.yml rarity, like the Artifact Mine's add_drop lines. +# 0 turns a rarity off. An entry below can list its own rarities instead. +# item -> TLibs item path of the sealed item +# element -> artifact type id, or random for a school from generator.yml random_pool +# (picked by type weight). Secondary schools roll as for any artifact. +# sound -> played to the player when a seal breaks. Blank for none. + +sound: entity.experience_orb.pickup + +rarities: + common: 65 + uncommon: 25 + rare: 7 + legendary: 3 + +sealed: + random: + item: m.loot.sealed_artifact + element: random + oseni: + item: m.loot.sealed_oseni_artifact + element: oseni + seithr: + item: m.loot.sealed_seithr_artifact + element: seithr + cerrith: + item: m.loot.sealed_cerrith_artifact + element: cerrith + mitlan: + item: m.loot.sealed_mitlan_artifact + element: mitlan diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index f37254e..e588fb0 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -163,6 +163,12 @@ artifact: ok: "#55ff55Gave artifact to {player}." + sealed: + + opened: "#c9a0ffThe seal breaks, and an artifact is yours." + + failed: "#ff5555The seal will not break. Nothing was used; please tell staff." + create: usage: "#aaaaaaUsage: /magic artifact create" diff --git a/src/test/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactTest.java b/src/test/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactTest.java new file mode 100644 index 0000000..877234e --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactTest.java @@ -0,0 +1,402 @@ +package net.tfminecraft.magic.artifact.sealed; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import java.util.logging.*; +import net.tfminecraft.magic.Cache; +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.Messages; +import net.tfminecraft.magic.artifact.config.*; +import net.tfminecraft.magic.artifact.generate.ArtifactRoll; +import net.tfminecraft.magic.artifact.generate.ArtifactRoller; +import net.tfminecraft.magic.loader.ElementsLoader; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.Material; +import org.bukkit.entity.Item; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; + +class SealedArtifactTest { + @TempDir Path temp; + ServerMock server; + final List warnings = new ArrayList<>(); + final List infos = new ArrayList<>(); + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + var logger = Logger.getAnonymousLogger(); + logger.setUseParentHandlers(false); + logger.addHandler( + new Handler() { + @Override + public void publish(LogRecord record) { + if (record.getLevel().intValue() >= Level.WARNING.intValue()) { + warnings.add(record.getMessage()); + } else { + infos.add(record.getMessage()); + } + } + + @Override + public void flush() {} + + @Override + public void close() {} + }); + when(Magic.plugin.getLogger()).thenReturn(logger); + when(Magic.plugin.namespace()).thenReturn("magic"); + clearRegistries(); + } + + @AfterEach + void cleanup() { + clearRegistries(); + Magic.plugin = null; + MockBukkit.unmock(); + } + + static void clearRegistries() { + SealedArtifactRegistry.clear(); + ArtifactRarityRegistry.clear(); + ArtifactTypeRegistry.clear(); + } + + static void rarities(String... ids) { + for (String id : ids) { + var rarity = mock(ArtifactRarityDef.class); + when(rarity.getId()).thenReturn(id); + ArtifactRarityRegistry.register(rarity); + } + } + + static void type(String id, boolean enabled, String... missingRarities) { + var type = mock(ArtifactTypeDef.class); + when(type.getElementId()).thenReturn(id); + when(type.isEnabled()).thenReturn(enabled); + when(type.hasRarity(anyString())).thenReturn(true); + for (String rarity : missingRarities) { + when(type.hasRarity(rarity)).thenReturn(false); + } + ArtifactTypeRegistry.register(type); + } + + SealedArtifactDef def(String id) { + return SealedArtifactRegistry.getAll().stream() + .filter(d -> d.getId().equals(id)) + .findFirst() + .orElse(null); + } + + boolean load(String yaml) throws Exception { + var file = temp.resolve("sealed.yml"); + Files.writeString(file, yaml); + return SealedArtifactConfigLoader.load(file.toFile()); + } + + @Test + void bundledSealedFileLoadsFiveSealsAgainstTheBundledGenerator() throws Exception { + new ElementsLoader().loadFolder(Path.of("src/main/resources/elements").toFile()); + Cache.artifactAuraCap = 150; + try (var files = Files.list(Path.of("src/main/resources/artifacts"))) { + for (Path file : files.toList()) { + Files.copy(file, temp.resolve(file.getFileName())); + } + } + assertTrue(ArtifactConfigLoader.loadFolder(temp.toFile())); + assertTrue(warnings.stream().noneMatch(w -> w.toLowerCase().contains("sealed")), warnings::toString); + assertEquals(5, SealedArtifactRegistry.size()); + var weights = new LinkedHashMap(); + weights.put("common", 65.0); + weights.put("uncommon", 25.0); + weights.put("rare", 7.0); + weights.put("legendary", 3.0); + for (var seal : SealedArtifactRegistry.getAll()) { + assertEquals(weights, seal.getRarities(), seal.getId()); + } + assertNull(def("random").getElementId()); + assertEquals("m.loot.sealed_artifact", def("random").getItemPath()); + for (String school : List.of("oseni", "seithr", "cerrith", "mitlan")) { + assertEquals(school, def(school).getElementId()); + assertEquals("m.loot.sealed_" + school + "_artifact", def(school).getItemPath()); + } + assertEquals("entity.experience_orb.pickup", SealedArtifactRegistry.getSound()); + + // A reload with no sealed.yml leaves the feature off without failing the load. + Files.delete(temp.resolve("sealed.yml")); + assertTrue(ArtifactConfigLoader.loadFolder(temp.toFile())); + assertEquals(0, SealedArtifactRegistry.size()); + } + + @Test + void missingOrBrokenFilesTurnSealsOffWithoutFailingTheLoad() throws Exception { + SealedArtifactRegistry.register(new SealedArtifactDef("old", "v.STONE", null, Map.of("x", 1.0))); + assertTrue(SealedArtifactConfigLoader.load(null)); + assertEquals(0, SealedArtifactRegistry.size()); + assertTrue(SealedArtifactConfigLoader.load(temp.resolve("absent.yml").toFile())); + assertEquals(List.of(), warnings); + assertTrue(load("broken: [unterminated")); + assertEquals(1, warnings.size()); + assertTrue(warnings.get(0).startsWith("[Magic] Failed to load sealed.yml, sealed artifacts disabled")); + assertEquals(0, SealedArtifactRegistry.size()); + assertTrue(load("sound: note\n")); + assertEquals(0, SealedArtifactRegistry.size()); + assertEquals("note", SealedArtifactRegistry.getSound()); + } + + @Test + void invalidEntriesAndWeightsAreSkippedWithAWarning() throws Exception { + rarities("common", "uncommon", "rare", "legendary"); + type("cerrith", true); + type("seithr", true, "legendary"); + type("mitlan", false); + String yaml = + """ + rarities: + Common: 5 + mythic: 3 + rare: -1 + legendary: .inf + uncommon: 0 + sealed: + scalar: bad + noitem: {element: cerrith} + unknown: {item: v.STONE, element: nope} + disabled: {item: v.STONE, element: mitlan} + own: {item: ' v.STONE ', element: CERRITH, rarities: {rare: 2}} + nothing: {item: v.STONE, rarities: {common: 0}} + random: {item: v.STONE} + partial: {item: v.STONE, element: seithr, rarities: {common: 1, legendary: 4}} + bracketless: {item: v.STONE, element: seithr, rarities: {legendary: 4}} + """; + assertTrue(load(yaml)); + assertEquals( + List.of( + "[Magic] Unknown rarity 'mythic' in sealed.yml rarities", + "[Magic] Rarity 'rare' in sealed.yml rarities needs a weight of 0 or more", + "[Magic] Rarity 'legendary' in sealed.yml rarities needs a weight of 0 or more", + "[Magic] Sealed artifact 'scalar' has no data", + "[Magic] Sealed artifact 'noitem' has no item", + "[Magic] Sealed artifact 'unknown' has unknown or disabled element 'nope'", + "[Magic] Sealed artifact 'disabled' has unknown or disabled element 'mitlan'", + "[Magic] Sealed artifact 'nothing' has no rarity with a weight above 0", + "[Magic] Sealed artifact 'partial': seithr has no legendary bracket", + "[Magic] Sealed artifact 'bracketless': seithr has no legendary bracket", + "[Magic] Sealed artifact 'bracketless' has no rarity with a weight above 0"), + warnings); + assertEquals( + List.of("own", "random", "partial"), + SealedArtifactRegistry.getAll().stream().map(SealedArtifactDef::getId).toList()); + assertEquals("v.STONE", def("own").getItemPath()); + assertEquals("cerrith", def("own").getElementId()); + assertEquals(Map.of("rare", 2.0), def("own").getRarities()); + assertNull(def("random").getElementId()); + assertEquals(Map.of("common", 5.0), def("random").getRarities()); + assertEquals(Map.of("common", 1.0), def("partial").getRarities()); + assertEquals("", SealedArtifactRegistry.getSound()); + + assertTrue(load("rarities: {common: 1}\nsealed:\n random: {item: v.STONE}\n")); + assertEquals(1, SealedArtifactRegistry.size()); + } + + @Test + void registryMatchesOnlyConfiguredSealedItems() { + SealedArtifactRegistry.setSound(null); + assertEquals("", SealedArtifactRegistry.getSound()); + assertNull(SealedArtifactRegistry.match(null)); + assertNull(SealedArtifactRegistry.match(new ItemStack(Material.AIR))); + assertNull(SealedArtifactRegistry.match(new ItemStack(Material.STONE))); + var seal = new SealedArtifactDef("random", "v.STONE", null, Map.of("common", 1.0)); + var broken = new SealedArtifactDef("broken", "v.BAD", null, Map.of("common", 1.0)); + SealedArtifactRegistry.register(seal); + try (var libs = mockStatic(TLibs.class)) { + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + libs.when(TLibs::getItemAPI).thenReturn(api); + var creator = api.getCreator(); + var stone = new ItemStack(Material.STONE); + when(api.getCreator().getItemFromPath("v.STONE")).thenReturn(new ItemStack(Material.STONE)); + when(api.getChecker().checkItemWithPath(stone, "v.STONE")).thenReturn(true); + assertNull(SealedArtifactRegistry.match(new ItemStack(Material.APPLE))); + assertSame(seal, SealedArtifactRegistry.match(stone)); + verify(api.getCreator(), times(1)).getItemFromPath("v.STONE"); + when(api.getChecker().checkItemWithPath(stone, "v.STONE")).thenReturn(false); + assertNull(SealedArtifactRegistry.match(stone)); + + // A path that does not resolve turns the material fast path off. + SealedArtifactRegistry.register(broken); + when(api.getCreator().getItemFromPath("v.BAD")).thenThrow(new IllegalStateException("bad")); + when(api.getChecker().checkItemWithPath(any(), eq("v.BAD"))).thenThrow(new IllegalStateException()); + var apple = new ItemStack(Material.APPLE); + when(api.getChecker().checkItemWithPath(apple, "v.STONE")).thenReturn(true); + assertSame(seal, SealedArtifactRegistry.match(apple)); + assertNull(SealedArtifactRegistry.match(stone)); + doReturn(null).when(creator).getItemFromPath("v.BAD"); + SealedArtifactRegistry.register(broken); + assertSame(seal, SealedArtifactRegistry.match(apple)); + doReturn(new ItemStack(Material.AIR)).when(creator).getItemFromPath("v.BAD"); + SealedArtifactRegistry.register(broken); + assertSame(seal, SealedArtifactRegistry.match(apple)); + } + } + + static Random fixed(double... values) { + return new Random() { + int next; + + @Override + public double nextDouble() { + return values[Math.min(next++, values.length - 1)]; + } + }; + } + + static Map weights() { + var weights = new LinkedHashMap(); + weights.put("common", 65.0); + weights.put("uncommon", 25.0); + weights.put("rare", 7.0); + weights.put("legendary", 3.0); + return weights; + } + + @Test + void rarityPickFollowsTheWeights() { + var service = new SealedArtifactService(fixed(0.0, 0.649, 0.65, 0.899, 0.9, 0.969, 0.97, 0.999, 1.0), null, null); + var picks = new ArrayList(); + for (int i = 0; i < 9; i++) { + picks.add(service.pickRarity(weights())); + } + assertEquals( + List.of("common", "common", "uncommon", "uncommon", "rare", "rare", "legendary", "legendary", "legendary"), + picks); + assertNull(service.pickRarity(Map.of())); + + var seeded = new SealedArtifactService(new Random(7), null, null); + var counts = new HashMap(); + for (int i = 0; i < 100_000; i++) { + counts.merge(seeded.pickRarity(weights()), 1, Integer::sum); + } + assertEquals(65_000, counts.get("common"), 1_000); + assertEquals(25_000, counts.get("uncommon"), 1_000); + assertEquals(7_000, counts.get("rare"), 500); + assertEquals(3_000, counts.get("legendary"), 300); + } + + ItemStack sealed(int amount) { + return new ItemStack(Material.ECHO_SHARD, amount); + } + + ItemStack artifactItem() { + return new ItemStack(Material.BLAZE_ROD); + } + + @Test + void breakingASealUsesOneItemAndGivesTheRolledArtifact() { + var player = server.addPlayer(); + var roller = mock(ArtifactRoller.class); + var rolled = ArtifactRoll.ok("cerrith", "rare", List.of()); + when(roller.roll(anyString(), anyString())).thenReturn(rolled); + var built = new ArrayList(); + var service = + new SealedArtifactService( + fixed(0.95), + roller, + roll -> { + built.add(roll); + return artifactItem(); + }); + var random = new SealedArtifactDef("random", "v.ECHO_SHARD", null, weights()); + var cerrith = new SealedArtifactDef("cerrith", "v.ECHO_SHARD", "cerrith", weights()); + SealedArtifactRegistry.setSound("entity.experience_orb.pickup"); + try (var messages = mockStatic(Messages.class, i -> i.getArgument(0))) { + player.getInventory().setItemInMainHand(sealed(2)); + assertTrue(service.open(player, EquipmentSlot.HAND, random)); + verify(roller).roll("random", "rare"); + assertEquals(List.of(rolled), built); + assertEquals(1, player.getInventory().getItemInMainHand().getAmount()); + assertTrue(player.getInventory().contains(Material.BLAZE_ROD, 1)); + assertEquals("artifact.sealed.opened", player.nextMessage()); + player.assertSoundHeard("entity.experience_orb.pickup"); + assertEquals( + List.of( + "[Magic] Sealed artifact opened: player=" + + player.getName() + + " seal=random element=cerrith rarity=rare artifact=null"), + infos); + + SealedArtifactRegistry.setSound(""); + player.getInventory().setItemInOffHand(sealed(1)); + assertTrue(service.open(player, EquipmentSlot.OFF_HAND, cerrith)); + verify(roller).roll("cerrith", "rare"); + assertTrue(player.getInventory().getItemInOffHand().getType().isAir()); + assertTrue(player.getInventory().contains(Material.BLAZE_ROD, 2)); + } + } + + @Test + void aFullInventoryDropsTheArtifactAtThePlayersFeet() { + var player = server.addPlayer(); + var roller = mock(ArtifactRoller.class); + when(roller.roll(anyString(), anyString())).thenReturn(ArtifactRoll.ok("seithr", "common", List.of())); + var service = new SealedArtifactService(fixed(0.0), roller, roll -> artifactItem()); + var seal = new SealedArtifactDef("seithr", "v.ECHO_SHARD", "seithr", weights()); + // MockBukkit's addItem also fills armour and off-hand slots, so fill those too. + for (int slot = 0; slot < player.getInventory().getSize(); slot++) { + player.getInventory().setItem(slot, new ItemStack(Material.DIRT, 64)); + } + player.getInventory().setItemInMainHand(sealed(2)); + try (var messages = mockStatic(Messages.class, i -> i.getArgument(0))) { + assertTrue(service.open(player, EquipmentSlot.HAND, seal)); + } + assertEquals(1, player.getInventory().getItemInMainHand().getAmount()); + assertFalse(player.getInventory().contains(Material.BLAZE_ROD)); + var dropped = player.getWorld().getEntitiesByClass(Item.class); + assertEquals(1, dropped.size()); + assertEquals(Material.BLAZE_ROD, dropped.iterator().next().getItemStack().getType()); + } + + @Test + void aSealThatCannotBeMadeIsKept() { + var player = server.addPlayer(); + var roller = mock(ArtifactRoller.class); + var seal = new SealedArtifactDef("cerrith", "v.ECHO_SHARD", "cerrith", weights()); + player.getInventory().setItemInMainHand(sealed(1)); + try (var messages = mockStatic(Messages.class, i -> i.getArgument(0))) { + when(roller.roll(anyString(), anyString())).thenReturn(ArtifactRoll.error("artifact.roll.no_bracket")); + var builder = new ArrayList(); + var service = + new SealedArtifactService( + fixed(0.0), + roller, + roll -> { + builder.add(roll); + return artifactItem(); + }); + assertFalse(service.open(player, EquipmentSlot.HAND, seal)); + assertTrue(builder.isEmpty()); + assertEquals("artifact.sealed.failed", player.nextMessage()); + + when(roller.roll(anyString(), anyString())).thenReturn(ArtifactRoll.ok("cerrith", "common", List.of())); + assertFalse(new SealedArtifactService(fixed(0.0), roller, roll -> null).open(player, EquipmentSlot.HAND, seal)); + assertEquals("artifact.sealed.failed", player.nextMessage()); + + // The real builder needs MMOItems, which tests do not load, so it fails safely too. + new ElementsLoader().loadFolder(Path.of("src/main/resources/elements").toFile()); + Cache.artifactAuraCap = 150; + assertTrue(ArtifactConfigLoader.loadFolder(Path.of("src/main/resources/artifacts").toFile())); + assertFalse(new SealedArtifactService().open(player, EquipmentSlot.HAND, seal)); + assertEquals("artifact.sealed.failed", player.nextMessage()); + } + assertEquals(1, player.getInventory().getItemInMainHand().getAmount()); + assertEquals(Material.ECHO_SHARD, player.getInventory().getItemInMainHand().getType()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/listener/SealedArtifactListenerTest.java b/src/test/java/net/tfminecraft/magic/listener/SealedArtifactListenerTest.java new file mode 100644 index 0000000..eda95e4 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/listener/SealedArtifactListenerTest.java @@ -0,0 +1,137 @@ +package net.tfminecraft.magic.listener; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Map; +import java.util.UUID; +import net.tfminecraft.magic.artifact.sealed.SealedArtifactDef; +import net.tfminecraft.magic.artifact.sealed.SealedArtifactRegistry; +import net.tfminecraft.magic.artifact.sealed.SealedArtifactService; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; + +class SealedArtifactListenerTest { + final SealedArtifactDef seal = new SealedArtifactDef("random", "v.ECHO_SHARD", null, Map.of("common", 1.0)); + final ItemStack sealed = new ItemStack(Material.ECHO_SHARD); + final ItemStack plain = new ItemStack(Material.STONE); + ServerMock server; + SealedArtifactService service; + SealedArtifactListener listener; + Player player; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + service = mock(SealedArtifactService.class); + listener = new SealedArtifactListener(() -> service); + player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + } + + @AfterEach + void cleanup() { + MockBukkit.unmock(); + } + + PlayerInteractEvent event(Action action, EquipmentSlot hand, ItemStack item, Material block) { + var event = mock(PlayerInteractEvent.class); + when(event.getAction()).thenReturn(action); + when(event.getHand()).thenReturn(hand); + when(event.getItem()).thenReturn(item); + when(event.getPlayer()).thenReturn(player); + when(event.useItemInHand()).thenReturn(Event.Result.DEFAULT); + if (block != null) { + var clicked = mock(Block.class); + when(clicked.getType()).thenReturn(block); + when(event.getClickedBlock()).thenReturn(clicked); + } + return event; + } + + void assertOpened(PlayerInteractEvent event, EquipmentSlot hand) { + server.getScheduler().performOneTick(); + listener.onInteract(event); + verify(event).setUseItemInHand(Event.Result.DENY); + verify(event).setUseInteractedBlock(Event.Result.DENY); + verify(service).open(player, hand, seal); + clearInvocations(service); + } + + void assertIgnored(PlayerInteractEvent event) { + listener.onInteract(event); + verify(event, never()).setUseItemInHand(any()); + verifyNoInteractions(service); + } + + @Test + void rightClickBreaksTheSealInTheClickingHand() { + assertNotNull(new SealedArtifactListener()); + try (var registry = mockStatic(SealedArtifactRegistry.class)) { + registry.when(() -> SealedArtifactRegistry.match(sealed)).thenReturn(seal); + assertOpened(event(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND, sealed, null), EquipmentSlot.HAND); + assertOpened(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND, sealed, Material.STONE), EquipmentSlot.HAND); + // A block in the event of an air click is not used. + assertOpened(event(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND, sealed, Material.CHEST), EquipmentSlot.HAND); + // Sneaking opens it even on a usable block. + when(player.isSneaking()).thenReturn(true); + assertOpened(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND, sealed, Material.CHEST), EquipmentSlot.HAND); + when(player.isSneaking()).thenReturn(false); + assertOpened(event(Action.RIGHT_CLICK_AIR, EquipmentSlot.OFF_HAND, sealed, null), EquipmentSlot.OFF_HAND); + } + } + + @Test + void oneClickBreaksOneSealEvenWhenItFiresSeveralEvents() { + try (var registry = mockStatic(SealedArtifactRegistry.class)) { + registry.when(() -> SealedArtifactRegistry.match(sealed)).thenReturn(seal); + assertOpened(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND, sealed, Material.STONE), EquipmentSlot.HAND); + // The same click's air and off-hand events, still in this tick, are denied but break nothing. + for (var extra : + new PlayerInteractEvent[] { + event(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND, sealed, null), + event(Action.RIGHT_CLICK_AIR, EquipmentSlot.OFF_HAND, sealed, null) + }) { + listener.onInteract(extra); + verify(extra).setUseItemInHand(Event.Result.DENY); + verifyNoInteractions(service); + } + // Another player in the same tick still gets theirs. + var other = player; + player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + var theirs = event(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND, sealed, null); + listener.onInteract(theirs); + verify(service).open(player, EquipmentSlot.HAND, seal); + clearInvocations(service); + // And the first player's next click, a tick later, works again. + player = other; + assertOpened(event(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND, sealed, null), EquipmentSlot.HAND); + } + } + + @Test + void otherClicksItemsAndBlockedUsesAreLeftAlone() { + try (var registry = mockStatic(SealedArtifactRegistry.class)) { + registry.when(() -> SealedArtifactRegistry.match(sealed)).thenReturn(seal); + assertIgnored(event(Action.LEFT_CLICK_AIR, EquipmentSlot.HAND, sealed, null)); + assertIgnored(event(Action.PHYSICAL, EquipmentSlot.HAND, sealed, Material.STONE)); + assertIgnored(event(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND, plain, null)); + assertIgnored(event(Action.RIGHT_CLICK_AIR, null, sealed, null)); + // Chests, doors and stations still open normally. + assertIgnored(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND, sealed, Material.CHEST)); + var denied = event(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND, sealed, null); + when(denied.useItemInHand()).thenReturn(Event.Result.DENY); + assertIgnored(denied); + } + } +} From 30ffb2bf80e4bf2f431ca1b2bad015e891fa3eb1 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:53:33 +0200 Subject: [PATCH 2/2] Stop interleaved clicks from opening extra seals and keep huge weights fair The once-per-click guard remembered only the last player, so player A's trailing event after player B's click in the same tick opened a second seal. It now tracks every player who opened a seal this tick. Rarity weights are divided by the largest before summing, as ArtifactRoller does, so finite weights near Double.MAX_VALUE no longer overflow to the last rarity. Co-Authored-By: Claude Opus 5.5 --- .../artifact/sealed/SealedArtifactService.java | 9 +++++++-- .../magic/listener/SealedArtifactListener.java | 18 +++++++++++------- .../artifact/sealed/SealedArtifactTest.java | 10 +++++++++- .../listener/SealedArtifactListenerTest.java | 7 ++++++- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactService.java b/src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactService.java index c9fedd6..2c05785 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactService.java +++ b/src/main/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactService.java @@ -37,15 +37,20 @@ public SealedArtifactService() { /** Picks a rarity id by weight. Weights must be above zero, as the loader keeps them. */ String pickRarity(Map weights) { + // Each weight is divided by the largest, so huge weights cannot overflow the sum. + double maximum = 0.0; + for (double weight : weights.values()) { + maximum = Math.max(maximum, weight); + } double total = 0.0; for (double weight : weights.values()) { - total += weight; + total += weight / maximum; } double pick = rng.nextDouble() * total; String last = null; for (Map.Entry entry : weights.entrySet()) { last = entry.getKey(); - pick -= entry.getValue(); + pick -= entry.getValue() / maximum; if (pick < 0.0) { return last; } diff --git a/src/main/java/net/tfminecraft/magic/listener/SealedArtifactListener.java b/src/main/java/net/tfminecraft/magic/listener/SealedArtifactListener.java index 349ed19..cccce86 100644 --- a/src/main/java/net/tfminecraft/magic/listener/SealedArtifactListener.java +++ b/src/main/java/net/tfminecraft/magic/listener/SealedArtifactListener.java @@ -1,5 +1,7 @@ package net.tfminecraft.magic.listener; +import java.util.HashSet; +import java.util.Set; import java.util.UUID; import java.util.function.Supplier; @@ -25,8 +27,8 @@ public final class SealedArtifactListener implements Listener { private final Supplier services; - private UUID lastOpener; - private int lastOpenTick; + private final Set openedThisTick = new HashSet<>(); + private int openTick; public SealedArtifactListener() { this(SealedArtifactService::new); @@ -54,14 +56,16 @@ public void onInteract(PlayerInteractEvent event) { event.setUseItemInHand(Event.Result.DENY); event.setUseInteractedBlock(Event.Result.DENY); // One click can fire several interact events (block then air, main then off hand), - // all in the same tick. Only the first one breaks a seal. - Player player = event.getPlayer(); + // all in the same tick. Only each player's first one breaks a seal. int tick = Bukkit.getCurrentTick(); - if (player.getUniqueId().equals(lastOpener) && tick == lastOpenTick) { + if (tick != openTick) { + openTick = tick; + openedThisTick.clear(); + } + Player player = event.getPlayer(); + if (!openedThisTick.add(player.getUniqueId())) { return; } - lastOpener = player.getUniqueId(); - lastOpenTick = tick; services.get().open(player, hand, def); } diff --git a/src/test/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactTest.java b/src/test/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactTest.java index 877234e..bd0c6ce 100644 --- a/src/test/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactTest.java +++ b/src/test/java/net/tfminecraft/magic/artifact/sealed/SealedArtifactTest.java @@ -270,7 +270,7 @@ static Map weights() { @Test void rarityPickFollowsTheWeights() { - var service = new SealedArtifactService(fixed(0.0, 0.649, 0.65, 0.899, 0.9, 0.969, 0.97, 0.999, 1.0), null, null); + var service = new SealedArtifactService(fixed(0.0, 0.6499, 0.6501, 0.8999, 0.9001, 0.9699, 0.9701, 0.999, 1.0), null, null); var picks = new ArrayList(); for (int i = 0; i < 9; i++) { picks.add(service.pickRarity(weights())); @@ -280,6 +280,14 @@ void rarityPickFollowsTheWeights() { picks); assertNull(service.pickRarity(Map.of())); + // Weights too large to add up still split evenly. + var huge = new LinkedHashMap(); + huge.put("common", 1e308); + huge.put("legendary", 1e308); + var halves = new SealedArtifactService(fixed(0.25, 0.75), null, null); + assertEquals("common", halves.pickRarity(huge)); + assertEquals("legendary", halves.pickRarity(huge)); + var seeded = new SealedArtifactService(new Random(7), null, null); var counts = new HashMap(); for (int i = 0; i < 100_000; i++) { diff --git a/src/test/java/net/tfminecraft/magic/listener/SealedArtifactListenerTest.java b/src/test/java/net/tfminecraft/magic/listener/SealedArtifactListenerTest.java index eda95e4..cb6b007 100644 --- a/src/test/java/net/tfminecraft/magic/listener/SealedArtifactListenerTest.java +++ b/src/test/java/net/tfminecraft/magic/listener/SealedArtifactListenerTest.java @@ -113,8 +113,13 @@ void oneClickBreaksOneSealEvenWhenItFiresSeveralEvents() { listener.onInteract(theirs); verify(service).open(player, EquipmentSlot.HAND, seal); clearInvocations(service); - // And the first player's next click, a tick later, works again. + // The first player's click still fires no more seals after the other player's. player = other; + var late = event(Action.RIGHT_CLICK_AIR, EquipmentSlot.OFF_HAND, sealed, null); + listener.onInteract(late); + verify(late).setUseItemInHand(Event.Result.DENY); + verifyNoInteractions(service); + // And the first player's next click, a tick later, works again. assertOpened(event(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND, sealed, null), EquipmentSlot.HAND); } }