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
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,28 @@ It supports both structured games with managed rounds and a free-play table for
- **Guild-owned tables** — connect house games to a guild's funds and let its members take over dealing between rounds.
- **Help at the table** — in-game books explain each game's rules and available actions.

## Poker tournaments

At an empty poker table, the host or staff can use
`/games poker configure <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;
otherwise they double at each interval and are collected at the next hand.
Set the starting blinds through the table options menu.

Players use `/games poker buyin` before the first hand. On their turn,
`/games poker bet <chips>` puts extra chips into the pot, followed by a
`raise` or `check`. `call` collects the chips needed to match automatically.
`/games bet allin` (or `allin` / `all in` in chat) stakes the entire remaining stack.
Cash tables take physical stakes only on the current player's turn.

Busted players can `/games poker rebuy` between hands within the configured limit.
The host can `/games poker kick <player>` between hands and, when only one positive
stack remains, `/games poker finish` to pay the Denar prize. Leaving or removal
after tournament play starts forfeits the entry. Before play starts it is refunded.
Tournament chips never enter player inventories or Denar payouts. Shutdown or
reload ends tournaments and returns Denar stakes through the table's refund path;
settings persist, chip stacks do not.

## Documentation

[Project documentation](https://github.com/TF-Minecraft/Docs/blob/main/projects/Games/README.md)
Expand All @@ -35,7 +57,7 @@ inspect uncovered behaviour. The build workflow uploads the coverage report
alongside test results. Coverage data is replaced on each test run; use the full
suite when assessing repository-wide coverage.

The suite covers every production line and branch. Tests should protect
The suite covers game rounds, money conservation, table interactions, and tournament settings. Tests should protect
supported behaviour, not create impossible internal states merely to execute a
branch. Where a branch cannot be reached through any real caller, remove it
rather than force it.
Expand Down
10 changes: 9 additions & 1 deletion src/main/java/net/tfminecraft/games/Messages.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;

import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.FileConfiguration;
Expand All @@ -22,6 +25,10 @@ public static void load(File file) {
FileConfiguration loaded = new YamlConfiguration();
try {
loaded.load(file);
try (InputStream defaults = Messages.class.getResourceAsStream("/messages.yml")) {
if (defaults != null) loaded.setDefaults(YamlConfiguration.loadConfiguration(
new InputStreamReader(defaults, StandardCharsets.UTF_8)));
}
config = loaded;
} catch (IOException | InvalidConfigurationException ex) {
Games.plugin.getLogger().severe("[Games] Failed to load messages.yml: " + ex.getMessage());
Expand All @@ -46,7 +53,8 @@ public static String getRaw(String path) {
if (config == null) {
return path;
}
return config.getString(path, path);
String value = config.getString(path);
return value != null ? value : path;
}

private static String format(String raw) {
Expand Down
19 changes: 17 additions & 2 deletions src/main/java/net/tfminecraft/games/command/CommandManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String
}
return handleHelp(sender, args);
}
if (args[0].equalsIgnoreCase("poker")) {
if (!(sender instanceof Player player)) {
sender.sendMessage(Messages.get("place.players_only"));
return true;
}
if (!sender.hasPermission("games.bet")) {
sender.sendMessage(Messages.get("admin.no_permission"));
return true;
}
return PokerCommands.execute(player, args);
}
if (args[0].equalsIgnoreCase("bet")) {
if (!sender.hasPermission("games.bet")) {
sender.sendMessage(Messages.get("admin.no_permission"));
Expand Down Expand Up @@ -328,7 +339,7 @@ private boolean handleBet(CommandSender sender, String[] args) {
String action = args[1].toLowerCase(Locale.ROOT);
if (action.equals("hit") || action.equals("stand") || action.equals("double")
|| action.equals("split") || action.equals("check") || action.equals("call")
|| action.equals("fold") || action.equals("raise")) {
|| action.equals("fold") || action.equals("raise") || action.equals("allin")) {
return handleBetPlay(player, table, action);
}
if (!player.getUniqueId().equals(table.dealerId())) {
Expand Down Expand Up @@ -528,6 +539,7 @@ public List<String> onTabComplete(CommandSender sender, Command cmd, String alia
}
if (bet) {
first.add("bet");
first.add("poker");
}
if (admin) {
first.addAll(List.of("reload", "deck", "display", "place", "payout", "session", "deal"));
Expand All @@ -539,7 +551,10 @@ public List<String> onTabComplete(CommandSender sender, Command cmd, String alia
}
if (args.length == 2 && args[0].equalsIgnoreCase("bet") && bet) {
return prefix(List.of("min", "max", "open", "close", "hit", "stand", "double", "split",
"check", "call", "fold", "raise"), args[1]);
"check", "call", "fold", "raise", "allin"), args[1]);
}
if (args.length == 2 && args[0].equalsIgnoreCase("poker") && bet) {
return prefix(List.of("configure", "buyin", "rebuy", "bet", "kick", "finish", "status"), args[1]);
}
if (!admin) {
return Collections.emptyList();
Expand Down
123 changes: 123 additions & 0 deletions src/main/java/net/tfminecraft/games/command/PokerCommands.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package net.tfminecraft.games.command;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import net.tfminecraft.games.Messages;
import net.tfminecraft.games.game.GamesRegistry;
import net.tfminecraft.games.game.PokerGame;
import net.tfminecraft.games.game.PokerTournament;
import net.tfminecraft.games.table.PayoutFlight;
import net.tfminecraft.games.table.Table;
import net.tfminecraft.games.table.TableManager;
import net.tfminecraft.games.wager.Accounts;
import net.tfminecraft.games.wager.TxResult;
import net.tfminecraft.games.wager.WagerEngine;
import net.tfminecraft.games.voice.RpNames;

/** Dealer settings and player tournament actions at the nearby poker table. */
final class PokerCommands {
private PokerCommands() {}

static boolean execute(Player player, String[] args) {
TableManager manager = TableManager.get();
Table table = manager.tableNearby(player);
if (table == null || !"poker".equalsIgnoreCase(table.getGameId())) {
player.sendMessage(Messages.get("poker.no_table"));
return true;
}
PokerTournament tournament = table.poker();
String action = args.length > 1 ? args[1].toLowerCase(Locale.ROOT) : "status";
try {
switch (action) {
case "status" -> player.sendMessage(Messages.get("poker.settings",
"buyin", String.valueOf(tournament.buyIn()),
"chips", String.valueOf(tournament.startingChips()),
"rebuys", String.valueOf(tournament.maxRebuys()),
"ante", String.valueOf(tournament.ante()),
"minutes", String.valueOf(tournament.blindMinutes())));
case "buyin", "rebuy" -> {
if (table.live() || table.isPaying() || !tournament.canBuy(player.getUniqueId())
|| (!tournament.registered(player.getUniqueId()) && table.actives().size() >= 100)) {
player.sendMessage(Messages.get("poker.buyin_refused"));
return true;
}
TxResult result = WagerEngine.get().begin(table, "tournament buy in")
.move(Accounts.coins(table, player), Accounts.bucket(table, player.getUniqueId()),
tournament.buyIn()).commit();
if (!result.ok()) {
player.sendMessage(Messages.get(result.messageKey()));
return true;
}
tournament.buy(player.getUniqueId());
table.actives().add(player.getUniqueId());
GamesRegistry.of("poker").onTableReady(table);
player.sendMessage(Messages.get("poker.bought", "n", String.valueOf(tournament.startingChips())));
}
case "bet" -> {
if (args.length != 3 || !((PokerGame) GamesRegistry.of("poker"))
.betChips(table, player, Integer.parseInt(args[2]))) {
player.sendMessage(Messages.get("poker.chip_bet_refused"));
return true;
}
}
case "configure" -> {
if (!manager.canEditHouse(player, table) || table.live() || table.isPaying()
|| WagerEngine.get().total(table) > 0 || args.length != 7) {
player.sendMessage(Messages.get("poker.configure_refused"));
return true;
}
tournament.configure(Integer.parseInt(args[2]), Integer.parseInt(args[3]),
Integer.parseInt(args[4]), Integer.parseInt(args[5]), Integer.parseInt(args[6]));
player.sendMessage(Messages.get("place.options_saved"));
}
case "kick" -> {
if (!manager.canEditHouse(player, table) || table.live() || table.isPaying()
|| !tournament.enabled() || args.length != 3) {
player.sendMessage(Messages.get("poker.kick_refused"));
return true;
}
Player target = Bukkit.getPlayerExact(args[2]);
if (target == null || !tournament.registered(target.getUniqueId())) {
player.sendMessage(Messages.get("admin.unknown_player", "name", args[2]));
return true;
}
GamesRegistry.of("poker").onLeave(table, target);
target.sendMessage(Messages.get("poker.kicked"));
}
case "finish" -> {
List<UUID> remaining = table.actives().stream()
.filter(id -> tournament.stack(id) > 0).toList();
if (!manager.canEditHouse(player, table) || table.live() || table.isPaying()
|| !tournament.started() || remaining.size() != 1) {
player.sendMessage(Messages.get("poker.finish_refused"));
return true;
}
UUID winner = remaining.getFirst();
List<PayoutFlight> flights = new ArrayList<>();
WagerEngine.get().sweepPot(table, Bukkit.getPlayer(winner), winner, flights, "tournament prize");
WagerEngine.get().announceWins(table, "poker");
if (WagerEngine.get().felt(table) > 0) {
manager.flushPiles(table, flights, null);
player.sendMessage(Messages.get("poker.prize_pending"));
return true;
}
tournament.reset();
table.roundMoney().clear();
table.actives().clear();
manager.flushPiles(table, flights, () -> manager.refreshLabel(table));
player.sendMessage(Messages.get("poker.win", "name", RpNames.of(winner)));
}
default -> player.sendMessage(Messages.get("poker.usage"));
}
} catch (IllegalArgumentException ex) {
player.sendMessage(Messages.get("poker.usage"));
return true;
}
manager.persistHouseChange(table);
return true;
}
}
11 changes: 11 additions & 0 deletions src/main/java/net/tfminecraft/games/game/HandTalk.java
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ static List<String> bestHand(String gameId, List<UUID> seats, Function<UUID, Lis
return lines;
}

static List<String> allHands(String gameId, List<UUID> seats, Function<UUID, List<Card>> handOf) {
List<String> lines = new ArrayList<>();
for (UUID id : seats) {
List<Card> cards = handOf.apply(id);
if (!cards.isEmpty()) {
lines.add(Messages.get("hand.shown", "name", RpNames.of(id), "cards", cardList(cards)));
}
}
return lines;
}

/** Raw, so the caller's message formats the whole line in one pass. */
private static String cardList(List<Card> cards) {
String template = Messages.getRaw("hand.card");
Expand Down
Loading
Loading