diff --git a/src/main/java/net/tfminecraft/magic/Cache.java b/src/main/java/net/tfminecraft/magic/Cache.java index 3061678..f3a850f 100644 --- a/src/main/java/net/tfminecraft/magic/Cache.java +++ b/src/main/java/net/tfminecraft/magic/Cache.java @@ -1,10 +1,13 @@ package net.tfminecraft.magic; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashSet; import java.util.Locale; import java.util.Set; +import io.lumine.mythic.lib.skill.trigger.TriggerType; + /** * Runtime flags from config.yml. */ @@ -36,6 +39,9 @@ public final class Cache { */ public static volatile Set castTriggers = Set.of(); + public static Set runeTypes = new HashSet<>(); + public static Set runeKeybinds = new LinkedHashSet<>(); + public static double tickIntervalSeconds() { return Math.max(1L, tickIntervalTicks) / 20.0; } diff --git a/src/main/java/net/tfminecraft/magic/Magic.java b/src/main/java/net/tfminecraft/magic/Magic.java index 683c4d8..1ee9d0a 100644 --- a/src/main/java/net/tfminecraft/magic/Magic.java +++ b/src/main/java/net/tfminecraft/magic/Magic.java @@ -300,6 +300,7 @@ private void createConfigs() { "gear/parts.yml", "gear/socket-colours.yml", "gear/orbs.yml", + "gear/model-schemes.yml", "elements/elements.yml", "artifacts/generator.yml", "artifacts/model-schemes.yml", diff --git a/src/main/java/net/tfminecraft/magic/artifact/generate/ArtifactItemBuilder.java b/src/main/java/net/tfminecraft/magic/artifact/generate/ArtifactItemBuilder.java index f6fbef7..0b5cfe8 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/generate/ArtifactItemBuilder.java +++ b/src/main/java/net/tfminecraft/magic/artifact/generate/ArtifactItemBuilder.java @@ -217,10 +217,7 @@ public static String kindForPath(ArtifactTypeDef type, String path) { private static String buildDisplayName(ArtifactTypeDef type, String baseName) { String rolledName = baseName != null && !baseName.isBlank() ? baseName : type.getElementId(); ElementDef element = ElementRegistry.getById(type.getElementId()); - String elementColor = element != null && element.getColor() != null && !element.getColor().isBlank() - ? element.getColor() - : "#ffffff"; - return MagicText.format(elementColor + rolledName); + return MagicText.elementText(element, rolledName); } private static void applyName(MMOItem mmo, String displayName) { diff --git a/src/main/java/net/tfminecraft/magic/attunement/AttunementCaptureService.java b/src/main/java/net/tfminecraft/magic/attunement/AttunementCaptureService.java index 3099931..89a5a40 100644 --- a/src/main/java/net/tfminecraft/magic/attunement/AttunementCaptureService.java +++ b/src/main/java/net/tfminecraft/magic/attunement/AttunementCaptureService.java @@ -5,6 +5,8 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; +import net.tfminecraft.magic.model.ElementDef; +import net.tfminecraft.magic.registry.ElementRegistry; import net.tfminecraft.magic.session.ResonanceSession; public final class AttunementCaptureService { @@ -22,6 +24,10 @@ public static void credit( if (session == null || elementId == null || elementId.isBlank() || gain <= EPSILON) { return; } + ElementDef element = ElementRegistry.getById(elementId.toLowerCase(java.util.Locale.ROOT)); + if (element != null && !element.isUnlocked(meditator)) { + return; + } double before = session.getResonance(elementId); session.addResonance(elementId, gain); AuraLog.append( diff --git a/src/main/java/net/tfminecraft/magic/command/MagicCommand.java b/src/main/java/net/tfminecraft/magic/command/MagicCommand.java index 4341c4a..a1e9586 100644 --- a/src/main/java/net/tfminecraft/magic/command/MagicCommand.java +++ b/src/main/java/net/tfminecraft/magic/command/MagicCommand.java @@ -15,6 +15,7 @@ import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; +import io.lumine.mythic.lib.skill.trigger.TriggerType; import net.tfminecraft.magic.Cache; import net.tfminecraft.magic.Magic; import net.tfminecraft.magic.Messages; @@ -35,6 +36,7 @@ import net.tfminecraft.magic.artifact.shrine.ShrineChargeService; import net.tfminecraft.magic.attunement.AuraLog; import net.tfminecraft.magic.gear.GearRefresher; +import net.tfminecraft.magic.gear.RuneKeybind; import net.tfminecraft.magic.model.ElementDef; import net.tfminecraft.magic.profile.MagicProfileService; import net.tfminecraft.magic.registry.ElementRegistry; @@ -48,13 +50,23 @@ public final class MagicCommand implements CommandExecutor, TabCompleter { @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { - if (!hasAdmin(sender)) { - sender.sendMessage(Messages.get("admin.no_permission")); + if (args.length == 0) { + if (hasAdmin(sender)) { + sender.sendMessage(Messages.get("admin.usage")); + } else if (hasRuneKeybind(sender)) { + sender.sendMessage(Messages.get("rune.usage")); + } else { + sender.sendMessage(Messages.get("admin.no_permission")); + } return true; } - if (args.length == 0) { - sender.sendMessage(Messages.get("admin.usage")); + if ("rune".equalsIgnoreCase(args[0])) { + return handleRune(sender, args); + } + + if (!hasAdmin(sender)) { + sender.sendMessage(Messages.get("admin.no_permission")); return true; } @@ -97,6 +109,38 @@ public boolean onCommand(CommandSender sender, Command command, String label, St return true; } + private static boolean handleRune(CommandSender sender, String[] args) { + if (!(sender instanceof Player player)) { + sender.sendMessage(Messages.get("rune.players_only")); + return true; + } + if (!hasRuneKeybind(sender)) { + sender.sendMessage(Messages.get("rune.no_permission")); + return true; + } + if (args.length < 3 || !"keybind".equalsIgnoreCase(args[1])) { + sender.sendMessage(Messages.get("rune.usage")); + return true; + } + TriggerType trigger = resolveRuneKeybind(args[2]); + if (trigger == null) { + sender.sendMessage(Messages.get("rune.unknown_trigger")); + return true; + } + ItemStack held = player.getInventory().getItemInMainHand(); + RuneKeybind.Outcome outcome = RuneKeybind.apply(held, trigger); + switch (outcome.result()) { + case NOT_RUNE -> sender.sendMessage(Messages.get("rune.not_a_rune")); + case NO_ABILITIES -> sender.sendMessage(Messages.get("rune.no_abilities")); + case FAILED -> sender.sendMessage(Messages.get("rune.failed")); + case OK -> { + player.getInventory().setItemInMainHand(outcome.item()); + sender.sendMessage(Messages.get("rune.success", "trigger", trigger.name())); + } + } + return true; + } + private static boolean handleRefresh(CommandSender sender) { if (!(sender instanceof Player player)) { sender.sendMessage(Messages.get("gear.refresh.players_only")); @@ -198,7 +242,7 @@ private static boolean handleResonance(CommandSender sender, String[] args) { } if ("reset".equals(action)) { String elementArg = args.length >= 4 ? args[3] : "all"; - if (!applyResonance(session, elementArg, Cache.defaultResonance, false, sender)) { + if (!applyResonance(target, session, elementArg, Cache.defaultResonance, false, sender)) { return true; } persistResonance(target, session); @@ -219,7 +263,7 @@ private static boolean handleResonance(CommandSender sender, String[] args) { return true; } if ("set".equals(action)) { - if (!applyResonance(session, elementArg, amount, false, sender)) { + if (!applyResonance(target, session, elementArg, amount, false, sender)) { return true; } persistResonance(target, session); @@ -231,7 +275,7 @@ private static boolean handleResonance(CommandSender sender, String[] args) { return true; } if ("add".equals(action)) { - if (!applyResonance(session, elementArg, amount, true, sender)) { + if (!applyResonance(target, session, elementArg, amount, true, sender)) { return true; } persistResonance(target, session); @@ -287,6 +331,7 @@ private static boolean handleShrine(CommandSender sender, String[] args) { } private static boolean applyResonance( + Player target, ResonanceSession session, String elementArg, double amount, @@ -297,22 +342,49 @@ private static boolean applyResonance( return false; } if ("all".equalsIgnoreCase(elementArg)) { + List skipped = new ArrayList<>(); + int applied = 0; for (ElementDef element : ElementRegistry.getAll()) { - double next = add ? session.getResonance(element.getId()) + amount : amount; + double current = session.getResonance(element.getId()); + double next = add ? current + amount : amount; + if (isGain(current, next) && !element.isUnlocked(target)) { + skipped.add(element.getId()); + continue; + } session.setResonance(element.getId(), next); + applied++; } - return true; + if (!skipped.isEmpty()) { + sender.sendMessage(Messages.get( + "resonance.admin.locked_skipped", + "player", target.getName(), + "elements", String.join(", ", skipped))); + } + return applied > 0; } ElementDef element = ElementRegistry.getById(elementArg.toLowerCase(Locale.ROOT)); if (element == null) { sender.sendMessage(Messages.get("resonance.admin.unknown_element")); return false; } - double next = add ? session.getResonance(element.getId()) + amount : amount; + double current = session.getResonance(element.getId()); + double next = add ? current + amount : amount; + if (isGain(current, next) && !element.isUnlocked(target)) { + sender.sendMessage(Messages.get( + "resonance.admin.locked", + "player", target.getName(), + "permission", element.getPermission(), + "element", element.getId())); + return false; + } session.setResonance(element.getId(), next); return true; } + private static boolean isGain(double current, double next) { + return next > current + 0.0001; + } + private static void persistResonance(Player target, ResonanceSession session) { MagicProfileService profiles = Magic.plugin.getProfileService(); if (profiles != null) { @@ -533,13 +605,63 @@ private static boolean hasAdmin(CommandSender sender) { return sender.hasPermission("magic.admin") || sender.hasPermission("magic.admin.reload"); } + private static boolean hasRuneKeybind(CommandSender sender) { + return sender.hasPermission("magic.rune.keybind"); + } + + private static TriggerType resolveRuneKeybind(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + String want = raw.trim().toUpperCase(Locale.ROOT); + for (TriggerType type : Cache.runeKeybinds) { + if (type != null && want.equals(type.name())) { + return type; + } + } + return null; + } + @Override public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { - if (!hasAdmin(sender) || args.length == 0) { + if (args.length == 0) { + return Collections.emptyList(); + } + boolean admin = hasAdmin(sender); + boolean rune = hasRuneKeybind(sender); + if (!admin && !rune) { return Collections.emptyList(); } if (args.length == 1) { - return filterPrefix(SUBCOMMANDS, args[0]); + List options = new ArrayList<>(); + if (rune) { + options.add("rune"); + } + if (admin) { + options.addAll(SUBCOMMANDS); + } + return filterPrefix(options, args[0]); + } + if ("rune".equalsIgnoreCase(args[0])) { + if (!rune) { + return Collections.emptyList(); + } + if (args.length == 2) { + return filterPrefix(List.of("keybind"), args[1]); + } + if (args.length == 3 && "keybind".equalsIgnoreCase(args[1])) { + List names = new ArrayList<>(); + for (TriggerType type : Cache.runeKeybinds) { + if (type != null && type.name() != null) { + names.add(type.name()); + } + } + return filterPrefix(names, args[2]); + } + return Collections.emptyList(); + } + if (!admin) { + return Collections.emptyList(); } if ("fillchest".equalsIgnoreCase(args[0])) { if (args.length == 2) { diff --git a/src/main/java/net/tfminecraft/magic/gear/ArchetypeDef.java b/src/main/java/net/tfminecraft/magic/gear/ArchetypeDef.java index 6875beb..88ab08f 100644 --- a/src/main/java/net/tfminecraft/magic/gear/ArchetypeDef.java +++ b/src/main/java/net/tfminecraft/magic/gear/ArchetypeDef.java @@ -14,6 +14,7 @@ public final class ArchetypeDef { private final GearType type; private final String name; private final String template; + private final String icon; private final boolean melee; private final List required; private final Map slots; @@ -23,12 +24,14 @@ public ArchetypeDef( GearType type, String name, String template, + String icon, boolean melee, List required, Map slots) { this.type = type; this.name = name == null || name.isBlank() ? type.getDisplayName() : name; this.template = template == null ? "" : template.trim(); + this.icon = icon == null ? "" : icon.trim(); this.melee = melee; this.required = List.copyOf(required == null ? List.of() : required); Map copy = new LinkedHashMap<>(); @@ -55,6 +58,10 @@ public String getTemplate() { return template; } + public String getIcon() { + return icon; + } + public boolean isMelee() { return melee; } diff --git a/src/main/java/net/tfminecraft/magic/gear/GearCache.java b/src/main/java/net/tfminecraft/magic/gear/GearCache.java index fb0a2ac..10a033c 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearCache.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearCache.java @@ -13,6 +13,9 @@ public final class GearCache { public static boolean alignmentEnabled = false; + /** When false, gem socket colour strings omit Common/Rare/Epic/Legendary prefix. */ + public static boolean socketRarityPrefix = true; + /** Weapon tier band in the spell's element -> bonus. Never a penalty. */ private static final Map ALIGNMENT = new LinkedHashMap<>(); diff --git a/src/main/java/net/tfminecraft/magic/gear/GearCosts.java b/src/main/java/net/tfminecraft/magic/gear/GearCosts.java index f344306..c6b85fb 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearCosts.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearCosts.java @@ -4,10 +4,12 @@ import java.util.HashMap; import java.util.Map; +import org.bukkit.Location; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.magic.util.ItemRef; public final class GearCosts { @@ -29,8 +31,12 @@ public static Map total(Collection parts) { return costs; } + public static boolean bypasses(Player player) { + return player != null && player.hasPermission("magic.bypass_crafting_cost"); + } + public static boolean has(Player player, Collection parts) { - if (player != null && player.hasPermission("magic.bypass_crafting_cost")) { + if (bypasses(player)) { return true; } Map costs = total(parts); @@ -59,7 +65,7 @@ public static boolean has(Player player, Collection parts) { } public static void take(Player player, Collection parts) { - if (player == null || player.hasPermission("magic.bypass_crafting_cost")) { + if (player == null || bypasses(player)) { return; } for (Map.Entry entry : total(parts).entrySet()) { @@ -82,4 +88,29 @@ public static void take(Player player, Collection parts) { } player.updateInventory(); } + + public static void refund(Player player, Map costs, Location drop) { + if (player == null || costs == null || costs.isEmpty()) { + return; + } + Location at = drop == null ? player.getLocation() : drop.clone().add(0.5, 1.0, 0.5); + for (Map.Entry entry : costs.entrySet()) { + int remaining = entry.getValue(); + while (remaining > 0) { + ItemStack stack = ItemRef.build(entry.getKey()); + if (stack == null || stack.getType().isAir()) { + break; + } + int give = Math.min(remaining, Math.max(1, stack.getMaxStackSize())); + stack.setAmount(give); + remaining -= give; + for (ItemStack leftover : player.getInventory().addItem(stack).values()) { + if (leftover != null && !leftover.getType().isAir() && at.getWorld() != null) { + at.getWorld().dropItem(at, leftover); + } + } + } + } + player.updateInventory(); + } } diff --git a/src/main/java/net/tfminecraft/magic/gear/GearItemBuilder.java b/src/main/java/net/tfminecraft/magic/gear/GearItemBuilder.java index c90af06..0358862 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearItemBuilder.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearItemBuilder.java @@ -3,7 +3,6 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; -import java.util.Map; import org.bukkit.Bukkit; import org.bukkit.Material; @@ -44,11 +43,13 @@ public static ItemStack rewriteSockets(ItemStack stack, int band) { if (archetype == null || GearProvenance.socketsLocked(stack)) { return stack; } - List colours = SocketLayout.colours(archetype, GearProvenance.resolveParts(stack), band); - ItemStack rewritten = applySockets(stack, colours); + List parts = GearProvenance.resolveParts(stack); + List colours = SocketLayout.colours(archetype, parts, band); + ItemStack rewritten = applyMmoData(stack, colours, parts); if (rewritten == null) { return stack; } + rewritten = GearModelResolver.apply(rewritten, type, parts); copyGearPdc(stack, rewritten); GearProvenance.lockSockets(rewritten); WeaponRequirement.fromItem(stack).persist(rewritten); @@ -77,19 +78,29 @@ private static ItemStack build(GearType type, Collection parts, boolean return decoratePreview(base, type, parts); } List colours = SocketLayout.colours(archetype, parts, band); - ItemStack withSockets = applySockets(base, colours); - if (withSockets == null) { - withSockets = base; + ItemStack withMmo = applyMmoData(base, colours, parts); + if (withMmo == null) { + withMmo = base; } - GearProvenance.stamp(withSockets, type, parts); - return WeaponLore.updateItem(withSockets); + withMmo = GearModelResolver.apply(withMmo, type, parts); + GearProvenance.stamp(withMmo, type, parts); + return WeaponLore.updateItem(withMmo); } private static boolean requiredPresent(ArchetypeDef archetype, Collection parts) { if (archetype.getRequired().isEmpty()) { return parts != null && !parts.isEmpty(); } - for (String required : archetype.getRequired()) { + PartDef core = null; + if (parts != null) { + for (PartDef part : parts) { + if (part != null && PartSlots.CORE.equalsIgnoreCase(part.getPartType())) { + core = part; + break; + } + } + } + for (String required : PartSlots.open(archetype, core)) { boolean found = false; if (parts != null) { for (PartDef part : parts) { @@ -106,17 +117,20 @@ private static boolean requiredPresent(ArchetypeDef archetype, Collection colours) { - if (stack == null || colours == null || colours.isEmpty() || !mmoItemsPresent()) { + private static ItemStack applyMmoData(ItemStack stack, List colours, Collection parts) { + if (stack == null || !mmoItemsPresent()) { return stack; } try { LiveMMOItem mmo = new LiveMMOItem(NBTItem.get(stack)); - mmo.setData(ItemStats.GEM_SOCKETS, new GemSocketsData(new ArrayList<>(colours))); + if (colours != null && !colours.isEmpty()) { + mmo.setData(ItemStats.GEM_SOCKETS, new GemSocketsData(new ArrayList<>(colours))); + } + GearStatApplicator.apply(mmo, parts); ItemStack built = mmo.newBuilder().build(); return built == null || built.getType().isAir() ? stack : built; } catch (Exception ex) { - Magic.plugin.getLogger().warning("[Magic] Failed to write GEM_SOCKETS: " + ex.getMessage()); + Magic.plugin.getLogger().warning("[Magic] Failed to write gear MMO data: " + ex.getMessage()); return stack; } } @@ -149,6 +163,14 @@ private static void copyGearPdc(ItemStack from, ItemStack to) { org.bukkit.persistence.PersistentDataType.INTEGER, archetypeRevision); } + Integer majority = fromMeta.getPersistentDataContainer().get( + GearKeys.majorityTier(), org.bukkit.persistence.PersistentDataType.INTEGER); + if (majority != null) { + toMeta.getPersistentDataContainer().set( + GearKeys.majorityTier(), + org.bukkit.persistence.PersistentDataType.INTEGER, + majority); + } to.setItemMeta(toMeta); } @@ -162,14 +184,13 @@ private static ItemStack decoratePreview( } List lore = meta.hasLore() ? new ArrayList<>(meta.getLore()) : new ArrayList<>(); lore.add(""); + int majority = MajorityTierResolver.resolve(parts); + if (majority > 0) { + lore.add("§eTier " + MajorityTierResolver.toRoman(majority)); + } lore.add("§6Sockets"); lore.addAll(SocketLayout.previewLines(ArchetypeRegistry.get(type), parts)); - Map costs = GearCosts.total(parts); - if (!costs.isEmpty()) { - lore.add(""); - lore.add("§aCost"); - lore.addAll(CostFormatter.getCostsFormatted(costs)); - } + CostFormatter.appendInput(lore, GearCosts.total(parts)); lore.add(""); lore.add("§eClick to prepare on the station"); if (!mmoItemsPresent()) { diff --git a/src/main/java/net/tfminecraft/magic/gear/GearKeys.java b/src/main/java/net/tfminecraft/magic/gear/GearKeys.java index 01efad3..08d7027 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearKeys.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearKeys.java @@ -36,6 +36,10 @@ public static NamespacedKey archetypeRevision() { return new NamespacedKey(Magic.plugin, "gear_archetype_revision"); } + public static NamespacedKey majorityTier() { + return new NamespacedKey(Magic.plugin, "majority_tier"); + } + public static NamespacedKey broken() { return new NamespacedKey(Magic.plugin, "gear_broken"); } diff --git a/src/main/java/net/tfminecraft/magic/gear/GearModelResolver.java b/src/main/java/net/tfminecraft/magic/gear/GearModelResolver.java new file mode 100644 index 0000000..93873f0 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/GearModelResolver.java @@ -0,0 +1,117 @@ +package net.tfminecraft.magic.gear; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.magic.util.LegacyModelData; +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.util.ItemRef; + +public final class GearModelResolver { + + private GearModelResolver() {} + + public static String path(GearType type, Collection parts) { + GearModelScheme scheme = winner(parts); + if (scheme == null) { + return null; + } + return scheme.pathFor(type); + } + + public static GearModelScheme winner(Collection parts) { + if (parts == null) { + return null; + } + Map votes = new LinkedHashMap<>(); + String coreScheme = ""; + for (PartDef part : parts) { + if (part == null || !part.hasModelScheme()) { + continue; + } + String id = part.getSchemeId(); + if (!GearModelSchemeRegistry.contains(id)) { + continue; + } + votes.merge(id, part.getSchemeWeight(), Integer::sum); + if (PartSlots.CORE.equalsIgnoreCase(part.getPartType())) { + coreScheme = id; + } + } + if (votes.isEmpty()) { + return null; + } + int best = -1; + String bestId = null; + boolean tie = false; + for (Map.Entry entry : votes.entrySet()) { + int weight = entry.getValue(); + if (weight > best) { + best = weight; + bestId = entry.getKey(); + tie = false; + } else if (weight == best) { + tie = true; + } + } + // bestId is already the first scheme seen among the tied leaders; the core wins a tie it is in. + if (tie && !coreScheme.isEmpty() && votes.getOrDefault(coreScheme, 0) == best) { + bestId = coreScheme; + } + return GearModelSchemeRegistry.get(bestId); + } + + public static ItemStack apply(ItemStack stack, GearType type, Collection parts) { + if (stack == null) { + return null; + } + String path = path(type, parts); + if (path == null || path.isBlank()) { + return stack; + } + String normalized = ItemRef.normalize(path); + String prefix = normalized.split("\\.")[0]; + try { + if (prefix.equalsIgnoreCase("ia")) { + return TLibs.getItemAPI().getArmorMerger().merge(stack, Optional.empty(), normalized); + } + if (prefix.equalsIgnoreCase("v")) { + return applyVanilla(stack, normalized); + } + Magic.plugin.getLogger().warning("[Magic] Unknown gear model path: " + path); + } catch (Exception ex) { + Magic.plugin.getLogger().warning("[Magic] Failed to apply gear model " + path + ": " + ex.getMessage()); + } + return stack; + } + + // This path mutates the existing ItemStack; replacing it would change aliases held by callers. + @SuppressWarnings("deprecation") + private static ItemStack applyVanilla(ItemStack stack, String path) { + String[] parts = path.split("\\."); + if (parts.length < 2) { + return stack; + } + Material material = Material.matchMaterial(parts[1].toUpperCase()); + if (material == null) { + Magic.plugin.getLogger().warning("[Magic] Unknown vanilla material in gear model: " + path); + return stack; + } + stack.setType(material); + if (parts.length >= 3) { + ItemMeta meta = stack.getItemMeta(); + if (meta != null) { + LegacyModelData.set(meta, Integer.parseInt(parts[2])); + stack.setItemMeta(meta); + } + } + return stack; + } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/GearModelScheme.java b/src/main/java/net/tfminecraft/magic/gear/GearModelScheme.java new file mode 100644 index 0000000..9f2c6c9 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/GearModelScheme.java @@ -0,0 +1,38 @@ +package net.tfminecraft.magic.gear; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Locale; +import java.util.Map; + +public final class GearModelScheme { + + private final String id; + private final Map paths; + + public GearModelScheme(String id, Map paths) { + this.id = id == null ? "" : id.trim().toLowerCase(Locale.ROOT); + EnumMap copy = new EnumMap<>(GearType.class); + if (paths != null) { + for (Map.Entry entry : paths.entrySet()) { + if (entry.getKey() == null || entry.getValue() == null || entry.getValue().isBlank()) { + continue; + } + copy.put(entry.getKey(), entry.getValue().trim()); + } + } + this.paths = Collections.unmodifiableMap(copy); + } + + public String getId() { + return id; + } + + public String pathFor(GearType type) { + if (type == null) { + return null; + } + String path = paths.get(type); + return path == null || path.isBlank() ? null : path; + } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/GearModelSchemeRegistry.java b/src/main/java/net/tfminecraft/magic/gear/GearModelSchemeRegistry.java new file mode 100644 index 0000000..560d2c0 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/GearModelSchemeRegistry.java @@ -0,0 +1,43 @@ +package net.tfminecraft.magic.gear; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +public final class GearModelSchemeRegistry { + + private static final Map BY_ID = new LinkedHashMap<>(); + + private GearModelSchemeRegistry() {} + + public static void clear() { + BY_ID.clear(); + } + + public static void register(GearModelScheme scheme) { + if (scheme == null || scheme.getId().isEmpty()) { + return; + } + BY_ID.put(scheme.getId(), scheme); + } + + public static boolean contains(String id) { + return get(id) != null; + } + + public static GearModelScheme get(String id) { + if (id == null || id.isBlank()) { + return null; + } + return BY_ID.get(id.trim().toLowerCase(Locale.ROOT)); + } + + public static int size() { + return BY_ID.size(); + } + + public static Map all() { + return Collections.unmodifiableMap(BY_ID); + } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/GearProvenance.java b/src/main/java/net/tfminecraft/magic/gear/GearProvenance.java index 69b606a..96be631 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearProvenance.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearProvenance.java @@ -37,9 +37,45 @@ public static void stamp(ItemStack stack, GearType type, Collection par meta.getPersistentDataContainer().set( GearKeys.archetypeRevision(), PersistentDataType.INTEGER, archetype == null ? 1 : archetype.getRevision()); + writeMajority(meta, parts); stack.setItemMeta(meta); } + public static int majorityOf(ItemStack stack) { + if (stack == null || !stack.hasItemMeta()) { + return 0; + } + ItemMeta meta = stack.getItemMeta(); + if (meta == null) { + return 0; + } + Integer stored = meta.getPersistentDataContainer().get( + GearKeys.majorityTier(), PersistentDataType.INTEGER); + return stored == null ? 0 : stored; + } + + public static void applyMajority(ItemStack stack, Collection parts) { + if (stack == null || !stack.hasItemMeta()) { + return; + } + ItemMeta meta = stack.getItemMeta(); + if (meta == null) { + return; + } + writeMajority(meta, parts); + stack.setItemMeta(meta); + } + + private static void writeMajority(ItemMeta meta, Collection parts) { + int majority = MajorityTierResolver.resolve(parts); + if (majority > 0) { + meta.getPersistentDataContainer().set( + GearKeys.majorityTier(), PersistentDataType.INTEGER, majority); + } else { + meta.getPersistentDataContainer().remove(GearKeys.majorityTier()); + } + } + /** Stamped part ids that no longer exist in the live registry. */ public static List missingPartIds(ItemStack stack) { List missing = new ArrayList<>(); diff --git a/src/main/java/net/tfminecraft/magic/gear/GearRefresher.java b/src/main/java/net/tfminecraft/magic/gear/GearRefresher.java index cde2735..8b14938 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearRefresher.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearRefresher.java @@ -19,10 +19,9 @@ /** * Brings a crafted weapon back in line with the live gear config. * - * Parts carry no stats today, only cost and socket counts, so this rewrites the socket - * layout rather than rebuilding the whole item. Runes already in the weapon are merged - * into the new layout; any that no longer fit are held by {@link GearBrokenMarker} - * instead of being dropped. + * Socket colours and part stats are rewritten from the stamped parts. Runes already in + * the weapon are merged into the new layout; any that no longer fit are held by + * {@link GearBrokenMarker} instead of being dropped. */ public final class GearRefresher { @@ -54,6 +53,12 @@ public static ItemStack refresh(ItemStack stack, Player holder, boolean force) { } if (!force && !GearProvenance.isOutdated(stack)) { + int live = MajorityTierResolver.resolve(GearProvenance.resolveParts(stack)); + if (live > 0 && GearProvenance.majorityOf(stack) != live) { + ItemStack clone = stack.clone(); + GearProvenance.applyMajority(clone, GearProvenance.resolveParts(clone)); + return WeaponLore.updateItem(clone); + } return null; } @@ -75,6 +80,7 @@ public static ItemStack refresh(ItemStack stack, Player holder, boolean force) { WeaponRequirement.fromItem(stack).persist(rebuilt); WeaponRift.copy(stack, rebuilt); GearProvenance.syncRevisions(rebuilt); + GearProvenance.applyMajority(rebuilt, GearProvenance.resolveParts(rebuilt)); rebuilt.setAmount(stack.getAmount()); if (!orphaned.isEmpty()) { @@ -92,7 +98,7 @@ public static ItemStack refresh(ItemStack stack, Player holder, boolean force) { /** * Writes the target socket colours while carrying existing runes across. Unlike - * {@code GearItemBuilder.applySockets}, which builds fresh socket data at craft time, + * {@code GearItemBuilder} craft, which builds fresh socket data at craft time, * this puts every gem it can back into a matching empty socket first. */ private static ItemStack rewrite(ItemStack stack, List colours, List orphaned) { @@ -114,8 +120,13 @@ private static ItemStack rewrite(ItemStack stack, List colours, List UNKNOWN_LOGGED = new HashSet<>(); + + private GearStatApplicator() {} + + public static Map sum(Collection parts) { + Map totals = new LinkedHashMap<>(); + if (parts == null) { + return totals; + } + for (PartDef part : parts) { + if (part == null || part.getStats().isEmpty()) { + continue; + } + for (Map.Entry entry : part.getStats().entrySet()) { + if (entry.getKey() == null || entry.getValue() == null) { + continue; + } + totals.merge(entry.getKey(), entry.getValue(), Double::sum); + } + } + return totals; + } + + public static Set managedIds() { + Set ids = new HashSet<>(); + for (PartDef part : PartRegistry.getAll()) { + ids.addAll(part.getStats().keySet()); + } + return ids; + } + + public static void apply(MMOItem mmo, Collection parts) { + if (mmo == null || !GearItemBuilder.mmoItemsPresent()) { + return; + } + Set managed = managedIds(); + if (managed.isEmpty()) { + return; + } + Map totals = sum(parts); + for (String statId : managed) { + ItemStat itemStat = resolve(statId); + if (itemStat == null) { + continue; + } + StatHistory hist = mmo.computeStatHistory(itemStat); + if (hist != null) { + hist.clearExternalData(); + Object og = hist.getOriginalData(); + if (og instanceof DoubleData doubleOg) { + doubleOg.setValue(0); + } + mmo.setStatHistory(itemStat, hist); + } + mmo.setData(itemStat, new DoubleData(0)); + } + for (String statId : managed) { + applyDouble(mmo, statId, totals.getOrDefault(statId, 0.0)); + } + } + + private static void applyDouble(MMOItem mmo, String statId, double value) { + ItemStat itemStat = resolve(statId); + if (itemStat == null) { + return; + } + DoubleData data = new DoubleData(value); + mmo.setData(itemStat, data); + StatHistory hist = mmo.computeStatHistory(itemStat); + if (hist != null) { + hist.registerExternalData(data); + mmo.setStatHistory(itemStat, hist); + } + } + + private static ItemStat resolve(String statId) { + if (statId == null || statId.isBlank()) { + return null; + } + ItemStat itemStat = MMOItems.plugin.getStats().get(statId.toUpperCase(Locale.ROOT)); + if (itemStat == null && UNKNOWN_LOGGED.add(statId.toLowerCase(Locale.ROOT))) { + Magic.plugin.getLogger().warning("[Magic] Unknown MMOItems stat: " + statId); + } + return itemStat; + } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java b/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java index 7376165..350f736 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java @@ -1,11 +1,15 @@ package net.tfminecraft.magic.gear; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.UUID; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Sound; import org.bukkit.block.Block; +import org.bukkit.entity.Entity; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -15,6 +19,7 @@ import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; +import dev.lone.itemsadder.api.Events.FurnitureBreakEvent; import net.tfminecraft.tlibs.TLibs; import net.tfminecraft.magic.Messages; import net.tfminecraft.magic.charge.ChargeIds; @@ -24,14 +29,54 @@ public final class GearStationListener implements Listener { + /** ItemsAdder breaks furniture two ticks after the swing, so an abort swing needs cover. */ + private static final long ABORT_BREAK_GUARD_MILLIS = 1000L; + private final GearInventoryManager inventory = new GearInventoryManager(); + private final Map recentAborts = new HashMap<>(); public GearInventoryManager inventory() { return inventory; } + /** + * Left-clicks at the station (orb hits, aborts) are swings, and ItemsAdder breaks + * furniture on a swing. A station holding a weapon or running orbs must survive them. + */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onFurnitureBreak(FurnitureBreakEvent event) { + if (!isStationFurniture(event.getNamespacedID())) { + return; + } + Entity entity = event.getBukkitEntity(); + if (entity == null) { + return; + } + Location location = entity.getLocation().getBlock().getLocation(); + Long aborted = recentAborts.get(GearStationStore.key(location)); + boolean justAborted = aborted != null + && System.currentTimeMillis() - aborted < ABORT_BREAK_GUARD_MILLIS; + if (justAborted || GearStationStore.isOccupied(location) || GearOrbService.isActive(location)) { + event.setCancelled(true); + } + } + + private static boolean isStationFurniture(String namespacedId) { + String station = GearCache.station == null ? "" : GearCache.station.trim(); + int open = station.indexOf('('); + int close = station.indexOf(')', open + 1); + if (namespacedId == null || !station.toLowerCase().startsWith("iaf(") || close <= open) { + return false; + } + return station.substring(open + 1, close).equalsIgnoreCase(namespacedId); + } + @EventHandler(priority = EventPriority.HIGH) public void onInteract(PlayerInteractEvent event) { + if (event.getAction() == Action.LEFT_CLICK_BLOCK) { + tryAbort(event); + return; + } if (event.getAction() != Action.RIGHT_CLICK_BLOCK) { return; } @@ -150,4 +195,53 @@ private static void eject(Player player, Location location) { drop.getWorld().dropItem(drop, item); player.playSound(location, Sound.ENTITY_ITEM_PICKUP, 1f, 1f); } + + private void tryAbort(PlayerInteractEvent event) { + if (event.getHand() != EquipmentSlot.HAND) { + return; + } + if (!event.getPlayer().isSneaking()) { + return; + } + Block block = event.getClickedBlock(); + if (block == null) { + return; + } + try { + if (!TLibs.getBlockAPI().getChecker().checkBlock(block, GearCache.station)) { + return; + } + } catch (Exception ex) { + return; + } + event.setCancelled(true); + Player player = event.getPlayer(); + Location location = block.getLocation(); + GearStationStore.Occupancy occupancy = GearStationStore.get(location); + if (occupancy == null) { + return; + } + if (GearStationStore.isAttuned(occupancy.getItem())) { + return; + } + UUID owner = occupancy.getOwner() != null ? occupancy.getOwner() : GearOrbService.sessionOwner(location); + if (owner != null && !owner.equals(player.getUniqueId()) && !player.hasPermission("magic.admin")) { + player.sendMessage(Messages.get("gear.abort.not_yours")); + player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); + return; + } + GearOrbService.abort(location); + ItemStack weapon = GearStationStore.takeForAbort(location); + if (weapon == null) { + return; + } + recentAborts.values().removeIf(at -> System.currentTimeMillis() - at >= ABORT_BREAK_GUARD_MILLIS); + recentAborts.put(GearStationStore.key(location), System.currentTimeMillis()); + Map charged = occupancy.getCharged() != null + ? occupancy.getCharged() + : GearCosts.total(GearProvenance.resolveParts(weapon)); + GearCosts.refund(player, charged, location); + player.sendMessage(Messages.get("gear.abort.done")); + player.playSound(location, Sound.BLOCK_ANVIL_LAND, 0.6f, 1.4f); + } } diff --git a/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java b/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java index a3d5f34..79167ee 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java @@ -3,6 +3,7 @@ import java.io.File; import java.io.IOException; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.UUID; @@ -31,10 +32,27 @@ public static final class Occupancy { private ItemStack item; private UUID displayId; private boolean orbSessionActive; + private final UUID owner; + private final Map charged; - Occupancy(ItemStack item, UUID displayId) { + Occupancy(ItemStack item, UUID displayId, UUID owner, Map charged) { this.item = item; this.displayId = displayId; + this.owner = owner; + this.charged = charged == null ? null : Map.copyOf(charged); + } + + /** Player who prepared the craft. Null for stations saved before owners were recorded. */ + public UUID getOwner() { + return owner; + } + + /** + * Materials taken when the craft was prepared, empty when costs were bypassed. + * Null for stations saved before charges were recorded. + */ + public Map getCharged() { + return charged; } public ItemStack getItem() { @@ -73,13 +91,13 @@ public static Occupancy get(Location location) { return location == null ? null : OCCUPIED.get(key(location)); } - public static Occupancy occupy(Location location, ItemStack item) { + public static Occupancy occupy(Location location, ItemStack item, UUID owner, Map charged) { if (location == null || item == null) { return null; } clear(location, false); UUID displayId = spawnDisplay(location, item); - Occupancy occupancy = new Occupancy(item, displayId); + Occupancy occupancy = new Occupancy(item, displayId, owner, charged); OCCUPIED.put(key(location), occupancy); save(); return occupancy; @@ -98,6 +116,17 @@ public static ItemStack eject(Location location) { return item; } + /** Clears the station even if the weapon is unattuned or mid-orb. Does not return the staff. */ + public static ItemStack takeForAbort(Location location) { + Occupancy occupancy = get(location); + if (occupancy == null) { + return null; + } + ItemStack item = occupancy.getItem(); + clear(location, true); + return item; + } + /** An unattuned weapon never leaves the station, so band 0 gear cannot exist in the world. */ public static boolean isAttuned(ItemStack item) { return item != null && WeaponRequirement.fromItem(item).highestBand() > 0; @@ -152,7 +181,28 @@ public static void load() { continue; } UUID displayId = spawnDisplay(location, item); - OCCUPIED.put(key(location), new Occupancy(item, displayId)); + UUID owner = null; + String rawOwner = section.getString("owner"); + if (rawOwner != null && !rawOwner.isBlank()) { + try { + owner = UUID.fromString(rawOwner); + } catch (IllegalArgumentException ignored) { + owner = null; + } + } + Map charged = null; + ConfigurationSection chargedSection = section.getConfigurationSection("charged"); + if (chargedSection != null) { + charged = new LinkedHashMap<>(); + for (String index : chargedSection.getKeys(false)) { + String path = chargedSection.getString(index + ".path"); + int amount = chargedSection.getInt(index + ".amount"); + if (path != null && !path.isBlank() && amount > 0) { + charged.merge(path, amount, Integer::sum); + } + } + } + OCCUPIED.put(key(location), new Occupancy(item, displayId, owner, charged)); } } @@ -171,6 +221,18 @@ public static void save() { config.set(path + ".y", location.getBlockY()); config.set(path + ".z", location.getBlockZ()); config.set(path + ".item", occupancy.getItem()); + if (occupancy.getOwner() != null) { + config.set(path + ".owner", occupancy.getOwner().toString()); + } + if (occupancy.getCharged() != null) { + config.createSection(path + ".charged"); + int chargedIndex = 0; + for (Map.Entry cost : occupancy.getCharged().entrySet()) { + String costPath = path + ".charged." + chargedIndex++; + config.set(costPath + ".path", cost.getKey()); + config.set(costPath + ".amount", cost.getValue()); + } + } } try { File file = file(); diff --git a/src/main/java/net/tfminecraft/magic/gear/MajorityTierResolver.java b/src/main/java/net/tfminecraft/magic/gear/MajorityTierResolver.java new file mode 100644 index 0000000..d99fab0 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/MajorityTierResolver.java @@ -0,0 +1,44 @@ +package net.tfminecraft.magic.gear; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +public final class MajorityTierResolver { + + private MajorityTierResolver() {} + + public static int resolve(Collection parts) { + if (parts == null || parts.isEmpty()) { + return 0; + } + Map votes = new HashMap<>(); + for (PartDef part : parts) { + if (part == null || !part.hasTier()) { + continue; + } + votes.merge(part.getTier(), 1, Integer::sum); + } + int bestTier = 0; + int bestCount = 0; + for (Map.Entry entry : votes.entrySet()) { + int tier = entry.getKey(); + int count = entry.getValue(); + if (count > bestCount || (count == bestCount && tier > bestTier)) { + bestCount = count; + bestTier = tier; + } + } + return bestTier; + } + + public static String toRoman(int tier) { + return switch (tier) { + case 1 -> "I"; + case 2 -> "II"; + case 3 -> "III"; + case 4 -> "IV"; + default -> String.valueOf(tier); + }; + } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/PartDef.java b/src/main/java/net/tfminecraft/magic/gear/PartDef.java index 0a7b993..6716469 100644 --- a/src/main/java/net/tfminecraft/magic/gear/PartDef.java +++ b/src/main/java/net/tfminecraft/magic/gear/PartDef.java @@ -1,5 +1,6 @@ package net.tfminecraft.magic.gear; +import java.util.ArrayList; import java.util.Collections; import java.util.EnumSet; import java.util.LinkedHashMap; @@ -16,11 +17,16 @@ public final class PartDef { private final String id; private final String name; private final String partType; + private final int tier; private final Set types; private final String itemPath; private final Map cost; + private final List partLimit; + private final Map stats; private final Map sockets; private final List lore; + private final String schemeId; + private final int schemeWeight; private final boolean disabled; private int revision = 1; @@ -28,20 +34,40 @@ public PartDef( String id, String name, String partType, + int tier, Set types, String itemPath, Map cost, + List partLimit, + Map stats, Map sockets, List lore, + String schemeId, + int schemeWeight, boolean disabled) { this.id = id == null ? "" : id.trim().toLowerCase(Locale.ROOT); this.name = name == null || name.isBlank() ? this.id : name; this.partType = partType == null ? "" : partType.trim().toLowerCase(Locale.ROOT); + this.tier = tier < 1 ? 0 : tier; this.types = types == null || types.isEmpty() ? EnumSet.noneOf(GearType.class) : EnumSet.copyOf(types); this.itemPath = itemPath == null ? "" : itemPath.trim(); this.cost = cost == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(cost)); + List limitCopy = new ArrayList<>(); + if (partLimit != null) { + for (String category : partLimit) { + if (category == null || category.isBlank()) { + continue; + } + String key = category.trim().toLowerCase(Locale.ROOT); + if (!limitCopy.contains(key)) { + limitCopy.add(key); + } + } + } + this.partLimit = List.copyOf(limitCopy); + this.stats = stats == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(stats)); Map socketCopy = new LinkedHashMap<>(); if (sockets != null) { for (Map.Entry entry : sockets.entrySet()) { @@ -53,6 +79,8 @@ public PartDef( } this.sockets = Collections.unmodifiableMap(socketCopy); this.lore = lore == null ? List.of() : List.copyOf(lore); + this.schemeId = schemeId == null ? "" : schemeId.trim().toLowerCase(Locale.ROOT); + this.schemeWeight = schemeWeight < 1 ? 1 : schemeWeight; this.disabled = disabled; } @@ -68,6 +96,14 @@ public String getPartType() { return partType; } + public int getTier() { + return tier; + } + + public boolean hasTier() { + return tier > 0; + } + public Set getTypes() { return types; } @@ -88,6 +124,28 @@ public boolean hasCost() { return !cost.isEmpty(); } + public List getPartLimit() { + return partLimit; + } + + public boolean hasPartLimit() { + return !partLimit.isEmpty(); + } + + public boolean allowsPart(String category) { + if (!hasPartLimit()) { + return true; + } + if (category == null || category.isBlank()) { + return false; + } + return partLimit.contains(category.trim().toLowerCase(Locale.ROOT)); + } + + public Map getStats() { + return stats; + } + public Map getSockets() { return sockets; } @@ -111,6 +169,18 @@ public List getLore() { return lore; } + public String getSchemeId() { + return schemeId; + } + + public int getSchemeWeight() { + return schemeWeight; + } + + public boolean hasModelScheme() { + return !schemeId.isEmpty(); + } + public boolean isDisabled() { return disabled; } @@ -130,11 +200,16 @@ public void setRevision(int revision) { public String buildRevisionContent() { StringBuilder out = new StringBuilder(); out.append("type=").append(partType).append(';'); + out.append("tier=").append(tier).append(';'); out.append("gear=").append(new TreeSet<>(types.stream() .map(Enum::name).collect(Collectors.toList()))).append(';'); out.append("item=").append(itemPath).append(';'); out.append("cost=").append(new TreeMap<>(cost)).append(';'); + out.append("part-limit=").append(partLimit).append(';'); + out.append("stats=").append(new TreeMap<>(stats)).append(';'); out.append("sockets=").append(new TreeMap<>(sockets)).append(';'); + out.append("scheme=").append(schemeId).append(';'); + out.append("scheme-weight=").append(schemeWeight).append(';'); out.append("disabled=").append(disabled); return out.toString(); } diff --git a/src/main/java/net/tfminecraft/magic/gear/PartSlots.java b/src/main/java/net/tfminecraft/magic/gear/PartSlots.java new file mode 100644 index 0000000..f58071d --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/PartSlots.java @@ -0,0 +1,59 @@ +package net.tfminecraft.magic.gear; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public final class PartSlots { + + public static final String CORE = "core"; + + private PartSlots() {} + + /** + * Part categories that are visible and mandatory for this archetype + core. + * Core is included when the archetype lists it. Extra slots are the intersection + * of archetype required and the core's part-limit. No limit means the full + * required list (never extra part-types.yml categories). + */ + public static List open(ArchetypeDef archetype, PartDef core) { + if (archetype == null) { + return List.of(); + } + List required = new ArrayList<>(); + for (String id : archetype.getRequired()) { + if (id == null || id.isBlank()) { + continue; + } + String key = id.trim().toLowerCase(Locale.ROOT); + if (!required.contains(key)) { + required.add(key); + } + } + if (core == null || !core.hasPartLimit()) { + return List.copyOf(required); + } + List open = new ArrayList<>(); + if (required.contains(CORE)) { + open.add(CORE); + } + for (String id : core.getPartLimit()) { + if (id == null || id.isBlank()) { + continue; + } + String key = id.trim().toLowerCase(Locale.ROOT); + if (CORE.equals(key) || open.contains(key) || !required.contains(key)) { + continue; + } + open.add(key); + } + return List.copyOf(open); + } + + public static boolean contains(List open, String category) { + if (open == null || category == null || category.isBlank()) { + return false; + } + return open.contains(category.trim().toLowerCase(Locale.ROOT)); + } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/RuneKeybind.java b/src/main/java/net/tfminecraft/magic/gear/RuneKeybind.java new file mode 100644 index 0000000..458fa04 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/RuneKeybind.java @@ -0,0 +1,89 @@ +package net.tfminecraft.magic.gear; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.inventory.ItemStack; + +import io.lumine.mythic.lib.api.item.NBTItem; +import io.lumine.mythic.lib.skill.trigger.TriggerType; +import net.Indyuce.mmoitems.ItemStats; +import net.Indyuce.mmoitems.api.item.mmoitem.LiveMMOItem; +import net.Indyuce.mmoitems.stat.data.AbilityData; +import net.Indyuce.mmoitems.stat.data.AbilityListData; +import net.tfminecraft.magic.Cache; + +public final class RuneKeybind { + + public enum Result { + NOT_RUNE, + NO_ABILITIES, + FAILED, + OK + } + + public record Outcome(Result result, ItemStack item) { + public static Outcome of(Result result) { + return new Outcome(result, null); + } + + public static Outcome ok(ItemStack item) { + return new Outcome(Result.OK, item); + } + } + + private RuneKeybind() {} + + public static boolean isRune(ItemStack item) { + if (item == null || item.getType().isAir() || Cache.runeTypes.isEmpty()) { + return false; + } + if (!GearItemBuilder.mmoItemsPresent()) { + return false; + } + NBTItem nbt = NBTItem.get(item); + if (!nbt.hasType()) { + return false; + } + return Cache.runeTypes.contains(nbt.getType().toLowerCase()); + } + + public static Outcome apply(ItemStack held, TriggerType trigger) { + if (!isRune(held) || trigger == null) { + return Outcome.of(Result.NOT_RUNE); + } + try { + LiveMMOItem mmo = new LiveMMOItem(NBTItem.get(held)); + if (!mmo.hasData(ItemStats.ABILITIES)) { + return Outcome.of(Result.NO_ABILITIES); + } + AbilityListData current = (AbilityListData) mmo.getData(ItemStats.ABILITIES); + if (current == null || current.isEmpty()) { + return Outcome.of(Result.NO_ABILITIES); + } + List copies = new ArrayList<>(); + for (AbilityData old : current.getAbilities()) { + if (old == null || old.getAbility() == null) { + continue; + } + AbilityData copy = new AbilityData(old.getAbility(), trigger); + for (String modifier : old.getModifiers()) { + copy.setModifier(modifier, old.getParameter(modifier)); + } + copies.add(copy); + } + if (copies.isEmpty()) { + return Outcome.of(Result.NO_ABILITIES); + } + mmo.setData(ItemStats.ABILITIES, new AbilityListData(copies)); + ItemStack rebuilt = mmo.newBuilder().build(); + if (rebuilt == null || rebuilt.getType().isAir()) { + return Outcome.of(Result.FAILED); + } + rebuilt.setAmount(held.getAmount()); + return Outcome.ok(rebuilt); + } catch (Exception ex) { + return Outcome.of(Result.FAILED); + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/SocketColourRegistry.java b/src/main/java/net/tfminecraft/magic/gear/SocketColourRegistry.java index 62e6cd3..f417513 100644 --- a/src/main/java/net/tfminecraft/magic/gear/SocketColourRegistry.java +++ b/src/main/java/net/tfminecraft/magic/gear/SocketColourRegistry.java @@ -45,7 +45,11 @@ public static String colour(int band, String suffix) { if (suffix == null || suffix.isBlank()) { return ""; } - return prefix(band) + " " + suffix.trim(); + String trimmed = suffix.trim(); + if (!GearCache.socketRarityPrefix) { + return trimmed; + } + return prefix(band) + " " + trimmed; } public static Map prefixes() { diff --git a/src/main/java/net/tfminecraft/magic/gear/WeaponAttunementChat.java b/src/main/java/net/tfminecraft/magic/gear/WeaponAttunementChat.java new file mode 100644 index 0000000..d45db28 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/WeaponAttunementChat.java @@ -0,0 +1,44 @@ +package net.tfminecraft.magic.gear; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import net.tfminecraft.magic.Messages; + +public final class WeaponAttunementChat { + + private WeaponAttunementChat() {} + + public static void sendPostChargeSummary(Player player, ItemStack weapon) { + if (player == null || weapon == null) { + return; + } + WeaponRequirement requirement = WeaponRequirement.fromItem(weapon); + int rift = WeaponRift.get(weapon); + + player.sendMessage(""); + player.sendMessage(Messages.get("gear.orbs.summary_title")); + if (!requirement.hasStored()) { + player.sendMessage(Messages.get("gear.orbs.summary_unattuned")); + } else { + String prefix = Messages.get("gear.orbs.summary_line_prefix"); + List lines = new ArrayList<>(); + WeaponResonanceDisplay.appendElementLines(lines, requirement, prefix); + for (String line : lines) { + player.sendMessage(line); + } + } + if (rift > 0) { + player.sendMessage(Messages.get("gear.orbs.summary_rift", "rift", String.valueOf(rift))); + } + player.sendMessage(""); + if (rift > 0) { + player.sendMessage(Messages.get("gear.orbs.summary_hint_resonance_and_clean")); + } else { + player.sendMessage(Messages.get("gear.orbs.summary_hint_resonance")); + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/WeaponLore.java b/src/main/java/net/tfminecraft/magic/gear/WeaponLore.java index c9cf5aa..de3043d 100644 --- a/src/main/java/net/tfminecraft/magic/gear/WeaponLore.java +++ b/src/main/java/net/tfminecraft/magic/gear/WeaponLore.java @@ -31,6 +31,7 @@ public static void apply(ItemStack stack) { if (!GearProvenance.isGear(stack)) { return; } + GearProvenance.applyMajority(stack, GearProvenance.resolveParts(stack)); WeaponRequirement requirement = WeaponRequirement.fromItem(stack); ItemMeta meta = stack.getItemMeta(); if (meta == null) { @@ -39,6 +40,11 @@ public static void apply(ItemStack stack) { List lore = meta.hasLore() ? new ArrayList<>(meta.getLore()) : new ArrayList<>(); lore = stripBlock(lore); List block = new ArrayList<>(); + int majority = GearProvenance.majorityOf(stack); + if (majority > 0) { + block.add(MagicText.format("{color:label_muted}Tier " + + MajorityTierResolver.toRoman(majority))); + } if (!requirement.hasStored()) { block.add(MagicText.format("{color:label_muted}Unattuned")); block.add(MagicText.format("{color:label_muted}Apply a charged charge at the station")); @@ -91,7 +97,7 @@ private static List stripBlock(List lore) { } for (int i = 0; i < lore.size(); i++) { String p = plain(lore.get(i)).toLowerCase(Locale.ROOT); - if ("resonance".equals(p) || "unattuned".equals(p)) { + if ("resonance".equals(p) || "unattuned".equals(p) || p.startsWith("tier ")) { return new ArrayList<>(lore.subList(0, i)); } } diff --git a/src/main/java/net/tfminecraft/magic/gear/WeaponResonanceDisplay.java b/src/main/java/net/tfminecraft/magic/gear/WeaponResonanceDisplay.java new file mode 100644 index 0000000..21aa785 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/WeaponResonanceDisplay.java @@ -0,0 +1,47 @@ +package net.tfminecraft.magic.gear; + +import java.util.List; + +import net.tfminecraft.magic.charge.TierBands; +import net.tfminecraft.magic.model.ElementDef; +import net.tfminecraft.magic.model.ElementVisibility; +import net.tfminecraft.magic.registry.ElementRegistry; +import net.tfminecraft.magic.util.MagicText; + +public final class WeaponResonanceDisplay { + + private WeaponResonanceDisplay() {} + + /** + * One formatted line per imbued element. Caller supplies title / unattuned messaging. + */ + public static void appendElementLines(List out, WeaponRequirement requirement, String linePrefix) { + if (requirement == null || !requirement.hasStored()) { + return; + } + String prefix = linePrefix != null ? linePrefix : ""; + for (ElementDef element : ElementRegistry.getAll()) { + String elementId = element.getId(); + if (!ElementVisibility.shownOnCharge(elementId) || !includeElement(requirement, elementId)) { + continue; + } + double fill = requirement.aura().getFill(elementId); + String numeral = TierBands.numeralFor(elementId, fill); + out.add(MagicText.format(prefix) + MagicText.elementName(element) + + MagicText.format(" {color:label_muted}" + (numeral.isEmpty() ? "-" : numeral))); + } + } + + static boolean includeElement(WeaponRequirement requirement, String elementId) { + if (requirement == null || elementId == null) { + return false; + } + return includeAuraEntry( + requirement.aura().getCap(elementId), + requirement.aura().getFill(elementId)); + } + + static boolean includeAuraEntry(double cap, double fill) { + return cap > 0 || fill > 0; + } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/gui/GearInventoryManager.java b/src/main/java/net/tfminecraft/magic/gear/gui/GearInventoryManager.java index 04c37b9..d3d6483 100644 --- a/src/main/java/net/tfminecraft/magic/gear/gui/GearInventoryManager.java +++ b/src/main/java/net/tfminecraft/magic/gear/gui/GearInventoryManager.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Map; import org.bukkit.Bukkit; import org.bukkit.Material; @@ -27,6 +28,7 @@ import net.tfminecraft.magic.gear.GearType; import net.tfminecraft.magic.gear.PartDef; import net.tfminecraft.magic.gear.PartRegistry; +import net.tfminecraft.magic.gear.PartSlots; import net.tfminecraft.magic.gear.PartTypeDef; import net.tfminecraft.magic.gear.PartTypeRegistry; import net.tfminecraft.magic.gear.SocketLayout; @@ -44,14 +46,21 @@ public void openAssembly(Player player) { inv.setItem(i, filler); } GearType type = TypeSelectionManager.get(player); + ArchetypeDef archetype = ArchetypeRegistry.get(type); + PartDef core = selectedOrFirst(player, PartSlots.CORE, type); + List open = PartSlots.open(archetype, core); inv.setItem(0, typeButton(type)); Collection parts = collectParts(player, type); - for (PartTypeDef category : PartTypeRegistry.getAll()) { + for (String categoryId : open) { + PartTypeDef category = PartTypeRegistry.get(categoryId); + if (category == null) { + continue; + } int slot = category.getSlot(); if (slot <= 0 || slot >= inv.getSize()) { continue; } - PartDef part = selectedOrFirst(player, category.getId(), type); + PartDef part = selectedOrFirst(player, categoryId, type); if (part == null) { inv.setItem(slot, barrier("No part")); } else { @@ -68,11 +77,11 @@ public void openTypeSelection(Player player) { Inventory inv = Bukkit.createInventory(new TypeSelectionHolder(), 9, "§6Select Archetype"); int slot = 0; for (GearType type : GearType.values()) { - ItemStack item = new ItemStack(type.getIcon()); + ArchetypeDef def = ArchetypeRegistry.get(type); + ItemStack item = archetypeIcon(type, def); ItemMeta meta = item.getItemMeta(); if (meta != null) { meta.setDisplayName("§e" + type.getDisplayName()); - ArchetypeDef def = ArchetypeRegistry.get(type); List lore = new ArrayList<>(); lore.add("§7Click to choose"); if (def != null && def.isMelee()) { @@ -97,6 +106,12 @@ public void openTypeSelection(Player player) { @SuppressWarnings("deprecation") public void openPartSelection(Player player, String categoryId) { GearType type = TypeSelectionManager.get(player); + ArchetypeDef archetype = ArchetypeRegistry.get(type); + PartDef core = selectedOrFirst(player, PartSlots.CORE, type); + if (!PartSlots.contains(PartSlots.open(archetype, core), categoryId)) { + openAssembly(player); + return; + } List options = PartRegistry.matching(categoryId, type); int size = Math.max(9, Math.min(54, ((options.size() + 8) / 9) * 9)); Inventory inv = Bukkit.createInventory( @@ -124,21 +139,13 @@ public Collection collectParts(Player player, GearType type) { if (archetype == null) { return parts; } - for (String category : archetype.getRequired()) { + PartDef core = selectedOrFirst(player, PartSlots.CORE, type); + for (String category : PartSlots.open(archetype, core)) { PartDef part = selectedOrFirst(player, category, type); if (part != null) { parts.add(part); } } - for (PartTypeDef category : PartTypeRegistry.getAll()) { - if (archetype.getRequired().contains(category.getId())) { - continue; - } - PartDef part = selectedOrFirst(player, category.getId(), type); - if (part != null) { - parts.add(part); - } - } return parts; } @@ -154,6 +161,18 @@ private PartDef selectedOrFirst(Player player, String categoryId, GearType type) return PartRegistry.firstMatching(categoryId, type); } + private void pruneClosedSelections(Player player) { + GearType type = TypeSelectionManager.get(player); + List open = PartSlots.open( + ArchetypeRegistry.get(type), + selectedOrFirst(player, PartSlots.CORE, type)); + for (String category : SelectedPartsManager.categories(player)) { + if (!PartSlots.contains(open, category)) { + SelectedPartsManager.remove(player, category); + } + } + } + // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") @EventHandler @@ -181,6 +200,11 @@ public void onClick(InventoryClickEvent event) { } String category = PartTypeRegistry.idForSlot(event.getSlot()); if (category != null) { + GearType type = TypeSelectionManager.get(player); + PartDef core = selectedOrFirst(player, PartSlots.CORE, type); + if (!PartSlots.contains(PartSlots.open(ArchetypeRegistry.get(type), core), category)) { + return; + } openPartSelection(player, category); clickSound(player); } @@ -227,6 +251,9 @@ public void onClick(InventoryClickEvent event) { return; } SelectedPartsManager.set(player, holder.getCategoryId(), partId); + if (PartSlots.CORE.equalsIgnoreCase(holder.getCategoryId())) { + pruneClosedSelections(player); + } openAssembly(player); clickSound(player); } @@ -261,8 +288,9 @@ private void tryPrepare(Player player) { player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); return; } + Map charged = GearCosts.bypasses(player) ? Map.of() : GearCosts.total(parts); GearCosts.take(player, parts); - GearStationStore.occupy(station, prepared); + GearStationStore.occupy(station, prepared, player.getUniqueId(), charged); OpenStationManager.clear(player); player.closeInventory(); player.sendMessage(Messages.get("gear.craft.prepared")); @@ -305,10 +333,17 @@ private static ItemStack barrier(String name) { return item; } + private static ItemStack archetypeIcon(GearType type, ArchetypeDef def) { + if (def == null) { + return new ItemStack(type.getIcon()); + } + return ItemRef.buildOrFallback(def.getIcon(), type.getIcon()); + } + // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") private static ItemStack typeButton(GearType type) { - ItemStack item = new ItemStack(Material.NETHER_STAR); + ItemStack item = archetypeIcon(type, ArchetypeRegistry.get(type)); ItemMeta meta = item.getItemMeta(); if (meta != null) { meta.setDisplayName("§6Archetype: §e" + type.getDisplayName()); @@ -335,9 +370,7 @@ private static ItemStack partIcon(PartDef part, boolean picker) { } } if (part.hasCost()) { - lore.add(""); - lore.add("§aCost"); - lore.addAll(CostFormatter.getCostsFormatted(part.getCost())); + CostFormatter.appendInput(lore, part.getCost()); } if (picker) { lore.add("§eClick to select"); diff --git a/src/main/java/net/tfminecraft/magic/gear/gui/SelectedPartsManager.java b/src/main/java/net/tfminecraft/magic/gear/gui/SelectedPartsManager.java index 1ca8523..f19ab2e 100644 --- a/src/main/java/net/tfminecraft/magic/gear/gui/SelectedPartsManager.java +++ b/src/main/java/net/tfminecraft/magic/gear/gui/SelectedPartsManager.java @@ -2,6 +2,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.Set; import java.util.UUID; import org.bukkit.entity.Player; @@ -28,6 +29,27 @@ public static void set(Player player, String categoryId, String partId) { .put(categoryId, partId); } + public static void remove(Player player, String categoryId) { + if (player == null || categoryId == null) { + return; + } + Map map = SELECTED.get(player.getUniqueId()); + if (map != null) { + map.remove(categoryId); + } + } + + public static Set categories(Player player) { + if (player == null) { + return Set.of(); + } + Map map = SELECTED.get(player.getUniqueId()); + if (map == null || map.isEmpty()) { + return Set.of(); + } + return Set.copyOf(map.keySet()); + } + public static void clear(Player player) { if (player != null) { SELECTED.remove(player.getUniqueId()); diff --git a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java index c0906c6..3dce276 100644 --- a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java +++ b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java @@ -28,6 +28,7 @@ import net.tfminecraft.magic.gear.GearItemBuilder; import net.tfminecraft.magic.gear.GearProvenance; import net.tfminecraft.magic.gear.GearStationStore; +import net.tfminecraft.magic.gear.WeaponAttunementChat; import net.tfminecraft.magic.gear.WeaponLore; import net.tfminecraft.magic.gear.WeaponRequirement; import net.tfminecraft.magic.gear.WeaponRift; @@ -69,6 +70,31 @@ public static boolean isActive(Location station) { return station != null && SESSIONS.containsKey(GearStationStore.key(station)); } + public static UUID sessionOwner(Location station) { + if (station == null) { + return null; + } + GearOrbSession session = SESSIONS.get(GearStationStore.key(station)); + return session == null ? null : session.getPlayerId(); + } + + /** + * Stops the run without writing captured aura or rift. The charge stays spent. + */ + public static void abort(Location station) { + if (station == null) { + return; + } + GearOrbSession session = SESSIONS.remove(GearStationStore.key(station)); + if (session != null) { + session.end(); + } + GearStationStore.Occupancy occupancy = GearStationStore.get(station); + if (occupancy != null) { + occupancy.setOrbSessionActive(false); + } + } + /** * Begins a run. The caller has already consumed the charge and flagged the station, * so this never fails silently: a station that is already running is rejected first. @@ -170,9 +196,7 @@ private static void complete(GearOrbSession session, boolean announce) { "percent", String.valueOf((int) Math.round(session.capturedFraction() * 100)), "band", TierBands.numeral(capturedBand))); } - if (session.riftDelta() > 0) { - player.sendMessage(Messages.get("gear.orbs.rift", "rift", String.valueOf(newRift))); - } + WeaponAttunementChat.sendPostChargeSummary(player, weapon); } @EventHandler @@ -192,6 +216,9 @@ public void onSwing(PlayerAnimationEvent event) { } private static void tryHit(Player player) { + if (player.isSneaking()) { + return; + } GearOrbSession session = sessionOf(player.getUniqueId()); if (session == null || !session.claimSwing(tickCount) || !session.canHit(tickCount)) { return; diff --git a/src/main/java/net/tfminecraft/magic/gui/ArtifactCreateGuiBuilder.java b/src/main/java/net/tfminecraft/magic/gui/ArtifactCreateGuiBuilder.java index 1da969d..8243df8 100644 --- a/src/main/java/net/tfminecraft/magic/gui/ArtifactCreateGuiBuilder.java +++ b/src/main/java/net/tfminecraft/magic/gui/ArtifactCreateGuiBuilder.java @@ -13,6 +13,7 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; +import net.tfminecraft.magic.util.MagicText; import net.tfminecraft.magic.ArtifactCreateCache; import net.tfminecraft.magic.GuiCache; import net.tfminecraft.magic.artifact.create.ArtifactCreateSession; @@ -162,7 +163,7 @@ private static ItemStack buildElementItem(ElementDef element, ArtifactCreateSess if (meta == null) { return item; } - meta.setDisplayName(GuiText.format(element.getName())); + meta.setDisplayName(MagicText.elementName(element)); ElementRole role = session != null ? session.roleOf(element.getId()) : ElementRole.OFF; List lore = new ArrayList<>(); if (role != ElementRole.OFF) { diff --git a/src/main/java/net/tfminecraft/magic/gui/ResonanceGuiBuilder.java b/src/main/java/net/tfminecraft/magic/gui/ResonanceGuiBuilder.java index 71b2922..c52bdef 100644 --- a/src/main/java/net/tfminecraft/magic/gui/ResonanceGuiBuilder.java +++ b/src/main/java/net/tfminecraft/magic/gui/ResonanceGuiBuilder.java @@ -16,6 +16,7 @@ import net.tfminecraft.rpcharacters.objects.RPCharacter; import net.tfminecraft.rpcharacters.api.CharacterSkull; +import net.tfminecraft.magic.util.MagicText; import net.tfminecraft.magic.GuiCache; import net.tfminecraft.magic.integration.RpCharactersBridge; import net.tfminecraft.magic.model.CastModeDef; @@ -66,7 +67,7 @@ public static void populate(Inventory inventory, Player player, ResonanceSession buildCastModeItem(GuiCache.castModeRight, castModeId.equals(GuiCache.castModeRight.getId()), session)); for (ElementDef element : ElementRegistry.getAll()) { - if (element.getSlot() >= 0) { + if (element.getSlot() >= 0 && element.isUnlocked(player)) { inventory.setItem(element.getSlot(), buildElementItem(element, session)); } } @@ -165,8 +166,10 @@ private static ItemStack buildCastModeItem(CastModeDef mode, boolean selected, R lore.addAll(ModifierLore.linesForFlow(session)); } if (selected) { - lore.add(GuiText.text("label_accent", "Selected")); + lore.add(GuiText.format("{color:resonance_high}§lSelected")); meta.addEnchant(Enchantment.UNBREAKING, 1, true); + } else { + lore.add(GuiText.text("label_muted", "Click to Select")); } meta.setLore(lore); meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_ADDITIONAL_TOOLTIP, ItemFlag.HIDE_ENCHANTS); @@ -182,7 +185,7 @@ private static ItemStack buildElementItem(ElementDef element, ResonanceSession s if (meta == null) { return item; } - meta.setDisplayName(GuiText.format(element.getName())); + meta.setDisplayName(MagicText.elementName(element)); List lore = new ArrayList<>(); double resonance = session != null ? session.getResonance(element.getId()) : 0.0; lore.add(ResonanceBar.formatLore(element, resonance)); diff --git a/src/main/java/net/tfminecraft/magic/listener/ResonanceCastListener.java b/src/main/java/net/tfminecraft/magic/listener/ResonanceCastListener.java index e19c5af..576c04e 100644 --- a/src/main/java/net/tfminecraft/magic/listener/ResonanceCastListener.java +++ b/src/main/java/net/tfminecraft/magic/listener/ResonanceCastListener.java @@ -17,6 +17,7 @@ import io.lumine.mythic.lib.api.item.NBTItem; import io.lumine.mythic.lib.skill.Skill; import net.Indyuce.mmoitems.api.interaction.util.DurabilityItem; +import net.tfminecraft.magic.util.MagicText; import net.tfminecraft.magic.Cache; import net.tfminecraft.magic.Magic; import net.tfminecraft.magic.Messages; @@ -36,9 +37,11 @@ /** * The ways a mage weapon stops or taxes a spell. * - *

Refusal is a locked door. The weapon demands more of the spell's element - * than the caster carries, or was never attuned to that element at all, so the cast is - * cancelled before {@code whenCast} and costs nothing. It is never called a whiff. + *

Refusal is a locked door. The spell's {@code skills.yml} tier is the + * floor: the weapon must hold that element at that band, and the caster's resonance + * must meet the same band. A high-attuned staff does not block a lower-tier spell. + * Foreign element or a failed floor cancels before {@code whenCast} and costs + * nothing. It is never called a whiff. * *

Overload is carrying more than one staff. After refusal passes, the cast * always fumbles: mana and cooldown are spent, but the spell does not fire. @@ -52,7 +55,6 @@ */ public final class ResonanceCastListener implements Listener { - private static final double EPSILON = 0.0001; private static final int CHAT_KEYS_MAX = 512; private static final Map lastRefuseChat = new ConcurrentHashMap<>(); @@ -66,10 +68,12 @@ public void onPlayerCastSkill(PlayerCastSkillEvent event) { if (!SkillIdResolver.isActiveCast(cast)) { return; } - String elementId = SkillElementRegistry.elementOf(SkillIdResolver.resolveSkillId(cast)); + String skillId = SkillIdResolver.resolveSkillId(cast); + String elementId = SkillElementRegistry.elementOf(skillId); if (elementId == null) { return; } + int spellTier = SkillElementRegistry.tierOf(skillId); Player player = event.getPlayer(); if (player == null) { return; @@ -87,7 +91,7 @@ public void onPlayerCastSkill(PlayerCastSkillEvent event) { broken(player, weapon, elementId); return; } - if (refuse(player, weapon, elementId)) { + if (refuse(player, weapon, elementId, spellTier)) { event.setCancelled(true); return; } @@ -122,40 +126,60 @@ private static void wearWeapon(Player player, HeldSlot slot, ItemStack weapon) { GearHand.setHeld(player, slot, durability.decreaseDurability(1).toItem()); } - /** @return true when the weapon will not carry this element for this caster */ + /** @return true when this spell cannot fire on this weapon for this caster */ // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") - private static boolean refuse(Player player, ItemStack weapon, String elementId) { - double required = WeaponRequirement.fromItem(weapon).aura().getFill(elementId); + private static boolean refuse(Player player, ItemStack weapon, String elementId, int spellTier) { + double weaponFill = WeaponRequirement.fromItem(weapon).aura().getFill(elementId); ResonanceSession session = Magic.plugin.getResonanceGuiManager().getSessionManager().get(player); - double actual = session == null ? 0.0 : session.getResonance(elementId); - boolean foreign = required <= 0; - if (!foreign && actual + EPSILON >= required) { + double playerFill = session == null ? 0.0 : session.getResonance(elementId); + int weaponBand = TierBands.bandOf(elementId, weaponFill); + int playerBand = TierBands.bandOf(elementId, playerFill); + SpellTierGate.Refuse kind = SpellTierGate.refuse(weaponFill > 0, weaponBand, playerBand, spellTier); + if (kind == SpellTierGate.Refuse.NONE) { return false; } String elementName = elementName(elementId); - player.sendTitle( - Messages.get("cast.refuse_title"), - foreign - ? Messages.get("cast.refuse_sub_foreign", "element", elementName) - : Messages.get("cast.refuse_sub"), - 0, 25, 10); - player.playSound(player.getLocation(), Sound.BLOCK_BEACON_DEACTIVATE, SoundCategory.PLAYERS, 0.7f, 1.4f); - if (claimChat(player, weapon, elementId)) { - if (foreign) { - player.sendMessage(Messages.get("cast.refuse_chat_foreign", "element", elementName)); - } else { - String have = TierBands.numeralFor(elementId, actual); - player.sendMessage(Messages.get( - "cast.refuse_chat", + String need = numeralOrDash(spellTier); + String weaponHave = numeralOrDash(weaponBand); + String playerHave = numeralOrDash(playerBand); + String subtitle; + String chat; + switch (kind) { + case FOREIGN -> { + subtitle = Messages.get("cast.refuse_sub_foreign", "element", elementName); + chat = Messages.get("cast.refuse_chat_foreign", "element", elementName); + } + case WEAPON -> { + subtitle = Messages.get("cast.refuse_sub_weapon"); + chat = Messages.get( + "cast.refuse_chat_weapon", "element", elementName, - "need", TierBands.numeralFor(elementId, required), - "have", have.isEmpty() ? "-" : have)); + "have", weaponHave, + "need", need); } + default -> { + subtitle = Messages.get("cast.refuse_sub_spell"); + chat = Messages.get( + "cast.refuse_chat_spell", + "element", elementName, + "need", need, + "have", playerHave); + } + } + player.sendTitle(Messages.get("cast.refuse_title"), subtitle, 0, 25, 10); + player.playSound(player.getLocation(), Sound.BLOCK_BEACON_DEACTIVATE, SoundCategory.PLAYERS, 0.7f, 1.4f); + if (claimChat(player, weapon, elementId)) { + player.sendMessage(chat); } return true; } + private static String numeralOrDash(int band) { + String numeral = TierBands.numeral(band); + return numeral.isEmpty() ? "-" : numeral; + } + /** * A damaged weapon refuses everything, so nothing is spent here either. Shares the * refusal rate limiter so a player holding one is told once, not once per cast. @@ -216,7 +240,7 @@ private static boolean claimChat(Player player, ItemStack weapon, String element private static String elementName(String elementId) { ElementDef element = ElementRegistry.getById(elementId); - return element == null ? elementId : element.getName(); + return element == null ? elementId : MagicText.elementName(element); } public static void clearAll() { diff --git a/src/main/java/net/tfminecraft/magic/listener/SpellTierGate.java b/src/main/java/net/tfminecraft/magic/listener/SpellTierGate.java new file mode 100644 index 0000000..e6059b7 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/listener/SpellTierGate.java @@ -0,0 +1,38 @@ +package net.tfminecraft.magic.listener; + +/** + * Per-spell floors: the weapon must hold the element at the spell's band, and the + * caster's resonance must meet the same band. A high-attuned staff does not block + * a lower-tier spell. + */ +public final class SpellTierGate { + + public enum Refuse { + NONE, + FOREIGN, + WEAPON, + SPELL + } + + private SpellTierGate() {} + + /** + * @param hasElement {@code false} when the weapon has no imbued fill for the spell's element + * @param weaponBand {@link net.tfminecraft.magic.charge.TierBands#bandOf} of that fill + * @param playerBand band of the caster's resonance in that element + * @param spellTier required band from skills.yml (1-4) + */ + public static Refuse refuse(boolean hasElement, int weaponBand, int playerBand, int spellTier) { + if (!hasElement) { + return Refuse.FOREIGN; + } + int need = Math.max(1, spellTier); + if (weaponBand < need) { + return Refuse.WEAPON; + } + if (playerBand < need) { + return Refuse.SPELL; + } + return Refuse.NONE; + } +} diff --git a/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java b/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java index feca373..1fa788a 100644 --- a/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java @@ -4,12 +4,15 @@ import java.io.IOException; import java.util.List; +import java.util.Locale; + import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.YamlConfiguration; import net.tfminecraft.tlibs.interfaces.LoaderInterface; +import io.lumine.mythic.lib.skill.trigger.TriggerType; import net.tfminecraft.magic.Cache; import net.tfminecraft.magic.GuiCache; import net.tfminecraft.magic.Magic; @@ -110,6 +113,8 @@ public boolean loadSafe(File configFile) { loadMeditation(config.getConfigurationSection("meditation")); loadAttunement(config.getConfigurationSection("attunement")); loadGear(config.getConfigurationSection("gear")); + loadRuneTypes(config); + loadRuneKeybinds(config); return true; } @@ -122,6 +127,36 @@ private static void loadCastTriggers(List keybinds) { Magic.plugin.getLogger().info("[Magic] Cast triggers: CAST, API, " + String.join(", ", Cache.castTriggers)); } + private static void loadRuneTypes(FileConfiguration config) { + Cache.runeTypes.clear(); + for (String type : config.getStringList("runes.types")) { + if (type != null && !type.isBlank()) { + Cache.runeTypes.add(type.trim().toLowerCase()); + } + } + } + + private static void loadRuneKeybinds(FileConfiguration config) { + Cache.runeKeybinds.clear(); + for (String raw : config.getStringList("runes.keybinds")) { + if (raw == null || raw.isBlank()) { + continue; + } + String id = raw.trim().toUpperCase(Locale.ROOT); + TriggerType trigger; + try { + trigger = TriggerType.valueOf(id); + } catch (IllegalArgumentException | NullPointerException ex) { + trigger = null; + } + if (trigger == null) { + Magic.plugin.getLogger().warning("[Magic] Unknown rune keybind '" + raw + "'"); + continue; + } + Cache.runeKeybinds.add(trigger); + } + } + private static void loadGear(ConfigurationSection section) { if (section == null) { return; @@ -136,6 +171,8 @@ private static void loadGear(ConfigurationSection section) { } long confirm = section.getLong("confirm_seconds", 5L); net.tfminecraft.magic.gear.GearCache.confirmMillis = Math.max(1L, confirm) * 1000L; + net.tfminecraft.magic.gear.GearCache.socketRarityPrefix = + section.getBoolean("socket_rarity_prefix", true); loadAlignment(section.getConfigurationSection("alignment")); } diff --git a/src/main/java/net/tfminecraft/magic/loader/GearLoader.java b/src/main/java/net/tfminecraft/magic/loader/GearLoader.java index d063713..4a1fe7f 100644 --- a/src/main/java/net/tfminecraft/magic/loader/GearLoader.java +++ b/src/main/java/net/tfminecraft/magic/loader/GearLoader.java @@ -20,6 +20,8 @@ import net.tfminecraft.magic.gear.orb.OrbCache; import net.tfminecraft.magic.gear.ArchetypeDef; import net.tfminecraft.magic.gear.ArchetypeRegistry; +import net.tfminecraft.magic.gear.GearModelScheme; +import net.tfminecraft.magic.gear.GearModelSchemeRegistry; import net.tfminecraft.magic.gear.GearType; import net.tfminecraft.magic.gear.PartDef; import net.tfminecraft.magic.gear.PartRegistry; @@ -36,6 +38,7 @@ public boolean loadFolder(File folder) { ArchetypeRegistry.clear(); PartTypeRegistry.clear(); PartRegistry.clear(); + GearModelSchemeRegistry.clear(); SocketColourRegistry.clear(); SocketLayout.clearLabels(); if (folder == null || !folder.exists()) { @@ -46,11 +49,13 @@ public boolean loadFolder(File folder) { ok &= loadPartTypes(new File(folder, "part-types.yml")); ok &= loadArchetypes(new File(folder, "archetypes.yml")); ok &= loadSocketColours(new File(folder, "socket-colours.yml")); + ok &= loadModelSchemes(new File(folder, "model-schemes.yml")); ok &= loadParts(new File(folder, "parts.yml")); ok &= loadOrbs(new File(folder, "orbs.yml")); Magic.plugin.getLogger().info("[Magic] Loaded " + ArchetypeRegistry.size() + " archetype(s), " + PartTypeRegistry.size() + " part type(s), " - + PartRegistry.size() + " part(s). Socket colours: " + + PartRegistry.size() + " part(s), " + GearModelSchemeRegistry.size() + + " model scheme(s). Socket colours: " + SocketColourRegistry.prefixes() + ". Socket labels: " + SocketLayout.labels()); return ok; @@ -109,6 +114,7 @@ private static boolean loadArchetypes(File file) { type, section.getString("name", type.getDisplayName()), section.getString("template", ""), + section.getString("icon", ""), section.getBoolean("melee", type == GearType.SWORD), section.getStringList("required"), slots); @@ -225,6 +231,58 @@ private static Particle particleOf(String name, Particle fallback) { } } + private static boolean loadModelSchemes(File file) { + YamlConfiguration config = read(file, "gear/model-schemes.yml"); + if (config == null) { + return false; + } + int skipped = 0; + for (String key : config.getKeys(false)) { + ConfigurationSection section = config.getConfigurationSection(key); + if (key == null || key.isBlank() || section == null) { + skipped++; + continue; + } + if (GearModelSchemeRegistry.contains(key)) { + Magic.plugin.getLogger().warning("[Magic] Duplicate model-scheme '" + key + "'"); + skipped++; + continue; + } + Map paths = new LinkedHashMap<>(); + for (String line : section.getStringList("models")) { + if (line == null || line.isBlank()) { + continue; + } + String trimmed = line.trim(); + int open = trimmed.indexOf('('); + int close = trimmed.lastIndexOf(')'); + if (open <= 0 || close <= open) { + Magic.plugin.getLogger().warning("[Magic] Invalid model '" + line + + "' in scheme '" + key + "'"); + continue; + } + GearType type = GearType.fromId(trimmed.substring(0, open).trim()); + String path = trimmed.substring(open + 1, close).trim(); + if (type == null || path.isEmpty()) { + Magic.plugin.getLogger().warning("[Magic] Invalid model '" + line + + "' in scheme '" + key + "'"); + continue; + } + paths.put(type, path); + } + if (paths.isEmpty()) { + Magic.plugin.getLogger().warning("[Magic] Model-scheme '" + key + "' has no models"); + skipped++; + continue; + } + GearModelSchemeRegistry.register(new GearModelScheme(key, paths)); + } + if (skipped > 0) { + Magic.plugin.getLogger().warning("[Magic] gear/model-schemes.yml skipped " + skipped); + } + return GearModelSchemeRegistry.size() > 0; + } + private static boolean loadParts(File file) { YamlConfiguration config = read(file, "gear/parts.yml"); if (config == null) { @@ -262,19 +320,63 @@ private static boolean loadParts(File file) { sockets.put(slotId, socketSection.getInt(slotId, 0)); } } + List partLimit = new ArrayList<>(); + for (String raw : section.getStringList("part-limit")) { + if (raw == null || raw.isBlank()) { + continue; + } + String category = raw.trim().toLowerCase(Locale.ROOT); + if (PartTypeRegistry.get(category) == null) { + Magic.plugin.getLogger().warning("[Magic] parts.yml: unknown part-limit '" + + raw + "' for '" + id + "'"); + continue; + } + if (!partLimit.contains(category)) { + partLimit.add(category); + } + } List lore = new ArrayList<>(); for (String line : section.getStringList("lore")) { lore.add(MagicText.format(line)); } + String schemeId = ""; + int schemeWeight = 1; + String schemeRaw = section.getString("model-scheme", ""); + if (schemeRaw != null && !schemeRaw.isBlank()) { + int start = schemeRaw.indexOf('('); + int end = schemeRaw.indexOf(')'); + if (start > 0 && end > start) { + schemeId = schemeRaw.substring(0, start).trim(); + try { + schemeWeight = Integer.parseInt(schemeRaw.substring(start + 1, end).trim()); + } catch (NumberFormatException ignored) { + schemeWeight = 1; + } + } else { + schemeId = schemeRaw.trim(); + } + schemeId = schemeId.toLowerCase(Locale.ROOT); + if (!GearModelSchemeRegistry.contains(schemeId)) { + Magic.plugin.getLogger().warning("[Magic] parts.yml: unknown model-scheme '" + + schemeRaw + "' for '" + id + "'"); + schemeId = ""; + schemeWeight = 1; + } + } PartDef def = new PartDef( id, MagicText.format(section.getString("name", id)), partType, + section.getInt("tier", 0), types, section.getString("item", "v.stone"), parseCost(section.getStringList("cost")), + partLimit, + parseStats(section.getStringList("stats")), sockets, lore, + schemeId, + schemeWeight, section.getBoolean("disabled", false)); if (def.totalSocketCount() > 4) { Magic.plugin.getLogger().warning("[Magic] part '" + id @@ -313,4 +415,31 @@ private static Map parseCost(List raw) { } return cost; } + + private static Map parseStats(List raw) { + Map stats = new LinkedHashMap<>(); + if (raw == null) { + return stats; + } + for (String entry : raw) { + if (entry == null || entry.isBlank()) { + continue; + } + int start = entry.indexOf('('); + int end = entry.indexOf(')'); + if (start <= 0 || end <= start) { + continue; + } + try { + String statId = entry.substring(0, start).trim().toLowerCase(Locale.ROOT); + if (statId.isEmpty()) { + continue; + } + stats.put(statId, Double.parseDouble(entry.substring(start + 1, end))); + } catch (NumberFormatException ignored) { + // skip malformed values + } + } + return stats; + } } diff --git a/src/main/java/net/tfminecraft/magic/loader/SkillsLoader.java b/src/main/java/net/tfminecraft/magic/loader/SkillsLoader.java index e8c4447..a8ab8de 100644 --- a/src/main/java/net/tfminecraft/magic/loader/SkillsLoader.java +++ b/src/main/java/net/tfminecraft/magic/loader/SkillsLoader.java @@ -33,6 +33,7 @@ public boolean loadSafe(File configFile) { continue; } String elementId; + int tier = SkillElementRegistry.DEFAULT_TIER; if (config.isConfigurationSection(skillId)) { ConfigurationSection section = config.getConfigurationSection(skillId); if (section == null) { @@ -40,6 +41,15 @@ public boolean loadSafe(File configFile) { continue; } elementId = section.getString("element"); + if (section.contains("tier")) { + int raw = section.getInt("tier", SkillElementRegistry.DEFAULT_TIER); + int clamped = SkillElementRegistry.clampTier(raw); + if (raw != clamped) { + Magic.plugin.getLogger().warning("[Magic] skills.yml: tier " + raw + + " for skill '" + skillId + "' clamped to " + clamped); + } + tier = clamped; + } } else { elementId = config.getString(skillId); } @@ -54,7 +64,7 @@ public boolean loadSafe(File configFile) { skipped++; continue; } - SkillElementRegistry.register(skillId, normalized); + SkillElementRegistry.register(skillId, normalized, tier); } Magic.plugin.getLogger().info("[Magic] Loaded " + SkillElementRegistry.size() + " skill binding(s)" + (skipped > 0 ? " (" + skipped + " skipped)" : "") + "."); diff --git a/src/main/java/net/tfminecraft/magic/manager/ResonanceGuiManager.java b/src/main/java/net/tfminecraft/magic/manager/ResonanceGuiManager.java index 604eb38..cece7e9 100644 --- a/src/main/java/net/tfminecraft/magic/manager/ResonanceGuiManager.java +++ b/src/main/java/net/tfminecraft/magic/manager/ResonanceGuiManager.java @@ -1,6 +1,10 @@ package net.tfminecraft.magic.manager; +import java.util.Locale; + import org.bukkit.Bukkit; +import org.bukkit.Sound; +import org.bukkit.SoundCategory; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; @@ -85,10 +89,36 @@ public void onInventoryClick(InventoryClickEvent event) { } if (!previousMode.equals(session.getCastModeId())) { + notifyCastModeChanged(player, session.getCastModeId()); refreshGui(player, holder, session); } } + private static void notifyCastModeChanged(Player player, String modeId) { + player.sendMessage(Messages.get("cast_mode.switched", "mode", castModeChatLabel(modeId))); + boolean flow = GuiCache.castModeRight.getId().equalsIgnoreCase(modeId); + player.playSound( + player.getLocation(), + Sound.BLOCK_AMETHYST_BLOCK_CHIME, + SoundCategory.PLAYERS, + 0.85f, + flow ? 1.5f : 0.75f); + } + + private static String castModeChatLabel(String modeId) { + if (modeId != null && modeId.equalsIgnoreCase(GuiCache.castModeRight.getId())) { + return "Flow"; + } + if (modeId != null && modeId.equalsIgnoreCase(GuiCache.castModeLeft.getId())) { + return "Surge"; + } + if (modeId == null || modeId.isBlank()) { + return "Surge"; + } + return modeId.substring(0, 1).toUpperCase(Locale.ROOT) + + modeId.substring(1).toLowerCase(Locale.ROOT); + } + @EventHandler public void onInventoryDrag(InventoryDragEvent event) { if (!(event.getView().getTopInventory().getHolder() instanceof ResonanceGuiHolder)) { diff --git a/src/main/java/net/tfminecraft/magic/model/ElementDef.java b/src/main/java/net/tfminecraft/magic/model/ElementDef.java index 6183a0a..94cae54 100644 --- a/src/main/java/net/tfminecraft/magic/model/ElementDef.java +++ b/src/main/java/net/tfminecraft/magic/model/ElementDef.java @@ -7,6 +7,7 @@ import java.util.regex.Pattern; import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; import net.tfminecraft.magic.Cache; import net.tfminecraft.magic.modifier.KeyframeCurve; @@ -23,6 +24,7 @@ public final class ElementDef { private final double maxResonance; private final Double decayPerHourOverride; private final double auraDecayPerHour; + private final String permission; private final KeyframeCurve resonance; public ElementDef(String id, ConfigurationSection config) { @@ -31,6 +33,8 @@ public ElementDef(String id, ConfigurationSection config) { this.icon = config.getString("icon", "v.BARRIER"); this.colors = readColors(config); this.slot = config.getInt("slot", -1); + String perm = config.getString("permission", ""); + this.permission = perm == null || perm.isBlank() ? null : perm.trim(); this.maxResonance = Math.max(1.0, config.getDouble("max_resonance", 100.0)); this.decayPerHourOverride = config.contains("decay_per_hour") ? config.getDouble("decay_per_hour") @@ -81,6 +85,18 @@ public int getSlot() { return slot; } + public String getPermission() { + return permission; + } + + /** Elements with a permission stay hidden and do not grow until the player holds it. */ + public boolean isUnlocked(Player player) { + if (permission == null) { + return true; + } + return player != null && player.hasPermission(permission); + } + public KeyframeCurve getResonanceCurve() { return resonance; } diff --git a/src/main/java/net/tfminecraft/magic/registry/SkillElementRegistry.java b/src/main/java/net/tfminecraft/magic/registry/SkillElementRegistry.java index 1a705da..eb877cc 100644 --- a/src/main/java/net/tfminecraft/magic/registry/SkillElementRegistry.java +++ b/src/main/java/net/tfminecraft/magic/registry/SkillElementRegistry.java @@ -7,34 +7,68 @@ public final class SkillElementRegistry { - private static final Map skillToElement = new LinkedHashMap<>(); + public static final int DEFAULT_TIER = 1; + public static final int MAX_TIER = 4; + + public record Binding(String elementId, int tier) {} + + private static final Map skills = new LinkedHashMap<>(); private SkillElementRegistry() {} public static void clear() { - skillToElement.clear(); + skills.clear(); } public static void register(String skillId, String elementId) { + register(skillId, elementId, DEFAULT_TIER); + } + + public static void register(String skillId, String elementId, int tier) { if (skillId == null || skillId.isBlank() || elementId == null || elementId.isBlank()) { return; } - skillToElement.put( - skillId.trim().toLowerCase(Locale.ROOT), elementId.trim().toLowerCase(Locale.ROOT)); + skills.put( + skillId.trim().toLowerCase(Locale.ROOT), + new Binding(elementId.trim().toLowerCase(Locale.ROOT), clampTier(tier))); } public static String elementOf(String skillId) { - if (skillId == null || skillId.isBlank()) { - return null; - } - return skillToElement.get(skillId.trim().toLowerCase(Locale.ROOT)); + Binding binding = bindingOf(skillId); + return binding == null ? null : binding.elementId(); + } + + public static int tierOf(String skillId) { + Binding binding = bindingOf(skillId); + return binding == null ? DEFAULT_TIER : binding.tier(); } public static Map bindings() { - return Collections.unmodifiableMap(skillToElement); + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : skills.entrySet()) { + copy.put(entry.getKey(), entry.getValue().elementId()); + } + return Collections.unmodifiableMap(copy); } public static int size() { - return skillToElement.size(); + return skills.size(); + } + + public static int clampTier(int tier) { + if (tier < DEFAULT_TIER) { + return DEFAULT_TIER; + } + if (tier > MAX_TIER) { + return MAX_TIER; + } + return tier; + } + + private static Binding bindingOf(String skillId) { + if (skillId == null || skillId.isBlank()) { + return null; + } + return skills.get(skillId.trim().toLowerCase(Locale.ROOT)); } } diff --git a/src/main/java/net/tfminecraft/magic/service/ResonanceService.java b/src/main/java/net/tfminecraft/magic/service/ResonanceService.java index 4a470ba..2c7c789 100644 --- a/src/main/java/net/tfminecraft/magic/service/ResonanceService.java +++ b/src/main/java/net/tfminecraft/magic/service/ResonanceService.java @@ -35,6 +35,9 @@ private static void tickSession(Player player, ResonanceSession session) { if (ratePerHour < 0.0 && current <= EPSILON) { continue; } + if (ratePerHour > 0.0 && !element.isUnlocked(player)) { + continue; + } session.addResonance(element.getId(), ratePerHour * dtHours); dirty = true; if (Cache.debug) { diff --git a/src/main/java/net/tfminecraft/magic/util/CostFormatter.java b/src/main/java/net/tfminecraft/magic/util/CostFormatter.java index 203bf5c..4324d3a 100644 --- a/src/main/java/net/tfminecraft/magic/util/CostFormatter.java +++ b/src/main/java/net/tfminecraft/magic/util/CostFormatter.java @@ -15,6 +15,15 @@ public final class CostFormatter { private CostFormatter() {} + public static void appendInput(List lore, Map costs) { + if (lore == null || costs == null || costs.isEmpty()) { + return; + } + lore.add(""); + lore.add(StringFormatter.formatHex("#76de91§lInput:")); + lore.addAll(getCostsFormatted(costs)); + } + public static List getCostsFormatted(Map map) { List result = new ArrayList<>(); if (map == null || map.isEmpty()) { diff --git a/src/main/java/net/tfminecraft/magic/util/MagicText.java b/src/main/java/net/tfminecraft/magic/util/MagicText.java index d2a6e03..abc715c 100644 --- a/src/main/java/net/tfminecraft/magic/util/MagicText.java +++ b/src/main/java/net/tfminecraft/magic/util/MagicText.java @@ -41,12 +41,20 @@ public static String elementName(ElementDef element) { if (element == null) { return ""; } - String plain = visibleName(element); + return elementText(element, visibleName(element)); + } + + /** Any text in the element's colour, solid or gradient like {@link #elementName}. */ + public static String elementText(ElementDef element, String plain) { + String text = plain != null ? plain : ""; + if (element == null) { + return format(FALLBACK_COLOR + text); + } List colors = element.getColors(); if (colors.size() > 1) { - return StringFormatter.applyColourGradient(plain, colors); + return StringFormatter.applyColourGradient(text, colors); } - return format(element.getColor() + plain); + return format(element.getColor() + text); } private static String visibleName(ElementDef element) { diff --git a/src/main/resources/artifacts/sacrifice.yml b/src/main/resources/artifacts/sacrifice.yml index ce9c4bb..af84302 100644 --- a/src/main/resources/artifacts/sacrifice.yml +++ b/src/main/resources/artifacts/sacrifice.yml @@ -54,7 +54,7 @@ tiers: elements: bloodmagic: words: - - "by blood i claim this" + - "Lorin Vekar Drakun Talis" lore: pain: "Filled with the pain of {character}" screams: "Filled with the screams of {character}" @@ -62,7 +62,7 @@ elements: necromancy: enabled: false words: - - "by bone i claim this" + - "Vorthas Luneth... Taro Vekran" lore: pain: "Filled with the pain of {character}" screams: "Filled with the screams of {character}" @@ -70,7 +70,7 @@ elements: shadowmancy: enabled: false words: - - "by shadow i claim this" + - "Need to do" lore: pain: "Filled with the pain of {character}" screams: "Filled with the screams of {character}" diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index b43d671..aa8ada1 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -25,10 +25,12 @@ equilibrium: 0: { mana: 0, damage: 0, cooldown: 0 } 100: { mana: -0.20, damage: 0.25, cooldown: -0.05 } -# MythicLib triggers the cast listener treats as a spell, besides CAST and API. -# These match the keybinds a rune can be set to. A weapon still has to be attuned -# to the spell's element. +# Runes: MMOItems type ids (item-types.yml) that /magic rune keybind can rebind. +# keybinds: triggers a rune can be set to. The cast listener also treats them as a +# spell, besides CAST and API. A weapon still has to be attuned to the spell's element. runes: + types: + - GEM_STONE keybinds: - RIGHT_CLICK - LEFT_CLICK @@ -95,10 +97,14 @@ attunement: off_per_hour: 0.04166666667 gear: - station: iaf(tfmc:weapon_station) + station: iaf(tfmc:magic_crafting_station) output-slot: 16 confirm_seconds: 5 + # When false, rune socket colours use only the archetype suffix (e.g. "Minor Rune") + # without the band prefix (Common / Rare / Epic / Legendary). Band still gates attunement. + socket_rarity_prefix: true + # One-sided bonus for holding a weapon attuned to the element of the spell you cast. # Keyed by the weapon's tier band in that element. A weapon with no attunement in an # element contributes nothing; there is no penalty side, because a weapon that cannot diff --git a/src/main/resources/elements/elements.yml b/src/main/resources/elements/elements.yml index 2ab77f4..2fef260 100644 --- a/src/main/resources/elements/elements.yml +++ b/src/main/resources/elements/elements.yml @@ -5,102 +5,108 @@ # Row 4 (second from bottom): Shadowmancy, Necromancy, Bloodmagic cerrith: - name: "#55ff55Cerrith" + name: Cerrith icon: m.cerrith_runes.rune_of_cerrith_glyph - color: "#55ff55" + color: "#466629" slot: 28 - aura_decay_per_hour: -0.05 + aura_decay_per_hour: 0 resonance: 0: { mana: 0.20, damage: -0.20, cooldown: 0.20 } 100: { mana: -0.20, damage: 0.20, cooldown: -0.20 } seithr: - name: "#ffffffSeithr" + name: Seithr icon: m.seithr_runes.rune_of_cold_embrace - color: "#ffffff" + color: "#15E5FF" slot: 30 - aura_decay_per_hour: -0.05 + aura_decay_per_hour: 0 resonance: 0: { mana: 0.20, damage: -0.20, cooldown: 0.20 } 100: { mana: -0.20, damage: 0.20, cooldown: -0.20 } oseni: - name: "#ffaa00Oseni" + name: Oseni icon: m.oseni_runes.rune_of_oseni_glyph - color: "#ffaa00" + color: "#e29a00" slot: 32 - aura_decay_per_hour: -0.05 + aura_decay_per_hour: 0 resonance: 0: { mana: 0.20, damage: -0.20, cooldown: 0.20 } 100: { mana: -0.20, damage: 0.20, cooldown: -0.20 } mitlan: - name: "#5555ffMitlan" + name: Mitlan icon: v.LAPIS_LAZULI color: "#5555ff" slot: 34 - aura_decay_per_hour: -0.05 + aura_decay_per_hour: 0 resonance: 0: { mana: 0.20, damage: -0.20, cooldown: 0.20 } 100: { mana: -0.20, damage: 0.20, cooldown: -0.20 } shadowmancy: - name: "#555555Shadowmancy" + name: Shadowmancy icon: v.INK_SAC color: "#555555" slot: 40 - aura_decay_per_hour: -0.05 + permission: magic.shadowmancy + aura_decay_per_hour: 0 resonance: 0: { mana: 0.20, damage: -0.20, cooldown: 0.20 } 100: { mana: -0.20, damage: 0.20, cooldown: -0.20 } necromancy: - name: "#00aaaaNecromancy" + name: Necromancy icon: v.WITHER_SKELETON_SKULL color: "#00aaaa" slot: 38 - aura_decay_per_hour: -0.05 + permission: magic.necromancy + aura_decay_per_hour: 0 resonance: 0: { mana: 0.20, damage: -0.20, cooldown: 0.20 } 100: { mana: -0.20, damage: 0.20, cooldown: -0.20 } arcanum: - name: "#aa00aaArcanum" + name: Arcanum icon: v.ENCHANTED_BOOK color: "#aa00aa" slot: 22 + permission: magic.arcanum decay_per_hour: -0.035 - aura_decay_per_hour: -0.05 + aura_decay_per_hour: 0 resonance: 0: { mana: 0.20, damage: -0.20, cooldown: 0.20 } 100: { mana: -0.20, damage: 0.20, cooldown: -0.20 } spirit: - name: "#e8d5a3Spirit" + name: Spirit icon: v.FEATHER color: "#e8d5a3" slot: 20 - aura_decay_per_hour: -0.05 + permission: magic.spirit + aura_decay_per_hour: 0 resonance: 0: { mana: 0.20, damage: -0.20, cooldown: 0.20 } 100: { mana: -0.20, damage: 0.20, cooldown: -0.20 } illusion: - name: "#e070b0Illusion" + name: Illusion icon: v.ENDER_PEARL color: "#e070b0" slot: 24 - aura_decay_per_hour: -0.05 + permission: magic.illusion + aura_decay_per_hour: 0 resonance: 0: { mana: 0.20, damage: -0.20, cooldown: 0.20 } 100: { mana: -0.20, damage: 0.20, cooldown: -0.20 } bloodmagic: - name: "#aa0000Bloodmagic" + name: Bloodmagic icon: v.REDSTONE color: "#aa0000" slot: 42 - aura_decay_per_hour: -0.05 + permission: magic.bloodmagic + aura_decay_per_hour: 0 resonance: 0: { mana: 0.20, damage: -0.20, cooldown: 0.20 } 100: { mana: -0.20, damage: 0.20, cooldown: -0.20 } diff --git a/src/main/resources/gear/archetypes.yml b/src/main/resources/gear/archetypes.yml index 353ac3b..d949c13 100644 --- a/src/main/resources/gear/archetypes.yml +++ b/src/main/resources/gear/archetypes.yml @@ -1,39 +1,51 @@ # Mage weapon archetypes. Templates are TLibs MMOItems paths Fran owns. # slots: id -> TLibs colour suffix. Prefix comes from socket-colours.yml. # Colour written = "{prefix} {suffix}", e.g. "Rare Staff Projectile Rune". +# icon: ItemRef path for picker/assembly GUIs (v.MATERIAL, m.TYPE.ID, ia.namespace:id). staff: name: Staff - template: m.mage_staffs.mage_iron_staff + template: m.mage_staffs.mage_custom_staff + icon: v.STICK melee: false required: - core - - form - - focus + - handle + - tome + - tome2 + - tome3 slots: - projectile: "Staff Projectile Rune" - support: "Staff Support Rune" - minor_spell: "Staff Minor Spell Rune" - major_spell: "Staff Major Spell Rune" + minor_rune: "Minor Rune" + lesser_rune: "Lesser Rune" + greater_rune: "Greater Rune" + ascendant_rune: "Ascendant Rune" wand: name: Wand - template: m.magic.wand_template + template: m.mage_wands.mage_custom_wand + icon: v.BLAZE_ROD melee: false required: - core - - focus + - handle + - tome slots: - minor_spell: "Wand Minor Spell Rune" - major_spell: "Wand Major Spell Rune" + minor_rune: "Minor Rune" + lesser_rune: "Lesser Rune" + greater_rune: "Greater Rune" + ascendant_rune: "Ascendant Rune" sword: name: Sword - template: m.magic.sword_template + template: m.mage_swords.mage_custom_sword + icon: v.IRON_SWORD melee: true required: - core - - form + - handle + - tome slots: - support: "Sword Support Rune" - spell: "Sword Spell Rune" + minor_rune: "Minor Rune" + lesser_rune: "Lesser Rune" + greater_rune: "Greater Rune" + ascendant_rune: "Ascendant Rune" diff --git a/src/main/resources/gear/model-schemes.yml b/src/main/resources/gear/model-schemes.yml new file mode 100644 index 0000000..98804b3 --- /dev/null +++ b/src/main/resources/gear/model-schemes.yml @@ -0,0 +1,26 @@ +# Placeholder gear skins. Fran replaces ia. paths. +# Each scheme maps archetype -> ItemRef path (ia.namespace:id or v.MATERIAL[.cmd]). + +iron: + models: + - staff(ia.tfmc_magic:iron_staff) + - wand(ia.tfmc_magic:iron_wand) + - sword(ia.tfmc_magic:iron_sword) + +steel: + models: + - staff(ia.tfmc_magic:steel_staff) + - wand(ia.tfmc_magic:steel_wand) + - sword(ia.tfmc_magic:steel_sword) + +abyssalite: + models: + - staff(ia.tfmc_magic:abyssalite_staff) + - wand(ia.tfmc_magic:abyssalite_wand) + - sword(ia.tfmc_magic:abyssalite_sword) + +mythril: + models: + - staff(ia.tfmc_magic:mythril_staff) + - wand(ia.tfmc_magic:mythril_wand) + - sword(ia.tfmc_magic:mythril_sword) diff --git a/src/main/resources/gear/part-types.yml b/src/main/resources/gear/part-types.yml index 5a614f2..3b711e1 100644 --- a/src/main/resources/gear/part-types.yml +++ b/src/main/resources/gear/part-types.yml @@ -3,9 +3,15 @@ core: slot: 10 name: Core -form: +handle: slot: 11 - name: Form -focus: + name: Handle +tome: slot: 12 - name: Focus + name: Tome +tome2: + slot: 13 + name: Tome2 +tome3: + slot: 14 + name: Tome3 \ No newline at end of file diff --git a/src/main/resources/gear/parts.yml b/src/main/resources/gear/parts.yml index 66e5299..8b0023e 100644 --- a/src/main/resources/gear/parts.yml +++ b/src/main/resources/gear/parts.yml @@ -1,72 +1,527 @@ # PoC parts. Fran tunes socket counts, items, and costs. # Ceiling is 4 empty sockets on the finished weapon; extras are dropped at craft. +# part-limit: extra part categories a core unlocks (mandatory once that core is chosen). +# stats: MMOItems ids with doubles, same shape as cost — attack_damage(4). +# model-scheme: scheme id, optional weight like iron(10). Looked up in model-schemes.yml. -timber_core: - name: "§7Timber Core" +iron_staff_core: + name: "#d8d8d8Iron Magical Core" part-type: core + tier: 1 type: - staff + item: m.materials.iron_core + model-scheme: iron + cost: + - v.iron_ingot(4) + - m.currency.enchanted_dust(4) + part-limit: + - handle + sockets: {} + lore: + - "§7A basic magical core with one rune slot" + +steel_staff_core: + name: "#7f7d80Steel Magical Core" + part-type: core + tier: 2 + type: + - staff + item: m.materials.steel_core + model-scheme: steel + cost: + - m.materials.steel_ingot(4) + - m.currency.enchanted_dust(4) + part-limit: + - handle + - tome + sockets: {} + lore: + - "§7A more advanced magical core with two rune slots" + +abyssalite_staff_core: + name: "#3b4e60Abyssalite Magical Core" + part-type: core + tier: 3 + type: + - staff + item: m.materials.abyssalite_core + model-scheme: abyssalite + cost: + - m.materials.abyssalite_ingot(4) + - m.currency.enchanted_dust(4) + part-limit: + - handle + - tome + - tome2 + sockets: {} + lore: + - "§7An incredible magical core with three rune slots" + +mythril_staff_core: + name: "#9be1f3Mythril Magical Core" + part-type: core + tier: 4 + type: + - staff + item: m.materials.mythril_core + model-scheme: mythril + cost: + - m.materials.mythril_ingot(4) + - m.currency.enchanted_dust(4) + part-limit: + - handle + - tome + - tome2 + - tome3 + sockets: {} + lore: + - "§7An insanely powerful magical core with four rune slots" + +iron_wand_core: + name: "#d8d8d8Iron Magical Core" + part-type: core + tier: 1 + type: + - wand + item: m.materials.iron_core + model-scheme: iron + cost: + - v.iron_ingot(4) + - m.currency.enchanted_dust(4) + part-limit: + - handle + stats: + - attack_damage(4) + - attack_speed(1.2) + - mana_cost(2) + sockets: {} + lore: + - "§7A basic magical core with one rune slot and a simple way of mana channeling" + +steel_wand_core: + name: "#7f7d80Steel Magical Core" + part-type: core + tier: 2 + type: - wand + item: m.materials.steel_core + model-scheme: steel + cost: + - m.materials.steel_ingot(4) + - m.currency.enchanted_dust(4) + part-limit: + - handle + sockets: {} + lore: + - "§7An improved magical core with one rune slot and a better way of mana channeling" + +abyssalite_wand_core: + name: "#3b4e60Abyssalite Magical Core" + part-type: core + tier: 3 + type: + - wand + item: m.materials.abyssalite_core + model-scheme: abyssalite + cost: + - m.materials.abyssalite_ingot(4) + - m.currency.enchanted_dust(4) + part-limit: + - handle + - tome + sockets: {} + lore: + - "§7A good magical core with two rune slots and an advanced way of mana channeling" + +mythril_wand_core: + name: "#9be1f3Mythril Magical Core" + part-type: core + tier: 4 + type: + - wand + item: m.materials.mythril_core + model-scheme: mythril + cost: + - m.materials.mythril_ingot(4) + - m.currency.enchanted_dust(4) + part-limit: + - handle + - tome + stats: + - attack_damage(6) + - attack_speed(1.2) + - mana_cost(1) + sockets: {} + lore: + - "§7A great magical core with two rune slots and a powerful way of mana channeling" + +iron_sword_core: + name: "#d8d8d8Iron Magical Core" + part-type: core + tier: 1 + type: + - sword + item: m.materials.iron_core + model-scheme: iron + cost: + - v.iron_ingot(4) + - m.currency.enchanted_dust(4) + part-limit: + - handle + sockets: {} + lore: + - "§7A basic magical core with one rune slot and a simple blade edge" + +steel_sword_core: + name: "#7f7d80Steel Magical Core" + part-type: core + tier: 2 + type: + - sword + item: m.materials.steel_core + model-scheme: steel + cost: + - m.materials.steel_ingot(4) + - m.currency.enchanted_dust(4) + part-limit: + - handle + sockets: {} + lore: + - "§7An improved magical core with one rune slot and a better blade edge" + +abyssalite_sword_core: + name: "#3b4e60Abyssalite Magical Core" + part-type: core + tier: 3 + type: + - sword + item: m.materials.abyssalite_core + model-scheme: abyssalite + cost: + - m.materials.abyssalite_ingot(4) + - m.currency.enchanted_dust(4) + part-limit: + - handle + - tome + sockets: {} + lore: + - "§7A good magical core with two rune slots and an advanced blade edge" + +mythril_sword_core: + name: "#9be1f3Mythril Magical Core" + part-type: core + tier: 4 + type: - sword - item: v.oak_log + item: m.materials.mythril_core + model-scheme: mythril cost: - - v.oak_log(2) + - m.materials.mythril_ingot(4) + - m.currency.enchanted_dust(4) + part-limit: + - handle + - tome sockets: {} lore: - - "§8The spine of the piece" + - "§7A great magical core with two rune slots and a powerful blade edge" -oak_shaft: - name: "§7Oak Shaft" - part-type: form +oak_handle: + name: "§dOak Magical Handle" + part-type: handle + tier: 1 type: - staff + - wand + - sword item: v.stick cost: - - v.stick(4) + - v.stick(2) + - m.currency.enchanted_dust(2) + sockets: + minor_rune: 1 + lore: + - "§7A simple handle capable of channeling minor runes" + +basic_handle: + name: "§dBasic Magical Handle" + part-type: handle + tier: 2 + type: + - staff + - wand + - sword + item: m.materials.basic_handle + cost: + - v.stick(2) + - m.currency.enchanted_dust(4) + sockets: + lesser_rune: 1 + lore: + - "§7A better handle capable of channeling lesser runes" + +petty_handle: + name: "§dPetty Magical Handle" + part-type: handle + tier: 3 + type: + - staff + - wand + - sword + item: m.materials.petty_handle + cost: + - v.stick(2) + - m.currency.enchanted_dust(6) sockets: - projectile: 1 - support: 1 + greater_rune: 1 lore: - - "§8Staff form: projectile and support" + - "§7An advanced handle capable of channeling greater runes" -steel_blade: - name: "§7Steel Blade" - part-type: form +heavy_handle: + name: "§dHeavy Magical Handle" + part-type: handle + tier: 4 type: + - staff + - wand + - sword + item: m.materials.heavy_handle + cost: + - v.stick(2) + - m.currency.enchanted_dust(8) + sockets: + ascendant_rune: 1 + lore: + - "§7An amazing handle capable of channeling ascendant runes" + +normal_oak_tome: + name: "§dSimple Tome" + part-type: tome + tier: 1 + type: + - staff + - wand + - sword + item: v.book + cost: + - v.book(2) + lore: + - "§7A mundane book with no effect" + +oak_tome: + name: "§dSimple Magical Tome" + part-type: tome + tier: 1 + type: + - staff + - wand - sword - item: v.iron_ingot + item: v.book cost: - - v.iron_ingot(3) + - v.book(2) + - m.currency.enchanted_dust(2) sockets: - support: 1 - spell: 1 + minor_rune: 1 lore: - - "§8Sword form: support and spell" + - "§7A simple tome capable of channeling minor runes" -amber_focus: - name: "§7Amber Focus" - part-type: focus +basic_tome: + name: "§dBasic Magical Tome" + part-type: tome + tier: 2 type: - staff - item: v.glowstone_dust + - wand + - sword + item: m.materials.basic_tome cost: - - v.glowstone_dust(2) + - v.book(2) + - m.currency.enchanted_dust(4) sockets: - minor_spell: 1 - major_spell: 1 + lesser_rune: 1 lore: - - "§8Staff focus: two spell sockets" + - "§7A better tome capable of channeling lesser runes" -glass_focus: - name: "§7Glass Focus" - part-type: focus +petty_tome: + name: "§dPetty Magical Tome" + part-type: tome + tier: 3 type: + - staff - wand - item: v.glass + - sword + item: m.materials.petty_tome cost: - - v.glass(2) + - v.book(2) + - m.currency.enchanted_dust(6) sockets: - minor_spell: 1 - major_spell: 1 + greater_rune: 1 lore: - - "§8Wand focus: two spell sockets" + - "§7An advanced tome capable of channeling greater runes" + +heavy_tome: + name: "§dHeavy Magical Tome" + part-type: tome + tier: 4 + type: + - staff + - wand + - sword + item: m.materials.heavy_tome + cost: + - v.book(2) + - m.currency.enchanted_dust(8) + sockets: + ascendant_rune: 1 + lore: + - "§7An amazing tome capable of channeling ascendant runes" + +normal_oak_tome2: + name: "§dSimple Tome" + part-type: tome2 + tier: 1 + type: + - staff + - wand + - sword + item: v.book + cost: + - v.book(2) + lore: + - "§7A mundane book with no effect" + +oak_tome2: + name: "§dSimple Magical Tome" + part-type: tome2 + tier: 1 + type: + - staff + item: v.book + cost: + - v.book(2) + - m.currency.enchanted_dust(2) + sockets: + minor_rune: 1 + lore: + - "§7A simple tome capable of channeling minor runes" + +basic_tome2: + name: "§dBasic Magical Tome" + part-type: tome2 + tier: 2 + type: + - staff + item: m.materials.basic_tome + cost: + - v.book(2) + - m.currency.enchanted_dust(4) + sockets: + lesser_rune: 1 + lore: + - "§7A better tome capable of channeling lesser runes" + +petty_tome2: + name: "§dPetty Magical Tome" + part-type: tome2 + tier: 3 + type: + - staff + item: m.materials.petty_tome + cost: + - v.book(2) + - m.currency.enchanted_dust(6) + sockets: + greater_rune: 1 + lore: + - "§7An advanced tome capable of channeling greater runes" + +heavy_tome2: + name: "§dHeavy Magical Tome" + part-type: tome2 + tier: 4 + type: + - staff + item: m.materials.heavy_tome + cost: + - v.book(2) + - m.currency.enchanted_dust(8) + sockets: + ascendant_rune: 1 + lore: + - "§7An amazing tome capable of channeling ascendant runes" + +normal_oak_tome3: + name: "§dSimple Tome" + part-type: tome3 + tier: 1 + type: + - staff + - wand + - sword + item: v.book + cost: + - v.book(2) + lore: + - "§7A mundane book with no effect" + +oak_tome3: + name: "§dSimple Magical Tome" + part-type: tome3 + tier: 1 + type: + - staff + item: v.book + cost: + - v.book(2) + - m.currency.enchanted_dust(2) + sockets: + minor_rune: 1 + lore: + - "§7A simple tome capable of channeling minor runes" + +basic_tome3: + name: "§dBasic Magical Tome" + part-type: tome3 + tier: 2 + type: + - staff + item: m.materials.basic_tome + cost: + - v.book(2) + - m.currency.enchanted_dust(4) + sockets: + lesser_rune: 1 + lore: + - "§7A better tome capable of channeling lesser runes" + +petty_tome3: + name: "§dPetty Magical Tome" + part-type: tome3 + tier: 3 + type: + - staff + item: m.materials.petty_tome + cost: + - v.book(2) + - m.currency.enchanted_dust(6) + sockets: + greater_rune: 1 + lore: + - "§7An advanced tome capable of channeling greater runes" + +heavy_tome3: + name: "§dHeavy Magical Tome" + part-type: tome3 + tier: 4 + type: + - staff + item: m.materials.heavy_tome + cost: + - v.book(2) + - m.currency.enchanted_dust(8) + sockets: + ascendant_rune: 1 + lore: + - "§7An amazing tome capable of channeling ascendant runes" + + diff --git a/src/main/resources/gear/socket-colours.yml b/src/main/resources/gear/socket-colours.yml index 15513cf..4cbbaf5 100644 --- a/src/main/resources/gear/socket-colours.yml +++ b/src/main/resources/gear/socket-colours.yml @@ -1,6 +1,7 @@ # Resonance band -> TLibs colour prefix. -# Colour string = prefix + " " + archetype slot suffix. +# Colour string = prefix + " " + archetype slot suffix (when config gear.socket_rarity_prefix is true). # Must match tlibs socket-tier-groups keys exactly. +# Set gear.socket_rarity_prefix: false in config.yml to write suffix only (no Common/Rare/Epic/Legendary). prefix: default: Common @@ -11,8 +12,11 @@ prefix: # Socket slot id -> player-facing name in the assembly GUI. labels: - projectile: Projectile - support: Support - minor_spell: Minor Spell - major_spell: Major Spell - spell: Spell + minor_rune: Minor Rune + lesser_rune: Lesser Rune + greater_rune: Greater Rune + asecandt_rune: Ascendant Rune + minor_armor_rune: Minor Armor Rune + lesser_armor_rune: Lesser Armor Rune + greater_armor_rune: Greater Armor Rune + asecandt_armor_rune: Ascendant Armor Rune diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 5281cf0..ba8a21b 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -4,6 +4,10 @@ reload: failed: "#ff5555[Magic] Reload failed - check console." +cast_mode: + + switched: "#55ff55Casting mode set to {mode}." + open: players_only: "#ff5555Only players can open the Resonance GUI." @@ -16,7 +20,25 @@ admin: no_permission: "#ff5555You do not have permission to use this command." - usage: "#aaaaaaUsage: /magic reload #555555| #aaaaaa/magic open #555555| #aaaaaa/magic resonance #555555| #aaaaaa/magic artifact roll #555555| #aaaaaa/magic artifact give #555555| #aaaaaa/magic artifact create #555555| #aaaaaa/magic artifact setfill #555555| #aaaaaa/magic fillchest #555555| #aaaaaa/magic shrine fill" + usage: "#aaaaaaUsage: /magic rune keybind #555555| #aaaaaa/magic reload #555555| #aaaaaa/magic open #555555| #aaaaaa/magic resonance #555555| #aaaaaa/magic artifact roll #555555| #aaaaaa/magic artifact give #555555| #aaaaaa/magic artifact create #555555| #aaaaaa/magic artifact setfill #555555| #aaaaaa/magic fillchest #555555| #aaaaaa/magic shrine fill" + +rune: + + usage: "#aaaaaaUsage: /magic rune keybind " + + players_only: "#ff5555Only players can rebind a held rune." + + no_permission: "#ff5555You do not have permission to rebind runes." + + not_a_rune: "#ff5555Hold a rune to rebind its abilities." + + no_abilities: "#ff5555That rune has no abilities to rebind." + + unknown_trigger: "#ff5555Unknown trigger." + + failed: "#ff5555Could not rebind that rune." + + success: "#55ff55Rebound all abilities on this rune to {trigger}." fillchest: @@ -38,17 +60,25 @@ cast: whiff: "§7*Whiff*" whiff_sub: "#aaaaaa§oRift {rift}%" - + whiff_sub_staffs: "#aaaaaa§oToo many staffs" # Refusal: the weapon never started. Nothing is spent, and it is never a whiff. refuse_title: "§7*Refused*" - refuse_sub: "#aaaaaa§oThis weapon is beyond you" + refuse_sub: "#aaaaaa§oThis spell is beyond you" + + refuse_sub_spell: "#aaaaaa§oThis spell is beyond you" + + refuse_sub_weapon: "#aaaaaa§oThis weapon cannot carry that spell" refuse_sub_foreign: "#aaaaaa§oThis weapon holds no {element}" - refuse_chat: "#aaaaaaThe weapon asks for {element} {need}. You carry {have}." + refuse_chat: "#aaaaaaThis spell needs {element} {need}. You carry {have}." + + refuse_chat_spell: "#aaaaaaThis spell needs {element} {need}. You carry {have}." + + refuse_chat_weapon: "#aaaaaaThe weapon holds {element} {have}. This spell needs {need}." refuse_chat_foreign: "#aaaaaaThis weapon was never attuned to {element}. Apply a {element} charge at a station." @@ -81,6 +111,10 @@ resonance: reset_ok: "#55ff55Reset {player} {element} resonance." + locked: "#ff5555{player} lacks {permission} ({element})." + + locked_skipped: "#ff5555Skipped locked elements for {player}: {elements}" + artifact: 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" @@ -261,11 +295,29 @@ gear: rift: "#ff5555The weapon is left with {rift}% rift." + summary_title: "{color:label_accent}Imbued resonance" + + summary_line_prefix: "{color:label_muted} " + + summary_unattuned: "{color:label_muted}None yet" + + summary_rift: "{color:corruption}Rift {color:label_muted}{rift}%" + + summary_hint_resonance: "{color:label_muted}Apply another enchanted charge to raise resonance on this weapon." + + summary_hint_resonance_and_clean: "{color:label_muted}Apply another enchanted charge to raise resonance or clean rift from this weapon." + busy: "#aaaaaa§oThis station is already mid-attunement" eject: - unattuned: "#aaaaaa§oThe weapon is inert. It holds no element yet" + unattuned: "#aaaaaa§oThe weapon is inert. Shift-left-click the station to take the materials back" + + abort: + + done: "#aaaaaa§oThe craft comes apart. The charge, if any, is spent" + + not_yours: "#ff5555Only the player who prepared this craft can take it apart." broken: diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 819825c..7db7c21 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -13,14 +13,16 @@ commands: aliases: [res] permission: magic.use magic: - description: Magic plugin admin commands - usage: /magic - permission: magic.admin + description: Magic plugin commands + usage: /magic permissions: magic.use: description: Allows using /resonance default: true + magic.rune.keybind: + description: Allows /magic rune keybind on a held rune + default: true magic.admin: description: Allows /magic admin commands (reload, open) default: op @@ -29,3 +31,21 @@ permissions: magic.admin.reload: description: Legacy permission alias for magic.admin default: op + magic.spirit: + description: Allows Spirit resonance + default: false + magic.arcanum: + description: Allows Arcanum resonance + default: false + magic.illusion: + description: Allows Illusion resonance + default: false + magic.shadowmancy: + description: Allows Shadowmancy resonance + default: false + magic.necromancy: + description: Allows Necromancy resonance + default: false + magic.bloodmagic: + description: Allows Bloodmagic resonance + default: false diff --git a/src/main/resources/skills.yml b/src/main/resources/skills.yml index 3bc04b7..c743332 100644 --- a/src/main/resources/skills.yml +++ b/src/main/resources/skills.yml @@ -1,4 +1,4 @@ -# skill id -> element id (MythicLib / MMOItems). Jar does not overwrite a live copy. +# skill id -> element + tier (MythicLib / MMOItems). Jar does not overwrite a live copy. # # The id is the MythicLib skill handler id, lowercase, which is what an MMOItems # ability reports. Flat or nested both work: @@ -7,6 +7,93 @@ # # arcanum_bolt: # element: arcanum +# tier: 1 # -# The element decides two things: which weapon requirement gates the cast, and which -# alignment band the weapon contributes. A skill with no binding is never gated. +# element: which weapon imbue and player resonance gate the cast, and which alignment +# band the weapon contributes. A skill with no binding is never gated. +# +# tier: 1-4 (Minor / Lesser / Greater / Ascendant). The weapon must hold that element +# at this band, and the caster's resonance must meet the same band. Omitted tier is 1. + +#Cerrith +Healing_Orb: + element: Cerrith + tier: 1 + +Shielding_Orb: + element: Cerrith + tier: 1 + +Hand_Cure: + element: Cerrith + tier: 2 + +Blessing_Of_Swiftness: + element: Cerrith + tier: 2 + +Cerrith_Glyph: + element: Cerrith + tier: 3 + +Restoration: + element: Cerrith + tier: 3 + +Blessing_Of_Healing: + element: Cerrith + tier: 4 + +Mana_Transfer: + element: Cerrith + tier: 4 + +#Oseni +Fire_Shard: + element: Oseni + tier: 1 + +Fire_Breath: + element: Oseni + tier: 2 + +Oseni_Glyph: + element: Oseni + tier: 3 + +Fire_Rain: + element: Oseni + tier: 4 + +#Seithr +Silencing_Shard: + element: Seithr + tier: 1 + +Ice_Shard: + element: Seithr + tier: 1 + +Ice_Shield: + element: Seithr + tier: 2 + +Ice_Wave: + element: Seithr + tier: 2 + +Seithr_Glyph: + element: Seithr + tier: 3 + +Cold_Embrace: + element: Seithr + tier: 3 + +Frostveil: + element: Seithr + tier: 4 + +Frozen_Tomb: + element: Seithr + tier: 4