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 @@ -17,6 +17,22 @@ GunsAndGadgets brings together weapon assembly and gun combat. Players build fir

The assembly interface lets players inspect available components and the resulting weapon before completing a craft. Those choices continue to matter in combat through the gun's statistics, ammunition, and handling, making gunsmithing part of how a character prepares their equipment.

## Staff gun commands

`/gg give <player> <rifle|pistol|shotgun|launcher> <part> [part...]`

Gives one completed, unloaded gun to an online player. Use part IDs from `parts.yml`
and supply exactly one enabled, compatible part for every category in `required-parts`.
The normal assembly builder applies stats, skins and provenance. Conflicting class
requirements and invalid designs are rejected. No crafting materials are charged or
recorded, and the recipient needs an empty inventory slot. Load ammunition normally.

`give-permission` in `config.yml` defaults to `gunsandgadgets.give` (operators).
Set it to your staff permission; a blank value disables giving. This permission is
independent of `gunsandgadgets.reload`, which still gates reload and refresh.
Reload configuration with `/gg reload`. Tab completion suggests recipients, types
and configured part IDs.

## Documentation

[Project documentation](https://github.com/TF-Minecraft/Docs/blob/main/projects/GunsAndGadgets/README.md)
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/net/tfminecraft/gunsandgadgets/cache/Cache.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import net.tfminecraft.gunsandgadgets.guns.GunType;

public class Cache {
public static String givePermission = "gunsandgadgets.give";

public static int outputSlot;
public static HashMap<GunType, String> outputItems = new HashMap<>();
public static String station;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public void loadConfig(File configFile) {
} catch (IOException | InvalidConfigurationException e) {
e.printStackTrace();
}
Cache.givePermission = config.getString("give-permission", "gunsandgadgets.give").trim();
Cache.outputSlot = config.getInt("output-slot", 15);
ConfigurationSection section = config.getConfigurationSection("outputs");
if (section != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ public final class GgCommand implements CommandExecutor, TabCompleter {
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (args.length == 0) {
sender.sendMessage("§eUsage: /gg reload | /gg refresh");
sender.sendMessage("§eUsage: /gg reload | /gg refresh | /gg give <player> <type> <part> [part...]");
return true;
}
if ("give".equalsIgnoreCase(args[0])) {
return GunGiveCommand.execute(sender, args);
}
String sub = args[0].toLowerCase();
if (sub.equals("reload")) {
if (!sender.hasPermission("gunsandgadgets.reload")) {
Expand Down Expand Up @@ -62,7 +65,7 @@ public boolean onCommand(CommandSender sender, Command command, String label, St
player.sendMessage("§aGun stats refreshed.");
return true;
}
sender.sendMessage("§eUsage: /gg reload | /gg refresh");
sender.sendMessage("§eUsage: /gg reload | /gg refresh | /gg give <player> <type> <part> [part...]");
return true;
}

Expand All @@ -73,13 +76,19 @@ public List<String> onTabComplete(
String alias,
String[] args
) {
if (args.length == 1 && sender.hasPermission("gunsandgadgets.reload")) {
if (args.length > 0 && "give".equalsIgnoreCase(args[0])) {
return GunGiveCommand.complete(sender, args);
}
if (args.length == 1) {
String prefix = args[0].toLowerCase();
List<String> out = new ArrayList<>();
if ("reload".startsWith(prefix)) {
if (GunGiveCommand.allowed(sender) && "give".startsWith(prefix)) {
out.add("give");
}
if (sender.hasPermission("gunsandgadgets.reload") && "reload".startsWith(prefix)) {
out.add("reload");
}
if ("refresh".startsWith(prefix)) {
if (sender.hasPermission("gunsandgadgets.reload") && "refresh".startsWith(prefix)) {
out.add("refresh");
}
return out;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package net.tfminecraft.gunsandgadgets.manager;

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.gunsandgadgets.cache.Cache;
import net.tfminecraft.gunsandgadgets.guns.GunType;
import net.tfminecraft.gunsandgadgets.guns.data.GunCraftInputs;
import net.tfminecraft.gunsandgadgets.guns.parts.GunPart;
import net.tfminecraft.gunsandgadgets.loader.PartLoader;
import net.tfminecraft.gunsandgadgets.manager.inventory.InventoryManager;

/** Staff spawning retains normal assembly validation, skins and provenance. */
public final class GunGiveCommand {
private GunGiveCommand() {}

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 guns.");
return true;
}
if (args.length < 4) {
sender.sendMessage("§eUsage: /gg give <player> <rifle|pistol|shotgun|launcher> <part> [part...]");
return true;
}
Player target = Bukkit.getPlayerExact(args[1]);
if (target == null) {
sender.sendMessage("§cPlayer must be online.");
return true;
}
GunType type;
try {
type = GunType.valueOf(args[2].toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException ex) {
sender.sendMessage("§cUnknown gun type.");
return true;
}
Map<String, GunPart> parts = new LinkedHashMap<>();
for (int i = 3; i < args.length; i++) {
GunPart part = PartLoader.getByString(args[i]);
if (part == null || part.isDisabled() || !part.getGunTypes().contains(type)) {
sender.sendMessage("§cUnknown, disabled or incompatible part: " + args[i]);
return true;
}
if (parts.putIfAbsent(part.getPartType().getId().toLowerCase(Locale.ROOT), part) != null) {
sender.sendMessage("§cOnly one part per category is allowed.");
return true;
}
}
List<String> required = Cache.requiredParts.getOrDefault(type, List.of());
Set<String> categories = new HashSet<>();
required.forEach(s -> categories.add(s.toLowerCase(Locale.ROOT)));
if (!parts.keySet().equals(categories)) {
sender.sendMessage("§cSupply exactly these part categories: " + String.join(", ", required));
return true;
}
InventoryManager builder = new InventoryManager();
if (builder.hasClassConflict(target, parts.values())) {
sender.sendMessage("§cThese parts have conflicting class requirements.");
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 = builder.createOutputItem(type, parts.values(), false);
if (item == null || item.getType().isAir() || item.getType() == Material.BARRIER) {
sender.sendMessage("§cGun could not be built; check its template, parts and skin.");
return true;
}
GunCraftInputs.applyTo(item, Map.of());
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)) return List.of();
List<String> options = new ArrayList<>();
if (args.length == 2) Bukkit.getOnlinePlayers().forEach(p -> options.add(p.getName()));
else if (args.length == 3) Arrays.stream(GunType.values()).forEach(t -> options.add(t.name().toLowerCase(Locale.ROOT)));
else if (args.length >= 4) options.addAll(PartLoader.get().keySet());
String prefix = args[args.length - 1].toLowerCase(Locale.ROOT);
return options.stream().filter(s -> s.toLowerCase(Locale.ROOT).startsWith(prefix)).sorted().toList();
}
}
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: gunsandgadgets.give

output-slot: 16

outputs:
Expand Down
7 changes: 4 additions & 3 deletions src/main/resources/plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ depend: [TLibs, MythicLib, MMOItems, MMOCore]
commands:
gg:
description: GunsAndGadgets admin commands
usage: /<command> reload|refresh
permission: gunsandgadgets.reload
permission-message: You do not have permission to use GunsAndGadgets admin commands.
usage: /<command> reload|refresh|give

permissions:
gunsandgadgets.reload:
description: Reload GunsAndGadgets configs (skins, parts, ammunition, config)
default: op
gunsandgadgets.give:
description: Give completed weapons (default give-permission)
default: op
81 changes: 81 additions & 0 deletions src/test/java/net/tfminecraft/gunsandgadgets/GunGiveTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package net.tfminecraft.gunsandgadgets;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.*;
import net.tfminecraft.gunsandgadgets.cache.Cache;
import net.tfminecraft.gunsandgadgets.guns.GunType;
import net.tfminecraft.gunsandgadgets.guns.parts.*;
import net.tfminecraft.gunsandgadgets.guns.data.GunCraftInputs;
import net.tfminecraft.gunsandgadgets.loader.PartLoader;
import net.tfminecraft.gunsandgadgets.manager.*;
import net.tfminecraft.gunsandgadgets.manager.inventory.InventoryManager;
import org.bukkit.Material;
import org.bukkit.command.CommandSender;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.*;
import org.mockbukkit.mockbukkit.entity.PlayerMock;

class GunGiveTest extends GunsTestSupport {
CommandSender sender;
PlayerMock target;
GgCommand command=new GgCommand();
String[] valid={"give","Gunner","rifle","barrel"};
@BeforeEach void setupGive() {
sender=mock(CommandSender.class); target=server.addPlayer("Gunner");
Cache.givePermission="custom.staff"; when(sender.hasPermission("custom.staff")).thenReturn(true);
Cache.requiredParts.put(GunType.RIFLE,List.of("barrel"));
PartLoader.get().put("barrel",part("barrel",false,GunType.RIFLE));
}
@AfterEach void resetPermission() { Cache.givePermission="gunsandgadgets.give"; }
GunPart part(String category,boolean disabled,GunType type) {
GunPart p=mock(GunPart.class); PartData data=mock(PartData.class);
when(data.getId()).thenReturn(category); when(p.getPartType()).thenReturn(data);
when(p.getGunTypes()).thenReturn(Set.of(type)); when(p.isDisabled()).thenReturn(disabled); return p;
}
void run(String... args) { assertTrue(command.onCommand(sender,null,"gg",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 validatesAndGivesWithoutReloadPermission() throws Exception {
var ctor=GunGiveCommand.class.getDeclaredConstructor();ctor.setAccessible(true);ctor.newInstance();
Cache.givePermission="";run(valid);Cache.givePermission="custom.staff";
when(sender.hasPermission("custom.staff")).thenReturn(false);run(valid);
when(sender.hasPermission("custom.staff")).thenReturn(true);run("give");
reject(1,"Offline","online");reject(2,"unknown","type");reject(3,"unknown","part");
PartLoader.get().put("disabled",part("barrel",true,GunType.RIFLE));reject(3,"disabled","part");
PartLoader.get().put("pistol",part("barrel",false,GunType.PISTOL));reject(3,"pistol","part");
run("give","Gunner","rifle","barrel","barrel");
PartLoader.get().put("stock",part("stock",false,GunType.RIFLE));reject(3,"stock","categories");
try(var builders=mockConstruction(InventoryManager.class,(b,c)->when(b.hasClassConflict(any(),any())).thenReturn(true))) {
run(valid);verify(sender).sendMessage(contains("conflicting"));
}
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));
for(Material material:List.of(Material.AIR,Material.BARRIER,Material.STICK)) {
try(var builders=mockConstruction(InventoryManager.class,(b,c)->when(b.createOutputItem(any(),any(),eq(false))).thenReturn(new ItemStack(material)))) { run(valid); }
}
try(var builders=mockConstruction(InventoryManager.class)) {run(valid);}
ItemStack airSlot=mock(ItemStack.class); when(airSlot.getType()).thenReturn(Material.AIR);
try(var bukkit=mockStatic(org.bukkit.Bukkit.class); var builders=mockConstruction(InventoryManager.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("Gunner")).thenReturn(airTarget);
run(valid);
}
assertEquals(Map.of(),GunCraftInputs.readFrom(target.getInventory().getItem(target.getInventory().first(Material.STICK))));
verify(sender).sendMessage(contains("Gave completed Rifle"));
}
List<String> tab(String... args) {return command.onTabComplete(sender,null,"gg",args);}
@Test void completionUsesSeparatePermission() {
assertTrue(tab().isEmpty()); assertTrue(tab("z").isEmpty());
assertEquals(List.of("give"),tab("g"));assertEquals(List.of("Gunner"),tab("give","G"));
assertEquals(List.of("rifle"),tab("give","Gunner","r"));
assertEquals(List.of("barrel"),tab("give","Gunner","rifle","b"));
assertTrue(tab("give").isEmpty());
Cache.givePermission="";assertTrue(tab("give","").isEmpty());Cache.givePermission="custom.staff";
when(sender.hasPermission("custom.staff")).thenReturn(false);assertTrue(tab("give","").isEmpty());assertTrue(tab("g").isEmpty());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ void reloadEnforcesPermissionAndUnknownInputShowsUsage() {
CommandSender sender = mock(CommandSender.class);
execute(sender);
execute(sender, "unknown");
verify(sender, times(2)).sendMessage("§eUsage: /gg reload | /gg refresh");
verify(sender, times(2)).sendMessage("§eUsage: /gg reload | /gg refresh | /gg give <player> <type> <part> [part...]");
execute(sender, "reload");
verify(plugin, never()).reload();
verify(sender).sendMessage(contains("do not have permission to reload"));
Expand Down