diff --git a/README.md b/README.md index 0449f3b..caa7501 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,12 @@ It supports both structured games with managed rounds and a free-play table for ## Poker tournaments +Set `hand-card-limit` under each game in `games.yml` to cap the cards a player +can hold, including cards still being dealt and Blackjack split groups. The +bundled limits are 2 for Hold'em and 5 for Five-Draw. Omitted, zero or negative +limits leave card counts unrestricted; Blackjack and free play omit the limit. +Existing servers must add the settings to their own `games.yml` to enable them. + At an empty poker table, the host or staff can use `/games poker configure `. A buy-in of `0` selects cash play. A blind interval of `0` keeps blinds fixed; diff --git a/src/main/java/net/tfminecraft/games/cache/Cache.java b/src/main/java/net/tfminecraft/games/cache/Cache.java index 489ff46..f085c26 100644 --- a/src/main/java/net/tfminecraft/games/cache/Cache.java +++ b/src/main/java/net/tfminecraft/games/cache/Cache.java @@ -82,6 +82,8 @@ public final class Cache { public static final List wagerItems = new ArrayList<>(); /** Catalog rank -> sort/play value, keyed by game id. Unmapped ranks stay as catalog rank. */ public static final Map> gameRankValues = new HashMap<>(); + /** Maximum cards held by one player, including cards still being dealt; 0 is unlimited. */ + public static final Map gameHandCardLimits = new HashMap<>(); public static final Map tableLayouts = new HashMap<>(); /** Help books from help.yml, keyed by what a player types after /games help. */ public static final Map helpBooks = new LinkedHashMap<>(); diff --git a/src/main/java/net/tfminecraft/games/loader/GamesLoader.java b/src/main/java/net/tfminecraft/games/loader/GamesLoader.java index 5d6db51..1a78f4f 100644 --- a/src/main/java/net/tfminecraft/games/loader/GamesLoader.java +++ b/src/main/java/net/tfminecraft/games/loader/GamesLoader.java @@ -39,6 +39,7 @@ public boolean loadSafe(File configFile) { } Cache.gameRankValues.clear(); + Cache.gameHandCardLimits.clear(); Cache.tableLayouts.clear(); for (String gameId : config.getKeys(false)) { ConfigurationSection section = config.getConfigurationSection(gameId); @@ -46,6 +47,8 @@ public boolean loadSafe(File configFile) { continue; } loadRankValues(gameId, section); + Cache.gameHandCardLimits.put(gameId.toLowerCase(Locale.ROOT), + Math.max(0, section.getInt("hand-card-limit", 0))); Cache.tableLayouts.put(gameId.toLowerCase(Locale.ROOT), readLayout(section)); } diff --git a/src/main/java/net/tfminecraft/games/table/TableManager.java b/src/main/java/net/tfminecraft/games/table/TableManager.java index 00a8c86..981b9ed 100644 --- a/src/main/java/net/tfminecraft/games/table/TableManager.java +++ b/src/main/java/net/tfminecraft/games/table/TableManager.java @@ -3636,6 +3636,13 @@ private void tryDraw(Player player, Table table) { * {@code after} runs once the card has landed, or {@code stopped} if it is stopped in the air. */ private boolean drawOneToPlayer(Table table, Player player, int slot, Runnable after, Runnable stopped) { + int limit = Cache.gameHandCardLimits.getOrDefault(table.getGameId().toLowerCase(Locale.ROOT), 0); + int heldCount = table.getHands().getOrDefault(player.getUniqueId(), List.of()).size(); + List pending = dealPendingCards.get(player.getUniqueId()); + if (limit > 0 && heldCount + (pending == null ? 0 : pending.size()) >= limit) { + player.sendMessage(Messages.get("hand.limit")); + return false; + } Table other = tableHolding(player.getUniqueId()); if (other != null && !other.getId().equals(table.getId())) { player.sendMessage(Messages.get("hand.busy")); diff --git a/src/main/resources/games.yml b/src/main/resources/games.yml index f66ae1a..8dc703c 100644 --- a/src/main/resources/games.yml +++ b/src/main/resources/games.yml @@ -1,5 +1,8 @@ # Per-game table rules. +# hand-card-limit caps each player's held and incoming cards (including split groups). +# Omit it or set it to 0 for no cap. Negative values also disable the cap. poker: + hand-card-limit: 2 # Blocks from the placed deck. Leaving this range returns cards and calls Game.onLeave. leave-distance: 6 # Named set from cards.yml. Poker does not deal jokers even if french_54 gains them later. @@ -27,6 +30,7 @@ poker: max-range: 1.2 draw: + hand-card-limit: 5 leave-distance: 6 card-set: french_52 label: Five-Draw diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 6416374..50be1e1 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -91,6 +91,7 @@ place: not_leader: "#ff5555Only the guild leader can place this table." hand: + limit: "#ff5555You have reached this game's hand card limit." empty: "#ff5555The shoe is empty." need_empty: "#ff5555Empty your hand to draw a card." busy: "#ff5555Return your cards at the other table first." diff --git a/src/test/java/net/tfminecraft/games/loader/GamesLoaderTest.java b/src/test/java/net/tfminecraft/games/loader/GamesLoaderTest.java index 9707753..b4aa91d 100644 --- a/src/test/java/net/tfminecraft/games/loader/GamesLoaderTest.java +++ b/src/test/java/net/tfminecraft/games/loader/GamesLoaderTest.java @@ -27,6 +27,7 @@ class GamesLoaderTest { private Games previousPlugin; private Map> previousRanks; private Map previousLayouts; + private Map previousLimits; private double previousLeave; private String previousSet; private String previousLabel; @@ -40,6 +41,7 @@ void setUp() { previousPlugin = Games.plugin; previousRanks = new HashMap<>(Cache.gameRankValues); previousLayouts = new HashMap<>(Cache.tableLayouts); + previousLimits = new HashMap<>(Cache.gameHandCardLimits); previousLeave = Cache.pokerLeaveDistance; previousSet = Cache.pokerCardSet; previousLabel = Cache.pokerLabel; @@ -56,6 +58,8 @@ void tearDown() { Cache.gameRankValues.putAll(previousRanks); Cache.tableLayouts.clear(); Cache.tableLayouts.putAll(previousLayouts); + Cache.gameHandCardLimits.clear(); + Cache.gameHandCardLimits.putAll(previousLimits); Cache.pokerLeaveDistance = previousLeave; Cache.pokerCardSet = previousSet; Cache.pokerLabel = previousLabel; @@ -255,6 +259,14 @@ void missingAndMalformedFilesRetainLastGoodConfiguration() throws Exception { verify(logger, times(2)).severe(startsWith("[Games] Failed to load games.yml: ")); } + @Test + void handLimitsArePerGameAndRemovingThemOnReloadRestoresUnlimitedPlay() throws Exception { + assertTrue(loader.loadSafe(yaml("Poker: {hand-card-limit: 2}\ndraw: {hand-card-limit: 5}\nfreeplay: {}\nblackjack: {hand-card-limit: -1}\n"))); + assertEquals(Map.of("poker", 2, "draw", 5, "freeplay", 0, "blackjack", 0), Cache.gameHandCardLimits); + assertTrue(loader.loadSafe(yaml("poker: {}\n"))); + assertEquals(Map.of("poker", 0), Cache.gameHandCardLimits); + } + private File yaml(String contents) throws Exception { return Files.writeString(Files.createTempFile(temp, "games-", ".yml"), contents).toFile(); } diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerHandLimitTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerHandLimitTest.java new file mode 100644 index 0000000..9b223d7 --- /dev/null +++ b/src/test/java/net/tfminecraft/games/table/TableManagerHandLimitTest.java @@ -0,0 +1,87 @@ +package net.tfminecraft.games.table; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import net.tfminecraft.games.cache.Cache; + +class TableManagerHandLimitTest extends TableManagerHandFixture { + private Map previousLimits; + + @BeforeEach void saveLimits() { + previousLimits = new HashMap<>(Cache.gameHandCardLimits); + Cache.gameHandCardLimits.clear(); + } + + @AfterEach void restoreLimits() { + Cache.gameHandCardLimits.clear(); + Cache.gameHandCardLimits.putAll(previousLimits); + } + + @Test void shoeClicksCannotDrawPastTheLimitOrConsumeAnotherCard() { + Cache.gameHandCardLimits.put("freeplay", 1); + Table table = placeAndClearMessage(); + shoeClick(table); + tick(5); + shoeClick(table); + tick(5); + assertEquals("hand.limit", player.nextMessage()); + assertEquals(1, table.handOf(player.getUniqueId()).size()); + assertEquals(1, table.getDeck().remaining()); + assertAllCardsAccountedFor(table, 2); + } + + @Test void rapidClicksCountCardsStillInFlight() { + Cache.gameHandCardLimits.put("freeplay", 1); + Cache.handDealTicks = 3; + Table table = placeAndClearMessage(); + shoeClick(table); + shoeClick(table); + // A second automatic request waits for the first flight, then checks the cap again. + manager.dealToPlayer(table, player, 1); + tick(20); + assertEquals("hand.limit", player.nextMessage()); + assertEquals(1, table.handOf(player.getUniqueId()).size()); + assertEquals(1, table.getDeck().remaining()); + assertAllCardsAccountedFor(table, 2); + } + + @Test void oversizedAutomaticDealCompletesItsCallbackOnceAtTheLimit() { + Cache.gameHandCardLimits.put("freeplay", 1); + Table table = placeAndClearMessage(); + Runnable done = mock(Runnable.class); + manager.dealToPlayer(table, player, 2, done); + tick(10); + verify(done, times(1)).run(); + assertEquals(1, table.handOf(player.getUniqueId()).size()); + assertEquals(1, table.getDeck().remaining()); + } + + @Test void returningACardMakesRoomToDrawAgain() throws InterruptedException { + Cache.gameHandCardLimits.put("freeplay", 1); + Table table = dealt(1); + select(table.handOf(player.getUniqueId()).getFirst()); + shoeClick(table); + tick(10); + assertEquals("hand.returned_selected", player.nextMessage()); + shoeClick(table); + tick(10); + assertEquals(1, table.handOf(player.getUniqueId()).size()); + assertAllCardsAccountedFor(table, 2); + } + + @Test void anOmittedLimitLeavesDrawingUnrestricted() { + Table table = placeAndClearMessage(); + shoeClick(table); + tick(5); + shoeClick(table); + tick(5); + assertEquals(2, table.handOf(player.getUniqueId()).size()); + assertAllCardsAccountedFor(table, 2); + } +}