diff --git a/src/main/java/net/tfminecraft/games/game/DrawGame.java b/src/main/java/net/tfminecraft/games/game/DrawGame.java index 427e3aa..66b34ee 100644 --- a/src/main/java/net/tfminecraft/games/game/DrawGame.java +++ b/src/main/java/net/tfminecraft/games/game/DrawGame.java @@ -137,6 +137,10 @@ public void onPlayWord(Table table, Player player, String word) { return; } int contrib = streetContrib(table, id); + if (contrib > street.currentBet && ("check".equals(word) || "call".equals(word))) { + // Chips past the bet are a bet whatever it is called, so the others have to answer it. + word = "raise"; + } switch (word) { case "check" -> { if (contrib < street.currentBet) { @@ -147,6 +151,12 @@ public void onPlayWord(Table table, Player player, String word) { } 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)) { + player.sendMessage(Messages.get("draw.need_call", + "n", String.valueOf(street.currentBet - contrib))); + return; + } street.capped.add(id); } else { street.capped.remove(id); @@ -195,6 +205,17 @@ public void onReturnedSelected(Table table, Player player, int count) { }); } + /** 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)); + } + @Override public void onFeltPilesChanged(Table table) { TableManager.get().refreshLabel(table); diff --git a/src/main/java/net/tfminecraft/games/game/Game.java b/src/main/java/net/tfminecraft/games/game/Game.java index 83c1925..6def226 100644 --- a/src/main/java/net/tfminecraft/games/game/Game.java +++ b/src/main/java/net/tfminecraft/games/game/Game.java @@ -99,6 +99,15 @@ default void onDealerGone(Table table) { onTableReady(table); } + /** + * Whether this player may put money on the felt right now. Games that deal a hand to a fixed + * set of seats say no to anyone not in the live hand, so a late stake cannot buy into a hand + * already dealt and a folded seat cannot feed a pot it has given up. + */ + default boolean allowStake(Table table, Player player) { + return true; + } + /** Every game reacts to chips arriving or a seat leaving, if only to refresh its label. */ void onChipIn(Table table, Player player); diff --git a/src/main/java/net/tfminecraft/games/game/PokerGame.java b/src/main/java/net/tfminecraft/games/game/PokerGame.java index 1f60ac6..e5be72d 100644 --- a/src/main/java/net/tfminecraft/games/game/PokerGame.java +++ b/src/main/java/net/tfminecraft/games/game/PokerGame.java @@ -123,6 +123,10 @@ public void onPlayWord(Table table, Player player, String word) { Street street = streets.get(table.getId()); UUID id = player.getUniqueId(); int contrib = streetContrib(table, id); + if (contrib > street.currentBet && ("check".equals(word) || "call".equals(word))) { + // Chips past the bet are a bet whatever it is called, so the others have to answer it. + word = "raise"; + } switch (word) { case "check" -> { if (contrib < street.currentBet) { @@ -133,6 +137,12 @@ public void onPlayWord(Table table, Player player, String word) { } 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)) { + player.sendMessage(Messages.get("poker.need_call", + "n", String.valueOf(street.currentBet - contrib))); + return; + } street.capped.add(id); } else { street.capped.remove(id); @@ -162,6 +172,17 @@ public void onPlayWord(Table table, Player player, String word) { finishOrAdvance(table, new ArrayList<>(table.actives()), true); } + /** 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)); + } + @Override public void onFeltPilesChanged(Table table) { TableManager.get().refreshLabel(table); diff --git a/src/main/java/net/tfminecraft/games/table/TableManager.java b/src/main/java/net/tfminecraft/games/table/TableManager.java index 51f0e7e..b7cb3ce 100644 --- a/src/main/java/net/tfminecraft/games/table/TableManager.java +++ b/src/main/java/net/tfminecraft/games/table/TableManager.java @@ -2519,6 +2519,9 @@ private boolean dumpLoot(Player player, Table table, ItemStack snapshot, int den if (dealerTray && refuseTrayStock(table, player)) { return false; } + if (!dealerTray && refuseStake(table, player)) { + return false; + } int placeDenars = denars * need; ItemStack one = snapshot.clone(); one.setAmount(1); @@ -2660,6 +2663,9 @@ private boolean tryPlaceChip(Player player, Location click) { if (!dealerTray && refuseBlackjackPlace(player, table, player.getUniqueId(), denars)) { return true; } + if (!dealerTray && refuseStake(table, player)) { + return true; + } UUID bucket = dealerTray ? table.getId() : player.getUniqueId(); // One coin of exactly this kind, picked out and staked as a single movement. The coin is in // the player's hand and worth exactly this much, so the movement always goes through. @@ -2679,6 +2685,16 @@ private boolean tryPlaceChip(Player player, Location click) { return true; } + /** True when the game will not take this player's money now, such as mid-hand from a stranger. */ + private static boolean refuseStake(Table table, Player player) { + Game game = gameOf(table); + if (game == null || game.allowStake(table, player)) { + return false; + } + player.sendMessage(Messages.get("wager.not_in_hand")); + return true; + } + /** * True when this dealer must not stock the tray by hand. On a backed table the house money is * the bank's, so personal coins going in would be money the guild then banks as its own. diff --git a/src/main/java/net/tfminecraft/games/wager/WagerEngine.java b/src/main/java/net/tfminecraft/games/wager/WagerEngine.java index 78e832b..8ac29ae 100644 --- a/src/main/java/net/tfminecraft/games/wager/WagerEngine.java +++ b/src/main/java/net/tfminecraft/games/wager/WagerEngine.java @@ -64,6 +64,14 @@ public int owned(Table table, UUID owner, int street) { return table.ledger().total(owner, street); } + /** + * True when this player's pockets hold nothing a table would take as a bet, so a call short + * of the current bet is an all in rather than a free ride. + */ + public boolean allIn(Table table, Player player) { + return Accounts.pockets(table, player).available() < 1; + } + public int tray(Table table) { return table.ledger().total(table.getId()); } diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 8a865ce..cab1fef 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -105,6 +105,7 @@ hand: poker: 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_chips: "#ff5555Put chips on this street first." checked: "#aaaaaaChecked." called: "#aaaaaaCalled." @@ -122,6 +123,7 @@ poker: draw: 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_chips: "#ff5555Put chips on this street first." checked: "#aaaaaaChecked." called: "#aaaaaaCalled." @@ -148,6 +150,7 @@ wager: need_item: "#ff5555Hold the item you want to wager." use_click: "#ff5555Place coins and valued chips by right-clicking the table." coins_only: "#ff5555This table only takes coins." + not_in_hand: "#ff5555You are not in this hand. Wait for the next one." need_amount: "#ff5555Enter a whole denar amount of 1 or more." no_vote: "#ff5555There is no wager to vote on." not_eligible: "#ff5555You cannot vote on this wager." diff --git a/src/test/java/net/tfminecraft/games/game/DrawGameTest.java b/src/test/java/net/tfminecraft/games/game/DrawGameTest.java index 0ba0682..4659ee7 100644 --- a/src/test/java/net/tfminecraft/games/game/DrawGameTest.java +++ b/src/test/java/net/tfminecraft/games/game/DrawGameTest.java @@ -8,6 +8,7 @@ import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.junit.jupiter.api.BeforeEach; +import org.mockbukkit.mockbukkit.MockBukkit; import org.mockbukkit.mockbukkit.entity.PlayerMock; import org.junit.jupiter.api.Test; @@ -263,6 +264,7 @@ void bettingRejectsEmptyRaiseAndUnmatchedCheckButAllowsShortAllInCall() { act("check"); assertEquals(carol.getUniqueId(), table.actor()); contribute(carol, 3); + broke.add(carol.getUniqueId()); act("call"); contribute(alice, 20); act("raise"); @@ -692,12 +694,67 @@ void removingTheTableDuringTheInitialDealStopsDealing() { assertNull(table.actor()); } + @Test + void shortCallIsRefusedWhileChipsRemainInPocketAndCapsOnlyOnceAllIn() { + start(alice, bob, carol); + contribute(bob, 10); + act("raise"); + assertEquals(carol.getUniqueId(), table.actor()); + act("call"); + assertEquals(carol.getUniqueId(), table.actor(), "a free call must not pass the turn"); + assertTrue(((PlayerMock) carol).nextMessage().startsWith("draw.need_call")); + assertTrue(game.extraLabel(table).contains("n, 10")); + contribute(carol, 3); + act("call"); + assertEquals(carol.getUniqueId(), table.actor()); + assertTrue(((PlayerMock) carol).nextMessage().contains("n, 7")); + broke.add(carol.getUniqueId()); + act("call"); + assertEquals(alice.getUniqueId(), table.actor()); + assertEquals("draw.called", ((PlayerMock) carol).nextMessage()); + } + + @Test + void onlySeatsStillInALiveHandMayStake() { + Player stranger = MockBukkit.getMock().addPlayer(); + assertTrue(game.allowStake(table, stranger), "an idle table takes anyone's chips"); + 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, stranger), "a stranger cannot buy into a dealt hand"); + drain(); + assertEquals(bob.getUniqueId(), table.actor()); + assertTrue(game.allowStake(table, bob)); + act("fold"); + assertTrue(table.live()); + assertFalse(game.allowStake(table, bob), "a folded seat cannot feed the pot"); + assertTrue(game.allowStake(table, carol)); + assertFalse(game.allowStake(table, stranger)); + } + + @Test + void chipsPastTheBetAreARaiseEvenWhenCalledACheckOrCall() { + start(alice, bob, carol); + contribute(bob, 10); + act("check"); + assertEquals("draw.raised[draw.raised, n, 10]", ((PlayerMock) bob).nextMessage()); + assertEquals(carol.getUniqueId(), table.actor()); + contribute(carol, 25); + act("call"); + assertEquals("draw.raised[draw.raised, n, 25]", ((PlayerMock) carol).nextMessage()); + contribute(alice, 25); + act("call"); + assertEquals(bob.getUniqueId(), table.actor(), "the first bettor has to answer the raise"); + } + @Test void allInSeatIsSkippedWhileTheOthersKeepRaising() { start(alice, bob, carol); contribute(bob, 10); act("raise"); contribute(carol, 3); + broke.add(carol.getUniqueId()); act("call"); contribute(alice, 20); act("raise"); diff --git a/src/test/java/net/tfminecraft/games/game/PokerGameTest.java b/src/test/java/net/tfminecraft/games/game/PokerGameTest.java index c70c773..746b501 100644 --- a/src/test/java/net/tfminecraft/games/game/PokerGameTest.java +++ b/src/test/java/net/tfminecraft/games/game/PokerGameTest.java @@ -104,6 +104,7 @@ void raiseRequiresChipsAndReopensActionWhileShortCallCapsPlayer() { act("check"); assertEquals(carol.getUniqueId(), table.actor()); contribute(carol, 5); + broke.add(carol.getUniqueId()); act("call"); assertEquals(alice.getUniqueId(), table.actor()); contribute(alice, 30); @@ -478,12 +479,67 @@ void endingTheSessionDuringTheHoleDealStopsDealing() { assertFalse(table.live()); } + @Test + void shortCallIsRefusedWhileChipsRemainInPocketAndCapsOnlyOnceAllIn() { + start(alice, bob, carol); + contribute(bob, 10); + act("raise"); + assertEquals(carol.getUniqueId(), table.actor()); + act("call"); + assertEquals(carol.getUniqueId(), table.actor(), "a free call must not pass the turn"); + assertTrue(((PlayerMock) carol).nextMessage().startsWith("poker.need_call")); + assertTrue(game.extraLabel(table).contains("n, 10")); + contribute(carol, 3); + act("call"); + assertEquals(carol.getUniqueId(), table.actor()); + assertTrue(((PlayerMock) carol).nextMessage().contains("n, 7")); + broke.add(carol.getUniqueId()); + act("call"); + assertEquals(alice.getUniqueId(), table.actor()); + assertEquals("poker.called", ((PlayerMock) carol).nextMessage()); + } + + @Test + void onlySeatsStillInALiveHandMayStake() { + Player stranger = MockBukkit.getMock().addPlayer(); + assertTrue(game.allowStake(table, stranger), "an idle table takes anyone's chips"); + 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, stranger), "a stranger cannot buy into a dealt hand"); + drain(); + assertEquals(bob.getUniqueId(), table.actor()); + assertTrue(game.allowStake(table, bob)); + act("fold"); + assertTrue(table.live()); + assertFalse(game.allowStake(table, bob), "a folded seat cannot feed the pot"); + assertTrue(game.allowStake(table, carol)); + assertFalse(game.allowStake(table, stranger)); + } + + @Test + void chipsPastTheBetAreARaiseEvenWhenCalledACheckOrCall() { + start(alice, bob, carol); + contribute(bob, 10); + act("check"); + assertEquals("poker.raised[poker.raised, n, 10]", ((PlayerMock) bob).nextMessage()); + assertEquals(carol.getUniqueId(), table.actor()); + contribute(carol, 25); + act("call"); + assertEquals("poker.raised[poker.raised, n, 25]", ((PlayerMock) carol).nextMessage()); + contribute(alice, 25); + act("call"); + assertEquals(bob.getUniqueId(), table.actor(), "the first bettor has to answer the raise"); + } + @Test void allInSeatIsSkippedWhileTheOthersKeepRaising() { start(alice, bob, carol); contribute(bob, 10); act("raise"); contribute(carol, 3); + broke.add(carol.getUniqueId()); act("call"); assertEquals(alice.getUniqueId(), table.actor()); contribute(alice, 20); @@ -680,6 +736,8 @@ abstract class GameScenarioFixture { final Map invested = new HashMap<>(); final Map> contributions = new HashMap<>(); final List dealt = new ArrayList<>(); + /** Seats with nothing left in their pockets to bet, so a short call is an all in. */ + final java.util.Set broke = new java.util.HashSet<>(); private MockedStatic managers; private MockedStatic wagers; private MockedStatic messages; @@ -704,6 +762,8 @@ void setUpScenario() { carol = MockBukkit.getMock().addPlayer(); when(manager.table(table.getId())).thenReturn(table); when(wager.totalsExcept(table, table.getId())).thenAnswer(call -> new HashMap<>(invested)); + when(wager.allIn(eq(table), any(Player.class))).thenAnswer(call -> + broke.contains(call.getArgument(1, Player.class).getUniqueId())); when(wager.owned(eq(table), any(UUID.class), anyInt())).thenAnswer(call -> contributions.getOrDefault(call.getArgument(2), Map.of()).getOrDefault(call.getArgument(1), 0)); doAnswer(call -> { diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerChipPlaceTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerChipPlaceTest.java index 313bb9e..b85bc4a 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerChipPlaceTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerChipPlaceTest.java @@ -67,6 +67,47 @@ class TableManagerChipPlaceTest extends TableManagerFixture { assertTrue(table.ledger().isEmpty()); } + @Test void aGameRefusingAStakeKeepsTheCoinsButStillTakesAnotherSeatsBet() { + Table table = placeQuietly(); + PlayerMock seated = opponent(); + stakeCoin(seated, table); + when(game.allowStake(any(), argThat(p -> p != null && p.getUniqueId().equals(player.getUniqueId())))) + .thenReturn(false); + table.startSession(); + player.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 3)); + assertTrue(click(player, felt(table)).isCancelled()); + assertEquals("wager.not_in_hand", player.nextMessage()); + assertEquals(3, player.getInventory().getItemInMainHand().getAmount()); + assertFalse(table.actives().contains(player.getUniqueId())); + assertEquals(0, manager.ownedDenars(table, player.getUniqueId())); + player.getInventory().setItemInMainHand(new ItemStack(Material.STONE)); + assertFalse(click(player, felt(table)).isCancelled(), "ordinary items still pass through"); + assertNull(player.nextMessage()); + seated.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 2)); + assertTrue(click(seated, felt(table)).isCancelled()); + assertEquals(2, manager.ownedDenars(table, seated.getUniqueId())); + } + + @Test void freePlayStillSeatsAStrangerWhoStakesMidHand() { + Table table = placeQuietly(); + stakeCoin(opponent(), table); + table.startSession(); + player.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 3)); + assertTrue(click(player, felt(table)).isCancelled()); + assertTrue(table.actives().contains(player.getUniqueId())); + assertEquals(1, manager.ownedDenars(table, player.getUniqueId())); + } + + @Test void aLiveTableWhoseGameIsNoLongerRegisteredStillTakesAStrangersCoins() { + Table table = placeQuietly(); + stakeCoin(opponent(), table); + table.startSession(); + games.when(() -> GamesRegistry.of("freeplay")).thenReturn(null); + player.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 3)); + assertTrue(click(player, felt(table)).isCancelled()); + assertEquals(1, manager.ownedDenars(table, player.getUniqueId())); + } + @Test void coinsCannotBeStakedOnTheShoe() { Cache.tableLayouts.put("freeplay", new TableLayout(Cache.pokerCardSet, "Cards", "icon", 6, Map.of(), new TableLayout.FeltRing(0.3, 1.2), null, null, 0.5)); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerFixture.java b/src/test/java/net/tfminecraft/games/table/TableManagerFixture.java index d345561..4e2dd12 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerFixture.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerFixture.java @@ -142,6 +142,7 @@ abstract class TableManagerFixture { anchors = track(mockStatic(WorldAnchors.class)); game = mock(Game.class, withSettings().extraInterfaces(net.tfminecraft.games.game.LiveCardReturns.class)); when(game.tablePileSlot(any(), anyString(), anyInt(), anyInt(), anyBoolean())).thenCallRealMethod(); + when(game.allowStake(any(), any())).thenCallRealMethod(); games = track(mockStatic(GamesRegistry.class)); games.when(() -> GamesRegistry.of("freeplay")).thenReturn(game); messages = track(mockStatic(Messages.class, call -> call.getArgument(0))); diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerLootTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerLootTest.java index 5648472..caf0111 100644 --- a/src/test/java/net/tfminecraft/games/table/TableManagerLootTest.java +++ b/src/test/java/net/tfminecraft/games/table/TableManagerLootTest.java @@ -93,6 +93,19 @@ class TableManagerLootTest extends TableManagerFixture { assertEquals("wager.busy", player.nextMessage(), "the refused placement keeps the arm"); } + @Test void lootTheGameRefusesStaysInHand() { + Table table = armedTable(7); + table.actives().remove(player.getUniqueId()); + when(game.allowStake(any(), argThat(p -> p != null && p.getUniqueId().equals(player.getUniqueId())))) + .thenReturn(false); + table.startSession(); + assertTrue(click(player, table.getOrigin().clone().add(0.75, 0, 0)).isCancelled()); + assertEquals("wager.not_in_hand", player.nextMessage()); + assertEquals(2, diamonds()); + assertEquals(1, table.ledger().total()); + assertFalse(table.actives().contains(player.getUniqueId())); + } + @Test void aDeclaredValueTooLargeToAddUpTakesNothing() { Table table = armedTable(2_000_000_000); assertTrue(click(player, table.getOrigin().clone().add(0.75, 0, 0)).isCancelled()); diff --git a/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java b/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java index 5b75973..01b77b8 100644 --- a/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java +++ b/src/test/java/net/tfminecraft/games/wager/WagerEngineTest.java @@ -81,6 +81,15 @@ private void bet(PlayerMock player, int count) { assertEquals(count, result.moved()); } + @Test + void allInOnlyOnceNothingStakeableIsLeftInPocket() { + assertTrue(engine.allIn(table, winner)); + winner.getInventory().addItem(new ItemStack(Material.STONE, 5)); + assertTrue(engine.allIn(table, winner), "items with no coin value are not chips"); + winner.getInventory().addItem(coin(1)); + assertFalse(engine.allIn(table, winner)); + } + @Test void restorePreservesStreetPlacementAndSeparatesTrayFromPot() { UUID owner = winner.getUniqueId();