423 lines
10 KiB
Go
423 lines
10 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"math/rand"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
)
|
||
|
||
// PokerModel — TUI поверх PokerGameState. Человек всегда занимает
|
||
// место 0; каждому боту при создании партии случайно достаётся один
|
||
// из трёх уровней сложности — выбирать сложность игроку не
|
||
// предлагается специально (так и было задумано).
|
||
type PokerModel struct {
|
||
game *PokerGameState
|
||
bots map[int]PokerBot
|
||
humanSeat int
|
||
rng *rand.Rand
|
||
|
||
choosingBetAmount bool
|
||
betAmount int
|
||
|
||
message string
|
||
isError bool
|
||
|
||
quitting bool
|
||
backToMenu bool
|
||
}
|
||
|
||
const (
|
||
pokerStartingStack = 1000
|
||
pokerSmallBlind = 10
|
||
pokerBigBlind = 20
|
||
)
|
||
|
||
// NewPokerModel начинает новый турнир на numPlayers мест. Человек —
|
||
// место 0; остальным местам случайно назначается один из трёх
|
||
// уровней сложности бота.
|
||
func NewPokerModel(numPlayers int) PokerModel {
|
||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||
game := NewPokerGame(numPlayers, pokerStartingStack, pokerSmallBlind, pokerBigBlind, rng)
|
||
bots := map[int]PokerBot{}
|
||
for i := 1; i < numPlayers; i++ {
|
||
bots[i] = PokerBot{Difficulty: PokerDifficulty(rng.Intn(3))}
|
||
}
|
||
return PokerModel{game: game, bots: bots, humanSeat: 0, rng: rng}
|
||
}
|
||
|
||
func (m PokerModel) Init() tea.Cmd {
|
||
return m.maybeScheduleBot()
|
||
}
|
||
|
||
func (m PokerModel) maybeScheduleBot() tea.Cmd {
|
||
g := m.game
|
||
if g.Phase == PokerPhaseTournamentOver || g.Phase == PokerPhaseShowdown {
|
||
return nil
|
||
}
|
||
if g.CurrentPlayerIdx == m.humanSeat {
|
||
return nil
|
||
}
|
||
return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} })
|
||
}
|
||
|
||
func (m *PokerModel) setInfo(key string) {
|
||
m.message = T(key)
|
||
m.isError = false
|
||
}
|
||
|
||
func (m *PokerModel) setError(err error) {
|
||
m.message = err.Error()
|
||
m.isError = true
|
||
}
|
||
|
||
func (m PokerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case botMoveMsg:
|
||
return m.handleBotMove()
|
||
case tea.KeyMsg:
|
||
return m.handleKey(msg)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m PokerModel) handleBotMove() (tea.Model, tea.Cmd) {
|
||
g := m.game
|
||
if g.Phase == PokerPhaseTournamentOver || g.Phase == PokerPhaseShowdown {
|
||
return m, nil
|
||
}
|
||
if g.CurrentPlayerIdx == m.humanSeat {
|
||
return m, nil
|
||
}
|
||
seat := g.CurrentPlayerIdx
|
||
bot := m.bots[seat]
|
||
if err := bot.PlayFullTurn(g, seat, m.rng); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
m.message = ""
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
|
||
// pokerHumanActions строит пронумерованный список действий,
|
||
// доступных человеку прямо сейчас.
|
||
func (m PokerModel) pokerHumanActions() []string {
|
||
g := m.game
|
||
p := g.Players[m.humanSeat]
|
||
toCall := g.highestBet() - p.CurrentBet
|
||
var actions []string
|
||
actions = append(actions, "fold")
|
||
if toCall == 0 {
|
||
actions = append(actions, "check")
|
||
} else {
|
||
actions = append(actions, "call")
|
||
}
|
||
if p.Stack > 0 {
|
||
actions = append(actions, "bet")
|
||
}
|
||
actions = append(actions, "allin")
|
||
return actions
|
||
}
|
||
|
||
func (m PokerModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
key := msg.String()
|
||
if key == "ctrl+c" {
|
||
m.quitting = true
|
||
return m, tea.Quit
|
||
}
|
||
if key == "q" {
|
||
m.backToMenu = true
|
||
return m, nil
|
||
}
|
||
|
||
g := m.game
|
||
if g.Phase == PokerPhaseTournamentOver {
|
||
return m, nil
|
||
}
|
||
|
||
if g.Phase == PokerPhaseShowdown {
|
||
if key == "n" || key == "enter" {
|
||
g.NextHand(m.rng)
|
||
m.message = ""
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
if g.CurrentPlayerIdx != m.humanSeat {
|
||
return m, nil
|
||
}
|
||
|
||
if m.choosingBetAmount {
|
||
return m.handleBetAmountKey(key)
|
||
}
|
||
|
||
actions := m.pokerHumanActions()
|
||
for i, a := range actions {
|
||
if key != fmt.Sprintf("%d", i+1) {
|
||
continue
|
||
}
|
||
return m.applyActionLabel(a)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m PokerModel) applyActionLabel(label string) (tea.Model, tea.Cmd) {
|
||
g := m.game
|
||
p := g.Players[m.humanSeat]
|
||
switch label {
|
||
case "fold":
|
||
if err := g.Fold(); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
case "check":
|
||
if err := g.Check(); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
case "call":
|
||
if err := g.Call(); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
case "bet":
|
||
m.choosingBetAmount = true
|
||
minTotal := g.highestBet() + g.BigBlind
|
||
if g.highestBet() == 0 {
|
||
minTotal = g.BigBlind
|
||
}
|
||
maxTotal := p.CurrentBet + p.Stack
|
||
if minTotal > maxTotal {
|
||
minTotal = maxTotal
|
||
}
|
||
m.betAmount = minTotal
|
||
return m, nil
|
||
case "allin":
|
||
if err := g.AllIn(); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
}
|
||
m.message = ""
|
||
if g.Phase == PokerPhaseShowdown || g.Phase == PokerPhaseTournamentOver {
|
||
return m, nil
|
||
}
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
|
||
func (m PokerModel) handleBetAmountKey(key string) (tea.Model, tea.Cmd) {
|
||
g := m.game
|
||
p := g.Players[m.humanSeat]
|
||
maxTotal := p.CurrentBet + p.Stack
|
||
minTotal := g.highestBet() + g.BigBlind
|
||
if g.highestBet() == 0 {
|
||
minTotal = g.BigBlind
|
||
}
|
||
if minTotal > maxTotal {
|
||
minTotal = maxTotal
|
||
}
|
||
|
||
switch key {
|
||
case "esc":
|
||
m.choosingBetAmount = false
|
||
return m, nil
|
||
case "up", "k":
|
||
m.betAmount += g.BigBlind
|
||
case "down", "j":
|
||
m.betAmount -= g.BigBlind
|
||
case "right", "l":
|
||
m.betAmount += g.BigBlind * 5
|
||
case "left", "h":
|
||
m.betAmount -= g.BigBlind * 5
|
||
case "enter":
|
||
if err := g.Bet(m.betAmount); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
m.choosingBetAmount = false
|
||
m.message = ""
|
||
return m, m.maybeScheduleBot()
|
||
default:
|
||
return m, nil
|
||
}
|
||
if m.betAmount < minTotal {
|
||
m.betAmount = minTotal
|
||
}
|
||
if m.betAmount > maxTotal {
|
||
m.betAmount = maxTotal
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func pokerCardStr(c Card) string {
|
||
return c.String()
|
||
}
|
||
|
||
// renderPokerBoardRow рисует общие карты стола: уже открытые —
|
||
// обычной рамкой, ещё не сданные (до 5 позиций) — рубашкой вниз, с
|
||
// той же шириной, чтобы ряд не "прыгал" по мере вскрытия карт.
|
||
func renderPokerBoardRow(board []Card) string {
|
||
boxes := make([][]string, 5)
|
||
for i := 0; i < 5; i++ {
|
||
if i < len(board) {
|
||
boxes[i] = renderCardBox(board[i], CardNormal)
|
||
} else {
|
||
boxes[i] = renderFaceDownBox()
|
||
}
|
||
}
|
||
lines := make([]string, 4)
|
||
for row := 0; row < 4; row++ {
|
||
parts := make([]string, 5)
|
||
for i := 0; i < 5; i++ {
|
||
parts[i] = boxes[i][row]
|
||
}
|
||
lines[row] = strings.Join(parts, " ")
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func (m PokerModel) View() string {
|
||
if m.quitting {
|
||
return T("common.goodbye")
|
||
}
|
||
var b strings.Builder
|
||
fmt.Fprintln(&b, titleStyle.Render(T("poker.title")))
|
||
fmt.Fprintln(&b)
|
||
|
||
g := m.game
|
||
if g.Phase == PokerPhaseTournamentOver {
|
||
fmt.Fprintln(&b, m.renderTournamentOver())
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, dimStyle.Render(T("poker.hint.quit")))
|
||
return b.String()
|
||
}
|
||
|
||
var board []Card
|
||
board = append(board, g.Board...)
|
||
fmt.Fprintln(&b, T("poker.board_label"))
|
||
fmt.Fprintln(&b, renderPokerBoardRow(board))
|
||
fmt.Fprintln(&b, Tf("poker.pot_line", g.potSize()))
|
||
fmt.Fprintln(&b)
|
||
|
||
for i, p := range g.Players {
|
||
fmt.Fprintln(&b, m.renderPlayerLine(i, p))
|
||
}
|
||
fmt.Fprintln(&b)
|
||
|
||
human := g.Players[m.humanSeat]
|
||
if !human.Eliminated {
|
||
fmt.Fprintln(&b, T("poker.your_hand_label"))
|
||
fmt.Fprintln(&b, renderCardBoxRow(human.HoleCards[:], nil))
|
||
}
|
||
fmt.Fprintln(&b)
|
||
|
||
switch {
|
||
case g.Phase == PokerPhaseShowdown:
|
||
fmt.Fprintln(&b, m.renderShowdown())
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, T("poker.hint.next_hand"))
|
||
case m.choosingBetAmount:
|
||
fmt.Fprintln(&b, Tf("poker.bet_amount_line", m.betAmount))
|
||
fmt.Fprintln(&b, T("poker.hint.bet_amount"))
|
||
case g.CurrentPlayerIdx == m.humanSeat:
|
||
fmt.Fprintln(&b, T("poker.hint.your_turn"))
|
||
for i, a := range m.pokerHumanActions() {
|
||
fmt.Fprintf(&b, "[%d] %s\n", i+1, T("poker.action."+a))
|
||
}
|
||
default:
|
||
fmt.Fprintln(&b, T("common.thinking_generic"))
|
||
}
|
||
|
||
if m.message != "" {
|
||
if m.isError {
|
||
fmt.Fprintln(&b, errorStyle.Render("! "+m.message))
|
||
} else {
|
||
fmt.Fprintln(&b, infoStyle.Render(m.message))
|
||
}
|
||
}
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, dimStyle.Render(T("poker.hint.quit")))
|
||
return b.String()
|
||
}
|
||
|
||
func (m PokerModel) renderPlayerLine(idx int, p *PokerPlayer) string {
|
||
name := Tf("poker.seat_label", idx+1)
|
||
if idx == m.humanSeat {
|
||
name = T("poker.you_label")
|
||
}
|
||
status := ""
|
||
switch {
|
||
case p.Eliminated:
|
||
status = T("poker.status.eliminated")
|
||
case p.Folded:
|
||
status = T("poker.status.folded")
|
||
case p.AllIn:
|
||
status = T("poker.status.allin")
|
||
}
|
||
marker := ""
|
||
if idx == m.game.DealerIdx {
|
||
marker = " (D)"
|
||
}
|
||
line := Tf("poker.player_line", name+marker, p.Stack, p.CurrentBet, status)
|
||
if idx == m.game.CurrentPlayerIdx && m.game.Phase != PokerPhaseShowdown && m.game.Phase != PokerPhaseTournamentOver {
|
||
return turnStyle.Render(line)
|
||
}
|
||
return line
|
||
}
|
||
|
||
func (m PokerModel) renderShowdown() string {
|
||
var b strings.Builder
|
||
res := m.game.Result
|
||
if res.WentToShowdown {
|
||
fmt.Fprintln(&b, T("poker.showdown.title"))
|
||
for idx, v := range res.HandValues {
|
||
name := Tf("poker.seat_label", idx+1)
|
||
if idx == m.humanSeat {
|
||
name = T("poker.you_label")
|
||
}
|
||
fmt.Fprintf(&b, " %s: %s %s — %s\n", name,
|
||
pokerCardStr(m.game.Players[idx].HoleCards[0]), pokerCardStr(m.game.Players[idx].HoleCards[1]),
|
||
T(pokerCategoryNameKey[v.Category]))
|
||
}
|
||
} else {
|
||
fmt.Fprintln(&b, T("poker.showdown.fold_title"))
|
||
}
|
||
for idx, amt := range res.Winners {
|
||
if idx == m.humanSeat {
|
||
fmt.Fprintln(&b, Tf("poker.showdown.winner_line_you", amt))
|
||
} else {
|
||
fmt.Fprintln(&b, Tf("poker.showdown.winner_line", Tf("poker.seat_label", idx+1), amt))
|
||
}
|
||
}
|
||
return strings.TrimRight(b.String(), "\n")
|
||
}
|
||
|
||
func (m PokerModel) renderTournamentOver() string {
|
||
for i, p := range m.game.Players {
|
||
if !p.Eliminated {
|
||
name := Tf("poker.seat_label", i+1)
|
||
if i == m.humanSeat {
|
||
return winStyle.Render(T("poker.outcome.win"))
|
||
}
|
||
return errorStyle.Render(Tf("poker.outcome.lose", name))
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// pokerCategoryNameKey — ключи переводов названий покерных
|
||
// комбинаций для показа на шоудауне.
|
||
var pokerCategoryNameKey = map[PokerHandCategory]string{
|
||
PokerHighCard: "poker.hand.highcard",
|
||
PokerPair: "poker.hand.pair",
|
||
PokerTwoPair: "poker.hand.twopair",
|
||
PokerThreeOfAKind: "poker.hand.trips",
|
||
PokerStraight: "poker.hand.straight",
|
||
PokerFlush: "poker.hand.flush",
|
||
PokerFullHouse: "poker.hand.fullhouse",
|
||
PokerFourOfAKind: "poker.hand.quads",
|
||
PokerStraightFlush: "poker.hand.straightflush",
|
||
}
|