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