package main import ( "fmt" "strings" "time" tea "github.com/charmbracelet/bubbletea" ) // ThousandModel — TUI поверх ThousandGameState. type ThousandModel struct { game *ThousandGameState humanIdx int bot ThousandBot cursor int bidAmount int message string isError bool quitting bool backToMenu bool } // NewThousandModel создаёт новую партию 1000 на троих (человек + // 2 бота со случайными именами). func NewThousandModel() ThousandModel { botNames := newThousandBotNames(2) names := append([]string{T("common.you")}, botNames...) return ThousandModel{ game: NewThousandGame(names, 0), humanIdx: 0, bot: ThousandBot{}, } } func (m ThousandModel) Init() tea.Cmd { return m.maybeScheduleBot() } func (m ThousandModel) actorForPhase() int { switch m.game.Phase { case ThousandPhaseBidding: return m.game.CurrentBidderIdx case ThousandPhaseDiscard: return m.game.DeclarerIdx case ThousandPhaseTrick: return m.game.currentTrickActor() } return -1 } func (m ThousandModel) maybeScheduleBot() tea.Cmd { if m.game.Phase == ThousandPhaseHandOver { return nil } if m.actorForPhase() == m.humanIdx { return nil } return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} }) } func (m *ThousandModel) resetSelection() { hand := m.currentHand() if len(hand) == 0 { m.cursor = 0 } else if m.cursor >= len(hand) { m.cursor = len(hand) - 1 } } func (m ThousandModel) currentHand() []Card { idx := m.actorForPhase() if idx < 0 { return nil } return m.game.Players[idx].Hand } func (m *ThousandModel) setInfo(format string, args ...interface{}) { m.message = fmt.Sprintf(format, args...) m.isError = false } func (m *ThousandModel) setError(err error) { m.message = T(err.Error()) m.isError = true } func (m ThousandModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: return m.handleKey(msg) case botMoveMsg: return m.handleBotMove() } return m, nil } func (m ThousandModel) handleBotMove() (tea.Model, tea.Cmd) { if m.game.Phase == ThousandPhaseHandOver { return m, nil } actor := m.actorForPhase() if actor == m.humanIdx { return m, nil } if err := m.bot.PlayFullTurn(m.game); err != nil { m.setError(fmt.Errorf(T("бот %s: %w"), m.game.Players[actor].Name, err)) return m, nil } m.setInfo(T("tonk.msg.bot_moved"), m.game.Players[actor].Name) if m.game.Phase != ThousandPhaseHandOver && m.actorForPhase() == m.humanIdx { m.resetSelection() m.bidAmount = m.game.HighBid + 5 if m.bidAmount < 100 { m.bidAmount = 100 } } return m, m.maybeScheduleBot() } func (m ThousandModel) 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 } if m.game.Phase == ThousandPhaseHandOver { if key == "n" && !m.game.Result.MatchOver { m.game.NextHand() m.message = "" m.isError = false m.resetSelection() return m, m.maybeScheduleBot() } return m, nil } if m.actorForPhase() != m.humanIdx { return m, nil } switch m.game.Phase { case ThousandPhaseBidding: return m.handleBiddingKey(key) case ThousandPhaseDiscard: return m.handleDiscardKey(key) case ThousandPhaseTrick: return m.handleTrickKey(key) } return m, nil } func (m ThousandModel) handleBiddingKey(key string) (tea.Model, tea.Cmd) { if m.bidAmount < 100 { m.bidAmount = 100 } switch key { case "up", "right", "l", "k": m.bidAmount += 5 case "down", "left", "h", "j": if m.bidAmount > 100 { m.bidAmount -= 5 } case "enter": if err := m.game.Bid(m.bidAmount); err != nil { m.setError(err) return m, nil } m.setInfo(T("thousand.msg.bid"), m.bidAmount) if m.game.Phase != ThousandPhaseHandOver { m.bidAmount = m.game.HighBid + 5 } return m, m.maybeScheduleBot() case "p": if err := m.game.Pass(); err != nil { m.setError(err) return m, nil } m.setInfo(T("thousand.msg.passed")) return m, m.maybeScheduleBot() case "v": if err := m.game.DeclareBlind(); err != nil { m.setError(err) return m, nil } m.setInfo(T("thousand.msg.blind_declared")) return m, m.maybeScheduleBot() } return m, nil } func (m ThousandModel) handleDiscardKey(key string) (tea.Model, tea.Cmd) { hand := m.currentHand() switch key { case "left", "h": if m.cursor > 0 { m.cursor-- } case "right", "l": if m.cursor < len(hand)-1 { m.cursor++ } case "enter", " ": if len(hand) == 0 { return m, nil } card := hand[m.cursor] if err := m.game.DiscardCard(card); err != nil { m.setError(err) return m, nil } m.setInfo(T("thousand.msg.gave_card"), card) m.resetSelection() if m.game.Phase != ThousandPhaseDiscard { return m, m.maybeScheduleBot() } } return m, nil } func (m ThousandModel) handleTrickKey(key string) (tea.Model, tea.Cmd) { hand := m.currentHand() switch key { case "left", "h": if m.cursor > 0 { m.cursor-- } case "right", "l": if m.cursor < len(hand)-1 { m.cursor++ } case "enter", " ": if len(hand) == 0 { return m, nil } card := hand[m.cursor] if err := m.game.PlayCard(card); err != nil { m.setError(err) return m, nil } m.setInfo(T("thousand.msg.played"), card) if m.game.Phase != ThousandPhaseHandOver { m.resetSelection() } return m, m.maybeScheduleBot() } return m, nil } // --- Отрисовка ----------------------------------------------------- func (m ThousandModel) renderHand() string { hand := m.currentHand() if len(hand) == 0 { return dimStyle.Render(T("common.no_cards")) } parts := make([]string, len(hand)) for i, c := range hand { text := renderCard(c) if i == m.cursor { text = cursorStyle.Render(c.String()) } parts[i] = text } return strings.Join(parts, " ") } func (m ThousandModel) renderPlayers() string { var b strings.Builder for i, p := range m.game.Players { marker := " " if m.game.Phase != ThousandPhaseHandOver && i == m.actorForPhase() { marker = "> " } name := p.Name role := "" if m.game.Phase != ThousandPhaseBidding && i == m.game.DeclarerIdx { role = T("thousand.declarer_tag") } if p.OnBarrel { role += " " + Tf("thousand.barrel_tag", p.BarrelAttempts, ThousandBarrelMaxAttempts) } line := fmt.Sprintf(T("thousand.player_line"), marker, name, len(p.Hand), p.HandPoints, p.Score, role) if m.game.Phase != ThousandPhaseHandOver && i == m.actorForPhase() { line = turnStyle.Render(line) } fmt.Fprintln(&b, line) } return strings.TrimRight(b.String(), "\n") } func (m ThousandModel) renderTrick() string { if len(m.game.CurrentTrick) == 0 { return dimStyle.Render(T("thousand.trick_not_started")) } parts := make([]string, len(m.game.CurrentTrick)) for i, play := range m.game.CurrentTrick { parts[i] = fmt.Sprintf("%s:%s", m.game.Players[play.PlayerIdx].Name, renderCard(play.Card)) } return strings.Join(parts, " ") } func (m ThousandModel) View() string { if m.quitting { return T("common.goodbye") } var b strings.Builder fmt.Fprintln(&b, titleStyle.Render(T("thousand.title"))) fmt.Fprintln(&b) if m.game.HandNumber <= ThousandGoldenHands { fmt.Fprintln(&b, dimStyle.Render(T("thousand.golden_round_tag"))) } if m.game.PlayedBlind { fmt.Fprintln(&b, dimStyle.Render(T("thousand.blind_round_tag"))) } fmt.Fprintln(&b, m.renderPlayers()) fmt.Fprintln(&b) if m.game.Phase == ThousandPhaseHandOver { fmt.Fprintln(&b, m.renderResult()) fmt.Fprintln(&b) if m.game.Result.MatchOver { fmt.Fprintln(&b, dimStyle.Render(T("thousand.hint.gameover_match"))) } else { fmt.Fprintln(&b, dimStyle.Render(T("thousand.hint.gameover"))) } return b.String() } switch m.game.Phase { case ThousandPhaseBidding: fmt.Fprintf(&b, T("thousand.bidding_status"), m.game.HighBid, m.bidderName()) case ThousandPhaseDiscard, ThousandPhaseTrick: trumpStr := T("thousand.trump_unset") if m.game.TrumpSet { trumpStr = suitName(m.game.TrumpSuit) } fmt.Fprintf(&b, T("thousand.play_status"), m.game.FinalBid, m.game.Players[m.game.DeclarerIdx].Name, trumpStr) if m.game.Phase == ThousandPhaseTrick { fmt.Fprintln(&b, T("thousand.trick_label"), m.renderTrick()) } fmt.Fprintln(&b) } if m.actorForPhase() == m.humanIdx { fmt.Fprintln(&b, T("thousand.your_hand")) fmt.Fprintln(&b, m.renderHand()) fmt.Fprintln(&b) switch m.game.Phase { case ThousandPhaseBidding: fmt.Fprintf(&b, T("thousand.hint.bid"), m.bidAmount) case ThousandPhaseDiscard: fmt.Fprintln(&b, T("thousand.hint.discard")) case ThousandPhaseTrick: fmt.Fprintln(&b, T("thousand.hint.trick")) } fmt.Fprintln(&b, dimStyle.Render(T("thousand.hint.menu"))) } else { fmt.Fprintf(&b, T("common.thinking"), m.game.Players[m.actorForPhase()].Name) } fmt.Fprintln(&b) if m.message != "" { if m.isError { fmt.Fprintln(&b, errorStyle.Render("! "+m.message)) } else { fmt.Fprintln(&b, infoStyle.Render(m.message)) } } return b.String() } func (m ThousandModel) bidderName() string { if m.game.HighBidderIdx < 0 { return T("thousand.no_bidder") } return m.game.Players[m.game.HighBidderIdx].Name } func suitName(s Suit) string { switch s { case Clubs: return T("thousand.suit.clubs") case Diamonds: return T("thousand.suit.diamonds") case Hearts: return T("thousand.suit.hearts") case Spades: return T("thousand.suit.spades") } return "?" } func (m ThousandModel) renderResult() string { res := m.game.Result var b strings.Builder declarerName := m.game.Players[res.DeclarerIdx].Name if res.Made { fmt.Fprintln(&b, winStyle.Render(Tf("thousand.result.made", declarerName, res.FinalBid))) } else { fmt.Fprintln(&b, errorStyle.Render(Tf("thousand.result.failed", declarerName, res.FinalBid))) } fmt.Fprintln(&b) for i, p := range m.game.Players { fmt.Fprintln(&b, fmt.Sprintf(T("thousand.result.delta_line"), p.Name, res.ScoreDeltas[i], p.Score)) } if res.MatchOver { fmt.Fprintln(&b) fmt.Fprintln(&b, winStyle.Render(Tf("thousand.result.match_over", m.game.Players[res.MatchWinner].Name))) } return strings.TrimRight(b.String(), "\n") }