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; + } +}