diff --git a/README.md b/README.md index 621b2ea..f6bd1ab 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ GunsAndGadgets brings together weapon assembly and gun combat. Players build fir - **Modular assembly** — select a weapon type and combine barrels, loaders, chambers, actions, and stocks where the design requires them. - **Meaningful parts** — components contribute to accuracy, damage, range, capacity, reload speed, fire rate, spread, and piercing. -- **Ammunition compatibility** — weapons accept their supported calibres and consume ammunition as they are loaded and fired. +- **Ammunition compatibility** — weapons accept their supported calibres and consume ammunition as they are loaded and fired. Crouch and right-click to choose which carried calibre the next reload uses; the choice is saved on the gun. - **Reloading and handling** — timed reloads, progress feedback, character attribute effects, and loaded or unloaded appearances communicate weapon state. - **Projectile combat** — shot trajectories, impacts, sounds, and visual effects support firearm and launcher attacks. - **Weapon appearances** — skin support keeps custom gun designs connected to their firing and reload states. diff --git a/src/main/java/net/tfminecraft/gunsandgadgets/manager/GunManager.java b/src/main/java/net/tfminecraft/gunsandgadgets/manager/GunManager.java index bbcb0fe..5e95d45 100644 --- a/src/main/java/net/tfminecraft/gunsandgadgets/manager/GunManager.java +++ b/src/main/java/net/tfminecraft/gunsandgadgets/manager/GunManager.java @@ -25,6 +25,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.UUID; @@ -52,6 +53,9 @@ import org.bukkit.scheduler.BukkitRunnable; import io.lumine.mythic.lib.api.item.NBTItem; +import io.lumine.mythic.lib.api.player.MMOPlayerData; +import io.lumine.mythic.lib.message.actionbar.ActionBarPriority; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import net.tfminecraft.tlibs.TLibs; import net.tfminecraft.tlibs.objects.api.subapi.ItemSkinPreserver; import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; @@ -72,10 +76,16 @@ public class GunManager implements Listener { private final NamespacedKey reloadAmmoKey = new NamespacedKey(GunsAndGadgets.getInstance(), "reload_ammo"); private final NamespacedKey reloadAmountKey = new NamespacedKey(GunsAndGadgets.getInstance(), "reload_amount"); private final NamespacedKey loadedAmmoKey = new NamespacedKey(GunsAndGadgets.getInstance(), "ammo_loaded"); + // Ammunition the player picked for the next reload; absent means the first carried caliber. + private final NamespacedKey selectedAmmoKey = new NamespacedKey(GunsAndGadgets.getInstance(), "ammo_selected"); private final Map reloading = new HashMap<>(); // Preserve the exact inputs through config reloads, including their item metadata. private final Map> reloadRefunds = new HashMap<>(); + // One click can arrive as several interact events (each hand, or an entity then the air) + // spread over neighbouring ticks; clients allow a new click only every 4 ticks. + private static final int AMMO_SWITCH_BURST_TICKS = 2; + private final Map lastAmmoSwitchTick = new HashMap<>(); @EventHandler public void preventOldMuskets(UntargetedWeaponUseEvent e) { @@ -157,6 +167,15 @@ public void handleGunUse(Player player, Cancellable event) { if (reloading.getOrDefault(id, false)) { return; } + if (player.isSneaking()) { + int tick = Bukkit.getCurrentTick(); + Integer last = lastAmmoSwitchTick.get(id); + if (last == null || tick - last > AMMO_SWITCH_BURST_TICKS) { + lastAmmoSwitchTick.put(id, tick); + cycleAmmo(player, item); + } + return; + } reloading.put(id, true); SkinData skin = SkinLoader.get().get(skinId); @@ -227,10 +246,15 @@ public void handleGunUse(Player player, Cancellable event) { .getOrDefault(capacityKey, PersistentDataType.INTEGER, 1); // 🔫 Try to consume ammo before reload - Collection calibers = Caliber.get(item); // your helper from before - String taken = takeAmmo(player, capacity, calibers); + List calibers = Caliber.get(item); // your helper from before + Ammunition selected = getSelectedAmmo(meta, calibers); + String taken = takeAmmo(player, capacity, selected == null ? calibers : List.of(selected)); if (taken.equals("none")) { + if (selected != null) { + sendActionBar(player, "§cYou have no " + getAmmoName(selected) + + "§c left. Crouch and right-click to choose another."); + } reloading.remove(id); return; } @@ -239,6 +263,9 @@ public void handleGunUse(Player player, Cancellable event) { String[] split = taken.split("\\."); String ammoId = split[0]; int takenAmount = Integer.parseInt(split[1]); + if (calibers.size() > 1) { + sendActionBar(player, "§7Loading " + getAmmoName(AmmunitionLoader.getByString(ammoId))); + } // Save ammo info in PDC (for refund or finalize) meta.getPersistentDataContainer().set(reloadAmmoKey, PersistentDataType.STRING, ammoId); @@ -585,17 +612,8 @@ private String makeProgressBar(double progress, int bars, String filled, String private String takeAmmo(Player p, int amount, Collection calibers) { if (amount <= 0) return "none"; for (Ammunition ammo : calibers) { - int totalFound = 0; - java.util.List matchingStacks = new ArrayList<>(); - - // Count how many of this ammo we have - for (ItemStack item : p.getInventory().getContents()) { - if (item == null) continue; - if (TLibs.getItemAPI().getChecker().checkItemWithPath(item, ammo.getInput())) { - totalFound += item.getAmount(); - matchingStacks.add(item); - } - } + java.util.List matchingStacks = findAmmo(p, ammo); + int totalFound = countStacks(matchingStacks); if (totalFound > 0) { // Take min(capacity, found) @@ -609,6 +627,78 @@ private String takeAmmo(Player p, int amount, Collection calibers) { return "none"; } + /** Stacks of this ammunition in the player's inventory, in slot order. */ + private java.util.List findAmmo(Player p, Ammunition ammo) { + java.util.List matchingStacks = new ArrayList<>(); + for (ItemStack item : p.getInventory().getContents()) { + if (item == null) continue; + if (TLibs.getItemAPI().getChecker().checkItemWithPath(item, ammo.getInput())) { + matchingStacks.add(item); + } + } + return matchingStacks; + } + + private int countStacks(java.util.List stacks) { + int total = 0; + for (ItemStack item : stacks) total += item.getAmount(); + return total; + } + + /** The player's pick for the next reload, or null when unset or no longer a caliber of this gun. */ + private Ammunition getSelectedAmmo(ItemMeta meta, List calibers) { + String selectedId = meta.getPersistentDataContainer().get(selectedAmmoKey, PersistentDataType.STRING); + for (Ammunition ammo : calibers) { + if (ammo.getKey().equals(selectedId)) return ammo; + } + return null; + } + + /** + * Crouch + right-click: pick the next caliber the player carries for the next reload. + * Bullets already loaded stay loaded. + */ + private void cycleAmmo(Player player, ItemStack item) { + List calibers = Caliber.get(item); + List carried = new ArrayList<>(); + for (Ammunition ammo : calibers) { + if (countStacks(findAmmo(player, ammo)) > 0) carried.add(ammo); + } + if (carried.isEmpty()) { + sendActionBar(player, "§cYou carry no shot this weapon can fire."); + return; + } + + ItemMeta meta = item.getItemMeta(); + Ammunition current = getSelectedAmmo(meta, calibers); + // Without a pick, reloads use the first carried caliber, so step past that one. + int index = calibers.indexOf(current != null ? current : carried.get(0)); + Ammunition next; + do { + index = (index + 1) % calibers.size(); + next = calibers.get(index); + } while (!carried.contains(next)); + + meta.getPersistentDataContainer().set(selectedAmmoKey, PersistentDataType.STRING, next.getKey()); + item.setItemMeta(meta); + player.getInventory().setItemInMainHand(item); + sendActionBar(player, "§7Next load: " + getAmmoName(next) + + " §8(" + countStacks(findAmmo(player, next)) + " carried)"); + player.playSound(player.getLocation(), Sound.BLOCK_LEVER_CLICK, 1f, 1.5f); + } + + // Keep the existing legacy text representation of ammunition item names. + @SuppressWarnings("deprecation") + private String getAmmoName(Ammunition ammo) { + return StringFormatter.getName(TLibs.getItemAPI().getCreator().getItemFromPath(ammo.getInput())).trim(); + } + + private void sendActionBar(Player player, String legacyText) { + // Hold MMOCore's stat bar back for two seconds so the message can be read; a null text only reserves it. + if (!MMOPlayerData.get(player).getActionBar().show(ActionBarPriority.NORMAL, 40L, (String) null)) return; + player.sendActionBar(LegacyComponentSerializer.legacySection().deserialize(legacyText)); + } + /** * Remove a certain number of items of a specific type from a player's inventory. diff --git a/src/main/java/net/tfminecraft/gunsandgadgets/manager/inventory/InventoryManager.java b/src/main/java/net/tfminecraft/gunsandgadgets/manager/inventory/InventoryManager.java index 1f48147..cb59b41 100644 --- a/src/main/java/net/tfminecraft/gunsandgadgets/manager/inventory/InventoryManager.java +++ b/src/main/java/net/tfminecraft/gunsandgadgets/manager/inventory/InventoryManager.java @@ -454,6 +454,9 @@ public ItemStack createOutputItem(GunType type, Collection parts, boole lore.add("§7• " + ammoKey); } } + if (calibers.size() > 1) { + lore.add("§8Crouch and right-click to change shot"); + } // ✅ Save calibers in PDC NamespacedKey calibersKey = new NamespacedKey(GunsAndGadgets.getInstance(), "calibers"); @@ -506,6 +509,7 @@ private void copyRuntimeAmmoPdc(ItemStack from, ItemStack to) { NamespacedKey reloadAmmoKey = new NamespacedKey(GunsAndGadgets.getInstance(), "reload_ammo"); NamespacedKey reloadAmountKey = new NamespacedKey(GunsAndGadgets.getInstance(), "reload_amount"); NamespacedKey lastFireKey = new NamespacedKey(GunsAndGadgets.getInstance(), "last_fire"); + NamespacedKey selectedAmmoKey = new NamespacedKey(GunsAndGadgets.getInstance(), "ammo_selected"); if (fromPdc.has(bulletsKey, PersistentDataType.INTEGER)) { toPdc.set(bulletsKey, PersistentDataType.INTEGER, @@ -542,6 +546,13 @@ private void copyRuntimeAmmoPdc(ItemStack from, ItemStack to) { toPdc.remove(lastFireKey); } + String selectedAmmo = fromPdc.get(selectedAmmoKey, PersistentDataType.STRING); + if (selectedAmmo != null) { + toPdc.set(selectedAmmoKey, PersistentDataType.STRING, selectedAmmo); + } else { + toPdc.remove(selectedAmmoKey); + } + to.setItemMeta(toMeta); } @@ -593,23 +604,23 @@ private void storeSounds(GunType gunType, Collection parts, /** * Finds the calibers for the selected parts. - * If an override is present, uses the last override only. + * If an override is present, the last part with one supplies every caliber. */ private List resolveCalibers(Collection parts) { - List overrides = new ArrayList<>(); + List override = null; List normal = new ArrayList<>(); for (GunPart part : parts) { if (part.getCaliberOverrides() != null && !part.getCaliberOverrides().isEmpty()) { - overrides.addAll(part.getCaliberOverrides()); + override = part.getCaliberOverrides(); } else if (!part.getCalibers().isEmpty()) { normal.addAll(part.getCalibers()); } } - if (!overrides.isEmpty()) { - // ✅ Only the last override matters - return List.of(overrides.get(overrides.size() - 1)); + if (override != null) { + // ✅ Only the last override matters, with all of its calibers + return List.copyOf(override); } return normal; } diff --git a/src/test/java/net/tfminecraft/gunsandgadgets/manager/GunManagerTest.java b/src/test/java/net/tfminecraft/gunsandgadgets/manager/GunManagerTest.java index 4e701d8..75c6950 100644 --- a/src/test/java/net/tfminecraft/gunsandgadgets/manager/GunManagerTest.java +++ b/src/test/java/net/tfminecraft/gunsandgadgets/manager/GunManagerTest.java @@ -32,6 +32,8 @@ class GunManagerTest { MockedStatic libs; MockedStatic attributes; MockedStatic sounds; + MockedStatic mmoPlayers; + io.lumine.mythic.lib.api.player.MMOPlayerData mmoPlayer; ItemStack gun; SkinData skin; io.lumine.mythic.lib.MythicLib oldMythic; @@ -72,6 +74,12 @@ void setup() { .when(() -> AttributeReader.getReloadReductionMultFromAttributes(player)) .thenReturn(1.0); sounds = mockStatic(SoundPlayer.class); + mmoPlayer = mock(io.lumine.mythic.lib.api.player.MMOPlayerData.class, RETURNS_DEEP_STUBS); + when(mmoPlayer.getActionBar().show(anyInt(), anyLong(), nullable(String.class))).thenReturn(true); + mmoPlayers = mockStatic(io.lumine.mythic.lib.api.player.MMOPlayerData.class); + mmoPlayers + .when(() -> io.lumine.mythic.lib.api.player.MMOPlayerData.get(any(OfflinePlayer.class))) + .thenReturn(mmoPlayer); gun = new ItemStack(Material.STICK); set("skin_id", "test"); set("gun_id", new String("same-id")); @@ -87,6 +95,7 @@ void setup() { void cleanup() { if (server != null && plugin != null) server.getScheduler().cancelTasks(plugin); if (sounds != null) sounds.close(); + if (mmoPlayers != null) mmoPlayers.close(); if (attributes != null) attributes.close(); if (libs != null) libs.close(); if (pluginStatic != null) pluginStatic.close(); @@ -950,4 +959,158 @@ void slotSelectionKeepsHealthyGunAndInventoryProtectionIgnoresMetadataFreeItems( manager.onInventoryClick(e); verify(e, never()).setCancelled(true); } + + // Three calibers told apart by item type: iron nuggets, gold nuggets (steel), copper (bronze). + void threeCalibers() { + for (String[] entry : + new String[][] {{"iron", "iron_nugget"}, {"steel", "gold_nugget"}, {"bronze", "copper_ingot"}}) { + var config = new YamlConfiguration(); + config.set("input", "v." + entry[1]); + AmmunitionLoader.get().put(entry[0], new Ammunition(entry[0], config)); + } + var checker = TLibs.getItemAPI().getChecker(); + var creator = TLibs.getItemAPI().getCreator(); + doAnswer( + i -> + ((ItemStack) i.getArgument(0)) + .getType() + .getKey() + .getKey() + .equals(((String) i.getArgument(1)).substring(2))) + .when(checker) + .checkItemWithPath(any(), anyString()); + doAnswer(i -> new ItemStack(Material.matchMaterial(((String) i.getArgument(0)).substring(2)))) + .when(creator) + .getItemFromPath(anyString()); + set("calibers", "iron;steel;bronze"); + player.getInventory().setItemInMainHand(gun); + } + + String actionBar() { + var bar = player.nextActionBar(); + return bar == null + ? null + : net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText() + .serialize(bar); + } + + String selected() { + return player + .getInventory() + .getItemInMainHand() + .getItemMeta() + .getPersistentDataContainer() + .get(key("ammo_selected"), PersistentDataType.STRING); + } + + @Test + void crouchClickCyclesCarriedCalibersWithoutFiringOrReloading() { + threeCalibers(); + setInt("bullets_loaded", 1); + set("ammo_loaded", "iron"); + player.getInventory().setItemInMainHand(gun); + player.getInventory().setItem(2, new ItemStack(Material.COPPER_INGOT, 4)); + player.setSneaking(true); + try (var shooter = mockStatic(ProjectileShooter.class)) { + verify(use()).setCancelled(true); + // Iron loads by default, steel is not carried, so the first pick is bronze. + assertEquals("bronze", selected()); + assertTrue(actionBar().matches("Next load: .*Copper.* \\(4 carried\\)")); + // Further events of the same click, even a tick or two later, must not switch again. + use(); + server.getScheduler().performTicks(2); + use(); + assertEquals("bronze", selected()); + assertNull(player.nextActionBar()); + server.getScheduler().performTicks(1); + use(); + assertEquals("iron", selected()); + assertTrue(actionBar().contains("(5 carried)")); + shooter.verifyNoInteractions(); + } + assertEquals(1, readInt(player.getInventory().getItemInMainHand(), "bullets_loaded")); + assertEquals(5, player.getInventory().getItem(1).getAmount()); + assertEquals(4, player.getInventory().getItem(2).getAmount()); + } + + @Test + void crouchClickWithoutUsableAmmoSaysSo() { + threeCalibers(); + player.getInventory().setItem(1, null); + player.setSneaking(true); + use(); + assertEquals("You carry no shot this weapon can fire.", actionBar()); + assertNull(selected()); + } + + @Test + void reloadUsesSelectedCaliber() { + threeCalibers(); + set("ammo_selected", "steel"); + player.getInventory().setItemInMainHand(gun); + player.getInventory().setItem(2, new ItemStack(Material.GOLD_NUGGET, 2)); + use(); + assertTrue(actionBar().matches("Loading .*Gold.*")); + server.getScheduler().performTicks(12); + ItemStack held = player.getInventory().getItemInMainHand(); + assertEquals(2, readInt(held, "bullets_loaded")); + assertEquals( + "steel", + held.getItemMeta().getPersistentDataContainer().get(key("ammo_loaded"), PersistentDataType.STRING)); + assertEquals(5, player.getInventory().getItem(1).getAmount()); + assertEquals("steel", selected()); + } + + @Test + void reloadRefusesOtherAmmoWhenSelectedCaliberRunsOut() { + threeCalibers(); + set("ammo_selected", "steel"); + player.getInventory().setItemInMainHand(gun); + use(); + assertTrue( + actionBar().matches("You have no .*Gold.* left\\. Crouch and right-click to choose another\\.")); + assertEquals(5, player.getInventory().getItem(1).getAmount()); + assertEquals(0, readInt(player.getInventory().getItemInMainHand(), "reload_amount")); + // The refusal must not leave the player stuck in a reload. + player.getInventory().setItem(2, new ItemStack(Material.GOLD_NUGGET, 1)); + use(); + assertEquals(1, readInt(player.getInventory().getItemInMainHand(), "reload_amount")); + } + + @Test + void selectionThatIsNoLongerACaliberFallsBackToFirstCarried() { + threeCalibers(); + set("ammo_selected", "mythril"); + player.getInventory().setItemInMainHand(gun); + use(); + assertEquals(3, readInt(player.getInventory().getItemInMainHand(), "reload_amount")); + assertEquals( + "iron", + player + .getInventory() + .getItemInMainHand() + .getItemMeta() + .getPersistentDataContainer() + .get(key("reload_ammo"), PersistentDataType.STRING)); + } + + @Test + void ammoMessagesHoldMmoCoreBarAndYieldToBusierOnes() { + threeCalibers(); + player.setSneaking(true); + use(); + verify(mmoPlayer.getActionBar()).show(30, 40L, (String) null); + assertNotNull(player.nextActionBar()); + when(mmoPlayer.getActionBar().show(anyInt(), anyLong(), nullable(String.class))).thenReturn(false); + server.getScheduler().performTicks(3); + use(); + assertEquals("iron", selected()); + assertNull(player.nextActionBar()); + } + + @Test + void singleCaliberReloadShowsNoAmmoName() { + use(); + assertNull(player.nextActionBar()); + } } diff --git a/src/test/java/net/tfminecraft/gunsandgadgets/manager/inventory/InventoryManagerTest.java b/src/test/java/net/tfminecraft/gunsandgadgets/manager/inventory/InventoryManagerTest.java index c75ad40..ffda9af 100644 --- a/src/test/java/net/tfminecraft/gunsandgadgets/manager/inventory/InventoryManagerTest.java +++ b/src/test/java/net/tfminecraft/gunsandgadgets/manager/inventory/InventoryManagerTest.java @@ -228,6 +228,7 @@ void rebuildingPreservesIdentitySaltAndAmmoRuntime() { p.set(key("reload_ammo"), PersistentDataType.STRING, "shot"); p.set(key("reload_amount"), PersistentDataType.INTEGER, 2); p.set(key("last_fire"), PersistentDataType.LONG, 123L); + p.set(key("ammo_selected"), PersistentDataType.STRING, "steel"); previous.setItemMeta(m); var rebuilt = manager.rebuildFromParts(previous, GunType.RIFLE, List.of(action)); assertEquals(string(previous, "gun_id"), string(rebuilt, "gun_id")); @@ -243,6 +244,7 @@ void rebuildingPreservesIdentitySaltAndAmmoRuntime() { .get(key(key), PersistentDataType.INTEGER)); assertEquals("shot", string(rebuilt, "ammo_loaded")); assertEquals("shot", string(rebuilt, "reload_ammo")); + assertEquals("steel", string(rebuilt, "ammo_selected")); assertEquals( 123L, rebuilt @@ -258,11 +260,12 @@ void rebuildingWithoutRuntimeGeneratesIdentityAndLeavesAmmoAbsent() { assertNotNull(string(rebuilt, "gun_id")); assertNull(string(rebuilt, "ammo_loaded")); assertNull(string(rebuilt, "reload_ammo")); + assertNull(string(rebuilt, "ammo_selected")); assertFalse(rebuilt.getItemMeta().getPersistentDataContainer().has(key("bullets_loaded"))); } @Test - void calibersCombineUnlessLastOverridePresent() { + void calibersCombineUnlessLastPartOverridesThemAll() { var barrel = part("long", "barrel", 2); when(action.getCalibers()).thenReturn(List.of("known", "unknown")); var config = new YamlConfiguration(); @@ -270,9 +273,16 @@ void calibersCombineUnlessLastOverridePresent() { .put( "known", new net.tfminecraft.gunsandgadgets.guns.ammunition.Ammunition("known", config)); - assertEquals("known;unknown", string(output(action), "calibers")); + var multi = output(action); + assertEquals("known;unknown", string(multi, "calibers")); + assertTrue(multi.getItemMeta().getLore().contains("§8Crouch and right-click to change shot")); when(barrel.getCaliberOverrides()).thenReturn(List.of("override1", "override2")); - assertEquals("override2", string(output(action, barrel), "calibers")); + assertEquals("override1;override2", string(output(action, barrel), "calibers")); + when(action.getCaliberOverrides()).thenReturn(List.of("first")); + assertEquals("override1;override2", string(output(action, barrel), "calibers")); + var single = output(barrel, action); + assertEquals("first", string(single, "calibers")); + assertFalse(single.getItemMeta().getLore().stream().anyMatch(l -> l.contains("Crouch"))); when(action.getCalibers()).thenReturn(List.of()); when(action.getCaliberOverrides()).thenReturn(null); assertNull(string(output(action), "calibers"));