go_games_collection/poker_tui.go
2026-07-10 09:12:06 +03:00

423 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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",
}