From f8675db0671d275ee4d3a4a78c0799617ce638bb Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:48:33 +0200 Subject: [PATCH 1/3] feat: overhaul poker tournaments and fix large-coin blackjack payouts --- README.md | 24 +- .../java/net/tfminecraft/games/Messages.java | 10 +- .../games/command/CommandManager.java | 19 +- .../games/command/PokerCommands.java | 123 +++++++++ .../net/tfminecraft/games/game/HandTalk.java | 11 + .../net/tfminecraft/games/game/PokerGame.java | 248 +++++++++++++++--- .../games/game/PokerTournament.java | 82 ++++++ .../net/tfminecraft/games/table/Table.java | 7 +- .../tfminecraft/games/table/TableManager.java | 15 +- .../games/wager/BucketAccount.java | 72 ++++- .../tfminecraft/games/wager/ChipItems.java | 32 +++ .../tfminecraft/games/wager/WagerEngine.java | 5 + src/main/resources/help.yml | 13 +- src/main/resources/messages.yml | 23 +- .../net/tfminecraft/games/MessagesTest.java | 10 + .../tfminecraft/games/game/HandTalkTest.java | 22 ++ .../tfminecraft/games/game/PokerGameTest.java | 13 +- .../games/game/PokerTournamentTest.java | 54 ++++ .../table/TableManagerBlackjackRoundTest.java | 41 +++ .../table/TableManagerPokerOverhaulTest.java | 137 ++++++++++ .../games/wager/WagerEngineTest.java | 26 ++ 21 files changed, 933 insertions(+), 54 deletions(-) create mode 100644 src/main/java/net/tfminecraft/games/command/PokerCommands.java create mode 100644 src/main/java/net/tfminecraft/games/game/PokerTournament.java create mode 100644 src/test/java/net/tfminecraft/games/game/PokerTournamentTest.java create mode 100644 src/test/java/net/tfminecraft/games/table/TableManagerPokerOverhaulTest.java diff --git a/README.md b/README.md index 601c6e9..0449f3b 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,28 @@ It supports both structured games with managed rounds and a free-play table for - **Guild-owned tables** — connect house games to a guild's funds and let its members take over dealing between rounds. - **Help at the table** — in-game books explain each game's rules and available actions. +## Poker tournaments + +At an empty poker table, the host or staff can use +`/games poker configure `. +A buy-in of `0` selects cash play. A blind interval of `0` keeps blinds fixed; +otherwise they double at each interval and are collected at the next hand. +Set the starting blinds through the table options menu. + +Players use `/games poker buyin` before the first hand. On their turn, +`/games poker bet ` puts extra chips into the pot, followed by a +`raise` or `check`. `call` collects the chips needed to match automatically. +`/games bet allin` (or `allin` / `all in` in chat) stakes the entire remaining stack. +Cash tables take physical stakes only on the current player's turn. + +Busted players can `/games poker rebuy` between hands within the configured limit. +The host can `/games poker kick ` between hands and, when only one positive +stack remains, `/games poker finish` to pay the Denar prize. Leaving or removal +after tournament play starts forfeits the entry. Before play starts it is refunded. +Tournament chips never enter player inventories or Denar payouts. Shutdown or +reload ends tournaments and returns Denar stakes through the table's refund path; +settings persist, chip stacks do not. + ## Documentation [Project documentation](https://github.com/TF-Minecraft/Docs/blob/main/projects/Games/README.md) @@ -35,7 +57,7 @@ inspect uncovered behaviour. The build workflow uploads the coverage report alongside test results. Coverage data is replaced on each test run; use the full suite when assessing repository-wide coverage. -The suite covers every production line and branch. Tests should protect +The suite covers game rounds, money conservation, table interactions, and tournament settings. Tests should protect supported behaviour, not create impossible internal states merely to execute a branch. Where a branch cannot be reached through any real caller, remove it rather than force it. diff --git a/src/main/java/net/tfminecraft/games/Messages.java b/src/main/java/net/tfminecraft/games/Messages.java index 776b7e0..8914387 100644 --- a/src/main/java/net/tfminecraft/games/Messages.java +++ b/src/main/java/net/tfminecraft/games/Messages.java @@ -2,6 +2,9 @@ import java.io.File; import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.FileConfiguration; @@ -22,6 +25,10 @@ public static void load(File file) { FileConfiguration loaded = new YamlConfiguration(); try { loaded.load(file); + try (InputStream defaults = Messages.class.getResourceAsStream("/messages.yml")) { + if (defaults != null) loaded.setDefaults(YamlConfiguration.loadConfiguration( + new InputStreamReader(defaults, StandardCharsets.UTF_8))); + } config = loaded; } catch (IOException | InvalidConfigurationException ex) { Games.plugin.getLogger().severe("[Games] Failed to load messages.yml: " + ex.getMessage()); @@ -46,7 +53,8 @@ public static String getRaw(String path) { if (config == null) { return path; } - return config.getString(path, path); + String value = config.getString(path); + return value != null ? value : path; } private static String format(String raw) { diff --git a/src/main/java/net/tfminecraft/games/command/CommandManager.java b/src/main/java/net/tfminecraft/games/command/CommandManager.java index 3051560..f245b86 100644 --- a/src/main/java/net/tfminecraft/games/command/CommandManager.java +++ b/src/main/java/net/tfminecraft/games/command/CommandManager.java @@ -70,6 +70,17 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String } return handleHelp(sender, args); } + if (args[0].equalsIgnoreCase("poker")) { + if (!(sender instanceof Player player)) { + sender.sendMessage(Messages.get("place.players_only")); + return true; + } + if (!sender.hasPermission("games.bet")) { + sender.sendMessage(Messages.get("admin.no_permission")); + return true; + } + return PokerCommands.execute(player, args); + } if (args[0].equalsIgnoreCase("bet")) { if (!sender.hasPermission("games.bet")) { sender.sendMessage(Messages.get("admin.no_permission")); @@ -328,7 +339,7 @@ private boolean handleBet(CommandSender sender, String[] args) { String action = args[1].toLowerCase(Locale.ROOT); if (action.equals("hit") || action.equals("stand") || action.equals("double") || action.equals("split") || action.equals("check") || action.equals("call") - || action.equals("fold") || action.equals("raise")) { + || action.equals("fold") || action.equals("raise") || action.equals("allin")) { return handleBetPlay(player, table, action); } if (!player.getUniqueId().equals(table.dealerId())) { @@ -528,6 +539,7 @@ public List onTabComplete(CommandSender sender, Command cmd, String alia } if (bet) { first.add("bet"); + first.add("poker"); } if (admin) { first.addAll(List.of("reload", "deck", "display", "place", "payout", "session", "deal")); @@ -539,7 +551,10 @@ public List onTabComplete(CommandSender sender, Command cmd, String alia } if (args.length == 2 && args[0].equalsIgnoreCase("bet") && bet) { return prefix(List.of("min", "max", "open", "close", "hit", "stand", "double", "split", - "check", "call", "fold", "raise"), args[1]); + "check", "call", "fold", "raise", "allin"), args[1]); + } + if (args.length == 2 && args[0].equalsIgnoreCase("poker") && bet) { + return prefix(List.of("configure", "buyin", "rebuy", "bet", "kick", "finish", "status"), args[1]); } if (!admin) { return Collections.emptyList(); diff --git a/src/main/java/net/tfminecraft/games/command/PokerCommands.java b/src/main/java/net/tfminecraft/games/command/PokerCommands.java new file mode 100644 index 0000000..5c46bb6 --- /dev/null +++ b/src/main/java/net/tfminecraft/games/command/PokerCommands.java @@ -0,0 +1,123 @@ +package net.tfminecraft.games.command; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import net.tfminecraft.games.Messages; +import net.tfminecraft.games.game.GamesRegistry; +import net.tfminecraft.games.game.PokerGame; +import net.tfminecraft.games.game.PokerTournament; +import net.tfminecraft.games.table.PayoutFlight; +import net.tfminecraft.games.table.Table; +import net.tfminecraft.games.table.TableManager; +import net.tfminecraft.games.wager.Accounts; +import net.tfminecraft.games.wager.TxResult; +import net.tfminecraft.games.wager.WagerEngine; +import net.tfminecraft.games.voice.RpNames; + +/** Dealer settings and player tournament actions at the nearby poker table. */ +final class PokerCommands { + private PokerCommands() {} + + static boolean execute(Player player, String[] args) { + TableManager manager = TableManager.get(); + Table table = manager.tableNearby(player); + if (table == null || !"poker".equalsIgnoreCase(table.getGameId())) { + player.sendMessage(Messages.get("poker.no_table")); + return true; + } + PokerTournament tournament = table.poker(); + String action = args.length > 1 ? args[1].toLowerCase(Locale.ROOT) : "status"; + try { + switch (action) { + case "status" -> player.sendMessage(Messages.get("poker.settings", + "buyin", String.valueOf(tournament.buyIn()), + "chips", String.valueOf(tournament.startingChips()), + "rebuys", String.valueOf(tournament.maxRebuys()), + "ante", String.valueOf(tournament.ante()), + "minutes", String.valueOf(tournament.blindMinutes()))); + case "buyin", "rebuy" -> { + if (table.live() || table.isPaying() || !tournament.canBuy(player.getUniqueId()) + || (!tournament.registered(player.getUniqueId()) && table.actives().size() >= 100)) { + player.sendMessage(Messages.get("poker.buyin_refused")); + return true; + } + TxResult result = WagerEngine.get().begin(table, "tournament buy in") + .move(Accounts.coins(table, player), Accounts.bucket(table, player.getUniqueId()), + tournament.buyIn()).commit(); + if (!result.ok()) { + player.sendMessage(Messages.get(result.messageKey())); + return true; + } + tournament.buy(player.getUniqueId()); + table.actives().add(player.getUniqueId()); + GamesRegistry.of("poker").onTableReady(table); + player.sendMessage(Messages.get("poker.bought", "n", String.valueOf(tournament.startingChips()))); + } + case "bet" -> { + if (args.length != 3 || !((PokerGame) GamesRegistry.of("poker")) + .betChips(table, player, Integer.parseInt(args[2]))) { + player.sendMessage(Messages.get("poker.chip_bet_refused")); + return true; + } + } + case "configure" -> { + if (!manager.canEditHouse(player, table) || table.live() || table.isPaying() + || WagerEngine.get().total(table) > 0 || args.length != 7) { + player.sendMessage(Messages.get("poker.configure_refused")); + return true; + } + tournament.configure(Integer.parseInt(args[2]), Integer.parseInt(args[3]), + Integer.parseInt(args[4]), Integer.parseInt(args[5]), Integer.parseInt(args[6])); + player.sendMessage(Messages.get("place.options_saved")); + } + case "kick" -> { + if (!manager.canEditHouse(player, table) || table.live() || table.isPaying() + || !tournament.enabled() || args.length != 3) { + player.sendMessage(Messages.get("poker.kick_refused")); + return true; + } + Player target = Bukkit.getPlayerExact(args[2]); + if (target == null || !tournament.registered(target.getUniqueId())) { + player.sendMessage(Messages.get("admin.unknown_player", "name", args[2])); + return true; + } + GamesRegistry.of("poker").onLeave(table, target); + target.sendMessage(Messages.get("poker.kicked")); + } + case "finish" -> { + List remaining = table.actives().stream() + .filter(id -> tournament.stack(id) > 0).toList(); + if (!manager.canEditHouse(player, table) || table.live() || table.isPaying() + || !tournament.started() || remaining.size() != 1) { + player.sendMessage(Messages.get("poker.finish_refused")); + return true; + } + UUID winner = remaining.getFirst(); + List flights = new ArrayList<>(); + WagerEngine.get().sweepPot(table, Bukkit.getPlayer(winner), winner, flights, "tournament prize"); + WagerEngine.get().announceWins(table, "poker"); + if (WagerEngine.get().felt(table) > 0) { + manager.flushPiles(table, flights, null); + player.sendMessage(Messages.get("poker.prize_pending")); + return true; + } + tournament.reset(); + table.roundMoney().clear(); + table.actives().clear(); + manager.flushPiles(table, flights, () -> manager.refreshLabel(table)); + player.sendMessage(Messages.get("poker.win", "name", RpNames.of(winner))); + } + default -> player.sendMessage(Messages.get("poker.usage")); + } + } catch (IllegalArgumentException ex) { + player.sendMessage(Messages.get("poker.usage")); + return true; + } + manager.persistHouseChange(table); + return true; + } +} diff --git a/src/main/java/net/tfminecraft/games/game/HandTalk.java b/src/main/java/net/tfminecraft/games/game/HandTalk.java index 491e8c3..d9a9cff 100644 --- a/src/main/java/net/tfminecraft/games/game/HandTalk.java +++ b/src/main/java/net/tfminecraft/games/game/HandTalk.java @@ -51,6 +51,17 @@ static List bestHand(String gameId, List seats, Function allHands(String gameId, List seats, Function> handOf) { + List lines = new ArrayList<>(); + for (UUID id : seats) { + List cards = handOf.apply(id); + if (!cards.isEmpty()) { + lines.add(Messages.get("hand.shown", "name", RpNames.of(id), "cards", cardList(cards))); + } + } + return lines; + } + /** Raw, so the caller's message formats the whole line in one pass. */ private static String cardList(List cards) { String template = Messages.getRaw("hand.card"); diff --git a/src/main/java/net/tfminecraft/games/game/PokerGame.java b/src/main/java/net/tfminecraft/games/game/PokerGame.java index e5be72d..a2d87f5 100644 --- a/src/main/java/net/tfminecraft/games/game/PokerGame.java +++ b/src/main/java/net/tfminecraft/games/game/PokerGame.java @@ -22,6 +22,9 @@ import net.tfminecraft.games.table.TableManager; import net.tfminecraft.games.voice.RpNames; import net.tfminecraft.games.wager.WagerEngine; +import net.tfminecraft.games.wager.Accounts; +import net.tfminecraft.games.wager.MoneyTx; +import net.tfminecraft.games.wager.TxResult; /** * Hold'em seats, button, holes, streets, and board deal. @@ -36,6 +39,7 @@ public final class PokerGame implements Game { private static final class Street { int currentBet; + boolean dealing = true; final Set folded = new HashSet<>(); final Set acted = new HashSet<>(); final Set capped = new HashSet<>(); @@ -73,6 +77,10 @@ public boolean tryClaimDealer(Table table, Player player) { @Override public void onSessionStart(Table table) { + if (table.poker().enabled()) { + table.actives().removeIf(id -> table.poker().stack(id) < 1); + } + ensureButton(table); List online = seatedOnline(table); if (online.size() < 2) { for (Player player : online) { @@ -81,6 +89,14 @@ public void onSessionStart(Table table) { TableManager.get().endSession(table); return; } + Street street = new Street(); + streets.put(table.getId(), street); + table.setStreet(1); + if (!postForcedBets(table, street)) { + streets.remove(table.getId()); + TableManager.get().endSession(table); + return; + } if (table.shufflePolicy() == ShufflePolicy.ROUND) { TableManager.get().reshuffleFull(table); } @@ -88,12 +104,13 @@ public void onSessionStart(Table table) { table.setStreet(1); table.setActor(null); TableManager.get().refreshLabel(table); - dealHoles(table, holeQueue(table), 0); + dealHoles(table, holeQueue(table), 0, street); } @Override public void onSessionEnd(Table table) { streets.remove(table.getId()); + table.poker().abortHand(); TableManager manager = TableManager.get(); for (UUID id : new ArrayList<>(table.getHands().keySet())) { manager.muckPlayer(table, id); @@ -103,6 +120,7 @@ public void onSessionEnd(Table table) { @Override public void onTableRemoved(Table table) { + table.poker().reset(); streets.remove(table.getId()); } @@ -114,12 +132,16 @@ public void onTableRemoved(Table table) { public boolean allowPlayChat(Table table, Player player) { UUID id = player.getUniqueId(); // A leaver stays the actor until their refund lands, but can no longer act. - return bettingPhase(table.phase()) && id.equals(table.actor()) && table.actives().contains(id); + Street street = streets.get(table.getId()); + return !table.isPaying() && street != null && !street.capped.contains(id) + && !street.folded.contains(id) && bettingPhase(table.phase()) + && id.equals(table.actor()) && table.actives().contains(id); } /** TableManager hands words only to the actor that allowPlayChat accepted. */ @Override public void onPlayWord(Table table, Player player, String word) { + if (!allowPlayChat(table, player)) return; Street street = streets.get(table.getId()); UUID id = player.getUniqueId(); int contrib = streetContrib(table, id); @@ -133,21 +155,26 @@ public void onPlayWord(Table table, Player player, String word) { player.sendMessage(Messages.get("poker.cannot_check")); return; } - player.sendMessage(Messages.get("poker.checked")); + announceAction(table, player, "checked"); } case "call" -> { if (contrib < street.currentBet) { // Calling short is only an all in: chips still in pockets have to go down first. - if (!WagerEngine.get().allIn(table, player)) { + if (table.poker().enabled()) { + table.poker().bet(id, street.currentBet - contrib); + contrib = streetContrib(table, id); + } + if (contrib < street.currentBet && !isAllIn(table, player)) { player.sendMessage(Messages.get("poker.need_call", "n", String.valueOf(street.currentBet - contrib))); return; } - street.capped.add(id); + if (isAllIn(table, player)) street.capped.add(id); } else { street.capped.remove(id); } - player.sendMessage(Messages.get("poker.called")); + if (isAllIn(table, player)) street.capped.add(id); + announceAction(table, player, "called"); } case "raise" -> { if (contrib <= street.currentBet) { @@ -156,13 +183,35 @@ public void onPlayWord(Table table, Player player, String word) { } street.currentBet = contrib; street.acted.clear(); - street.capped.remove(id); - player.sendMessage(Messages.get("poker.raised", "n", String.valueOf(street.currentBet))); + if (isAllIn(table, player)) street.capped.add(id); + else street.capped.remove(id); + tellSeated(table, Messages.get("poker.action_raised", "name", RpNames.of(id), + "n", String.valueOf(street.currentBet))); + } + case "allin" -> { + if (table.poker().enabled()) { + table.poker().bet(id, table.poker().stack(id)); + } else { + var pockets = Accounts.pockets(table, player); + TxResult result = WagerEngine.get().begin(table, "poker all in") + .move(pockets, Accounts.bucket(table, id), pockets.available()).commit(); + if (!result.ok()) { + player.sendMessage(Messages.get(result.messageKey())); + return; + } + } + contrib = streetContrib(table, id); + if (contrib > street.currentBet) { + street.currentBet = contrib; + street.acted.clear(); + } + street.capped.add(id); + announceAction(table, player, "allin"); } case "fold" -> { street.folded.add(id); TableManager.get().muckPlayer(table, id); - player.sendMessage(Messages.get("poker.folded")); + announceAction(table, player, "folded"); } default -> { return; @@ -175,12 +224,9 @@ public void onPlayWord(Table table, Player player, String word) { /** A live hand takes money only from seats still in it; anyone else waits for the next one. */ @Override public boolean allowStake(Table table, Player player) { - if (!table.live()) { - return true; - } - UUID id = player.getUniqueId(); - Street street = streets.get(table.getId()); - return table.actives().contains(id) && (street == null || !street.folded.contains(id)); + if (table.poker().enabled()) return false; + if (!table.live()) return true; + return allowPlayChat(table, player); } @Override @@ -211,8 +257,16 @@ public void onTableReady(Table table) { @Override public String extraLabel(Table table) { List lines = new ArrayList<>(); - int small = table.smallBlind(); - int big = table.bigBlind(); + int small = blind(table, table.smallBlind()); + int big = blind(table, table.bigBlind()); + if (table.poker().enabled()) { + for (UUID id : table.actives()) { + lines.add(Messages.get("poker.stack", "name", RpNames.of(id), + "n", String.valueOf(table.poker().stack(id)))); + } + lines.add(Messages.get("poker.chip_pot", "n", String.valueOf( + table.poker().invested().values().stream().mapToInt(Integer::intValue).sum()))); + } if (small > 0 || big > 0) { lines.add(Messages.get("label.blinds", "small", String.valueOf(small), @@ -230,7 +284,7 @@ public String extraLabel(Table table) { if (actor != null) { lines.add(Messages.get("label.turn", "name", RpNames.of(actor))); Street street = streets.get(table.getId()); - int toCall = Math.max(0, street.currentBet - streetContrib(table, actor)); + int toCall = street == null ? 0 : Math.max(0, street.currentBet - streetContrib(table, actor)); lines.add(Messages.get("label.holdem_tocall", "n", String.valueOf(toCall))); } } @@ -253,6 +307,10 @@ private void ensureButton(Table table) { @Override public void onLeave(Table table, Player player) { + if (table.poker().enabled()) { + leaveTournament(table, player); + return; + } TableManager manager = TableManager.get(); UUID leaver = player.getUniqueId(); List before = new ArrayList<>(table.actives()); @@ -291,6 +349,7 @@ public void onLeave(Table table, Player player) { WagerEngine.get().sweepPot(table, Bukkit.getPlayer(rest), rest, flights, "hand abandoned"); } boolean stop = live && table.actives().size() < 2; + if (stop) streets.remove(table.getId()); manager.flushPiles(table, flights, () -> { if (stop) { TableManager.get().endSession(table); @@ -325,9 +384,9 @@ private static List holeQueue(Table table) { return queue; } - private void dealHoles(Table table, List queue, int index) { + private void dealHoles(Table table, List queue, int index, Street street) { Table still = TableManager.get().table(table.getId()); - if (still == null || !still.live()) { + if (still == null || !still.live() || streets.get(table.getId()) != street) { return; } if (index >= queue.size()) { @@ -335,14 +394,24 @@ private void dealHoles(Table table, List queue, int index) { return; } Player player = queue.get(index); - TableManager.get().dealToPlayer(still, player, 1, () -> dealHoles(still, queue, index + 1)); + if (!still.actives().contains(player.getUniqueId())) { + dealHoles(still, queue, index + 1, street); + return; + } + TableManager.get().dealToPlayer(still, player, 1, () -> dealHoles(still, queue, index + 1, street)); } private void startStreet(Table table) { - Street street = new Street(); - streets.put(table.getId(), street); - table.setActor(leftOfButton(table, street)); - TableManager.get().refreshLabel(table); + Street street = streets.get(table.getId()); + if (street == null) return; + street.dealing = false; + UUID first = leftOfButton(table, street); + if (table.smallBlind() > 0 || table.bigBlind() > 0) { + UUID big = bigBlindSeat(table); + first = SeatOrder.first(SeatOrder.after(table, big), id -> betting(street, id)); + } + table.setActor(first); + finishOrAdvance(table, new ArrayList<>(table.actives()), false); } private static boolean bettingPhase(String phase) { @@ -371,10 +440,12 @@ private void resumeBetting(Table table, Street street) { if (streets.get(table.getId()) != street) { return; } + street.dealing = false; street.currentBet = 0; street.acted.clear(); - street.capped.clear(); + table.poker().nextStreet(); table.setActor(leftOfButton(table, street)); + finishOrAdvance(table, new ArrayList<>(table.actives()), false); TableManager.get().refreshLabel(table); } @@ -405,11 +476,11 @@ private void advanceBoard(Table table, Street street) { cards = 1; announce = "poker.river"; } + street.dealing = true; table.setPhase(nextPhase); table.setStreet(nextStreet); street.currentBet = 0; street.acted.clear(); - street.capped.clear(); tellSeated(table, Messages.get(announce)); TableManager.get().refreshLabel(table); TableManager.get().dealToTable(table, "board", cards, true, () -> resumeBetting(table, street)); @@ -441,6 +512,9 @@ private void showdown(Table table, Street street) { } } List board = boardCards(table); + for (String line : HandTalk.allHands(table.getGameId(), live, id -> cardsOf(table.heldBy(id)))) { + tellSeated(table, line); + } for (String line : HandTalk.bestHand(table.getGameId(), live, id -> { List cards = new ArrayList<>(board); cards.addAll(cardsOf(table.heldBy(id))); @@ -468,8 +542,9 @@ private void announceWinners(Table table, List winners) { } private void payPots(Table table, List live, List board, String gameId) { - // The ledger only reports owners with money on the felt, so every total is positive. - Map invested = WagerEngine.get().totalsExcept(table, table.getId()); + // Economy stakes and virtual chip investments both report only positive totals. + Map invested = table.poker().enabled() ? table.poker().invested() + : WagerEngine.get().totalsExcept(table, table.getId()); TreeSet levels = new TreeSet<>(invested.values()); if (levels.isEmpty()) { finishHand(table); @@ -479,6 +554,7 @@ private void payPots(Table table, List live, List board, String game // Showdown supplies at least two live seats, all still in the seating order. UUID leftover = SeatOrder.leftOfButton(table, live).getFirst(); int previous = 0; + int awarded = 0; for (int level : levels) { int covered = 0; for (int put : invested.values()) { @@ -501,9 +577,21 @@ private void payPots(Table table, List live, List board, String game List winners = rankSeats(table, contestants, board, gameId); leftover = winners.getFirst(); announceWinners(table, winners); - payEven(table, flights, winners, amount); + awarded += amount; + if (table.poker().enabled()) { + int share = amount / winners.size(); + int rem = amount % winners.size(); + for (UUID winner : winners) table.poker().award(winner, share + (rem-- > 0 ? 1 : 0)); + } else payEven(table, flights, winners, amount); } // Whatever the levels could not split in whole coins goes to one seat. + if (table.poker().enabled()) { + int total = invested.values().stream().mapToInt(Integer::intValue).sum(); + table.poker().award(leftover, total - awarded); + table.poker().finishHand(); + finishHand(table); + return; + } WagerEngine.get().sweepPot(table, Bukkit.getPlayer(leftover), leftover, flights, "pot remainder"); WagerEngine.get().announceWins(table, "poker"); finishAfterPayout(table, flights); @@ -549,6 +637,7 @@ private static void payEven(Table table, List flights, List } private void finishHand(Table table) { + table.poker().finishHand(); TableManager.get().endSession(table); passButton(table); TableManager.get().refreshLabel(table); @@ -574,7 +663,8 @@ private static List cardsOf(List held) { } private static int streetContrib(Table table, UUID owner) { - return WagerEngine.get().owned(table, owner, table.street()); + return table.poker().enabled() ? table.poker().contribution(owner) + : WagerEngine.get().owned(table, owner, table.street()); } private static List liveSeats(Table table, Street street) { @@ -594,7 +684,7 @@ private static List liveSeats(Table table, Street street) { */ private static boolean streetComplete(Table table, Street street) { for (UUID id : liveSeats(table, street)) { - if (!street.capped.contains(id) && !street.acted.contains(id)) { + if (!street.capped.contains(id) && (!street.acted.contains(id) || streetContrib(table, id) < street.currentBet)) { return false; } } @@ -607,8 +697,8 @@ private static boolean streetComplete(Table table, Street street) { */ private void finishOrAdvance(Table table, List seating, boolean advance) { Street street = streets.get(table.getId()); - // No street means the cards are still being dealt, or the hand is already settled. - if (street == null) { + // Dealing animations cannot start another street, and a settled hand has no street. + if (street == null || street.dealing) { TableManager.get().refreshLabel(table); return; } @@ -642,6 +732,14 @@ private void foldWin(Table table, UUID winner) { tellSeated(table, Messages.get("poker.win_fold", "name", RpNames.of(winner))); } List flights = new ArrayList<>(); + if (table.poker().enabled()) { + int pot = table.poker().invested().values().stream().mapToInt(Integer::intValue).sum(); + if (winner != null) table.poker().award(winner, pot); + else table.poker().abortHand(); + table.poker().finishHand(); + finishHand(table); + return; + } Player dest = winner != null ? Bukkit.getPlayer(winner) : null; if (dest != null) { WagerEngine.get().sweepPot(table, dest, winner, flights, "fold win"); @@ -653,6 +751,88 @@ private void foldWin(Table table, UUID winner) { finishAfterPayout(table, flights); } + private static boolean isAllIn(Table table, Player player) { + return table.poker().enabled() ? table.poker().stack(player.getUniqueId()) == 0 + : WagerEngine.get().allIn(table, player); + } + + private static int blind(Table table, int base) { + return table.poker().blind(base, System.currentTimeMillis()); + } + + private static UUID bigBlindSeat(Table table) { + return table.actives().size() == 2 ? SeatOrder.next(table, table.dealerId()) + : SeatOrder.next(table, SeatOrder.next(table, table.dealerId())); + } + + private boolean postForcedBets(Table table, Street street) { + PokerTournament tournament = table.poker(); + int small = blind(table, table.smallBlind()); + int big = blind(table, table.bigBlind()); + UUID smallSeat = table.actives().size() == 2 ? table.dealerId() : SeatOrder.next(table, table.dealerId()); + UUID bigSeat = bigBlindSeat(table); + if (tournament.enabled()) { + tournament.start(System.currentTimeMillis()); + for (UUID id : table.actives()) tournament.bet(id, tournament.ante()); + tournament.nextStreet(); // Antes are dead money, not a call credit. + tournament.bet(smallSeat, small); + tournament.bet(bigSeat, big); + } else if (small > 0 || big > 0) { + MoneyTx tx = WagerEngine.get().begin(table, "poker blinds"); + for (UUID id : table.actives()) { + int required = id.equals(smallSeat) ? small : id.equals(bigSeat) ? big : 0; + int need = Math.max(0, required - streetContrib(table, id)); + Player player = Bukkit.getPlayer(id); + if (player == null) { tellSeated(table, Messages.get("poker.blind_failed")); return false; } + if (need > 0) { + int available = Accounts.pockets(table, player).available(); + if (available <= need) tx.move(Accounts.pockets(table, player), Accounts.bucket(table, id), available); + else tx.move(Accounts.pockets(table, player), Accounts.bucket(table, id), need); + } + } + TxResult result = tx.commit(); + if (!result.ok()) { tellSeated(table, Messages.get("poker.blind_failed")); return false; } + } + street.currentBet = big; + if (small > big) street.currentBet = small; + if (small > 0 || big > 0 || tournament.enabled()) { + for (Player player : seatedOnline(table)) { + if (isAllIn(table, player)) street.capped.add(player.getUniqueId()); + } + } + return true; + } + + public boolean betChips(Table table, Player player, int amount) { + if (!table.poker().enabled() || !allowPlayChat(table, player) || amount < 1 + || amount > table.poker().stack(player.getUniqueId())) return false; + table.poker().bet(player.getUniqueId(), amount); + TableManager.get().refreshLabel(table); + return true; + } + + private void leaveTournament(Table table, Player player) { + UUID id = player.getUniqueId(); + if (!table.poker().started()) { + List flights = new ArrayList<>(); + WagerEngine.get().refund(table, id, player, 0, flights, "tournament withdrawal"); + TableManager.get().flushPiles(table, flights, null); + } + Street street = streets.get(table.getId()); + if (street != null) street.folded.add(id); + table.poker().remove(id); + List before = new ArrayList<>(table.actives()); + table.actives().remove(id); + if (id.equals(table.dealerId())) { + table.setDealerId(SeatOrder.first(SeatOrder.after(before, id), table.actives()::contains)); + } + finishOrAdvance(table, before, false); + } + + private static void announceAction(Table table, Player player, String action) { + tellSeated(table, Messages.get("poker.action_" + action, "name", RpNames.of(player.getUniqueId()))); + } + private static void tellSeated(Table table, String message) { for (Player player : seatedOnline(table)) { player.sendMessage(message); diff --git a/src/main/java/net/tfminecraft/games/game/PokerTournament.java b/src/main/java/net/tfminecraft/games/game/PokerTournament.java new file mode 100644 index 0000000..c9d2c77 --- /dev/null +++ b/src/main/java/net/tfminecraft/games/game/PokerTournament.java @@ -0,0 +1,82 @@ +package net.tfminecraft.games.game; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** Tournament chips are counters, never economy items. The table ledger holds the prize. */ +public final class PokerTournament { + private int buyIn; + private int startingChips = 1000; + private int maxRebuys; + private int ante; + private int blindMinutes; + private long startedAt; + private final Map stacks = new LinkedHashMap<>(); + private final Map rebuys = new LinkedHashMap<>(); + private final Map invested = new LinkedHashMap<>(); + private final Map street = new LinkedHashMap<>(); + + public int buyIn() { return buyIn; } + public int startingChips() { return startingChips; } + public int maxRebuys() { return maxRebuys; } + public int ante() { return ante; } + public int blindMinutes() { return blindMinutes; } + public boolean enabled() { return buyIn > 0; } + public boolean started() { return startedAt != 0; } + public boolean registered(UUID id) { return stacks.containsKey(id); } + public int stack(UUID id) { return stacks.getOrDefault(id, 0); } + public int contribution(UUID id) { return street.getOrDefault(id, 0); } + public Map invested() { return Map.copyOf(invested); } + public boolean occupied() { return !stacks.isEmpty(); } + + public void configure(int buyIn, int chips, int rebuys, int ante, int minutes) { + if (occupied() || buyIn < 0 || buyIn > 1_000_000 || chips < 1 || rebuys < 0 || ante < 0 || minutes < 0 + || chips > 1_000_000 || rebuys > 100 || minutes > 10_080) { + throw new IllegalArgumentException("Invalid or occupied tournament"); + } + this.buyIn = buyIn; + this.startingChips = chips; + this.maxRebuys = rebuys; + this.ante = ante; + this.blindMinutes = minutes; + } + + public boolean canBuy(UUID id) { + return enabled() && ((!registered(id) && !started()) + || (registered(id) && stack(id) == 0 && rebuys.getOrDefault(id, 0) < maxRebuys)); + } + + public void buy(UUID id) { + if (!canBuy(id)) throw new IllegalStateException("Buy-in refused"); + if (registered(id)) rebuys.merge(id, 1, Integer::sum); + stacks.put(id, startingChips); + } + + public void start(long now) { if (!started()) startedAt = now; } + public int blind(int base, long now) { + if (base == 0 || blindMinutes == 0 || !started()) return base; + long level = Math.min(20, Math.max(0, now - startedAt) / (blindMinutes * 60_000L)); + return (int) Math.min(1_000_000L, (long) base << level); + } + public int bet(UUID id, int amount) { + int paid = Math.min(stack(id), Math.max(0, amount)); + if (paid > 0) { + stacks.put(id, stack(id) - paid); + invested.merge(id, paid, Math::addExact); + street.merge(id, paid, Math::addExact); + } + return paid; + } + public void nextStreet() { street.clear(); } + public void award(UUID id, int amount) { stacks.merge(id, amount, Math::addExact); } + public void finishHand() { invested.clear(); street.clear(); } + public void abortHand() { + invested.forEach((id, amount) -> { if (registered(id)) award(id, amount); }); + finishHand(); + } + public void remove(UUID id) { stacks.remove(id); rebuys.remove(id); } + public void reset() { + stacks.clear(); rebuys.clear(); finishHand(); startedAt = 0; + } +} diff --git a/src/main/java/net/tfminecraft/games/table/Table.java b/src/main/java/net/tfminecraft/games/table/Table.java index 929895c..bf2ad38 100644 --- a/src/main/java/net/tfminecraft/games/table/Table.java +++ b/src/main/java/net/tfminecraft/games/table/Table.java @@ -12,6 +12,7 @@ import org.bukkit.Location; import net.tfminecraft.games.deck.Deck; +import net.tfminecraft.games.game.PokerTournament; import net.tfminecraft.games.wager.PotPile; import net.tfminecraft.games.wager.RoundMoney; import net.tfminecraft.games.wager.TableLedger; @@ -65,6 +66,8 @@ public final class Table { private int houseFloat; private int maxBoxes; private ShufflePolicy shufflePolicy = ShufflePolicy.SHOE; + private final PokerTournament poker = new PokerTournament(); + private int smallBlind; private int bigBlind; private UUID floatOwner; @@ -423,6 +426,8 @@ public void setShufflePolicy(ShufflePolicy shufflePolicy) { this.shufflePolicy = shufflePolicy; } + public PokerTournament poker() { return poker; } + public int smallBlind() { return smallBlind; } @@ -483,6 +488,6 @@ public void clearSession() { boxIndex = 0; handIndex = 0; autoCountdown = 0; - roundMoney.clear(); + if (!poker.enabled() || !poker.occupied()) roundMoney.clear(); } } diff --git a/src/main/java/net/tfminecraft/games/table/TableManager.java b/src/main/java/net/tfminecraft/games/table/TableManager.java index b7cb3ce..bf941ce 100644 --- a/src/main/java/net/tfminecraft/games/table/TableManager.java +++ b/src/main/java/net/tfminecraft/games/table/TableManager.java @@ -2008,8 +2008,8 @@ private static String playWord(String message) { String key = raw.toLowerCase(Locale.ROOT); if (key.equals("hit") || key.equals("stand") || key.equals("double") || key.equals("split") || key.equals("check") || key.equals("call") || key.equals("fold") || key.equals("raise") - || key.equals("draw")) { - return key; + || key.equals("draw") || key.equals("allin") || key.equals("all in")) { + return key.equals("all in") ? "allin" : key; } return null; } @@ -2691,7 +2691,8 @@ private static boolean refuseStake(Table table, Player player) { if (game == null || game.allowStake(table, player)) { return false; } - player.sendMessage(Messages.get("wager.not_in_hand")); + player.sendMessage(Messages.get("poker".equalsIgnoreCase(table.getGameId()) + ? "poker.stake_closed" : "wager.not_in_hand")); return true; } @@ -4396,6 +4397,11 @@ private static TableData toData(Table table) { data.maxBet = table.maxBet(); data.maxBoxes = table.maxBoxes(); data.shufflePolicy = table.shufflePolicy().name(); + data.pokerBuyIn = table.poker().buyIn(); + data.pokerChips = table.poker().startingChips(); + data.pokerRebuys = table.poker().maxRebuys(); + data.pokerAnte = table.poker().ante(); + data.pokerBlindMinutes = table.poker().blindMinutes(); data.smallBlind = table.smallBlind(); data.bigBlind = table.bigBlind(); data.floatOwner = table.floatOwner() != null ? table.floatOwner().toString() : null; @@ -4441,6 +4447,8 @@ private static Table fromData(TableData data, List unowned) { } } applyHouseData(table, data); + table.poker().configure(data.pokerBuyIn, data.pokerChips > 0 ? data.pokerChips : 1000, + data.pokerRebuys, data.pokerAnte, data.pokerBlindMinutes); return table; } @@ -4661,6 +4669,7 @@ static final class TableData { int maxBet; int maxBoxes; String shufflePolicy; + int pokerBuyIn, pokerChips, pokerRebuys, pokerAnte, pokerBlindMinutes; Integer smallBlind; Integer bigBlind; /** An absent private dealer the tray is being kept for. Absent on older files. */ diff --git a/src/main/java/net/tfminecraft/games/wager/BucketAccount.java b/src/main/java/net/tfminecraft/games/wager/BucketAccount.java index 159fc34..e0f994d 100644 --- a/src/main/java/net/tfminecraft/games/wager/BucketAccount.java +++ b/src/main/java/net/tfminecraft/games/wager/BucketAccount.java @@ -79,7 +79,10 @@ public Withdrawal planTake(int denars, ItemStack template) { slots.add(new CoinPlanner.Slot(stake.unit(), stake.count())); } int[] picks = CoinPlanner.exact(slots, denars); - return picks == null ? null : new BucketWithdrawal(usable, picks, denars); + if (picks != null) return new BucketWithdrawal(usable, picks, denars); + List changed = withChange(usable); + picks = CoinPlanner.exact(slots(changed), denars); + return picks == null ? null : new ChangedWithdrawal(usable, changed, picks, denars); } @Override @@ -113,7 +116,72 @@ public int largestTakeUpTo(int denars, ItemStack template) { for (Stake stake : spendable()) { slots.add(new CoinPlanner.Slot(stake.unit(), stake.count())); } - return CoinPlanner.best(slots, denars); + int best = CoinPlanner.best(slots, denars); + return best == denars ? best : Math.max(best, CoinPlanner.best(slots(withChange(spendable())), denars)); + } + + private static List slots(List stakes) { + List slots = new ArrayList<>(); + for (Stake stake : stakes) slots.add(new CoinPlanner.Slot(stake.unit(), stake.count())); + return slots; + } + + /** Pure planning: never exchange coins until the complete transaction has been accepted. */ + private static List withChange(List originals) { + List out = new ArrayList<>(); + for (Stake original : originals) { + List coins = original.unit() == ChipItems.unitDenars(original.item()) + ? ChipItems.smallestCoins(original.item()) : List.of(); + if (coins.isEmpty()) { + out.add(new Stake(original.item(), original.typeKey(), original.unit(), + original.count(), original.streetId())); + } else { + for (ItemStack coin : coins) { + Stake made = new Stake(coin.clone(), ChipItems.typeKey(coin), ChipItems.unitDenars(coin), + Math.multiplyExact(coin.getAmount(), original.count()), original.streetId()); + made.item().setAmount(1); + if (original.placed()) made.setSpot(original.x(), original.z()); + out.add(made); + } + } + } + return out; + } + + private final class ChangedWithdrawal implements Withdrawal { + private final List originals; + private final List changed; + private final int[] counts; + private final int denars; + + ChangedWithdrawal(List originals, List changed, int[] counts, int denars) { + this.originals = originals; + this.changed = changed; + this.counts = counts; + this.denars = denars; + } + + @Override public int denars() { return denars; } + @Override public List preview() { + List out = new ArrayList<>(); + for (int i = 0; i < changed.size(); i++) { + Stake coin = changed.get(i); + if (counts[i] > 0) out.add(new Stake(coin.item(), coin.typeKey(), coin.unit(), counts[i], coin.streetId())); + } + return out; + } + @Override public List take() { + for (Stake original : originals) original.split(original.count()); + table.ledger().tidy(); + List out = new ArrayList<>(); + for (int i = 0; i < changed.size(); i++) { + Stake coin = changed.get(i); + if (counts[i] > 0) out.add(coin.split(counts[i])); + if (coin.count() > 0) table.ledger().put(owner, coin, + coin.placed() ? coin.x() : null, coin.placed() ? coin.z() : null); + } + return out; + } } /** The live stakes a set amount can come out of, which is all of them unless a street was named. */ diff --git a/src/main/java/net/tfminecraft/games/wager/ChipItems.java b/src/main/java/net/tfminecraft/games/wager/ChipItems.java index e982d05..a4b57c4 100644 --- a/src/main/java/net/tfminecraft/games/wager/ChipItems.java +++ b/src/main/java/net/tfminecraft/games/wager/ChipItems.java @@ -132,6 +132,38 @@ public static List change(ItemStack stack) { return out == null ? List.of() : out; } + /** Break real economy coins down to the smallest available whole-denar denomination. */ + public static List smallestCoins(ItemStack stack) { + if (stack == null || unitDenars(stack) < 1) return List.of(); + ItemStack one = stack.clone(); + one.setAmount(1); + return smallestCoins(one, 0); + } + + private static List smallestCoins(ItemStack one, int depth) { + int original = unitDenars(one); + if (original <= 1 || depth >= 16) return List.of(one); + List change = change(one); + long value = 0; + for (ItemStack coin : change) { + int unit = unitDenars(coin); + if (unit < 1 || unit >= original || coin.getAmount() < 1) return List.of(one); + value += (long) unit * coin.getAmount(); + } + if (value != original) return List.of(one); + List out = new java.util.ArrayList<>(); + for (ItemStack coin : change) { + ItemStack single = coin.clone(); + single.setAmount(1); + for (ItemStack smaller : smallestCoins(single, depth + 1)) { + ItemStack made = smaller.clone(); + made.setAmount(Math.multiplyExact(smaller.getAmount(), coin.getAmount())); + out.add(made); + } + } + return out; + } + public static String displayModel(ItemStack stack) { return pileStyle(stack).model(); } diff --git a/src/main/java/net/tfminecraft/games/wager/WagerEngine.java b/src/main/java/net/tfminecraft/games/wager/WagerEngine.java index 8ac29ae..883f97d 100644 --- a/src/main/java/net/tfminecraft/games/wager/WagerEngine.java +++ b/src/main/java/net/tfminecraft/games/wager/WagerEngine.java @@ -262,6 +262,11 @@ public PayWinResult payWin(Table table, Player winner, UUID owner, int profit, P MoneyAccount payee = Accounts.payee(table, winner, owner); List sources = winSources(table, dealer, dealerBacked); + // Bank/mint top-ups must also make the net profit and tax exactly, not copy a large pouch. + for (ItemStack coin : ChipItems.smallestCoins(template)) { + if (ChipItems.unitDenars(coin) < ChipItems.unitDenars(template)) template = coin; + } + int trayBefore = tray(table); int paidNet = 0; if (net > 0) { diff --git a/src/main/resources/help.yml b/src/main/resources/help.yml index d224038..30ef29e 100644 --- a/src/main/resources/help.yml +++ b/src/main/resources/help.yml @@ -51,16 +51,21 @@ poker: - "#6b3f1d&lSitting down\n\n#232323Right click the felt with coins. That is you in.\n\nOnce two of you are sat down, anyone at the table can right click the shoe and the cards come out." - "#6b3f1d&lYour cards\n\n#232323Only you can see them. Left click one to have a proper look at it.\n\nPress your swap hands key to turn them face up for everyone. There is rarely a good reason to." - "#6b3f1d&lThe button\n\n#232323Somebody has the button, and it says who above the table.\n\nIt decides who acts first, and it moves one seat along after every hand, so nobody is stuck with it." - - "#6b3f1d&lThe blinds\n\n#7a2f2fThe blinds written above the table are a guide. Nothing is taken from you automatically.\n\n#232323If the table plays blinds, whoever owes one puts the coins out themselves." - - "#6b3f1d&lBetting\n\n#232323Money on the felt first, words second. Push your coins out, then say what you are doing.\n\n#6b3f1dcheck call raise fold" + - "#6b3f1d&lThe blinds\n\n#7a2f2fBlinds are collected automatically before dealing. Bring coins that can make the amount exactly.\n\n#232323With two players, the button posts the small blind and acts first before the flop." + - "#6b3f1d&lBetting\n\n#232323On your turn, push your coins out, then say what you are doing. You cannot add money after folding or join a hand already dealt.\n\n#6b3f1dcheck call raise fold" - "#6b3f1d&lWhat those mean\n\n#232323check is nothing more from me.\ncall is match what is out there.\nraise is more than that.\nfold is I am out and my money stays." - "#6b3f1d&lRaising\n\n#7a2f2fPut the coins down before you say raise.\n\n#232323The table looks at what you have actually got out this round, so saying it with nothing on the felt gets you nowhere." - - "#6b3f1d&lRunning short\n\n#232323Call with less than you need and you are in for what you could manage.\n\nYou stay in the hand for that much and the rest of the betting carries on without you." + - "#6b3f1d&lRunning short\n\n#232323A short call is allowed only when you have no chips left.\n\nUse /games bet allin to stake everything. You then stay all in for the rest of the hand." - "#6b3f1d&lThe five cards\n\n#232323Three come at once, then a fourth, then a fifth, with betting after each lot.\n\nThey arrive on their own. Nobody has to turn them." - "#6b3f1d&lThe end of it\n\n#232323Everyone left shows their hand and the best five cards take the pot.\n\nSame hand as somebody else and you split it between you." - "#6b3f1d&lIf they all fold\n\n#232323Last one still in takes the lot and never has to show anything.\n\nThat is allowed and it is often the point." - "#6b3f1d&lTwo pots\n\n#232323If somebody was in for less than the rest, they can only win what they matched.\n\nThe extra goes to the best hand among those who paid it." - - "#6b3f1d&lWalking off\n\n#232323Step more than a few blocks from the table and you are out of the hand.\n\nWhat you put in this round comes back. What went in earlier does not." + - "#6b3f1d&lWalking off\n\n#232323Step more than a few blocks from the table and you are out of the hand.\n\nOnly unmatched money from this street comes back. Called bets stay in the pot. Tournament players forfeit their chips and buy-in after play starts." + - "#6b3f1d&lTournament\n\n#232323The host sets a Denar buy-in, starting chips and rebuys. Use /games poker buyin before play starts. Chips stay at the table; Denars form the prize." + - "#6b3f1d&lChip bets\n\n#232323Use /games poker bet on your turn, then raise or check. A call takes the required chips automatically. Use /games bet allin for your whole stack." + - "#6b3f1d&lRebuys and prize\n\n#232323With no chips, use /games poker rebuy between hands, within the host limit. The host uses /games poker finish with one remaining stack to pay the prize." + - "#6b3f1d&lTournament settings\n\n#232323/games poker configure \n\nSet this on an empty table. Antes use chips. Blinds double at each interval, applied at the next hand. Zero minutes keeps them fixed." + - "#6b3f1d&lHost controls\n\n#232323/games poker kick removes a player between hands. Leaving or removal after play starts forfeits entry.\n\nOn shutdown the tournament ends and Denar stakes are refunded." draw: title: "Five-Draw" diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index cab1fef..bccca0d 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -97,15 +97,36 @@ hand: returned: "#aaaaaaYour cards went to the discard pile." returned_selected: "#aaaaaaSelected cards went to the discard pile." locked: "#ff5555The shoe is locked while a session is live." + shown: "#ffffff{name} #aaaaaashows {cards}." best: "#ffaa00Best hand: #ffffff{name}" best_cards: "#aaaaaa{cards}" card: "{rank} #aaaaaaof {suit}" card_join: "#aaaaaa, " poker: + action_checked: "#aaaaaa{name} checks." + action_called: "#aaaaaa{name} calls." + action_raised: "#55ff55{name} raises to {n}." + action_folded: "#aaaaaa{name} folds." + action_allin: "#ffaa00{name} goes all in." + stake_closed: "#ff5555Stake only on your turn in this hand. Tournament chips use /games poker bet ." + blind_failed: "#ff5555Cannot collect blinds exactly. Bring suitable coins before dealing." + prize_pending: "#ff5555Some prize coins remain on the table. Retry finishing before starting another tournament." + chip_pot: "#ffaa00Chip pot: {n}" + stack: "#aaaaaa{name}: {n} tournament chips" + no_table: "#ff5555Stand near a poker table." + settings: "#aaaaaaTournament: buy-in {buyin} Denars, {chips} chips, {rebuys} rebuys, ante {ante}, blinds double every {minutes} minutes (0 = fixed)." + buyin_refused: "#ff5555Buy in before the tournament starts; rebuy only between hands with no chips and rebuys remaining." + bought: "#55ff55Bought {n} tournament chips." + chip_bet_refused: "#ff5555Bet a positive amount within your chip stack, on your turn." + configure_refused: "#ff5555Only the table host or staff can configure an idle, empty tournament." + kick_refused: "#ff5555Only the host or staff can kick tournament players between hands." + kicked: "#ff5555The host removed you from the tournament." + finish_refused: "#ff5555Only the host or staff can finish a started tournament between hands with one remaining chip stack." + usage: "#aaaaaa/games poker configure | buyin | rebuy | bet | kick | finish | status" need_players: "#ff5555Need two seated players to deal." cannot_check: "#ff5555You cannot check. Call, raise, or fold." - need_call: "#ff5555Put {n} more on this street to call, or fold." + need_call: "#ff5555Put {n} more on this street to call, or use /games bet allin, or fold." need_chips: "#ff5555Put chips on this street first." checked: "#aaaaaaChecked." called: "#aaaaaaCalled." diff --git a/src/test/java/net/tfminecraft/games/MessagesTest.java b/src/test/java/net/tfminecraft/games/MessagesTest.java index f399ce0..e6c5423 100644 --- a/src/test/java/net/tfminecraft/games/MessagesTest.java +++ b/src/test/java/net/tfminecraft/games/MessagesTest.java @@ -68,6 +68,16 @@ void reloadReplacesOldMessagesAndFailedLoadsReportFallbackKeys() throws Exceptio verify(logger, times(2)).severe(startsWith("[Games] Failed to load messages.yml:")); } + @Test + void bundledNewMessagesFillMissingKeysWithoutReplacingCustomText() throws Exception { + Path file = directory.resolve("messages.yml"); + Files.writeString(file, "poker:\n action_called: 'Custom {name}'\n"); + Messages.load(file.toFile()); + assertEquals("Custom Alice", Messages.get("poker.action_called", "name", "Alice")); + assertTrue(Messages.getRaw("poker.action_allin").contains("{name}")); + assertFalse(Messages.getRaw("poker.action_allin").equals("poker.action_allin")); + } + @Test void keysAreShownAsTheyAreBeforeMessagesAreLoaded() throws Exception { config.set(null, null); diff --git a/src/test/java/net/tfminecraft/games/game/HandTalkTest.java b/src/test/java/net/tfminecraft/games/game/HandTalkTest.java index cd1dcd1..a741e7e 100644 --- a/src/test/java/net/tfminecraft/games/game/HandTalkTest.java +++ b/src/test/java/net/tfminecraft/games/game/HandTalkTest.java @@ -43,6 +43,28 @@ first, hand("cerrith", 9, 10, 11, 12, 13), } } + @Test + void showdownChatIncludesBothPlayersActualHoleCards() { + UUID first = UUID.randomUUID(), second = UUID.randomUUID(); + try (MockedStatic messages = mockStatic(Messages.class); + MockedStatic names = mockStatic(RpNames.class)) { + names.when(() -> RpNames.of(first)).thenReturn("First"); + names.when(() -> RpNames.of(second)).thenReturn("Second"); + messages.when(() -> Messages.getRaw("hand.card")).thenReturn("{rank} of {suit}"); + messages.when(() -> Messages.getRaw("hand.card_join")).thenReturn(", "); + messages.when(() -> Messages.get(eq("hand.shown"), eq("name"), anyString(), eq("cards"), anyString())) + .thenAnswer(call -> call.getArgument(2) + ": " + call.getArgument(4)); + List lines = HandTalk.allHands(null, List.of(first, second), + id -> id.equals(first) ? hand("oseni", 1, 1) : hand("cerrith", 2, 3)); + assertEquals(2, lines.size()); + assertTrue(lines.get(0).startsWith("First:")); + assertTrue(lines.get(0).contains("Ace")); + assertTrue(lines.get(1).startsWith("Second:")); + assertTrue(lines.get(1).contains("2 of")); + assertTrue(lines.get(1).contains("3 of")); + } + } + private List hand(String suit, int... ranks) { List cards = new ArrayList<>(); for (int rank : ranks) cards.add(new Card(suit + rank, suit, rank, false, "")); diff --git a/src/test/java/net/tfminecraft/games/game/PokerGameTest.java b/src/test/java/net/tfminecraft/games/game/PokerGameTest.java index 746b501..98eee51 100644 --- a/src/test/java/net/tfminecraft/games/game/PokerGameTest.java +++ b/src/test/java/net/tfminecraft/games/game/PokerGameTest.java @@ -382,6 +382,8 @@ void labelShowsConfiguredBlindsShuffleAndOnlyTheCurrentBettingActor() { assertTrue(idle.contains("label.shuffle_shoe")); assertFalse(idle.contains("label.turn")); table.setShufflePolicy(ShufflePolicy.ROUND); + table.setSmallBlind(0); + table.setBigBlind(0); start(alice, bob); assertTrue(game.extraLabel(table).contains("label.shuffle_round")); assertTrue(game.extraLabel(table).contains("label.button")); @@ -484,6 +486,7 @@ void shortCallIsRefusedWhileChipsRemainInPocketAndCapsOnlyOnceAllIn() { start(alice, bob, carol); contribute(bob, 10); act("raise"); + ((PlayerMock) carol).nextMessage(); // The table hears Bob raise. assertEquals(carol.getUniqueId(), table.actor()); act("call"); assertEquals(carol.getUniqueId(), table.actor(), "a free call must not pass the turn"); @@ -496,7 +499,7 @@ void shortCallIsRefusedWhileChipsRemainInPocketAndCapsOnlyOnceAllIn() { broke.add(carol.getUniqueId()); act("call"); assertEquals(alice.getUniqueId(), table.actor()); - assertEquals("poker.called", ((PlayerMock) carol).nextMessage()); + assertTrue(((PlayerMock) carol).nextMessage().startsWith("poker.action_called")); } @Test @@ -506,7 +509,7 @@ void onlySeatsStillInALiveHandMayStake() { seat(alice, bob, carol); table.startSession(); game.onSessionStart(table); - assertTrue(game.allowStake(table, alice), "seats may stake while holes are dealt"); + assertFalse(game.allowStake(table, alice), "no stakes while holes are dealt"); assertFalse(game.allowStake(table, stranger), "a stranger cannot buy into a dealt hand"); drain(); assertEquals(bob.getUniqueId(), table.actor()); @@ -523,11 +526,11 @@ void chipsPastTheBetAreARaiseEvenWhenCalledACheckOrCall() { start(alice, bob, carol); contribute(bob, 10); act("check"); - assertEquals("poker.raised[poker.raised, n, 10]", ((PlayerMock) bob).nextMessage()); + assertTrue(((PlayerMock) bob).nextMessage().startsWith("poker.action_raised")); assertEquals(carol.getUniqueId(), table.actor()); contribute(carol, 25); act("call"); - assertEquals("poker.raised[poker.raised, n, 25]", ((PlayerMock) carol).nextMessage()); + assertTrue(((PlayerMock) carol).nextMessage().startsWith("poker.action_raised")); contribute(alice, 25); act("call"); assertEquals(bob.getUniqueId(), table.actor(), "the first bettor has to answer the raise"); @@ -554,7 +557,7 @@ void allInSeatIsSkippedWhileTheOthersKeepRaising() { drain(); assertEquals(bob.getUniqueId(), table.actor()); act("check"); - assertEquals(carol.getUniqueId(), table.actor(), "a new street reopens action for the all-in seat"); + assertEquals(alice.getUniqueId(), table.actor(), "an all-in seat stays capped on later streets"); } @Test diff --git a/src/test/java/net/tfminecraft/games/game/PokerTournamentTest.java b/src/test/java/net/tfminecraft/games/game/PokerTournamentTest.java new file mode 100644 index 0000000..30ed433 --- /dev/null +++ b/src/test/java/net/tfminecraft/games/game/PokerTournamentTest.java @@ -0,0 +1,54 @@ +package net.tfminecraft.games.game; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class PokerTournamentTest { + @Test void chipPotsAndAbortedHandsConserveStacksWithoutCreatingDenars() { + PokerTournament tournament = new PokerTournament(); + UUID alice = UUID.randomUUID(), bob = UUID.randomUUID(); + tournament.configure(10, 100, 1, 2, 5); + tournament.buy(alice); tournament.buy(bob); + tournament.start(1000); + assertEquals(100, tournament.bet(alice, 500)); + assertEquals(30, tournament.bet(bob, 30)); + tournament.nextStreet(); + assertEquals(0, tournament.contribution(alice)); + assertEquals(100, tournament.invested().get(alice)); + tournament.abortHand(); + assertEquals(100, tournament.stack(alice)); + assertEquals(100, tournament.stack(bob)); + assertTrue(tournament.invested().isEmpty()); + } + + @Test void rebuysAreLimitedAndNewEntrantsCloseWhenPlayStarts() { + PokerTournament tournament = new PokerTournament(); + UUID alice = UUID.randomUUID(); + tournament.configure(10, 100, 1, 0, 0); + tournament.buy(alice); + assertFalse(tournament.canBuy(alice)); + tournament.start(1000); + assertFalse(tournament.canBuy(UUID.randomUUID())); + tournament.bet(alice, 100); + tournament.finishHand(); + assertTrue(tournament.canBuy(alice)); + tournament.buy(alice); + tournament.bet(alice, 100); + tournament.finishHand(); + assertFalse(tournament.canBuy(alice)); + assertThrows(IllegalStateException.class, () -> tournament.buy(alice)); + assertThrows(IllegalArgumentException.class, () -> tournament.configure(20, 100, 2, 0, 0)); + } + + @Test void blindLevelsUseElapsedIntervalsAndStayBounded() { + PokerTournament tournament = new PokerTournament(); + tournament.configure(10, 100, 0, 0, 5); + tournament.start(1000); + assertEquals(10, tournament.blind(10, 300999)); + assertEquals(20, tournament.blind(10, 301000)); + assertEquals(40, tournament.blind(10, 601000)); + assertEquals(1_000_000, tournament.blind(10, Long.MAX_VALUE)); + assertEquals(0, tournament.blind(0, Long.MAX_VALUE)); + } +} diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerBlackjackRoundTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerBlackjackRoundTest.java index 1c9a571..5113e05 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerBlackjackRoundTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerBlackjackRoundTest.java @@ -46,6 +46,10 @@ import net.tfminecraft.games.game.GamesRegistry; import net.tfminecraft.games.layout.TableLayout; import net.tfminecraft.games.loader.CardLoader; +import net.tfminecraft.games.wager.ChipItems; +import net.tfminecraft.games.wager.CitizenTax; +import net.tfminecraft.games.wager.WagerItemOverride; +import org.mockito.MockedStatic; /** Blackjack rounds across the real game, table, deck and money implementations. */ class TableManagerBlackjackRoundTest extends TableManagerFixture { @@ -132,6 +136,43 @@ void blackjackExtraStakeAndSettlementConserveRealCardsAndMoney(String action) th assertEquals(24, gold(player) + gold(dealer)); } + @ParameterizedTest + @ValueSource(ints = {10, 1}) + void taxedAutoDealerWinBreaksLargeCoinsAndPaysProfitPlusReturnedStake(int firstRank) throws Exception { + restoreShoe(fullDeck(), List.of("oseni_" + firstRank, "cerrith_10", + firstRank == 1 ? "clubs_10" : "clubs_9", "cerrith_7")); + table.setStaffMint(true); + table.setMaxBet(1000); + rules.onTableReady(table); + Cache.wagerItems.add(new WagerItemOverride("GOLD_BLOCK", 100, null, null, null, null, + null, null, false, null)); + ItemStack pouch = new ItemStack(Material.GOLD_BLOCK); + try (MockedStatic currency = mockStatic(ChipItems.class, CALLS_REAL_METHODS); + MockedStatic tax = mockStatic(CitizenTax.class)) { + // Stand-ins for DenarEconomy's real 100d pouch and 1d change at the external API boundary. + currency.when(() -> ChipItems.isMoneyCoin(argThat(item -> item != null + && item.getType() == Material.GOLD_BLOCK))).thenReturn(true); + currency.when(() -> ChipItems.change(argThat(item -> item != null + && item.getType() == Material.GOLD_BLOCK))) + .thenReturn(List.of(new ItemStack(Material.GOLD_NUGGET, 100))); + int profit = firstRank == 1 ? 150 : 100; + tax.when(() -> CitizenTax.levy(player, profit)).thenReturn(new CitizenTax.Levy(10, 10)); + player.getInventory().setItemInMainHand(pouch); + clickFeltAt(player, 0); + assertEquals(100, manager.ownedDenars(table, player.getUniqueId())); + advanceUntil(() -> BlackjackGame.PLAY.equals(table.phase()) || BlackjackGame.SETTLE.equals(table.phase())); + if (BlackjackGame.PLAY.equals(table.phase())) manager.applyPlayCall(player, "stand"); + advanceUntil(() -> BlackjackGame.SETTLE.equals(table.phase())); + assertEquals(profit - 10, gold(player), "profit less 10d tax must be paid in smaller coins"); + assertEquals(1, player.getInventory().all(Material.GOLD_BLOCK).values().stream() + .mapToInt(ItemStack::getAmount).sum(), "the 100d original stake also comes back"); + assertTrue(messages(player).contains(firstRank == 1 ? "bet.natural" : "bet.win")); + advanceUntil(() -> !table.live()); + assertTrue(table.ledger().isEmpty()); + tax.verify(() -> CitizenTax.tell(player, 10)); + } + } + @Test void aDealerWhoseShoeRunsDryStandsOnWhatTheyHoldAndTheRoundSettles() throws Exception { // Five cards and nothing in the discards: the dealer's second hit finds an empty shoe. diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerPokerOverhaulTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerPokerOverhaulTest.java new file mode 100644 index 0000000..6cda6f5 --- /dev/null +++ b/src/test/java/net/tfminecraft/games/table/TableManagerPokerOverhaulTest.java @@ -0,0 +1,137 @@ +package net.tfminecraft.games.table; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.ArrayList; +import java.util.List; +import org.bukkit.Material; +import org.bukkit.command.Command; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; +import net.tfminecraft.games.cache.Cache; +import net.tfminecraft.games.card.Card; +import net.tfminecraft.games.command.CommandManager; +import net.tfminecraft.games.game.GamesRegistry; +import net.tfminecraft.games.game.PokerGame; +import net.tfminecraft.games.loader.CardLoader; +import net.tfminecraft.games.wager.Accounts; + +class TableManagerPokerOverhaulTest extends TableManagerFixture { + private Table poker() { + List deck = new ArrayList<>(); + for (String suit : List.of("clubs", "hearts", "diamonds", "spades")) { + for (int rank = 1; rank <= 13; rank++) { + Card card = new Card(suit + rank, suit, rank, false, "face"); + deck.add(card); + cards.when(() -> CardLoader.get(card.getId())).thenReturn(card); + } + } + String set = Cache.cardSetOf("poker"); + cards.when(() -> CardLoader.hasSet(set)).thenReturn(true); + cards.when(() -> CardLoader.getSet(set)).thenReturn(deck); + games.when(() -> GamesRegistry.of("poker")).thenReturn(new PokerGame()); + manager.armPlace(player, "poker", false); + assertTrue(manager.tryPlace(player, player.getLocation())); + return manager.tables().iterator().next(); + } + + private void command(org.mockbukkit.mockbukkit.entity.PlayerMock who, String... args) { + who.setOp(true); + Command command = mock(Command.class); + when(command.getName()).thenReturn("games"); + assertTrue(new CommandManager().onCommand(who, command, "games", args)); + } + + @Test void automaticHeadsUpBlindsAndAllInsRunOutTheBoardWithoutMoreTurns() { + Table table = poker(); + var other = opponent(); + stakeCoin(player, table); stakeCoin(other, table); + player.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 19)); + other.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 19)); + table.setSmallBlind(2); table.setBigBlind(4); + manager.beginSession(table); + tick(30); + assertEquals(6, table.ledger().total()); + assertEquals(2, table.ledger().total(player.getUniqueId(), 1)); + assertEquals(4, table.ledger().total(other.getUniqueId(), 1)); + assertEquals(player.getUniqueId(), table.actor(), "the button acts first heads up"); + assertFalse(GamesRegistry.of("poker").allowStake(table, other)); + manager.applyPlayCall(player, "allin"); + assertEquals(20, table.ledger().total(player.getUniqueId())); + assertEquals(0, Accounts.pockets(table, player).available()); + manager.applyPlayCall(other, "allin"); + tick(200); + assertFalse(table.live()); + assertTrue(table.ledger().isEmpty()); + assertEquals(40, Accounts.pockets(table, player).available() + Accounts.pockets(table, other).available()); + } + + @Test void tournamentChipsSettleBetweenHandsWhileDenarsRemainInPrizeLedger() { + Table table = poker(); + var other = opponent(); + command(player, "poker", "configure", "10", "100", "1", "2", "5"); + player.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 10)); + other.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 10)); + command(player, "poker", "buyin"); command(other, "poker", "buyin"); + assertEquals(20, table.ledger().total()); + table.setSmallBlind(5); table.setBigBlind(10); + manager.beginSession(table); tick(30); + assertEquals(93, table.poker().stack(player.getUniqueId())); + assertEquals(88, table.poker().stack(other.getUniqueId())); + manager.applyPlayCall(player, "call"); + assertEquals(88, table.poker().stack(player.getUniqueId())); + manager.applyPlayCall(other, "check"); tick(30); + assertEquals(PokerGame.FLOP, table.phase()); + assertEquals(other.getUniqueId(), table.actor()); + manager.applyPlayCall(other, "fold"); tick(30); + assertFalse(table.live()); + assertEquals(112, table.poker().stack(player.getUniqueId())); + assertEquals(88, table.poker().stack(other.getUniqueId())); + assertEquals(20, table.ledger().total(), "chips must never pay Denars between hands"); + assertEquals(10, table.roundMoney().moneyIn(player.getUniqueId()), "buy-in principal survives hands"); + assertEquals(0, Accounts.pockets(table, player).available()); + assertEquals(0, Accounts.pockets(table, other).available()); + command(player, "poker", "kick", other.getName()); + command(player, "poker", "finish"); tick(30); + assertEquals(20, Accounts.pockets(table, player).available()); + assertTrue(table.ledger().isEmpty()); + assertFalse(table.poker().occupied()); + } + + @Test void shutdownRefundsTournamentEntriesAndKeepsSettingsForNextStart() { + Table table = poker(); + var other = opponent(); + command(player, "poker", "configure", "10", "100", "1", "2", "5"); + player.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 10)); + other.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 10)); + command(player, "poker", "buyin"); command(other, "poker", "buyin"); + manager.beginSession(table); tick(30); + assertTrue(table.live()); + command(player, "poker", "rebuy"); // Buying during play is refused. + assertEquals(20, table.ledger().total()); + manager.despawnWorldAll(); + assertEquals(10, Accounts.pockets(table, player).available()); + assertEquals(10, Accounts.pockets(table, other).available()); + assertTrue(table.ledger().isEmpty()); + assertFalse(table.poker().occupied()); + assertEquals(10, table.poker().buyIn()); + assertEquals(2, table.poker().ante()); + } + + @Test void failedBlindCollectionMovesNothingAndLeavesTheTableIdle() { + Table table = poker(); + var other = opponent(); + stakeCoin(player, table); stakeCoin(other, table); + table.setSmallBlind(2); table.setBigBlind(4); + // One seat has enough, the other has too few. A zero-pocket seat instead goes all in. + player.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 2)); + Cache.wagerGold = new net.tfminecraft.games.wager.WagerItemOverride("GOLD_NUGGET", 5, + null, null, null, null, null, null, false, null); + other.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 2)); + manager.beginSession(table); + assertFalse(table.live()); + assertEquals(2, table.ledger().total()); + assertEquals(2, player.getInventory().getItemInMainHand().getAmount()); + assertEquals(2, other.getInventory().getItemInMainHand().getAmount()); + } +} diff --git a/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java b/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java index 01b77b8..c7d3a2a 100644 --- a/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java +++ b/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java @@ -65,6 +65,32 @@ private ItemStack coin(int count) { return new ItemStack(Material.GOLD_NUGGET, count); } + @Test + void largeCoinChangeRemainsAtomicAndHouseTopupsUseSmallDenominations() { + table.setStaffMint(true); + Cache.wagerItems.add(new WagerItemOverride("GOLD_BLOCK", 100, null, null, null, null, + null, null, false, null)); + ItemStack pouch = new ItemStack(Material.GOLD_BLOCK); + try (MockedStatic currency = mockStatic(ChipItems.class, CALLS_REAL_METHODS)) { + currency.when(() -> ChipItems.change(argThat(item -> item != null + && item.getType() == Material.GOLD_BLOCK))) + .thenReturn(List.of(new ItemStack(Material.GOLD_NUGGET, 100))); + engine.restore(table, table.getId(), pouch, "pouch", 100, 1, 1, null, null); + TxResult refused = engine.begin(table, "refused change") + .move(Accounts.tray(table), Accounts.bank(table), 15).commit(); + assertFalse(refused.ok()); + assertEquals(100, engine.tray(table)); + assertEquals(Material.GOLD_BLOCK, table.ledger().stakes(table.getId()).getFirst().item().getType()); + assertEquals(1, table.ledger().stakes(table.getId()).getFirst().count()); + PayWinResult paid = engine.payWin(table, winner, winner.getUniqueId(), 115, + null, false, pouch, null); + assertEquals(115, paid.moved(), "100d tray plus a 15d mint topup must both pay exactly"); + assertEquals(0, paid.owe()); + assertEquals(115, inventoryCoins(winner)); + assertEquals(0, engine.tray(table)); + } + } + private void restore(UUID owner, int count, int street) { engine.restore(table, owner, coin(1), "gold", 1, count, street, null, null); } From 0896873750d65bc6169eccf9b4c79e7067e1858c Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:49:31 +0200 Subject: [PATCH 2/3] test: count all payout denominations in house topup regression --- src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java b/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java index c7d3a2a..20b48fd 100644 --- a/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java +++ b/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java @@ -86,7 +86,7 @@ void largeCoinChangeRemainsAtomicAndHouseTopupsUseSmallDenominations() { null, false, pouch, null); assertEquals(115, paid.moved(), "100d tray plus a 15d mint topup must both pay exactly"); assertEquals(0, paid.owe()); - assertEquals(115, inventoryCoins(winner)); + assertEquals(115, Accounts.pockets(table, winner).available()); assertEquals(0, engine.tray(table)); } } From 397a0f9a4d20c367ff5a52ba54283b0d3b4044d7 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:02:06 +0200 Subject: [PATCH 3/3] Isolate invalid saved poker settings during table loading --- .../tfminecraft/games/table/TableManager.java | 8 ++++++-- .../table/TableManagerPersistenceTest.java | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/tfminecraft/games/table/TableManager.java b/src/main/java/net/tfminecraft/games/table/TableManager.java index bf941ce..00a8c86 100644 --- a/src/main/java/net/tfminecraft/games/table/TableManager.java +++ b/src/main/java/net/tfminecraft/games/table/TableManager.java @@ -4447,8 +4447,12 @@ private static Table fromData(TableData data, List unowned) { } } applyHouseData(table, data); - table.poker().configure(data.pokerBuyIn, data.pokerChips > 0 ? data.pokerChips : 1000, - data.pokerRebuys, data.pokerAnte, data.pokerBlindMinutes); + try { + table.poker().configure(data.pokerBuyIn, data.pokerChips > 0 ? data.pokerChips : 1000, + data.pokerRebuys, data.pokerAnte, data.pokerBlindMinutes); + } catch (IllegalArgumentException ex) { + throw new JsonParseException("Invalid poker settings for table " + data.id, ex); + } return table; } diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java index b9ed32f..cda3d61 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java @@ -308,6 +308,24 @@ void invalidTableIdentifierIsIsolatedWithoutPreventingOtherTablesLoading() throw verify(Games.plugin.getLogger()).warning(contains("invalid-id.json")); } + @Test + void invalidPokerSettingsAreIsolatedAndRetainedWhileValidTablesLoad() throws Exception { + UUID invalidId = UUID.randomUUID(); + JsonObject invalid = document(invalidId); + invalid.addProperty("pokerRebuys", -1); + write(invalidId + ".json", invalid); + UUID valid = UUID.randomUUID(); + write(valid + ".json", document(valid)); + + assertDoesNotThrow(manager::loadAll); + + assertNull(manager.table(invalidId)); + assertNotNull(manager.table(valid)); + assertEquals(1, manager.tables().size()); + assertEquals(-1, read(invalidId).get("pokerRebuys").getAsInt()); + verify(Games.plugin.getLogger()).warning(contains("Invalid poker settings for table " + invalidId)); + } + @Test void staleSeatListCanContainMissingOrInvalidIdentitiesWithoutLosingValidTable() throws Exception { UUID id = UUID.randomUUID();