From 4997893d251112531d1d9606a448cba5fa151d0e Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:05:05 +0000 Subject: [PATCH 1/3] fix: keep called chips in the pot when a poker or draw player leaves A player who left a Hold'em or Five-Draw hand got their whole bet on the current street back, even after other seats had called it, and even after they had folded. Raising, waiting for the calls, then walking away took the raise back out of a pot the other players had matched. A leaver now gets back only the part of this street's bet that nobody has matched: their stake less the largest stake any other seat has on the street. Called chips stay in the pot for whoever wins it. A folded seat gets nothing back. Before the hand is dealt nothing has been bet against anyone, so an idle table still hands the whole bet back. When the leaver's coins on that street cannot make the uncalled amount exactly, the most they can make goes back and the rest stays in the pot. BucketAccount.onStreet keeps that take on the current street, so coins from earlier streets are never used to make change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../net/tfminecraft/games/game/DrawGame.java | 11 +++++- .../net/tfminecraft/games/game/PokerGame.java | 11 +++++- .../games/wager/BucketAccount.java | 22 ++++++++++- .../tfminecraft/games/wager/WagerEngine.java | 21 +++++++++++ .../tfminecraft/games/game/DrawGameTest.java | 28 ++++++++++++-- .../tfminecraft/games/game/PokerGameTest.java | 24 +++++++++++- .../games/wager/WagerEngineTest.java | 37 +++++++++++++++++++ 7 files changed, 142 insertions(+), 12 deletions(-) diff --git a/src/main/java/net/tfminecraft/games/game/DrawGame.java b/src/main/java/net/tfminecraft/games/game/DrawGame.java index 0772f01..427e3aa 100644 --- a/src/main/java/net/tfminecraft/games/game/DrawGame.java +++ b/src/main/java/net/tfminecraft/games/game/DrawGame.java @@ -269,6 +269,7 @@ public void onLeave(Table table, Player player) { List before = new ArrayList<>(table.actives()); boolean live = table.live(); Street street = streets.get(table.getId()); + boolean folded = street != null && street.folded.contains(leaver); if (street != null) { street.folded.remove(leaver); street.acted.remove(leaver); @@ -286,8 +287,14 @@ public void onLeave(Table table, Player player) { TableManager.get().refreshLabel(table); int streetId = table.street(); List flights = new ArrayList<>(); - // A leaver gets this street's bet back; earlier streets stay in the pot. - WagerEngine.get().refundStreet(table, leaver, streetId, flights, "player left"); + // Earlier streets stay in the pot whatever happens. Before the hand, nothing on the felt has + // been bet against anyone, so it all goes back. Once it is dealt, only the part of this + // street's bet nobody has called goes back, and a folded hand has given up even that. + if (!live) { + WagerEngine.get().refundStreet(table, leaver, streetId, flights, "player left"); + } else if (!folded) { + WagerEngine.get().refundUncalled(table, leaver, streetId, flights, "player left"); + } UUID rest = null; if (table.actives().size() == 1) { rest = table.actives().iterator().next(); diff --git a/src/main/java/net/tfminecraft/games/game/PokerGame.java b/src/main/java/net/tfminecraft/games/game/PokerGame.java index 4d925a6..1f60ac6 100644 --- a/src/main/java/net/tfminecraft/games/game/PokerGame.java +++ b/src/main/java/net/tfminecraft/games/game/PokerGame.java @@ -237,6 +237,7 @@ public void onLeave(Table table, Player player) { List before = new ArrayList<>(table.actives()); boolean live = table.live(); Street street = streets.get(table.getId()); + boolean folded = street != null && street.folded.contains(leaver); if (street != null) { street.folded.remove(leaver); street.acted.remove(leaver); @@ -250,8 +251,14 @@ public void onLeave(Table table, Player player) { TableManager.get().refreshLabel(table); int streetId = table.street(); List flights = new ArrayList<>(); - // A leaver gets this street's bet back; earlier streets stay in the pot. - WagerEngine.get().refundStreet(table, leaver, streetId, flights, "player left"); + // Earlier streets stay in the pot whatever happens. Before the hand, nothing on the felt has + // been bet against anyone, so it all goes back. Once it is dealt, only the part of this + // street's bet nobody has called goes back, and a folded hand has given up even that. + if (!live) { + WagerEngine.get().refundStreet(table, leaver, streetId, flights, "player left"); + } else if (!folded) { + WagerEngine.get().refundUncalled(table, leaver, streetId, flights, "player left"); + } UUID rest = null; if (table.actives().size() == 1) { rest = table.actives().iterator().next(); diff --git a/src/main/java/net/tfminecraft/games/wager/BucketAccount.java b/src/main/java/net/tfminecraft/games/wager/BucketAccount.java index cf8577f..159fc34 100644 --- a/src/main/java/net/tfminecraft/games/wager/BucketAccount.java +++ b/src/main/java/net/tfminecraft/games/wager/BucketAccount.java @@ -19,6 +19,7 @@ public final class BucketAccount implements MoneyAccount { private final UUID owner; private Location anchor; private boolean placed; + private Integer street; BucketAccount(Table table, UUID owner) { this.table = table; @@ -42,6 +43,12 @@ public BucketAccount placedAt(Location where) { return this; } + /** Only what was staked on this betting street can be taken out of here. */ + public BucketAccount onStreet(int street) { + this.street = street; + return this; + } + public UUID owner() { return owner; } @@ -63,7 +70,7 @@ public Withdrawal planTake(int denars, ItemStack template) { } // The ledger refuses empty or worthless stakes and every take tidies what it empties, so // each live stake can be spent. - List usable = new ArrayList<>(table.ledger().liveStakes(owner)); + List usable = spendable(); // Biggest coins first so a take hands over as few pieces as it can, but the search // behind this will still find a combination that only the smaller ones can make. usable.sort((a, b) -> Integer.compare(b.unit(), a.unit())); @@ -103,12 +110,23 @@ public int largestTakeUpTo(int denars, ItemStack template) { return 0; } List slots = new ArrayList<>(); - for (Stake stake : table.ledger().liveStakes(owner)) { + for (Stake stake : spendable()) { slots.add(new CoinPlanner.Slot(stake.unit(), stake.count())); } return CoinPlanner.best(slots, denars); } + /** The live stakes a set amount can come out of, which is all of them unless a street was named. */ + private List spendable() { + List out = new ArrayList<>(); + for (Stake stake : table.ledger().liveStakes(owner)) { + if (street == null || stake.streetId() == street) { + out.add(stake); + } + } + return out; + } + @Override public int accept(List stakes) { if (stakes == null || stakes.isEmpty()) { diff --git a/src/main/java/net/tfminecraft/games/wager/WagerEngine.java b/src/main/java/net/tfminecraft/games/wager/WagerEngine.java index f5f9027..78e832b 100644 --- a/src/main/java/net/tfminecraft/games/wager/WagerEngine.java +++ b/src/main/java/net/tfminecraft/games/wager/WagerEngine.java @@ -317,6 +317,27 @@ public TxResult refundStreet(Table table, UUID owner, int street, List flights, + String reason) { + int called = 0; + for (UUID other : potOwners(table)) { + if (!other.equals(owner)) { + called = Math.max(called, owned(table, other, street)); + } + } + int uncalled = owned(table, owner, street) - called; + return begin(table, reason).animate(flights) + .moveUpTo(Accounts.bucket(table, owner).onStreet(street), + Accounts.payee(table, Accounts.online(owner), owner), uncalled) + .commit(); + } + /** * Put money back on a table as it was saved. Loading is not a transfer of anything, so no * transaction, but it still comes through here so the ledger only has one door. diff --git a/src/test/java/net/tfminecraft/games/game/DrawGameTest.java b/src/test/java/net/tfminecraft/games/game/DrawGameTest.java index 1a5d0c0..0ba0682 100644 --- a/src/test/java/net/tfminecraft/games/game/DrawGameTest.java +++ b/src/test/java/net/tfminecraft/games/game/DrawGameTest.java @@ -138,7 +138,7 @@ void departedDrawersOldCompletionCannotAdvanceAReplacementForTheNextPlayer() { act("draw"); assertEquals(DrawGame.BET, table.phase()); assertEquals(2, table.street()); - verify(wager).refundStreet(eq(table), eq(bob.getUniqueId()), eq(1), anyList(), eq("player left")); + verify(wager).refundUncalled(eq(table), eq(bob.getUniqueId()), eq(1), anyList(), eq("player left")); verify(wager, never()).sweepPot(any(), any(), any(), anyList(), anyString()); } @@ -328,7 +328,27 @@ void tiedHandsShareFoldedContributionWithOddChipLeftOfButton() { } @Test - void leavingDrawRoundRefundsOnlyCurrentStreetAndKeepsOtherPlayersDrawing() { + void leavingBeforeTheHandIsDealtHandsTheWholeBetBack() { + seat(alice, bob, carol); + game.onLeave(table, alice); + verify(wager).refundStreet(eq(table), eq(alice.getUniqueId()), eq(1), anyList(), eq("player left")); + verify(wager, never()).refundUncalled(any(), any(), anyInt(), anyList(), anyString()); + } + + @Test + void aFoldedSeatThatLeavesGetsNothingBackFromThePot() { + start(alice, bob, carol); + Player folder = Bukkit.getPlayer(table.actor()); + act("fold"); + game.onLeave(table, folder); + drain(); + verify(wager, never()).refundStreet(any(), any(), anyInt(), anyList(), anyString()); + verify(wager, never()).refundUncalled(any(), any(), anyInt(), anyList(), anyString()); + assertTrue(table.live(), "two seats are still playing for the pot"); + } + + @Test + void leavingDrawRoundRefundsOnlyTheUncalledBetAndKeepsOtherPlayersDrawing() { start(alice, bob, carol); enterDraw(); game.onLeave(table, bob); @@ -336,7 +356,7 @@ void leavingDrawRoundRefundsOnlyCurrentStreetAndKeepsOtherPlayersDrawing() { assertTrue(table.live()); assertEquals(DrawGame.DRAW, table.phase()); assertNotEquals(bob.getUniqueId(), table.actor()); - verify(wager).refundStreet(eq(table), eq(bob.getUniqueId()), eq(1), anyList(), eq("player left")); + verify(wager).refundUncalled(eq(table), eq(bob.getUniqueId()), eq(1), anyList(), eq("player left")); act("draw"); act("draw"); assertEquals(DrawGame.BET, table.phase()); @@ -578,7 +598,7 @@ void bettingActorLeavingKeepsOutstandingRaiseForRemainingPlayer() { act("call"); assertEquals(DrawGame.DRAW, table.phase()); assertEquals(bob.getUniqueId(), table.actor()); - verify(wager).refundStreet(eq(table), eq(carol.getUniqueId()), eq(1), anyList(), eq("player left")); + verify(wager).refundUncalled(eq(table), eq(carol.getUniqueId()), eq(1), anyList(), eq("player left")); verify(manager, never()).endSession(table); } diff --git a/src/test/java/net/tfminecraft/games/game/PokerGameTest.java b/src/test/java/net/tfminecraft/games/game/PokerGameTest.java index 7f7fea6..c70c773 100644 --- a/src/test/java/net/tfminecraft/games/game/PokerGameTest.java +++ b/src/test/java/net/tfminecraft/games/game/PokerGameTest.java @@ -176,11 +176,31 @@ void checkedHandWithoutMoneyEndsWithoutPayout() { } @Test - void leavingRefundsCurrentStreetAndHandsAbandonedPotToRemainingSeat() { + void leavingBeforeTheHandIsDealtHandsTheWholeBetBack() { + seat(alice, bob, carol); + game.onLeave(table, alice); + verify(wager).refundStreet(eq(table), eq(alice.getUniqueId()), eq(1), anyList(), eq("player left")); + verify(wager, never()).refundUncalled(any(), any(), anyInt(), anyList(), anyString()); + } + + @Test + void aFoldedSeatThatLeavesGetsNothingBackFromThePot() { + start(alice, bob, carol); + Player folder = Bukkit.getPlayer(table.actor()); + act("fold"); + game.onLeave(table, folder); + drain(); + verify(wager, never()).refundStreet(any(), any(), anyInt(), anyList(), anyString()); + verify(wager, never()).refundUncalled(any(), any(), anyInt(), anyList(), anyString()); + assertTrue(table.live(), "two seats are still playing for the pot"); + } + + @Test + void leavingRefundsTheUncalledBetAndHandsAbandonedPotToRemainingSeat() { start(alice, bob); game.onLeave(table, alice); assertEquals(bob.getUniqueId(), table.dealerId()); - verify(wager).refundStreet(eq(table), eq(alice.getUniqueId()), eq(1), anyList(), eq("player left")); + verify(wager).refundUncalled(eq(table), eq(alice.getUniqueId()), eq(1), anyList(), eq("player left")); verify(wager).sweepPot(eq(table), eq(bob), eq(bob.getUniqueId()), anyList(), eq("hand abandoned")); assertTrue(table.live()); drain(); diff --git a/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java b/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java index 6b8df02..5b75973 100644 --- a/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java +++ b/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java @@ -131,6 +131,43 @@ void streetRefundLeavesEarlierBetInPotThenFullRefundConservesAllCoins() { verify(host, times(3)).moneyMoved(eq(table), anyCollection()); } + @Test + void aLeaverGetsBackOnlyThePartOfTheirStreetBetThatNobodyHasCalled() { + UUID leaver = winner.getUniqueId(); + UUID caller = opponent.getUniqueId(); + UUID shortCaller = UUID.randomUUID(); + restore(leaver, 4, 1); + restore(leaver, 10, 2); + restore(caller, 6, 2); + restore(shortCaller, 3, 2); + restore(table.getId(), 20, 2); + assertEquals(4, engine.refundUncalled(table, leaver, 2, null, "player left").moved(), + "six of the ten were called, so only four come back"); + assertEquals(4, inventoryCoins(winner)); + assertEquals(6, engine.owned(table, leaver, 2), "the called chips stay in the pot"); + assertEquals(4, engine.owned(table, leaver, 1), "earlier streets are never refunded"); + assertEquals(20, engine.tray(table), "the house tray is not a caller"); + assertEquals(0, engine.refundUncalled(table, leaver, 2, null, "player left").moved(), + "what is left has all been called"); + assertEquals(0, engine.refundUncalled(table, shortCaller, 2, null, "player left").moved(), + "a call below the bet has nothing uncalled"); + assertEquals(3, engine.owned(table, shortCaller, 2)); + } + + @Test + void anUncalledPartTheStreetsCoinsCannotMakeReturnsTheMostTheyCanFromThatStreetOnly() { + UUID leaver = winner.getUniqueId(); + restore(leaver, 4, 1); + engine.restore(table, leaver, coin(1), "gold", 5, 1, 2, null, null); + restore(leaver, 2, 2); + restore(opponent.getUniqueId(), 3, 2); + // Four are uncalled, but this street holds a five and two ones. Coins from street one could + // make four exactly, and must not be used to. + assertEquals(2, engine.refundUncalled(table, leaver, 2, null, "player left").moved()); + assertEquals(5, engine.owned(table, leaver, 2)); + assertEquals(4, engine.owned(table, leaver, 1)); + } + @Test void cancellationReturnsEveryPlayersOwnStakeWithoutSpendingHouseTray() { bet(winner, 4); From 98ed2a8a0c89f9719a6ee3156c88f676cb039556 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:05:20 +0000 Subject: [PATCH 2/3] fix: stop leavers, table punchers and visitors taking table money Blackjack: leaving a live round refunded the whole stake. A box that had bust, doubled or split could walk six blocks away or log off and get every coin back, and on a staff mint table the house then paid new money. A box that leaves a round in play now forfeits its stake as a loss: into the house tray on a guild or mint table, into a private dealer's pockets on their own table. Its hands leave the round, so the settle does not count them again, and the player is told the bet is lost. Leaving before the deal still refunds the bet. Pickup: any player could pick any table up by hitting it. Mid-hand that handed every bucket back and undid the hand, offline owners' stakes went to the puncher, and guild and staff tables could be deleted by anyone. Only the table's owner, the leader of the guild that owns it, or staff (games.admin or games.autodealer.staff, as for table options) may pick a table up now, and never while a round is live. A stake whose owner is offline is dropped at the table instead of going to the picker, since there is no way to credit an offline player. A table placed without using a deck (/games place) no longer drops one when picked up; this is saved as deckConsumed, and older table files, which cannot tell, keep giving one back. Free play: shift-clicking the shoe paid the whole felt, every bucket included, to whoever clicked. Only the table's owner or the player holding its shoe may do that now, still only between games. A table whose game has been retired follows the same rule. wager.no_flush was never in messages.yml, so the refusal showed its key; it has a message now. Private dealer float: a dealer walking off or logging off left their float in the tray, where the next dealer used it and a pickup dropped it at the table. Walking off, logging off or stepping down from an idle table now returns the float. Mid-round the float stays to cover the bets in play, losing bets go into the tray rather than being dropped on the floor, and the tray goes back to the dealer when the round ends. A dealer who is offline then gets it when they next log in; until then the tray is marked as theirs (floatOwner, saved with the table and kept across restarts) and nobody else can take the shoe. A guild table's tray stays with the guild. Guards removed or refactored: - BlackjackGame.onLeave: the idle peel no longer checks !table.live(). A live table now returns before that line, so the check was always true. - TableManager.clearFeltNow: the fallback payee and its null check are gone. Pickup was the only caller to pass one, and it no longer pays offline owners' stakes to the picker. - The saved ownerPlayer id is read through a playerId helper shared with floatOwner; behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tfminecraft/games/game/BlackjackGame.java | 73 ++++-- .../tfminecraft/games/game/FreePlayGame.java | 6 +- .../java/net/tfminecraft/games/game/Game.java | 3 +- .../tfminecraft/games/guild/GuildTables.java | 6 + .../net/tfminecraft/games/table/Table.java | 29 +++ .../tfminecraft/games/table/TableManager.java | 132 +++++++++-- src/main/resources/messages.yml | 5 + .../games/game/BlackjackGameTest.java | 80 ++++++- .../games/game/FreePlayGameTest.java | 22 +- .../games/guild/GuildTablesTest.java | 11 + .../games/table/TableManagerActionTest.java | 15 ++ .../table/TableManagerBlackjackRoundTest.java | 207 +++++++++++++++++- .../table/TableManagerBoardAnimationTest.java | 3 +- .../table/TableManagerInteractionTest.java | 77 ++++++- .../table/TableManagerLifecycleTest.java | 4 +- .../table/TableManagerPersistenceTest.java | 61 ++++++ .../games/table/TableManagerPileTest.java | 1 + .../table/TableManagerRetiredGameTest.java | 6 + .../games/table/TableManagerSettleTest.java | 44 +++- 19 files changed, 723 insertions(+), 62 deletions(-) diff --git a/src/main/java/net/tfminecraft/games/game/BlackjackGame.java b/src/main/java/net/tfminecraft/games/game/BlackjackGame.java index f64868e..6dc9970 100644 --- a/src/main/java/net/tfminecraft/games/game/BlackjackGame.java +++ b/src/main/java/net/tfminecraft/games/game/BlackjackGame.java @@ -103,6 +103,12 @@ private static Table stillLive(UUID tableId) { /** TableManager only offers the shoe for claiming while the table is idle. */ @Override public boolean tryClaimDealer(Table table, Player player) { + if (table.floatOwner() != null) { + // Only a dealer who is offline still has a float waiting here: anyone online gets theirs + // back when their round ends or they log in. A new dealer would be paying with it. + player.sendMessage(Messages.get("dealer.float_held")); + return true; + } if (auto(table)) { if (!inStandRange(table, player)) { return true; @@ -135,6 +141,7 @@ public boolean tryClaimDealer(Table table, Player player) { } if (have.equals(player.getUniqueId())) { table.setDealerId(null); + TableManager.get().returnDealerFloat(table, player); TableManager.get().persistHouseChange(table); player.sendMessage(Messages.get("dealer.unset")); return true; @@ -361,22 +368,63 @@ public void onChipIn(Table table, Player player) { @Override public void onLeave(Table table, Player player) { + UUID id = player.getUniqueId(); + if (table.live()) { + forfeitBox(table, player); + // A dealer can leave as a seat too, when they had their own chips on the felt. + continueWithout(table, id, id.equals(table.dealerId())); + return; + } TableManager manager = TableManager.get(); int felt = 0; - boolean peel = auto(table) && !table.live() && table.betOpen(); + boolean peel = auto(table) && table.betOpen(); if (peel) { - felt = manager.ownedDenars(table, player.getUniqueId()); + felt = manager.ownedDenars(table, id); } Game.super.onLeave(table, player); if (peel && felt > 0) { peelAutoTray(table, felt); } onChipIn(table, player); - if (table.live()) { - // A dealer can leave as a seat too, when they had their own chips on the felt. - UUID id = player.getUniqueId(); - continueWithout(table, id, id.equals(table.dealerId())); + } + + /** + * A box that walks away or logs off once the round has started loses its bet, doubles and + * splits included, exactly as a bust would. Handing it back would let a player see a losing + * hand and leave with the stake. + */ + private void forfeitBox(Table table, Player player) { + UUID owner = player.getUniqueId(); + List flights = new ArrayList<>(); + collectLoss(table, owner, 0, dealerAtShoe(table), flights, "box forfeited"); + WagerEngine.get().forget(table, owner); + // Its hands are lost with the bet, so the settle does not count them again. A live table + // always holds its round, which onSessionStart put there before any card was dealt. + rounds.get(table.getId()).removeIf(hand -> owner.equals(hand.owner)); + table.actives().remove(owner); + TableManager.get().flushPiles(table, flights, null); + player.sendMessage(Messages.get("bet.forfeit")); + } + + /** + * A losing bet leaves its box: into the house tray on a backed table, and into a private + * dealer's pockets on their own. With that dealer gone from the shoe it goes into the tray they + * stocked, which is handed back to them after the round, rather than onto the floor for anyone + * to pick up. Pass {@code denars} below 1 for the whole box. Returns what left the table. + */ + private static int collectLoss(Table table, UUID owner, int denars, Player dealer, + List flights, String reason) { + if (houseFunded(table) || dealer == null) { + WagerEngine.get().toTray(table, owner, denars, flights, reason); + return 0; } + return WagerEngine.get().refund(table, owner, dealer, denars, flights, reason + " to dealer").moved(); + } + + /** The human dealer, while they hold the shoe and are online. */ + private static Player dealerAtShoe(Table table) { + UUID dealerId = table.dealerId(); + return dealerId != null ? Bukkit.getPlayer(dealerId) : null; } @Override @@ -1136,8 +1184,7 @@ private void settle(Table table) { TableManager manager = TableManager.get(); int dealerTotal = bestTotal(table.tablePile(DEALER)); boolean dealerBust = dealerTotal > 21; - UUID dealerId = table.dealerId(); - Player dealer = dealerId != null ? Bukkit.getPlayer(dealerId) : null; + Player dealer = dealerAtShoe(table); Map collect = new HashMap<>(); Map pay = new HashMap<>(); Map templates = new HashMap<>(); @@ -1170,16 +1217,8 @@ private void settle(Table table) { // Nothing comes into the table during a settle. A dealer or a bank covering a win pays the // winner directly, so that money is never table money for even an instant. int wentOut = 0; - // Losing bets leave the box: a private dealer takes them, the house tray keeps them. for (Map.Entry entry : collect.entrySet()) { - UUID owner = entry.getKey(); - if (dealerBacked) { - wentOut += WagerEngine.get() - .refund(table, owner, dealer, entry.getValue(), flights, "loss to dealer") - .moved(); - } else { - WagerEngine.get().toTray(table, owner, entry.getValue(), flights, "loss"); - } + wentOut += collectLoss(table, entry.getKey(), entry.getValue(), dealer, flights, "loss"); } // A winning box still holds the coins it staked, so they show what to pay it in. for (Map.Entry entry : pay.entrySet()) { diff --git a/src/main/java/net/tfminecraft/games/game/FreePlayGame.java b/src/main/java/net/tfminecraft/games/game/FreePlayGame.java index f08e804..f967726 100644 --- a/src/main/java/net/tfminecraft/games/game/FreePlayGame.java +++ b/src/main/java/net/tfminecraft/games/game/FreePlayGame.java @@ -20,9 +20,13 @@ public boolean allowReturnSelected(Table table, Player player) { return table != null && !table.live(); } + /** + * The felt is paid out whole, other players' stakes included, so only the table's host may + * do it: whoever put the table down, or whoever holds its shoe. + */ @Override public boolean allowManualPotFlush(Table table, Player player) { - return table != null && !table.live(); + return table != null && !table.live() && table.hostedBy(player.getUniqueId()); } @Override diff --git a/src/main/java/net/tfminecraft/games/game/Game.java b/src/main/java/net/tfminecraft/games/game/Game.java index eea189f..83c1925 100644 --- a/src/main/java/net/tfminecraft/games/game/Game.java +++ b/src/main/java/net/tfminecraft/games/game/Game.java @@ -22,7 +22,8 @@ public interface Game { /** * Open table: refund every pile this player owns, then drop them from actives. - * Poker overrides to refund only the current street and pay the last remaining player. + * Poker and draw override to refund only the part of the current street nobody has called + * and pay the last remaining player; blackjack forfeits a box that leaves a round in play. */ default void onLeave(Table table, Player player) { TableManager manager = TableManager.get(); diff --git a/src/main/java/net/tfminecraft/games/guild/GuildTables.java b/src/main/java/net/tfminecraft/games/guild/GuildTables.java index 65ee97f..a89ec54 100644 --- a/src/main/java/net/tfminecraft/games/guild/GuildTables.java +++ b/src/main/java/net/tfminecraft/games/guild/GuildTables.java @@ -191,6 +191,12 @@ public static boolean mayDeal(Table table, Player player) { return table.ownerPlayer() != null && table.ownerPlayer().equals(player.getUniqueId()); } + /** True for the leader of the guild that owns this table, who is the one who may place and remove it. */ + public static boolean leadsOwner(Table table, Player player) { + String guildId = table.ownerGuildId(); + return guildId != null && !guildId.isBlank() && isLeader(guildId, player); + } + private static boolean isMember(String guildId, Player player) { try { Guild guild = guild(guildId); diff --git a/src/main/java/net/tfminecraft/games/table/Table.java b/src/main/java/net/tfminecraft/games/table/Table.java index 041bd11..929895c 100644 --- a/src/main/java/net/tfminecraft/games/table/Table.java +++ b/src/main/java/net/tfminecraft/games/table/Table.java @@ -67,6 +67,8 @@ public final class Table { private ShufflePolicy shufflePolicy = ShufflePolicy.SHOE; private int smallBlind; private int bigBlind; + private UUID floatOwner; + private boolean deckConsumed = true; public Table(UUID id, String gameId, Location origin, float yaw, Deck deck) { this.id = id; @@ -437,6 +439,33 @@ public void setBigBlind(int bigBlind) { this.bigBlind = Math.max(0, bigBlind); } + /** + * The player whose own money sits in the tray of a table no house backs, once they are no + * longer at the shoe to take it back. Null while the tray is the current dealer's, or the + * house's. + */ + public UUID floatOwner() { + return floatOwner; + } + + public void setFloatOwner(UUID floatOwner) { + this.floatOwner = floatOwner; + } + + /** The player who put this table down, or whoever is holding its shoe. */ + public boolean hostedBy(UUID playerId) { + return playerId.equals(ownerPlayer) || playerId.equals(dealerId); + } + + /** Whether putting this table down used up a deck, so picking it up should give one back. */ + public boolean deckConsumed() { + return deckConsumed; + } + + public void setDeckConsumed(boolean deckConsumed) { + this.deckConsumed = deckConsumed; + } + public void clearBetWindow() { betOpen = false; autoCountdown = 0; diff --git a/src/main/java/net/tfminecraft/games/table/TableManager.java b/src/main/java/net/tfminecraft/games/table/TableManager.java index 1e79516..96e7249 100644 --- a/src/main/java/net/tfminecraft/games/table/TableManager.java +++ b/src/main/java/net/tfminecraft/games/table/TableManager.java @@ -45,6 +45,7 @@ import org.bukkit.event.player.AsyncPlayerChatEvent; import org.bukkit.event.player.PlayerInteractAtEntityEvent; import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.player.PlayerSwapHandItemsEvent; import org.bukkit.event.player.PlayerToggleSneakEvent; @@ -334,8 +335,14 @@ private void resetTableToIdle(Table table, Location dropAt) { discardPlayerCards(table, playerId); } despawnTablePiles(table); - settleAutoTray(table, dropAt); - clearFeltNow(table, null); + if (table.floatOwner() == null) { + settleAutoTray(table, dropAt); + } else { + // A dealer who left mid-round is owed this tray, so it waits for them rather than + // being dropped at an empty table on shutdown. + settleAbsentFloat(table); + } + clearFeltNow(table); table.actives().clear(); table.setDealerId(null); table.setStreet(1); @@ -463,6 +470,7 @@ public boolean tryPlace(Player player, Location at) { player.sendMessage(Messages.get("place.spawn_failed")); return true; } + table.setDeckConsumed(arm.requireDeck); tables.put(table.getId(), table); save(table); if (arm.requireDeck) { @@ -554,6 +562,18 @@ public void onQuit(PlayerQuitEvent event) { clearRevealed(event.getPlayer().getUniqueId(), null); } + /** A dealer who logged off mid-round gets back the tray the round left them. */ + @EventHandler + public void onJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + for (Table table : tables.values()) { + // A live round hands the tray back itself when it ends. + if (!table.live() && player.getUniqueId().equals(table.floatOwner())) { + payTray(table, player); + } + } + } + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false) public void onSwapHands(PlayerSwapHandItemsEvent event) { Player player = event.getPlayer(); @@ -654,9 +674,23 @@ public void onHitEntity(EntityDamageByEntityEvent event) { return; } event.setCancelled(true); + if (!mayPickUp(player, table)) { + player.sendMessage(Messages.get("place.pickup_denied")); + return; + } + if (table.live()) { + // Picking up clears the felt, which mid-round would hand every bet back and undo the hand. + player.sendMessage(Messages.get("place.pickup_live")); + return; + } pickup(player, table); } + /** Whoever put the table down, the leader of the guild that owns it, or staff. */ + private boolean mayPickUp(Player player, Table table) { + return canEditHouse(player, table) || GuildTables.leadsOwner(table, player); + } + @EventHandler public void onChunkLoad(ChunkLoadEvent event) { World world = event.getWorld(); @@ -683,7 +717,7 @@ private void pickup(Player player, Table table) { cancelLootArmsForTable(table.getId()); Location dropAt = table.getOrigin().clone(); settleAutoTray(table, dropAt); - clearFeltNow(table, player); + clearFeltNow(table); returnAllHands(table, false, false); table.actives().clear(); table.clearSession(); @@ -696,7 +730,9 @@ private void pickup(Player player, Table table) { despawnWorld(table); tables.remove(table.getId()); deleteFile(table.getId()); - ItemStack deckItem = TLibs.getItemAPI().getCreator().getItemFromPath(CardLoader.getDeckItem()); + // A table staff put down without a deck gives none back, or placing and lifting it would mint decks. + ItemStack deckItem = table.deckConsumed() + ? TLibs.getItemAPI().getCreator().getItemFromPath(CardLoader.getDeckItem()) : null; if (deckItem != null) { dropAt.getWorld().dropItemNaturally(dropAt, deckItem); } @@ -795,6 +831,13 @@ private void clearDealer(Player player) { for (Table table : tables.values()) { if (id.equals(table.dealerId())) { table.setDealerId(null); + if (table.live() && !GuildTables.houseBacked(table)) { + // Their float still covers the bets in play, so it goes back once the round is + // over, together with anything the round wins for them. + table.setFloatOwner(id); + } else { + returnDealerFloat(table, player); + } Game game = gameOf(table); if (game != null) { game.onDealerGone(table); @@ -1704,6 +1747,7 @@ public void endSession(Table table) { checkFeltEmpty(table, "session end"); despawnTablePiles(table); rebuildCardStacks(table); + settleAbsentFloat(table); save(table); recycleIfNeeded(table, null); Game game = gameOf(table); @@ -1821,13 +1865,9 @@ private boolean tryOpenHouseOptions(Table table, Player player) { private static void applyHouseData(Table table, TableData data) { TableLayout layout = Cache.layoutOf(table.getGameId()); - if (data.ownerPlayer != null) { - try { - table.setOwnerPlayer(UUID.fromString(data.ownerPlayer)); - } catch (IllegalArgumentException ignored) { - // keep null - } - } + table.setOwnerPlayer(playerId(data.ownerPlayer)); + table.setFloatOwner(playerId(data.floatOwner)); + table.setDeckConsumed(!Boolean.FALSE.equals(data.deckConsumed)); table.setOwnerGuildId(data.ownerGuildId); table.setHouseFloat(data.houseFloat != null ? data.houseFloat : 0); if (data.autoDealer == null) { @@ -1858,6 +1898,18 @@ private static void applyHouseData(Table table, TableData data) { } } + /** A saved player id, or null when it is missing or unreadable. */ + private static UUID playerId(String raw) { + if (raw == null) { + return null; + } + try { + return UUID.fromString(raw); + } catch (IllegalArgumentException ignored) { + return null; + } + } + private void tryManualFlush(Table table, Player player) { if (!allowManualPotFlush(table, player)) { player.sendMessage(Messages.get("wager.no_flush")); @@ -1981,9 +2033,11 @@ public void applyPlayCall(Player player, String action) { } } + /** A table without a game is plain free play, so it keeps free play's rule. */ private static boolean allowManualPotFlush(Table table, Player player) { Game game = gameOf(table); - return game != null ? game.allowManualPotFlush(table, player) : !table.live(); + return game != null ? game.allowManualPotFlush(table, player) + : !table.live() && table.hostedBy(player.getUniqueId()); } /** Only asked of idle tables, where a table without a game is plain free play. */ @@ -2938,18 +2992,15 @@ private void refundStreet(Table table, Player player, int street) { * Hand every bucket back: players get their own stakes, the tray goes to the guild bank * when the house funded it and to the dealer when a human did. */ - private void clearFeltNow(Table table, Player fallback) { + private void clearFeltNow(Table table) { table.bumpPayoutGen(); table.clearPayoutOnDone(); List owners = wager().potOwners(table); MoneyTx tx = wager().begin(table, "felt cleared"); for (UUID owner : owners) { - Player dest = Bukkit.getPlayer(owner); - if (dest == null) { - // Only a pickup passes a fallback, and the player picking the table up is online. - dest = fallback; - } - tx.moveAll(Accounts.bucket(table, owner), Accounts.payee(table, dest, owner)); + // Nobody else is ever paid a player's stake. With nowhere to credit an offline owner, + // theirs is dropped at the table like any other payout to someone who is gone. + tx.moveAll(Accounts.bucket(table, owner), Accounts.payee(table, Bukkit.getPlayer(owner), owner)); } tx.commit(); for (UUID owner : owners) { @@ -2995,6 +3046,43 @@ private void settleAutoTray(Table table, Location dropAt) { wager().forget(table, house); } + /** + * A private dealer stepping away from an idle table takes their float with them. On a backed + * table the tray is the house's, whoever was dealing, so it stays where it is. + */ + public void returnDealerFloat(Table table, Player dealer) { + if (!GuildTables.houseBacked(table)) { + payTray(table, dealer); + } + } + + /** + * A round that a private dealer walked out of is over: hand them the tray if they are still + * around. If they logged off it waits in the tray, marked as theirs, until they are back. + */ + private void settleAbsentFloat(Table table) { + UUID owner = table.floatOwner(); + if (owner == null) { + return; + } + Player online = Bukkit.getPlayer(owner); + if (online != null) { + payTray(table, online); + } else if (trayDenars(table) < 1) { + table.setFloatOwner(null); + } + } + + /** The whole tray to one player who is online, and nobody owed it any more. */ + private void payTray(Table table, Player to) { + table.setFloatOwner(null); + wager().begin(table, "dealer float returned") + .moveAll(Accounts.tray(table), Accounts.payee(table, to, to.getUniqueId())) + .commit(); + wager().forget(table, table.getId()); + save(table); + } + private boolean trySelectCard(Player player) { if (revealBusy.contains(player.getUniqueId())) { return false; @@ -4289,6 +4377,8 @@ private static TableData toData(Table table) { data.shufflePolicy = table.shufflePolicy().name(); data.smallBlind = table.smallBlind(); data.bigBlind = table.bigBlind(); + data.floatOwner = table.floatOwner() != null ? table.floatOwner().toString() : null; + data.deckConsumed = table.deckConsumed(); return data; } @@ -4552,6 +4642,10 @@ static final class TableData { String shufflePolicy; Integer smallBlind; Integer bigBlind; + /** An absent private dealer the tray is being kept for. Absent on older files. */ + String floatOwner; + /** Whether placing the table used a deck. Absent on older files, which all gave one back. */ + Boolean deckConsumed; } /** One kind of coin held by one bucket. The money half of the old PileData. */ diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 9936f32..8a865ce 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -51,6 +51,8 @@ place: spawn_failed: "#ff5555Could not spawn table displays. The table was not saved." done: "#55ff55Placed {game} table." picked_up: "#55ff55Picked up the table." + pickup_denied: "#ff5555Only the table's owner, its guild leader or staff can pick this table up." + pickup_live: "#ff5555Wait until the round is over before picking the table up." gui_title: "Select a game" gui_poker: "#55ff55Tenceur Hold'em" gui_draw: "#55ff55Five-Draw" @@ -166,6 +168,7 @@ wager: paid: "#55ff55The pot went to {player}." no_bet_zone: "#ff5555Too close to the shoe or house tray." tray_is_funded: "#ff5555The house tray is stocked from the guild bank. Keep your own coins." + no_flush: "#ff5555You cannot pay the felt out by hand here. At a free play table only its owner can, between games." dealer: claimed: "#55ff55You are the dealer at this table." @@ -177,6 +180,7 @@ dealer: denied: "#ff5555You cannot deal at this table." takeover: "#55ff55You took over as dealer." staff_table: "#ff5555This staff table cannot be taken over." + float_held: "#ff5555The tray still holds a dealer's float. It goes back to them when they return." no_flush: "#ff5555You cannot take the pot by hand during this game." need_players: "#ff5555Someone else has to buy in before that item can go on the table." @@ -217,6 +221,7 @@ bet: doubled: "#55ff55Doubled." split: "#55ff55Split." round_end: "#aaaaaaRound ends in {seconds} seconds." + forfeit: "#ff5555You left mid-round, so your bet is lost." label: title: "#ffaa00{name}" diff --git a/src/test/java/net/tfminecraft/games/game/BlackjackGameTest.java b/src/test/java/net/tfminecraft/games/game/BlackjackGameTest.java index 3fb0524..1a501a3 100644 --- a/src/test/java/net/tfminecraft/games/game/BlackjackGameTest.java +++ b/src/test/java/net/tfminecraft/games/game/BlackjackGameTest.java @@ -128,12 +128,14 @@ void setUp() { trayBalance -= amount; return result(true, amount); }); - when(wagers.toTray(eq(table), any(UUID.class), anyInt(), anyList(), eq("loss"))).thenAnswer(call -> { + when(wagers.toTray(eq(table), any(UUID.class), anyInt(), anyList(), anyString())).thenAnswer(call -> { UUID owner = call.getArgument(1); - int amount = call.getArgument(2); - bets.computeIfPresent(owner, (id, held) -> held > amount ? held - amount : null); - trayBalance += amount; - return result(true, amount); + int requested = call.getArgument(2); + int held = bets.getOrDefault(owner, 0); + int moved = requested < 1 ? held : Math.min(held, requested); + bets.computeIfPresent(owner, (id, left) -> left > moved ? left - moved : null); + trayBalance += moved; + return result(true, moved); }); doAnswer(call -> { trayBalance = 0; @@ -530,7 +532,73 @@ void boxesPlayLeftToRightAndLeavingCurrentPlayerAdvancesToNextBox() { game.onLeave(table, left); assertEquals(player.getUniqueId(), table.actor()); assertEquals(List.of(player.getUniqueId()), table.boxes()); - verify(manager).refundOwnedPiles(table, left); + verify(manager, never()).refundOwnedPiles(any(), any()); + verify(wagers).refund(eq(table), eq(left.getUniqueId()), eq(dealer), eq(0), anyList(), + eq("box forfeited to dealer")); + assertFalse(bets.containsKey(left.getUniqueId()), "the private dealer takes the stake as a loss"); + assertFalse(table.actives().contains(left.getUniqueId())); + assertMessage(left, "bet.forfeit"); + game.onBetStand(table, player); + finishAnimations(); + assertEquals(BlackjackGame.SETTLE, table.phase()); + verify(wagers, times(1)).refund(eq(table), eq(left.getUniqueId()), any(), anyInt(), anyList(), anyString()); + assertNull(left.nextMessage(), "a forfeited box is not settled again as a second loss"); + } + + @Test + void aBoxThatLeavesAHouseTableMidRoundLosesItsStakeToTheTray() { + houseBacked(); + PlayerMock other = server.addPlayer(); + seat(other, 10, 1); + start(10, 9, 10, 9, 7, 8); + UUID leaver = table.actor(); + int stake = bets.get(leaver); + game.onLeave(table, Bukkit.getPlayer(leaver)); + verify(wagers).toTray(eq(table), eq(leaver), eq(0), anyList(), eq("box forfeited")); + verify(manager, never()).refundOwnedPiles(any(), any()); + assertEquals(stake, trayBalance, "on a mint or guild table the house keeps it"); + assertFalse(bets.containsKey(leaver)); + } + + @Test + void aBoxLeavingARoundStartedWhileBetsWereOpenDoesNotStartTheBetClock() { + houseBacked(); + table.setDealerId(null); + table.setAutoDealer(true); + PlayerMock other = server.addPlayer(); + seat(other, 10, 1); + game.onTableReady(table); + assertTrue(table.betOpen()); + // Staff can start a round by command while the betting window is still open. + start(10, 9, 10, 9, 7, 8); + Player leaver = Bukkit.getPlayer(table.actor()); + game.onLeave(table, leaver); + // TableManager reports the changed seat to the game straight after every departure. + game.onChipIn(table, leaver); + assertEquals(0, table.autoCountdown(), "no bet window counts down over a round in play"); + server.getScheduler().performTicks(40); + verify(manager, never()).beginSession(table); + } + + @Test + void aPrivateDealersLossesGoToTheirTrayWhileTheyAreAwayFromTheShoe() { + PlayerMock other = server.addPlayer(); + seat(other, 10, 1); + start(10, 9, 10, 9, 7, 8); + UUID leaver = table.actor(); + UUID stayer = leaver.equals(player.getUniqueId()) ? other.getUniqueId() : player.getUniqueId(); + table.setDealerId(null); + game.onDealerGone(table); + int stake = bets.get(leaver); + game.onLeave(table, Bukkit.getPlayer(leaver)); + verify(wagers).toTray(eq(table), eq(leaver), eq(0), anyList(), eq("box forfeited")); + assertEquals(stake, trayBalance); + game.onBetStand(table, Bukkit.getPlayer(stayer)); + finishAnimations(); + assertEquals(BlackjackGame.SETTLE, table.phase()); + verify(wagers).toTray(eq(table), eq(stayer), eq(10), anyList(), eq("loss")); + verify(wagers, never()).refund(any(), any(), isNull(), anyInt(), anyList(), anyString()); + assertEquals(stake + 10, trayBalance, "nothing is dropped where anyone could pick it up"); } @Test diff --git a/src/test/java/net/tfminecraft/games/game/FreePlayGameTest.java b/src/test/java/net/tfminecraft/games/game/FreePlayGameTest.java index 12c1c52..05b4b25 100644 --- a/src/test/java/net/tfminecraft/games/game/FreePlayGameTest.java +++ b/src/test/java/net/tfminecraft/games/game/FreePlayGameTest.java @@ -30,14 +30,30 @@ class FreePlayGameTest { void sandboxActionsAreAvailableOnlyWhileTableIsIdle() { assertFalse(game.allowFreeDraw(null, player)); assertFalse(game.allowReturnSelected(null, player)); - assertFalse(game.allowManualPotFlush(null, player)); assertTrue(game.allowFreeDraw(table, player)); assertTrue(game.allowReturnSelected(table, player)); - assertTrue(game.allowManualPotFlush(table, player)); table.startSession(); assertFalse(game.allowFreeDraw(table, player)); assertFalse(game.allowReturnSelected(table, player)); - assertFalse(game.allowManualPotFlush(table, player)); + } + + @Test + void onlyTheTablesHostMayPayTheWholeFeltOutAndOnlyBetweenGames() { + UUID owner = UUID.randomUUID(); + UUID dealer = UUID.randomUUID(); + Player host = mock(Player.class); + Player shoe = mock(Player.class); + when(host.getUniqueId()).thenReturn(owner); + when(shoe.getUniqueId()).thenReturn(dealer); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + table.setOwnerPlayer(owner); + table.setDealerId(dealer); + assertFalse(game.allowManualPotFlush(null, host)); + assertTrue(game.allowManualPotFlush(table, host), "the owner may pay out their own table"); + assertTrue(game.allowManualPotFlush(table, shoe), "so may whoever holds the shoe"); + assertFalse(game.allowManualPotFlush(table, player), "a visitor may not take everyone's stakes"); + table.startSession(); + assertFalse(game.allowManualPotFlush(table, host), "not even the owner, once a game is under way"); } @Test diff --git a/src/test/java/net/tfminecraft/games/guild/GuildTablesTest.java b/src/test/java/net/tfminecraft/games/guild/GuildTablesTest.java index 0862c31..de3b92e 100644 --- a/src/test/java/net/tfminecraft/games/guild/GuildTablesTest.java +++ b/src/test/java/net/tfminecraft/games/guild/GuildTablesTest.java @@ -233,6 +233,17 @@ void dealingRequiresMembershipPersonalOwnershipOrExplicitStaffPermission() { assertFalse(GuildTables.mayDeal(personal, null)); } + @Test + void onlyTheLeaderOfTheOwningGuildLeadsItsTable() { + assertTrue(GuildTables.leadsOwner(table("guild"), player), "the leader is matched ignoring case"); + when(guild.getLeader()).thenReturn("Bob"); + assertFalse(GuildTables.leadsOwner(table("guild"), player), "a member who does not lead it"); + when(guild.getLeader()).thenReturn("Alice"); + assertFalse(GuildTables.leadsOwner(table(null), player), "a personal table has no guild to lead"); + assertFalse(GuildTables.leadsOwner(table(" "), player)); + assertFalse(GuildTables.leadsOwner(table("deleted"), player)); + } + @Test void blankSavedGuildFallsBackToPersonalOwnershipAndDeletedGuildHasNoMembers() { Table blank = table(" "); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerActionTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerActionTest.java index 5925137..a6e431d 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerActionTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerActionTest.java @@ -116,6 +116,21 @@ void selectingGameWithoutSurfaceArmsTheNextPlacement(boolean requireDeck) { assertEquals(0, Accounts.coins(table, other).available()); } + @Test void aVisitorCannotFlushSomeoneElsesFreeplayFeltToThemselves() { + games.when(() -> GamesRegistry.of("freeplay")).thenReturn(new FreePlayGame()); + Table table = place(false); + PlayerMock visitor = opponent(); + stakeCoin(player, table); + stakeCoin(visitor, table); + visitor.setSneaking(true); + clearMessages(visitor); + shoe(table, visitor); + assertEquals("wager.no_flush", visitor.nextMessage()); + assertEquals(1, table.ledger().total(player.getUniqueId()), "the owner's stake stays on the felt"); + assertEquals(1, table.ledger().total(visitor.getUniqueId())); + assertEquals(0, Accounts.coins(table, visitor).available()); + } + @Test void liveShoeReturnsSelectedCardAndReportsExactCountToGame() { Table table = place(false); Map poses = new HashMap<>(); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerBlackjackRoundTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerBlackjackRoundTest.java index b269666..f971369 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerBlackjackRoundTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerBlackjackRoundTest.java @@ -22,6 +22,7 @@ import org.bukkit.event.entity.EntityDamageByEntityEvent; import org.bukkit.event.player.PlayerInteractAtEntityEvent; import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; @@ -177,7 +178,8 @@ void aTableCanBePickedUpAfterABoxOwnerQuitMidRound() throws Exception { manager.onQuit(new PlayerQuitEvent(leaver, "quit")); leaver.disconnect(); - assertEquals(4, gold(leaver), "a box that quits takes its stake with it"); + assertEquals(2, gold(leaver), "a box that quits mid-round loses its stake, as a bust would"); + assertEquals(22, gold(dealer), "to the dealer who was covering it"); assertEquals(stayer.getUniqueId(), table.actor()); manager.applyPlayCall(stayer, "stand"); advanceUntil(() -> BlackjackGame.SETTLE.equals(table.phase())); @@ -200,7 +202,7 @@ void aTableCanBePickedUpAfterABoxOwnerQuitMidRound() throws Exception { assertTrue(messages(dealer).contains("place.picked_up")); assertEquals(1, world.getEntitiesByClass(Item.class).stream() .filter(drop -> drop.getItemStack().getType() == Material.PAPER).count(), "the deck comes back"); - assertEquals(18, gold(dealer)); + assertEquals(20, gold(dealer), "the forfeited stake paid the winner"); table = null; } @@ -232,9 +234,9 @@ void aBoxThatWalksAwayWhileItsCardTurnsOverLeavesNoHandBehind() throws Exception advanceUntil(() -> !table.live()); manager.stopClock(); assertTrue(table.getHands().isEmpty()); - assertEquals(4, gold(walker), "walking away takes the stake back"); + assertEquals(2, gold(walker), "walking away mid-round loses the stake"); assertEquals(2, gold(stayer), "eleven loses to seventeen"); - assertEquals(22, gold(dealer)); + assertEquals(24, gold(dealer)); assertEquals(52, table.getDeck().remaining() + table.getDeck().discarded()); } @@ -263,9 +265,9 @@ void theRoundCarriesOnWhenABoxWalksAwayWhileItsHitIsInTheAir() throws Exception advanceUntil(() -> !table.live()); manager.stopClock(); assertTrue(table.getHands().isEmpty()); - assertEquals(4, gold(walker), "walking away takes the stake back"); + assertEquals(2, gold(walker), "walking away mid-round loses the stake"); assertEquals(2, gold(stayer), "eleven loses to seventeen"); - assertEquals(22, gold(dealer)); + assertEquals(24, gold(dealer)); assertEquals(52, table.getDeck().remaining() + table.getDeck().discarded(), "the card in the air is not lost"); } @@ -296,12 +298,195 @@ void theDealCarriesOnWithoutABoxThatWalksAwayWhileItsFirstCardIsInTheAir() throw manager.applyPlayCall(stayer, "stand"); advanceUntil(() -> !table.live()); manager.stopClock(); - assertEquals(4, gold(walker)); + assertEquals(2, gold(walker), "a box that walks away during the deal has still bet"); assertEquals(6, gold(stayer), "nineteen beats seventeen"); - assertEquals(18, gold(dealer)); + assertEquals(20, gold(dealer)); assertEquals(52, table.getDeck().remaining() + table.getDeck().discarded()); } + @Test + void neitherABoxNorTheOwnerCanPickTheTableUpToUndoAHandInPlay() throws Exception { + restoreShoe(fullDeck(), List.of("oseni_10", "cerrith_6", "clubs_10", "oseni_8")); + openBets(); + player.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 4)); + stake(player, 2, 0); + dealRound(); + assertEquals(player.getUniqueId(), table.actor()); + messages(player); + messages(dealer); + hitTable(player); + assertTrue(messages(player).contains("place.pickup_denied"), "a losing box cannot punch the table away"); + hitTable(dealer); + assertTrue(messages(dealer).contains("place.pickup_live"), "the owner waits for the round to end"); + assertSame(table, manager.table(table.getId())); + assertEquals(2, manager.ownedDenars(table, player.getUniqueId()), "the bet is still in play"); + assertEquals(2, gold(player)); + } + + @Test + void aDealerWhoWalksAwayFromAnIdleTableTakesTheirFloatWithThem() throws Exception { + restoreShoe(fullDeck(), List.of()); + clickShoe(dealer); + stockTray(3); + manager.startClock(); + dealer.teleport(table.getOrigin().clone().add(40, 0, 0)); + advanceUntil(() -> table.dealerId() == null); + manager.stopClock(); + assertEquals(20, gold(dealer), "the float comes back rather than waiting for the next dealer"); + assertEquals(0, manager.trayDenars(table)); + } + + @Test + void aDealerWhoStepsDownTakesTheirFloatWithThem() throws Exception { + restoreShoe(fullDeck(), List.of()); + clickShoe(dealer); + stockTray(3); + clickShoe(dealer); + assertNull(table.dealerId()); + assertTrue(messages(dealer).contains("dealer.unset")); + assertEquals(20, gold(dealer)); + assertEquals(0, manager.trayDenars(table)); + } + + @Test + void aDealerWhoWalksOffMidRoundStillCoversItAndGetsTheTrayBackOnceItEnds() throws Exception { + PlayerMock other = playFloatRound(); + manager.startClock(); + dealer.teleport(table.getOrigin().clone().add(40, 0, 0)); + advanceUntil(() -> table.dealerId() == null); + assertTrue(table.live(), "the round carries on without its dealer"); + assertEquals(16, gold(dealer), "the float stays to cover the bets in play"); + finishFloatRound(other); + manager.stopClock(); + assertEquals(20, gold(dealer), "the float less the win, plus the loss, goes back to the dealer"); + assertEquals(0, manager.trayDenars(table)); + assertNull(table.floatOwner()); + assertEquals(0, droppedGold(), "nothing is left on the floor for anyone to pick up"); + } + + @Test + void aDealerWhoLogsOffMidRoundHasTheirTrayKeptUntilTheyAreBack() throws Exception { + PlayerMock other = playFloatRound(); + manager.onQuit(new PlayerQuitEvent(dealer, "quit")); + dealer.disconnect(); + assertNull(table.dealerId()); + finishFloatRound(other); + assertEquals(4, manager.trayDenars(table), "the float less the win, plus the loss, waits in the tray"); + assertEquals(dealer.getUniqueId(), table.floatOwner()); + assertEquals(0, droppedGold(), "a losing bet is never dropped where anyone could take it"); + player.addAttachment(Games.plugin, "games.admin", true); + messages(player); + clickShoe(player); + assertNull(table.dealerId(), "nobody else can deal with an absent dealer's float"); + assertTrue(messages(player).contains("dealer.float_held")); + assertEquals(16, gold(dealer)); + dealer.reconnect(); + manager.onJoin(new PlayerJoinEvent(dealer, "join")); + assertEquals(20, gold(dealer)); + assertEquals(0, manager.trayDenars(table)); + assertNull(table.floatOwner()); + clickShoe(player); + assertEquals(player.getUniqueId(), table.dealerId(), "the table is free to deal again"); + } + + @Test + void aDealerWhoLogsBackInMidRoundGetsTheTrayOnlyOnceTheRoundIsOver() throws Exception { + PlayerMock other = playFloatRound(); + manager.onQuit(new PlayerQuitEvent(dealer, "quit")); + dealer.disconnect(); + dealer.reconnect(); + manager.onJoin(new PlayerJoinEvent(dealer, "join")); + assertEquals(4, manager.trayDenars(table), "the float still covers the round in play"); + assertEquals(16, gold(dealer)); + finishFloatRound(other); + assertEquals(20, gold(dealer)); + assertEquals(0, manager.trayDenars(table)); + assertNull(table.floatOwner()); + } + + @Test + void aBoxThatWalksAwayBeforeTheDealTakesItsStakeBack() throws Exception { + restoreShoe(fullDeck(), List.of()); + openBets(); + player.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 4)); + stake(player, 2, 0); + manager.startClock(); + player.teleport(table.getOrigin().clone().add(40, 0, 0)); + advanceUntil(() -> manager.ownedDenars(table, player.getUniqueId()) == 0); + manager.stopClock(); + assertFalse(table.live()); + assertEquals(4, gold(player), "nothing has been dealt, so nothing has been bet yet"); + assertEquals(20, gold(dealer)); + } + + @Test + void aDealerWithNoFloatWhoLogsOffMidRoundLeavesTheTableFreeToDeal() throws Exception { + restoreShoe(fullDeck(), List.of("oseni_10", "clubs_10", "oseni_9", "clubs_8")); + openBets(); + player.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 4)); + stake(player, 2, 0); + dealRound(); + manager.onQuit(new PlayerQuitEvent(dealer, "quit")); + dealer.disconnect(); + manager.applyPlayCall(player, "stand"); + advanceUntil(() -> !table.live()); + assertEquals(4, gold(player), "nineteen beats eighteen, but the dealer left nothing to pay the win with"); + assertNull(table.floatOwner(), "an empty tray holds nothing for anyone"); + player.addAttachment(Games.plugin, "games.admin", true); + clickShoe(player); + assertEquals(player.getUniqueId(), table.dealerId()); + } + + /** + * The dealer stocks four coins, two boxes stake two each and the round is dealt: the first box + * holds nineteen, the second sixteen, and the dealer eighteen. Returns the second player. + */ + private PlayerMock playFloatRound() throws Exception { + restoreShoe(fullDeck(), List.of("oseni_10", "cerrith_10", "clubs_10", "oseni_9", "cerrith_6", "clubs_8")); + PlayerMock other = opponent(); + openBets(); + stockTray(4); + player.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 4)); + other.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 4)); + stake(player, 2, 0.3); + stake(other, 2, -0.3); + dealRound(); + assertEquals(16, gold(dealer)); + return other; + } + + /** Both boxes stand. The first box wins out of the float, the second loses into the tray. */ + private void finishFloatRound(PlayerMock other) { + PlayerMock first = Bukkit.getPlayer(table.boxes().getFirst()) == player ? player : other; + PlayerMock second = first == player ? other : player; + manager.applyPlayCall(first, "stand"); + manager.applyPlayCall(second, "stand"); + advanceUntil(() -> !table.live()); + assertEquals(6, gold(first), "nineteen beats eighteen, paid out of the float"); + assertEquals(2, gold(second), "sixteen loses to eighteen"); + } + + /** The dealer puts {@code coins} of their own into the tray, one click each. */ + private void stockTray(int coins) { + int before = gold(dealer); + for (int i = 0; i < coins; i++) clickAt(dealer, 0, 2); + assertEquals(coins, manager.trayDenars(table)); + assertEquals(before - coins, gold(dealer)); + } + + private void hitTable(PlayerMock by) { + EntityDamageByEntityEvent hit = mock(EntityDamageByEntityEvent.class); + when(hit.getEntity()).thenReturn(shoe); + when(hit.getDamager()).thenReturn(by); + manager.onHitEntity(hit); + verify(hit).setCancelled(true); + } + + private int droppedGold() { + return world.getEntitiesByClass(Item.class).stream().map(Item::getItemStack) + .filter(item -> item.getType() == Material.GOLD_NUGGET).mapToInt(ItemStack::getAmount).sum(); + } + private List fullDeck() { List deck = new ArrayList<>(); for (String suit : List.of("oseni", "cerrith", "clubs", "hearts")) { @@ -384,9 +569,13 @@ private void clickShoe(PlayerMock actor) { } private void clickFeltAt(PlayerMock actor, double side) { + clickAt(actor, 0.75, side); + } + + private void clickAt(PlayerMock actor, double x, double z) { // Supply the block-ray result at the input boundary; game state and transfers remain real. Player clicker = mock(Player.class, org.mockito.AdditionalAnswers.delegatesTo(actor)); - Location hit = table.getOrigin().clone().add(0.75, 0, side); + Location hit = table.getOrigin().clone().add(x, 0, z); doReturn(new RayTraceResult(hit.toVector())).when(clicker).rayTraceBlocks(anyDouble()); PlayerInteractEvent click = new PlayerInteractEvent(clicker, Action.RIGHT_CLICK_BLOCK, actor.getInventory().getItemInMainHand(), world.getBlockAt(0, 64, 0), diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerBoardAnimationTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerBoardAnimationTest.java index 28d20b3..a9e2107 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerBoardAnimationTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerBoardAnimationTest.java @@ -71,7 +71,8 @@ class TableManagerBoardAnimationTest extends TableManagerFixture { @ParameterizedTest @ValueSource(ints = {0, 2}) void pickupBeforeOrDuringFlightRemovesEveryDisplayAndPreventsLateArrival(int elapsedTicks) { - Table table = place(false); + player.getInventory().setItemInMainHand(new ItemStack(Material.PAPER)); + Table table = place(true); Runnable cancelled = mock(Runnable.class); manager.dealToTable(table, "board", 1, true, cancelled); tick(elapsedTicks); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerInteractionTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerInteractionTest.java index 5e76c04..02c273f 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerInteractionTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerInteractionTest.java @@ -21,9 +21,17 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.util.BoundingBox; import org.junit.jupiter.api.Test; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.mockito.MockedStatic; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.games.Games; import net.tfminecraft.games.cache.Cache; import net.tfminecraft.games.display.WorldAnchors; import net.tfminecraft.games.gui.GameSelectGui; +import net.tfminecraft.games.wager.Accounts; class TableManagerInteractionTest extends TableManagerFixture { @Test void armedPlacementUsesTheSurfaceTopAndRetainsArmAfterRefusedSideClick() { @@ -69,7 +77,8 @@ class TableManagerInteractionTest extends TableManagerFixture { } @Test void pickupRemovesSavedTableAndDisplaysAndReturnsThePhysicalDeck() throws Exception { - Table table = place(false); + player.getInventory().setItemInMainHand(new ItemStack(Material.PAPER)); + Table table = place(true); manager.dealToPlayer(table, player, 1); tick(3); List handTokens = table.handOf(player.getUniqueId()).stream().map(HandCard::tokenId).toList(); var file = data.resolve("Data/tables/" + table.getId() + ".json"); @@ -91,6 +100,72 @@ class TableManagerInteractionTest extends TableManagerFixture { assertEquals(new ItemStack(Material.PAPER), drops.getFirst().getItemStack()); } + @Test void aTableStaffPlacedWithoutADeckGivesNoDeckBack() { + Table table = place(false); + hitTable(table, player); + assertNull(manager.table(table.getId())); + assertTrue(world.getEntities().stream().noneMatch(Item.class::isInstance), + "no deck was used, so none comes back"); + } + + @Test void onlyTheOwnerOrStaffMayPickUpATableAndNeverMidRound() { + Table table = place(false); + PlayerMock visitor = opponent(); + stakeCoin(visitor, table); + hitTable(table, visitor); + assertSame(table, manager.table(table.getId()), "a visitor cannot take the table away"); + assertEquals("place.pickup_denied", visitor.nextMessage()); + assertEquals(1, manager.ownedDenars(table, visitor.getUniqueId()), "nor clear the felt"); + manager.beginSession(table); + while (player.nextMessage() != null) { } + hitTable(table, player); + assertSame(table, manager.table(table.getId()), "the owner waits for the round to end"); + assertEquals("place.pickup_live", player.nextMessage()); + assertEquals(1, manager.ownedDenars(table, visitor.getUniqueId()), "and the hand is not undone"); + manager.endSession(table); + PlayerMock staff = opponent(); + staff.addAttachment(Games.plugin, "games.admin", true); + hitTable(table, staff); + assertNull(manager.table(table.getId())); + assertEquals("place.picked_up", staff.nextMessage()); + assertEquals(1, Accounts.coins(table, visitor).available(), "the visitor's stake goes back to them"); + assertEquals(0, Accounts.coins(table, staff).available()); + } + + @Test void theLeaderOfTheGuildThatOwnsATableMayPickItUpButItsMembersMayNot() { + Table table = place(false); + table.setOwnerGuildId("guild"); + PlayerMock leader = opponent(); + PlayerMock member = opponent(); + PluginManager plugins = mock(PluginManager.class); + Plugin factions = mock(Plugin.class); + when(factions.isEnabled()).thenReturn(true); + when(plugins.getPlugin("SimpleFactions")).thenReturn(factions); + Guild guild = mock(Guild.class); + when(guild.getLeader()).thenReturn(leader.getName()); + try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS); + MockedStatic registry = mockStatic(FactionManager.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + registry.when(() -> FactionManager.getGuildByString("guild")).thenReturn(guild); + hitTable(table, member); + assertSame(table, manager.table(table.getId())); + assertEquals("place.pickup_denied", member.nextMessage()); + hitTable(table, leader); + } + assertNull(manager.table(table.getId())); + assertEquals("place.picked_up", leader.nextMessage()); + } + + private void hitTable(Table table, PlayerMock by) { + Entity anchor = mock(Entity.class); + anchors.when(() -> WorldAnchors.tableId(anchor)).thenReturn(table.getId().toString()); + EntityDamageByEntityEvent event = mock(EntityDamageByEntityEvent.class); + when(event.getEntity()).thenReturn(anchor); + when(event.getDamager()).thenReturn(by); + manager.onHitEntity(event); + verify(event).setCancelled(true); + } + @Test void unrelatedEntityDamageDoesNotPickUpATable() { Table table = place(false); Entity anchor = mock(Entity.class); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerLifecycleTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerLifecycleTest.java index 2099a48..c5ce830 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerLifecycleTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerLifecycleTest.java @@ -237,7 +237,9 @@ class TableManagerLifecycleTest extends TableManagerFixture { @Test void pickingUpARetiredGameTableWithoutADeckItemStillRemovesItAndRefunds() throws Exception { UUID id = UUID.randomUUID(); - write(id, document(id, "retired")); + JsonObject owned = document(id, "retired"); + owned.addProperty("ownerPlayer", player.getUniqueId().toString()); + write(id, owned); manager.loadAll(); Table table = manager.table(id); WagerEngine.get().restore(table, player.getUniqueId(), new ItemStack(Material.GOLD_NUGGET), diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java index 605e72f..b9ed32f 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerPersistenceTest.java @@ -16,6 +16,7 @@ import org.bukkit.entity.Entity; import org.bukkit.entity.Item; import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.PluginManager; @@ -26,6 +27,7 @@ import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.CsvSource; +import org.mockbukkit.mockbukkit.entity.PlayerMock; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonObject; @@ -536,6 +538,65 @@ void modernOrphanRecoveryDoesNotReviveRetainedLegacyMoney() throws Exception { assertEquals(3, droppedGold()); } + @Test + void anAbsentDealersTraySurvivesARestartAndGoesBackWhenTheyLogIn() throws Exception { + PlayerMock gone = opponent(); + gone.disconnect(); + UUID id = UUID.randomUUID(); + JsonObject saved = document(id); + saved.getAsJsonArray("ledger").add(stake(id, 4)); + saved.addProperty("floatOwner", gone.getUniqueId().toString()); + write(id + ".json", saved); + manager.loadAll(); + Table loaded = manager.table(id); + assertNotNull(loaded); + assertEquals(4, manager.trayDenars(loaded), "the tray is kept for the dealer it belongs to"); + assertEquals(0, droppedGold(), "rather than dropped at an empty table"); + assertEquals(gone.getUniqueId(), loaded.floatOwner()); + assertEquals(gone.getUniqueId().toString(), read(id).get("floatOwner").getAsString()); + gone.reconnect(); + manager.onJoin(new PlayerJoinEvent(gone, "join")); + assertEquals(4, gone.getInventory().all(Material.GOLD_NUGGET).values().stream() + .mapToInt(ItemStack::getAmount).sum()); + assertEquals(0, manager.trayDenars(loaded)); + assertNull(loaded.floatOwner()); + assertFalse(read(id).has("floatOwner")); + manager.onJoin(new PlayerJoinEvent(player, "join")); + assertEquals(0, inventoryGold(), "logging in pays nobody else"); + } + + @ParameterizedTest + @NullSource + @ValueSource(booleans = {true, false}) + void aSavedTableGivesADeckBackOnlyIfPlacingItUsedOne(Boolean consumed) throws Exception { + UUID id = UUID.randomUUID(); + JsonObject saved = document(id); + saved.addProperty("ownerPlayer", player.getUniqueId().toString()); + if (consumed != null) saved.addProperty("deckConsumed", consumed); + write(id + ".json", saved); + manager.loadAll(); + Table loaded = manager.table(id); + assertEquals(!Boolean.FALSE.equals(consumed), loaded.deckConsumed(), + "older files, written before this was recorded, always gave a deck back"); + Entity anchor = mock(Entity.class); + anchors.when(() -> WorldAnchors.tableId(anchor)).thenReturn(id.toString()); + EntityDamageByEntityEvent pickup = mock(EntityDamageByEntityEvent.class); + when(pickup.getEntity()).thenReturn(anchor); + when(pickup.getDamager()).thenReturn(player); + manager.onHitEntity(pickup); + assertNull(manager.table(id)); + assertEquals(Boolean.FALSE.equals(consumed) ? 0 : 1, world.getEntitiesByClass(Item.class).stream() + .filter(drop -> drop.getItemStack().getType() == Material.PAPER).count()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void placingATableRecordsWhetherItUsedADeck(boolean requireDeck) throws Exception { + player.getInventory().setItemInMainHand(new ItemStack(Material.PAPER)); + Table table = place(requireDeck); + assertEquals(requireDeck, read(table.getId()).get("deckConsumed").getAsBoolean()); + } + private JsonObject document(UUID id) { JsonObject document = new JsonObject(); document.addProperty("id", id.toString()); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerPileTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerPileTest.java index dd42750..e07e603 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerPileTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerPileTest.java @@ -346,6 +346,7 @@ private Table load(String gameId, ShufflePolicy policy, List remaining, saved.addProperty("y", 65); saved.addProperty("setName", Cache.pokerCardSet); saved.addProperty("shufflePolicy", policy.name()); + saved.addProperty("ownerPlayer", player.getUniqueId().toString()); saved.add("remaining", json.toJsonTree(remaining)); saved.add("discarded", json.toJsonTree(discarded)); var folder = data.resolve("Data/tables"); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerRetiredGameTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerRetiredGameTest.java index 6c26a36..313c814 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerRetiredGameTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerRetiredGameTest.java @@ -39,6 +39,12 @@ class TableManagerRetiredGameTest extends TableManagerFixture { stakeCoin(other, table); assertFalse(table.live(), "without a game nothing decides when a round begins"); drain(player); + drain(other); + other.setSneaking(true); + shoe(table, other); + assertEquals("wager.no_flush", other.nextMessage(), "only the table's host pays the felt out"); + assertEquals(2, table.ledger().total()); + assertEquals(0, Accounts.coins(table, other).available()); player.setSneaking(true); shoe(table, player); assertTrue(table.ledger().isEmpty()); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerSettleTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerSettleTest.java index 3b41fc2..5df83e3 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerSettleTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerSettleTest.java @@ -24,6 +24,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.MockedStatic; import org.mockbukkit.mockbukkit.entity.PlayerMock; import net.tfminecraft.games.cache.Cache; @@ -31,6 +33,7 @@ import net.tfminecraft.games.layout.TableLayout; import net.tfminecraft.games.wager.Accounts; import net.tfminecraft.games.wager.WagerEngine; +import net.tfminecraft.simplefactions.enums.GuildModifier; import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.objects.Bank; @@ -87,7 +90,7 @@ private void pickUp(Table table, Player by) { assertEquals(5, table.houseFloat(), "nothing reached the bank, so the float is still owed"); } - @Test void pickingUpATableHandsAnAbsentPlayersStakeToThePlayerPickingItUp() { + @Test void pickingUpATableNeverHandsAnAbsentPlayersStakeToThePlayerPickingItUp() { // Like poker, this game keeps a leaver's chips in the pot but gives up their seat. doAnswer(call -> ((Table) call.getArgument(0)).actives().remove(((Player) call.getArgument(1)).getUniqueId())) .when(game).onLeave(any(), any()); @@ -99,11 +102,46 @@ private void pickUp(Table table, Player by) { assertEquals(1, manager.ownedDenars(table, gone.getUniqueId()), "the leaver's chips stayed in the pot"); pickUp(table, player); assertNull(manager.table(table.getId())); - assertEquals(1, Accounts.coins(table, player).available(), "rather than scattered on the floor"); - assertEquals(0, dropped()); + assertEquals(0, Accounts.coins(table, player).available(), "someone else's stake is not the picker's"); + assertEquals(1, dropped(), "with nowhere to credit an offline owner it is dropped at the table"); assertTrue(table.ledger().isEmpty()); } + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void aGuildTablesDealerWhoLeavesNeverTakesTheGuildsFloatWithThem(boolean midRound) { + Table table = place(false); + table.setOwnerGuildId("guild"); + PlayerMock member = opponent(); + table.setDealerId(member.getUniqueId()); + WagerEngine.get().restore(table, table.getId(), new ItemStack(Material.GOLD_NUGGET), "gold", 1, 5, + table.street(), null, null); + PluginManager plugins = mock(PluginManager.class); + Plugin factions = mock(Plugin.class); + when(factions.isEnabled()).thenReturn(true); + when(plugins.getPlugin("SimpleFactions")).thenReturn(factions); + Guild guild = mock(Guild.class); + when(guild.getModifier(GuildModifier.AUTO_DEALER_TABLES)).thenReturn(2.0); + try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS); + MockedStatic registry = mockStatic(FactionManager.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + registry.when(() -> FactionManager.getGuildByString("guild")).thenReturn(guild); + if (midRound) { + manager.beginSession(table); + assertTrue(table.live()); + } + manager.onQuit(new PlayerQuitEvent(member, "quit")); + assertNull(table.dealerId()); + if (midRound) { + manager.endSession(table); + } + } + assertEquals(5, manager.trayDenars(table), "the tray is the guild's, whoever was dealing"); + assertNull(table.floatOwner(), "and nobody is owed it"); + assertEquals(0, Accounts.coins(table, member).available()); + assertEquals(0, dropped()); + } + @Test void aDealerClickingTheFeltRatherThanTheTrayPlacesAnOrdinaryBet() { Cache.tableLayouts.put("freeplay", new TableLayout(Cache.pokerCardSet, "Cards", "icon", 6, Map.of("tray", new TableLayout.PileSlot(0, 0.75)), new TableLayout.FeltRing(0.3, 1.2), From 612c6a17d34d72bfea5bf04306e38881ed6ab4a0 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:18:06 +0000 Subject: [PATCH 3/3] fix: keep an absent dealer's tray when their table is picked up A private dealer who logs off mid-round has their tray kept for them, but the table's owner or staff could still pick the table up. Pickup deletes the table, so the tray was dropped at the table, at the picker's feet. Picking up is now refused with dealer.float_held until the dealer is back and has had the tray returned. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/main/java/net/tfminecraft/games/table/TableManager.java | 5 +++++ .../games/table/TableManagerBlackjackRoundTest.java | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/src/main/java/net/tfminecraft/games/table/TableManager.java b/src/main/java/net/tfminecraft/games/table/TableManager.java index 96e7249..51f0e7e 100644 --- a/src/main/java/net/tfminecraft/games/table/TableManager.java +++ b/src/main/java/net/tfminecraft/games/table/TableManager.java @@ -683,6 +683,11 @@ public void onHitEntity(EntityDamageByEntityEvent event) { player.sendMessage(Messages.get("place.pickup_live")); return; } + if (table.floatOwner() != null) { + // Picking up would drop an absent dealer's tray at the picker's feet, so it waits for them. + player.sendMessage(Messages.get("dealer.float_held")); + return; + } pickup(player, table); } diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerBlackjackRoundTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerBlackjackRoundTest.java index f971369..1c9a571 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerBlackjackRoundTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerBlackjackRoundTest.java @@ -379,6 +379,10 @@ void aDealerWhoLogsOffMidRoundHasTheirTrayKeptUntilTheyAreBack() throws Exceptio clickShoe(player); assertNull(table.dealerId(), "nobody else can deal with an absent dealer's float"); assertTrue(messages(player).contains("dealer.float_held")); + hitTable(player); + assertSame(table, manager.table(table.getId()), "nor pick the table up and drop it on the floor"); + assertTrue(messages(player).contains("dealer.float_held")); + assertEquals(0, droppedGold()); assertEquals(16, gold(dealer)); dealer.reconnect(); manager.onJoin(new PlayerJoinEvent(dealer, "join"));