From fc10ff6ae61969df138d35f29a659ec8b7a7067b Mon Sep 17 00:00:00 2001 From: Ryan Barlow Date: Sun, 27 Sep 2026 23:06:33 +0000 Subject: [PATCH] feat: add /deco give to credit a bank or pouch Console and operators can now pay a player with /deco give [bank|pouch]. The bank is the default. Payments go through OfflineModifier, so online players are credited in their live session and offline players in their saved account file. Amounts must be positive whole cents. Each payment is logged, and an online recipient is told about it. The new denareconomy.give permission is op by default. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 1 + .../denareconomy/managers/CommandManager.java | 101 ++++++++++- src/main/resources/messages.yml | 7 + src/main/resources/plugin.yml | 3 + .../managers/CommandManagerTest.java | 159 ++++++++++++++++++ .../managers/GiveCommandOfflineTest.java | 97 +++++++++++ 6 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 src/test/java/net/tfminecraft/denareconomy/managers/GiveCommandOfflineTest.java diff --git a/README.md b/README.md index c736c60..c878fa3 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ The plugin also supplies the shared economy used by other TF-Minecraft systems, - **Multiple denominations** — represent values with gold and silver coins, handfuls, stacks, and pouches of coins. - **Coins in the world** — drop money from your pouch as physical coins for others to collect. - **Balance visibility** — inspect personal balances and view a leaderboard of the wealthiest accounts. +- **Admin credits** — console or operators can add denars to a player's bank or pouch with `/deco give [bank|pouch]`, whether the player is online or offline (permission `denareconomy.give`, op by default). - **Shared earnings** — give other plugins a common way to award money and respond to income, banking, and material deposits. ## Documentation diff --git a/src/main/java/net/tfminecraft/denareconomy/managers/CommandManager.java b/src/main/java/net/tfminecraft/denareconomy/managers/CommandManager.java index 7e1c5a1..79e32f9 100644 --- a/src/main/java/net/tfminecraft/denareconomy/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/denareconomy/managers/CommandManager.java @@ -3,6 +3,8 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; +import java.util.UUID; +import java.util.logging.Level; import java.util.stream.Collectors; import org.bukkit.Bukkit; @@ -16,10 +18,12 @@ import net.tfminecraft.tlibs.utils.ParseUtils; import net.tfminecraft.denareconomy.DenarEconomy; +import net.tfminecraft.denareconomy.accounts.OfflineModifier; import net.tfminecraft.denareconomy.data.Account; import net.tfminecraft.denareconomy.data.PlayerData; import net.tfminecraft.denareconomy.database.BalTopEntry; import net.tfminecraft.denareconomy.database.Database; +import net.tfminecraft.denareconomy.enums.Accounts; import net.tfminecraft.denareconomy.item.Coin; import net.tfminecraft.denareconomy.loaders.CoinLoader; import net.tfminecraft.denareconomy.loaders.MessageLoader; @@ -40,6 +44,12 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String return true; } + if (cmd.getName().equalsIgnoreCase(cmd1) && args.length > 0 + && args[0].equalsIgnoreCase("give")) { + handleGive(sender, args); + return true; + } + if (!(sender instanceof Player)) { MessageLoader.send(sender, "general.players-only"); return false; @@ -96,11 +106,84 @@ private void handleReload(CommandSender sender) { } private static boolean canReload(CommandSender sender) { + return isAdmin(sender, "denareconomy.reload"); + } + + private static boolean canGive(CommandSender sender) { + return isAdmin(sender, "denareconomy.give"); + } + + /** Console always passes; a player needs operator status or the permission. */ + private static boolean isAdmin(CommandSender sender, String permission) { if (!(sender instanceof Player)) { return true; } Player player = (Player) sender; - return player.isOp() || player.hasPermission("denareconomy.reload"); + return player.isOp() || player.hasPermission(permission); + } + + /** + * Credit an account from console or by an operator: /deco give [bank|pouch]. + * The bank is the default. Offline players are paid through their saved account. + */ + private void handleGive(CommandSender sender, String[] args) { + if (!canGive(sender)) { + MessageLoader.send(sender, "errors.no-permission"); + return; + } + if (args.length < 3 || args.length > 4) { + MessageLoader.send(sender, "errors.give-usage"); + return; + } + + BigDecimal amount = parseBankAmount(args[2]); + if (amount == null) { + MessageLoader.send(sender, "errors.invalid-amount"); + return; + } + + Accounts account = args.length == 4 ? parseAccount(args[3]) : Accounts.BANK; + if (account == null) { + MessageLoader.send(sender, "errors.give-usage"); + return; + } + + String name = args[1]; + UUID id = OfflineModifier.playerId(name); + if (id == null) { + MessageLoader.send(sender, "errors.unknown-player", "player", name); + return; + } + + String accountName = account.name().toLowerCase(); + boolean paid; + try { + paid = OfflineModifier.apply(id, account, amount.doubleValue()); + } catch (RuntimeException failure) { + DenarEconomy.plugin.getLogger().log(Level.SEVERE, "Could not give " + amount + " to " + name + + " (" + id + ")", failure); + paid = false; + } + if (!paid) { + MessageLoader.send(sender, "errors.give-failed", "player", name); + return; + } + + DenarEconomy.plugin.getLogger().info(sender.getName() + " gave " + amount + " to " + name + + " (" + id + ") " + accountName); + MessageLoader.send(sender, "give.sent", "amount", amount, "player", name, "account", accountName); + Player target = Bukkit.getPlayer(id); + if (target != null) { + MessageLoader.send(target, "give.received", "amount", amount, "account", accountName); + } + } + + private static Accounts parseAccount(String token) { + return switch (token.toLowerCase()) { + case "bank" -> Accounts.BANK; + case "pouch" -> Accounts.POUCH; + default -> null; + }; } private void handleBalTop(Player p) { @@ -288,6 +371,15 @@ private void sendBankReport(Player p, String action, BigDecimal amount, PlayerDa p.playSound(p, Sound.BLOCK_NOTE_BLOCK_CHIME, 1f, 1f); } + private static List giveCompletions(int position) { + return switch (position) { + case 2 -> List.of(""); + case 3 -> List.of(""); + case 4 -> List.of("bank", "pouch"); + default -> List.of(); + }; + } + private void sendError(Player p) { MessageLoader.send(p, "general.unknown-subcommand"); } @@ -303,9 +395,16 @@ public List onTabComplete(CommandSender sender, Command cmd, String alia completions.add("deposit"); completions.add("withdraw"); completions.add("baltop"); + if (canGive(sender)) { + completions.add("give"); + } if (canReload(sender)) { completions.add("reload"); } + } else if (args.length > 1 && args[0].equalsIgnoreCase("give")) { + if (canGive(sender)) { + completions.addAll(giveCompletions(args.length)); + } } else if (args.length == 2) { if (args[0].equalsIgnoreCase("pay") || args[0].equalsIgnoreCase("toitem") || args[0].equalsIgnoreCase("deposit") || args[0].equalsIgnoreCase("withdraw")) { diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index cd9be8b..e03d485 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -20,6 +20,9 @@ errors: unknown-coin: "%prefix%&cUnknown coin." invalid-count: "%prefix%&cAmount must be a whole number of coins." coins-unavailable: "%prefix%&cCannot create coins for this amount. Your pouch has not been charged." + give-usage: "%prefix%&cUsage: /deco give [bank|pouch]" + unknown-player: "%prefix%&cNo denar account is known for %player%." + give-failed: "%prefix%&cCould not pay %player%. Their balance has not changed." balance: pouch: "#3ce8c9Current Pouch Balance: #b39122%amount%#dbaf1dd" @@ -43,6 +46,10 @@ bank: deposited: "Deposited" withdrew: "Withdrew" +give: + sent: "%prefix%&aGave #b39122%amount%#dbaf1dd &ato %player%'s %account%." + received: "#dbaf1d+#b39122%amount%#dbaf1dd #3ce8c9added to your %account%." + money: earned: "#dbaf1d+#b39122%amount%#dbaf1dd" tax: "#44524f(%tax% in tax)" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 16d97fc..cfbde3f 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -17,4 +17,7 @@ commands: permissions: denareconomy.reload: description: Reload DenarEconomy configs + default: op + denareconomy.give: + description: Credit a player's bank or pouch with /deco give default: op \ No newline at end of file diff --git a/src/test/java/net/tfminecraft/denareconomy/managers/CommandManagerTest.java b/src/test/java/net/tfminecraft/denareconomy/managers/CommandManagerTest.java index b91b733..811c0a2 100644 --- a/src/test/java/net/tfminecraft/denareconomy/managers/CommandManagerTest.java +++ b/src/test/java/net/tfminecraft/denareconomy/managers/CommandManagerTest.java @@ -7,10 +7,14 @@ import java.math.BigDecimal; import java.util.List; import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; import net.tfminecraft.denareconomy.DenarEconomy; +import net.tfminecraft.denareconomy.accounts.OfflineModifier; import net.tfminecraft.denareconomy.data.PlayerData; import net.tfminecraft.denareconomy.database.BalTopEntry; import net.tfminecraft.denareconomy.database.Database; +import net.tfminecraft.denareconomy.enums.Accounts; import net.tfminecraft.denareconomy.event.PlayerBankPulseEvent; import net.tfminecraft.denareconomy.item.Coin; import net.tfminecraft.denareconomy.loaders.CoinLoader; @@ -317,6 +321,161 @@ void completionsFilterCaseInsensitivelyAndRespectReloadPermission() { List.of(), commands.onTabComplete(player, command("other"), "other", new String[] {""})); } + @Test + void giveRejectsPlayersWithoutOperatorStatusOrPermission() { + try (MockedStatic offline = mockStatic(OfflineModifier.class)) { + assertTrue(run("give", "Alex", "5")); + messages.verify(() -> MessageLoader.send(player, "errors.no-permission")); + offline.verifyNoInteractions(); + } + } + + @Test + void giveValidatesUsageAmountAccountAndPlayer() { + CommandSender console = mock(CommandSender.class); + try (MockedStatic offline = mockStatic(OfflineModifier.class)) { + give(console); + give(console, "Alex"); + give(console, "Alex", "5", "bank", "extra"); + give(console, "Alex", "5", "wallet"); + messages.verify(() -> MessageLoader.send(console, "errors.give-usage"), times(4)); + give(console, "Alex", "0.005"); + give(console, "Alex", "-1"); + messages.verify(() -> MessageLoader.send(console, "errors.invalid-amount"), times(2)); + give(console, "Nobody", "5"); + messages.verify( + () -> MessageLoader.send(console, "errors.unknown-player", "player", "Nobody")); + offline.verify(() -> OfflineModifier.apply(any(UUID.class), any(), anyDouble()), never()); + } + } + + @Test + void giveCreditsTheBankByDefaultAndThePouchOnRequest() { + Logger logger = pluginLogger(); + CommandSender console = mock(CommandSender.class); + when(console.getName()).thenReturn("CONSOLE"); + UUID id = UUID.randomUUID(); + try (MockedStatic offline = mockStatic(OfflineModifier.class)) { + offline.when(() -> OfflineModifier.playerId("Alex")).thenReturn(id); + offline.when(() -> OfflineModifier.apply(eq(id), any(), anyDouble())).thenReturn(true); + give(console, "Alex", "12.5"); + give(console, "Alex", "0.02", "POUCH"); + give(console, "Alex", "3", "Bank"); + offline.verify(() -> OfflineModifier.apply(id, Accounts.BANK, 12.5)); + offline.verify(() -> OfflineModifier.apply(id, Accounts.POUCH, 0.02)); + offline.verify(() -> OfflineModifier.apply(id, Accounts.BANK, 3.0)); + messages.verify( + () -> + MessageLoader.send( + console, + "give.sent", + "amount", + new BigDecimal("12.50"), + "player", + "Alex", + "account", + "bank")); + messages.verify( + () -> + MessageLoader.send( + console, + "give.sent", + "amount", + new BigDecimal("0.02"), + "player", + "Alex", + "account", + "pouch")); + verify(logger).info("CONSOLE gave 12.50 to Alex (" + id + ") bank"); + verify(logger).info("CONSOLE gave 0.02 to Alex (" + id + ") pouch"); + // Alex is offline, so only the sender hears about it. + messages.verify(() -> MessageLoader.send(any(Player.class), eq("give.received"), any()), never()); + } + } + + @Test + void giveTellsAnOnlineRecipientAndAcceptsOperatorsAndPermissionHolders() { + pluginLogger(); + UUID id = UUID.randomUUID(); + Player target = mock(Player.class); + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(target); + when(player.getName()).thenReturn("Admin"); + try (MockedStatic offline = mockStatic(OfflineModifier.class)) { + offline.when(() -> OfflineModifier.playerId("Sam")).thenReturn(id); + offline.when(() -> OfflineModifier.apply(eq(id), any(), anyDouble())).thenReturn(true); + when(player.isOp()).thenReturn(true); + assertTrue(run("give", "Sam", "4")); + when(player.isOp()).thenReturn(false); + when(player.hasPermission("denareconomy.give")).thenReturn(true); + assertTrue(run("GIVE", "Sam", "1.25", "pouch")); + offline.verify(() -> OfflineModifier.apply(id, Accounts.BANK, 4.0)); + offline.verify(() -> OfflineModifier.apply(id, Accounts.POUCH, 1.25)); + messages.verify( + () -> + MessageLoader.send( + target, "give.received", "amount", new BigDecimal("4.00"), "account", "bank")); + messages.verify( + () -> + MessageLoader.send( + target, "give.received", "amount", new BigDecimal("1.25"), "account", "pouch")); + } + } + + @Test + void giveReportsRefusedAndFailedPaymentsWithoutClaimingSuccess() { + Logger logger = pluginLogger(); + CommandSender console = mock(CommandSender.class); + UUID refused = UUID.randomUUID(); + UUID broken = UUID.randomUUID(); + IllegalStateException failure = new IllegalStateException("disk full"); + try (MockedStatic offline = mockStatic(OfflineModifier.class)) { + offline.when(() -> OfflineModifier.playerId("Alex")).thenReturn(refused); + offline.when(() -> OfflineModifier.playerId("Sam")).thenReturn(broken); + offline.when(() -> OfflineModifier.apply(eq(refused), any(), anyDouble())).thenReturn(false); + offline + .when(() -> OfflineModifier.apply(eq(broken), any(), anyDouble())) + .thenThrow(failure); + give(console, "Alex", "5"); + give(console, "Sam", "5"); + messages.verify(() -> MessageLoader.send(console, "errors.give-failed", "player", "Alex")); + messages.verify(() -> MessageLoader.send(console, "errors.give-failed", "player", "Sam")); + verify(logger) + .log(Level.SEVERE, "Could not give 5.00 to Sam (" + broken + ")", failure); + verify(logger, never()).info(anyString()); + messages.verify( + () -> MessageLoader.send(eq(console), eq("give.sent"), any(Object[].class)), never()); + } + } + + @Test + void giveCompletionsNeedPermissionAndSuggestEachArgument() { + CommandSender console = mock(CommandSender.class); + assertTrue( + commands.onTabComplete(console, deco, "deco", new String[] {"g"}).contains("give")); + assertEquals(List.of(), tab("give", "")); + when(player.hasPermission("denareconomy.give")).thenReturn(true); + assertEquals(List.of("give"), tab("gi")); + assertEquals(List.of(""), tab("give", "")); + assertEquals(List.of(""), tab("give", "Alex", "")); + assertEquals(List.of("bank", "pouch"), tab("give", "Alex", "5", "")); + assertEquals(List.of("pouch"), tab("Give", "Alex", "5", "P")); + assertEquals(List.of(), tab("give", "Alex", "5", "bank", "")); + } + + private void give(CommandSender sender, String... args) { + String[] full = new String[args.length + 1]; + full[0] = "give"; + System.arraycopy(args, 0, full, 1, args.length); + assertTrue(commands.onCommand(sender, deco, "deco", full)); + } + + private Logger pluginLogger() { + DenarEconomy.plugin = mock(DenarEconomy.class); + Logger logger = mock(Logger.class); + when(DenarEconomy.plugin.getLogger()).thenReturn(logger); + return logger; + } + private boolean run(String... args) { return commands.onCommand(player, deco, "deco", args); } diff --git a/src/test/java/net/tfminecraft/denareconomy/managers/GiveCommandOfflineTest.java b/src/test/java/net/tfminecraft/denareconomy/managers/GiveCommandOfflineTest.java new file mode 100644 index 0000000..d69fa0b --- /dev/null +++ b/src/test/java/net/tfminecraft/denareconomy/managers/GiveCommandOfflineTest.java @@ -0,0 +1,97 @@ +package net.tfminecraft.denareconomy.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import java.util.logging.Logger; +import net.tfminecraft.denareconomy.DenarEconomy; +import net.tfminecraft.denareconomy.accounts.OfflineModifier; +import net.tfminecraft.denareconomy.data.PlayerData; +import net.tfminecraft.denareconomy.database.Database; +import net.tfminecraft.denareconomy.loaders.MessageLoader; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +/** /deco give against the real offline account path: the saved file changes, nothing is cached. */ +class GiveCommandOfflineTest { + private static final Path NAMES = Path.of("plugins/DenarEconomy/Data/player-names.json"); + private final UUID id = UUID.randomUUID(); + private Path account; + private Path namesBackup; + private Object previousBook; + private Object previousNames; + private DenarEconomy previousPlugin; + + @BeforeEach + void isolate() throws Exception { + previousBook = field("book").get(null); + previousNames = field("names").get(null); + field("book").set(null, null); + field("names").set(null, null); + previousPlugin = DenarEconomy.plugin; + if (Files.exists(NAMES)) { + namesBackup = Files.createTempDirectory("denar-give-names").resolve("file"); + Files.move(NAMES, namesBackup); + } + account = Path.of("plugins/DenarEconomy/PlayerData", id + ".json"); + } + + @AfterEach + void restore() throws Exception { + field("book").set(null, previousBook); + field("names").set(null, previousNames); + DenarEconomy.plugin = previousPlugin; + Files.deleteIfExists(account); + Files.deleteIfExists(NAMES); + if (namesBackup != null) { + Files.move(namesBackup, NAMES); + Files.delete(namesBackup.getParent()); + } + } + + @Test + void offlinePlayerIsPaidIntoTheirSavedAccount() { + PlayerData saved = new PlayerData(id); + saved.getPouch().setBal(1.1); + saved.getBank().setBal(1); + Database.savePlayerData(saved); + CommandSender console = mock(CommandSender.class); + Command deco = mock(Command.class); + when(deco.getName()).thenReturn("deco"); + DenarEconomy.plugin = mock(DenarEconomy.class); + when(DenarEconomy.plugin.getLogger()).thenReturn(mock(Logger.class)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic economy = mockStatic(DenarEconomy.class); + MockedStatic messages = mockStatic(MessageLoader.class)) { + // No server and no player manager: Alex is known only by name and saved file. + OfflineModifier.remember("Alex", id); + CommandManager commands = new CommandManager(); + assertTrue(commands.onCommand(console, deco, "deco", new String[] {"give", "alex", "2.5"})); + assertTrue( + commands.onCommand( + console, deco, "deco", new String[] {"give", "Alex", "0.04", "pouch"})); + messages.verify( + () -> MessageLoader.send(eq(console), eq("give.sent"), any(Object[].class)), times(2)); + } + + PlayerData reloaded = Database.loadPlayerData(id); + assertEquals(3.5, reloaded.getBank().getBal()); + assertEquals(1.14, reloaded.getPouch().getBal()); + } + + private static Field field(String name) throws Exception { + Field field = OfflineModifier.class.getDeclaredField(name); + field.setAccessible(true); + return field; + } +}