Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <buy-in Denars> <starting chips> <max rebuys> <ante chips> <blind minutes>`.
A buy-in of `0` selects cash play. A blind interval of `0` keeps blinds fixed;
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/net/tfminecraft/games/cache/Cache.java
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ public final class Cache {
public static final List<WagerItemOverride> wagerItems = new ArrayList<>();
/** Catalog rank -> sort/play value, keyed by game id. Unmapped ranks stay as catalog rank. */
public static final Map<String, Map<Integer, Integer>> gameRankValues = new HashMap<>();
/** Maximum cards held by one player, including cards still being dealt; 0 is unlimited. */
public static final Map<String, Integer> gameHandCardLimits = new HashMap<>();
public static final Map<String, TableLayout> tableLayouts = new HashMap<>();
/** Help books from help.yml, keyed by what a player types after /games help. */
public static final Map<String, HelpBook> helpBooks = new LinkedHashMap<>();
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/net/tfminecraft/games/loader/GamesLoader.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,16 @@ 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);
if (section == null) {
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));
}

Expand Down
7 changes: 7 additions & 0 deletions src/main/java/net/tfminecraft/games/table/TableManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<Card> 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"));
Expand Down
4 changes: 4 additions & 0 deletions src/main/resources/games.yml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -27,6 +30,7 @@ poker:
max-range: 1.2

draw:
hand-card-limit: 5
leave-distance: 6
card-set: french_52
label: Five-Draw
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/messages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
12 changes: 12 additions & 0 deletions src/test/java/net/tfminecraft/games/loader/GamesLoaderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class GamesLoaderTest {
private Games previousPlugin;
private Map<String, Map<Integer, Integer>> previousRanks;
private Map<String, TableLayout> previousLayouts;
private Map<String, Integer> previousLimits;
private double previousLeave;
private String previousSet;
private String previousLabel;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Integer> 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);
}
}
Loading