class Poker
Constants
- AI_OPPONENT_NAMES
- HAND_CATEGORY_ORDER
- LEADERBOARD_TITLE
- MAX_BET
- MIN_BET
- STARTING_CREDITS
- TITLE
Public Class Methods
Source
# File plugins/poker.rb, line 413 def self.active_player_names(table) table[:players].reject { |_, player| player[:folded] }.keys end
Source
# File plugins/poker.rb, line 471 def self.advance_street(table) case table[:stage] when :preflop deal_community(table, 3) table[:stage] = :flop when :flop deal_community(table, 1) table[:stage] = :turn when :turn deal_community(table, 1) table[:stage] = :river when :river return :showdown end reset_street_bets(table) table[:stage] end
Source
# File plugins/poker.rb, line 47 def self.ai_player?(user) AI_OPPONENT_NAMES.include?(normalize_user(user)) end
Source
# File plugins/poker.rb, line 449 def self.auto_play_ai_opponents(table, channel:, store:, tables:, viewer: nil, _internal: false) while !table[:action_queue].empty? && ai_player?(table[:action_queue].first) ai_name = table[:action_queue].first action = choose_ai_action(table, ai_name, store: store) result = player_action(ai_name, action, channel: channel, store: store, tables: tables, _ai_internal: true) return result if result[:resolved] || result[:error] end if betting_round_complete?(table) return complete_betting_round(table, channel: channel, store: store, tables: tables, viewer: viewer) end nil end
Source
# File plugins/poker.rb, line 507 def self.award_pot(winner, amount, store:) credit_winnings(winner, amount, store: store) amount end
Source
# File plugins/poker.rb, line 158 def self.best_hand(cards) return hand_score(cards) if cards.length <= 5 cards.combination(5).map { |combo| hand_score(combo) }.max { |a, b| compare_scores(a, b) } end
Source
# File plugins/poker.rb, line 489 def self.betting_round_complete?(table) return table[:action_queue].empty? if solo_vs_dealer?(table) active = active_player_names(table) return true if active.length <= 1 active.all? do |name| player = table[:players][name] player[:street_bet] == table[:current_bet] || player[:folded] end && table[:action_queue].empty? end
Source
# File plugins/poker.rb, line 71 def self.choose_ai_action(table, ai_name, store: default_store) player = table[:players][ai_name] owed = table[:current_bet] - player[:street_bet] return :check if owed.zero? balance = balance_for(ai_name, store: store) owed <= balance ? :call : :fold end
Source
# File plugins/poker.rb, line 15 def self.command_pattern /poker(?: (.+))?$/i end
Source
# File plugins/poker.rb, line 164 def self.compare_scores(a, b) a_order = HAND_CATEGORY_ORDER[a[:category]] b_order = HAND_CATEGORY_ORDER[b[:category]] return a_order <=> b_order unless a_order == b_order a[:ranks] <=> b[:ranks] end
Source
# File plugins/poker.rb, line 570 def self.complete_betting_round(table, channel:, store:, tables:, viewer: nil) dealer_auto_act(table) return showdown(table, channel: channel, store: store, tables: tables) if advance_street(table) == :showdown street = format_street_name(table[:stage]) { message: "#{format_table_state(table, viewer: viewer)}\n#{STYLE_LABEL}#{street}#{IRC_RESET} — betting open." } end
Source
# File plugins/poker.rb, line 464 def self.deal_community(table, count) count.times do card, table[:deck] = deal_card(table[:deck]) table[:community] << card end end
Source
# File plugins/poker.rb, line 395 def self.deal_hole_cards(table) table[:players].each_key do |name| 2.times do card, table[:deck] = deal_card(table[:deck]) table[:players][name][:hole] << card end end if table[:players].length == 1 dealer_hole = [] 2.times do card, table[:deck] = deal_card(table[:deck]) dealer_hole << card end table[:dealer] = { hole: dealer_hole, street_bet: 0, folded: false } end end
Source
# File plugins/poker.rb, line 501 def self.dealer_auto_act(table) return unless table[:dealer] && !table[:dealer][:folded] table[:dealer][:street_bet] = table[:current_bet] end
Source
# File plugins/poker.rb, line 661 def self.dispatch_command(args_text, user:, channel: "#default", store: default_store, tables: default_tables, deck: nil, rand: ->(max) { Random.rand(0...max) }) parsed = parse_command(args_text.to_s) return { error: parsed[:error] } if parsed[:error] return { message: usage_message } if parsed[:action] == :help case parsed[:action] when :balance { message: format_balance(user, balance_for(user, store: store)) } when :leaderboard { message: format_leaderboard(store) } when :new new_table(user, parsed[:amount], channel: channel, store: store, tables: tables, deck: deck, rand: rand) when :join join_table(user, channel: channel, store: store, tables: tables) when :start start_hand(user, channel: channel, store: store, tables: tables) when :stop stop_table(user, channel: channel, store: store, tables: tables) when :fold player_action(user, :fold, channel: channel, store: store, tables: tables) when :check player_action(user, :check, channel: channel, store: store, tables: tables) when :call player_action(user, :call, channel: channel, store: store, tables: tables) when :raise player_action(user, :raise, amount: parsed[:amount], channel: channel, store: store, tables: tables) when :table { message: table_status(channel: channel, tables: tables) } else { message: usage_message } end end
Source
# File plugins/poker.rb, line 51 def self.ensure_ai_opponents(table, store: default_store) return unless table[:stage] == :lobby return unless table[:players].length == 1 AI_OPPONENT_NAMES.each do |ai_name| next if table[:players][ai_name] amount = table[:buy_in] deduct_bet(ai_name, amount, store: store) table[:players][ai_name] = { hole: [], street_bet: 0, folded: false, invested: amount, ai: true } table[:pot] += amount end end
Source
# File plugins/poker.rb, line 238 def self.format_balance(user, balance) super(user, balance, title: TITLE) end
Calls superclass method
Game::format_balance
Source
# File plugins/poker.rb, line 242 def self.format_leaderboard(store) super(store, title: LEADERBOARD_TITLE, empty_message: "No hands played yet — shuffle up!") end
Calls superclass method
Game::format_leaderboard
Source
# File plugins/poker.rb, line 259 def self.format_street_name(stage) case stage when :preflop then "Preflop" when :flop then "Flop" when :turn then "Turn" when :river then "River" else stage.to_s.capitalize end end
Source
# File plugins/poker.rb, line 246 def self.format_table_lobby(table) players = table[:players].keys lines = [ "#{STYLE_TITLE}══════ ♦ POKER TABLE ♦ ══════#{IRC_RESET}", "#{STYLE_LABEL}Host:#{IRC_RESET} #{STYLE_NICK}#{table[:host]}#{IRC_RESET} " \ "#{STYLE_LABEL}│#{IRC_RESET} #{STYLE_LABEL}Buy-in:#{IRC_RESET} #{STYLE_VALUE}#{table[:buy_in]}#{IRC_RESET}", "#{STYLE_LABEL}Players (#{players.length}):#{IRC_RESET} #{players.map { |p| "#{STYLE_NICK}#{p}#{IRC_RESET}" }.join(", ")}", "#{STYLE_HINT}Join:#{IRC_RESET} !poker join #{STYLE_HINT}|#{IRC_RESET} " \ "#{STYLE_HINT}Start:#{IRC_RESET} !poker start" ] lines.join("\n") end
Source
# File plugins/poker.rb, line 269 def self.format_table_state(table, viewer: nil) community = table[:community] pot = table[:pot] multiplayer = table[:players].length > 1 solo = solo_vs_dealer?(table) lines = [ "#{STYLE_TITLE}══════ ♦ POKER ♦ ══════#{IRC_RESET} " \ "#{STYLE_LABEL}│#{IRC_RESET} #{STYLE_LABEL}#{format_street_name(table[:stage])}#{IRC_RESET} " \ "#{STYLE_LABEL}│#{IRC_RESET} #{STYLE_LABEL}Pot:#{IRC_RESET} #{STYLE_VALUE}#{pot}#{IRC_RESET}", "#{STYLE_LABEL}Board:#{IRC_RESET} #{community.empty? ? "#{STYLE_HINT}(none)#{IRC_RESET}" : format_hand(community)}" ] table[:players].each do |name, player| next if player[:folded] hole_display = if multiplayer format_hand(player[:hole], hidden: true) elsif solo && viewer && normalize_user(viewer) == name format_hand(player[:hole]) elsif viewer && normalize_user(viewer) == name format_hand(player[:hole]) else format_hand(player[:hole], hidden: true) end lines << "#{STYLE_NICK}#{name}#{IRC_RESET} #{hole_display} " \ "#{STYLE_LABEL}bet:#{IRC_RESET} #{STYLE_VALUE}#{player[:street_bet]}#{IRC_RESET}" end if table[:dealer] && !table[:dealer][:folded] if solo lines << "#{STYLE_LABEL}Dealer#{IRC_RESET}" else lines << "#{STYLE_LABEL}Dealer#{IRC_RESET} #{format_hand(table[:dealer][:hole], hidden: true)}" end end lines.join("\n") end
Source
# File plugins/poker.rb, line 172 def self.hand_category_name(category, ranks) case category when :straight_flush ranks.first == 14 ? "Royal Flush" : "Straight Flush" when :four_of_a_kind then "Four of a Kind" when :full_house then "Full House" when :flush then "Flush" when :straight then "Straight" when :three_of_a_kind then "Three of a Kind" when :two_pair then "Two Pair" when :one_pair then "Pair of #{rank_name(ranks.first)}s" else "High Card" end end
Source
# File plugins/poker.rb, line 90 def self.hand_score(cards) values = cards.map { |card| rank_value(card[:rank]) }.sort.reverse suits = cards.map { |card| card[:suit] } counts = values.tally.sort_by { |rank, count| [count, rank] }.reverse flush = suits.tally.values.max >= 5 straight_high = straight_high_card(values) if flush && straight_high suited_values = cards.group_by { |c| c[:suit] }.values.find { |group| group.length >= 5 } .map { |c| rank_value(c[:rank]) }.sort.reverse sf_high = straight_high_card(suited_values) return { category: :straight_flush, ranks: [sf_high] } if sf_high end if counts[0][1] == 4 quad = counts[0][0] kicker = counts[1][0] return { category: :four_of_a_kind, ranks: [quad, kicker] } end if counts[0][1] == 3 && counts[1][1] >= 2 return { category: :full_house, ranks: [counts[0][0], counts[1][0]] } end if flush flush_ranks = cards.group_by { |c| c[:suit] }.values.find { |group| group.length >= 5 } .map { |c| rank_value(c[:rank]) }.sort.reverse.take(5) return { category: :flush, ranks: flush_ranks } end return { category: :straight, ranks: [straight_high] } if straight_high if counts[0][1] == 3 kickers = counts[1..].map(&:first) return { category: :three_of_a_kind, ranks: [counts[0][0]] + kickers } end if counts[0][1] == 2 && counts[1][1] == 2 high_pair, low_pair = [counts[0][0], counts[1][0]].sort.reverse kicker = counts[2][0] return { category: :two_pair, ranks: [high_pair, low_pair, kicker] } end if counts[0][1] == 2 pair = counts[0][0] kickers = counts[1..].map(&:first) return { category: :one_pair, ranks: [pair] + kickers } end { category: :high_card, ranks: values.take(5) } end
Source
# File plugins/poker.rb, line 374 def self.join_table(user, channel:, store: default_store, tables: default_tables) table = active_table(channel, tables: tables) return { error: "#{user}, no table here — use !poker new <amount> to open one." } unless table return { error: "#{user}, the hand already started." } unless table[:stage] == :lobby normalized = normalize_user(user) return { error: "#{user}, you're already seated." } if table[:players][normalized] amount = table[:buy_in] balance = balance_for(user, store: store) if amount > balance return insufficient_credits_error(user, "#{user}, not enough credits (balance: #{balance}).", store: store) end deduct_bet(user, amount, store: store) table[:players][normalized] = { hole: [], street_bet: 0, folded: false, invested: amount } table[:pot] += amount { message: format_table_lobby(table).sub("Join:", "#{STYLE_NICK}#{normalized}#{IRC_RESET} joined! #{STYLE_HINT}Join:") } end
Source
# File plugins/poker.rb, line 344 def self.new_table(user, amount, channel:, store: default_store, tables: default_tables, deck: nil, rand: ->(max) { Random.rand(0...max) }) key = channel.to_s return { error: "#{user}, a table is already open in this channel." } if tables[key] balance = balance_for(user, store: store) if amount > balance return insufficient_credits_error(user, "#{user}, not enough credits (balance: #{balance}).", store: store) end deduct_bet(user, amount, store: store) normalized = normalize_user(user) tables[key] = { host: normalized, buy_in: amount, stage: :lobby, players: { normalized => { hole: [], street_bet: 0, folded: false, invested: amount } }, pot: amount, deck: deck ? deck.dup : shuffle_deck(build_deck, rand: rand), community: [], current_bet: 0, action_queue: [], dealer: nil } message = format_table_lobby(tables[key]) { message: message } end
Source
# File plugins/poker.rb, line 197 def self.parse_command(text) parts = text.to_s.strip.split(/\s+/) return { action: :help } if parts.empty? meta = parse_meta_command(parts) return meta if meta case parts[0].downcase when "new" return command_error unless parts.length >= 2 amount = parts[1].to_i return command_error unless valid_amount?(amount) return { action: :new, amount: amount } when "join" return { action: :join } when "start" return { action: :start } when "fold" return { action: :fold } when "check" return { action: :check } when "call" return { action: :call } when "raise" return command_error unless parts.length >= 2 amount = parts[1].to_i return command_error unless valid_amount?(amount) return { action: :raise, amount: amount } when "table", "status" return { action: :table } when "stop", "close" return { action: :stop } end command_error end
Source
# File plugins/poker.rb, line 578 def self.player_action(user, action, amount: nil, channel:, store: default_store, tables: default_tables, _ai_internal: false) table = active_table(channel, tables: tables) return { error: "#{user}, no active hand." } unless table return { error: "#{user}, still in the lobby — !poker start when ready." } if table[:stage] == :lobby normalized = normalize_user(user) player = table[:players][normalized] return { error: "#{user}, you're not at this table." } unless player return { error: "#{user}, you already folded." } if player[:folded] unless table[:action_queue].empty? expected = table[:action_queue].first return { error: "#{user}, wait — it's #{expected}'s turn." } unless expected == normalized end case action when :fold player[:folded] = true table[:action_queue].shift return resolve_fold(table, normalized, channel: channel, store: store, tables: tables) when :check if player[:street_bet] < table[:current_bet] return { error: "#{user}, you must call #{table[:current_bet] - player[:street_bet]} or raise." } end table[:action_queue].shift when :call owed = table[:current_bet] - player[:street_bet] return player_action(user, :check, channel: channel, store: store, tables: tables) if owed.zero? balance = balance_for(user, store: store) if owed > balance return insufficient_credits_error(user, "#{user}, not enough credits to call #{owed}.", store: store) end deduct_bet(user, owed, store: store) player[:street_bet] += owed player[:invested] += owed table[:pot] += owed table[:action_queue].shift when :raise raise_amount = amount.to_i total = table[:current_bet] - player[:street_bet] + raise_amount balance = balance_for(user, store: store) if total > balance return insufficient_credits_error(user, "#{user}, not enough credits to raise.", store: store) end deduct_bet(user, total, store: store) player[:street_bet] += total player[:invested] += total table[:pot] += total table[:current_bet] = player[:street_bet] table[:action_queue] = active_player_names(table).reject { |n| n == normalized } end dealer_auto_act(table) if solo_vs_dealer?(table) && table[:action_queue].empty? unless _ai_internal ai_result = auto_play_ai_opponents(table, channel: channel, store: store, tables: tables, viewer: user) return ai_result if ai_result end if betting_round_complete?(table) return complete_betting_round(table, channel: channel, store: store, tables: tables, viewer: user) end dealer_auto_act(table) { message: format_table_state(table, viewer: user) } end
Source
# File plugins/poker.rb, line 695 def self.poker(args_text, user:, channel: "#default", store: default_store, tables: default_tables, deck: nil, rand: ->(max) { Random.rand(0...max) }) result = dispatch_command(args_text, user: user, channel: channel, store: store, tables: tables, deck: deck, rand: rand) return result[:error] if result[:error] result[:message] || usage_message end
Source
# File plugins/poker.rb, line 312 def self.private_hand_messages(table) return {} unless table[:players].length > 1 table[:players].each_with_object({}) do |(name, player), messages| next if ai_player?(name) plain_hand = player[:hole].map { |card| "#{card[:rank]}#{SUIT_SYMBOLS[card[:suit]]}" }.join(" ") messages[name] = "#{STYLE_TITLE}Your hand#{IRC_RESET} #{STYLE_NICK}#{name}#{IRC_RESET}: " \ "#{format_hand(player[:hole])} #{STYLE_HINT}(#{plain_hand})#{IRC_RESET}" end end
Source
# File plugins/poker.rb, line 187 def self.rank_name(value) case value when 14 then "Ace" when 13 then "King" when 12 then "Queen" when 11 then "Jack" else value.to_s end end
Source
# File plugins/poker.rb, line 80 def self.rank_value(rank) case rank when "A" then 14 when "K" then 13 when "Q" then 12 when "J" then 11 else rank.to_i end end
Source
# File plugins/poker.rb, line 417 def self.reset_street_bets(table) table[:current_bet] = 0 table[:players].each_value { |p| p[:street_bet] = 0 } table[:dealer][:street_bet] = 0 if table[:dealer] table[:action_queue] = active_player_names(table).dup end
Source
# File plugins/poker.rb, line 512 def self.resolve_fold(table, folder, channel:, store:, tables:) remaining = active_player_names(table) if remaining.length == 1 winner = remaining.first pot = award_pot(winner, table[:pot], store: store) message = "#{STYLE_WIN}#{folder} folded — #{winner} wins #{pot} credits!#{IRC_RESET}" clear_table(channel, tables: tables) return { resolved: true, message: message } end { message: format_table_state(table) } end
Source
# File plugins/poker.rb, line 525 def self.showdown(table, channel:, store:, tables:) results = [] table[:players].each do |name, player| next if player[:folded] score = best_hand(player[:hole] + table[:community]) results << { name: name, score: score } end if table[:dealer] && !table[:dealer][:folded] score = best_hand(table[:dealer][:hole] + table[:community]) results << { name: "dealer", score: score } end best = results.max { |a, b| compare_scores(a[:score], b[:score]) } winners = results.select { |r| compare_scores(r[:score], best[:score]).zero? } playable_winners = winners.reject { |w| w[:name] == "dealer" } lines = ["#{STYLE_TITLE}═══ SHOWDOWN ═══#{IRC_RESET}"] results.each do |entry| label = entry[:name] == "dealer" ? "Dealer" : entry[:name] cards = entry[:name] == "dealer" ? table[:dealer][:hole] : table[:players][entry[:name]][:hole] hand_name = hand_category_name(entry[:score][:category], entry[:score][:ranks]) lines << "#{STYLE_NICK}#{label}#{IRC_RESET} #{format_hand(cards)} — #{STYLE_VALUE}#{hand_name}#{IRC_RESET}" end pot = table[:pot] if playable_winners.length == 1 winner = playable_winners.first[:name] award_pot(winner, pot, store: store) lines << "#{STYLE_WIN}>>> #{winner} wins #{pot} credits! <<<#{IRC_RESET}" elsif playable_winners.empty? lines << "#{STYLE_LOSE}>>> Dealer wins the pot <<<#{IRC_RESET}" else share = pot / playable_winners.length playable_winners.each { |w| award_pot(w[:name], share, store: store) } names = playable_winners.map { |w| w[:name] }.join(", ") lines << "#{STYLE_WIN}>>> Split pot — #{names} win #{share} each <<<#{IRC_RESET}" end clear_table(channel, tables: tables) { resolved: true, message: lines.join("\n") } end
Source
# File plugins/poker.rb, line 308 def self.solo_vs_dealer?(table) table[:dealer] && table[:players].reject { |_, player| player[:folded] }.length == 1 end
Source
# File plugins/poker.rb, line 424 def self.start_hand(user, channel:, store: default_store, tables: default_tables) table = active_table(channel, tables: tables) return { error: "#{user}, no table here." } unless table return { error: "#{user}, only the host can start." } unless normalize_user(user) == table[:host] return { error: "#{user}, hand already in progress." } unless table[:stage] == :lobby added_ai = table[:players].length == 1 ensure_ai_opponents(table, store: store) deal_hole_cards(table) table[:stage] = :preflop reset_street_bets(table) message = "#{format_table_state(table, viewer: user)}\n" if added_ai ai_list = AI_OPPONENT_NAMES.map { |ai| "#{STYLE_NICK}#{ai}#{IRC_RESET}" }.join(", ") message += "#{STYLE_LABEL}AI opponents joined:#{IRC_RESET} #{ai_list}\n" end message += "#{STYLE_HINT}Check/Call/Fold/Raise — e.g. !poker check#{IRC_RESET}" if table[:players].length > 1 message += "\n#{STYLE_HINT}Hole cards sent by private message.#{IRC_RESET}" end { message: message, private_messages: private_hand_messages(table) } end
Source
# File plugins/poker.rb, line 324 def self.stop_table(user, channel:, store: default_store, tables: default_tables) table = active_table(channel, tables: tables) return { error: "#{user}, no table here." } unless table normalized = normalize_user(user) unless table[:players][normalized] return { error: "#{user}, you're not at this table." } end table[:players].each do |name, player| credit_winnings(name, player[:invested], store: store) end clear_table(channel, tables: tables) { message: "#{STYLE_TITLE}Table closed by #{STYLE_NICK}#{normalized}#{IRC_RESET}. " \ "#{STYLE_LABEL}Buy-ins refunded.#{IRC_RESET}" } end
Source
# File plugins/poker.rb, line 142 def self.straight_high_card(values) unique = values.uniq.sort.reverse return nil if unique.length < 5 (0...(unique.length - 4)).each do |index| segment = unique[index, 5] next unless segment.length == 5 return segment.first if segment.each_cons(2).all? { |a, b| a - b == 1 } end wheel = [14, 5, 4, 3, 2] return 5 if (wheel - unique).empty? nil end
Source
# File plugins/poker.rb, line 650 def self.table_status(channel:, tables: default_tables) table = active_table(channel, tables: tables) return "No poker table in this channel — !poker new <amount> to open one." unless table if table[:stage] == :lobby format_table_lobby(table) else format_table_state(table) end end
Source
# File plugins/poker.rb, line 41 def self.usage_message "Usage: !poker new <amount> — open a table | !poker join | !poker start | " \ "!poker stop — close the table | !poker check | !poker call | !poker raise <amount> | " \ "!poker fold | !poker table | !poker balance | !poker leaderboard" end
Public Instance Methods
Source
# File plugins/poker.rb, line 712 def deliver_private_messages(source_message, private_messages) return if private_messages.nil? || private_messages.empty? private_messages.each do |nick, text| next if text.nil? || text.empty? user = User(nick) reply_pieces(text, message: source_message).each_with_index do |piece, index| delay = FloodSafe::CHUNK_DELAY_SEC * index if delay.zero? user.send(piece) else Timer(delay, shots: 1) { user.send(piece) } end end end end
Source
# File plugins/poker.rb, line 730 def execute(m, args_text = nil) channel = m.channel ? m.channel.name : m.user.nick result = self.class.dispatch_command( args_text, user: m.user.nick, channel: channel, store: self.class.default_store, tables: self.class.default_tables ) if result[:error] themed_flood_safe_reply(m, result[:error]) return end themed_flood_safe_reply(m, result[:message]) if result[:message] deliver_private_messages(m, result[:private_messages]) end
Source
# File plugins/poker.rb, line 704 def poker(args_text = nil, **options) channel = options[:channel] || "#default" self.class.poker(args_text, user: options[:user], channel: channel, store: options[:store] || self.class.default_store, tables: options[:tables] || self.class.default_tables, deck: options[:deck], rand: options[:rand] || ->(max) { Random.rand(0...max) }) end