Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <player> <staff|wand|sword> <element> <aura> <part> [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)
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/net/tfminecraft/magic/Cache.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
15 changes: 14 additions & 1 deletion src/main/java/net/tfminecraft/magic/command/MagicCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 <player> <staff|wand|sword> <element> <aura> <part> [part...]");
} else if (hasRuneKeybind(sender)) {
sender.sendMessage(Messages.get("rune.usage"));
} else {
Expand All @@ -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;
Expand Down Expand Up @@ -658,13 +664,20 @@ public List<String> 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<String> options = new ArrayList<>();
if (give) {
options.add("weapon");
}
if (rune) {
options.add("rune");
}
Expand Down
110 changes: 110 additions & 0 deletions src/main/java/net/tfminecraft/magic/command/WeaponGiveCommand.java
Original file line number Diff line number Diff line change
@@ -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 <player> <staff|wand|sword> <element> <aura> <part> [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<String, PartDef> 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<String> 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<String> complete(CommandSender sender, String[] args) {
if (!allowed(sender) || args.length == 1) return List.of();
List<String> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/config.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/main/resources/plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ commands:
permission: magic.use
magic:
description: Magic plugin commands
usage: /magic <rune|reload|open|resonance|artifact|fillchest|shrine|refresh>
usage: /magic <rune|reload|open|resonance|artifact|fillchest|shrine|refresh|weapon>

permissions:
magic.use:
Expand Down Expand Up @@ -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
103 changes: 103 additions & 0 deletions src/test/java/net/tfminecraft/magic/WeaponGiveTest.java
Original file line number Diff line number Diff line change
@@ -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<String> 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());
}
}
Loading