diff --git a/Project.toml b/Project.toml index 49a7578b..0506f435 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "TexasHoldem" uuid = "6cef90fc-eb55-4a2a-97d0-7ecce2b738fe" authors = ["Charles Kawczynski "] -version = "0.4.2" +version = "0.4.3" [deps] Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" diff --git a/src/TexasHoldem.jl b/src/TexasHoldem.jl index 35f06a70..76b09eb1 100644 --- a/src/TexasHoldem.jl +++ b/src/TexasHoldem.jl @@ -23,6 +23,10 @@ export Chips include("custom_logger.jl") +abstract type AbstractGUI end +struct PlainLogger <: AbstractGUI end # no gui +struct Terminal <: AbstractGUI end + abstract type AbstractRound end struct PreFlop <: AbstractRound end struct Flop <: AbstractRound end @@ -40,6 +44,9 @@ include("player_actions.jl") include("player_options.jl") include(joinpath("terminal", "human_player_options.jl")) include(joinpath("terminal", "config_game.jl")) +include(joinpath("terminal", "ascii_card.jl")) +include(joinpath("terminal", "ascii_player.jl")) +include(joinpath("terminal", "ui.jl")) include("recreate.jl") end # module diff --git a/src/game.jl b/src/game.jl index 9236cd67..04a6fd88 100644 --- a/src/game.jl +++ b/src/game.jl @@ -30,10 +30,15 @@ any_actions_required(game::Game) = any_actions_required(game.table) round(game::Game) = round(game.table) move_buttons!(game) = move_buttons!(game.table) +function print_round(table, round) + table.gui isa Terminal && return nothing + print_round(table, round) +end + print_round(table, round::PreFlop) = @cinfo table.logger "Pre-flop!" -print_round(table, round::Flop) = @cinfo table.logger "Flop: $(repeat(" ", 44)) $(table.cards[1:3])" -print_round(table, round::Turn) = @cinfo table.logger "Turn: $(repeat(" ", 44)) $(table.cards[4])" -print_round(table, round::River) = @cinfo table.logger "River: $(repeat(" ", 43)) $(table.cards[5])" +print_round(table, round::Flop) = @cinfo table.logger "Flop: $(repeat(" ", 44)) $(table.cards[1:3])" +print_round(table, round::Turn) = @cinfo table.logger "Turn: $(repeat(" ", 44)) $(table.cards[4])" +print_round(table, round::River) = @cinfo table.logger "River: $(repeat(" ", 43)) $(table.cards[5])" set_preflop_blind_raise!(table::Table, player, ::AbstractRound, i::Int) = nothing function set_preflop_blind_raise!(table::Table, player::Player, ::PreFlop, i::Int) @@ -141,6 +146,7 @@ function act_generic!(game::Game, round::AbstractRound, sf::StartFrom) @assert sf.game_point isa StartOfGame || sf.game_point isa PlayerOption if sf.game_point isa StartOfGame set_round!(table, round) + update_gui(table) print_round(table, round) reset_round_bank_rolls!(game, round) @@ -311,7 +317,7 @@ function _deal_and_play!(game::Game, sf::StartFrom) if sf.game_point isa StartOfGame reset!(table.transactions, players) - @assert all(p->cards(p) == nothing, players) + @assert all(p->cards(p) == (nothing,nothing), players) @assert cards(table) == nothing reset_round_bank_rolls!(table) # round bank-rolls must account for blinds deal!(table, blinds(table)) @@ -326,6 +332,7 @@ function _deal_and_play!(game::Game, sf::StartFrom) distribute_winnings!(players, table.transactions, cards(table), logger) winners.declared = true + update_gui(table) @cdebug logger "amounts.(table.transactions.side_pots) = $(amounts.(table.transactions.side_pots))" @cdebug logger "initial_∑brs = $(initial_∑brs)" @cdebug logger "sum(bank_roll.(players)) = $(sum(bank_roll.(players)))" @@ -363,7 +370,7 @@ function _deal_and_play!(game::Game, sf::StartFrom) end @cinfo logger "------ Finished game!" - return winners + return any(x->quit_game(game, x), players) end function set_active_status!(table::Table) @@ -390,12 +397,13 @@ function reset_game!(game::Game) dealer_pidx=dealer_pidx(table), blinds=table.blinds, logger=logger, + gui=table.gui, ) PlayingCards.reset!(game.table.deck) table = game.table players = players_at_table(table) for player in players - player.cards = nothing + player.cards = (nothing,nothing) player.pot_investment = 0 player.game_profit = Chips(0) player.all_in = false @@ -422,7 +430,8 @@ function tournament!(game::Game) table = game.table players = players_at_table(table) while length(players) > 1 - play!(game) + quit = play!(game) + quit && break n_players_remaining = count(x->!(bank_roll(x) == 0), players) if n_players_remaining ≤ 1 @cinfo logger "Victor emerges!" diff --git a/src/player_type.jl b/src/player_type.jl index d0b577f0..783c154d 100644 --- a/src/player_type.jl +++ b/src/player_type.jl @@ -52,7 +52,7 @@ for flow control logic. mutable struct Player{S #=<: AbstractStrategy=#} strategy::S seat_number::Int - cards::Union{Nothing,Tuple{<:Card,<:Card}} + cards::NTuple{2,Union{Card,Nothing}} bank_roll::Chips game_profit::Chips action_required::Bool @@ -70,7 +70,7 @@ function Base.show(io::IO, player::Player) print(io, "$(name(player)): $(player.cards)") end -function Player(strategy, seat_number = -1, cards = nothing; bank_roll = 200) +function Player(strategy, seat_number = -1, cards = (nothing,nothing); bank_roll = 200) action_required = true all_in = false round_bank_roll = bank_roll @@ -152,5 +152,7 @@ inactive(player::Player) = !active(player) pot_investment(player::Player) = player.pot_investment round_contribution(player::Player) = player.round_contribution strategy(player::Player) = player.strategy +pot_eligible(player::Player) = !folded(player) && still_playing(player) && active(player) +pot_eligible(player::Nothing) = false notify_reward(player) = nothing diff --git a/src/table.jl b/src/table.jl index d86f0d39..c84b6ec3 100644 --- a/src/table.jl +++ b/src/table.jl @@ -50,7 +50,7 @@ buttons(b::Buttons) = ( b.first_to_act, ) -mutable struct Table{P<:Players, L, TM, B <: Blinds, D <: PlayingCards.AbstractDeck} +mutable struct Table{P<:Players, L, TM, B <: Blinds, D <: PlayingCards.AbstractDeck, G} deck::D players::P cards::Union{Nothing,Tuple{<:Card,<:Card,<:Card,<:Card,<:Card}} @@ -65,6 +65,7 @@ mutable struct Table{P<:Players, L, TM, B <: Blinds, D <: PlayingCards.AbstractD play_out_game::Bool n_max_actions::Int logger::L + gui::G end buttons(table::Table) = table.buttons @@ -100,6 +101,8 @@ Table(players; kwargs...) = Table(Players(players); kwargs...) function Table(players::Players; deck = PlayingCards.MaskedDeck(), cards = nothing, + gui = PlainLogger(), # good for test/debugging, but not very fun + # gui = Terminal(), # fun, but not good for tests/debugging blinds = Blinds(), pot = 0, round = PreFlop(), @@ -119,7 +122,7 @@ function Table(players::Players; L = typeof(logger) TM = typeof(transactions) B = typeof(blinds) - return Table{P, L, TM, B, typeof(deck)}(deck, + return Table{P, L, TM, B, typeof(deck), typeof(gui)}(deck, players, cards, blinds, @@ -132,7 +135,8 @@ function Table(players::Players; winners, play_out_game, n_max_actions, - logger) + logger, + gui) end function Buttons(players::Players, dealer_pidx) @@ -180,6 +184,12 @@ observed_cards(table::Table, ::Flop) = table.cards[1:3] observed_cards(table::Table, ::Turn) = table.cards[1:4] observed_cards(table::Table, ::River) = table.cards +observed_cards_all(table::Table) = observed_cards_all(table, round(table)) +observed_cards_all(table::Table, ::PreFlop) = ntuple(_->nothing, 5) +observed_cards_all(table::Table, ::Flop) = (table.cards[1:3]..., nothing, nothing) +observed_cards_all(table::Table, ::Turn) = (table.cards[1:4]..., nothing) +observed_cards_all(table::Table, ::River) = table.cards + # for testing unobserved_cards(table::Table) = unobserved_cards(table, round(table)) unobserved_cards(table::Table, ::PreFlop) = table.cards diff --git a/src/terminal/ascii_card.jl b/src/terminal/ascii_card.jl new file mode 100644 index 00000000..29f731de --- /dev/null +++ b/src/terminal/ascii_card.jl @@ -0,0 +1,61 @@ +import PlayingCards +const PC = PlayingCards +using PlayingCards + +""" + ascii_card(cards...; to_string=true) + +ASCII cards. +""" +# ascii_card(cards...; kwargs...) = ascii_card(cards; kwargs...) +function ascii_card(cards::NTuple;to_string=true, rbuffer=" ") + lines = map(enumerate(cards)) do (i, c) + _rbuffer = iseven(i) ? rbuffer : "" + if c isa Card + r = PC.rank_string(PC.rank(c)) + # space = PC.rank(c)==10 ? "" : " " + space = " " + # get the cards suit in two steps + s = string(PC.suit(c)) + l1 = "┌─────────┐$_rbuffer" + l2 = "│$r$space │$_rbuffer" + l3 = "│ │$_rbuffer" + l4 = "│ │$_rbuffer" + l5 = "│ $s │$_rbuffer" + l6 = "│ │$_rbuffer" + l7 = "│ │$_rbuffer" + l8 = "│ $space$(r)│$_rbuffer" + l9 = "└─────────┘$_rbuffer" + l1,l2,l3,l4,l5,l6,l7,l8,l9 + else + ["┌─────────┐$_rbuffer", map(x->"│░░░░░░░░░│$_rbuffer", 1:7)..., "└─────────┘$_rbuffer"] + end + end + strs = map(1:9) do j + join( + map(1:length(cards)) do i + getindex(lines[i],j) + end, + "" + ) + end + return to_string ? join(strs, "\n") : strs +end + +""" + ascii_card_dealer(cards; to_string=true) + +Similar to `ascii_card`, except that it hides +cards of type `card::Nothing`. +""" +function ascii_card_dealer(cards; to_string=true) + all_lines = map(_->"", 1:9) + for card in cards + card_lines = ascii_card((card,); to_string=false, rbuffer="") + all_lines = [x * y for (x, y) in zip(all_lines, card_lines)] + end + card_str = to_string ? join(all_lines, "\n") : all_lines + width = length(all_lines[1]) + height = length(all_lines) + return (card_str, width, height) +end diff --git a/src/terminal/ascii_player.jl b/src/terminal/ascii_player.jl new file mode 100644 index 00000000..94925771 --- /dev/null +++ b/src/terminal/ascii_player.jl @@ -0,0 +1,26 @@ +import PokerHandEvaluator +const PHE = PokerHandEvaluator + +function ascii_player(table, player, player_cards; to_string=false, rbuffer="") + showdown = table.winners.declared + tm = table.transactions + card_lines = ascii_card(player_cards; to_string, rbuffer) + width = length(card_lines[1]) + net_winnings = showdown ? "Net winnings: $(profit(player, tm))" : "" + hand = if !isnothing(player_cards[1]) && showdown + "$(PHE.hand_type(PHE.CompactHandEval((player_cards..., table.cards...))))" + else + "" + end + info = [ + name(player), + "pot investment: $(pot_investment(player))", + "bank roll: $(bank_roll(player))", + "pot-eligible: $(pot_eligible(player) ? "yes" : "no")", + net_winnings, + hand, + ] + lines = [i * repeat(" ", (width - length(i))) for i in info] + lines = vcat(lines, card_lines) + return to_string ? join(lines, "\n") : lines +end diff --git a/src/terminal/config_game.jl b/src/terminal/config_game.jl index 1a52a767..51e1fe64 100644 --- a/src/terminal/config_game.jl +++ b/src/terminal/config_game.jl @@ -64,7 +64,7 @@ function configure_basic_heads_up_game() Player(Human(), 1; bank_roll=bank_roll), Player(Bot5050(), 2; bank_roll=bank_roll) ) - return Game(players; blinds=blinds) + return Game(players; blinds=blinds, gui=Terminal()) end function configure_basic_1v4_game() @@ -77,7 +77,7 @@ function configure_basic_1v4_game() Player(Bot5050(), 4; bank_roll=bank_roll), Player(Bot5050(), 5; bank_roll=bank_roll), ) - return Game(players; blinds=blinds) + return Game(players; blinds=blinds, gui=Terminal()) end function configure_basic_2_bots_game() @@ -87,7 +87,7 @@ function configure_basic_2_bots_game() Player(Bot5050(), 1; bank_roll=bank_roll), Player(Bot5050(), 2; bank_roll=bank_roll), ) - return Game(players; blinds=blinds) + return Game(players; blinds=blinds, gui=Terminal()) end function configure_basic_4_bots_game() @@ -99,7 +99,7 @@ function configure_basic_4_bots_game() Player(Bot5050(), 3; bank_roll=bank_roll), Player(Bot5050(), 4; bank_roll=bank_roll), ) - return Game(players; blinds=blinds) + return Game(players; blinds=blinds, gui=Terminal()) end function configure_human_players(n_players) @@ -127,7 +127,7 @@ function configure_custom_game() end end - return Game(players; blinds=blinds) + return Game(players; blinds=blinds, gui=Terminal()) end function configure_game() diff --git a/src/terminal/human_player_options.jl b/src/terminal/human_player_options.jl index 60909979..0c79f9be 100644 --- a/src/terminal/human_player_options.jl +++ b/src/terminal/human_player_options.jl @@ -2,19 +2,21 @@ ##### Human player options (ask via prompts) ##### -function player_option(game::Game, player::Player{Human}, ::CheckRaiseFold, io::IO=stdin) +function player_option(game::Game, player::Player{Human}, ::CheckRaiseFold, ioin::IO=stdin) table = game.table + update_gui(stdout, table, player) vrr = valid_raise_range(table, player) options = ["Check", "Raise [$(first(vrr)), $(last(vrr))]", "Fold"] menu = RadioMenu(options, pagesize=4) choice = request("$(name(player))'s turn to act:", menu) choice == -1 && error("Uncaught case") choice == 1 && return Check() - choice == 2 && return Raise(input_raise_amt(table, player, io)) + choice == 2 && return Raise(input_raise_amt(table, player, ioin)) choice == 3 && return Fold() end -function player_option(game::Game, player::Player{Human}, ::CallRaiseFold, io::IO=stdin) +function player_option(game::Game, player::Player{Human}, ::CallRaiseFold, ioin::IO=stdin) table = game.table + update_gui(stdout, table, player) vrr = valid_raise_range(table, player) call_amt = call_amount(table, player) blind_str = is_blind_call(table, player) ? " (blind)" : "" @@ -23,11 +25,12 @@ function player_option(game::Game, player::Player{Human}, ::CallRaiseFold, io::I choice = request("$(name(player))'s turn to act:", menu) choice == -1 && error("Uncaught case") choice == 1 && return Call(table, player) - choice == 2 && return Raise(input_raise_amt(table, player, io)) + choice == 2 && return Raise(input_raise_amt(table, player, ioin)) choice == 3 && return Fold() end -function player_option(game::Game, player::Player{Human}, ::CallAllInFold) +function player_option(game::Game, player::Player{Human}, ::CallAllInFold, ioin::IO=stdin) table = game.table + update_gui(stdout, table, player) call_amt = call_amount(table, player) all_in_amt = round_bank_roll(player) blind_str = is_blind_call(table, player) ? " (blind)" : "" @@ -41,6 +44,7 @@ function player_option(game::Game, player::Player{Human}, ::CallAllInFold) end function player_option(game::Game, player::Player{Human}, ::CallFold) table = game.table + update_gui(stdout, table, player) call_amt = call_amount(table, player) blind_str = is_blind_call(table, player) ? " (blind)" : "" options = ["Call $(call_amt)$blind_str", "Fold"] @@ -51,6 +55,19 @@ function player_option(game::Game, player::Player{Human}, ::CallFold) choice == 2 && return Fold() end +quit_game(game::Game, player::Player, ioin::IO=stdin) = false + +function quit_game(game::Game, player::Player{Human}, ioin::IO=stdin) + table = game.table + update_gui(stdout, table, player) + options = ["Continue playing", "Quite game"] + menu = RadioMenu(options, pagesize=4) + choice = request("Continue or quit?", menu) + choice == -1 && error("Uncaught case") + choice == 1 && return false + choice == 2 && return true +end + # io only works for tests, but does not for user input # so we have a switch for the test suite use_input_io() = false diff --git a/src/terminal/ui.jl b/src/terminal/ui.jl new file mode 100644 index 00000000..1a3a6a18 --- /dev/null +++ b/src/terminal/ui.jl @@ -0,0 +1,75 @@ + +update_gui(table::Table, pov_player=nothing) = update_gui(stdout, table, pov_player) +update_gui(io::IO, table::Table, pov_player) = update_gui(io, table, table.gui, pov_player) + +update_gui(io::IO, table::Table, ::PlainLogger, pov_player) = nothing + +clear_screen(io::IO) = print(io, "\33c\e[3J") + +function combine_ascii_objects(objects) + lengths = length.(objects) + lmax = maximum(lengths) + return map(1:lmax) do i + lines = map(objects) do obj + getindex(obj, i) + end + join(lines, "") + end +end + +function hide_card(winners, pov_player, player) + return if winners.declared + pot_eligible(player) ? false : true + elseif pov_player isa Nothing + true + else + seat_number(pov_player) == seat_number(player) ? false : true + end +end + +function update_gui(io::IO, table::Table, ::Terminal, pov_player) + gui = table.gui + clear_screen(io) + # Print pot info + println(io, " Round: $(nameof(typeof(table.round)))") + println(io, " Chips in pot: $(pot(table.transactions))") + for i in 1:2; println(io); end + ocs = if table.winners.declared + observed_cards_all(table, River()) + else + observed_cards_all(table) + end + (table_cards, width, height) = ascii_card_dealer(ocs; to_string=true) + println(io, " Table cards") + println(io, table_cards) + println(io) + println(io, " Player cards") + println(io) + players = players_at_table(table) + + visible_player_cards = map(players) do player + if hide_card(table.winners, pov_player, player) + (nothing, nothing) + else + cards(player) + end + end + + ascii_players = map(zip(players, visible_player_cards)) do (p, pc) + ascii_player(table, p, pc; rbuffer=" ") + end + ascii_players = combine_ascii_objects(ascii_players) + for line in ascii_players + println(io, line) + end + println(io) + + if table.winners.declared + tm = table.transactions + for (player, player_winnings) in zip(players, tm.side_pot_winnings) + log_player_winnings(player, player_winnings, tm) + end + end + +end + diff --git a/src/transactions.jl b/src/transactions.jl index 91063ff3..e236f2a1 100644 --- a/src/transactions.jl +++ b/src/transactions.jl @@ -222,6 +222,12 @@ contribution_fits_in_sidepot(side_pot, sn::Int, contribution) = contribution_that_fits_in_sidepot(side_pot, sn::Int) = cap(side_pot) - side_pot.amts[sn] +# TODO: should we have a pot/sidepot that persists through `distribute_winnings!`? +function pot(tm::TransactionManager) + sp = sidepot_winnings(tm, length(tm.side_pots)) + return sp == 0 ? sum(spw->sum(spw), tm.side_pot_winnings) : sp +end + Base.@propagate_inbounds function sidepot_winnings(tm::TransactionManager, id::Int) mapreduce(i->sum(tm.side_pots[i].amts), +, 1:id; init=0) end @@ -263,7 +269,7 @@ function distribute_winnings!(players, tm::TransactionManager, table_cards, logg sorted_hand_evals = tm.sorted_hand_evals @inbounds for (ssn, p) in enumerate(perm) player = players[p] - if inactive(player) || folded(player) || !still_playing(player) + if !pot_eligible(player) sorted_hand_evals[ssn].hand_rank = -1 sorted_hand_evals[ssn].hand_type = :empty sorted_hand_evals[ssn].best_cards = ntuple(j->joker, 5) @@ -338,8 +344,8 @@ function distribute_winnings!(players, tm::TransactionManager, table_cards, logg end # Adjust bank rolls: - for (player, initial_br, player_winnings) in zip(players, tm.initial_brs, tm.side_pot_winnings) - ∑spw = sum(player_winnings) + for (player, initial_br, player_sp_winnings) in zip(players, tm.initial_brs, tm.side_pot_winnings) + ∑spw = sum(player_sp_winnings) amt_contributed = initial_br - bank_roll(player) prof = profit(player, tm) player.game_profit = prof @@ -351,16 +357,16 @@ function distribute_winnings!(players, tm::TransactionManager, table_cards, logg end end # Can the above be replaced with - # for (player, initial_br, player_winnings) in zip(players, tm.initial_brs, tm.side_pot_winnings) + # for (player, initial_br, player_sp_winnings) in zip(players, tm.initial_brs, tm.side_pot_winnings) # player.game_profit = profit(player, tm) - # player.bank_roll += sum(player_winnings) + # player.bank_roll += sum(player_sp_winnings) # end # ? if !(logger isa ByPassLogger) - for (player, player_winnings) in zip(players, tm.side_pot_winnings) - log_player_winnings(player, player_winnings, tm) + for (player, player_sp_winnings) in zip(players, tm.side_pot_winnings) + log_player_winnings(player, player_sp_winnings, tm) end end @@ -368,10 +374,10 @@ function distribute_winnings!(players, tm::TransactionManager, table_cards, logg return nothing end -function log_player_winnings(player, player_winnings, tm) +function log_player_winnings(player, player_sp_winnings, tm) logger = tm.logger sorted_hand_evals = tm.sorted_hand_evals - ∑spw = sum(player_winnings) + ∑spw = sum(player_sp_winnings) ssn = tm.unsorted_to_sorted_map[seat_number(player)] prof = profit(player, tm) winnings = ∑spw @@ -389,7 +395,7 @@ function log_player_winnings(player, player_winnings, tm) "$(name(player)): folded / inactive." end else - @cdebug logger "$(name(player))'s side-pot wins: $(player_winnings)!" + @cdebug logger "$(name(player))'s side-pot wins: $(player_sp_winnings)!" @cinfo logger begin "$(name(player)): winnings $(winnings.n), contributed $(contributed.n), net $(net_winnings.n) with $bc ($hand_name)." end diff --git a/test/perf.jl b/test/perf.jl index 71e876a0..732a08f4 100644 --- a/test/perf.jl +++ b/test/perf.jl @@ -31,7 +31,7 @@ game = Game(players();logger=TH.ByPassLogger()) n_expected_failures = Dict() n_expected_failures[v"1.9.2"] = 0 n_expected_failures[v"1.9.3"] = 0 -n_expected_failures[v"1.8.5"] = 11 +n_expected_failures[v"1.8.5"] = 13 nef = get(n_expected_failures, VERSION, minimum(values(n_expected_failures))) @testset "Inference" begin n = @n_failures do_work!(game)