diff --git a/README.md b/README.md index 5a4336d..b026901 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,22 @@ Magic connects a character's relationship with the elements to meditation, artif Magic keeps resonance profiles tied to roleplay characters through RPCharacters. Its menus make current affinities and modifiers visible, while its equipment systems connect preparation at the crafting station with the character's experience when casting spells. +## Staff weapon commands + +`/magic weapon give [part...]` + +Gives one completed mage weapon to an online player. Use part IDs from `gear/parts.yml` +and an element ID from the loaded element configuration. Supply exactly one part for +each required category, respecting the core's part limit. Aura is the raw attunement +amount and must reach a configured tier band. The command applies attunement, finalizes +sockets and records no material cost. It does not change the recipient's resonance. +The recipient needs an empty inventory slot. + +`give-permission` in `config.yml` defaults to `magic.weapon.give` (operators). +Set it to your staff permission; a blank value disables giving. This permission is +independent of `magic.admin`. Reload configuration with `/magic reload`. +Tab completion suggests recipients, archetypes, elements and enabled part IDs. + ## Documentation [Project documentation](https://github.com/TF-Minecraft/Docs/blob/main/projects/Magic/README.md) diff --git a/src/main/java/net/tfminecraft/magic/Cache.java b/src/main/java/net/tfminecraft/magic/Cache.java index f3a850f..c369ec2 100644 --- a/src/main/java/net/tfminecraft/magic/Cache.java +++ b/src/main/java/net/tfminecraft/magic/Cache.java @@ -12,6 +12,8 @@ * Runtime flags from config.yml. */ public final class Cache { + public static String givePermission = "magic.weapon.give"; + public static boolean debug = false; public static String defaultCastMode = "flow"; diff --git a/src/main/java/net/tfminecraft/magic/command/MagicCommand.java b/src/main/java/net/tfminecraft/magic/command/MagicCommand.java index ff3a3d4..a346fc2 100644 --- a/src/main/java/net/tfminecraft/magic/command/MagicCommand.java +++ b/src/main/java/net/tfminecraft/magic/command/MagicCommand.java @@ -55,6 +55,8 @@ public boolean onCommand(CommandSender sender, Command command, String label, St if (args.length == 0) { if (hasAdmin(sender)) { sender.sendMessage(Messages.get("admin.usage")); + } else if (WeaponGiveCommand.allowed(sender)) { + sender.sendMessage("\u00a7eUsage: /magic weapon give [part...]"); } else if (hasRuneKeybind(sender)) { sender.sendMessage(Messages.get("rune.usage")); } else { @@ -67,6 +69,10 @@ public boolean onCommand(CommandSender sender, Command command, String label, St return handleRune(sender, args); } + if ("weapon".equalsIgnoreCase(args[0])) { + return WeaponGiveCommand.execute(sender, args); + } + if (!hasAdmin(sender)) { sender.sendMessage(Messages.get("admin.no_permission")); return true; @@ -658,13 +664,20 @@ public List onTabComplete(CommandSender sender, Command command, String if (args.length == 0) { return Collections.emptyList(); } + if ("weapon".equalsIgnoreCase(args[0])) { + return WeaponGiveCommand.complete(sender, args); + } + boolean give = WeaponGiveCommand.allowed(sender); boolean admin = hasAdmin(sender); boolean rune = hasRuneKeybind(sender); - if (!admin && !rune) { + if (!admin && !rune && !give) { return Collections.emptyList(); } if (args.length == 1) { List options = new ArrayList<>(); + if (give) { + options.add("weapon"); + } if (rune) { options.add("rune"); } diff --git a/src/main/java/net/tfminecraft/magic/command/WeaponGiveCommand.java b/src/main/java/net/tfminecraft/magic/command/WeaponGiveCommand.java new file mode 100644 index 0000000..edb183b --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/command/WeaponGiveCommand.java @@ -0,0 +1,110 @@ +package net.tfminecraft.magic.command; + +import java.util.*; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import net.tfminecraft.magic.Cache; +import net.tfminecraft.magic.charge.TierBands; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.model.ElementVisibility; +import net.tfminecraft.magic.registry.ElementRegistry; + +/** Staff spawning uses the same builder and socket finalization as station crafting. */ +public final class WeaponGiveCommand { + private WeaponGiveCommand() {} + + static boolean allowed(CommandSender sender) { + return !Cache.givePermission.isBlank() && sender.hasPermission(Cache.givePermission); + } + + static boolean execute(CommandSender sender, String[] args) { + if (!allowed(sender)) { + sender.sendMessage("§cYou do not have permission to give mage weapons."); + return true; + } + if (args.length < 7 || !"give".equalsIgnoreCase(args[1])) { + sender.sendMessage("§eUsage: /magic weapon give [part...]"); + return true; + } + Player target = Bukkit.getPlayerExact(args[2]); + if (target == null) { + sender.sendMessage("§cPlayer must be online."); + return true; + } + GearType type = GearType.fromId(args[3]); + ArchetypeDef archetype = ArchetypeRegistry.get(type); + if (archetype == null) { + sender.sendMessage("§cUnknown weapon archetype."); + return true; + } + String element = args[4].toLowerCase(Locale.ROOT); + if (ElementRegistry.getById(element) == null || !ElementVisibility.shownOnCharge(element)) { + sender.sendMessage("§cUnknown or disabled attunement element."); + return true; + } + double aura; + try { + aura = Double.parseDouble(args[5]); + } catch (NumberFormatException ex) { + sender.sendMessage("§cAura must be a finite positive amount reaching an attunement band."); + return true; + } + if (!Double.isFinite(aura) || aura <= 0 || TierBands.bandOf(element, aura) == 0) { + sender.sendMessage("§cAura must be a finite positive amount reaching an attunement band."); + return true; + } + Map parts = new LinkedHashMap<>(); + for (int i = 6; i < args.length; i++) { + PartDef part = PartRegistry.get(args[i]); + if (part == null || part.isDisabled() || !part.supports(type)) { + sender.sendMessage("§cUnknown, disabled or incompatible part: " + args[i]); + return true; + } + if (parts.putIfAbsent(part.getPartType(), part) != null) { + sender.sendMessage("§cOnly one part per category is allowed."); + return true; + } + } + List required = PartSlots.open(archetype, parts.get(PartSlots.CORE)); + if (!parts.keySet().equals(new HashSet<>(required))) { + sender.sendMessage("§cSupply exactly these part categories: " + String.join(", ", required)); + return true; + } + if (Arrays.stream(target.getInventory().getStorageContents()).noneMatch(slot -> slot == null || slot.getType().isAir())) { + sender.sendMessage("§cRecipient needs an empty inventory slot."); + return true; + } + ItemStack item = GearItemBuilder.prepare(type, parts.values()); + if (item == null || item.getType().isAir() || item.getType() == Material.BARRIER) { + sender.sendMessage("§cWeapon could not be built; check its template and parts."); + return true; + } + GearProvenance.stampInputs(item, Map.of()); + WeaponRequirement requirement = WeaponRequirement.fromItem(item); + requirement.mergeAmounts(Map.of(element, aura)); + requirement.persist(item); + item = GearItemBuilder.rewriteSockets(item, requirement.highestBand()); + target.getInventory().addItem(item); + sender.sendMessage("§aGave completed " + type.getDisplayName() + " to " + target.getName() + "."); + return true; + } + + static List complete(CommandSender sender, String[] args) { + if (!allowed(sender) || args.length == 1) return List.of(); + List options = new ArrayList<>(); + if (args.length == 2) options.add("give"); + else if (!"give".equalsIgnoreCase(args[1])) return List.of(); + else if (args.length == 3) Bukkit.getOnlinePlayers().forEach(p -> options.add(p.getName())); + else if (args.length == 4) ArchetypeRegistry.getAll().keySet().forEach(t -> options.add(t.name().toLowerCase(Locale.ROOT))); + else if (args.length == 5) options.addAll(ElementRegistry.getAllIds()); + else if (args.length >= 7) { + GearType type = GearType.fromId(args[3]); + PartRegistry.matching(null, type).forEach(p -> options.add(p.getId())); + } + String prefix = args[args.length - 1].toLowerCase(Locale.ROOT); + return options.stream().filter(s -> s.toLowerCase(Locale.ROOT).startsWith(prefix)).sorted().toList(); + } +} diff --git a/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java b/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java index 5ecbc89..ffc75e1 100644 --- a/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java @@ -38,6 +38,7 @@ public boolean loadSafe(File configFile) { return false; } + Cache.givePermission = config.getString("give-permission", "magic.weapon.give").trim(); Cache.debug = config.getBoolean("debug", Cache.debug); Cache.loggingEnabled = config.getBoolean("logging", true); Cache.wipeLog = config.getBoolean("wipe-log", true); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index bb07f4c..0a9a782 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,3 +1,6 @@ +# Permission required to spawn completed weapons; blank disables the command. +give-permission: magic.weapon.give + debug: false # When true, aura events are appended to plugins/Magic/logs/aura.log logging: true diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 7db7c21..2f44a20 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -14,7 +14,7 @@ commands: permission: magic.use magic: description: Magic plugin commands - usage: /magic + usage: /magic permissions: magic.use: @@ -49,3 +49,6 @@ permissions: magic.bloodmagic: description: Allows Bloodmagic resonance default: false + magic.weapon.give: + description: Give completed weapons (default give-permission) + default: op diff --git a/src/test/java/net/tfminecraft/magic/WeaponGiveTest.java b/src/test/java/net/tfminecraft/magic/WeaponGiveTest.java new file mode 100644 index 0000000..ed81fd5 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/WeaponGiveTest.java @@ -0,0 +1,103 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.*; +import net.tfminecraft.magic.command.*; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.charge.TierBands; +import net.tfminecraft.magic.model.ElementVisibility; +import net.tfminecraft.magic.registry.ElementRegistry; +import org.bukkit.Material; +import org.bukkit.command.CommandSender; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +class WeaponGiveTest { + ServerMock server; + PlayerMock target; + CommandSender sender; + MagicCommand command = new MagicCommand(); + String[] valid = {"weapon", "give", "Mage", "staff", "fire", "10", "core"}; + + @BeforeEach void setup() { + server = MockBukkit.mock(); target = server.addPlayer("Mage"); + Magic.plugin = mock(Magic.class); when(Magic.plugin.namespace()).thenReturn("magic"); + sender = mock(CommandSender.class); + Cache.givePermission = "custom.staff"; + when(sender.hasPermission("custom.staff")).thenReturn(true); + ArchetypeRegistry.clear(); PartRegistry.clear(); ElementRegistry.clear(); TierBands.clear(); + ArchetypeRegistry.register(new ArchetypeDef(GearType.STAFF, "Staff", "v.STICK", "v.STICK", false, List.of("core"), Map.of())); + PartRegistry.register(part("core", "core", false, GearType.STAFF)); + ElementRegistry.register(DomainTest.element("fire")); TierBands.register("fire", 1, 5); + } + PartDef part(String id, String category, boolean disabled, GearType type) { + return new PartDef(id, id, category, 1, Set.of(type), "v.STICK", Map.of(), List.of(), Map.of(), Map.of(), List.of(), "", 1, disabled); + } + @AfterEach void cleanup() { + Cache.givePermission = "magic.weapon.give"; + ArchetypeRegistry.clear(); PartRegistry.clear(); ElementRegistry.clear(); TierBands.clear(); + Magic.plugin = null; MockBukkit.unmock(); + } + void run(String... args) { assertTrue(command.onCommand(sender, null, "magic", args)); } + void reject(int index, String value, String message) { + clearInvocations(sender); String[] args=valid.clone(); args[index]=value; run(args); + verify(sender).sendMessage(contains(message)); assertEquals(-1, target.getInventory().first(Material.STICK)); + } + @Test void validatesBeforeGivingAndPermissionDoesNotRequireAdmin() throws Exception { + var ctor=WeaponGiveCommand.class.getDeclaredConstructor(); ctor.setAccessible(true); ctor.newInstance(); + Cache.givePermission=""; run(valid); assertTrue(command.onTabComplete(sender,null,"magic",valid).isEmpty()); + Cache.givePermission="custom.staff"; when(sender.hasPermission("custom.staff")).thenReturn(false); run(valid); + when(sender.hasPermission("custom.staff")).thenReturn(true); + run("weapon"); run("weapon","bad","Mage","staff","fire","10","core"); + reject(2,"Offline","online"); reject(3,"bad","archetype"); reject(3,"wand","archetype"); + reject(4,"missing","element"); + try(var visibility=mockStatic(ElementVisibility.class)) { reject(4,"fire","element"); } + for(String amount:List.of("bad","NaN","Infinity","0","-1","1")) reject(5,amount,"Aura"); + reject(6,"unknown","part"); + PartRegistry.register(part("disabled","core",true,GearType.STAFF)); reject(6,"disabled","part"); + PartRegistry.register(part("wand","core",false,GearType.WAND)); reject(6,"wand","part"); + run("weapon","give","Mage","staff","fire","10","core","core"); + PartRegistry.register(part("extra","handle",false,GearType.STAFF)); reject(6,"extra","categories"); + for(int i=0;i<36;i++) target.getInventory().setItem(i,new ItemStack(Material.STONE)); + clearInvocations(sender); run(valid); verify(sender).sendMessage(contains("empty inventory")); target.getInventory().clear(); target.getInventory().setItem(0,new ItemStack(Material.AIR)); + ItemStack airSlot=mock(ItemStack.class); when(airSlot.getType()).thenReturn(Material.AIR); + try(var bukkit=mockStatic(org.bukkit.Bukkit.class); var builder=mockStatic(GearItemBuilder.class)) { + var airTarget=mock(org.bukkit.entity.Player.class); var inventory=mock(org.bukkit.inventory.PlayerInventory.class); + when(airTarget.getInventory()).thenReturn(inventory); + when(inventory.getStorageContents()).thenReturn(new ItemStack[]{airSlot}); + bukkit.when(()->org.bukkit.Bukkit.getPlayerExact("Mage")).thenReturn(airTarget); + run(valid); + } + try(var builder=mockStatic(GearItemBuilder.class)) { + run(valid); + builder.when(()->GearItemBuilder.prepare(eq(GearType.STAFF),anyCollection())).thenReturn(new ItemStack(Material.AIR)); run(valid); + builder.when(()->GearItemBuilder.prepare(eq(GearType.STAFF),anyCollection())).thenReturn(new ItemStack(Material.BARRIER)); run(valid); + ItemStack item=new ItemStack(Material.STICK); GearProvenance.stamp(item,GearType.STAFF,List.of(PartRegistry.get("core"))); + builder.when(()->GearItemBuilder.prepare(eq(GearType.STAFF),anyCollection())).thenReturn(item); + builder.when(()->GearItemBuilder.rewriteSockets(item,1)).thenAnswer(inv->{GearProvenance.lockSockets(item); return item;}); + run(valid); + ItemStack given=target.getInventory().getItem(target.getInventory().first(Material.STICK)); + assertEquals(10,WeaponRequirement.fromItem(given).aura().getFill("fire")); + assertEquals(Map.of(),GearProvenance.readInputs(given)); assertTrue(GearProvenance.socketsLocked(given)); + verify(sender).sendMessage(contains("Gave completed Staff")); + } + } + List tab(String... args) { return command.onTabComplete(sender,null,"magic",args); } + @Test void completionRespectsPermissionAndPrefixes() { + run(); verify(sender).sendMessage(contains("Usage: /magic weapon give")); + assertTrue(tab("weapon").isEmpty()); + assertEquals(List.of("weapon"),tab("we")); + assertEquals(List.of("give"),tab("weapon","g")); + assertTrue(tab("weapon","bad","").isEmpty()); + assertEquals(List.of("Mage"),tab("weapon","give","M")); + assertEquals(List.of("staff"),tab("weapon","give","Mage","s")); + assertEquals(List.of("fire"),tab("weapon","give","Mage","staff","f")); + assertTrue(tab("weapon","give","Mage","staff","fire","").isEmpty()); + assertEquals(List.of("core"),tab("weapon","give","Mage","staff","fire","10","c")); + when(sender.hasPermission("custom.staff")).thenReturn(false); + assertTrue(tab("weapon","give","").isEmpty()); assertTrue(tab("").isEmpty()); + } +}