562 lines
18 KiB
Go
562 lines
18 KiB
Go
package main
|
||
|
||
import (
|
||
"errors"
|
||
"math/rand"
|
||
"sort"
|
||
)
|
||
|
||
// Техасский Холдем (No-Limit) — турнирный формат с выбыванием: игра
|
||
// идёт до тех пор, пока не останется один игрок с фишками. Блайнды
|
||
// фиксированы (не растут по ходу турнира) — это сознательное
|
||
// упрощение ради управляемого объёма игры.
|
||
//
|
||
// ВАЖНОЕ УПРОЩЕНИЕ ПРАВИЛ: короткий олл-ин рейзом МЕНЬШЕ полного
|
||
// размера предыдущего рейза, по строгим правилам, не должен заново
|
||
// открывать торги игрокам, которые уже походили в этом раунде (они
|
||
// обязаны лишь доколлировать либо сбросить, но не имеют права
|
||
// повторно рейзить в ответ именно на такой недостаточный рейз). Эта
|
||
// игра такого разграничения не делает — любой рейз (в том числе
|
||
// короткий олл-ин) заново даёт всем активным игрокам право хода,
|
||
// включая повторный рейз. Расхождение с турнирными правилами
|
||
// возможно только в редких ситуациях с несколькими короткостёчными
|
||
// олл-инами подряд.
|
||
|
||
// PokerPhase — фаза текущей раздачи.
|
||
type PokerPhase int
|
||
|
||
const (
|
||
PokerPhasePreflop PokerPhase = iota
|
||
PokerPhaseFlop
|
||
PokerPhaseTurn
|
||
PokerPhaseRiver
|
||
PokerPhaseShowdown // раздача завершена, идёт показ карт/распределение банка
|
||
PokerPhaseTournamentOver // остался один игрок с фишками — турнир окончен
|
||
)
|
||
|
||
// PokerPlayer — один участник партии.
|
||
type PokerPlayer struct {
|
||
Name string
|
||
Stack int // фишки, НЕ поставленные в текущий банк
|
||
Bot *PokerBot
|
||
|
||
HoleCards [2]Card
|
||
Folded bool
|
||
AllIn bool
|
||
Eliminated bool // стек обнулился — выбыл из турнира насовсем
|
||
|
||
CurrentBet int // ставка ТЕКУЩЕГО раунда торгов (preflop/flop/turn/river)
|
||
TotalContributed int // сколько всего внесено в банк за ВСЮ раздачу (для сайд-потов)
|
||
}
|
||
|
||
// PokerSidePot — один "слой" банка с собственным списком, кто может
|
||
// на него претендовать (см. computeSidePots).
|
||
type PokerSidePot struct {
|
||
Amount int
|
||
EligiblePlayers []int
|
||
}
|
||
|
||
// PokerHandResult — итог завершённой раздачи (для отображения на
|
||
// экране шоудауна/сброса).
|
||
type PokerHandResult struct {
|
||
Pots []PokerSidePot
|
||
Winners map[int]int // индекс игрока -> сколько выиграл всего по всем банкам
|
||
WentToShowdown bool
|
||
HandValues map[int]PokerHandValue // только для тех, кто дошёл до шоудауна
|
||
}
|
||
|
||
// PokerGameState — состояние всей партии (турнира).
|
||
type PokerGameState struct {
|
||
Players []*PokerPlayer
|
||
Deck []Card
|
||
Board []Card
|
||
|
||
DealerIdx int
|
||
SmallBlind int
|
||
BigBlind int
|
||
|
||
Phase PokerPhase
|
||
CurrentPlayerIdx int
|
||
|
||
actedThisRound map[int]bool // кто уже отреагировал на ТЕКУЩИЙ уровень ставки в этом раунде торгов
|
||
lastAggressorIdx int // кто последний раз ставил/рейзил в этом раунде (-1, если раунд ещё без ставок)
|
||
|
||
Result *PokerHandResult
|
||
}
|
||
|
||
var (
|
||
ErrPokerWrongPhase = errors.New("недопустимое действие для текущей фазы")
|
||
ErrPokerNotYourTurn = errors.New("сейчас не ваш ход")
|
||
ErrPokerCannotCheck = errors.New("нельзя пасовать (чек) — на столе уже есть ставка, которую нужно уравнять или сбросить")
|
||
ErrPokerBetTooLow = errors.New("ставка меньше минимально допустимой")
|
||
ErrPokerNotEnoughChips = errors.New("недостаточно фишек для такой ставки")
|
||
)
|
||
|
||
// NewPokerGame создаёт новый турнир: заданное число игроков (первый
|
||
// — человек), фиксированный стартовый стек и блайнды.
|
||
func NewPokerGame(numPlayers, startingStack, smallBlind, bigBlind int, rnd *rand.Rand) *PokerGameState {
|
||
g := &PokerGameState{SmallBlind: smallBlind, BigBlind: bigBlind, DealerIdx: -1}
|
||
for i := 0; i < numPlayers; i++ {
|
||
g.Players = append(g.Players, &PokerPlayer{Stack: startingStack})
|
||
}
|
||
g.startNewHand(rnd)
|
||
return g
|
||
}
|
||
|
||
// activeTournamentPlayers — индексы игроков, ещё не выбывших из
|
||
// турнира.
|
||
func (g *PokerGameState) activeTournamentPlayers() []int {
|
||
var out []int
|
||
for i, p := range g.Players {
|
||
if !p.Eliminated {
|
||
out = append(out, i)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// nextActiveTournamentSeat находит следующее по кругу от from место
|
||
// среди ещё не выбывших из турнира игроков.
|
||
func (g *PokerGameState) nextActiveTournamentSeat(from int) int {
|
||
n := len(g.Players)
|
||
for i := 1; i <= n; i++ {
|
||
idx := (from + i) % n
|
||
if !g.Players[idx].Eliminated {
|
||
return idx
|
||
}
|
||
}
|
||
return from
|
||
}
|
||
|
||
// nextToAct находит следующего игрока по кругу от from, который ещё
|
||
// может действовать в текущем раунде торгов (не сбросил, не в
|
||
// олл-ине, не выбыл).
|
||
func (g *PokerGameState) nextToAct(from int) int {
|
||
n := len(g.Players)
|
||
for i := 1; i <= n; i++ {
|
||
idx := (from + i) % n
|
||
p := g.Players[idx]
|
||
if !p.Folded && !p.AllIn && !p.Eliminated {
|
||
return idx
|
||
}
|
||
}
|
||
return from
|
||
}
|
||
|
||
func (g *PokerGameState) countActingPlayers() int {
|
||
n := 0
|
||
for _, p := range g.Players {
|
||
if !p.Folded && !p.AllIn && !p.Eliminated {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
func (g *PokerGameState) countInHand() int {
|
||
n := 0
|
||
for _, p := range g.Players {
|
||
if !p.Folded && !p.Eliminated {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
// startNewHand начинает новую раздачу: сдвигает дилера, раздаёт
|
||
// карты, ставит блайнды, определяет первый ход.
|
||
func (g *PokerGameState) startNewHand(rnd *rand.Rand) {
|
||
active := g.activeTournamentPlayers()
|
||
if len(active) <= 1 {
|
||
g.Phase = PokerPhaseTournamentOver
|
||
return
|
||
}
|
||
|
||
for _, p := range g.Players {
|
||
p.HoleCards = [2]Card{}
|
||
p.Folded = p.Eliminated
|
||
p.AllIn = false
|
||
p.CurrentBet = 0
|
||
p.TotalContributed = 0
|
||
}
|
||
g.Result = nil
|
||
g.Board = nil
|
||
|
||
g.Deck = shuffledDeck(rnd)
|
||
|
||
if g.DealerIdx < 0 {
|
||
g.DealerIdx = active[0]
|
||
} else {
|
||
g.DealerIdx = g.nextActiveTournamentSeat(g.DealerIdx)
|
||
}
|
||
|
||
// В игре один на один (heads-up) дилер ставит МАЛЫЙ блайнд сам
|
||
// (а не "следующий" игрок) — общее правило "SB = следующий после
|
||
// дилера" здесь даёт неверный результат, так как с двумя
|
||
// игроками "следующий после дилера" и "следующий после этого
|
||
// игрока" зацикливаются друг на друга, меняя роли блайндов местами.
|
||
var sbIdx, bbIdx int
|
||
if len(active) == 2 {
|
||
sbIdx = g.DealerIdx
|
||
bbIdx = g.nextActiveTournamentSeat(g.DealerIdx)
|
||
} else {
|
||
sbIdx = g.nextActiveTournamentSeat(g.DealerIdx)
|
||
bbIdx = g.nextActiveTournamentSeat(sbIdx)
|
||
}
|
||
g.postBlind(sbIdx, g.SmallBlind)
|
||
g.postBlind(bbIdx, g.BigBlind)
|
||
|
||
for i := 0; i < 2; i++ {
|
||
for _, idx := range g.activeTournamentPlayers() {
|
||
g.Players[idx].HoleCards[i] = g.drawCard()
|
||
}
|
||
}
|
||
|
||
g.Phase = PokerPhasePreflop
|
||
g.lastAggressorIdx = bbIdx
|
||
g.actedThisRound = map[int]bool{}
|
||
g.CurrentPlayerIdx = g.nextToAct(bbIdx)
|
||
}
|
||
|
||
func (g *PokerGameState) postBlind(idx, amount int) {
|
||
p := g.Players[idx]
|
||
if amount >= p.Stack {
|
||
amount = p.Stack
|
||
p.AllIn = true
|
||
}
|
||
p.Stack -= amount
|
||
p.CurrentBet = amount
|
||
p.TotalContributed = amount
|
||
}
|
||
|
||
func (g *PokerGameState) drawCard() Card {
|
||
card := g.Deck[0]
|
||
g.Deck = g.Deck[1:]
|
||
return card
|
||
}
|
||
|
||
func shuffledDeck(rnd *rand.Rand) []Card {
|
||
deck := make([]Card, 0, 52)
|
||
for s := Clubs; s <= Spades; s++ {
|
||
for r := Ace; r <= King; r++ {
|
||
deck = append(deck, Card{Suit: s, Rank: r})
|
||
}
|
||
}
|
||
rnd.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] })
|
||
return deck
|
||
}
|
||
|
||
// highestBet — самая большая ставка ЭТОГО раунда торгов среди всех
|
||
// игроков.
|
||
func (g *PokerGameState) highestBet() int {
|
||
max := 0
|
||
for _, p := range g.Players {
|
||
if p.CurrentBet > max {
|
||
max = p.CurrentBet
|
||
}
|
||
}
|
||
return max
|
||
}
|
||
|
||
// Fold — игрок сбрасывает карты.
|
||
func (g *PokerGameState) Fold() error {
|
||
if err := g.checkCanAct(); err != nil {
|
||
return err
|
||
}
|
||
g.Players[g.CurrentPlayerIdx].Folded = true
|
||
g.advanceAfterAction()
|
||
return nil
|
||
}
|
||
|
||
// Check — пропустить ход без ставки (легально только если нечего уравнивать).
|
||
func (g *PokerGameState) Check() error {
|
||
if err := g.checkCanAct(); err != nil {
|
||
return err
|
||
}
|
||
p := g.Players[g.CurrentPlayerIdx]
|
||
if p.CurrentBet != g.highestBet() {
|
||
return ErrPokerCannotCheck
|
||
}
|
||
g.advanceAfterAction()
|
||
return nil
|
||
}
|
||
|
||
// Call — уравнять текущую наибольшую ставку (либо пойти в олл-ин,
|
||
// если фишек не хватает на полное уравнивание).
|
||
func (g *PokerGameState) Call() error {
|
||
if err := g.checkCanAct(); err != nil {
|
||
return err
|
||
}
|
||
p := g.Players[g.CurrentPlayerIdx]
|
||
need := g.highestBet() - p.CurrentBet
|
||
if need <= 0 {
|
||
return g.Check()
|
||
}
|
||
if need >= p.Stack {
|
||
need = p.Stack
|
||
p.AllIn = true
|
||
}
|
||
p.Stack -= need
|
||
p.CurrentBet += need
|
||
p.TotalContributed += need
|
||
g.advanceAfterAction()
|
||
return nil
|
||
}
|
||
|
||
// Bet ставит (или рейзит) так, чтобы СУММАРНАЯ ставка игрока в этом
|
||
// раунде торгов стала равна totalAmount. totalAmount должен быть не
|
||
// меньше текущей наибольшей ставки плюс большой блайнд (минимальный
|
||
// рейз в этой упрощённой реализации — см. пояснение в начале файла).
|
||
func (g *PokerGameState) Bet(totalAmount int) error {
|
||
if err := g.checkCanAct(); err != nil {
|
||
return err
|
||
}
|
||
p := g.Players[g.CurrentPlayerIdx]
|
||
minTotal := g.highestBet() + g.BigBlind
|
||
if g.highestBet() == 0 {
|
||
minTotal = g.BigBlind
|
||
}
|
||
maxTotal := p.CurrentBet + p.Stack
|
||
if totalAmount > maxTotal {
|
||
return ErrPokerNotEnoughChips
|
||
}
|
||
if totalAmount < minTotal && totalAmount < maxTotal {
|
||
return ErrPokerBetTooLow
|
||
}
|
||
delta := totalAmount - p.CurrentBet
|
||
p.Stack -= delta
|
||
p.CurrentBet = totalAmount
|
||
p.TotalContributed += delta
|
||
if p.Stack == 0 {
|
||
p.AllIn = true
|
||
}
|
||
g.lastAggressorIdx = g.CurrentPlayerIdx
|
||
g.actedThisRound = map[int]bool{g.CurrentPlayerIdx: true}
|
||
g.advanceAfterAction()
|
||
return nil
|
||
}
|
||
|
||
// AllIn ставит весь оставшийся стек.
|
||
func (g *PokerGameState) AllIn() error {
|
||
if err := g.checkCanAct(); err != nil {
|
||
return err
|
||
}
|
||
p := g.Players[g.CurrentPlayerIdx]
|
||
return g.Bet(p.CurrentBet + p.Stack)
|
||
}
|
||
|
||
func (g *PokerGameState) checkCanAct() error {
|
||
if g.Phase == PokerPhaseShowdown || g.Phase == PokerPhaseTournamentOver {
|
||
return ErrPokerWrongPhase
|
||
}
|
||
p := g.Players[g.CurrentPlayerIdx]
|
||
if p.Folded || p.AllIn || p.Eliminated {
|
||
return ErrPokerNotYourTurn
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// advanceAfterAction обрабатывает переход хода после действия
|
||
// текущего игрока: завершение раздачи (остался один не сбросивший),
|
||
// переход к следующей фазе (когда раунд торгов завершён), либо
|
||
// передачу хода следующему активному игроку.
|
||
func (g *PokerGameState) advanceAfterAction() {
|
||
if g.countInHand() == 1 {
|
||
g.resolveHandByFold()
|
||
return
|
||
}
|
||
|
||
if g.actedThisRound == nil {
|
||
g.actedThisRound = map[int]bool{}
|
||
}
|
||
g.actedThisRound[g.CurrentPlayerIdx] = true
|
||
|
||
allActed := true
|
||
for i, p := range g.Players {
|
||
if !p.Folded && !p.AllIn && !p.Eliminated && !g.actedThisRound[i] {
|
||
allActed = false
|
||
break
|
||
}
|
||
}
|
||
if allActed {
|
||
g.advancePhase()
|
||
return
|
||
}
|
||
g.CurrentPlayerIdx = g.nextToAct(g.CurrentPlayerIdx)
|
||
}
|
||
|
||
// advancePhase переходит к следующей фазе раздачи (флоп/тёрн/ривер/
|
||
// шоудаун), сбрасывая ставки раунда. Если активных (способных
|
||
// действовать) игроков не осталось вовсе (все, кроме максимум
|
||
// одного, в олл-ине) — карты дораздаются сразу до шоудауна без
|
||
// дальнейших торгов.
|
||
func (g *PokerGameState) advancePhase() {
|
||
for _, p := range g.Players {
|
||
p.CurrentBet = 0
|
||
}
|
||
g.actedThisRound = map[int]bool{}
|
||
|
||
switch g.Phase {
|
||
case PokerPhasePreflop:
|
||
g.Board = append(g.Board, g.drawCard(), g.drawCard(), g.drawCard())
|
||
g.Phase = PokerPhaseFlop
|
||
case PokerPhaseFlop:
|
||
g.Board = append(g.Board, g.drawCard())
|
||
g.Phase = PokerPhaseTurn
|
||
case PokerPhaseTurn:
|
||
g.Board = append(g.Board, g.drawCard())
|
||
g.Phase = PokerPhaseRiver
|
||
case PokerPhaseRiver:
|
||
g.resolveShowdown()
|
||
return
|
||
}
|
||
|
||
if g.countActingPlayers() <= 1 {
|
||
// все, кроме максимум одного, в олл-ине — дальше действовать
|
||
// некому, доигрываем карты до шоудауна автоматически
|
||
g.advancePhase()
|
||
return
|
||
}
|
||
g.lastAggressorIdx = -1
|
||
g.CurrentPlayerIdx = g.nextToAct(g.DealerIdx)
|
||
}
|
||
|
||
// resolveHandByFold завершает раздачу, когда все, кроме одного,
|
||
// сбросили карты — оставшийся игрок забирает весь банк без показа карт.
|
||
func (g *PokerGameState) resolveHandByFold() {
|
||
var winnerIdx int
|
||
for i, p := range g.Players {
|
||
if !p.Folded && !p.Eliminated {
|
||
winnerIdx = i
|
||
break
|
||
}
|
||
}
|
||
pots := computeSidePots(g.Players)
|
||
total := 0
|
||
for _, pot := range pots {
|
||
total += pot.Amount
|
||
}
|
||
g.Players[winnerIdx].Stack += total
|
||
g.Result = &PokerHandResult{
|
||
Pots: pots,
|
||
Winners: map[int]int{winnerIdx: total},
|
||
WentToShowdown: false,
|
||
}
|
||
g.Phase = PokerPhaseShowdown
|
||
g.markEliminated()
|
||
}
|
||
|
||
// resolveShowdown вскрывает карты и распределяет все банки (включая
|
||
// сайд-поты) между лучшими руками среди дошедших до конца.
|
||
func (g *PokerGameState) resolveShowdown() {
|
||
pots := computeSidePots(g.Players)
|
||
values := map[int]PokerHandValue{}
|
||
for i, p := range g.Players {
|
||
if !p.Folded && !p.Eliminated {
|
||
all := append([]Card{p.HoleCards[0], p.HoleCards[1]}, g.Board...)
|
||
values[i] = EvaluateBest5(all)
|
||
}
|
||
}
|
||
|
||
winnings := map[int]int{}
|
||
for _, pot := range pots {
|
||
var best []int
|
||
for _, idx := range pot.EligiblePlayers {
|
||
if len(best) == 0 {
|
||
best = []int{idx}
|
||
continue
|
||
}
|
||
cmp := ComparePokerHands(values[idx], values[best[0]])
|
||
if cmp > 0 {
|
||
best = []int{idx}
|
||
} else if cmp == 0 {
|
||
best = append(best, idx)
|
||
}
|
||
}
|
||
share := pot.Amount / len(best)
|
||
remainder := pot.Amount - share*len(best)
|
||
for i, idx := range best {
|
||
amt := share
|
||
if i == 0 {
|
||
amt += remainder // остаток от неровного деления — первому по очереди из победителей
|
||
}
|
||
winnings[idx] += amt
|
||
g.Players[idx].Stack += amt
|
||
}
|
||
}
|
||
|
||
g.Result = &PokerHandResult{
|
||
Pots: pots,
|
||
Winners: winnings,
|
||
WentToShowdown: true,
|
||
HandValues: values,
|
||
}
|
||
g.Phase = PokerPhaseShowdown
|
||
g.markEliminated()
|
||
}
|
||
|
||
func (g *PokerGameState) markEliminated() {
|
||
for _, p := range g.Players {
|
||
if p.Stack == 0 && !p.Eliminated {
|
||
p.Eliminated = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// NextHand начинает следующую раздачу турнира (или переводит в
|
||
// PokerPhaseTournamentOver, если остался один игрок с фишками).
|
||
func (g *PokerGameState) NextHand(rnd *rand.Rand) {
|
||
if g.Phase != PokerPhaseShowdown {
|
||
return
|
||
}
|
||
g.startNewHand(rnd)
|
||
}
|
||
|
||
// computeSidePots раскладывает общий банк на "слои" по уровням
|
||
// взносов игроков за раздачу — стандартный алгоритм сайд-потов:
|
||
// каждый слой формируют все, кто внёс хотя бы до этого уровня
|
||
// (включая уже сбросивших — их фишки остаются в банке), а
|
||
// претендовать на слой могут только НЕ сбросившие игроки, дошедшие
|
||
// до этого уровня.
|
||
func computeSidePots(players []*PokerPlayer) []PokerSidePot {
|
||
levelSet := map[int]bool{}
|
||
for _, p := range players {
|
||
if p.TotalContributed > 0 {
|
||
levelSet[p.TotalContributed] = true
|
||
}
|
||
}
|
||
var levels []int
|
||
for lvl := range levelSet {
|
||
levels = append(levels, lvl)
|
||
}
|
||
sort.Ints(levels)
|
||
|
||
var pots []PokerSidePot
|
||
prev := 0
|
||
carry := 0
|
||
for _, level := range levels {
|
||
layerSize := level - prev
|
||
contributors := 0
|
||
var eligible []int
|
||
for i, p := range players {
|
||
if p.TotalContributed >= level {
|
||
contributors++
|
||
if !p.Folded {
|
||
eligible = append(eligible, i)
|
||
}
|
||
}
|
||
}
|
||
amount := layerSize*contributors + carry
|
||
if len(eligible) == 0 {
|
||
carry = amount
|
||
} else {
|
||
pots = append(pots, PokerSidePot{Amount: amount, EligiblePlayers: eligible})
|
||
carry = 0
|
||
}
|
||
prev = level
|
||
}
|
||
if carry > 0 && len(pots) > 0 {
|
||
pots[len(pots)-1].Amount += carry
|
||
}
|
||
return pots
|
||
}
|